Files
gaokao-volunteer-system/admin/static/dashboard.html
Hermes Agent 40ee7f4dc0
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(frontend): complete local UI remediation for web, portal, and dashboard
2026-06-16 15:02:01 +08:00

347 lines
11 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>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--card: #ffffff;
--border: #dbe3f0;
--text: #0f172a;
--muted: #64748b;
--primary: #2563eb;
--success: #059669;
}
* { 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: 1160px;
margin: 0 auto;
padding: 24px;
}
.header, .login-panel, .chart-card, .card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.05);
}
.charts-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
}
.title h1 {
margin: 0 0 6px;
font-size: 28px;
}
.title p, .meta, .hint, .error {
margin: 0;
color: var(--muted);
}
.login-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: 12px;
padding: 16px;
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 13px;
color: var(--muted);
}
.field input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
}
button {
border: 0;
border-radius: 10px;
padding: 10px 16px;
background: var(--primary);
color: #fff;
font-size: 14px;
cursor: pointer;
}
button.secondary {
background: #e2e8f0;
color: var(--text);
}
button.active-range {
background: var(--primary);
color: #fff;
}
.actions {
display: flex;
align-items: end;
gap: 8px;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card {
padding: 20px;
}
.card-title {
font-size: 14px;
color: var(--muted);
margin-bottom: 10px;
}
.card-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.card-subtitle {
font-size: 13px;
color: var(--muted);
}
.chart-card {
padding: 20px;
}
.chart-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
#trend-chart {
width: 100%;
height: 380px;
}
.mini-chart {
width: 100%;
height: 280px;
}
.error {
color: #b91c1c;
min-height: 20px;
margin-top: 8px;
}
.success {
color: var(--success);
}
@media (max-width: 900px) {
.login-panel, .cards, .charts-grid { grid-template-columns: 1fr; }
.actions { align-items: stretch; flex-direction: column; }
.header, .chart-head { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="page">
<section class="header">
<div class="title">
<h1>运营总览</h1>
<p>聚合查看订单、收入、用户与主要结构指标,帮助后台快速判断当前经营状态。</p>
<p><a href="/admin/orders/new">手动添加订单</a> · <a href="/admin/notifications">通知审计</a> · <a href="/admin/ops-alerts">运维告警</a></p>
</div>
<div class="meta">
<div>核心经营指标</div>
<div id="generated-at">尚未加载</div>
</div>
</section>
<section class="login-panel">
<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="actions">
<button id="login-btn" type="button">登录并加载</button>
<button id="refresh-btn" class="secondary" type="button">仅刷新数据</button>
</div>
</section>
<div id="message" class="hint">请先登录,或粘贴已有登录令牌后加载数据。</div>
<div id="error" class="error"></div>
<section class="cards">
<article class="card">
<div class="card-title">订单数</div>
<div id="total-orders" class="card-value">--</div>
<div id="orders-subtitle" class="card-subtitle">今日 -- / 7d -- / 30d --</div>
</article>
<article class="card">
<div class="card-title">后台可登录用户</div>
<div id="total-users" class="card-value">--</div>
<div class="card-subtitle">已创建后台账号总数</div>
</article>
<article class="card">
<div class="card-title">收入</div>
<div id="total-revenue" class="card-value">--</div>
<div id="revenue-subtitle" class="card-subtitle">今日 -- / 7d -- / 30d --</div>
</article>
</section>
<section class="chart-card">
<div class="chart-head">
<div>
<h2 id="trend-title" style="margin: 0 0 4px; font-size: 20px;">7 天趋势</h2>
<p class="hint">订单数与收入共用同一时间轴。</p>
</div>
<div class="actions">
<button id="range-7d" type="button">7d</button>
<button id="range-30d" class="secondary" type="button">30d</button>
</div>
</div>
<div id="trend-chart"></div>
</section>
<section class="charts-grid">
<section class="chart-card">
<div class="chart-head">
<div>
<h2 style="margin: 0 0 4px; font-size: 18px;">状态分布</h2>
<p class="hint">观察订单漏斗积压。</p>
</div>
</div>
<div id="status-chart" class="mini-chart"></div>
</section>
<section class="chart-card">
<div class="chart-head">
<div>
<h2 style="margin: 0 0 4px; font-size: 18px;">来源分布</h2>
<p class="hint">查看渠道结构。</p>
</div>
</div>
<div id="source-chart" class="mini-chart"></div>
</section>
<section class="chart-card">
<div class="chart-head">
<div>
<h2 style="margin: 0 0 4px; font-size: 18px;">服务版本分布</h2>
<p class="hint">查看产品结构。</p>
</div>
</div>
<div id="service-chart" class="mini-chart"></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>