Files
skill-lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html
木炎 51913555ad feat: add initial skill authoring workspace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:34:56 +08:00

2445 lines
74 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;
/* V2 height contract: header / KPI / charts / table / footer */
grid-template-rows: 92px 140px 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.35fr) minmax(420px, 1.15fr);
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-chip-row {
display: flex;
align-items: stretch;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 14px;
width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable both-edges;
padding-bottom: 4px;
}
/* Ensure horizontal scroll works (no forced shrink-to-fit that overflows header). */
.header-chip-row > * {
flex: 0 0 auto;
min-width: 0;
}
.chip-group {
display: flex;
align-items: stretch;
gap: 10px;
min-width: 0;
}
.chip-group-meta {
flex: 1 1 auto;
justify-content: flex-end;
min-width: max-content;
}
.chip-group-actions {
flex: 0 0 auto;
justify-content: flex-end;
min-width: max-content;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 52px;
padding: 10px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--muted);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.035) 100%);
font-size: 12px;
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.meta-chip strong {
color: var(--text);
font-weight: 600;
font-family: var(--font-number);
letter-spacing: 0.02em;
}
.meta-chip.status-chip {
border-color: rgba(116, 198, 157, 0.32);
color: rgba(234, 244, 255, 0.92);
background: linear-gradient(180deg, rgba(116, 198, 157, 0.16) 0%, rgba(116, 198, 157, 0.08) 100%);
}
.meta-chip.status-chip strong {
color: var(--success);
font-family: var(--font-main);
letter-spacing: 0;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 4px rgba(116, 198, 157, 0.12), 0 0 10px rgba(116, 198, 157, 0.28);
flex: 0 0 auto;
}
.meta-chip.action-chip {
padding: 8px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.065) 0%, rgba(255, 255, 255, 0.028) 100%);
}
.meta-chip.action-chip .theme-switcher {
gap: 8px;
}
#snapshotId {
display: inline-block;
min-width: 0;
max-width: 18ch;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.theme-switcher {
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 10px;
min-width: max-content;
}
.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 track is fixed (140px) in the V2 screen grid contract, so the KPI layout
must be deterministic and never wrap.
Spec: 2 large + 3 small cards in one row.
We use a 5-column grid where the first two columns are wider.
*/
.kpi-grid {
display: grid;
grid-template-columns: 2fr 2fr 1.2fr 1.2fr 1.2fr;
gap: 14px;
align-items: stretch;
}
.kpi-card {
border-radius: 16px;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
gap: 8px 12px;
min-width: 0;
grid-column: auto;
align-content: start;
}
/* V2 KPI tiers (kept for typography differences, not layout spans). */
.kpi-card.kpi-card-large {
min-height: 124px;
align-content: center;
}
.kpi-card.kpi-card-small {
min-height: 0;
}
.kpi-label,
.kpi-sub,
.kpi-value {
grid-column: 1 / 2;
min-width: 0;
}
.kpi-badge-slot {
grid-column: 2 / 3;
grid-row: 1 / 2;
justify-self: end;
align-self: start;
min-width: 0;
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
}
.kpi-badge {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 120px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: var(--text);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-badge.is-muted {
color: var(--muted);
}
.kpi-badge.has-tone {
border-color: currentColor;
background: transparent;
}
.kpi-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: 0 0 10px currentColor;
}
.kpi-value {
font-family: var(--font-number);
font-size: clamp(20px, 2vw, 24px);
line-height: 1.15;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-card.kpi-card-large .kpi-value {
font-size: clamp(30px, 2.6vw, 36px);
}
/* 最高热话题2-line clamp */
.kpi-card.kpi-card-small .kpi-value.long {
font-size: clamp(20px, 1.75vw, 24px);
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.kpi-sub {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-label {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* KPI typography handled by V2 tier rules above. */
.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 {
position: relative;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
padding: 0 0 14px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.panel-head::before {
content: "";
position: absolute;
left: 0;
top: 2px;
width: 4px;
height: 22px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%);
opacity: 0.9;
}
.panel-head::after {
content: "";
position: absolute;
right: 0;
top: 0;
width: 84px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
opacity: 0.85;
pointer-events: none;
}
.panel-title-wrap {
min-width: 0;
}
.panel-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.panel-kicker::before {
content: "";
width: 22px;
height: 1px;
background: linear-gradient(90deg, var(--accent) 0%, rgba(216, 179, 106, 0.08) 100%);
}
.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 {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.025) 100%);
border-radius: 14px;
padding: 14px 14px 14px 16px;
}
.insight-section::before {
content: "";
position: absolute;
left: 0;
top: 14px;
bottom: 14px;
width: 3px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent) 0%, rgba(255, 255, 255, 0.1) 100%);
opacity: 0.9;
}
.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: none;
}
.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: nowrap;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
scrollbar-width: thin;
}
.category-pill,
.category-tag {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: max-content;
border-radius: 14px;
padding: 10px 14px;
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);
white-space: nowrap;
}
.swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: none;
}
.table-wrap {
min-height: 0;
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.09);
background: rgba(2, 10, 22, 0.24);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
table {
width: 100%;
min-width: 1020px;
border-collapse: separate;
border-spacing: 0;
}
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.1);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
backdrop-filter: blur(10px);
}
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);
}
/* weak highlight for Top3 only */
tbody tr.top-1 { background: rgba(255, 215, 128, 0.038); }
tbody tr.top-2 { background: rgba(207, 219, 232, 0.032); }
tbody tr.top-3 { background: rgba(201, 145, 89, 0.032); }
.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);
}
#footerNote {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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) {
body {
height: auto;
min-height: 100%;
overflow-y: auto;
}
.screen {
height: auto;
min-height: 1080px;
padding: 16px;
gap: 12px;
grid-template-rows: auto auto auto auto auto;
}
.screen-header {
grid-template-columns: 1fr;
align-items: start;
padding: 18px;
}
.header-side {
justify-items: start;
}
.header-chip-row {
flex-wrap: wrap;
}
.chip-group {
flex-wrap: wrap;
}
.header-chip-row,
.chip-group-meta,
.chip-group-actions,
.theme-switcher {
justify-content: flex-start;
}
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.kpi-card,
.kpi-card.kpi-card-large,
.kpi-card.kpi-card-small {
grid-column: auto;
}
.main-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.insights-panel {
grid-column: 1 / -1;
}
}
@media (max-height: 900px) {
body {
height: auto;
min-height: 100%;
overflow-y: auto;
}
.screen {
height: auto;
min-height: 1080px;
grid-template-rows: auto auto auto auto auto;
}
}
@media (max-width: 1280px), (max-height: 900px) {
.kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kpi-card,
.kpi-card.kpi-card-large,
.kpi-card.kpi-card-small {
grid-column: span 1;
}
.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-grid {
grid-template-columns: 1fr;
}
.kpi-card,
.kpi-card.kpi-card-large,
.kpi-card.kpi-card-small {
grid-column: span 1;
}
.header-title {
font-size: 28px;
}
.data-drawer {
left: 8px;
right: 8px;
bottom: 8px;
}
.drawer-actions,
.theme-switcher,
.footer-meta,
.header-chip-row,
.chip-group {
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-chip-row">
<div class="chip-group chip-group-meta">
<div class="meta-chip" id="metaCurrentTime">
<span>当前时间</span>
<strong id="currentTime">-</strong>
</div>
<div class="meta-chip" id="metaSnapshot">
<span>快照</span>
<strong id="snapshotId">demo-snapshot</strong>
</div>
<div class="meta-chip" id="metaGeneratedAt">
<span>生成时间</span>
<strong id="generatedAt">-</strong>
</div>
<div class="meta-chip status-chip" id="metaStatus">
<span class="status-dot" aria-hidden="true"></span>
<span>状态</span>
<strong>在线</strong>
</div>
</div>
<div class="chip-group chip-group-actions">
<div class="meta-chip action-chip" id="metaThemeSwitcher">
<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>
</div>
</div>
<div class="meta-chip action-chip" id="metaDrawerAction">
<button class="drawer-toggle primary" type="button" id="drawerToggle">数据输入</button>
</div>
</div>
</div>
</div>
</header>
<section class="kpi-grid" id="summaryGrid"></section>
<section class="main-grid">
<article class="panel chart-panel">
<div class="panel-head">
<div class="panel-title-wrap">
<div class="panel-kicker">Chart 01</div>
<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">
<div class="panel-kicker">Chart 02</div>
<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" id="insightTipsList"></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 metaStatus = document.getElementById("metaStatus")
const metaStatusDot = metaStatus ? metaStatus.querySelector(".status-dot") : null
const metaStatusText = metaStatus ? metaStatus.querySelector("strong") : null
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 new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(date)
}
function formatClock(timestamp) {
if (!timestamp) return "-"
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return "-"
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(date)
}
function displayStructural(value) {
const text = String(value ?? "").trim()
if (!text) return "-"
if (text === "#" || text === "null" || text === "undefined") return "-"
return text
}
function displaySnapshotId(value) {
const text = String(value ?? "").trim()
if (!text || text === "null" || text === "undefined") return "未命名快照"
return text
}
function formatHeatDisplay(heatText, heatValue) {
const text = String(heatText ?? "").trim()
if (text) return text
return formatNumber(heatValue)
}
function getGeneratedAtDisplay(payload) {
if (!payload) return "-"
const generatedAtLocal = String(payload.generatedAtLocal ?? "").trim()
if (generatedAtLocal && generatedAtLocal !== "-") return generatedAtLocal
const formatted = formatTime(payload.generated_at_ms)
if (!formatted || formatted === "-") return "-"
return formatted
}
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),
generatedAtLocal: formatTime(payload.generated_at_ms),
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) {
const canonicalStatus = String(statusText || "").trim() || "正常"
if (canonicalStatus === "解析失败" || canonicalStatus === "ECharts 未加载") {
return { text: canonicalStatus, className: "kpi-status-danger", cssVar: "--danger" }
}
if (canonicalStatus === "无数据") {
return { text: canonicalStatus, className: "kpi-status-warning", cssVar: "--warning" }
}
return { text: canonicalStatus, className: "kpi-status-normal", cssVar: "--success" }
}
function resolveDashboardStatus(payload, overrideStatus) {
if (overrideStatus) return getStatusMeta(overrideStatus).text
if (!window.echarts) return getStatusMeta("ECharts 未加载").text
if (!payload || !Array.isArray(payload.table) || payload.table.length === 0) return getStatusMeta("无数据").text
return getStatusMeta("正常").text
}
function compareTextAsc(left, right) {
return String(left || "").localeCompare(String(right || ""), "zh-CN")
}
function compareTopCategory(left, right) {
return Number(right?.item_count || 0) - Number(left?.item_count || 0)
|| Number(right?.total_heat || 0) - Number(left?.total_heat || 0)
|| compareTextAsc(left?.category_label, right?.category_label)
}
function compareHottestItem(left, right) {
return Number(right?.heat_value || 0) - Number(left?.heat_value || 0)
|| Number(left?.rank || 0) - Number(right?.rank || 0)
|| compareTextAsc(left?.title, right?.title)
}
function compareCapsuleCategory(left, right) {
return Number(right?.total_heat || 0) - Number(left?.total_heat || 0)
|| Number(right?.item_count || 0) - Number(left?.item_count || 0)
|| compareTextAsc(left?.category_label, right?.category_label)
}
function getTopCategory(categories) {
return categories.slice().sort(compareTopCategory)[0]
}
function getHottestItem(table) {
return table.slice().sort(compareHottestItem)[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(totalHeat),
sub: `汇总 ${formatNumber(payload.categories.length)} 个分类`,
large: true,
badge: {
text: `${formatNumber(totalItems)}`,
title: `热榜条数 ${formatNumber(totalItems)}`
}
},
{
label: "主导类别",
value: topCategory ? displayStructural(topCategory.category_label) : "-",
sub: topCategory ? `${formatNumber(topCategory.item_count)} 条,热度 ${formatNumber(topCategory.total_heat)}` : "暂无数据",
large: true,
badge: topCategory ? {
text: formatCompactHeat(topCategory.total_heat),
title: `主导类别总热度 ${formatNumber(topCategory.total_heat)}`
} : {
text: "暂无数据",
title: "暂无数据",
muted: true
}
},
{
label: "热榜条数",
value: formatNumber(totalItems),
sub: `覆盖 ${formatNumber(payload.categories.length)} 个类别`,
badge: {
text: "条目",
title: "热榜条目数",
muted: true
}
},
{
label: "最高热话题",
value: hottestItem ? displayStructural(hottestItem.title) : "-",
sub: hottestItem ? `热度 ${formatHeatDisplay(hottestItem.heat_text, hottestItem.heat_value)}` : "暂无数据",
long: true,
badge: hottestItem ? {
text: displayStructural(hottestItem.category_label),
title: `所属类别 ${displayStructural(hottestItem.category_label)}`
} : {
text: "暂无数据",
title: "暂无数据",
muted: true
}
},
{
label: "数据状态",
value: statusMeta.text,
sub: `上次渲染:${formatClock(state.lastRenderedAtMs)}`,
className: statusMeta.className,
badge: {
text: statusMeta.text,
title: `当前状态 ${statusMeta.text}`,
dotColorVar: statusMeta.cssVar
}
}
]
summaryGrid.innerHTML = cards.map((card) => {
const badgeHtml = card.badge ? `
<div class="kpi-badge-slot">
<span class="kpi-badge ${card.badge.muted ? "is-muted" : ""} ${card.badge.dotColorVar ? "has-tone" : ""}" title="${escapeHtml(card.badge.title || card.badge.text)}"${card.badge.dotColorVar ? ` style="color: var(${card.badge.dotColorVar}); border-color: currentColor; background: transparent;"` : ""}>
${card.badge.dotColorVar ? '<span class="kpi-badge-dot" aria-hidden="true" style="background: currentColor;"></span>' : ""}
<span>${escapeHtml(card.badge.text)}</span>
</span>
</div>
` : '<div class="kpi-badge-slot" aria-hidden="true"></div>'
return `
<article class="kpi-card ${card.large ? "kpi-card-large" : "kpi-card-small"}">
<div class="kpi-label">${escapeHtml(card.label)}</div>
${badgeHtml}
<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
}
const sorted = categories.slice().sort(compareCapsuleCategory)
categoryPills.innerHTML = sorted.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(displayStructural(category.category_label))}
<span>· ${formatNumber(category.item_count)} 条</span>
<span>· 热度 ${formatNumber(category.total_heat)}</span>
</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(compareHottestItem).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(displayStructural(row.title))}">${escapeHtml(displayStructural(row.title))}</div>
<div class="insight-meta">${escapeHtml(displayStructural(row.category_label))} · 热度 ${escapeHtml(formatHeatDisplay(row.heat_text, row.heat_value))}</div>
</div>
</div>
`).join("")
}
function renderTips() {
const tips = [
"数据输入:打开右上角“数据输入”,粘贴 echarts JSON或包含 echarts 字段的 payload后点击渲染。",
"颜色口径:同一类别在图表、胶囊带、表格标签中颜色一致。",
"数据口径:热度来自热榜展示值;互动指标为空则显示 0。"
]
const el = document.getElementById("insightTipsList")
if (!el) return
el.innerHTML = tips.map((item) => `
<div class="conclusion-item" title="${escapeHtml(item)}">${escapeHtml(item)}</div>
`).join("")
}
function renderConclusions(payload) {
const topCategory = getTopCategory(payload.categories)
const hottestItem = getHottestItem(payload.table)
const topCategoryLabel = topCategory ? displayStructural(topCategory.category_label) : "-"
const topCategoryCount = topCategory ? formatNumber(topCategory.item_count) : "0"
const hottestTitle = hottestItem ? displayStructural(hottestItem.title) : "-"
const hottestHeat = hottestItem ? formatHeatDisplay(hottestItem.heat_text, hottestItem.heat_value) : "0"
const generatedAtLocal = getGeneratedAtDisplay(payload)
const conclusions = [
`主导类别:${topCategoryLabel}${topCategoryCount} 条)`,
`关注度最高:${hottestTitle}(热度 ${hottestHeat}`,
`数据更新时间:${generatedAtLocal}`
]
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(displayStructural(row.title))}">${escapeHtml(displayStructural(row.title))}</div>
<a class="topic-url" href="${escapeHtml(displayStructural(row.url)) === "-" ? "#" : escapeHtml(displayStructural(row.url))}" target="_blank" rel="noreferrer" title="${escapeHtml(displayStructural(row.url))}">${escapeHtml(displayStructural(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(displayStructural(row.category_label))}
</span>
</td>
<td>
<div class="heat-value">${escapeHtml(formatHeatDisplay(row.heat_text, 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: 52, 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,
margin: 16,
formatter(value) {
return truncateLabel(value, 6)
}
}
},
yAxis: {
type: "value",
nameTextStyle: {
color: muted,
fontSize: 11,
padding: [0, 0, 8, 0]
},
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(compareTopCategory)
const labels = sorted.map((item) => item.category_label)
countChart.setOption({
...chartBaseOption(labels),
yAxis: {
...chartBaseOption(labels).yAxis,
name: "条目数"
},
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",
distance: 10,
color: getComputedStyle(document.documentElement).getPropertyValue("--text").trim(),
fontSize: 12,
fontWeight: 700,
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(compareCapsuleCategory)
const labels = sorted.map((item) => item.category_label)
heatChart.setOption({
...chartBaseOption(labels),
yAxis: {
...chartBaseOption(labels).yAxis,
name: "热度值",
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",
distance: 10,
color: getComputedStyle(document.documentElement).getPropertyValue("--text").trim(),
fontSize: 12,
fontWeight: 700,
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 = displaySnapshotId(payload?.snapshot_id)
generatedAtEl.textContent = getGeneratedAtDisplay(payload)
}
function updateHeaderStatus(statusText) {
if (!metaStatus || !metaStatusText || !metaStatusDot) return
const statusMeta = getStatusMeta(statusText)
const computed = getComputedStyle(document.documentElement)
const statusColor = computed.getPropertyValue(statusMeta.cssVar).trim() || computed.getPropertyValue("--success").trim()
metaStatusText.textContent = statusMeta.text
metaStatus.style.color = "rgba(234, 244, 255, 0.92)"
metaStatus.style.borderColor = `${statusColor}55`
metaStatus.style.background = `${statusColor}14`
metaStatusDot.style.background = statusColor
metaStatusDot.style.boxShadow = `0 0 10px ${statusColor}66`
metaStatusText.style.color = statusColor
}
function updateFooterMeta(payload) {
const generatedAtLocal = getGeneratedAtDisplay(payload)
footerGeneratedAtEl.textContent = generatedAtLocal
footerSkinEl.textContent = (themeMeta[state.theme] || themeMeta.gov_blue_gold).label
}
function renderDashboard(payload, statusText, options = {}) {
const resolvedStatus = resolveDashboardStatus(payload, statusText)
const statusMeta = getStatusMeta(resolvedStatus)
const shouldUpdateLastRenderedAtMs = options.updateLastRenderedAtMs !== false
state.payload = payload
state.status = statusMeta.text
if (shouldUpdateLastRenderedAtMs) {
state.lastRenderedAtMs = Date.now()
}
syncCategoryColorMap(payload.categories)
updateHeaderMeta(payload)
updateHeaderStatus(statusMeta.text)
updateFooterMeta(payload)
buildSummaryCards(payload, statusMeta.text)
renderCategoryPills(payload.categories)
renderTopTopics(payload.table)
renderConclusions(payload)
renderTips()
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
const statusText = resolveDashboardStatus(payload)
state.status = statusText
renderDashboard(payload, statusText)
setFeedback(`看板已更新:${payload.categories.length} 个类别,${payload.table.length} 条热榜。`, false)
} catch (error) {
state.status = resolveDashboardStatus(state.lastSuccessfulPayload, !window.echarts ? "ECharts 未加载" : "解析失败")
if (state.lastSuccessfulPayload) {
renderDashboard(state.lastSuccessfulPayload, state.status, { updateLastRenderedAtMs: false })
}
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, resolveDashboardStatus(normalizedDefault, !window.echarts ? "ECharts 未加载" : "正常"))
if (!window.echarts) {
setFeedback("ECharts 脚本加载失败,请确认当前环境可以访问 CDN。", true)
} else {
setFeedback("已加载示例数据。", false)
}
}
init()
</script>
</body>
</html>