fix(frontend): 保留 Mermaid 大图文字并完善图表与卡片交互

This commit is contained in:
2026-09-05 20:58:33 +08:00
parent 8d626ee16b
commit 031ab135d2
8 changed files with 493 additions and 28 deletions
@@ -1,4 +1,4 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import DiagramInteractions from './DiagramInteractions.vue'
@@ -24,3 +24,42 @@ it.each(['markdown-mermaid', 'editor-mermaid-preview'])('handles copied SVG cont
expect(dialog.textContent).toContain('Diagram')
wrapper.unmount()
})
it('arms wheel zoom with middle click and releases page scrolling after mouse movement', async () => {
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 400 200"><text>Chart</text></svg></div>' }, attachTo: document.body })
const svg = wrapper.get('svg').element as SVGSVGElement
const scroll = () => { const event = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }); svg.dispatchEvent(event); return event }
expect(scroll().defaultPrevented).toBe(false)
await wrapper.get('svg').trigger('mousedown', { button: 1, clientX: 10, clientY: 20 })
expect(scroll().defaultPrevented).toBe(true)
expect(parseFloat(svg.style.width)).toBeGreaterThan(400)
const width = svg.style.width
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 11, clientY: 20 }))
expect(scroll().defaultPrevented).toBe(false)
expect(svg.style.width).toBe(width)
await wrapper.get('svg').trigger('mousedown', { button: 1 })
window.dispatchEvent(new Event('blur'))
expect(scroll().defaultPrevented).toBe(false)
wrapper.unmount()
})
it('preserves Mermaid HTML node and edge labels in the viewer while removing active HTML', async () => {
const container = document.createElement('div')
container.className = 'markdown-mermaid'
container.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200"><g class="nodeLabel"><foreignObject width="100" height="30"><div xmlns="http://www.w3.org/1999/xhtml"><span onclick="alert(1)">系统验证</span></div></foreignObject></g><g class="edgeLabel"><foreignObject width="100" height="30"><div xmlns="http://www.w3.org/1999/xhtml">验证通过<img src="x" onerror="alert(1)" /></div></foreignObject></g><text>结束</text></svg>`
appendDiagramControls(container)
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"></div>' }, attachTo: document.body })
wrapper.get('.markdown-mermaid').element.innerHTML = container.innerHTML
const dialog = document.querySelector('dialog')!
dialog.showModal = vi.fn()
await wrapper.get('[data-diagram-action="view"]').trigger('click')
await flushPromises()
expect(dialog.querySelectorAll('foreignObject')).toHaveLength(2)
expect(dialog.textContent).toContain('系统验证')
expect(dialog.textContent).toContain('验证通过')
expect(dialog.textContent).toContain('结束')
expect(dialog.querySelector('[onclick], [onerror], script')).toBeNull()
wrapper.unmount()
})
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { nextTick, ref, onBeforeUnmount } from 'vue'
import AppIcon from './AppIcon.vue'
import { ZoomIn, ZoomOut, Refresh, Close } from '@element-plus/icons-vue'
import DOMPurify from 'dompurify'
const viewer = ref<HTMLDialogElement | null>(null)
@@ -7,6 +9,45 @@ const svgHtml = ref('')
const scale = ref(1)
const baseWidth = ref(800)
let opener: HTMLElement | null = null
let wheelTarget: HTMLElement | null = null
let anchor = { x: 0, y: 0 }
const wheelActive = ref(false)
function disarm() {
wheelTarget?.removeAttribute('data-wheel-zoom')
wheelTarget = null; wheelActive.value = false
document.removeEventListener('mousemove', moved, true)
document.removeEventListener('wheel', wheel, true)
window.removeEventListener('blur', disarm)
}
function moved(event: MouseEvent) { if (event.clientX !== anchor.x || event.clientY !== anchor.y) disarm() }
function arm(event: MouseEvent) {
if (event.button !== 1 || !(event.target instanceof Element) || !event.target.closest('svg') || event.target.closest('.diagram-controls')) return
const target = event.target.closest<HTMLElement>('.editor-mermaid-preview, .markdown-mermaid, .diagram-viewer-image')
if (!target) return
event.preventDefault(); event.stopPropagation(); disarm()
wheelTarget = target; wheelActive.value = true; anchor = { x: event.clientX, y: event.clientY }
target.dataset.wheelZoom = 'true'
document.addEventListener('mousemove', moved, true)
document.addEventListener('wheel', wheel, { capture: true, passive: false })
window.addEventListener('blur', disarm)
}
function wheel(event: WheelEvent) {
if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return }
event.preventDefault(); event.stopPropagation()
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1)
const factor = Math.exp(-Math.max(-200, Math.min(200, delta)) * .002)
if (wheelTarget.classList.contains('diagram-viewer-image')) scale.value = Math.max(.2, Math.min(5, scale.value * factor))
else zoom(wheelTarget, Math.max(.2, Math.min(5, Number(wheelTarget.dataset.diagramScale || 1) * factor)))
}
function zoom(diagram: HTMLElement, next: number) {
const svg = diagram.querySelector<SVGSVGElement>('svg')
if (!svg) return
diagram.dataset.diagramScale = String(next)
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px`
svg.style.maxWidth = next === 1 ? '' : 'none'
svg.style.height = 'auto'
}
onBeforeUnmount(disarm)
function widthOf(svg: SVGSVGElement) {
return svg.viewBox?.baseVal?.width || Number(svg.getAttribute('viewBox')?.split(/[ ,]+/)[2]) || svg.getBoundingClientRect().width || 800
}
@@ -22,7 +63,17 @@ async function interact(event: MouseEvent) {
if (action === 'view') {
opener = button
baseWidth.value = widthOf(svg)
svgHtml.value = DOMPurify.sanitize(svg.outerHTML, { USE_PROFILES: { svg: true, svgFilters: true, html: true } })
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
// integration point while still sanitizing the embedded HTML and handlers.
const copy = svg.cloneNode(true) as SVGSVGElement
for (const label of copy.querySelectorAll('foreignObject, foreignobject')) {
label.innerHTML = DOMPurify.sanitize(label.innerHTML, { USE_PROFILES: { html: true } })
}
svgHtml.value = DOMPurify.sanitize(copy.outerHTML, {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'], ADD_ATTR: ['xmlns'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
})
scale.value = 1
await nextTick()
viewer.value?.showModal()
@@ -30,25 +81,23 @@ async function interact(event: MouseEvent) {
}
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'
zoom(diagram, next)
}
function close() { viewer.value?.close(); svgHtml.value = ''; opener?.focus() }
function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.focus() }
</script>
<template>
<div class="diagram-interactions" @click.capture="interact">
<div class="diagram-interactions" @click.capture="interact" @mousedown.capture="arm">
<slot />
<span v-if="wheelActive" class="wheel-zoom-hint" role="status">滚轮缩放中 · 移动鼠标退出</span>
<Teleport to="body">
<dialog ref="viewer" class="diagram-viewer" aria-label="图表大图查看" @cancel.prevent="close">
<dialog ref="viewer" class="diagram-viewer" aria-label="图表大图查看" @cancel.prevent="close" @mousedown.capture="arm">
<header><strong>图表查看</strong><div class="diagram-controls">
<button type="button" @click="scale = Math.max(.2, scale / 1.2)">缩小</button>
<button type="button" @click="scale = Math.max(.2, scale / 1.2)"><AppIcon :icon="ZoomOut" :size="16" />缩小</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>
<button type="button" @click="scale = Math.min(5, scale * 1.2)"><AppIcon :icon="ZoomIn" :size="16" />放大</button>
<button type="button" @click="scale = 1"><AppIcon :icon="Refresh" :size="16" />重置</button>
<button type="button" autofocus @click="close"><AppIcon :icon="Close" :size="16" />关闭</button>
</div></header>
<div class="diagram-viewer-scroll"><div class="diagram-viewer-image" :style="{ width: `${baseWidth * scale}px` }" v-html="svgHtml" /></div>
</dialog>
@@ -59,13 +108,20 @@ function close() { viewer.value?.close(); svgHtml.value = ''; opener?.focus() }
<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 { display: inline-flex; align-items: center; gap: 6px; 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 { margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
</style>
<style>
.editor-mermaid-preview > svg, .markdown-mermaid > svg { transition: width 180ms ease-out; }
[data-wheel-zoom="true"] { outline: 2px solid var(--color-accent-primary); outline-offset: -2px; cursor: zoom-in; }
.wheel-zoom-hint { position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%); z-index: 2000; padding: 8px 14px; border-radius: var(--radius-md); background: var(--color-surface-elevated); color: var(--color-text-primary); border: 1px solid var(--color-border-default); pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .editor-mermaid-preview > svg, .markdown-mermaid > svg, .diagram-viewer-image { transition: none; } }
</style>
+12 -2
View File
@@ -267,9 +267,19 @@ onMounted(load)
<style scoped>
.editor-fields { display: grid; gap: var(--space-lg); border: 0; padding: 0; margin: 0; min-width: 0; }
.mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
.mcp-page { overflow: auto; }
.mcp-page > :is(.feature-header, .notice-banner, .error-banner, .server-list, .empty) { box-sizing: border-box; width: 100%; max-width: 1180px; margin-inline: auto; }
.server-list { grid-template-columns: minmax(0, 1fr); }
.server-card, .server-main, .server-title, .server-title > div, .secret-input { min-width: 0; }
.server-title { flex: 1; }
.server-title h2, .metadata span, .secrets label > span { overflow-wrap: anywhere; }
.server-title code { display: block; white-space: pre-wrap; }
.server-main > .badge { flex-shrink: 0; }
.secret-input input { min-width: 0; width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); padding: 8px 10px; background: var(--color-background-secondary); color: var(--color-text-primary); font: inherit; }
.secret-input button { flex-shrink: 0; }
.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
.card-actions { flex-wrap: wrap; justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(0,.7fr) minmax(0,1fr); align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }.modal-card { width: min(800px,100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }.modal-card header,.modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input,.modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-family-mono); font-size: var(--font-size-sm); }.json-editor { line-height: 1.55; }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }
.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template,.mode-tabs button { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); cursor: pointer; }.template.active,.mode-tabs button.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); background: var(--color-accent-soft); }.mode-tabs { display: inline-flex; justify-self: start; gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-background-secondary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } } @media (max-width:720px) { .two-columns,.secrets label { grid-template-columns:1fr; }.card-actions { flex-wrap:wrap; } }
@@ -8,6 +8,10 @@ it('distinguishes unreported tokens from zero and switches to request counts', a
api: { requests: 2, totals: { input_tokens: 0 }, coverage: { input_tokens: 1 } },
}] } })
expect(wrapper.findAll('.usage-bar.missing')).toHaveLength(1)
expect(wrapper.get('.usage-pie').attributes('aria-label')).toContain('本地模型: 0')
await wrapper.get('.chart-column').trigger('mouseenter')
expect(wrapper.get('.chart-column').classes()).toContain('highlighted')
expect(wrapper.get('.chart-readout').text()).toContain('未提供')
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')
+27 -7
View File
@@ -11,6 +11,11 @@ export interface UsageBucket {
}
const props = defineProps<{ buckets: UsageBucket[] }>()
const metric = ref('input_tokens')
const hovered = ref<string | null>(null)
const activeBucket = computed(() => props.buckets.find(bucket => bucket.date === hovered.value))
const sums = computed(() => sources.map(source => ({ source, value: props.buckets.reduce((sum, bucket) => sum + (value(bucket, source) ?? 0), 0), coverage: props.buckets.reduce((sum, bucket) => sum + (metric.value === 'requests' ? bucket[source].requests : bucket[source].coverage[metric.value] ?? 0), 0), requests: props.buckets.reduce((sum, bucket) => sum + bucket[source].requests, 0) })))
const total = computed(() => sums.value.reduce((sum, item) => sum + item.value, 0))
const pie = computed(() => total.value ? `conic-gradient(var(--color-info) 0 ${sums.value[0]!.value / total.value * 100}%, var(--color-accent-primary) 0 100%)` : 'var(--color-border-subtle)')
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'))
@@ -38,33 +43,48 @@ const maximum = computed(() => Math.max(1, ...props.buckets.flatMap(bucket => so
</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>
<div class="chart-layout"><div class="bar-pane">
<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-columns" :style="{ minWidth: `${buckets.length * 14}px` }">
<div v-for="bucket in buckets" :key="bucket.date" class="chart-column" :class="{ highlighted: hovered === bucket.date }" tabindex="0" @mouseenter="hovered = bucket.date" @mouseleave="hovered = null" @focus="hovered = bucket.date" @blur="hovered = null">
<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>
<div class="chart-readout" aria-live="polite"><template v-if="activeBucket"><strong>{{ activeBucket.date }}</strong><span v-for="source in sources" :key="source" :class="source">{{ description(activeBucket, source) }}</span></template><span v-else>{{ t('悬停或聚焦日期查看数据', 'Hover or focus a date for details') }}</span></div>
</div><aside class="pie-pane"><h4>{{ t('来源占比', 'Usage by source') }}</h4>
<div class="usage-pie" role="img" :aria-label="sums.map(item => `${labels[item.source]}: ${item.value}`).join(' · ')" :style="{ background: pie }"><div><strong>{{ total.toLocaleString() }}</strong><small>{{ metricLabel }}</small></div></div>
<div v-for="item in sums" :key="item.source" class="pie-value" :class="item.source"><AppIcon :icon="item.source === 'local' ? Cpu : Connection" :size="16" /><span>{{ labels[item.source] }}</span><strong>{{ item.coverage ? item.value.toLocaleString() : item.requests ? t('未提供', 'Unavailable') : '0' }} · {{ total ? (item.value / total * 100).toFixed(1) + '%' : '—' }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ item.coverage }}/{{ item.requests }}</small></div>
<p class="subtle">{{ t('占比仅基于已报告值;缺失指标不计入分母。', 'Shares use reported values only; missing counters are excluded.') }}</p>
</aside></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>
.chart-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 24px; margin-top: 16px; }
.bar-pane { min-width: 0; }.pie-pane { border-left: 1px dashed var(--color-border-default); padding-left: 24px; min-width: 0; }
.usage-pie { width: 170px; aspect-ratio: 1; margin: 20px auto; border-radius: 50%; display: grid; place-items: center; }
.usage-pie > div { width: 112px; aspect-ratio: 1; border-radius: 50%; background: var(--color-surface-primary); display: flex; flex-direction: column; align-items: center; justify-content: center; }.usage-pie strong { font-size: 20px; }.usage-pie small { font-size: 11px; }
.pie-value { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12px; margin: 10px 0; }.pie-value small { width: 100%; color: var(--color-text-secondary); }.pie-pane > p { font-size: 12px; }
.chart-column { transition: background-color 150ms; border-radius: 4px; }.chart-column.highlighted { background: var(--color-accent-soft); outline: 1px solid var(--color-border-focus); outline-offset: -1px; }.highlighted .usage-bar { filter: brightness(1.15); }
.chart-readout { min-height: 76px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; margin-top: 12px; padding: 10px; border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-sm); background: var(--color-surface-primary); overflow-wrap: anywhere; }
@media (max-width: 760px) { .chart-layout { grid-template-columns: 1fr; }.pie-pane { border-left: 0; border-top: 1px dashed var(--color-border-default); padding: 20px 0 0; } }
.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); }
.chart-scale { margin: 12px 0 0; font-size: 12px; }.chart-scroll { overflow-x: auto; padding-bottom: 2px; }.chart-columns { display: flex; gap: 2px; }
.chart-column { flex: 1; min-width: 12px; text-align: center; }
.chart-bars { height: 176px; display: flex; align-items: flex-end; justify-content: center; gap: 2px; 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: 35%; max-width: 28px; 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); }
+15 -1
View File
@@ -7,7 +7,21 @@ export function appendDiagramControls(container: HTMLElement) {
const button = document.createElement('button')
button.type = 'button'
button.dataset.diagramAction = action
button.textContent = label
const paths: Record<string, string> = {
out: 'M8 11h6 M15 15l5 5 M17 10a7 7 0 1 1-14 0a7 7 0 0 1 14 0',
in: 'M8 10h6 M11 7v6 M15 15l5 5 M17 10a7 7 0 1 1-14 0a7 7 0 0 1 14 0',
reset: 'M3 10a9 9 0 1 1 2 8 M3 3v7h7',
view: 'M3 9V3h6 M15 3h6v6 M21 15v6h-6 M9 21H3v-6',
}
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
icon.setAttribute('viewBox', '0 0 24 24')
icon.setAttribute('width', '16'); icon.setAttribute('height', '16')
icon.setAttribute('aria-hidden', 'true')
const path = document.createElementNS(icon.namespaceURI, 'path')
path.setAttribute('d', paths[action!]!)
path.setAttribute('fill', 'none'); path.setAttribute('stroke', 'currentColor'); path.setAttribute('stroke-width', '1.8')
icon.append(path)
button.append(icon, document.createTextNode(label!))
controls.append(button)
}
container.append(controls)