mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-14 13:25:20 +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
|
date: str
|
||||||
cost: float
|
cost: float
|
||||||
|
by_model: dict[str, float] = {} # 按模型分组的消费
|
||||||
|
|
||||||
|
|
||||||
class ModelStats(BaseModel):
|
class ModelStats(BaseModel):
|
||||||
|
|
@ -102,6 +103,7 @@ class StatsResponse(BaseModel):
|
||||||
|
|
||||||
daily: list[DailyStats]
|
daily: list[DailyStats]
|
||||||
by_model: list[ModelStats]
|
by_model: list[ModelStats]
|
||||||
|
models: list[str] = [] # 所有模型列表(用于前端生成堆叠图系列)
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
|
|
@ -189,41 +191,101 @@ async def get_logs(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=StatsResponse)
|
@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:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
# cutoff: 纳秒级时间戳
|
now = datetime.now()
|
||||||
cutoff = int((time.time() - days * 86400) * 1000000000)
|
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(
|
db.query(
|
||||||
func.date_trunc(
|
func.date_trunc(
|
||||||
"day",
|
trunc_unit,
|
||||||
# created_at 是纳秒级,需要除以 1000000000 转换为秒级
|
|
||||||
func.to_timestamp(BillingLog.created_at / 1000000000)
|
func.to_timestamp(BillingLog.created_at / 1000000000)
|
||||||
).label("date"),
|
).label("date"),
|
||||||
|
BillingLog.model_id,
|
||||||
func.sum(BillingLog.total_cost).label("total"),
|
func.sum(BillingLog.total_cost).label("total"),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
BillingLog.user_id == user.id,
|
BillingLog.user_id == user.id,
|
||||||
BillingLog.created_at >= cutoff,
|
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")
|
.order_by("date")
|
||||||
.all()
|
.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 = (
|
by_model_query = (
|
||||||
db.query(
|
db.query(
|
||||||
|
|
@ -234,23 +296,20 @@ async def get_stats(user=Depends(get_verified_user), days: int = 7):
|
||||||
.filter(
|
.filter(
|
||||||
BillingLog.user_id == user.id,
|
BillingLog.user_id == user.id,
|
||||||
BillingLog.created_at >= cutoff,
|
BillingLog.created_at >= cutoff,
|
||||||
BillingLog.log_type == "deduct",
|
BillingLog.log_type.in_(["deduct", "settle"]),
|
||||||
)
|
)
|
||||||
.group_by(BillingLog.model_id)
|
.group_by(BillingLog.model_id)
|
||||||
.order_by(func.sum(BillingLog.total_cost).desc())
|
.order_by(func.sum(BillingLog.total_cost).desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# cost 单位转换:毫 → 元(除以 10000)
|
|
||||||
return StatsResponse(
|
return StatsResponse(
|
||||||
daily=[
|
daily=daily_stats,
|
||||||
DailyStats(date=str(d[0].date()), cost=d[1] / 10000 if d[1] else 0)
|
|
||||||
for d in daily_query
|
|
||||||
],
|
|
||||||
by_model=[
|
by_model=[
|
||||||
ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2])
|
ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2])
|
||||||
for m in by_model_query
|
for m in by_model_query
|
||||||
],
|
],
|
||||||
|
models=sorted(list(all_models)), # 按字母排序的模型列表
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"查询统计失败: {e}")
|
log.error(f"查询统计失败: {e}")
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ from open_webui.utils.misc import (
|
||||||
prepend_to_first_user_message_content,
|
prepend_to_first_user_message_content,
|
||||||
convert_logit_bias_input_to_json,
|
convert_logit_bias_input_to_json,
|
||||||
get_content_from_message,
|
get_content_from_message,
|
||||||
|
merge_consecutive_messages,
|
||||||
)
|
)
|
||||||
from open_webui.utils.tools import get_tools
|
from open_webui.utils.tools import get_tools
|
||||||
from open_webui.utils.plugin import load_function_module_by_id
|
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]
|
ordered_messages = [summary_system_message, *ordered_messages]
|
||||||
|
|
||||||
if ordered_messages:
|
if ordered_messages:
|
||||||
|
# 合并连续的同角色消息,避免 LLM API 报错
|
||||||
|
ordered_messages = merge_consecutive_messages(ordered_messages)
|
||||||
form_data["messages"] = ordered_messages
|
form_data["messages"] = ordered_messages
|
||||||
|
|
||||||
# === 2. 处理 System Prompt 变量替换 ===
|
# === 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"]
|
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]]:
|
def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]:
|
||||||
return get_system_message(messages), remove_system_message(messages)
|
return get_system_message(messages), remove_system_message(messages)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface BillingLog {
|
||||||
export interface DailyStats {
|
export interface DailyStats {
|
||||||
date: string;
|
date: string;
|
||||||
cost: number;
|
cost: number;
|
||||||
|
by_model: Record<string, number>; // 按模型分组的消费
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelStats {
|
export interface ModelStats {
|
||||||
|
|
@ -34,6 +35,7 @@ export interface ModelStats {
|
||||||
export interface BillingStats {
|
export interface BillingStats {
|
||||||
daily: DailyStats[];
|
daily: DailyStats[];
|
||||||
by_model: ModelStats[];
|
by_model: ModelStats[];
|
||||||
|
models: string[]; // 所有模型列表(用于堆叠图)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelPricing {
|
export interface ModelPricing {
|
||||||
|
|
@ -133,11 +135,12 @@ export const getBillingLogs = async (
|
||||||
*/
|
*/
|
||||||
export const getBillingStats = async (
|
export const getBillingStats = async (
|
||||||
token: string,
|
token: string,
|
||||||
days: number = 7
|
days: number = 7,
|
||||||
|
granularity: string = 'day'
|
||||||
): Promise<BillingStats> => {
|
): Promise<BillingStats> => {
|
||||||
let error = null;
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -3,176 +3,215 @@
|
||||||
import { billingStats } from '$lib/stores';
|
import { billingStats } from '$lib/stores';
|
||||||
import { getBillingStats } from '$lib/apis/billing';
|
import { getBillingStats } from '$lib/apis/billing';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
let dailyChartContainer: HTMLDivElement;
|
let chartContainer: HTMLDivElement;
|
||||||
let modelChartContainer: HTMLDivElement;
|
let chart: any = null;
|
||||||
let dailyChart: echarts.ECharts | null = null;
|
let period = '7d'; // 24h, 7d, 30d, 12m
|
||||||
let modelChart: echarts.ECharts | null = null;
|
|
||||||
let days = 7;
|
|
||||||
let loading = false;
|
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 () => {
|
const loadStats = async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
// 延迟 500ms 后才显示 loading,避免快速加载时闪烁
|
||||||
|
loadingTimer = setTimeout(() => {
|
||||||
|
if (loading) {
|
||||||
|
showLoading = true;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await getBillingStats(localStorage.token, days);
|
const stats = await getBillingStats(localStorage.token, currentPeriod.days, currentPeriod.granularity);
|
||||||
billingStats.set(stats);
|
billingStats.set(stats);
|
||||||
|
|
||||||
// 等待 DOM 更新后初始化图表
|
|
||||||
await tick();
|
|
||||||
await initCharts();
|
|
||||||
|
|
||||||
// 渲染图表
|
|
||||||
renderCharts();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error($i18n.t('查询统计失败: ') + error.message);
|
toast.error($i18n.t('查询统计失败: ') + error.message);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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
|
||||||
|
];
|
||||||
|
|
||||||
// 按日统计图表
|
const getModelColor = (index: number) => MODEL_COLORS[index % MODEL_COLORS.length];
|
||||||
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 renderChart = () => {
|
||||||
if (modelChart && $billingStats.by_model.length > 0) {
|
if (!$billingStats || !echarts || !chart) return;
|
||||||
modelChart.setOption({
|
|
||||||
title: {
|
const data = $billingStats.daily || [];
|
||||||
text: $i18n.t('模型消费分布'),
|
const models = $billingStats.models || [];
|
||||||
left: 'center',
|
if (data.length === 0) return;
|
||||||
textStyle: {
|
|
||||||
fontSize: 16,
|
// 计算Y轴最大值,确保至少为1(当所有数据为0时)
|
||||||
fontWeight: 'normal'
|
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',
|
name: '消费',
|
||||||
formatter: (params: any) => {
|
type: 'bar',
|
||||||
return `${params.name}<br/>费用: ¥${params.value.toFixed(4)}<br/>占比: ${params.percent}%`;
|
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: {
|
axisLine: {
|
||||||
orient: 'vertical',
|
lineStyle: { color: '#e5e7eb' }
|
||||||
left: 'left',
|
|
||||||
top: 'middle'
|
|
||||||
},
|
},
|
||||||
series: [
|
axisTick: {
|
||||||
{
|
show: false
|
||||||
type: 'pie',
|
}
|
||||||
radius: ['40%', '70%'],
|
},
|
||||||
center: ['60%', '50%'],
|
yAxis: {
|
||||||
data: $billingStats.by_model.map((m) => ({
|
type: 'value',
|
||||||
name: m.model,
|
min: 0,
|
||||||
value: m.cost
|
max: yAxisMax,
|
||||||
})),
|
axisLabel: {
|
||||||
emphasis: {
|
formatter: (value: number) => `¥${value.toFixed(2)}`,
|
||||||
itemStyle: {
|
fontSize: 11,
|
||||||
shadowBlur: 10,
|
color: '#6b7280'
|
||||||
shadowOffsetX: 0,
|
},
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
splitLine: {
|
||||||
}
|
lineStyle: { color: '#f3f4f6', type: 'dashed' }
|
||||||
},
|
},
|
||||||
label: {
|
axisLine: {
|
||||||
formatter: '{b}: ¥{c}'
|
show: false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
]
|
series
|
||||||
});
|
}, true); // true = 不合并,完全替换
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initCharts = async () => {
|
const initChart = () => {
|
||||||
// 等待 DOM 更新
|
if (!echarts) return;
|
||||||
await tick();
|
|
||||||
|
|
||||||
// 确保 DOM 元素存在后再初始化
|
// 确保 DOM 元素存在且有尺寸后再初始化
|
||||||
if (dailyChartContainer && !dailyChart) {
|
if (chartContainer && !chart) {
|
||||||
dailyChart = echarts.init(dailyChartContainer);
|
const width = chartContainer.offsetWidth;
|
||||||
}
|
const height = chartContainer.offsetHeight;
|
||||||
if (modelChartContainer && !modelChart) {
|
if (width > 0 && height > 0) {
|
||||||
modelChart = echarts.init(modelChartContainer);
|
chart = echarts.init(chartContainer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// 动态导入 echarts(避免 SSR 问题)
|
||||||
|
try {
|
||||||
|
echarts = await import('echarts');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load echarts:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// loadStats 会处理图表初始化和渲染
|
// loadStats 会处理图表初始化和渲染
|
||||||
await loadStats();
|
await loadStats();
|
||||||
|
|
||||||
// 响应式调整(等待图表初始化完成)
|
// 响应式调整
|
||||||
await tick();
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
if (dailyChartContainer && modelChartContainer && dailyChart && modelChart) {
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
dailyChart?.resize();
|
|
||||||
modelChart?.resize();
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(dailyChartContainer);
|
// 延迟设置 ResizeObserver,确保图表已初始化
|
||||||
resizeObserver.observe(modelChartContainer);
|
setTimeout(() => {
|
||||||
}
|
if (chartContainer) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
chart?.resize();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(chartContainer);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
|
|
@ -180,56 +219,48 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
dailyChart?.dispose();
|
chart?.dispose();
|
||||||
modelChart?.dispose();
|
chart = null;
|
||||||
|
if (loadingTimer) {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
loadingTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="billing-stats bg-white dark:bg-gray-850 rounded-xl shadow-sm p-4">
|
<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="stats-header flex justify-between items-center mb-4">
|
<!-- 标题和时间选择器 -->
|
||||||
<h2 class="text-lg font-semibold">{$i18n.t('消费统计')}</h2>
|
<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
|
<select
|
||||||
bind:value={days}
|
bind:value={period}
|
||||||
on:change={loadStats}
|
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>
|
{#each periodOptions as opt}
|
||||||
<option value={30}>{$i18n.t('最近30天')}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
<option value={90}>{$i18n.t('最近90天')}</option>
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
<div class="relative h-56">
|
||||||
<div class="flex justify-center items-center py-12">
|
{#if showLoading}
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
<div class="absolute inset-0 flex justify-center items-center bg-gray-50/80 dark:bg-gray-800/80 z-10">
|
||||||
</div>
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500"></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>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue