feat: 完善模型用量趋势与全局手账卡片并补齐阶段验收
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
it('shows provider status and enables testing only after a successful enable', async () => {
|
||||
const store = useProviderStore()
|
||||
store.providers = [{ provider_id: 'p1', name: 'Example', provider_type: 'openai_compatible', enabled: false, default_model: '', capabilities: {}, has_credential: false }]
|
||||
vi.spyOn(store, 'loadProviders').mockResolvedValue()
|
||||
vi.spyOn(store, 'loadPresets').mockResolvedValue()
|
||||
vi.spyOn(store, 'refreshEnabledModels').mockResolvedValue()
|
||||
vi.spyOn(useSettingsStore(), 'loadDiagnostics').mockResolvedValue()
|
||||
const update = vi.spyOn(store, 'updateProvider').mockImplementation(async (_id, data) => {
|
||||
store.providers[0] = { ...store.providers[0]!, ...data }
|
||||
return store.providers[0]!
|
||||
})
|
||||
const wrapper = mount(SettingsView, { global: { stubs: { UsageCard: true, LocalModelSettings: true, ModelRoutingSettings: true, ProviderLogo: true } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '模型提供商')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('已停用')
|
||||
const test = () => wrapper.findAll('button').find(button => button.text() === '测试')!
|
||||
expect(test().attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '启用')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(update).toHaveBeenCalledWith('p1', { enabled: true })
|
||||
expect(wrapper.text()).toContain('已启用')
|
||||
expect(test().attributes('disabled')).toBeUndefined()
|
||||
update.mockRejectedValueOnce(new Error('保存失败'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '停用')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已启用')
|
||||
expect(wrapper.text()).toContain('保存失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -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); }
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, type Component } from 'vue'
|
||||
import UsageChart from './UsageChart.vue'
|
||||
import type { UsageBucket } from './UsageChart.vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { DataAnalysis, Download, Upload, Coin, CircleCheck, CircleClose, FolderAdd, Cpu, PieChart, Microphone, Refresh } from '@element-plus/icons-vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
|
||||
interface Usage {series?:UsageBucket[];audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
|
||||
const data = ref<Usage | null>(null)
|
||||
const period = ref('7')
|
||||
const provider = ref('')
|
||||
@@ -12,23 +16,31 @@ const start = ref('')
|
||||
const end = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let generation = 0
|
||||
const metrics = computed<Record<string,string>>(() => ({input_tokens:t('输入 Token','Input tokens'),output_tokens:t('输出 Token','Output tokens'),total_tokens:t('总 Token','Total tokens'),cache_hit_tokens:t('缓存命中','Cache hits'),cache_miss_tokens:t('缓存未命中','Cache misses'),cache_write_tokens:t('缓存写入','Cache writes'),reasoning_tokens:t('推理 Token','Reasoning tokens')}))
|
||||
const metricIcons: Record<string, Component> = {
|
||||
input_tokens: Download, output_tokens: Upload, total_tokens: Coin,
|
||||
cache_hit_tokens: CircleCheck, cache_miss_tokens: CircleClose,
|
||||
cache_write_tokens: FolderAdd, reasoning_tokens: Cpu,
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const until = period.value === 'custom' ? new Date(end.value) : new Date()
|
||||
const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
|
||||
if (period.value === 'today') from.setHours(0,0,0,0)
|
||||
else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value))
|
||||
else if (period.value !== 'custom') { from.setDate(from.getDate() - Number(period.value) + 1); from.setHours(0,0,0,0) }
|
||||
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error(t('请选择有效的开始与结束时间。', 'Choose a valid start and end time.'))
|
||||
data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
|
||||
} catch(e) { error.value = (e as Error).message } finally { busy.value = false }
|
||||
const result = await apiClient.get<Usage>('/api/usage', {params: {timezone_offset: -new Date().getTimezoneOffset(), start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
|
||||
if (current === generation) data.value = result
|
||||
} catch(e) { if (current === generation) { error.value = (e as Error).message; data.value = null } } finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="panel usage-card"><header><h3>{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? t('加载中…', 'Loading…') : t('刷新统计', 'Refresh') }}</button></header>
|
||||
<div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
|
||||
<section class="panel usage-card"><header><h3><AppIcon :icon="DataAnalysis" :size="20" />{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load"><AppIcon :icon="Refresh" :size="16" />{{ busy ? t('加载中…', 'Loading…') : t('刷新统计', 'Refresh') }}</button></header>
|
||||
<div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="90">{{ t('近三个月(90 天)', 'Last 3 months (90 days)') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
|
||||
<label>{{ t('提供商', 'Provider') }}<select v-model="provider" class="select" @change="model = ''; load()"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
|
||||
<label>{{ t('模型', 'Model') }}<select v-model="model" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
|
||||
<label>{{ t('来源', 'Source') }}<select v-model="source" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option value="api">{{ t('远程 API', 'Remote API') }}</option><option value="local">{{ t('本地服务', 'Local service') }}</option></select></label>
|
||||
@@ -36,11 +48,16 @@ onMounted(load)
|
||||
<div v-if="period === 'custom'" class="filters"><label>{{ t('开始', 'Start') }}<input v-model="start" class="input" type="datetime-local" /></label><label>{{ t('结束', 'End') }}<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">{{ t('应用时间段', 'Apply period') }}</button></div>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<template v-if="data"><p v-if="!data.request_count" class="subtle">{{ t('该时间段没有已记录的模型请求。', 'No model requests were recorded during this period.') }}</p>
|
||||
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
|
||||
<div><small>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
|
||||
<p class="subtle">{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
|
||||
<UsageChart v-if="data.series" :buckets="data.series" />
|
||||
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small class="metric-label"><span class="metric-icon"><AppIcon :icon="metricIcons[key]!" :size="18" /></span>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
|
||||
<div><small class="metric-label"><span class="metric-icon"><AppIcon :icon="PieChart" :size="18" /></span>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
|
||||
<p class="subtle audio-usage"><AppIcon :icon="Microphone" :size="16" />{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
|
||||
<p class="subtle">{{ t('请求', 'Requests') }} {{ data.request_count }}, {{ t('其中完整结束', 'completed') }} {{ data.complete_requests }}. {{ t('输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。', 'Input totals include provider-reported cache tokens; reasoning tokens are not added to output twice.') }}</p>
|
||||
</template><p class="subtle">{{ t('统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
|
||||
<style scoped>
|
||||
.usage-card h3, .usage-card header button, .metric-label, .audio-usage { display: flex; align-items: center; gap: 8px; }
|
||||
.usage-card h3 > .app-icon, .audio-usage > .app-icon { color: var(--color-accent-primary); }
|
||||
.metric-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); background: var(--color-background-secondary); color: var(--color-accent-primary); flex-shrink: 0; }
|
||||
.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import UsageChart from './UsageChart.vue'
|
||||
it('distinguishes unreported tokens from zero and switches to request counts', async () => {
|
||||
const wrapper = mount(UsageChart, { props: { buckets: [{ date: '2026-09-05', end_date: '2026-09-05',
|
||||
local: { requests: 1, totals: { input_tokens: null }, coverage: { input_tokens: 0 } },
|
||||
api: { requests: 2, totals: { input_tokens: 0 }, coverage: { input_tokens: 1 } },
|
||||
}] } })
|
||||
expect(wrapper.findAll('.usage-bar.missing')).toHaveLength(1)
|
||||
expect(wrapper.get('.usage-bar.local').attributes('aria-label')).toContain('未提供')
|
||||
expect(wrapper.get('.usage-bar.api').attributes('aria-label')).toContain('覆盖 1/2')
|
||||
await wrapper.get('select').setValue('requests')
|
||||
expect(wrapper.findAll('.usage-bar.missing')).toHaveLength(0)
|
||||
expect(wrapper.get('.usage-bar.api').attributes('style')).toContain('160px')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Cpu, Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
export interface UsageBucket {
|
||||
date: string
|
||||
end_date: string
|
||||
local: { requests: number; totals: Record<string, number | null>; coverage: Record<string, number> }
|
||||
api: { requests: number; totals: Record<string, number | null>; coverage: Record<string, number> }
|
||||
}
|
||||
const props = defineProps<{ buckets: UsageBucket[] }>()
|
||||
const metric = ref('input_tokens')
|
||||
const sources = ['local', 'api'] as const
|
||||
const labels = computed(() => ({ local: t('本地模型', 'Local models'), api: t('模型提供商', 'Providers') }))
|
||||
const metricLabel = computed(() => metric.value === 'requests' ? t('请求次数', 'Requests') : metric.value === 'input_tokens' ? t('输入 Token', 'Input tokens') : metric.value === 'output_tokens' ? t('输出 Token', 'Output tokens') : t('总 Token', 'Total tokens'))
|
||||
function value(bucket: UsageBucket, source: 'local' | 'api') {
|
||||
const item = bucket[source]
|
||||
return metric.value === 'requests' ? item.requests : item.requests === 0 ? 0 : item.totals[metric.value] ?? null
|
||||
}
|
||||
function description(bucket: UsageBucket, source: 'local' | 'api') {
|
||||
const count = value(bucket, source)
|
||||
const coverage = metric.value === 'requests' ? '' : ` · ${t('覆盖', 'Coverage')} ${bucket[source].coverage[metric.value] ?? 0}/${bucket[source].requests}`
|
||||
return `${bucket.date}${bucket.end_date !== bucket.date ? ' – ' + bucket.end_date : ''} · ${labels.value[source]} · ${metricLabel.value}: ${count === null ? t('未提供', 'Unavailable') : count.toLocaleString()}${coverage}`
|
||||
}
|
||||
const maximum = computed(() => Math.max(1, ...props.buckets.flatMap(bucket => sources.map(source => value(bucket, source) ?? 0))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="usage-chart" :aria-label="t('使用趋势', 'Usage trend')">
|
||||
<div class="chart-toolbar">
|
||||
<h4>{{ t('使用趋势', 'Usage trend') }}</h4>
|
||||
<select v-model="metric" class="select" :aria-label="t('图表统计指标', 'Chart metric')">
|
||||
<option value="input_tokens">{{ t('输入 Token', 'Input tokens') }}</option>
|
||||
<option value="output_tokens">{{ t('输出 Token', 'Output tokens') }}</option>
|
||||
<option value="total_tokens">{{ t('总 Token', 'Total tokens') }}</option>
|
||||
<option value="requests">{{ t('请求次数', 'Requests') }}</option>
|
||||
</select>
|
||||
<div class="chart-legend"><span class="local"><AppIcon :icon="Cpu" :size="16" />{{ labels.local }} · {{ t('实色', 'Solid') }}</span><span class="api"><AppIcon :icon="Connection" :size="16" />{{ labels.api }} · {{ t('斜纹', 'Striped') }}</span></div>
|
||||
</div>
|
||||
<p class="subtle chart-scale">{{ metricLabel }} · 0 — {{ maximum.toLocaleString() }}</p>
|
||||
<div class="chart-scroll" tabindex="0" :aria-label="t('按日期横向滚动查看柱状图', 'Scroll the chart by date')">
|
||||
<div class="chart-columns" :style="{ minWidth: `${buckets.length * 48}px` }">
|
||||
<div v-for="bucket in buckets" :key="bucket.date" class="chart-column">
|
||||
<div class="chart-bars">
|
||||
<div v-for="source in sources" :key="source" class="usage-bar" :class="[source, { missing: value(bucket, source) === null }]"
|
||||
:style="{ height: value(bucket, source) === null ? '8px' : `${(value(bucket, source) ?? 0) / maximum * 160}px` }"
|
||||
role="img" :aria-label="description(bucket, source)" :title="description(bucket, source)" />
|
||||
</div>
|
||||
<small :title="`${bucket.date} – ${bucket.end_date}`">{{ bucket.date.slice(5) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtle">{{ t('按本机时区分组;柱高仅汇总已报告值,悬停可查看覆盖请求数。虚线表示有请求但未提供该指标,不作为零消耗。', 'Grouped by your local UTC offset. Bars sum reported values; hover for coverage. Dashed markers mean requests with unavailable counters, not zero usage.') }}</p>
|
||||
<details><summary>{{ t('查看图表数据', 'View chart data') }}</summary><div class="chart-scroll"><table><thead><tr><th>{{ t('日期', 'Date') }}</th><th>{{ labels.local }}</th><th>{{ labels.api }}</th></tr></thead><tbody><tr v-for="bucket in buckets" :key="bucket.date"><th>{{ bucket.date }}<template v-if="bucket.date !== bucket.end_date"> – {{ bucket.end_date }}</template></th><td v-for="source in sources" :key="source">{{ description(bucket, source) }}</td></tr></tbody></table></div></details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-chart { min-width: 0; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-background-primary); }
|
||||
.chart-toolbar, .chart-legend, .chart-legend span { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.chart-toolbar { justify-content: space-between; }.chart-toolbar .select { width: auto; }.chart-legend { font-size: 12px; }
|
||||
.local { color: var(--color-info); }.api { color: var(--color-accent-primary); }
|
||||
.chart-scale { margin: 12px 0 0; font-size: 12px; }.chart-scroll { overflow-x: auto; padding-bottom: 8px; }.chart-columns { display: flex; gap: 8px; }
|
||||
.chart-column { flex: 1; min-width: 40px; text-align: center; }.chart-column small { font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
|
||||
.chart-bars { height: 176px; display: flex; align-items: flex-end; justify-content: center; gap: 4px; border-bottom: 1px solid var(--color-border-default); background: repeating-linear-gradient(to top, transparent 0 39px, var(--color-border-subtle) 39px 40px); }
|
||||
.usage-bar { width: 14px; max-width: 35%; background: currentColor; border-radius: 3px 3px 0 0; }.usage-bar.api { background: repeating-linear-gradient(45deg, currentColor 0 4px, color-mix(in srgb, currentColor 45%, var(--color-surface-primary)) 4px 7px); }
|
||||
.usage-bar.missing { background: transparent; border: 1px dashed currentColor; box-sizing: border-box; }
|
||||
.usage-chart > p:last-of-type { font-size: 12px; margin: 12px 0; }.usage-chart summary { cursor: pointer; color: var(--color-text-link); font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }th, td { padding: 8px; text-align: left; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user