feat: add trend indicators and color accents to stat cards

- Add get_usage_trend() and get_amount_trend() functions for day-over-day comparison
- Display trend arrows (up/down/flat) with percentage change in stat_today_usage and stat_today_amount
- Add colored left borders to all stat cards for visual distinction:
  - Blue: usage, total users
  - Purple: amount, total orgs
  - Green: active users, new users this month
  - Cyan: concurrent users
  - Red: errors
- Enhance visual hierarchy with accent colors matching SVG icons
This commit is contained in:
yumoqing 2026-06-01 15:49:46 +08:00
parent 855f376671
commit 9cc59160e8
9 changed files with 182 additions and 27 deletions

View File

@ -26,6 +26,70 @@ async def get_today_amount(request):
return amount
async def get_usage_trend(request):
"""计算今日调用量趋势(与昨日对比)"""
env = request._run_ns
today = env.curDateString()
yesterday = (date.today() - timedelta(days=1)).isoformat()
async with get_sor_context(env, 'sage') as sor:
# 今日
sql_today = "SELECT COUNT(*) as cnt FROM llmusage WHERE use_date = ${today}$"
recs_today = await sor.sqlExe(sql_today, {'today': today})
today_cnt = int(recs_today[0].get('cnt', 0)) if recs_today else 0
# 昨日
sql_yesterday = "SELECT COUNT(*) as cnt FROM llmusage WHERE use_date = ${yesterday}$"
recs_yesterday = await sor.sqlExe(sql_yesterday, {'yesterday': yesterday})
yesterday_cnt = int(recs_yesterday[0].get('cnt', 0)) if recs_yesterday else 0
# 计算趋势
if yesterday_cnt == 0:
return {'trend': 'flat', 'percentage': 0, 'value': today_cnt}
change_pct = ((today_cnt - yesterday_cnt) / yesterday_cnt) * 100
if change_pct > 5:
trend = 'up'
elif change_pct < -5:
trend = 'down'
else:
trend = 'flat'
return {'trend': trend, 'percentage': abs(change_pct), 'value': today_cnt}
async def get_amount_trend(request):
"""计算今日金额趋势(与昨日对比)"""
env = request._run_ns
today = env.curDateString()
yesterday = (date.today() - timedelta(days=1)).isoformat()
async with get_sor_context(env, 'sage') as sor:
# 今日
sql_today = "SELECT COALESCE(SUM(amount), 0) as total_amount FROM llmusage WHERE use_date = ${today}$"
recs_today = await sor.sqlExe(sql_today, {'today': today})
today_amount = float(recs_today[0].get('total_amount', 0)) if recs_today else 0.0
# 昨日
sql_yesterday = "SELECT COALESCE(SUM(amount), 0) as total_amount FROM llmusage WHERE use_date = ${yesterday}$"
recs_yesterday = await sor.sqlExe(sql_yesterday, {'yesterday': yesterday})
yesterday_amount = float(recs_yesterday[0].get('total_amount', 0)) if recs_yesterday else 0.0
# 计算趋势
if yesterday_amount == 0:
return {'trend': 'flat', 'percentage': 0, 'value': today_amount}
change_pct = ((today_amount - yesterday_amount) / yesterday_amount) * 100
if change_pct > 5:
trend = 'up'
elif change_pct < -5:
trend = 'down'
else:
trend = 'flat'
return {'trend': trend, 'percentage': abs(change_pct), 'value': today_amount}
async def get_total_users(request):
"""获取用户总数"""
env = request._run_ns
@ -526,6 +590,8 @@ def load_dashboard():
g = ServerEnv()
g.get_today_usage = get_today_usage
g.get_today_amount = get_today_amount
g.get_usage_trend = get_usage_trend
g.get_amount_trend = get_amount_trend
g.get_total_users = get_total_users
g.get_concurrent_users = get_concurrent_users
g.get_top_models = get_top_models

View File

