feat:实现统计图标

This commit is contained in:
sylarchen1389 2025-12-07 22:58:16 +08:00
parent 194f80b787
commit e78488e131
5 changed files with 353 additions and 194 deletions

View file

@ -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}")

View file

@ -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 变量替换 ===

View file

@ -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)

View file

@ -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',

View file

@ -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'
const getModelColor = (index: number) => MODEL_COLORS[index % MODEL_COLORS.length];
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]
}
},
}))
: [{
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) => {
const data = params[0];
return `${data.name}<br/>费用: ¥${data.value.toFixed(4)}`;
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: '3%',
bottom: models.length > 0 ? '18%' : '12%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: $billingStats.daily.map((d) => d.date),
data: data.map((d: any) => d.date),
axisLabel: {
rotate: 45
rotate: data.length > 12 ? 45 : 0,
fontSize: 11,
color: '#6b7280'
},
axisLine: {
lineStyle: { color: '#e5e7eb' }
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
name: '费用(元)',
min: 0,
max: yAxisMax,
axisLabel: {
formatter: '¥{value}'
formatter: (value: number) => `¥${value.toFixed(2)}`,
fontSize: 11,
color: '#6b7280'
},
splitLine: {
lineStyle: { color: '#f3f4f6', type: 'dashed' }
},
axisLine: {
show: false
}
},
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'
}
}
]
});
}
// 按模型统计图表
if (modelChart && $billingStats.by_model.length > 0) {
modelChart.setOption({
title: {
text: $i18n.t('模型消费分布'),
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.name}<br/>费用: ¥${params.value.toFixed(4)}<br/>占比: ${params.percent}%`;
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle'
},
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}'
}
}
]
});
}
series
}, true); // true = 不合并,完全替换
};
const initCharts = async () => {
// 等待 DOM 更新
await tick();
const initChart = () => {
if (!echarts) return;
// 确保 DOM 元素存在后再初始化
if (dailyChartContainer && !dailyChart) {
dailyChart = echarts.init(dailyChartContainer);
// 确保 DOM 元素存在且有尺寸后再初始化
if (chartContainer && !chart) {
const width = chartContainer.offsetWidth;
const height = chartContainer.offsetHeight;
if (width > 0 && height > 0) {
chart = echarts.init(chartContainer);
}
if (modelChartContainer && !modelChart) {
modelChart = echarts.init(modelChartContainer);
}
};
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 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 $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"
/>
{#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-sm">暂无统计数据</p>
<p class="text-xs">{$i18n.t('暂无消费记录')}</p>
</div>
{/if}
{/if}
<div bind:this={chartContainer} class="w-full h-full"></div>
</div>
</div>