Files
skill-lib/frontend/zhihu-hotlist-echarts.html
木炎 e3b0f4f3c9 feat: add first zhihu big-screen dashboard
Deliver the first single-file Zhihu hotlist reporting screen with themed 16:9 layout, theme switching, upgraded charts, and overlay JSON input for live preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:38:13 +08:00

1955 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-theme="gov_blue_gold">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>知乎热榜主题分类分析大屏</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
:root {
--bg: #071b34;
--bg-alt: #0e2a4d;
--bg-glow: rgba(62, 199, 255, 0.16);
--panel: rgba(9, 28, 53, 0.92);
--panel-strong: rgba(14, 42, 77, 0.97);
--panel-soft: rgba(12, 36, 68, 0.78);
--line: rgba(62, 199, 255, 0.2);
--line-strong: rgba(216, 179, 106, 0.34);
--text: #eaf4ff;
--muted: #8faecc;
--accent: #d8b36a;
--accent-2: #3ec7ff;
--accent-3: #5b8cff;
--success: #74c69d;
--danger: #e05a47;
--warning: #d79a43;
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
--table-header: rgba(8, 25, 48, 0.98);
--panel-radius: 18px;
--font-main: "Segoe UI Variable Text", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-number: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif;
}
html[data-theme="gov_blue_gold"] {
--bg: #071b34;
--bg-alt: #0e2a4d;
--bg-glow: rgba(62, 199, 255, 0.18);
--panel: rgba(9, 28, 53, 0.92);
--panel-strong: rgba(14, 42, 77, 0.97);
--panel-soft: rgba(12, 36, 68, 0.78);
--line: rgba(62, 199, 255, 0.2);
--line-strong: rgba(216, 179, 106, 0.34);
--text: #eaf4ff;
--muted: #8faecc;
--accent: #d8b36a;
--accent-2: #3ec7ff;
--accent-3: #5b8cff;
--success: #74c69d;
--danger: #e05a47;
--warning: #d79a43;
--table-header: rgba(8, 25, 48, 0.98);
}
html[data-theme="tech_cyan_blue"] {
--bg: #061a2d;
--bg-alt: #0b2844;
--bg-glow: rgba(34, 211, 238, 0.2);
--panel: rgba(6, 26, 45, 0.92);
--panel-strong: rgba(11, 40, 68, 0.97);
--panel-soft: rgba(9, 34, 58, 0.82);
--line: rgba(34, 211, 238, 0.23);
--line-strong: rgba(91, 140, 255, 0.34);
--text: #e6f7ff;
--muted: #93b7d8;
--accent: #22d3ee;
--accent-2: #5b8cff;
--accent-3: #99f6ff;
--success: #5eead4;
--danger: #fb7185;
--warning: #60a5fa;
--table-header: rgba(4, 21, 38, 0.98);
}
html[data-theme="industry_ink_green"] {
--bg: #0a1d1d;
--bg-alt: #113536;
--bg-glow: rgba(116, 198, 157, 0.18);
--panel: rgba(10, 29, 29, 0.92);
--panel-strong: rgba(17, 53, 54, 0.97);
--panel-soft: rgba(14, 45, 45, 0.82);
--line: rgba(116, 198, 157, 0.22);
--line-strong: rgba(214, 181, 110, 0.34);
--text: #edfdf7;
--muted: #99bdb7;
--accent: #d6b56e;
--accent-2: #74c69d;
--accent-3: #34d399;
--success: #74c69d;
--danger: #f87171;
--warning: #d6b56e;
--table-header: rgba(9, 26, 26, 0.98);
}
html[data-theme="meeting_red_gold"] {
--bg: #2c1111;
--bg-alt: #4b1e1e;
--bg-glow: rgba(224, 90, 71, 0.16);
--panel: rgba(44, 17, 17, 0.92);
--panel-strong: rgba(75, 30, 30, 0.97);
--panel-soft: rgba(58, 23, 23, 0.82);
--line: rgba(224, 90, 71, 0.22);
--line-strong: rgba(215, 154, 67, 0.36);
--text: #fff0ea;
--muted: #d8b4ad;
--accent: #d79a43;
--accent-2: #e05a47;
--accent-3: #f59e0b;
--success: #fca5a5;
--danger: #fb7185;
--warning: #d79a43;
--table-header: rgba(37, 14, 14, 0.98);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
overflow: hidden;
color: var(--text);
font-family: var(--font-main);
background:
radial-gradient(circle at 18% 16%, var(--bg-glow), transparent 24%),
radial-gradient(circle at 82% 12%, rgba(216, 179, 106, 0.12), transparent 22%),
linear-gradient(135deg, var(--bg) 0%, var(--bg-alt) 52%, var(--bg) 100%);
transition: background 0.35s ease, color 0.35s ease;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
body::before {
background-image:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(180deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.18));
}
body::after {
background:
linear-gradient(90deg, transparent 0, rgba(255, 255, 255, 0.03) 50%, transparent 100%),
linear-gradient(180deg, transparent 0, rgba(255, 255, 255, 0.03) 50%, transparent 100%);
mix-blend-mode: screen;
opacity: 0.35;
}
button,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.screen {
position: relative;
z-index: 1;
height: 100vh;
padding: 20px 24px 14px;
display: grid;
grid-template-rows: 88px 120px 380px minmax(0, 1fr) 44px;
gap: 14px;
}
.panel,
.screen-header,
.screen-footer,
.kpi-card,
.data-drawer {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, var(--panel-strong) 0%, var(--panel) 100%);
border: 1px solid var(--line);
box-shadow: var(--shadow);
transition: background 0.35s ease, border-color 0.35s ease, color 0.35s ease;
}
.panel::before,
.screen-header::before,
.screen-footer::before,
.kpi-card::before,
.data-drawer::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(255, 255, 255, 0.04);
pointer-events: none;
}
.panel::after,
.screen-header::after,
.screen-footer::after,
.kpi-card::after,
.data-drawer::after {
content: "";
position: absolute;
inset: 12px;
border: 1px solid rgba(255, 255, 255, 0.03);
clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px);
pointer-events: none;
}
.screen-header {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: 18px;
align-items: center;
padding: 0 24px;
border-radius: 18px;
}
.header-main {
min-width: 0;
}
.header-eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.18em;
color: var(--accent);
background: rgba(255, 255, 255, 0.04);
text-transform: uppercase;
}
.header-eyebrow::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-2);
box-shadow: 0 0 10px rgba(62, 199, 255, 0.4);
}
.header-title {
margin: 10px 0 6px;
font-size: 34px;
line-height: 1.1;
letter-spacing: 0.02em;
font-weight: 700;
}
.header-subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-side {
display: grid;
justify-items: end;
gap: 12px;
min-width: 0;
}
.header-meta {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px 18px;
font-size: 12px;
color: var(--muted);
}
.header-meta strong {
color: var(--text);
font-weight: 600;
}
.theme-switcher {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.theme-btn,
.drawer-toggle,
.action-btn {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
background: rgba(255, 255, 255, 0.04);
transition: transform 0.2s ease, background 0.25s ease, border-color 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.theme-btn {
padding: 8px 12px;
font-size: 12px;
min-width: 78px;
}
.theme-btn.active {
color: #05121d;
border-color: transparent;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
}
.drawer-toggle,
.action-btn {
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
}
.drawer-toggle.primary,
.action-btn.primary {
color: #05121d;
border-color: transparent;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.05);
}
.action-btn.ghost {
background: transparent;
}
.theme-btn:hover,
.drawer-toggle:hover,
.action-btn:hover {
transform: translateY(-1px);
}
.theme-btn:focus-visible,
.drawer-toggle:focus-visible,
.action-btn:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.kpi-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.kpi-card {
border-radius: 16px;
padding: 18px 20px;
display: grid;
gap: 8px;
min-width: 0;
}
.kpi-label {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.kpi-value {
font-family: var(--font-number);
font-size: clamp(24px, 2.3vw, 34px);
line-height: 1.15;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value.long {
font-size: clamp(18px, 1.45vw, 24px);
}
.kpi-sub {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-status-normal { color: var(--success); }
.kpi-status-warning { color: var(--warning); }
.kpi-status-danger { color: var(--danger); }
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 360px;
gap: 14px;
min-height: 0;
}
.panel {
border-radius: var(--panel-radius);
padding: 18px 18px 16px;
min-height: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 12px;
}
.panel-title-wrap {
min-width: 0;
}
.panel-title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.03em;
}
.panel-desc {
margin: 6px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.chart-box {
height: calc(100% - 58px);
min-height: 280px;
width: 100%;
}
.insights-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.insights-body {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 12px;
min-height: 0;
}
.insight-section {
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
border-radius: 14px;
padding: 14px;
}
.insight-section h3 {
margin: 0 0 10px;
font-size: 14px;
letter-spacing: 0.08em;
color: var(--accent);
}
.insight-list,
.conclusion-list,
.category-pills {
display: grid;
gap: 10px;
}
.insight-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.insight-rank {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 12px;
font-family: var(--font-number);
font-size: 16px;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.medal-gold { color: #ffe5a3; border-color: rgba(255, 215, 128, 0.4); background: rgba(255, 215, 128, 0.08); }
.medal-silver { color: #dbe7f6; border-color: rgba(207, 219, 232, 0.35); background: rgba(207, 219, 232, 0.08); }
.medal-bronze { color: #e6c49a; border-color: rgba(201, 145, 89, 0.35); background: rgba(201, 145, 89, 0.08); }
.insight-title {
font-size: 14px;
line-height: 1.5;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.insight-meta {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.conclusion-item {
padding-left: 14px;
position: relative;
font-size: 13px;
color: var(--text);
line-height: 1.65;
}
.conclusion-item::before {
content: "";
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-2);
box-shadow: 0 0 8px rgba(62, 199, 255, 0.35);
}
.insight-empty,
.chart-empty,
.table-empty {
display: grid;
place-items: center;
text-align: center;
color: var(--muted);
border-radius: 14px;
border: 1px dashed rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.03);
min-height: 120px;
padding: 16px;
}
.table-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
min-height: 0;
}
.category-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.category-pill,
.category-tag {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--text);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: 0 0 10px currentColor;
}
.table-wrap {
min-height: 0;
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(2, 10, 22, 0.24);
}
table {
width: 100%;
min-width: 1020px;
border-collapse: collapse;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 14px 16px;
text-align: left;
font-size: 12px;
color: var(--muted);
background: var(--table-header);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
tbody td {
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 13px;
vertical-align: top;
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
tbody tr.top-1 { background: rgba(255, 215, 128, 0.06); }
tbody tr.top-2 { background: rgba(207, 219, 232, 0.05); }
tbody tr.top-3 { background: rgba(201, 145, 89, 0.05); }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 30px;
padding: 0 12px;
border-radius: 999px;
font-family: var(--font-number);
font-size: 14px;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
}
.topic-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.topic-title {
font-size: 14px;
font-weight: 600;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.topic-url {
color: var(--accent-2);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
display: inline-block;
max-width: 100%;
}
.topic-url:hover {
text-decoration: underline;
}
.heat-value {
font-family: var(--font-number);
font-size: 14px;
font-weight: 700;
color: var(--accent);
white-space: nowrap;
}
.muted {
font-size: 12px;
color: var(--muted);
}
.screen-footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 0 18px;
border-radius: 14px;
font-size: 12px;
color: var(--muted);
}
.footer-meta {
display: flex;
gap: 18px;
flex-wrap: wrap;
justify-content: flex-end;
}
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 10;
background: rgba(2, 8, 18, 0.38);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
.drawer-backdrop.open {
opacity: 1;
visibility: visible;
}
.data-drawer {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
height: min(420px, 36vh);
z-index: 11;
border-radius: 20px;
transform: translateY(calc(100% + 24px));
transition: transform 0.28s ease;
padding: 18px;
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 12px;
}
.data-drawer.open {
transform: translateY(0);
}
.drawer-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
}
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.drawer-note {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.drawer-close {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 16px;
}
textarea {
width: 100%;
height: 100%;
min-height: 0;
resize: none;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
background: rgba(2, 12, 24, 0.6);
color: var(--text);
padding: 14px 16px;
font: 13px/1.6 "Consolas", "Cascadia Code", "SFMono-Regular", monospace;
}
.drawer-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.feedback {
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
color: var(--success);
background: rgba(116, 198, 157, 0.09);
border: 1px solid rgba(116, 198, 157, 0.2);
}
.feedback.error {
color: #ffb4ab;
background: rgba(224, 90, 71, 0.1);
border-color: rgba(224, 90, 71, 0.22);
}
@media (max-width: 1600px) {
.screen {
padding: 16px;
gap: 12px;
}
.screen-header {
grid-template-columns: 1fr;
align-items: start;
padding: 18px;
}
.header-side {
justify-items: start;
}
.theme-switcher,
.header-meta {
justify-content: flex-start;
}
.kpi-row,
.main-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.insights-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 1280px), (max-height: 900px) {
body {
height: auto;
min-height: 100%;
overflow: auto;
}
.screen {
height: auto;
min-height: 1080px;
grid-template-rows: auto auto auto auto auto;
}
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.main-grid {
grid-template-columns: 1fr;
}
.chart-box {
height: 320px;
}
.data-drawer {
left: 12px;
right: 12px;
height: min(500px, 56vh);
}
}
@media (max-width: 720px) {
.screen {
padding: 12px;
}
.kpi-row {
grid-template-columns: 1fr;
}
.header-title {
font-size: 28px;
}
.data-drawer {
left: 8px;
right: 8px;
bottom: 8px;
}
.drawer-actions,
.theme-switcher,
.footer-meta,
.header-meta {
gap: 8px;
}
}
</style>
</head>
<body>
<div class="screen">
<header class="screen-header">
<div class="header-main">
<div class="header-eyebrow">Zhihu Hotlist Big Screen</div>
<h1 class="header-title">知乎热榜主题分类分析大屏</h1>
<p class="header-subtitle">基于知乎热榜快照,按主题分类汇总热度与互动指标,适配国企汇报场景的一页式监测大屏。</p>
</div>
<div class="header-side">
<div class="header-meta">
<span>当前时间 <strong id="currentTime">-</strong></span>
<span>数据源 <strong id="snapshotId">demo-snapshot</strong></span>
<span>生成时间 <strong id="generatedAt">-</strong></span>
</div>
<div class="theme-switcher" id="themeSwitcher">
<button class="theme-btn active" type="button" data-theme="gov_blue_gold">政务蓝金</button>
<button class="theme-btn" type="button" data-theme="tech_cyan_blue">科技青蓝</button>
<button class="theme-btn" type="button" data-theme="industry_ink_green">工业墨绿</button>
<button class="theme-btn" type="button" data-theme="meeting_red_gold">会议红金</button>
<button class="drawer-toggle primary" type="button" id="drawerToggle">数据输入</button>
</div>
</div>
</header>
<section class="kpi-row" id="summaryGrid"></section>
<section class="main-grid">
<article class="panel chart-panel">
<div class="panel-head">
<div class="panel-title-wrap">
<h2 class="panel-title">分类条目数量对比</h2>
<p class="panel-desc">比较各主题在当前热榜中的条目数量,识别内容分布重心。</p>
</div>
</div>
<div class="chart-box" id="categoryCountChart"></div>
</article>
<article class="panel chart-panel">
<div class="panel-head">
<div class="panel-title-wrap">
<h2 class="panel-title">分类总热度对比</h2>
<p class="panel-desc">按主题汇总热度,突出最高热度类目,辅助快速研判舆论集中点。</p>
</div>
</div>
<div class="chart-box" id="categoryHeatChart"></div>
</article>
<aside class="panel insights-panel">
<div class="panel-head">
<div class="panel-title-wrap">
<h2 class="panel-title">重点摘要卡</h2>
<p class="panel-desc">按热度筛出 Top3 热点,并输出当前快照的模板化结论。</p>
</div>
</div>
<div class="insights-body">
<section class="insight-section">
<h3>TOP3 热点话题</h3>
<div class="insight-list" id="insightTopList"></div>
</section>
<section class="insight-section">
<h3>研判结论</h3>
<div class="conclusion-list" id="insightConclusionList"></div>
</section>
<section class="insight-section">
<h3>提示说明</h3>
<div class="conclusion-list">
<div class="conclusion-item">默认使用示例数据,支持直接粘贴 `zhihu_hotlist_report` 的 `echarts` JSON。</div>
<div class="conclusion-item">输入抽屉为覆盖层,不挤压大屏布局;适合汇报时快速切换数据快照。</div>
<div class="conclusion-item">类别颜色在图表、摘要、表格标签之间保持一致。</div>
</div>
</section>
</div>
</aside>
</section>
<section class="panel table-panel">
<div class="panel-head">
<div class="panel-title-wrap">
<h2 class="panel-title">热榜明细表</h2>
<p class="panel-desc">逐条查看排名、标题、类别、热度以及互动指标,支撑汇报落到具体热点。</p>
</div>
</div>
<div class="category-pills" id="categoryPills"></div>
<div class="table-wrap" id="tableWrap"></div>
</section>
<footer class="screen-footer">
<div id="footerNote">热度来自热榜展示值;互动指标来自问题页抓取的快照(若为空则显示 0</div>
<div class="footer-meta">
<span>生成时间 <strong id="footerGeneratedAt">-</strong></span>
<span>当前皮肤 <strong id="footerSkin">政务蓝金</strong></span>
</div>
</footer>
</div>
<div class="drawer-backdrop" id="drawerBackdrop"></div>
<section class="data-drawer" id="dataDrawer" aria-hidden="true">
<div class="drawer-head">
<div>
<h2 class="drawer-title">数据输入</h2>
<p class="drawer-note">支持粘贴 skill 输出的 payload若为外层对象会自动提取 `echarts` 字段。按 Esc 关闭Ctrl+Enter 可直接渲染。</p>
</div>
<button class="drawer-close" type="button" id="drawerClose" aria-label="关闭输入抽屉">×</button>
</div>
<textarea id="payloadInput" spellcheck="false"></textarea>
<div class="drawer-actions">
<button class="action-btn primary" type="button" id="renderBtn">渲染看板</button>
<button class="action-btn secondary" type="button" id="sampleBtn">载入示例</button>
<button class="action-btn ghost" type="button" id="formatBtn">格式化 JSON</button>
</div>
<div class="feedback" id="feedback">已加载示例数据。</div>
</section>
<script>
const defaultPayload = {
snapshot_id: "demo-snapshot-20260328",
generated_at_ms: 1774628400000,
categories: [
{ category_code: "society", category_label: "社会", item_count: 3, total_heat: 138000, avg_heat: 46000 },
{ category_code: "entertainment", category_label: "娱乐", item_count: 2, total_heat: 116000, avg_heat: 58000 },
{ category_code: "technology", category_label: "科技", item_count: 2, total_heat: 103000, avg_heat: 51500 },
{ category_code: "sports", category_label: "体育", item_count: 2, total_heat: 93000, avg_heat: 46500 },
{ category_code: "military", category_label: "军事", item_count: 1, total_heat: 42000, avg_heat: 42000 },
{ category_code: "finance", category_label: "财经", item_count: 1, total_heat: 39000, avg_heat: 39000 }
],
table: [
{
rank: 1,
title: "明星新电影票房破纪录,娱乐板块热度再度走高",
url: "https://www.zhihu.com/question/100001",
category_code: "entertainment",
category_label: "娱乐",
heat_text: "6.8 万热度",
heat_value: 68000,
reply_count: 132,
upvote_count: 865,
favorite_count: 215,
heart_count: 96
},
{
rank: 2,
title: "AI 智能体落地企业办公场景,科技话题持续升温",
url: "https://www.zhihu.com/question/100002",
category_code: "technology",
category_label: "科技",
heat_text: "6.2 万热度",
heat_value: 62000,
reply_count: 110,
upvote_count: 790,
favorite_count: 188,
heart_count: 75
},
{
rank: 3,
title: "城市更新与民生配套建设引发社会讨论",
url: "https://www.zhihu.com/question/100003",
category_code: "society",
category_label: "社会",
heat_text: "5.5 万热度",
heat_value: 55000,
reply_count: 96,
upvote_count: 602,
favorite_count: 144,
heart_count: 52
},
{
rank: 4,
title: "国足新周期备战方案公布,体育板块关注度回升",
url: "https://www.zhihu.com/question/100004",
category_code: "sports",
category_label: "体育",
heat_text: "5.2 万热度",
heat_value: 52000,
reply_count: 84,
upvote_count: 573,
favorite_count: 116,
heart_count: 49
},
{
rank: 5,
title: "航母编队演训画面公布,军事类目成为高热讨论点",
url: "https://www.zhihu.com/question/100005",
category_code: "military",
category_label: "军事",
heat_text: "4.2 万热度",
heat_value: 42000,
reply_count: 75,
upvote_count: 418,
favorite_count: 103,
heart_count: 44
},
{
rank: 6,
title: "居民消费新政发布后,财经与社会议题出现联动",
url: "https://www.zhihu.com/question/100006",
category_code: "finance",
category_label: "财经",
heat_text: "3.9 万热度",
heat_value: 39000,
reply_count: 68,
upvote_count: 366,
favorite_count: 91,
heart_count: 28
},
{
rank: 7,
title: "短剧与综艺互动模式创新,娱乐内容二次发酵",
url: "https://www.zhihu.com/question/100007",
category_code: "entertainment",
category_label: "娱乐",
heat_text: "4.8 万热度",
heat_value: 48000,
reply_count: 62,
upvote_count: 312,
favorite_count: 74,
heart_count: 26
},
{
rank: 8,
title: "具身智能新进展吸引开发者讨论,科技类目热度稳定",
url: "https://www.zhihu.com/question/100008",
category_code: "technology",
category_label: "科技",
heat_text: "4.1 万热度",
heat_value: 41000,
reply_count: 55,
upvote_count: 295,
favorite_count: 67,
heart_count: 25
},
{
rank: 9,
title: "大型赛事带动城市消费,体育与社会话题并行上涨",
url: "https://www.zhihu.com/question/100009",
category_code: "sports",
category_label: "体育",
heat_text: "4.1 万热度",
heat_value: 41000,
reply_count: 49,
upvote_count: 248,
favorite_count: 61,
heart_count: 19
},
{
rank: 10,
title: "基层治理数字化试点扩围,社会治理议题升温",
url: "https://www.zhihu.com/question/100010",
category_code: "society",
category_label: "社会",
heat_text: "4.4 万热度",
heat_value: 44000,
reply_count: 58,
upvote_count: 301,
favorite_count: 77,
heart_count: 22
},
{
rank: 11,
title: "就业与职业转型讨论走热,社会类目保持高频曝光",
url: "https://www.zhihu.com/question/100011",
category_code: "society",
category_label: "社会",
heat_text: "3.9 万热度",
heat_value: 39000,
reply_count: 46,
upvote_count: 229,
favorite_count: 54,
heart_count: 17
}
]
}
const themeMeta = {
gov_blue_gold: {
label: "政务蓝金",
palette: ["#3EC7FF", "#D8B36A", "#5B8CFF", "#74C69D", "#E8C16E", "#6EE7F9", "#9CC3FF", "#FDBA74", "#FCA5A5", "#A7F3D0"]
},
tech_cyan_blue: {
label: "科技青蓝",
palette: ["#22D3EE", "#5B8CFF", "#99F6FF", "#60A5FA", "#2DD4BF", "#A5F3FC", "#93C5FD", "#C4B5FD", "#F0ABFC", "#67E8F9"]
},
industry_ink_green: {
label: "工业墨绿",
palette: ["#74C69D", "#D6B56E", "#34D399", "#6EE7B7", "#FCD34D", "#A7F3D0", "#86EFAC", "#FBBF24", "#FDBA74", "#EAB308"]
},
meeting_red_gold: {
label: "会议红金",
palette: ["#E05A47", "#D79A43", "#FB7185", "#F59E0B", "#FCA5A5", "#FDBA74", "#FCD34D", "#F87171", "#FBBF24", "#F9A8D4"]
}
}
const state = {
payload: null,
lastSuccessfulPayload: null,
theme: "gov_blue_gold",
drawerOpen: false,
categoryColorMap: {},
lastRenderedAtMs: 0,
status: "正常"
}
const payloadInput = document.getElementById("payloadInput")
const feedback = document.getElementById("feedback")
const summaryGrid = document.getElementById("summaryGrid")
const snapshotIdEl = document.getElementById("snapshotId")
const generatedAtEl = document.getElementById("generatedAt")
const currentTimeEl = document.getElementById("currentTime")
const footerGeneratedAtEl = document.getElementById("footerGeneratedAt")
const footerSkinEl = document.getElementById("footerSkin")
const categoryPills = document.getElementById("categoryPills")
const insightTopList = document.getElementById("insightTopList")
const insightConclusionList = document.getElementById("insightConclusionList")
const tableWrap = document.getElementById("tableWrap")
const drawerToggle = document.getElementById("drawerToggle")
const drawerClose = document.getElementById("drawerClose")
const drawerBackdrop = document.getElementById("drawerBackdrop")
const dataDrawer = document.getElementById("dataDrawer")
const themeSwitcher = document.getElementById("themeSwitcher")
const hexagonSymbol = "path://M12 2 L36 2 L46 18 L36 34 L12 34 L2 18 Z"
let countChart = null
let heatChart = null
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN")
}
function formatTime(timestamp) {
if (!timestamp) return "-"
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return "-"
return date.toLocaleString("zh-CN", { hour12: false })
}
function formatClock(timestamp) {
if (!timestamp) return "-"
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return "-"
return date.toLocaleTimeString("zh-CN", { hour12: false })
}
function formatCompactHeat(value) {
const num = Number(value || 0)
if (num >= 10000) {
return `${(Math.round(num / 100) / 100).toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1")}`
}
return formatNumber(num)
}
function truncateLabel(value, maxLength) {
const text = String(value || "")
if (text.length <= maxLength) return text
return `${text.slice(0, Math.max(0, maxLength - 1))}`
}
function normalizeNumber(value) {
const num = Number(value)
return Number.isFinite(num) ? num : 0
}
function extractJsonCandidate(raw) {
const fencedMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fencedMatch && fencedMatch[1]) {
return fencedMatch[1].trim()
}
const firstBrace = raw.indexOf("{")
const lastBrace = raw.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return raw.slice(firstBrace, lastBrace + 1)
}
return raw
}
function unwrapPayload(parsed) {
if (parsed && parsed.echarts && typeof parsed.echarts === "object") {
return parsed.echarts
}
return parsed
}
function normalizeRow(row, index) {
const rank = normalizeNumber(row?.rank) || index + 1
const categoryCode = String(row?.category_code || row?.category_label || "other").trim() || "other"
const categoryLabel = String(row?.category_label || row?.category_code || "其他").trim() || "其他"
return {
rank,
title: String(row?.title || "未命名话题"),
url: String(row?.url || "#"),
category_code: categoryCode,
category_label: categoryLabel,
heat_text: String(row?.heat_text || ""),
heat_value: normalizeNumber(row?.heat_value),
reply_count: normalizeNumber(row?.reply_count),
upvote_count: normalizeNumber(row?.upvote_count),
favorite_count: normalizeNumber(row?.favorite_count),
heart_count: normalizeNumber(row?.heart_count)
}
}
function deriveCategoriesFromTable(table) {
const map = new Map()
table.forEach((row) => {
const key = row.category_code || row.category_label || "other"
const current = map.get(key) || {
category_code: key,
category_label: row.category_label || "其他",
item_count: 0,
total_heat: 0,
avg_heat: 0
}
current.item_count += 1
current.total_heat += normalizeNumber(row.heat_value)
map.set(key, current)
})
return Array.from(map.values()).map((item) => ({
...item,
avg_heat: item.item_count ? Math.round(item.total_heat / item.item_count) : 0
}))
}
function normalizeCategories(categories, table) {
const source = Array.isArray(categories) && categories.length ? categories : deriveCategoriesFromTable(table)
return source.map((item) => ({
category_code: String(item?.category_code || item?.category_label || "other").trim() || "other",
category_label: String(item?.category_label || item?.category_code || "其他").trim() || "其他",
item_count: normalizeNumber(item?.item_count),
total_heat: normalizeNumber(item?.total_heat),
avg_heat: normalizeNumber(item?.avg_heat)
}))
}
function normalizePayload(parsed) {
const payload = unwrapPayload(parsed)
if (!payload || typeof payload !== "object") {
throw new Error("输入内容不是有效的对象 JSON。")
}
const table = Array.isArray(payload.table) ? payload.table.map(normalizeRow) : []
const categories = normalizeCategories(payload.categories, table)
return {
snapshot_id: String(payload.snapshot_id || "未命名快照"),
generated_at_ms: normalizeNumber(payload.generated_at_ms) || Date.now(),
categories,
table
}
}
function parsePayloadFromInput() {
const raw = payloadInput.value.trim()
if (!raw) {
throw new Error("请先粘贴 skill 返回的 JSON。")
}
const candidate = extractJsonCandidate(raw)
return normalizePayload(JSON.parse(candidate))
}
function setFeedback(message, isError) {
feedback.textContent = message
feedback.classList.toggle("error", Boolean(isError))
}
function getStatusMeta(statusText) {
if (statusText === "解析失败" || statusText === "ECharts 未加载") {
return { className: "kpi-status-danger" }
}
if (statusText === "无数据") {
return { className: "kpi-status-warning" }
}
return { className: "kpi-status-normal" }
}
function getTopCategory(categories) {
return categories.slice().sort((a, b) => Number(b.item_count || 0) - Number(a.item_count || 0))[0]
}
function getHottestItem(table) {
return table.slice().sort((a, b) => Number(b.heat_value || 0) - Number(a.heat_value || 0))[0]
}
function buildSummaryCards(payload, statusText) {
const totalItems = payload.table.length
const totalHeat = payload.categories.reduce((sum, item) => sum + normalizeNumber(item.total_heat), 0)
const topCategory = getTopCategory(payload.categories)
const hottestItem = getHottestItem(payload.table)
const statusMeta = getStatusMeta(statusText)
const cards = [
{
label: "热榜条数",
value: formatNumber(totalItems),
sub: "当前展示条数"
},
{
label: "总热度",
value: formatNumber(totalHeat),
sub: "分类汇总热度"
},
{
label: "主导类别",
value: topCategory ? topCategory.category_label : "-",
sub: topCategory ? `${formatNumber(topCategory.item_count)}` : "暂无数据"
},
{
label: "最高热话题",
value: hottestItem ? hottestItem.title : "-",
sub: hottestItem ? `热度 ${formatCompactHeat(hottestItem.heat_value)}` : "暂无数据",
long: true
},
{
label: "数据状态",
value: statusText,
sub: `上次渲染:${formatClock(state.lastRenderedAtMs)}`,
className: statusMeta.className
}
]
summaryGrid.innerHTML = cards.map((card) => `
<article class="kpi-card">
<div class="kpi-label">${escapeHtml(card.label)}</div>
<div class="kpi-value ${card.long ? "long" : ""} ${card.className || ""}" title="${escapeHtml(card.value)}">${escapeHtml(card.value)}</div>
<div class="kpi-sub">${escapeHtml(card.sub)}</div>
</article>
`).join("")
}
function syncCategoryColorMap(categories) {
const known = { ...state.categoryColorMap }
const usedSlots = new Set(Object.values(known))
const codes = categories.map((item) => item.category_code).filter(Boolean)
const newCodes = Array.from(new Set(codes)).filter((code) => known[code] === undefined).sort()
newCodes.forEach((code) => {
let slot = 0
while (usedSlots.has(slot)) {
slot += 1
}
known[code] = slot
usedSlots.add(slot)
})
state.categoryColorMap = known
}
function getThemePalette() {
const theme = themeMeta[state.theme] || themeMeta.gov_blue_gold
return theme.palette
}
function getCategoryColor(code) {
const palette = getThemePalette()
const slot = state.categoryColorMap[code] ?? 0
return palette[slot % palette.length]
}
function makeGradient(baseColor) {
if (!window.echarts) return baseColor
return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: baseColor },
{ offset: 1, color: `${baseColor}22` }
])
}
function renderCategoryPills(categories) {
if (!categories.length) {
categoryPills.innerHTML = '<div class="table-empty">当前没有可展示的类别汇总。</div>'
return
}
categoryPills.innerHTML = categories.map((category) => {
const color = getCategoryColor(category.category_code)
return `
<span class="category-pill" style="border-color:${color}55;color:${color};background:${color}14;">
<span class="swatch" style="background:${color};color:${color};"></span>
${escapeHtml(category.category_label)} · ${formatNumber(category.item_count)} 条 · 热度 ${formatNumber(category.total_heat)}
</span>
`
}).join("")
}
function medalClassByRank(rank) {
if (rank === 1) return "medal-gold"
if (rank === 2) return "medal-silver"
if (rank === 3) return "medal-bronze"
return ""
}
function renderTopTopics(table) {
if (!table.length) {
insightTopList.innerHTML = '<div class="insight-empty">当前没有可展示的热点话题。</div>'
return
}
const topTopics = table.slice().sort((a, b) => b.heat_value - a.heat_value).slice(0, 3)
insightTopList.innerHTML = topTopics.map((row, index) => `
<div class="insight-item">
<div class="insight-rank ${medalClassByRank(index + 1)}">${index + 1}</div>
<div>
<div class="insight-title" title="${escapeHtml(row.title)}">${escapeHtml(row.title)}</div>
<div class="insight-meta">${escapeHtml(row.category_label)} · 热度 ${escapeHtml(row.heat_text || formatCompactHeat(row.heat_value))}</div>
</div>
</div>
`).join("")
}
function renderConclusions(payload) {
const topCategory = getTopCategory(payload.categories)
const hottestItem = getHottestItem(payload.table)
const conclusions = [
`主导类别:${topCategory ? `${topCategory.category_label}${formatNumber(topCategory.item_count)} 条)` : "暂无数据"}`,
`关注度最高:${hottestItem ? `${hottestItem.title}(热度 ${formatCompactHeat(hottestItem.heat_value)}` : "暂无数据"}`,
`数据更新时间:${formatTime(payload.generated_at_ms)}`
]
insightConclusionList.innerHTML = conclusions.map((item) => `
<div class="conclusion-item" title="${escapeHtml(item)}">${escapeHtml(item)}</div>
`).join("")
}
function renderTable(table) {
if (!table.length) {
tableWrap.innerHTML = '<div class="table-empty">当前没有可展示的明细数据。</div>'
return
}
const rows = table.slice().sort((a, b) => a.rank - b.rank)
tableWrap.innerHTML = `
<table>
<thead>
<tr>
<th>排名</th>
<th>标题</th>
<th>类别</th>
<th>热度</th>
<th>回复</th>
<th>赞同</th>
<th>收藏</th>
<th>红心</th>
</tr>
</thead>
<tbody>
${rows.map((row, index) => {
const rank = row.rank || index + 1
const themeColor = getCategoryColor(row.category_code)
const topClass = rank === 1 ? "top-1" : rank === 2 ? "top-2" : rank === 3 ? "top-3" : ""
return `
<tr class="${topClass}">
<td><span class="rank-badge ${medalClassByRank(rank)}">#${escapeHtml(rank)}</span></td>
<td>
<div class="topic-cell">
<div class="topic-title" title="${escapeHtml(row.title)}">${escapeHtml(row.title)}</div>
<a class="topic-url" href="${escapeHtml(row.url || "#")}" target="_blank" rel="noreferrer" title="${escapeHtml(row.url || "#")}">${escapeHtml(row.url || "-")}</a>
</div>
</td>
<td>
<span class="category-tag" style="border-color:${themeColor}55;color:${themeColor};background:${themeColor}14;">
<span class="swatch" style="background:${themeColor};color:${themeColor};"></span>
${escapeHtml(row.category_label || "其他")}
</span>
</td>
<td>
<div class="heat-value">${escapeHtml(row.heat_text || formatCompactHeat(row.heat_value))}</div>
<div class="muted">数值 ${formatNumber(row.heat_value)}</div>
</td>
<td>${formatNumber(row.reply_count)}</td>
<td>${formatNumber(row.upvote_count)}</td>
<td>${formatNumber(row.favorite_count)}</td>
<td>${formatNumber(row.heart_count)}</td>
</tr>
`
}).join("")}
</tbody>
</table>
`
}
function buildEmptyChartOption(text) {
return {
animation: false,
title: {
text,
left: "center",
top: "center",
textStyle: {
color: getComputedStyle(document.documentElement).getPropertyValue("--muted").trim() || "#8faecc",
fontSize: 14,
fontWeight: 500
}
},
xAxis: { show: false },
yAxis: { show: false },
series: []
}
}
function chartBaseOption(labels) {
const styles = getComputedStyle(document.documentElement)
const text = styles.getPropertyValue("--text").trim() || "#eaf4ff"
const muted = styles.getPropertyValue("--muted").trim() || "#8faecc"
const line = styles.getPropertyValue("--line").trim() || "rgba(62, 199, 255, 0.2)"
const panelStrong = styles.getPropertyValue("--panel-strong").trim() || "rgba(14, 42, 77, 0.97)"
return {
backgroundColor: "transparent",
grid: { left: 56, right: 24, top: 36, bottom: 56 },
tooltip: {
trigger: "axis",
backgroundColor: panelStrong,
borderColor: line,
borderWidth: 1,
textStyle: { color: text },
extraCssText: "box-shadow:0 12px 28px rgba(0,0,0,0.28);border-radius:12px;"
},
xAxis: {
type: "category",
data: labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: line } },
axisLabel: {
color: muted,
fontSize: 12,
interval: 0,
formatter(value) {
return truncateLabel(value, 6)
}
}
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: line, type: "dashed" } },
axisLine: { show: false },
axisLabel: { color: muted, fontSize: 12 }
}
}
}
function renderCountChart(categories) {
if (!countChart) return
if (!categories.length) {
countChart.clear()
countChart.setOption(buildEmptyChartOption("暂无分类数据"))
return
}
const sorted = categories.slice().sort((a, b) => b.item_count - a.item_count)
const labels = sorted.map((item) => item.category_label)
const data = sorted.map((item) => item.item_count)
countChart.setOption({
...chartBaseOption(labels),
series: [
{
name: "热榜条数",
type: "bar",
barWidth: 30,
data: sorted.map((item) => ({
value: item.item_count,
itemStyle: {
color: makeGradient(getCategoryColor(item.category_code)),
borderRadius: [10, 10, 0, 0],
shadowBlur: 14,
shadowColor: `${getCategoryColor(item.category_code)}66`
}
})),
label: {
show: true,
position: "top",
color: getComputedStyle(document.documentElement).getPropertyValue("--text").trim(),
fontSize: 12,
formatter: ({ value }) => formatNumber(value)
}
},
{
name: "顶部强调",
type: "pictorialBar",
symbol: hexagonSymbol,
symbolSize: [20, 14],
symbolOffset: [0, -7],
symbolPosition: "end",
z: 10,
data: sorted.map((item) => ({
value: item.item_count,
itemStyle: {
color: getCategoryColor(item.category_code),
shadowBlur: 10,
shadowColor: `${getCategoryColor(item.category_code)}88`
}
}))
}
]
}, true)
}
function renderHeatChart(categories) {
if (!heatChart) return
if (!categories.length) {
heatChart.clear()
heatChart.setOption(buildEmptyChartOption("暂无热度数据"))
return
}
const sorted = categories.slice().sort((a, b) => b.total_heat - a.total_heat)
const labels = sorted.map((item) => item.category_label)
heatChart.setOption({
...chartBaseOption(labels),
yAxis: {
...chartBaseOption(labels).yAxis,
axisLabel: {
color: getComputedStyle(document.documentElement).getPropertyValue("--muted").trim(),
fontSize: 12,
formatter(value) {
const num = Number(value || 0)
if (num >= 10000) {
return `${Math.round(num / 1000) / 10}`
}
return formatNumber(num)
}
}
},
series: [
{
name: "总热度",
type: "bar",
barWidth: 30,
data: sorted.map((item) => ({
value: item.total_heat,
itemStyle: {
color: makeGradient(getCategoryColor(item.category_code)),
borderRadius: [10, 10, 0, 0],
shadowBlur: 14,
shadowColor: `${getCategoryColor(item.category_code)}66`
}
})),
label: {
show: true,
position: "top",
color: getComputedStyle(document.documentElement).getPropertyValue("--text").trim(),
fontSize: 12,
formatter: ({ value }) => formatCompactHeat(value)
},
markPoint: {
symbol: "pin",
symbolSize: 42,
label: {
color: "#05121d",
formatter: `最高:${truncateLabel(sorted[0].category_label, 6)}`,
fontSize: 11,
fontWeight: 700
},
itemStyle: {
color: getCategoryColor(sorted[0].category_code)
},
data: [{ type: "max", name: "最高热度" }]
}
}
]
}, true)
}
function updateHeaderMeta(payload) {
snapshotIdEl.textContent = payload.snapshot_id || "未命名快照"
generatedAtEl.textContent = formatTime(payload.generated_at_ms)
}
function updateFooterMeta(payload) {
footerGeneratedAtEl.textContent = formatTime(payload.generated_at_ms)
footerSkinEl.textContent = (themeMeta[state.theme] || themeMeta.gov_blue_gold).label
}
function renderDashboard(payload, statusText) {
const nextStatus = statusText || (!window.echarts ? "ECharts 未加载" : payload.table.length === 0 ? "无数据" : "正常")
state.payload = payload
state.status = nextStatus
state.lastRenderedAtMs = Date.now()
syncCategoryColorMap(payload.categories)
updateHeaderMeta(payload)
updateFooterMeta(payload)
buildSummaryCards(payload, nextStatus)
renderCategoryPills(payload.categories)
renderTopTopics(payload.table)
renderConclusions(payload)
renderCountChart(payload.categories)
renderHeatChart(payload.categories)
renderTable(payload.table)
}
function safeLocalStorageGet(key) {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
function safeLocalStorageSet(key, value) {
try {
window.localStorage.setItem(key, value)
} catch {
return null
}
return null
}
function updateThemeButtons() {
themeSwitcher.querySelectorAll(".theme-btn").forEach((button) => {
button.classList.toggle("active", button.dataset.theme === state.theme)
})
}
function resizeCharts() {
if (countChart) countChart.resize()
if (heatChart) heatChart.resize()
}
function setTheme(themeName) {
if (!themeMeta[themeName]) return
state.theme = themeName
document.documentElement.dataset.theme = themeName
safeLocalStorageSet("zhihu-hotlist-theme", themeName)
updateThemeButtons()
if (state.lastSuccessfulPayload) {
renderDashboard(state.lastSuccessfulPayload, state.status)
}
requestAnimationFrame(() => resizeCharts())
}
function openDrawer() {
state.drawerOpen = true
dataDrawer.classList.add("open")
drawerBackdrop.classList.add("open")
dataDrawer.setAttribute("aria-hidden", "false")
requestAnimationFrame(() => payloadInput.focus())
}
function closeDrawer() {
state.drawerOpen = false
dataDrawer.classList.remove("open")
drawerBackdrop.classList.remove("open")
dataDrawer.setAttribute("aria-hidden", "true")
drawerToggle.focus()
requestAnimationFrame(() => resizeCharts())
}
function toggleDrawer() {
if (state.drawerOpen) {
closeDrawer()
} else {
openDrawer()
}
}
function tryRenderFromInput() {
try {
const payload = parsePayloadFromInput()
state.lastSuccessfulPayload = payload
renderDashboard(payload)
const statusText = !window.echarts ? "ECharts 未加载" : payload.table.length === 0 ? "无数据" : "正常"
state.status = statusText
buildSummaryCards(payload, statusText)
setFeedback(`看板已更新:${payload.categories.length} 个类别,${payload.table.length} 条热榜。`, false)
} catch (error) {
state.status = !window.echarts ? "ECharts 未加载" : "解析失败"
if (state.lastSuccessfulPayload) {
renderDashboard(state.lastSuccessfulPayload, state.status)
}
setFeedback(error?.message || "渲染失败,请检查 JSON 格式。", true)
}
}
function formatPayloadIntoInput(payload) {
payloadInput.value = JSON.stringify(payload, null, 2)
}
function startClock() {
const tick = () => {
currentTimeEl.textContent = formatTime(Date.now())
}
tick()
window.setInterval(tick, 1000)
}
function initCharts() {
if (!window.echarts) return
countChart = echarts.init(document.getElementById("categoryCountChart"))
heatChart = echarts.init(document.getElementById("categoryHeatChart"))
}
function bindEvents() {
document.getElementById("renderBtn").addEventListener("click", tryRenderFromInput)
document.getElementById("sampleBtn").addEventListener("click", () => {
formatPayloadIntoInput(defaultPayload)
tryRenderFromInput()
})
document.getElementById("formatBtn").addEventListener("click", () => {
try {
const payload = parsePayloadFromInput()
formatPayloadIntoInput(payload)
setFeedback("JSON 已格式化。", false)
} catch (error) {
setFeedback(error?.message || "当前内容不是合法 JSON。", true)
}
})
drawerToggle.addEventListener("click", toggleDrawer)
drawerClose.addEventListener("click", closeDrawer)
drawerBackdrop.addEventListener("click", closeDrawer)
dataDrawer.addEventListener("transitionend", resizeCharts)
themeSwitcher.querySelectorAll(".theme-btn").forEach((button) => {
button.addEventListener("click", () => setTheme(button.dataset.theme))
})
payloadInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault()
tryRenderFromInput()
}
})
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.drawerOpen) {
event.preventDefault()
closeDrawer()
}
})
window.addEventListener("resize", resizeCharts)
}
function init() {
const storedTheme = safeLocalStorageGet("zhihu-hotlist-theme")
if (storedTheme && themeMeta[storedTheme]) {
state.theme = storedTheme
document.documentElement.dataset.theme = storedTheme
}
updateThemeButtons()
initCharts()
bindEvents()
formatPayloadIntoInput(defaultPayload)
startClock()
const normalizedDefault = normalizePayload(defaultPayload)
state.lastSuccessfulPayload = normalizedDefault
renderDashboard(normalizedDefault, !window.echarts ? "ECharts 未加载" : "正常")
if (!window.echarts) {
setFeedback("ECharts 脚本加载失败,请确认当前环境可以访问 CDN。", true)
} else {
setFeedback("已加载示例数据。", false)
}
}
init()
</script>
</body>
</html>