@ -5,7 +5,8 @@
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #22c55e"
},
"subwidgets": [
{

View File

@ -4,7 +4,8 @@
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #06b6d4"
},
"subwidgets": [
{

View File

@ -5,7 +5,8 @@
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #ef4444"
},
"subwidgets": [
{

View File

@ -1,10 +1,12 @@
{
"widgettype": "VBox",
"options": {
"css": "stat-card",
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #10b981"
},
"subwidgets": [
{

View File

@ -1,3 +1,4 @@
{% set trend = get_amount_trend(request) %}
{
"widgettype": "VBox",
"options": {
@ -5,7 +6,8 @@
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid {% if trend.trend == 'up' %}#22c55e{% elif trend.trend == 'down' %}#ef4444{% else %}#8b5cf6{% endif %}"
},
"subwidgets": [
{
@ -20,7 +22,8 @@
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 6v12m6-6H6\"/></svg>",
"width": "24px",
"height": "24px"
"height": "24px",
"color": "#8b5cf6"
}
},
{
@ -32,21 +35,58 @@
"widgettype": "Text",
"options": {
"css": "stat-value",
"text": "¥{{get_today_amount(request)|round(2)}}",
"text": "¥{{trend.value|round(2)}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginTop": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"css": "stat-label",
"fontSize": "14px",
"marginTop": "4px",
"otext": "今日交易金额",
"i18n": true
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"gap": "4px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "{% if trend.trend == 'up' %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22c55e\" stroke-width=\"2\"><polyline points=\"18 15 12 9 6 15\"></polyline></svg>{% elif trend.trend == 'down' %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#ef4444\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>{% else %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#94a3b8\" stroke-width=\"2\"><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line></svg>{% endif %}",
"width": "16px",
"height": "16px"
}
},
{
"widgettype": "Text",
"options": {
"text": "{{trend.percentage|round(1)}}%",
"fontSize": "12px",
"fontWeight": "600",
"color": "{% if trend.trend == 'up' %}#22c55e{% elif trend.trend == 'down' %}#ef4444{% else %}#94a3b8{% endif %}"
}
}
]
}
]
}
]
}

View File

@ -1,3 +1,4 @@
{% set trend = get_usage_trend(request) %}
{
"widgettype": "VBox",
"options": {
@ -6,7 +7,8 @@
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px",
"cursor": "pointer"
"cursor": "pointer",
"borderLeft": "4px solid {% if trend.trend == 'up' %}#22c55e{% elif trend.trend == 'down' %}#ef4444{% else %}#3b82f6{% endif %}"
},
"subwidgets": [
{
@ -21,7 +23,8 @@
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M7.5 21L3 16.5m0 0L7.5 12M12 9v7.5m0 0l4.5-4.5M12 9l4.5 4.5m0 0L12 16.5\"/><path d=\"M21 12h-4.5M12 3v4.5m0 0L7.5 12\"/></svg>",
"width": "24px",
"height": "24px"
"height": "24px",
"color": "#3b82f6"
}
},
{
@ -33,21 +36,58 @@
"widgettype": "Text",
"options": {
"css": "stat-value",
"text": "{{get_today_usage(request)}}",
"text": "{{trend.value}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginTop": "4px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"css": "stat-label",
"fontSize": "14px",
"marginTop": "4px",
"otext": "今日调用笔数",
"i18n": true
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"gap": "4px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "{% if trend.trend == 'up' %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22c55e\" stroke-width=\"2\"><polyline points=\"18 15 12 9 6 15\"></polyline></svg>{% elif trend.trend == 'down' %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#ef4444\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>{% else %}<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#94a3b8\" stroke-width=\"2\"><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line></svg>{% endif %}",
"width": "16px",
"height": "16px"
}
},
{
"widgettype": "Text",
"options": {
"text": "{{trend.percentage|round(1)}}%",
"fontSize": "12px",
"fontWeight": "600",
"color": "{% if trend.trend == 'up' %}#22c55e{% elif trend.trend == 'down' %}#ef4444{% else %}#94a3b8{% endif %}"
}
}
]
}
]
}
]
}

View File

@ -1,10 +1,12 @@
{
"widgettype": "VBox",
"options": {
"css": "stat-card",
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #8b5cf6"
},
"subwidgets": [
{

View File

@ -5,7 +5,8 @@
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
"minHeight": "110px",
"borderLeft": "4px solid #3b82f6"
},
"subwidgets": [
{
@ -20,7 +21,8 @@
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\"/></svg>",
"width": "24px",
"height": "24px"
"height": "24px",
"color": "#3b82f6"
}
},
{