Files
gaokao-volunteer-system/admin/static/dashboard.html
Hermes Agent fd7174a70e
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
  - pending_overdue_24h: status='pending' AND created_at < (now - 24h)
    (超时未付,需主动催付)
  - pending_missing_intake: status='pending' AND
    (order_intakes 无记录 OR order_intakes.status='draft')
    (资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
  (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
  导致 overdue_cutoff datetime 字符串误入 IN 子句,
  'completed' 误入 created_at < ? 子句, 收入少算 30%。
  已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义

## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效

## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域

## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
  - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
  - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
  - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
  - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed

## 真实环境验证
- admin/test-pass-123 登录后:
  pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
  '超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00

549 lines
20 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台仪表盘</title>
<link rel="stylesheet" href="/static/portal-ui.css" />
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--card: #ffffff;
--border: #dbe3f0;
--border-strong: #c5d2e6;
--text: #0f172a;
--muted: #64748b;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-soft: #e0ebff;
--success: #059669;
--success-soft: #d1fae5;
--warning: #b45309;
--warning-soft: #fef3c7;
--danger: #b91c1c;
--danger-soft: #fee2e2;
--shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: flex-start;
padding: 22px 24px;
margin-bottom: 16px;
}
.title h1 {
margin: 0 0 8px;
font-size: 26px;
letter-spacing: -0.01em;
}
.title p {
margin: 0;
color: var(--muted);
line-height: 1.7;
max-width: 720px;
}
.quick-links {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.quick-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
background: var(--primary-soft);
color: var(--primary-dark);
font-size: 13px;
font-weight: 600;
text-decoration: none;
border: 1px solid transparent;
}
.quick-link:hover { background: #cfe0ff; }
.system-status {
display: flex;
align-items: stretch;
gap: 12px;
min-width: 280px;
padding: 14px 18px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #1f6feb, #0e4ec3);
border: 1px solid #0e4ec3;
color: #fff;
box-shadow: 0 14px 30px rgba(31, 111, 235, 0.22);
}
.system-status.online { background: linear-gradient(135deg, #047857, #065f46); border-color: #065f46; box-shadow: 0 14px 30px rgba(4, 120, 87, 0.22); }
.system-status.error { background: linear-gradient(135deg, #b91c1c, #7f1d1d); border-color: #7f1d1d; box-shadow: 0 14px 30px rgba(185, 28, 28, 0.22); }
.status-dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.18);
flex: 0 0 auto;
}
.status-body { display: grid; gap: 2px; }
.status-title { font-size: 14px; font-weight: 700; }
.status-meta { font-size: 12px; color: var(--muted); }
.login-panel {
display: grid;
grid-template-columns: 1.1fr 1.1fr 1.4fr 1.3fr auto;
gap: 12px;
padding: 16px;
margin-bottom: 16px;
align-items: end;
}
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field label { font-size: 13px; color: var(--muted); font-weight: 600; }
.field input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 14px;
background: #fff;
}
.field input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.login-actions { display: flex; gap: 8px; align-items: center; }
button {
border: 0;
border-radius: var(--radius-sm);
padding: 10px 16px;
background: var(--primary);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
button:hover { background: var(--primary-dark); }
button.secondary { background: #e2e8f0; color: var(--text); }
button.secondary:hover { background: #d8e0ec; }
button.active-range { background: var(--primary); color: #fff; }
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card { padding: 18px 20px; }
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--muted);
margin-bottom: 10px;
font-weight: 600;
}
.card-title .pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: #eef2f7;
color: var(--muted);
}
.card-value {
font-size: 30px;
font-weight: 800;
margin-bottom: 6px;
letter-spacing: -0.02em;
}
.card-subtitle { font-size: 12px; color: var(--muted); }
.card.empty .card-value {
color: #94a3b8;
font-weight: 700;
font-size: 18px;
}
.pending-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.pending-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.pending-tag strong { font-size: 12px; }
.pending-tag[hidden] { display: none; }
.pending-tag-overdue {
background: rgba(220, 38, 38, 0.1);
color: #b91c1c;
}
.pending-tag-missing {
background: rgba(217, 119, 6, 0.1);
color: #b45309;
}
.pending-tag-zero {
background: rgba(100, 116, 139, 0.08);
color: #94a3b8;
}
.card.empty .card-subtitle::before {
content: "登录后查看 · ";
color: var(--primary);
font-weight: 600;
}
.spark {
display: block;
height: 24px;
margin: 6px 0 8px;
background-image: linear-gradient(90deg, #e2e8f0 0 4px, transparent 4px),
linear-gradient(0deg, #e2e8f0 0 1px, transparent 1px);
background-size: 12px 100%, 100% 24px;
background-repeat: repeat-x, no-repeat;
background-position: 0 100%, 0 100%;
border-radius: 4px;
opacity: 0.65;
}
.card.loaded .spark { background: linear-gradient(180deg, #e0ebff 0 60%, transparent 60%); }
.skeleton {
display: block;
height: 16px;
border-radius: 6px;
background: linear-gradient(90deg, #e2e8f0 0%, #f1f5f9 50%, #e2e8f0 100%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
margin-bottom: 6px;
}
.skeleton.lg { height: 30px; width: 60%; }
.skeleton.sm { height: 12px; width: 80%; }
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
.chart-card { padding: 20px; }
.chart-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.chart-head h2 { margin: 0; font-size: 18px; }
.chart-head p { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
.chart-actions { display: flex; gap: 6px; }
.range-btn {
font-size: 13px;
padding: 6px 12px;
}
#trend-chart { width: 100%; height: 360px; position: relative; }
.mini-chart {
width: 100%;
height: 260px;
position: relative;
}
.chart-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 14px;
pointer-events: none;
}
.chart-empty .label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 999px;
background: #f1f5f9;
border: 1px dashed var(--border-strong);
}
.charts-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.message {
margin: 0 0 16px;
padding: 12px 16px;
border-radius: var(--radius-md);
background: #f1f5f9;
color: var(--muted);
font-size: 14px;
border: 1px solid var(--border);
}
.message.success { background: var(--success-soft); color: #065f46; border-color: #a7f3d0; }
.message.error { background: var(--danger-soft); color: #7f1d1d; border-color: #fecaca; }
.message.loading { background: var(--primary-soft); color: var(--primary-dark); border-color: #c7d9ff; }
.error-line { color: var(--danger); font-size: 13px; min-height: 18px; margin-bottom: 8px; }
.hidden { display: none !important; }
@media (max-width: 1024px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.charts-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.header { grid-template-columns: 1fr; }
.login-panel { grid-template-columns: 1fr; }
.login-actions { justify-content: flex-start; }
.system-status { width: 100%; }
.cards { grid-template-columns: 1fr; }
.chart-actions { align-self: stretch; }
}
</style>
</head>
<body>
<div class="page">
<section class="header panel" aria-label="页面标题与系统状态">
<div class="title">
<h1>运营总览</h1>
<p>聚合查看订单、收入、用户与主要结构指标,帮助后台快速判断当前经营状态。</p>
<div class="quick-links" id="quick-links">
<a class="quick-link" href="/admin/orders/new">手动添加订单 →</a>
<a class="quick-link" href="/admin/notifications">通知审计 →</a>
<a class="quick-link" href="/admin/ops-alerts">运维告警 →</a>
</div>
</div>
<aside class="system-status" id="system-status" role="status" aria-live="polite">
<span class="status-dot" id="status-dot" aria-hidden="true"></span>
<div class="status-body">
<span class="status-title" id="status-title">尚未连接</span>
<span class="status-meta" id="status-meta">登录后查看核心经营指标</span>
</div>
<div class="status-actions">
<button class="status-btn" id="quick-refresh-btn" type="button">立即刷新</button>
<button class="status-btn secondary" id="quick-logout-btn" type="button">退出登录</button>
</div>
</aside>
</section>
<section class="login-panel panel" id="login-form" aria-label="登录与数据服务">
<div class="field">
<label for="username">用户名</label>
<input id="username" autocomplete="username" placeholder="admin" />
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" type="password" autocomplete="current-password" placeholder="请输入密码" />
</div>
<div class="field">
<label for="token">登录令牌(可选)</label>
<input id="token" placeholder="如已获取登录令牌,可直接粘贴" />
</div>
<div class="field">
<label for="api-base">数据服务地址</label>
<input id="api-base" value="" placeholder="默认使用当前站点地址" />
</div>
<div class="login-actions">
<button id="login-btn" type="button">登录并加载</button>
<button id="refresh-btn" class="secondary" type="button">仅刷新数据</button>
</div>
</section>
<div id="message" class="message">请先登录,或粘贴已有登录令牌后加载数据。</div>
<div id="error" class="error-line"></div>
<section class="cards" aria-label="核心经营指标">
<article class="card empty" data-card="orders">
<div class="card-title">订单数 <span class="pill">实时</span></div>
<div id="total-orders" class="card-value">--</div>
<div class="spark" id="orders-spark" aria-hidden="true"></div>
<div id="orders-subtitle" class="card-subtitle">今日 -- / 7d -- / 30d --</div>
</article>
<article class="card empty" data-card="revenue">
<div class="card-title">收入 <span class="pill">实时</span></div>
<div id="total-revenue" class="card-value">--</div>
<div class="spark" id="revenue-spark" aria-hidden="true"></div>
<div id="revenue-subtitle" class="card-subtitle">今日 -- / 7d -- / 30d --</div>
</article>
<article class="card empty" data-card="users">
<div class="card-title">后台可登录用户 <span class="pill">累计</span></div>
<div id="total-users" class="card-value">--</div>
<div class="card-subtitle">已创建后台账号总数</div>
</article>
<article class="card empty" data-card="pending">
<div class="card-title">待处理订单 <span class="pill">待办</span></div>
<div id="pending-orders" class="card-value">--</div>
<div class="card-subtitle">未支付 / 待补资料订单合计</div>
<div class="pending-breakdown" id="pending-breakdown" hidden>
<span class="pending-tag pending-tag-overdue" id="pending-overdue-tag" hidden>
超时 <strong id="pending-overdue-24h">0</strong>
</span>
<span class="pending-tag pending-tag-missing" id="pending-missing-tag" hidden>
资料待补 <strong id="pending-missing-intake">0</strong>
</span>
</div>
</article>
</section>
<section class="chart-card panel" aria-label="订单与收入趋势">
<div class="chart-head">
<div>
<h2 id="trend-title">7 天趋势</h2>
<p>订单数与收入共用同一时间轴,可切换查看更长周期。</p>
</div>
<div class="chart-actions">
<button id="range-7d" class="range-btn active-range" type="button">7 天</button>
<button id="range-30d" class="range-btn secondary" type="button">30 天</button>
</div>
</div>
<div id="trend-chart">
<div class="chart-empty" id="trend-empty"><span class="label">登录后展示订单与收入趋势</span></div>
</div>
</section>
<section class="charts-grid" aria-label="结构分布">
<section class="chart-card panel">
<div class="chart-head">
<div>
<h2>状态分布</h2>
<p>观察订单漏斗积压情况。</p>
</div>
</div>
<div id="status-chart" class="mini-chart">
<div class="chart-empty"><span class="label">等待加载状态分布</span></div>
</div>
</section>
<section class="chart-card panel">
<div class="chart-head">
<div>
<h2>来源分布</h2>
<p>查看渠道结构。</p>
</div>
</div>
<div id="source-chart" class="mini-chart">
<div class="chart-empty"><span class="label">等待加载来源分布</span></div>
</div>
</section>
<section class="chart-card panel">
<div class="chart-head">
<div>
<h2>服务版本分布</h2>
<p>查看产品结构。</p>
</div>
</div>
<div id="service-chart" class="mini-chart">
<div class="chart-empty"><span class="label">等待加载服务版本分布</span></div>
</div>
</section>
</section>
</div>
<script src="/static/vendor/echarts.min.js"></script>
<script>
if (!window.echarts) {
window.echarts = {
init(el) {
const escapeHtml = (value) =>
String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[ch]));
const maxOf = (values) =>
values.reduce((acc, item) => Math.max(acc, Number(item) || 0), 0) || 1;
const renderLine = (option) => {
const categories = option?.xAxis?.data || [];
const first = option?.series?.[0]?.data || [];
const second = option?.series?.[1]?.data || [];
const maxValue = maxOf(first.concat(second));
const toPoints = (values) =>
values
.map((value, index) => {
const x = categories.length > 1 ? (index * 100) / (categories.length - 1) : 0;
const y = 90 - ((Number(value) || 0) / maxValue) * 80;
return `${x},${y}`;
})
.join(" ");
el.innerHTML = `
<div style="font:12px sans-serif;color:#0f172a">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:260px;background:#fff">
<polyline fill="none" stroke="#2563eb" stroke-width="2" points="${toPoints(first)}"></polyline>
<polyline fill="none" stroke="#059669" stroke-width="2" points="${toPoints(second)}"></polyline>
</svg>
<div style="display:grid;grid-template-columns:repeat(${Math.max(categories.length, 1)}, minmax(0, 1fr));gap:4px;color:#64748b">
${categories.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}
</div>
</div>`;
};
const renderBar = (option) => {
const categories = option?.xAxis?.data || [];
const values = option?.series?.[0]?.data || [];
const color = option?.series?.[0]?.itemStyle?.color || "#2563eb";
const maxValue = maxOf(values);
el.innerHTML = `
<div style="padding:8px 4px">
${categories
.map((category, index) => {
const value = Number(values[index] || 0);
const width = Math.max(6, Math.round((value / maxValue) * 100));
return `<div style="margin:10px 0">
<div style="display:flex;justify-content:space-between;font:12px sans-serif;color:#475569">
<span>${escapeHtml(category)}</span>
<span>${value}</span>
</div>
<div style="height:10px;background:#e2e8f0;border-radius:999px;overflow:hidden">
<div style="width:${width}%;height:100%;background:${escapeHtml(color)}"></div>
</div>
</div>`;
})
.join("")}
</div>`;
};
return {
setOption(option) {
if ((option?.series || [])[0]?.type === "bar") {
renderBar(option);
return;
}
renderLine(option);
},
resize() {},
};
},
};
}
</script>
<script src="/static/dashboard.js"></script>
</body>
</html>