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
+34 -1
View File
@@ -1,6 +1,6 @@
theme_id: paper-moments
name: 纸间时光 · Paper Moments
version: 1.4.1
version: 1.5.0
author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0
@@ -266,3 +266,36 @@ license: MIT
@media (max-width: 720px) {
[data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; }
}
/* Shared paper surfaces across settings, search, agents, media and extensions. */
[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .usage-chart) {
position: relative;
border: 1px solid #b5a693;
border-radius: 8px 14px 8px 8px;
outline: 1px dashed #d5c8b5;
outline-offset: -6px;
background-color: #fffdf5;
background-image: repeating-linear-gradient(transparent 0 31px, #b6c7bd18 31px 32px);
box-shadow: 3px 4px 0 #d8e6e2, 6px 7px 0 #f0d8cf;
}
[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal)::before {
content: '';
position: absolute;
inset: 0 24px auto auto;
opacity: 1;
transform: none;
width: 48px;
height: 9px;
background: repeating-linear-gradient(45deg, #c5dfe0b0 0 6px, #daeceba0 6px 12px);
pointer-events: none;
}
[data-theme="paper-moments"] :is(.item-card, .event-card, .citation-card, .routing-card):nth-child(2n)::before {
background: repeating-linear-gradient(45deg, #e7bcb3b0 0 6px, #f2d4cba0 6px 12px);
}
[data-theme="paper-moments"] :is(.panel, .item-card, .routing-card, .vault-card) :is(h2, h3, h4) {
font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif;
color: #875343;
}
[data-theme="paper-moments"] .usage-chart { background-color: #fbf7ea; }
[data-theme="paper-moments"] .usage-grid > div { padding: 12px; border: 1px dashed #d5c8b5; border-radius: 5px; background: #fffdf580; }
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import DiagramInteractions from './DiagramInteractions.vue'
import { appendDiagramControls } from '@/utils/diagramControls'
it.each(['markdown-mermaid', 'editor-mermaid-preview'])('handles copied SVG controls in %s', async className => {
const container = document.createElement('div')
container.className = className
container.innerHTML = '<svg viewBox="0 0 400 200"><text>Diagram</text></svg>'
appendDiagramControls(container)
const wrapper = mount(DiagramInteractions, { slots: { default: container.outerHTML }, attachTo: document.body })
const svg = wrapper.get('svg').element as SVGSVGElement
await wrapper.get('[data-diagram-action="in"]').trigger('click')
expect(svg.style.width).toBe('480px')
await wrapper.get('[data-diagram-action="reset"]').trigger('click')
expect(svg.style.maxWidth).toBe('')
const dialog = document.querySelector('dialog')!
const show = vi.fn()
dialog.showModal = show
await wrapper.get('[data-diagram-action="view"]').trigger('click')
await flushPromises()
expect(show).toHaveBeenCalledOnce()
expect(dialog.textContent).toContain('Diagram')
wrapper.unmount()
})
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import DOMPurify from 'dompurify'
const viewer = ref<HTMLDialogElement | null>(null)
const svgHtml = ref('')
const scale = ref(1)
const baseWidth = ref(800)
let opener: HTMLElement | null = null
function widthOf(svg: SVGSVGElement) {
return svg.viewBox?.baseVal?.width || Number(svg.getAttribute('viewBox')?.split(/[ ,]+/)[2]) || svg.getBoundingClientRect().width || 800
}
async function interact(event: MouseEvent) {
if (!(event.target instanceof Element)) return
const button = event.target.closest<HTMLElement>('[data-diagram-action]')
const diagram = button?.closest<HTMLElement>('.editor-mermaid-preview, .markdown-mermaid')
const svg = diagram?.querySelector<SVGSVGElement>('svg')
if (!button || !diagram || !svg) return
event.preventDefault()
event.stopPropagation()
const action = button.dataset.diagramAction
if (action === 'view') {
opener = button
baseWidth.value = widthOf(svg)
svgHtml.value = DOMPurify.sanitize(svg.outerHTML, { USE_PROFILES: { svg: true, svgFilters: true, html: true } })
scale.value = 1
await nextTick()
viewer.value?.showModal()
return
}
const previous = Number(diagram.dataset.diagramScale || 1)
const next = action === 'reset' ? 1 : Math.max(.2, Math.min(5, previous * (action === 'in' ? 1.2 : 1 / 1.2)))
diagram.dataset.diagramScale = String(next)
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px`
svg.style.maxWidth = next === 1 ? '' : 'none'
svg.style.height = 'auto'
}
function close() { viewer.value?.close(); svgHtml.value = ''; opener?.focus() }
</script>
<template>
<div class="diagram-interactions" @click.capture="interact">
<slot />
<Teleport to="body">
<dialog ref="viewer" class="diagram-viewer" aria-label="图表大图查看" @cancel.prevent="close">
<header><strong>图表查看</strong><div class="diagram-controls">
<button type="button" @click="scale = Math.max(.2, scale / 1.2)">缩小</button>
<output>{{ Math.round(scale * 100) }}%</output>
<button type="button" @click="scale = Math.min(5, scale * 1.2)">放大</button>
<button type="button" @click="scale = 1">重置</button>
<button type="button" autofocus @click="close">关闭</button>
</div></header>
<div class="diagram-viewer-scroll"><div class="diagram-viewer-image" :style="{ width: `${baseWidth * scale}px` }" v-html="svgHtml" /></div>
</dialog>
</Teleport>
</div>
</template>
<style>
.diagram-interactions { min-width: 0; }
.diagram-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.diagram-controls button { padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.diagram-viewer::backdrop { background: #0008; }
.diagram-viewer header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.diagram-viewer-scroll { height: calc(100% - 64px); overflow: auto; }
.diagram-viewer-image { margin: auto; }
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DiagramInteractions from './DiagramInteractions.vue'
import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme'
@@ -19,7 +20,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async
</script>
<template>
<div class="markdown-content" v-html="html" />
<DiagramInteractions><div class="markdown-content" v-html="html" /></DiagramInteractions>
</template>
<style>
@@ -41,6 +41,31 @@ async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
}
describe('TraceTimeline 树形视图', () => {
it('filters errors while retaining tree ancestors and final tool data', async () => {
const events = sampleEvents()
const result = events.find(item => item.event === 'ToolResult')!
result.data.success = false
result.data.error_code = 'TIMEOUT'
const wrapper = mountTree(events)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(wrapper.findAll('.event-card')).toHaveLength(1)
await switchToTree(wrapper)
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
expect(wrapper.text()).toContain('TIMEOUT')
await wrapper.get('[aria-label="搜索执行轨迹"]').setValue('no-match')
expect(wrapper.text()).toContain('没有匹配的事件')
wrapper.unmount()
})
it('filters a tool including its result and keeps citation navigation usable', async () => {
const wrapper = mountTree(sampleEvents())
await wrapper.get('[aria-label="工具筛选"]').setValue('read_note')
expect(wrapper.findAll('.event-card')).toHaveLength(2)
await wrapper.findAll('button').find(button => button.text() === '清除筛选')!.trigger('click')
await wrapper.get('[aria-label="事件类型"]').setValue('Citation')
await wrapper.get('.event-citation').trigger('click')
expect(wrapper.emitted('open-citation')).toHaveLength(1)
wrapper.unmount()
})
it('叶子节点点击后能看到自己的数据', async () => {
// 回归:之前行的 click 是 `children.length && toggleExpand(id)`
// 而详情 v-if 又要求 children.length === 0 —— 两个条件互斥,
+43 -4
View File
@@ -21,6 +21,32 @@ const expandedNodes = ref<Set<string>>(new Set())
const detailNodes = ref<Set<string>>(new Set())
const viewMode = ref<'timeline' | 'tree'>('timeline')
const showDetails = ref(true)
const query = ref('')
const eventType = ref('')
const toolName = ref('')
const errorsOnly = ref(false)
const eventTypes = computed(() => [...new Set(props.events.map(event => event.event))])
const toolNames = computed(() => [...new Set(props.events.filter(event => event.event === 'ToolCall').map(event => String(event.data.name ?? '')))].filter(Boolean))
const filtering = computed(() => Boolean(query.value.trim() || eventType.value || toolName.value || errorsOnly.value))
const filteredEvents = computed(() => {
const toolIds = new Set(props.events.filter(event => event.event === 'ToolCall' && event.data.name === toolName.value).map(event => event.data.tool_call_id))
return props.events.filter(event => (!eventType.value || event.event === eventType.value)
&& (!toolName.value || (event.data.tool_call_id != null && toolIds.has(event.data.tool_call_id)))
&& (!errorsOnly.value || event.event.endsWith('Failed') || Boolean(event.data.error_code) || event.data.success === false || event.data.is_error === true)
&& (!query.value.trim() || `${eventLabel(event.event)} ${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query.value.trim().toLowerCase())))
})
const filteredTree = computed(() => {
if (!filtering.value) return traceNodes.value
const matches = (node: TraceNode) => filteredEvents.value.some(event => event.sequence === node.sequence
|| (node.type === 'tool_call' && node.data.tool_call_id != null && node.data.tool_call_id === event.data.tool_call_id)
|| (node.type === 'model_call' && node.data.model_call_id != null && node.data.model_call_id === event.data.model_call_id))
const prune = (nodes: TraceNode[]): TraceNode[] => nodes.flatMap(node => {
const children = prune(node.children)
return matches(node) || children.length ? [{ ...node, children }] : []
})
return prune(traceNodes.value)
})
function resetFilters() { query.value = ''; eventType.value = ''; toolName.value = ''; errorsOnly.value = false }
const traceNodes = computed(() => buildTraceNodes(props.events))
const toolCalls = computed(() => getToolCallsFromEvents(props.events))
@@ -119,14 +145,14 @@ function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; dept
const result: Array<{ node: TraceNode; depth: number }> = []
for (const node of nodes) {
result.push({ node, depth })
if (node.children.length > 0 && isExpanded(node.id)) {
if (node.children.length > 0 && (filtering.value || isExpanded(node.id))) {
result.push(...flatNodes(node.children, depth + 1))
}
}
return result
}
const flatTrace = computed(() => flatNodes(traceNodes.value))
const flatTrace = computed(() => flatNodes(filteredTree.value))
</script>
<template>
@@ -165,10 +191,19 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</div>
</div>
<div class="trace-filters">
<input v-model="query" class="input" aria-label="搜索执行轨迹" placeholder="搜索参数、结果或引用…" />
<select v-model="eventType" class="select" aria-label="事件类型"><option value="">全部事件</option><option v-for="kind in eventTypes" :key="kind" :value="kind">{{ eventLabel(kind) }}</option></select>
<select v-model="toolName" class="select" aria-label="工具筛选"><option value="">全部工具</option><option v-for="name in toolNames" :key="name" :value="name">{{ name }}</option></select>
<label><input v-model="errorsOnly" type="checkbox" /> 仅错误</label>
<button v-if="filtering" class="button-secondary" @click="resetFilters">清除筛选</button>
<span aria-live="polite">{{ filteredEvents.length }} / {{ events.length }} 事件</span>
</div>
<p v-if="filtering && !filteredEvents.length" class="subtle" role="status">没有匹配的事件</p>
<div v-if="viewMode === 'timeline'" class="timeline-view">
<div class="timeline">
<article
v-for="event in events"
v-for="event in filteredEvents"
:key="event.sequence"
class="event-card"
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
@@ -265,7 +300,7 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</div>
</div>
<div v-if="toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
<div v-if="!filtering && toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
<h3 class="panel-title">工具调用统计</h3>
<div class="tool-call-list">
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status">
@@ -284,6 +319,10 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</template>
<style scoped>
.trace-filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.trace-filters > input { flex: 1 1 220px; min-width: 0; }
.trace-filters label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.trace-filters span { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
.trace-visualization {
display: grid;
gap: var(--space-lg);
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
@@ -68,7 +69,7 @@ function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
if (entry.apply === apply) diagramPreviews.delete(id)
}
const element = createMermaidPreview(source, themeStore.isDark, apply)
diagramPreviews.set(element.id, { source, apply })
diagramPreviews.set(element.dataset.previewId!, { source, apply })
return element
}
watch(() => themeStore.currentThemeId, () => {
@@ -264,7 +265,7 @@ defineExpose({ getEditor: () => crepe?.editor })
</script>
<template>
<div class="visual-editor">
<DiagramInteractions class="visual-editor">
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span>
@@ -313,7 +314,7 @@ defineExpose({ getEditor: () => crepe?.editor })
</section>
<div ref="editorRoot" />
</div>
</div>
</DiagramInteractions>
</template>
<style scoped>
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { getMarkdown } from '@milkdown/kit/utils'
import type { Editor } from '@milkdown/kit/core'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn(async () => ({ svg: '<svg viewBox="0 0 400 200"><text>Diagram</text></svg>', warnings: [], width: 400, height: 200 })) }))
let wrapper: VueWrapper
afterEach(() => { wrapper?.unmount(); document.body.innerHTML = ''; vi.unstubAllGlobals() })
it('keeps diagram buttons usable after real Milkdown preview copying without changing Markdown', async () => {
localStorage.clear()
setActivePinia(createPinia())
vi.stubGlobal('IntersectionObserver', class {
constructor(private callback: IntersectionObserverCallback) {}
observe(target: Element) { queueMicrotask(() => this.callback([{ isIntersecting: true, target } as IntersectionObserverEntry], this as unknown as IntersectionObserver)) }
unobserve() {}
disconnect() {}
})
wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```mermaid\ngraph TD; A-->B\n```' }, attachTo: document.body })
await vi.waitFor(() => expect(wrapper.find('[data-diagram-action="in"]').exists()).toBe(true), { timeout: 3000 })
const editor = (wrapper.vm as unknown as { getEditor(): Editor }).getEditor()
const before = editor.action(getMarkdown())
await wrapper.get('[data-diagram-action="in"]').trigger('click')
expect((wrapper.get('.editor-mermaid-preview svg').element as SVGSVGElement).style.width).toBe('480px')
expect(editor.action(getMarkdown())).toBe(before)
})
@@ -23,7 +23,7 @@ it('renders SVG with the requested theme and keeps async revisions isolated', as
expect(oldPublish).not.toHaveBeenCalled()
expect(latestPublish).toHaveBeenCalledWith(latest)
expect(latestPublish.mock.calls[0]![0]).not.toBe(latest)
document.getElementById(latest.id)?.remove()
document.body.replaceChildren()
expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' })
})
@@ -31,7 +31,7 @@ it('shows syntax errors as text without executing markup', async () => {
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: ['<img src=x onerror=alert(1)>'], width: 0, height: 0 })
const preview = createMermaidPreview('invalid', false, vi.fn())
await flushPromises()
expect(preview.classList.contains('has-error')).toBe(true)
expect(preview.querySelector('.has-error')).not.toBeNull()
expect(preview.querySelector('img')).toBeNull()
expect(preview.textContent).toContain('点击编辑')
})
@@ -1,13 +1,19 @@
import { nextTick } from 'vue'
import { renderMermaid } from '@/services/mermaidService'
import { t } from '@/i18n'
import { appendDiagramControls } from '@/utils/diagramControls'
let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
// Each revision owns its element, so a slow render cannot replace newer content.
// Milkdown sanitizes Element input to its inner HTML; retain the revision
// marker and controls inside an otherwise disposable envelope.
const envelope = document.createElement('div')
const container = document.createElement('div')
envelope.append(container)
container.className = 'editor-mermaid-preview'
container.id = `editor-mermaid-preview-${++previewId}`
envelope.dataset.previewId = container.id
container.setAttribute('aria-live', 'polite')
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => {
@@ -18,7 +24,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback.
applyPreview(container.cloneNode(true) as HTMLElement)
applyPreview(envelope.cloneNode(true) as HTMLElement)
}
}
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
@@ -30,10 +36,11 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
}
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
container.innerHTML = result.svg
appendDiagramControls(container)
void publish()
}).catch(() => {
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
void publish()
})
return container
return envelope
}
+6 -2
View File
@@ -32,7 +32,9 @@ const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('
const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])])
const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status)
const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`
const warningLabel = (warning: string) => ({
const warningLabel = (warning: string) => warning.startsWith('MEDIA_CORRUPT_PACKETS_SKIPPED:')
? t(`已跳过 ${warning.split(':')[1]} 个损坏音频包;缺失时长以静音保留,请校对受影响内容。`, `Skipped ${warning.split(':')[1]} damaged audio packets; missing duration was retained as silence. Review the affected content.`)
: ({
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
@@ -57,6 +59,8 @@ async function action(work: () => Promise<void>) {
async function submit() {
if (!file.value) return
await action(async () => {
if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.'))
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
let terms = {}
if (terminology.value.trim()) {
terms = JSON.parse(terminology.value)
@@ -106,7 +110,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template>
<section class="media-page">
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}</p></header>
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
@@ -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); }
+28 -11
View File
@@ -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>
@@ -4,16 +4,20 @@ import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw'
const props = defineProps<{ themeId: string }>()
const props = defineProps<{ themeId: string; name?: string; css?: string }>()
const emit = defineEmits<{ (event: 'close'): void }>()
const theme = computed(() => mockCommunityThemes.find(item => item.theme_id === props.themeId))
const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId))
const previewDocument = computed(() => {
// Only bundled community CSS enters this script-free, isolated document.
// Both imported and bundled CSS are previewed in a script-free isolated document.
// Previewing never installs a theme or changes application styles/storage.
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
doc.documentElement.dataset.theme = props.themeId
const policy = doc.createElement('meta')
policy.httpEquiv = 'Content-Security-Policy'
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
doc.head.append(policy)
const style = doc.createElement('style')
style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
style.textContent = `${tokensCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
doc.head.append(style)
const article = doc.createElement('article')
article.className = 'panel'
@@ -21,6 +21,12 @@ it('downloads a URL for inspection without automatically installing it', async (
await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true))
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
expect(wrapper.get('.inspection-result').text()).toContain('纸间时光')
await wrapper.findAll('button').find(button => button.text() === '预览主题效果')!.trigger('click')
const preview = wrapper.get('iframe')
expect(preview.attributes('sandbox')).toBe('')
expect(preview.attributes('srcdoc')).toContain('Content-Security-Policy')
expect(preview.attributes('srcdoc')).toContain('data-theme="paper-moments"')
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
})
it('ignores a URL response after the dialog is cancelled', async () => {
@@ -9,6 +9,7 @@ import CommunityThemePreview from './CommunityThemePreview.vue'
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
const themeStore = useThemeStore()
const previewImport = ref(false)
const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false)
@@ -22,6 +23,7 @@ let importGeneration = 0
let downloadController: AbortController | undefined
function resetImport() {
previewImport.value = false
importGeneration++
downloadController?.abort()
importing.value = false
@@ -253,6 +255,7 @@ onMounted(() => {
<summary>将要安装的 CSS{{ themeStore.pendingInspection.css.length }} 字符</summary>
<pre>{{ themeStore.pendingInspection.css }}</pre>
</details>
<button type="button" class="button-secondary" @click="previewImport = true">预览主题效果</button>
</div>
<div v-else class="upload-area">
@@ -280,6 +283,7 @@ onMounted(() => {
</div>
</div>
</section>
<CommunityThemePreview v-if="previewImport && themeStore.pendingInspection?.compatible" :theme-id="themeStore.pendingInspection.manifest.theme_id" :name="themeStore.pendingInspection.manifest.name" :css="themeStore.pendingInspection.css" @close="previewImport = false" />
</template>
<style scoped>
+14 -3
View File
@@ -1,16 +1,27 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { strToU8, zipSync } from 'fflate'
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from './themePackageService'
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, installTheme, MAX_THEME_BYTES, THEME_APP_VERSION } from './themePackageService'
import paper from '@/assets/themes/paper-moments.theme?raw'
afterEach(() => vi.unstubAllGlobals())
it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => {
const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`)
expect((await inspectThemePackage(source)).compatible).toBe(false)
const { manifest, css } = await inspectThemePackage(paper)
await expect(installTheme({ ...manifest, min_app_version: version }, css)).rejects.toThrow()
})
it('accepts the current version and preserves real YAML list metadata', async () => {
const result = await inspectThemePackage(paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${THEME_APP_VERSION}\ntags: [paper, "a,b"]\n`))
expect(result.compatible).toBe(true)
expect(Array.isArray(result.manifest.tags)).toBe(true)
})
it('reads ZIP manifests under repository folders and validates the bundled CSS', async () => {
const [yaml, css] = paper.split('\n---\n')
const [yaml, css] = paper.split(/\r?\n---\r?\n/)
const zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
const result = await inspectThemePackage(await decodeThemePackage(zip))
expect(result.compatible).toBe(true)
expect(result.css).toBe(css!.trim())
expect(result.css).toBe(css!.replace(/\r\n/g, '\n').trim())
})
it('accepts a zipped single-file theme', async () => {
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
+19 -20
View File
@@ -1,5 +1,10 @@
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw'
import { valid, gt } from 'semver'
import appPackage from '../../package.json'
import { isMap, parseDocument } from 'yaml'
export const THEME_APP_VERSION = appPackage.version
const STORAGE_KEY = 'installed-themes'
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
@@ -103,9 +108,11 @@ function validateManifest(raw: Record<string, unknown>): { manifest: ThemeManife
if (!/^[a-z0-9_-]+$/.test(String(raw.theme_id))) {
throw new Error('THEME_MANIFEST_INVALID: theme_id must match [a-z0-9_-]+')
}
if (!/^\d+\.\d+\.\d+/.test(String(raw.version))) {
warnings.push('版本号格式建议使用 semver(如 1.0.0')
for (const field of ['version', 'min_app_version']) {
if (typeof raw[field] !== 'string' || !valid(raw[field] as string)) throw new Error(`THEME_MANIFEST_INVALID: ${field} 必须是有效的 semver 版本号`)
}
if (gt(raw.min_app_version as string, THEME_APP_VERSION)) throw new Error(`THEME_VERSION_INCOMPATIBLE: 主题需要应用 ${raw.min_app_version},当前版本为 ${THEME_APP_VERSION}`)
if (raw.is_dark !== undefined && typeof raw.is_dark !== 'boolean') throw new Error('THEME_MANIFEST_INVALID: is_dark 必须是布尔值')
const cssEntry = String(raw.css_entry)
if (cssEntry.includes('://') || cssEntry.startsWith('data:')) {
throw new Error('THEME_SECURITY_VIOLATION: css_entry must be a relative path within the package')
@@ -158,24 +165,9 @@ function removeThemeCss(themeId: string) {
}
function inspectYamlContent(yamlText: string): ThemeManifest {
const lines = yamlText.split('\n')
const result: Record<string, unknown> = {}
let currentKey: string | null = null
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const match = trimmed.match(/^([a-z_]+):\s*(.*)$/i)
if (match) {
currentKey = match[1]
let value = match[2].trim()
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1)
else if (value === 'true') result[currentKey] = true
else if (value === 'false') result[currentKey] = false
else if (/^\d+$/.test(value)) result[currentKey] = Number(value)
if (currentKey && !(currentKey in result)) result[currentKey] = value
}
}
const document = parseDocument(yamlText)
if (document.errors.length || document.warnings.length || !isMap(document.contents)) throw new Error('THEME_MANIFEST_INVALID: 主题清单必须是有效的 YAML 映射')
const result = document.toJS({ maxAliasCount: 20 }) as Record<string, unknown>
const { manifest } = validateManifest(result)
return manifest
}
@@ -278,6 +270,7 @@ export async function installTheme(
manifest: ThemeManifest,
cssContent: string,
): Promise<InstalledTheme> {
validateManifest(manifest as unknown as Record<string, unknown>)
// validateCssSafety 会对 @import / expression() / javascript: 抛错,
// 必须在 applyThemeCss 之前调用 —— 未校验的 CSS 一律不许进入页面。
const warnings = validateCssSafety(cssContent)
@@ -314,6 +307,7 @@ export async function enableTheme(themeId: string): Promise<InstalledTheme> {
const themes = loadStoredThemes()
const theme = themes.find((t) => t.theme_id === themeId)
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
validateManifest(theme.manifest as unknown as Record<string, unknown>)
theme.enabled = true
saveThemes(themes)
return theme
@@ -346,6 +340,11 @@ export function getActiveCustomTheme(): string | null {
}
export function setActiveCustomTheme(themeId: string | null) {
if (themeId) {
const theme = loadStoredThemes().find(item => item.theme_id === themeId)
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
validateManifest(theme.manifest as unknown as Record<string, unknown>)
}
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
// Validate before changing the current page. Only the selected theme owns a style node.
if (css) validateCssSafety(css)
+14
View File
@@ -0,0 +1,14 @@
/** Markup survives Milkdown's preview copying; the enclosing Vue component handles clicks. */
export function appendDiagramControls(container: HTMLElement) {
const controls = document.createElement('div')
controls.className = 'diagram-controls'
controls.setAttribute('contenteditable', 'false')
for (const [action, label] of [['out', '缩小图表'], ['in', '放大图表'], ['reset', '重置缩放'], ['view', '大图查看']]) {
const button = document.createElement('button')
button.type = 'button'
button.dataset.diagramAction = action
button.textContent = label
controls.append(button)
}
container.append(controls)
}
+2
View File
@@ -6,6 +6,7 @@ import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls'
marked.setOptions({ gfm: true, breaks: true })
@@ -82,6 +83,7 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
const container = document.createElement('div')
container.className = 'markdown-mermaid'
container.innerHTML = result.svg
if (!result.warnings.length) appendDiagramControls(container)
pre.replaceWith(container)
} catch {
const fallback = document.createElement('pre')