feat: 增加token 使用统计
This commit is contained in:
parent
8927681b6e
commit
87e56ed1c3
|
|
@ -26,7 +26,7 @@ def create_app():
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|
||||||
from .models import User, Conversation, Message
|
from .models import User, Conversation, Message, TokenUsage
|
||||||
from .routes import register_routes
|
from .routes import register_routes
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,20 @@ class Message(db.Model):
|
||||||
token_count = db.Column(db.Integer, default=0)
|
token_count = db.Column(db.Integer, default=0)
|
||||||
thinking_content = db.Column(db.Text, default="")
|
thinking_content = db.Column(db.Text, default="")
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsage(db.Model):
|
||||||
|
__tablename__ = "token_usage"
|
||||||
|
|
||||||
|
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False)
|
||||||
|
date = db.Column(db.Date, nullable=False) # 使用日期
|
||||||
|
model = db.Column(db.String(64), nullable=False) # 模型名称
|
||||||
|
prompt_tokens = db.Column(db.Integer, default=0)
|
||||||
|
completion_tokens = db.Column(db.Integer, default=0)
|
||||||
|
total_tokens = db.Column(db.Integer, default=0)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("user_id", "date", "model", name="uq_user_date_model"),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import request, jsonify, Response, Blueprint, current_app
|
from flask import request, jsonify, Response, Blueprint, current_app
|
||||||
from . import db
|
from . import db
|
||||||
from .models import Conversation, Message, User
|
from .models import Conversation, Message, User, TokenUsage
|
||||||
from . import load_config
|
from . import load_config
|
||||||
|
|
||||||
bp = Blueprint("api", __name__)
|
bp = Blueprint("api", __name__)
|
||||||
|
|
@ -50,6 +50,30 @@ def to_dict(inst, **extra):
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
|
||||||
|
"""记录 token 使用量"""
|
||||||
|
from datetime import date
|
||||||
|
today = date.today()
|
||||||
|
usage = TokenUsage.query.filter_by(
|
||||||
|
user_id=user_id, date=today, model=model
|
||||||
|
).first()
|
||||||
|
if usage:
|
||||||
|
usage.prompt_tokens += prompt_tokens
|
||||||
|
usage.completion_tokens += completion_tokens
|
||||||
|
usage.total_tokens += prompt_tokens + completion_tokens
|
||||||
|
else:
|
||||||
|
usage = TokenUsage(
|
||||||
|
user_id=user_id,
|
||||||
|
date=today,
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=prompt_tokens + completion_tokens,
|
||||||
|
)
|
||||||
|
db.session.add(usage)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def build_glm_messages(conv):
|
def build_glm_messages(conv):
|
||||||
msgs = []
|
msgs = []
|
||||||
if conv.system_prompt:
|
if conv.system_prompt:
|
||||||
|
|
@ -67,6 +91,102 @@ def list_models():
|
||||||
return ok(MODELS)
|
return ok(MODELS)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Token Usage Statistics --------------------------------
|
||||||
|
|
||||||
|
@bp.route("/api/stats/tokens", methods=["GET"])
|
||||||
|
def token_stats():
|
||||||
|
"""获取 token 使用统计"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
user = get_or_create_default_user()
|
||||||
|
period = request.args.get("period", "daily") # daily, weekly, monthly
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if period == "daily":
|
||||||
|
# 今日统计
|
||||||
|
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
|
||||||
|
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),
|
||||||
|
"by_model": {s.model: {"prompt": s.prompt_tokens, "completion": s.completion_tokens, "total": s.total_tokens} for s in stats}
|
||||||
|
}
|
||||||
|
elif period == "weekly":
|
||||||
|
# 本周统计 (最近7天)
|
||||||
|
start_date = today - timedelta(days=6)
|
||||||
|
stats = TokenUsage.query.filter(
|
||||||
|
TokenUsage.user_id == user.id,
|
||||||
|
TokenUsage.date >= start_date,
|
||||||
|
TokenUsage.date <= today
|
||||||
|
).all()
|
||||||
|
|
||||||
|
daily_data = {}
|
||||||
|
for s in stats:
|
||||||
|
d = s.date.isoformat()
|
||||||
|
if d not in daily_data:
|
||||||
|
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
|
||||||
|
daily_data[d]["prompt"] += s.prompt_tokens
|
||||||
|
daily_data[d]["completion"] += s.completion_tokens
|
||||||
|
daily_data[d]["total"] += s.total_tokens
|
||||||
|
|
||||||
|
# 填充没有数据的日期
|
||||||
|
for i in range(7):
|
||||||
|
d = (today - timedelta(days=6-i)).isoformat()
|
||||||
|
if d not in daily_data:
|
||||||
|
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"period": "weekly",
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"end_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),
|
||||||
|
"daily": daily_data
|
||||||
|
}
|
||||||
|
elif period == "monthly":
|
||||||
|
# 本月统计 (最近30天)
|
||||||
|
start_date = today - timedelta(days=29)
|
||||||
|
stats = TokenUsage.query.filter(
|
||||||
|
TokenUsage.user_id == user.id,
|
||||||
|
TokenUsage.date >= start_date,
|
||||||
|
TokenUsage.date <= today
|
||||||
|
).all()
|
||||||
|
|
||||||
|
daily_data = {}
|
||||||
|
for s in stats:
|
||||||
|
d = s.date.isoformat()
|
||||||
|
if d not in daily_data:
|
||||||
|
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
|
||||||
|
daily_data[d]["prompt"] += s.prompt_tokens
|
||||||
|
daily_data[d]["completion"] += s.completion_tokens
|
||||||
|
daily_data[d]["total"] += s.total_tokens
|
||||||
|
|
||||||
|
# 填充没有数据的日期
|
||||||
|
for i in range(30):
|
||||||
|
d = (today - timedelta(days=29-i)).isoformat()
|
||||||
|
if d not in daily_data:
|
||||||
|
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"period": "monthly",
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"end_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),
|
||||||
|
"daily": daily_data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return err(400, "invalid period")
|
||||||
|
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
# -- Conversation CRUD ------------------------------------
|
# -- Conversation CRUD ------------------------------------
|
||||||
|
|
||||||
@bp.route("/api/conversations", methods=["GET", "POST"])
|
@bp.route("/api/conversations", methods=["GET", "POST"])
|
||||||
|
|
@ -209,31 +329,40 @@ def _sync_response(conv):
|
||||||
|
|
||||||
choice = result["choices"][0]
|
choice = result["choices"][0]
|
||||||
usage = result.get("usage", {})
|
usage = result.get("usage", {})
|
||||||
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||||
|
completion_tokens = usage.get("completion_tokens", 0)
|
||||||
|
|
||||||
msg = Message(
|
msg = Message(
|
||||||
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
|
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
|
||||||
content=choice["message"]["content"],
|
content=choice["message"]["content"],
|
||||||
token_count=usage.get("completion_tokens", 0),
|
token_count=completion_tokens,
|
||||||
thinking_content=choice["message"].get("reasoning_content", ""),
|
thinking_content=choice["message"].get("reasoning_content", ""),
|
||||||
)
|
)
|
||||||
db.session.add(msg)
|
db.session.add(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# 记录 token 使用
|
||||||
|
user = get_or_create_default_user()
|
||||||
|
record_token_usage(user.id, conv.model, prompt_tokens, completion_tokens)
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"message": to_dict(msg, thinking_content=msg.thinking_content or None),
|
"message": to_dict(msg, thinking_content=msg.thinking_content or None),
|
||||||
"usage": {"prompt_tokens": usage.get("prompt_tokens", 0),
|
"usage": {"prompt_tokens": prompt_tokens,
|
||||||
"completion_tokens": usage.get("completion_tokens", 0),
|
"completion_tokens": completion_tokens,
|
||||||
"total_tokens": usage.get("total_tokens", 0)},
|
"total_tokens": usage.get("total_tokens", 0)},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _stream_response(conv):
|
def _stream_response(conv):
|
||||||
conv_id = conv.id
|
conv_id = conv.id
|
||||||
|
conv_model = conv.model
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_thinking = ""
|
full_thinking = ""
|
||||||
token_count = 0
|
token_count = 0
|
||||||
|
prompt_tokens = 0
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -267,6 +396,7 @@ def _stream_response(conv):
|
||||||
usage = chunk.get("usage", {})
|
usage = chunk.get("usage", {})
|
||||||
if usage:
|
if usage:
|
||||||
token_count = usage.get("completion_tokens", 0)
|
token_count = usage.get("completion_tokens", 0)
|
||||||
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
@ -280,6 +410,10 @@ def _stream_response(conv):
|
||||||
db.session.add(msg)
|
db.session.add(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# 记录 token 使用
|
||||||
|
user = get_or_create_default_user()
|
||||||
|
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
||||||
|
|
||||||
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count})}\n\n"
|
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count})}\n\n"
|
||||||
|
|
||||||
return Response(generate(), mimetype="text/event-stream",
|
return Response(generate(), mimetype="text/event-stream",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ export const modelApi = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const statsApi = {
|
||||||
|
getTokens(period = 'daily') {
|
||||||
|
return request(`/stats/tokens?period=${period}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const conversationApi = {
|
export const conversationApi = {
|
||||||
list(cursor, limit = 20) {
|
list(cursor, limit = 20) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@
|
||||||
<button class="btn-cancel" @click="$emit('close')">取消</button>
|
<button class="btn-cancel" @click="$emit('close')">取消</button>
|
||||||
<button class="btn-save" @click="save">保存</button>
|
<button class="btn-save" @click="save">保存</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stats">
|
||||||
|
<StatsPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
@ -110,6 +114,7 @@
|
||||||
import { reactive, ref, watch, onMounted } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { modelApi } from '../api'
|
import { modelApi } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import StatsPanel from './StatsPanel.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
|
|
@ -392,6 +397,12 @@ function save() {
|
||||||
background: var(--accent-primary-hover);
|
background: var(--accent-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-stats {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,411 @@
|
||||||
|
<template>
|
||||||
|
<div class="stats-panel">
|
||||||
|
<div class="stats-header">
|
||||||
|
<h4>Token 使用统计</h4>
|
||||||
|
<div class="period-tabs">
|
||||||
|
<button
|
||||||
|
v-for="p in periods"
|
||||||
|
:key="p.value"
|
||||||
|
:class="['tab', { active: period === p.value }]"
|
||||||
|
@click="changePeriod(p.value)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="stats-loading">加载中...</div>
|
||||||
|
|
||||||
|
<template v-else-if="stats">
|
||||||
|
<div class="stats-summary">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">输入 Token</div>
|
||||||
|
<div class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">输出 Token</div>
|
||||||
|
<div class="stat-value">{{ formatNumber(stats.completion_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card highlight">
|
||||||
|
<div class="stat-label">总计</div>
|
||||||
|
<div class="stat-value">{{ formatNumber(stats.total_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="period !== 'daily' && stats.daily" class="stats-chart">
|
||||||
|
<div class="chart-title">每日使用趋势</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
|
||||||
|
<!-- 网格线 -->
|
||||||
|
<g class="grid-lines">
|
||||||
|
<line
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="'grid-' + i"
|
||||||
|
:x1="padding"
|
||||||
|
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
|
||||||
|
:x2="chartWidth - padding"
|
||||||
|
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
|
||||||
|
stroke="var(--border-light)"
|
||||||
|
stroke-dasharray="4,4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 填充区域 -->
|
||||||
|
<path
|
||||||
|
:d="areaPath"
|
||||||
|
fill="url(#gradient)"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 折线 -->
|
||||||
|
<path
|
||||||
|
:d="linePath"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent-primary)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 数据点 -->
|
||||||
|
<g class="data-points">
|
||||||
|
<circle
|
||||||
|
v-for="(point, idx) in chartPoints"
|
||||||
|
:key="idx"
|
||||||
|
:cx="point.x"
|
||||||
|
:cy="point.y"
|
||||||
|
r="4"
|
||||||
|
fill="var(--accent-primary)"
|
||||||
|
stroke="var(--bg-primary)"
|
||||||
|
stroke-width="2"
|
||||||
|
class="data-point"
|
||||||
|
@mouseenter="hoveredPoint = idx"
|
||||||
|
@mouseleave="hoveredPoint = null"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 渐变定义 -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="var(--accent-primary)" />
|
||||||
|
<stop offset="100%" stop-color="var(--accent-primary)" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- X轴标签 -->
|
||||||
|
<div class="x-labels">
|
||||||
|
<span
|
||||||
|
v-for="(data, date) in sortedDaily"
|
||||||
|
:key="date"
|
||||||
|
class="x-label"
|
||||||
|
>
|
||||||
|
{{ formatDateLabel(date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮提示 -->
|
||||||
|
<div
|
||||||
|
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
||||||
|
class="tooltip"
|
||||||
|
:style="{ left: chartPoints[hoveredPoint].x + 'px', top: (chartPoints[hoveredPoint].y - 40) + 'px' }"
|
||||||
|
>
|
||||||
|
<div class="tooltip-date">{{ chartPoints[hoveredPoint].date }}</div>
|
||||||
|
<div class="tooltip-value">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="period === 'daily' && stats.by_model" class="stats-by-model">
|
||||||
|
<div class="model-title">按模型分布</div>
|
||||||
|
<div v-for="(data, model) in stats.by_model" :key="model" class="model-row">
|
||||||
|
<span class="model-name">{{ model }}</span>
|
||||||
|
<span class="model-value">{{ formatNumber(data.total) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { statsApi } from '../api'
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
{ value: 'daily', label: '今日' },
|
||||||
|
{ value: 'weekly', label: '本周' },
|
||||||
|
{ value: 'monthly', label: '本月' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const period = ref('daily')
|
||||||
|
const stats = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hoveredPoint = ref(null)
|
||||||
|
|
||||||
|
const chartWidth = 320
|
||||||
|
const chartHeight = 160
|
||||||
|
const padding = 20
|
||||||
|
|
||||||
|
const sortedDaily = computed(() => {
|
||||||
|
if (!stats.value?.daily) return {}
|
||||||
|
const entries = Object.entries(stats.value.daily)
|
||||||
|
entries.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const data = sortedDaily.value
|
||||||
|
return Object.entries(data).map(([date, val]) => ({
|
||||||
|
date,
|
||||||
|
value: val.total,
|
||||||
|
prompt: val.prompt,
|
||||||
|
completion: val.completion,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxValue = computed(() => {
|
||||||
|
if (chartData.value.length === 0) return 100
|
||||||
|
return Math.max(100, ...chartData.value.map(d => d.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
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: padding + (i / Math.max(1, data.length - 1)) * xRange,
|
||||||
|
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
|
||||||
|
date: d.date,
|
||||||
|
value: d.value,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
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 xRange = chartWidth - 2 * padding
|
||||||
|
const startX = padding
|
||||||
|
const endX = chartWidth - padding
|
||||||
|
const baseY = chartHeight - padding
|
||||||
|
|
||||||
|
let path = `M ${startX} ${baseY} `
|
||||||
|
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
||||||
|
path += ` L ${endX} ${baseY} Z`
|
||||||
|
|
||||||
|
return path
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num) {
|
||||||
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||||
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await statsApi.getTokens(period.value)
|
||||||
|
stats.value = res.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load stats:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePeriod(p) {
|
||||||
|
period.value = p
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadStats)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stats-panel {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.highlight {
|
||||||
|
background: var(--accent-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.highlight .stat-value {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-chart {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title,
|
||||||
|
.model-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-point {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: r 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-point:hover {
|
||||||
|
r: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-date {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-by-model {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue