feat(frontend): 完成主题与设置页面

This commit is contained in:
2026-08-29 22:49:44 +08:00
parent 3239bab696
commit 5192a8b4e8
4 changed files with 143 additions and 16 deletions
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import type { ProviderConfig, ProviderType } from '@/contracts'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
const sections: Array<{ id: Section; label: string }> = [
{ id: 'general', label: '通用' }, { id: 'editor', label: '编辑器' }, { id: 'providers', label: '模型提供商' },
{ id: 'index', label: '索引与模型' }, { id: 'permissions', label: '权限' }, { id: 'ai-core', label: 'AI Core 诊断' },
]
const activeSection = ref<Section>('general')
const settingsStore = useSettingsStore()
const providerStore = useProviderStore()
const showProviderForm = ref(false)
const editingProviderId = ref<string | null>(null)
const providerAction = ref('')
const testResults = ref<Record<string, string>>({})
const providerForm = reactive({ provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
onMounted(() => { void providerStore.loadProviders(); void settingsStore.loadDiagnostics() })
function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null
Object.assign(providerForm, { provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? '', enabled: provider?.enabled ?? true })
showProviderForm.value = true
}
async function saveProvider() {
providerAction.value = ''
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try { if (editingProviderId.value) await providerStore.updateProvider(editingProviderId.value, data); else await providerStore.addProvider(data); showProviderForm.value = false } catch (error) { providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败' }
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` }
</script>
<template>
<section class="feature-page settings-page">
<header class="feature-header"><div><h1>设置</h1><p>管理应用偏好模型索引权限和本地 AI Core</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>通用</h2><label class="setting-row"><span><strong>恢复上次 Vault</strong><small>启动后自动打开最近使用的知识库</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>自动保存间隔</strong><small>编辑停止后等待多久写入文件</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 秒</option><option :value="1500">1.5 秒</option><option :value="3000">3 秒</option></select></div><div class="setting-row"><span><strong>界面语言</strong><small>当前阶段支持中文和英文入口</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>版本</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">所见即所得</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="settingsStore.editorFontSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="settingsStore.editorLineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>正文最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section"><div class="section-head"><h2>模型提供商</h2><button class="button-primary" @click="openProvider()">新增 Provider</button></div><div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div><div class="provider-list"><article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card"><div><div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div><p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p><div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div><p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p></div><div class="inline-actions"><button class="button-secondary" @click="testProvider(provider)">测试</button><button class="button-secondary" @click="openProvider(provider)">编辑</button><button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button></div></article></div></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
<div v-if="showProviderForm" class="modal-backdrop" @click.self="showProviderForm = false"><div class="modal"><h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2><form @submit.prevent="saveProvider"><div class="field"><label>类型</label><select v-model="providerForm.provider_type" class="select"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></div><div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div><div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" /></div><div class="field"><label>默认模型</label><input v-model="providerForm.default_model" class="input" /></div><div class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="密钥明文由 Stronghold 保存" /><small class="subtle">此处不输入或回显 API Key。</small></div><label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showProviderForm = false">取消</button></div></form></div></div>
</section>
</template>
<style scoped>
.settings-page { max-width: 1120px; margin: 0 auto; }
.settings-section { display: grid; gap: var(--space-md); }
.settings-section h2 { margin-bottom: var(--space-sm); }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
@media (max-width: 700px) { .provider-card, .setting-row { align-items: flex-start; flex-direction: column; }.short { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>主题</h1><p>预览并切换 Design Token编辑器偏好会即时生效</p></div><button class="button-secondary" @click="themeStore.resetToDefault">恢复默认</button></header>
<div class="feature-grid themes">
<button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)">
<div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div>
<div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">使用中</span></div>
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
</button>
</div>
<div class="panel preference-panel"><h2 class="panel-title">编辑器外观</h2><div class="form-grid"><div class="field"><label>字号{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div><div class="field"><label>行高{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div><div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div></div><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存更在于被重新发现和使用</p><code>const notes = await search('本地优先')</code></div></div>
</section>
</template>
<style scoped>
.themes { margin-bottom: var(--space-xl); }
.theme-card { display: grid; gap: var(--space-md); text-align: left; }
.theme-preview { display: grid; grid-template-columns: 30px 1fr; grid-template-rows: repeat(3, 18px); gap: 6px; height: 120px; padding: var(--space-md); border-radius: var(--radius-md); background: #fff; border: 1px solid #ddd; }
.theme-preview span { grid-column: 1; border-radius: 4px; background: #dfe3eb; }
.theme-preview div { grid-column: 2; grid-row: 1 / 4; border-radius: 6px; background: #f4f5f7; }
.preview-dark { background: #0d1117; border-color: #30363d; }.preview-dark span { background: #30363d; }.preview-dark div { background: #161b22; }
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }.preview-sepia span { background: #d8c69c; }.preview-sepia div { background: #f4e8ca; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
.preference-panel { display: grid; gap: var(--space-xl); }
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.editor-preview p { margin: var(--space-sm) 0; }.editor-preview code { color: var(--color-accent-primary); }
</style>
+13 -13
View File
@@ -1,13 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo } from '@/contracts' import type { ProviderConfig, ModelInfo } from '@/contracts'
import { listModels, listProviders, mockProviders, mockModels } from '@/services/providerService' import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders) const providers = ref<ProviderConfig[]>(mockProviders)
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels) const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const defaultProviderId = ref('mock') const defaultProviderId = ref('mock')
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null)
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled)) const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
const defaultProvider = computed(() => const defaultProvider = computed(() =>
@@ -18,6 +19,9 @@ export const useProviderStore = defineStore('provider', () => {
isLoading.value = true isLoading.value = true
try { try {
providers.value = await listProviders() providers.value = await listProviders()
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -28,32 +32,27 @@ export const useProviderStore = defineStore('provider', () => {
} }
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) { async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
const newProvider: ProviderConfig = { const newProvider = await createProvider(data)
...data,
provider_id: `prov-${Date.now()}`,
}
providers.value.push(newProvider) providers.value.push(newProvider)
return newProvider return newProvider
} }
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) { async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
const p = providers.value.find((p) => p.provider_id === providerId) const updated = await updateProviderRequest(providerId, data)
if (p) Object.assign(p, data) const index = providers.value.findIndex((provider) => provider.provider_id === providerId)
if (index >= 0) providers.value[index] = updated
} }
async function deleteProvider(providerId: string) { async function deleteProvider(providerId: string) {
await deleteProviderRequest(providerId)
const idx = providers.value.findIndex((p) => p.provider_id === providerId) const idx = providers.value.findIndex((p) => p.provider_id === providerId)
if (idx > -1) providers.value.splice(idx, 1) if (idx > -1) providers.value.splice(idx, 1)
delete modelsByProvider.value[providerId] delete modelsByProvider.value[providerId]
} }
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> { async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
await new Promise((r) => setTimeout(r, 1000)) const result = await testProviderRequest(providerId)
const p = providers.value.find((p) => p.provider_id === providerId) return { success: result.success, latency_ms: result.latency_ms, error: result.error_message }
if (p?.enabled && p.has_credential) {
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
}
return { success: false, error: '认证失败,请检查 API Key' }
} }
function setDefaultProvider(providerId: string) { function setDefaultProvider(providerId: string) {
@@ -67,6 +66,7 @@ export const useProviderStore = defineStore('provider', () => {
enabledProviders, enabledProviders,
defaultProvider, defaultProvider,
isLoading, isLoading,
error,
loadProviders, loadProviders,
loadModels, loadModels,
addProvider, addProvider,
+27 -3
View File
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts' import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService' import { mockIndexStatus } from '@/services/indexService'
import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
// General // General
@@ -37,6 +39,22 @@ export const useSettingsStore = defineStore('settings', () => {
'network.request': 'confirm', 'network.request': 'confirm',
'secrets.use': 'confirm', 'secrets.use': 'confirm',
}) })
const diagnosticsError = ref<string | null>(null)
async function loadDiagnostics() {
try {
const [health, status, index] = await Promise.all([
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(),
])
aiCoreStatus.value = health.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.version
indexStatus.value = index
diagnosticsError.value = null
} catch (reason) {
aiCoreStatus.value = 'error'
diagnosticsError.value = reason instanceof Error ? reason.message : '诊断信息加载失败'
}
}
function setAutoSaveInterval(ms: number) { function setAutoSaveInterval(ms: number) {
autoSaveInterval.value = ms autoSaveInterval.value = ms
@@ -63,9 +81,13 @@ export const useSettingsStore = defineStore('settings', () => {
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') { async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing' indexStatus.value.status = 'indexing'
setTimeout(() => { try {
indexStatus.value.status = 'idle' await indexService.rebuildIndex(scope)
}, 3000) indexStatus.value = await indexService.getIndexStatus()
} catch (reason) {
indexStatus.value.status = 'error'
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败'
}
} }
return { return {
@@ -83,6 +105,8 @@ export const useSettingsStore = defineStore('settings', () => {
aiCoreAddress, aiCoreAddress,
indexStatus, indexStatus,
permissionPolicy, permissionPolicy,
diagnosticsError,
loadDiagnostics,
setAutoSaveInterval, setAutoSaveInterval,
setDefaultEditorMode, setDefaultEditorMode,
setPermission, setPermission,