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""" """Token statistics API routes"""
from datetime import date, timedelta from datetime import date, timedelta, datetime, timezone
from flask import Blueprint, request, g from flask import Blueprint, request, g
from sqlalchemy import func from sqlalchemy import func, extract
from backend.models import TokenUsage from backend.models import TokenUsage, Message, Conversation
from backend.utils.helpers import ok, err from backend.utils.helpers import ok, err
from backend import db
bp = Blueprint("stats", __name__) 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"]) @bp.route("/api/stats/tokens", methods=["GET"])
def token_stats(): def token_stats():
"""Get token usage statistics""" """Get token usage statistics"""
user = g.current_user user = g.current_user
period = request.args.get("period", "daily") period = request.args.get("period", "daily")
today = date.today() today = _utc_today()
if period == "daily": if period == "daily":
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all() 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 = { result = {
"period": "daily", "period": "daily",
"date": today.isoformat(), "date": today.isoformat(),
"prompt_tokens": sum(s.prompt_tokens for s in stats), "prompt_tokens": sum(s.prompt_tokens for s in stats),
"completion_tokens": sum(s.completion_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), "total_tokens": sum(s.total_tokens for s in stats),
"hourly": hourly,
"by_model": { "by_model": {
s.model: { s.model: {
"prompt": s.prompt_tokens, "prompt": s.prompt_tokens,
@ -92,3 +101,37 @@ def _build_period_result(stats, period, start_date, end_date, days):
"daily": daily_data, "daily": daily_data,
"by_model": by_model, "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"}) result = registry.execute("web_search", {"query": "Python"})
""" """
from backend.tools.core import ToolDefinition, ToolResult, ToolRegistry, registry from backend.tools.core import registry
from backend.tools.factory import tool, register_tool from backend.tools.factory import tool
from backend.tools.executor import ToolExecutor from backend.tools.executor import ToolExecutor
@ -47,16 +47,12 @@ def init_tools() -> None:
# Public API exports # Public API exports
__all__ = [ __all__ = [
# Core classes
"ToolDefinition",
"ToolResult",
"ToolRegistry",
"ToolExecutor",
# Instances # Instances
"registry", "registry",
# Factory functions # Factory functions
"tool", "tool",
"register_tool", # Classes
"ToolExecutor",
# Initialization # Initialization
"init_tools", "init_tools",
# Service locator # Service locator

View File

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

View File

@ -34,30 +34,3 @@ def tool(
return func return func
return decorator 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""" """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__ = [ __all__ = [
"ok", "ok",
"err", "err",
"to_dict", "to_dict",
"get_or_create_default_user", "message_to_dict",
"record_token_usage", "record_token_usage",
"build_messages", "build_messages",
] ]

View File

@ -22,6 +22,7 @@
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"chart.js": "^4.5.1",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.40", "katex": "^0.16.40",
@ -791,6 +792,12 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "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": { "node_modules/@lezer/common": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
@ -1430,6 +1437,18 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/codemirror": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",

View File

@ -9,21 +9,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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-cpp": "^6.0.2",
"@codemirror/lang-rust": "^6.0.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.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-sql": "^6.8.0",
"@codemirror/lang-xml": "^6.1.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", "highlight.js": "^11.11.1",
"katex": "^0.16.40", "katex": "^0.16.40",
"marked": "^15.0.12", "marked": "^15.0.12",

View File

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

View File

@ -79,13 +79,13 @@ import MessageBubble from './MessageBubble.vue'
import MessageInput from './MessageInput.vue' import MessageInput from './MessageInput.vue'
import MessageNav from './MessageNav.vue' import MessageNav from './MessageNav.vue'
import ProcessBlock from './ProcessBlock.vue' import ProcessBlock from './ProcessBlock.vue'
import { modelApi } from '../api'
const props = defineProps({ const props = defineProps({
conversation: { type: Object, default: null }, conversation: { type: Object, default: null },
messages: { type: Array, required: true }, messages: { type: Array, required: true },
streaming: { type: Boolean, default: false }, streaming: { type: Boolean, default: false },
streamingProcessSteps: { type: Array, default: () => [] }, streamingProcessSteps: { type: Array, default: () => [] },
modelNameMap: { type: Object, default: () => ({}) },
hasMoreMessages: { type: Boolean, default: false }, hasMoreMessages: { type: Boolean, default: false },
loadingMore: { type: Boolean, default: false }, loadingMore: { type: Boolean, default: false },
toolsEnabled: { type: Boolean, default: true }, toolsEnabled: { type: Boolean, default: true },
@ -95,27 +95,15 @@ const emit = defineEmits(['sendMessage', 'stopStreaming', 'deleteMessage', 'rege
const scrollContainer = ref(null) const scrollContainer = ref(null)
const inputRef = ref(null) const inputRef = ref(null)
const modelNameMap = ref({})
const activeMessageId = ref(null) const activeMessageId = ref(null)
let scrollObserver = null let scrollObserver = null
const observedElements = new WeakSet() const observedElements = new WeakSet()
function formatModelName(modelId) { function formatModelName(modelId) {
return modelNameMap.value[modelId] || modelId return props.modelNameMap[modelId] || modelId
} }
onMounted(async () => { onMounted(() => {
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)
}
if (scrollContainer.value) { if (scrollContainer.value) {
scrollObserver = new IntersectionObserver( scrollObserver = new IntersectionObserver(
(entries) => { (entries) => {
@ -257,16 +245,6 @@ watch(() => props.conversation?.id, () => {
line-height: 1; 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 { .messages-container {
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
@ -313,4 +291,5 @@ watch(() => props.conversation?.id, () => {
</style> </style>

View File

@ -123,19 +123,20 @@
<script setup> <script setup>
import { reactive, ref, watch, onMounted } from 'vue' import { reactive, ref, watch, onMounted } from 'vue'
import { modelApi, conversationApi } from '../api' import { conversationApi } from '../api'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { icons } from '../utils/icons' import { icons } from '../utils/icons'
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
conversation: { type: Object, default: null }, conversation: { type: Object, default: null },
models: { type: Array, default: () => [] },
defaultModel: { type: String, default: '' },
}) })
const emit = defineEmits(['close', 'save']) const emit = defineEmits(['close', 'save'])
const { isDark, toggleTheme } = useTheme() const { isDark, toggleTheme } = useTheme()
const models = ref([])
const tabs = [ const tabs = [
{ value: 'basic', label: '基本' }, { value: 'basic', label: '基本' },
@ -154,15 +155,6 @@ const form = reactive({
thinking_enabled: false, 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() { function syncFormFromConversation() {
if (props.conversation) { if (props.conversation) {
form.title = props.conversation.title || '' form.title = props.conversation.title || ''
@ -170,27 +162,61 @@ function syncFormFromConversation() {
form.temperature = props.conversation.temperature ?? 1.0 form.temperature = props.conversation.temperature ?? 1.0
form.max_tokens = props.conversation.max_tokens ?? 65536 form.max_tokens = props.conversation.max_tokens ?? 65536
form.thinking_enabled = props.conversation.thinking_enabled ?? false form.thinking_enabled = props.conversation.thinking_enabled ?? false
// model: 使 conversation models // model: 使 conversation defaultModel models
if (props.conversation.model) { if (props.conversation.model) {
form.model = props.conversation.model form.model = props.conversation.model
} else if (models.value.length > 0) { } else if (props.defaultModel) {
form.model = models.value[0].id form.model = props.defaultModel
} else if (props.models.length > 0) {
form.model = props.models[0].id
} }
} }
} }
// Sync form when panel opens or conversation changes // Track which conversation the form is synced to, to avoid saving stale data
watch([() => props.visible, () => props.conversation, models], () => { let syncedConvId = null
if (props.visible) { let isSyncing = false
activeTab.value = 'basic'
syncFormFromConversation()
}
}, { deep: true })
// 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 let saveTimer = null
watch(form, () => { watch(form, () => {
if (props.visible && props.conversation) { if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) {
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveChanges, 500) saveTimer = setTimeout(saveChanges, 500)
} }
@ -205,8 +231,6 @@ async function saveChanges() {
console.error('Failed to save settings:', e) console.error('Failed to save settings:', e)
} }
} }
onMounted(loadModels)
</script> </script>
<style scoped> <style scoped>

View File

@ -60,107 +60,10 @@
</div> </div>
<!-- 趋势图 --> <!-- 趋势图 -->
<div v-if="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart"> <div v-if="chartData.length > 0" class="stats-chart">
<div class="chart-title">每日趋势</div> <div class="chart-title">{{ period === 'daily' ? '今日趋势' : '每日趋势' }}</div>
<div class="chart-container"> <div class="chart-container">
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`"> <canvas ref="chartCanvas"></canvas>
<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>
</div> </div>
</div> </div>
@ -197,11 +100,14 @@
</template> </template>
<script setup> <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 { statsApi } from '../api'
import { formatNumber } from '../utils/format' import { formatNumber } from '../utils/format'
import { icons } from '../utils/icons' import { icons } from '../utils/icons'
Chart.register(...registerables)
defineEmits(['close']) defineEmits(['close'])
const periods = [ const periods = [
@ -213,15 +119,8 @@ const periods = [
const period = ref('daily') const period = ref('daily')
const stats = ref(null) const stats = ref(null)
const loading = ref(false) const loading = ref(false)
const hoveredPoint = ref(null) const chartCanvas = ref(null)
let chartInstance = null
const accentColor = computed(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
})
const chartWidth = 320
const chartHeight = 140
const padding = 32
const sortedDaily = computed(() => { const sortedDaily = computed(() => {
if (!stats.value?.daily) return {} if (!stats.value?.daily) return {}
@ -231,18 +130,36 @@ const sortedDaily = computed(() => {
}) })
const chartData = computed(() => { const chartData = computed(() => {
const data = sortedDaily.value if (period.value === 'daily' && stats.value?.hourly) {
return Object.entries(data).map(([date, val]) => ({ const hourly = stats.value.hourly
date, let minH = 24, maxH = -1
value: val.total, for (const h of Object.keys(hourly)) {
prompt: val.prompt || 0, const hour = parseInt(h)
completion: val.completion || 0, 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(() => { const data = sortedDaily.value
if (chartData.value.length === 0) return 100 return Object.entries(data).map(([date, val]) => {
return Math.max(100, ...chartData.value.map(d => d.value)) 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(() => { const maxModelTokens = computed(() => {
@ -250,54 +167,136 @@ const maxModelTokens = computed(() => {
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total)) return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
}) })
const chartPoints = computed(() => { function getAccentColor() {
const data = chartData.value return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
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 formatFullDate(dateStr) { function getTextColor(alpha = 1) {
const d = new Date(dateStr) const c = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
return `${d.getMonth() + 1}${d.getDate()}` 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() { async function loadStats() {
loading.value = true loading.value = true
try { try {
@ -312,11 +311,11 @@ async function loadStats() {
function changePeriod(p) { function changePeriod(p) {
period.value = p period.value = p
hoveredPoint.value = null
loadStats() loadStats()
} }
onMounted(loadStats) onMounted(loadStats)
onBeforeUnmount(destroyChart)
</script> </script>
<style scoped> <style scoped>
@ -324,8 +323,6 @@ onMounted(loadStats)
padding: 0; padding: 0;
} }
/* panel-header, panel-title, header-actions now in global.css */
.stats-loading { .stats-loading {
display: flex; display: flex;
align-items: center; align-items: center;
@ -430,99 +427,9 @@ onMounted(loadStats)
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 10px; border-radius: 10px;
padding: 12px 8px 8px 8px; padding: 10px;
position: relative; position: relative;
overflow: hidden; height: 180px;
}
.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;
} }
/* 模型分布 */ /* 模型分布 */