feat: 增加token 使用统计
This commit is contained in:
parent
8927681b6e
commit
87e56ed1c3
|
|
@ -26,7 +26,7 @@ def create_app():
|
|||
|
||||
db.init_app(app)
|
||||
|
||||
from .models import User, Conversation, Message
|
||||
from .models import User, Conversation, Message, TokenUsage
|
||||
from .routes import register_routes
|
||||
register_routes(app)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,3 +44,20 @@ class Message(db.Model):
|
|||
token_count = db.Column(db.Integer, default=0)
|
||||
thinking_content = db.Column(db.Text, default="")
|
||||
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 flask import request, jsonify, Response, Blueprint, current_app
|
||||
from . import db
|
||||
from .models import Conversation, Message, User
|
||||
from .models import Conversation, Message, User, TokenUsage
|
||||
from . import load_config
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
|
@ -50,6 +50,30 @@ def to_dict(inst, **extra):
|
|||
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):
|
||||
msgs = []
|
||||
if conv.system_prompt:
|
||||
|
|
@ -67,6 +91,102 @@ def list_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 ------------------------------------
|
||||
|
||||
@bp.route("/api/conversations", methods=["GET", "POST"])
|
||||
|
|
@ -209,31 +329,40 @@ def _sync_response(conv):
|
|||
|
||||
choice = result["choices"][0]
|
||||
usage = result.get("usage", {})
|
||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
completion_tokens = usage.get("completion_tokens", 0)
|
||||
|
||||
msg = Message(
|
||||
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
|
||||
content=choice["message"]["content"],
|
||||
token_count=usage.get("completion_tokens", 0),
|
||||
token_count=completion_tokens,
|
||||
thinking_content=choice["message"].get("reasoning_content", ""),
|
||||
)
|
||||
db.session.add(msg)
|
||||
db.session.commit()
|
||||
|
||||
# 记录 token 使用
|
||||
user = get_or_create_default_user()
|
||||
record_token_usage(user.id, conv.model, prompt_tokens, completion_tokens)
|
||||
|
||||
return ok({
|
||||
"message": to_dict(msg, thinking_content=msg.thinking_content or None),
|
||||
"usage": {"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"usage": {"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": usage.get("total_tokens", 0)},
|
||||
})
|
||||
|
||||
|
||||
def _stream_response(conv):
|
||||
conv_id = conv.id
|
||||
conv_model = conv.model
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def generate():
|
||||
full_content = ""
|
||||
full_thinking = ""
|
||||
token_count = 0
|
||||
prompt_tokens = 0
|
||||
msg_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
|
|
@ -267,6 +396,7 @@ def _stream_response(conv):
|
|||
usage = chunk.get("usage", {})
|
||||
if usage:
|
||||
token_count = usage.get("completion_tokens", 0)
|
||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
except Exception as e:
|
||||
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
|
@ -280,6 +410,10 @@ def _stream_response(conv):
|
|||
db.session.add(msg)
|
||||
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"
|
||||
|
||||
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 = {
|
||||
list(cursor, limit = 20) {
|
||||
const params = new URLSearchParams()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@
|
|||
<button class="btn-cancel" @click="$emit('close')">取消</button>
|
||||
<button class="btn-save" @click="save">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-stats">
|
||||
<StatsPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
@ -110,6 +114,7 @@
|
|||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { modelApi } from '../api'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import StatsPanel from './StatsPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
|
|
@ -392,6 +397,12 @@ function save() {
|
|||
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-leave-active {
|
||||
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