fix: 修复前后端同步问题

This commit is contained in:
ViperEkura 2026-03-28 11:52:05 +08:00
parent 6aea98554f
commit 836ee8ac9d
11 changed files with 334 additions and 371 deletions

View File

@ -1,29 +1,38 @@
"""Token statistics API routes"""
from datetime import date, timedelta
from datetime import date, timedelta, datetime, timezone
from flask import Blueprint, request, g
from sqlalchemy import func
from backend.models import TokenUsage
from sqlalchemy import func, extract
from backend.models import TokenUsage, Message, Conversation
from backend.utils.helpers import ok, err
from backend import db
bp = Blueprint("stats", __name__)
def _utc_today():
"""Get today's date in UTC to match stored timestamps."""
return datetime.now(timezone.utc).date()
@bp.route("/api/stats/tokens", methods=["GET"])
def token_stats():
"""Get token usage statistics"""
user = g.current_user
period = request.args.get("period", "daily")
today = date.today()
today = _utc_today()
if period == "daily":
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
# Hourly breakdown from Message table
hourly = _build_hourly_stats(user.id, today)
result = {
"period": "daily",
"date": today.isoformat(),
"prompt_tokens": sum(s.prompt_tokens for s in stats),
"completion_tokens": sum(s.completion_tokens for s in stats),
"total_tokens": sum(s.total_tokens for s in stats),
"hourly": hourly,
"by_model": {
s.model: {
"prompt": s.prompt_tokens,
@ -92,3 +101,37 @@ def _build_period_result(stats, period, start_date, end_date, days):
"daily": daily_data,
"by_model": by_model,
}
def _build_hourly_stats(user_id, day):
"""Build hourly token breakdown for a given day (UTC) from Message table."""
day_start = datetime.combine(day, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = datetime.combine(day, datetime.max.time()).replace(tzinfo=timezone.utc)
conv_ids = (
db.session.query(Conversation.id)
.filter(Conversation.user_id == user_id)
.subquery()
)
rows = (
db.session.query(
extract("hour", Message.created_at).label("hour"),
func.sum(Message.token_count).label("total"),
)
.filter(
Message.conversation_id.in_(conv_ids),
Message.role == "assistant",
Message.created_at >= day_start,
Message.created_at <= day_end,
)
.group_by(extract("hour", Message.created_at))
.all()
)
hourly = {}
for r in rows:
h = int(r.hour)
hourly[str(h)] = {"total": r.total or 0}
return hourly

View File

@ -15,8 +15,8 @@ Usage:
result = registry.execute("web_search", {"query": "Python"})
"""
from backend.tools.core import ToolDefinition, ToolResult, ToolRegistry, registry
from backend.tools.factory import tool, register_tool
from backend.tools.core import registry
from backend.tools.factory import tool
from backend.tools.executor import ToolExecutor
@ -47,16 +47,12 @@ def init_tools() -> None:
# Public API exports
__all__ = [
# Core classes
"ToolDefinition",
"ToolResult",
"ToolRegistry",
"ToolExecutor",
# Instances
"registry",
# Factory functions
"tool",
"register_tool",
# Classes
"ToolExecutor",
# Initialization
"init_tools",
# Service locator

View File

@ -51,10 +51,6 @@ class ToolExecutor:
return record["result"]
return None
def clear_history(self) -> None:
"""Clear call history (call this at start of new conversation turn)"""
self._call_history.clear()
@staticmethod
def _inject_context(name: str, args: dict, context: Optional[dict]) -> None:
"""Inject context fields into tool arguments in-place.

View File

@ -34,30 +34,3 @@ def tool(
return func
return decorator
def register_tool(
name: str,
handler: Callable,
description: str,
parameters: dict,
category: str = "general"
) -> None:
"""
Register a tool directly (without decorator)
Usage:
register_tool(
name="my_tool",
handler=my_function,
description="Description",
parameters={...}
)
"""
tool_def = ToolDefinition(
name=name,
description=description,
parameters=parameters,
handler=handler,
category=category
)
registry.register(tool_def)

View File

@ -1,11 +1,11 @@
"""Backend utilities"""
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user, record_token_usage, build_messages
from backend.utils.helpers import ok, err, to_dict, message_to_dict, record_token_usage, build_messages
__all__ = [
"ok",
"err",
"to_dict",
"get_or_create_default_user",
"message_to_dict",
"record_token_usage",
"build_messages",
]

View File

@ -22,6 +22,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/theme-one-dark": "^6.1.2",
"chart.js": "^4.5.1",
"codemirror": "^6.0.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.40",
@ -791,6 +792,12 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
@ -1430,6 +1437,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",

View File

@ -9,21 +9,22 @@
"preview": "vite preview"
},
"dependencies": {
"codemirror": "^6.0.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/lang-java": "^6.0.1",
"@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-rust": "^6.0.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-java": "^6.0.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-rust": "^6.0.1",
"@codemirror/lang-sql": "^6.8.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/theme-one-dark": "^6.1.2",
"chart.js": "^4.5.1",
"codemirror": "^6.0.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.40",
"marked": "^15.0.12",

View File

@ -42,6 +42,7 @@
:messages="messages"
:streaming="streaming"
:streaming-process-steps="streamProcessSteps"
:model-name-map="modelNameMap"
:has-more-messages="hasMoreMessages"
:loading-more="loadingMessages"
:tools-enabled="toolsEnabled"
@ -59,8 +60,11 @@
<div v-if="showSettings" class="modal-overlay" @click.self="showSettings = false">
<div class="modal-content">
<SettingsPanel
:key="currentConvId || '__none__'"
:visible="showSettings"
:conversation="currentConv"
:models="models"
:default-model="defaultModel"
@close="showSettings = false"
@save="saveSettings"
/>
@ -120,10 +124,29 @@ import { useModal } from './composables/useModal'
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
import { conversationApi, messageApi, projectApi } from './api'
import { conversationApi, messageApi, projectApi, modelApi } from './api'
const modal = useModal()
// -- Models state (preloaded) --
const models = ref([])
const modelNameMap = ref({})
const defaultModel = computed(() => models.value.length > 0 ? models.value[0].id : '')
async function loadModels() {
try {
const res = await modelApi.getCached()
models.value = res.data || []
const map = {}
for (const m of models.value) {
if (m.id && m.name) map[m.id] = m.name
}
modelNameMap.value = map
} catch (e) {
console.error('Failed to load models:', e)
}
}
// -- Conversations state --
const conversations = shallowRef([])
const currentConvId = ref(null)
@ -258,6 +281,7 @@ async function createConversationInProject(project) {
const res = await conversationApi.create({
title: '新对话',
project_id: project.id || null,
model: defaultModel.value || undefined,
})
conversations.value = [res.data, ...conversations.value]
await selectConversation(res.data.id)
@ -602,7 +626,8 @@ async function deleteProject(project) {
}
// -- Init --
onMounted(() => {
onMounted(async () => {
await loadModels()
loadProjects()
loadConversations()
})

View File

@ -79,13 +79,13 @@ import MessageBubble from './MessageBubble.vue'
import MessageInput from './MessageInput.vue'
import MessageNav from './MessageNav.vue'
import ProcessBlock from './ProcessBlock.vue'
import { modelApi } from '../api'
const props = defineProps({
conversation: { type: Object, default: null },
messages: { type: Array, required: true },
streaming: { type: Boolean, default: false },
streamingProcessSteps: { type: Array, default: () => [] },
modelNameMap: { type: Object, default: () => ({}) },
hasMoreMessages: { type: Boolean, default: false },
loadingMore: { type: Boolean, default: false },
toolsEnabled: { type: Boolean, default: true },
@ -95,27 +95,15 @@ const emit = defineEmits(['sendMessage', 'stopStreaming', 'deleteMessage', 'rege
const scrollContainer = ref(null)
const inputRef = ref(null)
const modelNameMap = ref({})
const activeMessageId = ref(null)
let scrollObserver = null
const observedElements = new WeakSet()
function formatModelName(modelId) {
return modelNameMap.value[modelId] || modelId
return props.modelNameMap[modelId] || modelId
}
onMounted(async () => {
try {
const res = await modelApi.getCached()
const map = {}
for (const m of res.data) {
if (m.id && m.name) map[m.id] = m.name
}
modelNameMap.value = map
} catch (e) {
console.warn('Failed to load model names:', e)
}
onMounted(() => {
if (scrollContainer.value) {
scrollObserver = new IntersectionObserver(
(entries) => {
@ -257,16 +245,6 @@ watch(() => props.conversation?.id, () => {
line-height: 1;
}
.thinking-badge {
background: rgba(245, 158, 11, 0.12);
color: #d97706;
}
[data-theme="dark"] .thinking-badge {
background: rgba(245, 158, 11, 0.18);
color: #fbbf24;
}
.messages-container {
flex: 1 1 auto;
overflow-y: auto;
@ -313,4 +291,5 @@ watch(() => props.conversation?.id, () => {
</style>

View File

@ -123,19 +123,20 @@
<script setup>
import { reactive, ref, watch, onMounted } from 'vue'
import { modelApi, conversationApi } from '../api'
import { conversationApi } from '../api'
import { useTheme } from '../composables/useTheme'
import { icons } from '../utils/icons'
const props = defineProps({
visible: { type: Boolean, default: false },
conversation: { type: Object, default: null },
models: { type: Array, default: () => [] },
defaultModel: { type: String, default: '' },
})
const emit = defineEmits(['close', 'save'])
const { isDark, toggleTheme } = useTheme()
const models = ref([])
const tabs = [
{ value: 'basic', label: '基本' },
@ -154,15 +155,6 @@ const form = reactive({
thinking_enabled: false,
})
async function loadModels() {
try {
const res = await modelApi.getCached()
models.value = res.data || []
} catch (e) {
console.error('Failed to load models:', e)
}
}
function syncFormFromConversation() {
if (props.conversation) {
form.title = props.conversation.title || ''
@ -170,27 +162,61 @@ function syncFormFromConversation() {
form.temperature = props.conversation.temperature ?? 1.0
form.max_tokens = props.conversation.max_tokens ?? 65536
form.thinking_enabled = props.conversation.thinking_enabled ?? false
// model: 使 conversation models
// model: 使 conversation defaultModel models
if (props.conversation.model) {
form.model = props.conversation.model
} else if (models.value.length > 0) {
form.model = models.value[0].id
} else if (props.defaultModel) {
form.model = props.defaultModel
} else if (props.models.length > 0) {
form.model = props.models[0].id
}
}
}
// Sync form when panel opens or conversation changes
watch([() => props.visible, () => props.conversation, models], () => {
if (props.visible) {
activeTab.value = 'basic'
syncFormFromConversation()
}
}, { deep: true })
// Track which conversation the form is synced to, to avoid saving stale data
let syncedConvId = null
let isSyncing = false
// Auto-save with debounce when form changes
function doSync() {
if (!props.conversation) return
isSyncing = true
syncFormFromConversation()
syncedConvId = props.conversation.id
// Defer resetting flag to after all watchers flush
setTimeout(() => { isSyncing = false }, 0)
}
// Sync form when panel opens or conversation switches
watch([() => props.visible, () => props.conversation?.id, () => props.models, () => props.defaultModel], () => {
if (props.visible && props.conversation) {
activeTab.value = 'basic'
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
doSync()
} else if (!props.visible) {
syncedConvId = null
}
})
// Sync when conversation data updates (e.g. auto-generated title after stream)
watch(
() => props.conversation,
(conv) => {
if (!props.visible || !conv || syncedConvId !== conv.id) return
doSync()
},
{ deep: true },
)
// Initial sync on mount (component may be recreated via :key)
onMounted(() => {
if (props.visible && props.conversation) doSync()
})
// Auto-save with debounce when user edits form
let saveTimer = null
watch(form, () => {
if (props.visible && props.conversation) {
if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveChanges, 500)
}
@ -205,8 +231,6 @@ async function saveChanges() {
console.error('Failed to save settings:', e)
}
}
onMounted(loadModels)
</script>
<style scoped>

View File

@ -60,107 +60,10 @@
</div>
<!-- 趋势图 -->
<div v-if="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart">
<div class="chart-title">每日趋势</div>
<div v-if="chartData.length > 0" class="stats-chart">
<div class="chart-title">{{ period === 'daily' ? '今日趋势' : '每日趋势' }}</div>
<div class="chart-container">
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
<defs>
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="accentColor" stop-opacity="0.25"/>
<stop offset="100%" :stop-color="accentColor" stop-opacity="0.02"/>
</linearGradient>
</defs>
<!-- 网格线 -->
<line
v-for="i in 4"
:key="'grid-' + i"
:x1="padding"
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
:x2="chartWidth - padding"
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
stroke="var(--border-light)"
stroke-dasharray="3,3"
/>
<!-- Y轴标签 -->
<text
v-for="i in 4"
:key="'yl-' + i"
:x="padding - 4"
:y="padding + (chartHeight - 2 * padding) * (i - 1) / 3 + 3"
text-anchor="end"
class="y-label"
>{{ formatNumber(maxValue - (maxValue * (i - 1)) / 3) }}</text>
<!-- 填充区域 -->
<path :d="areaPath" fill="url(#areaGradient)"/>
<!-- 折线 -->
<path
:d="linePath"
fill="none"
:stroke="accentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 数据点 -->
<circle
v-for="(point, idx) in chartPoints"
:key="idx"
:cx="point.x"
:cy="point.y"
r="3"
:fill="accentColor"
stroke="var(--bg-primary)"
stroke-width="2"
class="data-point"
@mouseenter="hoveredPoint = idx"
@mouseleave="hoveredPoint = null"
/>
<!-- 竖线指示 -->
<line
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
:x1="chartPoints[hoveredPoint].x"
:y1="padding"
:x2="chartPoints[hoveredPoint].x"
:y2="chartHeight - padding"
stroke="var(--border-medium)"
stroke-dasharray="3,3"
/>
</svg>
<!-- X轴标签 -->
<div class="x-labels">
<span
v-for="(point, idx) in chartPoints"
:key="idx"
class="x-label"
:class="{ active: hoveredPoint === idx }"
>
{{ formatDateLabel(point.date) }}
</span>
</div>
<!-- 悬浮提示 -->
<Transition name="fade">
<div
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
class="tooltip"
:style="{
left: chartPoints[hoveredPoint].x + 'px',
top: (chartPoints[hoveredPoint].y - 52) + 'px'
}"
>
<div class="tooltip-date">{{ formatFullDate(chartPoints[hoveredPoint].date) }}</div>
<div class="tooltip-row">
<span class="tooltip-dot prompt"></span>
输入 {{ formatNumber(chartPoints[hoveredPoint].prompt) }}
</div>
<div class="tooltip-row">
<span class="tooltip-dot completion"></span>
输出 {{ formatNumber(chartPoints[hoveredPoint].completion) }}
</div>
<div class="tooltip-total">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
</div>
</Transition>
<canvas ref="chartCanvas"></canvas>
</div>
</div>
@ -197,11 +100,14 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
import { statsApi } from '../api'
import { formatNumber } from '../utils/format'
import { icons } from '../utils/icons'
Chart.register(...registerables)
defineEmits(['close'])
const periods = [
@ -213,15 +119,8 @@ const periods = [
const period = ref('daily')
const stats = ref(null)
const loading = ref(false)
const hoveredPoint = ref(null)
const accentColor = computed(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
})
const chartWidth = 320
const chartHeight = 140
const padding = 32
const chartCanvas = ref(null)
let chartInstance = null
const sortedDaily = computed(() => {
if (!stats.value?.daily) return {}
@ -231,18 +130,36 @@ const sortedDaily = computed(() => {
})
const chartData = computed(() => {
const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => ({
date,
value: val.total,
prompt: val.prompt || 0,
completion: val.completion || 0,
}))
})
if (period.value === 'daily' && stats.value?.hourly) {
const hourly = stats.value.hourly
let minH = 24, maxH = -1
for (const h of Object.keys(hourly)) {
const hour = parseInt(h)
if (hour < minH) minH = hour
if (hour > maxH) maxH = hour
}
if (minH > maxH) return []
const start = Math.max(0, minH)
const end = Math.min(23, maxH)
return Array.from({ length: end - start + 1 }, (_, i) => {
const h = start + i
return {
label: `${h}:00`,
value: hourly[String(h)]?.total || 0,
}
})
}
const maxValue = computed(() => {
if (chartData.value.length === 0) return 100
return Math.max(100, ...chartData.value.map(d => d.value))
const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => {
const d = new Date(date)
return {
label: `${d.getMonth() + 1}/${d.getDate()}`,
value: val.total,
prompt: val.prompt || 0,
completion: val.completion || 0,
}
})
})
const maxModelTokens = computed(() => {
@ -250,54 +167,136 @@ const maxModelTokens = computed(() => {
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
})
const chartPoints = computed(() => {
const data = chartData.value
if (data.length === 0) return []
const xRange = chartWidth - 2 * padding
const yRange = chartHeight - 2 * padding
return data.map((d, i) => ({
x: data.length === 1
? chartWidth / 2
: padding + (i / Math.max(1, data.length - 1)) * xRange,
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
date: d.date,
value: d.value,
prompt: d.prompt,
completion: d.completion,
}))
})
const linePath = computed(() => {
const points = chartPoints.value
if (points.length === 0) return ''
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
})
const areaPath = computed(() => {
const points = chartPoints.value
if (points.length === 0) return ''
const baseY = chartHeight - padding
let path = `M ${points[0].x} ${baseY} `
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
path += ` L ${points[points.length - 1].x} ${baseY} Z`
return path
})
function formatDateLabel(dateStr) {
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()}`
function getAccentColor() {
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
}
function formatFullDate(dateStr) {
const d = new Date(dateStr)
return `${d.getMonth() + 1}${d.getDate()}`
function getTextColor(alpha = 1) {
const c = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
if (alpha === 1) return c
// Convert hex to rgba
if (c.startsWith('#')) {
const r = parseInt(c.slice(1, 3), 16)
const g = parseInt(c.slice(3, 5), 16)
const b = parseInt(c.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
return c
}
function destroyChart() {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
}
function buildChart() {
if (!chartCanvas.value || chartData.value.length === 0) return
destroyChart()
const accent = getAccentColor()
const ctx = chartCanvas.value.getContext('2d')
// Gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, 200)
gradient.addColorStop(0, accent + '40')
gradient.addColorStop(1, accent + '05')
const labels = chartData.value.map(d => d.label)
const values = chartData.value.map(d => d.value)
// Determine max ticks for x-axis
const maxTicks = chartData.value.length <= 8 ? chartData.value.length : 6
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data: values,
borderColor: accent,
backgroundColor: gradient,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: accent,
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
fill: true,
tension: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
layout: {
padding: { top: 4, right: 4, bottom: 0, left: 0 },
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: {
color: getTextColor(),
font: { size: 10 },
maxTicksLimit: maxTicks,
maxRotation: 0,
},
},
y: {
beginAtZero: true,
grid: {
color: getTextColor(0.15),
drawBorder: false,
},
border: { display: false },
ticks: {
color: getTextColor(),
font: { size: 9 },
maxTicksLimit: 4,
callback: (v) => formatNumber(v),
},
},
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleColor: '#fff',
bodyColor: '#ccc',
titleFont: { size: 11, weight: '500' },
bodyFont: { size: 11 },
padding: 8,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex
const d = chartData.value[idx]
if (period.value === 'daily') {
return `${d.label} - ${parseInt(d.label) + 1}:00`
}
return d.label
},
label: (item) => `${formatNumber(item.raw)} tokens`,
},
},
},
interaction: {
mode: 'index',
intersect: false,
},
},
})
}
watch(chartData, () => {
nextTick(buildChart)
})
async function loadStats() {
loading.value = true
try {
@ -312,11 +311,11 @@ async function loadStats() {
function changePeriod(p) {
period.value = p
hoveredPoint.value = null
loadStats()
}
onMounted(loadStats)
onBeforeUnmount(destroyChart)
</script>
<style scoped>
@ -324,8 +323,6 @@ onMounted(loadStats)
padding: 0;
}
/* panel-header, panel-title, header-actions now in global.css */
.stats-loading {
display: flex;
align-items: center;
@ -430,99 +427,9 @@ onMounted(loadStats)
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 12px 8px 8px 8px;
padding: 10px;
position: relative;
overflow: hidden;
}
.line-chart {
width: 100%;
height: 140px;
}
.y-label {
fill: var(--text-tertiary);
font-size: 9px;
}
.data-point {
cursor: pointer;
transition: r 0.15s;
}
.data-point:hover {
r: 5;
}
.x-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
padding: 0 28px 0 32px;
}
.x-label {
font-size: 10px;
color: var(--text-tertiary);
transition: color 0.15s;
}
.x-label.active {
color: var(--text-primary);
font-weight: 500;
}
/* 提示框 */
.tooltip {
position: absolute;
background: var(--bg-primary);
border: 1px solid var(--border-medium);
padding: 8px 10px;
border-radius: 8px;
font-size: 11px;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateX(-50%);
z-index: 10;
min-width: 120px;
}
.tooltip-date {
color: var(--text-tertiary);
font-size: 10px;
margin-bottom: 4px;
}
.tooltip-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.tooltip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltip-dot.prompt {
background: #3b82f6;
}
.tooltip-dot.completion {
background: #a855f7;
}
.tooltip-total {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-light);
font-weight: 600;
color: var(--text-primary);
font-size: 12px;
height: 180px;
}
/* 模型分布 */