## 实现内容 (6 项改动) 1. **admin /health 端点增强** — 主键契约 status:ok 保持 + checks 子对象 - db_writable: connect + CREATE TEMP TABLE + INSERT + SELECT + DROP - disk_writable: 在 ops_alert_log_path 目录创建临时文件 + 删除 - settings_valid: 复用 is_jwt_secret_secure 判断 2. **_enforce_jwt_secret_policy** — prod env 使用 dev 默认 JWT / 长度<32 → fail-closed 3. **_enforce_default_admin_password_policy** — prod env 用 admin123 / 长度<10 / 字符类<3 → fail-closed 4. **L-A R7: admin UI footer** — dashboard.html (592 行) + ui.py 内联 admin/orders/new 模板加 footer 隐私政策 + 数据删除 + 服务说明链接, 与 portal _render_footer_links() 同口径 5. **L-A R1+R4: LEGAL_PRIVACY_BASELINE 文档同步** - §6 移除孤儿 'admin' consent_channel 值 (代码侧从未实际产生) - §7 已具备/尚缺 重写, 显式归到 6/20 增量 6. **Q-A: tests/test_crowd_db_data_quality.py** — 8 个测试锁住 - 27 省总数 + 仅湖南 high + 其它 26 省 ≤ usable - 高考生源大省 (广东/江苏/北京/上海/山东/河南/四川/湖北) 不在 high 集合 - data_year=2025 (6/25 后需显式更新) ## 测试 - 4/4 RED → GREEN (admin/tests/test_health.py: JWT/admin password 拒绝 dev 默认值) - 8/8 GREEN (data/crowd_db/tests/test_crowd_db_data_quality.py: 数据质量契约) - 3 回归修复 (test_app.py + test_routes.py + test_health.py 适配新 /health checks 字段) - 31/31 GREEN (admin/tests/test_app.py + test_routes.py + test_health.py) ## 报告 - reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md (363 行, 9 风险 0 阻塞) - reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md (45 行, CRITICAL 文档失真已规避) ## 文件 M CHANGELOG.md (v2.1.3) M admin/config.py (2 个 _enforce_*_policy + load_settings post-load) M admin/routes/health.py (3 个 _check_* + checks 子对象) M admin/routes/ui.py (admin/orders/new 模板加 footer) M admin/static/dashboard.html (footer 块) M admin/tests/test_app.py (适配 checks 字段 + regex 兼容) M admin/tests/test_health.py (4 个新测试) M admin/tests/test_routes.py (适配 checks 字段) M docs/CURRENT_STATE.md (0.3-0.5 增量段) M docs/LEGAL_PRIVACY_BASELINE.md (§6 清理 + §7 重写) + data/crowd_db/tests/test_crowd_db_data_quality.py (8 tests) + reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md + reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md
600 lines
22 KiB
HTML
600 lines
22 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; }
|
|
.dev-tools {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
border: 1px dashed #cbd5e1;
|
|
background: linear-gradient(180deg, #fffaf0, #fff7ed);
|
|
}
|
|
.dev-tools-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.dev-tools-copy {
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
.dev-tools-copy strong { font-size: 14px; }
|
|
.dev-tools-copy span { color: var(--muted); font-size: 12px; }
|
|
.dev-tools-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.dev-tools-hint {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.title h1 { cursor: default; }
|
|
.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 id="dashboard-title">运营总览</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>
|
|
|
|
<section class="panel dev-tools hidden" id="dev-seed-panel" aria-label="隐藏测试造数工具">
|
|
<div class="dev-tools-head">
|
|
<div class="dev-tools-copy">
|
|
<strong>隐藏测试造数工具</strong>
|
|
<span>仅本地开发/演示使用。会调用受保护的后台接口写入带标签的演示订单。</span>
|
|
</div>
|
|
<button class="secondary" id="dev-seed-close-btn" type="button">收起</button>
|
|
</div>
|
|
<div class="dev-tools-actions">
|
|
<button id="dev-seed-overdue-btn" type="button">补 1 笔超时待办</button>
|
|
<button class="secondary" id="dev-seed-clean-btn" type="button">清理演示造数</button>
|
|
</div>
|
|
<div class="dev-tools-hint">打开方式:标题连点 5 次,或在 URL 后加 ?seed-tools=1。</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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
}[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>
|
|
<!-- 合规 footer: 与 portal 前台 _render_footer_links() 同口径 -->
|
|
<footer style="margin-top:32px;padding:16px;border-top:1px solid #d8e0ee;color:#5b6b88;font-size:13px;text-align:center;background:#f4f6fb;">
|
|
<a href="/privacy" style="color:#2f55a4;text-decoration:none;margin:0 8px;">隐私政策</a> ·
|
|
<a href="/deletion-policy" style="color:#2f55a4;text-decoration:none;margin:0 8px;">数据删除说明</a> ·
|
|
<a href="/service-terms" style="color:#2f55a4;text-decoration:none;margin:0 8px;">服务说明</a>
|
|
<div style="margin-top:4px;color:#7c8aa8;">© 2026 高考志愿填报智能系统 · 内部操作员亦需遵守隐私与数据合规</div>
|
|
</footer>
|
|
</body>
|
|
</html>
|