mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-14 05:15:18 +00:00
feat:实现统计图标
This commit is contained in:
parent
194f80b787
commit
e78488e131
5 changed files with 353 additions and 194 deletions
|
|
@ -87,6 +87,7 @@ class DailyStats(BaseModel):
|
|||
|
||||
date: str
|
||||
cost: float
|
||||
by_model: dict[str, float] = {} # 按模型分组的消费
|
||||
|
||||
|
||||
class ModelStats(BaseModel):
|
||||
|
|
@ -102,6 +103,7 @@ class StatsResponse(BaseModel):
|
|||
|
||||
daily: list[DailyStats]
|
||||
by_model: list[ModelStats]
|
||||
models: list[str] = [] # 所有模型列表(用于前端生成堆叠图系列)
|
||||
|
||||
|
||||
####################
|
||||
|
|
@ -189,41 +191,101 @@ async def get_logs(
|
|||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse)
|
||||
async def get_stats(user=Depends(get_verified_user), days: int = 7):
|
||||
async def get_stats(
|
||||
user=Depends(get_verified_user),
|
||||
days: int = 7,
|
||||
granularity: str = "day"
|
||||
):
|
||||
"""
|
||||
查询统计报表
|
||||
|
||||
按日统计和按模型统计
|
||||
Args:
|
||||
days: 查询天数
|
||||
granularity: 时间粒度 (hour/day/month)
|
||||
|
||||
需要登录
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
with get_db() as db:
|
||||
# cutoff: 纳秒级时间戳
|
||||
now = datetime.now()
|
||||
cutoff = int((time.time() - days * 86400) * 1000000000)
|
||||
|
||||
# 按日统计
|
||||
daily_query = (
|
||||
# 根据粒度选择分组方式和生成完整时间序列(包含当前时段)
|
||||
if granularity == "hour":
|
||||
trunc_unit = "hour"
|
||||
date_format = "%H:00"
|
||||
# 生成过去24小时的完整序列(包含当前小时)
|
||||
all_periods = []
|
||||
for i in range(23, -1, -1):
|
||||
dt = now - timedelta(hours=i)
|
||||
all_periods.append(dt.replace(minute=0, second=0, microsecond=0))
|
||||
elif granularity == "month":
|
||||
trunc_unit = "month"
|
||||
date_format = "%Y-%m"
|
||||
# 生成过去12个月的完整序列(包含当前月)
|
||||
all_periods = []
|
||||
for i in range(11, -1, -1):
|
||||
dt = now - relativedelta(months=i)
|
||||
all_periods.append(dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0))
|
||||
else:
|
||||
# 默认按天分组
|
||||
trunc_unit = "day"
|
||||
date_format = "%m-%d"
|
||||
# 生成过去N天的完整序列(包含今天)
|
||||
all_periods = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
dt = now - timedelta(days=i)
|
||||
all_periods.append(dt.replace(hour=0, minute=0, second=0, microsecond=0))
|
||||
|
||||
# 按时间+模型分组统计(用于堆叠图)
|
||||
daily_by_model_query = (
|
||||
db.query(
|
||||
func.date_trunc(
|
||||
"day",
|
||||
# created_at 是纳秒级,需要除以 1000000000 转换为秒级
|
||||
trunc_unit,
|
||||
func.to_timestamp(BillingLog.created_at / 1000000000)
|
||||
).label("date"),
|
||||
BillingLog.model_id,
|
||||
func.sum(BillingLog.total_cost).label("total"),
|
||||
)
|
||||
.filter(
|
||||
BillingLog.user_id == user.id,
|
||||
BillingLog.created_at >= cutoff,
|
||||
BillingLog.log_type == "deduct",
|
||||
BillingLog.log_type.in_(["deduct", "settle"]),
|
||||
)
|
||||
.group_by("date")
|
||||
.group_by("date", BillingLog.model_id)
|
||||
.order_by("date")
|
||||
.all()
|
||||
)
|
||||
|
||||
# 构建数据结构: {date_key: {model_id: cost, ...}, ...}
|
||||
data_dict: dict[str, dict[str, float]] = {}
|
||||
all_models: set[str] = set()
|
||||
for d in daily_by_model_query:
|
||||
if d[0] and d[1]:
|
||||
date_key = d[0].strftime(date_format)
|
||||
model_id = d[1]
|
||||
cost = d[2] / 10000 if d[2] else 0
|
||||
all_models.add(model_id)
|
||||
if date_key not in data_dict:
|
||||
data_dict[date_key] = {}
|
||||
data_dict[date_key][model_id] = cost
|
||||
|
||||
log.debug(f"统计查询: granularity={granularity}, days={days}, 记录数={len(daily_by_model_query)}, 模型数={len(all_models)}")
|
||||
|
||||
# 填充完整时间序列
|
||||
daily_stats = []
|
||||
for period in all_periods:
|
||||
key = period.strftime(date_format)
|
||||
by_model = data_dict.get(key, {})
|
||||
total_cost = sum(by_model.values())
|
||||
daily_stats.append(DailyStats(date=key, cost=total_cost, by_model=by_model))
|
||||
|
||||
log.debug(f"生成时间序列: 数量={len(daily_stats)}, 模型列表={list(all_models)}")
|
||||
|
||||
# 按模型统计
|
||||
by_model_query = (
|
||||
db.query(
|
||||
|
|
@ -234,23 +296,20 @@ async def get_stats(user=Depends(get_verified_user), days: int = 7):
|
|||
.filter(
|
||||
BillingLog.user_id == user.id,
|
||||
BillingLog.created_at >= cutoff,
|
||||
BillingLog.log_type == "deduct",
|
||||
BillingLog.log_type.in_(["deduct", "settle"]),
|
||||
)
|
||||
.group_by(BillingLog.model_id)
|
||||
.order_by(func.sum(BillingLog.total_cost).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# cost 单位转换:毫 → 元(除以 10000)
|
||||
return StatsResponse(
|
||||
daily=[
|
||||
DailyStats(date=str(d[0].date()), cost=d[1] / 10000 if d[1] else 0)
|
||||
for d in daily_query
|
||||
],
|
||||
daily=daily_stats,
|
||||
by_model=[
|
||||
ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2])
|
||||
for m in by_model_query
|
||||
],
|
||||
models=sorted(list(all_models)), # 按字母排序的模型列表
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"查询统计失败: {e}")
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ from open_webui.utils.misc import (
|
|||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
merge_consecutive_messages,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
|
|
@ -1183,6 +1184,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
ordered_messages = [summary_system_message, *ordered_messages]
|
||||
|
||||
if ordered_messages:
|
||||
# 合并连续的同角色消息,避免 LLM API 报错
|
||||
ordered_messages = merge_consecutive_messages(ordered_messages)
|
||||
form_data["messages"] = ordered_messages
|
||||
|
||||
# === 2. 处理 System Prompt 变量替换 ===
|
||||
|
|
|
|||
|
|
@ -155,6 +155,69 @@ def remove_system_message(messages: list[dict]) -> list[dict]:
|
|||
return [message for message in messages if message["role"] != "system"]
|
||||
|
||||
|
||||
def merge_consecutive_messages(messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
合并连续的同角色消息,避免 LLM API 报错
|
||||
|
||||
某些 LLM API(如 OpenAI)不允许连续的 assistant 或 user 消息。
|
||||
此函数将连续的同角色消息合并为一条,并过滤空内容的非 system 消息。
|
||||
|
||||
:param messages: 消息列表
|
||||
:return: 合并后的消息列表
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# 先过滤掉空内容的非 system 消息
|
||||
def is_valid_message(msg: dict) -> bool:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# system 消息保留
|
||||
if role == "system":
|
||||
return True
|
||||
|
||||
# 检查内容是否为空
|
||||
if isinstance(content, list):
|
||||
# 多模态消息:检查是否有非空内容
|
||||
return any(
|
||||
item.get("text", "").strip() or item.get("image_url")
|
||||
for item in content
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
else:
|
||||
# 文本消息:检查是否为空字符串
|
||||
return bool(str(content).strip())
|
||||
|
||||
filtered = [msg for msg in messages if is_valid_message(msg)]
|
||||
|
||||
# 合并连续的同角色消息
|
||||
merged = []
|
||||
for msg in filtered:
|
||||
if not merged:
|
||||
merged.append({**msg})
|
||||
continue
|
||||
|
||||
last = merged[-1]
|
||||
# 如果角色相同(且不是 system),合并内容
|
||||
if last.get("role") == msg.get("role") and last.get("role") != "system":
|
||||
# 获取内容
|
||||
last_content = last.get("content", "")
|
||||
msg_content = msg.get("content", "")
|
||||
|
||||
# 处理 content 为 list 的情况(多模态消息)
|
||||
if isinstance(last_content, list) or isinstance(msg_content, list):
|
||||
# 多模态消息不合并,保持原样
|
||||
merged.append({**msg})
|
||||
else:
|
||||
# 文本消息合并
|
||||
last["content"] = f"{last_content}\n\n{msg_content}".strip()
|
||||
else:
|
||||
merged.append({**msg})
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]:
|
||||
return get_system_message(messages), remove_system_message(messages)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface BillingLog {
|
|||
export interface DailyStats {
|
||||
date: string;
|
||||
cost: number;
|
||||
by_model: Record<string, number>; // 按模型分组的消费
|
||||
}
|
||||
|
||||
export interface ModelStats {
|
||||
|
|
@ -34,6 +35,7 @@ export interface ModelStats {
|
|||
export interface BillingStats {
|
||||
daily: DailyStats[];
|
||||
by_model: ModelStats[];
|
||||
models: string[]; // 所有模型列表(用于堆叠图)
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
|
|
@ -133,11 +135,12 @@ export const getBillingLogs = async (
|
|||
*/
|
||||
export const getBillingStats = async (
|
||||
token: string,
|
||||
days: number = 7
|
||||
days: number = 7,
|
||||
granularity: string = 'day'
|
||||
): Promise<BillingStats> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/billing/stats?days=${days}`, {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/billing/stats?days=${days}&granularity=${granularity}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -3,176 +3,215 @@
|
|||
import { billingStats } from '$lib/stores';
|
||||
import { getBillingStats } from '$lib/apis/billing';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let dailyChartContainer: HTMLDivElement;
|
||||
let modelChartContainer: HTMLDivElement;
|
||||
let dailyChart: echarts.ECharts | null = null;
|
||||
let modelChart: echarts.ECharts | null = null;
|
||||
let days = 7;
|
||||
let chartContainer: HTMLDivElement;
|
||||
let chart: any = null;
|
||||
let period = '7d'; // 24h, 7d, 30d, 12m
|
||||
let loading = false;
|
||||
let showLoading = false; // 延迟显示 loading
|
||||
let loadingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let echarts: any = null;
|
||||
|
||||
// 时间选项配置
|
||||
const periodOptions = [
|
||||
{ value: '24h', label: '过去24小时', days: 1, granularity: 'hour' },
|
||||
{ value: '7d', label: '过去7天', days: 7, granularity: 'day' },
|
||||
{ value: '30d', label: '过去1个月', days: 30, granularity: 'day' },
|
||||
{ value: '1y', label: '过去1年', days: 365, granularity: 'month' }
|
||||
];
|
||||
|
||||
$: currentPeriod = periodOptions.find((p) => p.value === period) || periodOptions[1];
|
||||
|
||||
const loadStats = async () => {
|
||||
loading = true;
|
||||
// 延迟 500ms 后才显示 loading,避免快速加载时闪烁
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (loading) {
|
||||
showLoading = true;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const stats = await getBillingStats(localStorage.token, days);
|
||||
const stats = await getBillingStats(localStorage.token, currentPeriod.days, currentPeriod.granularity);
|
||||
billingStats.set(stats);
|
||||
|
||||
// 等待 DOM 更新后初始化图表
|
||||
await tick();
|
||||
await initCharts();
|
||||
|
||||
// 渲染图表
|
||||
renderCharts();
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('查询统计失败: ') + error.message);
|
||||
} finally {
|
||||
loading = false;
|
||||
// 清除定时器并隐藏 loading
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer);
|
||||
loadingTimer = null;
|
||||
}
|
||||
showLoading = false;
|
||||
|
||||
// 等待 DOM 更新后渲染图表
|
||||
await tick();
|
||||
setTimeout(() => {
|
||||
if (!chart && chartContainer) {
|
||||
initChart();
|
||||
}
|
||||
renderChart();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!$billingStats || !dailyChart || !modelChart) return;
|
||||
// 模型颜色配置
|
||||
const MODEL_COLORS = [
|
||||
'#4F46E5', // indigo
|
||||
'#10B981', // emerald
|
||||
'#F59E0B', // amber
|
||||
'#EF4444', // red
|
||||
'#8B5CF6', // violet
|
||||
'#06B6D4', // cyan
|
||||
'#EC4899', // pink
|
||||
'#84CC16', // lime
|
||||
'#F97316', // orange
|
||||
'#6366F1', // indigo-light
|
||||
];
|
||||
|
||||
// 按日统计图表
|
||||
if (dailyChart && $billingStats.daily.length > 0) {
|
||||
dailyChart.setOption({
|
||||
title: {
|
||||
text: $i18n.t('每日消费趋势'),
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const data = params[0];
|
||||
return `${data.name}<br/>费用: ¥${data.value.toFixed(4)}`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: $billingStats.daily.map((d) => d.date),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '费用(元)',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: $billingStats.daily.map((d) => d.cost),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0.1)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#3b82f6',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
const getModelColor = (index: number) => MODEL_COLORS[index % MODEL_COLORS.length];
|
||||
|
||||
// 按模型统计图表
|
||||
if (modelChart && $billingStats.by_model.length > 0) {
|
||||
modelChart.setOption({
|
||||
title: {
|
||||
text: $i18n.t('模型消费分布'),
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal'
|
||||
const renderChart = () => {
|
||||
if (!$billingStats || !echarts || !chart) return;
|
||||
|
||||
const data = $billingStats.daily || [];
|
||||
const models = $billingStats.models || [];
|
||||
if (data.length === 0) return;
|
||||
|
||||
// 计算Y轴最大值,确保至少为1(当所有数据为0时)
|
||||
const maxCost = Math.max(...data.map((d: any) => d.cost || 0));
|
||||
const yAxisMax = maxCost > 0 ? undefined : 1;
|
||||
|
||||
// 为每个模型创建一个 series
|
||||
const series = models.length > 0
|
||||
? models.map((model: string, index: number) => ({
|
||||
name: model,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: '50%',
|
||||
data: data.map((d: any) => d.by_model?.[model] || 0),
|
||||
itemStyle: {
|
||||
color: getModelColor(index),
|
||||
// 只有最后一个(堆叠顶部)才有圆角
|
||||
borderRadius: index === models.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
return `${params.name}<br/>费用: ¥${params.value.toFixed(4)}<br/>占比: ${params.percent}%`;
|
||||
}))
|
||||
: [{
|
||||
name: '消费',
|
||||
type: 'bar',
|
||||
barWidth: '50%',
|
||||
data: data.map((d: any) => d.cost),
|
||||
itemStyle: {
|
||||
color: '#4F46E5',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
}];
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
const date = params[0].name;
|
||||
let total = 0;
|
||||
let details = params
|
||||
.filter((p: any) => p.value > 0)
|
||||
.map((p: any) => {
|
||||
total += p.value;
|
||||
return `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};margin-right:5px;"></span>${p.seriesName}: ¥${p.value.toFixed(4)}`;
|
||||
})
|
||||
.join('<br/>');
|
||||
return `<strong>${date}</strong><br/>${details || '无消费'}<br/><strong>合计: ¥${total.toFixed(4)}</strong>`;
|
||||
}
|
||||
},
|
||||
legend: models.length > 0 ? {
|
||||
data: models,
|
||||
bottom: 0,
|
||||
type: 'scroll',
|
||||
textStyle: { fontSize: 10, color: '#6b7280' }
|
||||
} : undefined,
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: models.length > 0 ? '18%' : '12%',
|
||||
top: '8%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d: any) => d.date),
|
||||
axisLabel: {
|
||||
rotate: data.length > 12 ? 45 : 0,
|
||||
fontSize: 11,
|
||||
color: '#6b7280'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle'
|
||||
axisLine: {
|
||||
lineStyle: { color: '#e5e7eb' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
data: $billingStats.by_model.map((m) => ({
|
||||
name: m.model,
|
||||
value: m.cost
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
formatter: '{b}: ¥{c}'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: yAxisMax,
|
||||
axisLabel: {
|
||||
formatter: (value: number) => `¥${value.toFixed(2)}`,
|
||||
fontSize: 11,
|
||||
color: '#6b7280'
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#f3f4f6', type: 'dashed' }
|
||||
},
|
||||
axisLine: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
series
|
||||
}, true); // true = 不合并,完全替换
|
||||
};
|
||||
|
||||
const initCharts = async () => {
|
||||
// 等待 DOM 更新
|
||||
await tick();
|
||||
const initChart = () => {
|
||||
if (!echarts) return;
|
||||
|
||||
// 确保 DOM 元素存在后再初始化
|
||||
if (dailyChartContainer && !dailyChart) {
|
||||
dailyChart = echarts.init(dailyChartContainer);
|
||||
}
|
||||
if (modelChartContainer && !modelChart) {
|
||||
modelChart = echarts.init(modelChartContainer);
|
||||
// 确保 DOM 元素存在且有尺寸后再初始化
|
||||
if (chartContainer && !chart) {
|
||||
const width = chartContainer.offsetWidth;
|
||||
const height = chartContainer.offsetHeight;
|
||||
if (width > 0 && height > 0) {
|
||||
chart = echarts.init(chartContainer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
// 动态导入 echarts(避免 SSR 问题)
|
||||
try {
|
||||
echarts = await import('echarts');
|
||||
} catch (e) {
|
||||
console.error('Failed to load echarts:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// loadStats 会处理图表初始化和渲染
|
||||
await loadStats();
|
||||
|
||||
// 响应式调整(等待图表初始化完成)
|
||||
await tick();
|
||||
// 响应式调整
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (dailyChartContainer && modelChartContainer && dailyChart && modelChart) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
dailyChart?.resize();
|
||||
modelChart?.resize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(dailyChartContainer);
|
||||
resizeObserver.observe(modelChartContainer);
|
||||
}
|
||||
// 延迟设置 ResizeObserver,确保图表已初始化
|
||||
setTimeout(() => {
|
||||
if (chartContainer) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
chart?.resize();
|
||||
});
|
||||
resizeObserver.observe(chartContainer);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
|
|
@ -180,56 +219,48 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
dailyChart?.dispose();
|
||||
modelChart?.dispose();
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer);
|
||||
loadingTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="billing-stats bg-white dark:bg-gray-850 rounded-xl shadow-sm p-4">
|
||||
<div class="stats-header flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold">{$i18n.t('消费统计')}</h2>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-inner">
|
||||
<!-- 标题和时间选择器 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">{$i18n.t('消费统计')}</p>
|
||||
<select
|
||||
bind:value={days}
|
||||
bind:value={period}
|
||||
on:change={loadStats}
|
||||
class="px-3 py-1.5 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-sm"
|
||||
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value={7}>{$i18n.t('最近7天')}</option>
|
||||
<option value={30}>{$i18n.t('最近30天')}</option>
|
||||
<option value={90}>{$i18n.t('最近90天')}</option>
|
||||
{#each periodOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
{:else if $billingStats}
|
||||
<div class="charts-container grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="chart-wrapper">
|
||||
<div bind:this={dailyChartContainer} class="chart h-80"></div>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<div bind:this={modelChartContainer} class="chart h-80"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $billingStats.daily.length === 0 && $billingStats.by_model.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<svg
|
||||
class="w-16 h-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">暂无统计数据</p>
|
||||
<div class="relative h-56">
|
||||
{#if showLoading}
|
||||
<div class="absolute inset-0 flex justify-center items-center bg-gray-50/80 dark:bg-gray-800/80 z-10">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !$billingStats || !$billingStats.daily || $billingStats.daily.length === 0}
|
||||
{#if !loading && !showLoading}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-10 h-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p class="text-xs">{$i18n.t('暂无消费记录')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div bind:this={chartContainer} class="w-full h-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue