feat: 完善模型用量趋势与全局手账卡片并补齐阶段验收

This commit is contained in:
2026-09-05 20:40:36 +08:00
parent 02dd585a4e
commit 8d626ee16b
38 changed files with 733 additions and 86 deletions
@@ -24,6 +24,18 @@ const showProviderForm = ref(false)
const editingProvider = ref<ProviderConfig>()
const providerAction = ref('')
const testResults = ref<Record<string, string>>({})
const providerBusy = ref<Record<string, boolean>>({})
async function toggleProvider(provider: ProviderConfig) {
if (providerBusy.value[provider.provider_id]) return
providerBusy.value[provider.provider_id] = true
providerAction.value = ''
try {
await providerStore.updateProvider(provider.provider_id, { enabled: !provider.enabled })
delete testResults.value[provider.provider_id]
} catch (error) {
providerAction.value = error instanceof Error ? error.message : t('启停失败,请重试。', 'Could not change provider status. Please retry.')
} finally { providerBusy.value[provider.provider_id] = false }
}
onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels()
@@ -40,7 +52,7 @@ function openProvider(provider?: ProviderConfig) {
editingProvider.value = provider
providerAction.value = ''
showProviderForm.value = true
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
if (provider?.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
async function providerSaved(provider: ProviderConfig) {
@@ -68,18 +80,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section">
<section class="panel provider-settings-card">
<div class="section-head">
<div><h2>{{ t('模型提供商', 'Model Providers') }}</h2><p class="subtle">{{ t('选择国内外提供商预设,或配置自定义 API 与独立密钥。', 'Choose a provider preset or configure a custom API with separate credentials.') }}</p></div>
<button class="button-primary" @click="openProvider()">{{ t('新增 Provider', 'Add Provider') }}</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<LocalModelSettings />
<UsageCard />
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? t('正在加载提供商', 'Loading providers') : t('尚无可用提供商请添加真实 API 或本地 Ollama 配置', 'No providers are available. Add a real API or local Ollama configuration.') }}</p>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge">{{ provider.provider_type }}</span><span class="badge" :class="{ success: provider.enabled }">{{ provider.enabled ? t('已启用', 'Enabled') : t('已停用', 'Disabled') }}</span></div>
<p class="subtle">{{ provider.base_url || t('本地内置', 'Built in locally') }} · {{ t('默认模型', 'Default model') }} {{ provider.default_model || t('未设置', 'Not set') }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
@@ -94,13 +105,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div>
<div class="inline-actions provider-actions">
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? t('获取中…', 'Loading…') : t('刷新模型', 'Refresh models') }}</button>
<button class="button-secondary" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
<button class="button-secondary" :disabled="providerBusy[provider.provider_id]" :aria-pressed="provider.enabled" @click="toggleProvider(provider)">{{ providerBusy[provider.provider_id] ? t('保存中…', 'Saving…') : provider.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button>
<button class="button-secondary" :disabled="!provider.enabled || providerBusy[provider.provider_id] || providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? t('获取中…', 'Loading…') : t('刷新模型', 'Refresh models') }}</button>
<button class="button-secondary" :disabled="!provider.enabled || providerBusy[provider.provider_id]" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
<button class="button-secondary" @click="openProvider(provider)">{{ t('编辑', 'Edit') }}</button>
<button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</button>
</div>
</article>
</div>
</section>
<LocalModelSettings class="panel local-settings-card" />
<UsageCard />
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>{{ t('索引与模型', 'Index and Models') }}</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>{{ t('待处理任务', 'Pending jobs') }} {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</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')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建。', 'The current backend supports a full rebuild.') }}</span></div><ModelRoutingSettings /></div>
@@ -116,6 +131,9 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<style scoped>
.settings-page { max-width: 1120px; margin: 0 auto; }
.settings-section { display: grid; gap: var(--space-md); }
.provider-settings-card, .local-settings-card { padding: 20px; min-width: 0; }
.provider-settings-card { display: grid; gap: var(--space-md); }
.provider-settings-card .section-head { margin-bottom: 0; gap: var(--space-md); flex-wrap: wrap; }
.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: 58px; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); transition: background-color var(--motion-fast); }
.setting-row:hover { background: var(--color-background-secondary); }