|
|
@@ -0,0 +1,1021 @@
|
|
|
+import type { CategoryNode, WeightDimMeta } from '../types/category'
|
|
|
+import type { DemandsByCategory } from '../types/demand'
|
|
|
+
|
|
|
+export interface CategoryTreeExportPayload {
|
|
|
+ nodes: CategoryNode[]
|
|
|
+ dims: WeightDimMeta[]
|
|
|
+ bizDt: string | null
|
|
|
+ demandsByCategory: DemandsByCategory
|
|
|
+}
|
|
|
+
|
|
|
+/** Escape text for embedding inside HTML (not attribute). */
|
|
|
+function escHtml(text: string): string {
|
|
|
+ return text
|
|
|
+ .replace(/&/g, '&')
|
|
|
+ .replace(/</g, '<')
|
|
|
+ .replace(/>/g, '>')
|
|
|
+ .replace(/"/g, '"')
|
|
|
+}
|
|
|
+
|
|
|
+/** Safely embed JSON in a <script type="application/json"> block. */
|
|
|
+function embedJson(data: unknown): string {
|
|
|
+ return JSON.stringify(data)
|
|
|
+ .replace(/</g, '\\u003c')
|
|
|
+ .replace(/>/g, '\\u003e')
|
|
|
+ .replace(/&/g, '\\u0026')
|
|
|
+ .replace(/\u2028/g, '\\u2028')
|
|
|
+ .replace(/\u2029/g, '\\u2029')
|
|
|
+}
|
|
|
+
|
|
|
+function buildFilename(bizDt: string | null): string {
|
|
|
+ const stamp = bizDt || new Date().toISOString().slice(0, 10)
|
|
|
+ return `category-tree-${stamp}.html`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Build a standalone HTML snapshot of the category tree page.
|
|
|
+ * Looks and behaves like the live page; all data is embedded (offline).
|
|
|
+ */
|
|
|
+export function buildCategoryTreeHtml(payload: CategoryTreeExportPayload): string {
|
|
|
+ const dataJson = embedJson({
|
|
|
+ nodes: payload.nodes,
|
|
|
+ dims: payload.dims,
|
|
|
+ bizDt: payload.bizDt,
|
|
|
+ demandsByCategory: payload.demandsByCategory,
|
|
|
+ })
|
|
|
+
|
|
|
+ const bizLabel = payload.bizDt
|
|
|
+ ? `biz_dt ${escHtml(payload.bizDt)} · avg`
|
|
|
+ : '暂无权重数据'
|
|
|
+ const bizClass = payload.bizDt ? 'biz-dt' : 'biz-dt warn'
|
|
|
+
|
|
|
+ return `<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="utf-8" />
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
+ <title>全局分类树${payload.bizDt ? ` · ${escHtml(payload.bizDt)}` : ''}(导出)</title>
|
|
|
+ <style>
|
|
|
+${EXPORT_CSS}
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="app-shell">
|
|
|
+ <nav class="nav">
|
|
|
+ <span class="brand">SupplyAgent</span>
|
|
|
+ <span class="nav-badge">导出快照</span>
|
|
|
+ <span class="current">全局分类树</span>
|
|
|
+ </nav>
|
|
|
+ <main class="main">
|
|
|
+ <div class="category-tree" id="app" style="--col-width:180px;--col-gap:36px">
|
|
|
+ <header class="toolbar">
|
|
|
+ <div class="title-row">
|
|
|
+ <h1>全局分类树</h1>
|
|
|
+ <span class="${bizClass}">${bizLabel}</span>
|
|
|
+ </div>
|
|
|
+ <div class="controls">
|
|
|
+ <label class="control">
|
|
|
+ <span>展开到第</span>
|
|
|
+ <select id="depth-select"></select>
|
|
|
+ <span>层</span>
|
|
|
+ </label>
|
|
|
+ <button type="button" class="btn" id="expand-all">全部展开</button>
|
|
|
+ <span class="hint" id="hint"></span>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+ <div class="dim-bar">
|
|
|
+ <div class="tabs" id="tabs" role="tablist" aria-label="热度维度"></div>
|
|
|
+ <div class="legend" id="legend" hidden aria-hidden="true">
|
|
|
+ <span class="legend-label">冷</span>
|
|
|
+ <div class="legend-bar"></div>
|
|
|
+ <span class="legend-label">热</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div id="empty" class="empty" hidden></div>
|
|
|
+ <div id="tree-panel" class="tree-panel" hidden>
|
|
|
+ <div class="tree-scroll-inner">
|
|
|
+ <div class="level-headers" id="level-headers"></div>
|
|
|
+ <div class="forest" id="forest"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="drawer-overlay" class="overlay" hidden>
|
|
|
+ <aside class="drawer" role="dialog" aria-label="需求词列表">
|
|
|
+ <header class="drawer-header">
|
|
|
+ <div>
|
|
|
+ <h2 id="drawer-title">分类节点</h2>
|
|
|
+ <p class="sub" id="drawer-sub"></p>
|
|
|
+ </div>
|
|
|
+ <button type="button" class="close" id="drawer-close" title="关闭">×</button>
|
|
|
+ </header>
|
|
|
+ <div class="drawer-body" id="drawer-body"></div>
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <script id="export-data" type="application/json">${dataJson}</script>
|
|
|
+ <script>
|
|
|
+${EXPORT_JS}
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>`
|
|
|
+}
|
|
|
+
|
|
|
+export function downloadCategoryTreeHtml(payload: CategoryTreeExportPayload): void {
|
|
|
+ const html = buildCategoryTreeHtml(payload)
|
|
|
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = url
|
|
|
+ a.download = buildFilename(payload.bizDt)
|
|
|
+ document.body.appendChild(a)
|
|
|
+ a.click()
|
|
|
+ a.remove()
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+}
|
|
|
+
|
|
|
+const EXPORT_CSS = `
|
|
|
+:root {
|
|
|
+ font-family: 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
|
|
+ line-height: 1.5;
|
|
|
+ font-weight: 400;
|
|
|
+ color: #0f172a;
|
|
|
+ background: #f1f5f9;
|
|
|
+ font-synthesis: none;
|
|
|
+ text-rendering: optimizeLegibility;
|
|
|
+ -webkit-font-smoothing: antialiased;
|
|
|
+}
|
|
|
+* { box-sizing: border-box; }
|
|
|
+html, body {
|
|
|
+ margin: 0;
|
|
|
+ min-width: 320px;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+body {
|
|
|
+ background:
|
|
|
+ radial-gradient(ellipse 80% 50% at 0% 0%, #e0e7ff 0%, transparent 55%),
|
|
|
+ radial-gradient(ellipse 60% 40% at 100% 0%, #dbeafe 0%, transparent 50%),
|
|
|
+ #f1f5f9;
|
|
|
+}
|
|
|
+button, select { font-family: inherit; }
|
|
|
+
|
|
|
+.app-shell {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100vh;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.nav {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+ padding: 0 28px;
|
|
|
+ height: 48px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
+ background: rgba(255, 255, 255, 0.82);
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+}
|
|
|
+.brand {
|
|
|
+ font-weight: 700;
|
|
|
+ font-size: 15px;
|
|
|
+ color: #0f172a;
|
|
|
+ letter-spacing: -0.02em;
|
|
|
+}
|
|
|
+.nav-badge {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 24px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #4338ca;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.current {
|
|
|
+ margin-left: auto;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+.main {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 20px 28px 24px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.category-tree {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+.toolbar {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: baseline;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px 24px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.title-row {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 10px 14px;
|
|
|
+}
|
|
|
+.toolbar h1 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #0f172a;
|
|
|
+ letter-spacing: -0.02em;
|
|
|
+}
|
|
|
+.biz-dt {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #64748b;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+}
|
|
|
+.biz-dt.warn { color: #b45309; }
|
|
|
+.controls {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px 14px;
|
|
|
+}
|
|
|
+.control {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #334155;
|
|
|
+}
|
|
|
+.control select {
|
|
|
+ height: 32px;
|
|
|
+ padding: 0 8px;
|
|
|
+ border: 1px solid #cbd5e1;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #fff;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #0f172a;
|
|
|
+}
|
|
|
+.btn {
|
|
|
+ height: 32px;
|
|
|
+ padding: 0 12px;
|
|
|
+ border: 1px solid #334155;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #0f172a;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 13px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.btn:hover { background: #1e293b; }
|
|
|
+.hint { font-size: 12px; color: #94a3b8; }
|
|
|
+
|
|
|
+.dim-bar {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 10px 16px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.tabs {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 4px;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #f8fafc;
|
|
|
+}
|
|
|
+.tab {
|
|
|
+ height: 32px;
|
|
|
+ padding: 0 12px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-radius: 7px;
|
|
|
+ background: transparent;
|
|
|
+ color: #475569;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.tab:hover { color: #0f172a; background: #fff; }
|
|
|
+.tab.active {
|
|
|
+ background: #0f172a;
|
|
|
+ border-color: #0f172a;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+.legend {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.legend[hidden] { display: none !important; }
|
|
|
+.legend-label { font-size: 11px; color: #94a3b8; }
|
|
|
+.legend-bar {
|
|
|
+ width: 120px;
|
|
|
+ height: 10px;
|
|
|
+ border-radius: 999px;
|
|
|
+ border: 1px solid rgba(15, 23, 42, 0.08);
|
|
|
+ background: linear-gradient(
|
|
|
+ 90deg,
|
|
|
+ rgb(239, 68, 68) 0%,
|
|
|
+ rgb(255, 255, 255) 50%,
|
|
|
+ rgb(34, 197, 94) 100%
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ min-width: 0;
|
|
|
+ width: 100%;
|
|
|
+ overflow: scroll;
|
|
|
+ overscroll-behavior: contain;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(255, 255, 255, 0.55);
|
|
|
+ cursor: grab;
|
|
|
+ scrollbar-gutter: stable;
|
|
|
+}
|
|
|
+.tree-panel[hidden], .empty[hidden] { display: none !important; }
|
|
|
+.tree-panel.is-panning {
|
|
|
+ cursor: grabbing;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+.tree-panel::-webkit-scrollbar { width: 12px; height: 12px; }
|
|
|
+.tree-panel::-webkit-scrollbar-thumb {
|
|
|
+ background: #94a3b8;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 2px solid transparent;
|
|
|
+ background-clip: content-box;
|
|
|
+}
|
|
|
+.tree-panel::-webkit-scrollbar-track {
|
|
|
+ background: #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+.tree-scroll-inner {
|
|
|
+ display: inline-block;
|
|
|
+ min-width: 100%;
|
|
|
+ padding: 8px 12px 24px;
|
|
|
+ vertical-align: top;
|
|
|
+}
|
|
|
+.level-headers {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: stretch;
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 2;
|
|
|
+ background: rgba(248, 250, 252, 0.96);
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding: 8px 0;
|
|
|
+ width: max-content;
|
|
|
+ min-width: 100%;
|
|
|
+}
|
|
|
+.level-header {
|
|
|
+ width: var(--col-width, 180px);
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-right: var(--col-gap, 36px);
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #475569;
|
|
|
+ letter-spacing: 0.02em;
|
|
|
+}
|
|
|
+.level-header:last-child { margin-right: 0; }
|
|
|
+.forest {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 16px;
|
|
|
+ width: max-content;
|
|
|
+ min-width: 100%;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ padding: 48px;
|
|
|
+ text-align: center;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-node {
|
|
|
+ --line-y: 20px;
|
|
|
+ --line-color: #cbd5e1;
|
|
|
+ --gap: var(--col-gap, 36px);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: flex-start;
|
|
|
+}
|
|
|
+.node-card {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ width: var(--col-width, 180px);
|
|
|
+ min-height: 40px;
|
|
|
+ padding: 8px 10px;
|
|
|
+ border: 1px solid #d8dee6;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
|
|
+ flex-shrink: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.node-card.muted { opacity: 0.72; }
|
|
|
+.toggle {
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border: 1px solid rgba(15, 23, 42, 0.18);
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgba(255, 255, 255, 0.65);
|
|
|
+ color: #334155;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1;
|
|
|
+ cursor: pointer;
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+.toggle:hover {
|
|
|
+ border-color: #64748b;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+.toggle-spacer { width: 22px; flex-shrink: 0; }
|
|
|
+.node-main {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.node-text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
+.node-name {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.3;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+.node-weight {
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ opacity: 0.85;
|
|
|
+ letter-spacing: 0.01em;
|
|
|
+}
|
|
|
+.inspect {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ margin-top: -1px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: transparent;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+.inspect:hover { background: rgba(15, 23, 42, 0.08); }
|
|
|
+.children {
|
|
|
+ --half-gap: calc(var(--gap) / 2);
|
|
|
+ --sibling-gap: 10px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ list-style: none;
|
|
|
+ margin: 0;
|
|
|
+ padding: 0 0 0 var(--gap);
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.children::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: var(--line-y);
|
|
|
+ width: var(--half-gap);
|
|
|
+ border-top: 2px solid var(--line-color);
|
|
|
+}
|
|
|
+.child { position: relative; }
|
|
|
+.child:not(:last-child) { padding-bottom: var(--sibling-gap); }
|
|
|
+.child::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ top: var(--line-y);
|
|
|
+ left: 0;
|
|
|
+ width: var(--half-gap);
|
|
|
+ margin-left: calc(-1 * var(--half-gap));
|
|
|
+ border-top: 2px solid var(--line-color);
|
|
|
+}
|
|
|
+.child::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ margin-left: calc(-1 * var(--half-gap));
|
|
|
+ border-left: 2px solid var(--line-color);
|
|
|
+}
|
|
|
+.child:not(:first-child):not(:last-child)::after { top: 0; bottom: 0; }
|
|
|
+.child:first-child:not(:last-child)::after { top: var(--line-y); bottom: 0; }
|
|
|
+.child:last-child:not(:first-child)::after { top: 0; height: var(--line-y); }
|
|
|
+.child:only-child::after { display: none; }
|
|
|
+
|
|
|
+.overlay {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 1000;
|
|
|
+ background: rgba(15, 23, 42, 0.28);
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+.overlay[hidden] { display: none !important; }
|
|
|
+.drawer {
|
|
|
+ width: min(480px, 100vw);
|
|
|
+ height: 100%;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.drawer-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 20px 20px 16px;
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
+}
|
|
|
+.drawer-header h2 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ color: #0f172a;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+.sub {
|
|
|
+ margin: 4px 0 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+.close {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: transparent;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 22px;
|
|
|
+ line-height: 1;
|
|
|
+ cursor: pointer;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.close:hover { background: #f1f5f9; color: #0f172a; }
|
|
|
+.drawer-body {
|
|
|
+ flex: 1;
|
|
|
+ overflow: auto;
|
|
|
+ padding: 0 0 24px;
|
|
|
+}
|
|
|
+.drawer-body table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+.drawer-body th,
|
|
|
+.drawer-body td {
|
|
|
+ padding: 10px 16px;
|
|
|
+ text-align: left;
|
|
|
+ vertical-align: top;
|
|
|
+ border-bottom: 1px solid #eef2f7;
|
|
|
+ color: #0f172a;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+.drawer-body th {
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ background: #f8fafc;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #64748b;
|
|
|
+ letter-spacing: 0.02em;
|
|
|
+}
|
|
|
+.drawer-body .col-name { width: 36%; }
|
|
|
+.drawer-body .col-reason { width: 64%; }
|
|
|
+.drawer-body tbody tr:hover td { background: #f8fafc; }
|
|
|
+.drawer-empty {
|
|
|
+ padding: 48px 20px;
|
|
|
+ text-align: center;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+`
|
|
|
+
|
|
|
+const EXPORT_JS = `
|
|
|
+(function () {
|
|
|
+ const FULL_TREE_KEY = 'full';
|
|
|
+ const DEFAULT_DEPTH = 3;
|
|
|
+ const DEFAULT_DIMS = [
|
|
|
+ { key: 'ext_pop', label: '外部热度' },
|
|
|
+ { key: 'plat_sust_pop', label: '平台持续热度' },
|
|
|
+ { key: 'plat_ly_pop', label: '去年同期热度' },
|
|
|
+ { key: 'recent_pop', label: '近期热度' },
|
|
|
+ { key: 'real_rov_7d', label: '真实ROV(7日)' },
|
|
|
+ { key: 'real_vov_7d', label: '真实VOV(7日)' },
|
|
|
+ ];
|
|
|
+
|
|
|
+ const raw = JSON.parse(document.getElementById('export-data').textContent);
|
|
|
+ const allNodes = raw.nodes || [];
|
|
|
+ const dims = (raw.dims && raw.dims.length) ? raw.dims : DEFAULT_DIMS;
|
|
|
+ const demandsByCategory = raw.demandsByCategory || {};
|
|
|
+
|
|
|
+ const state = {
|
|
|
+ activeTab: FULL_TREE_KEY,
|
|
|
+ expandDepth: DEFAULT_DEPTH,
|
|
|
+ expandedMap: {},
|
|
|
+ collapsedMap: {},
|
|
|
+ };
|
|
|
+
|
|
|
+ const els = {
|
|
|
+ tabs: document.getElementById('tabs'),
|
|
|
+ legend: document.getElementById('legend'),
|
|
|
+ depthSelect: document.getElementById('depth-select'),
|
|
|
+ expandAll: document.getElementById('expand-all'),
|
|
|
+ hint: document.getElementById('hint'),
|
|
|
+ empty: document.getElementById('empty'),
|
|
|
+ treePanel: document.getElementById('tree-panel'),
|
|
|
+ levelHeaders: document.getElementById('level-headers'),
|
|
|
+ forest: document.getElementById('forest'),
|
|
|
+ overlay: document.getElementById('drawer-overlay'),
|
|
|
+ drawerTitle: document.getElementById('drawer-title'),
|
|
|
+ drawerSub: document.getElementById('drawer-sub'),
|
|
|
+ drawerBody: document.getElementById('drawer-body'),
|
|
|
+ drawerClose: document.getElementById('drawer-close'),
|
|
|
+ };
|
|
|
+
|
|
|
+ function esc(text) {
|
|
|
+ return String(text == null ? '' : text)
|
|
|
+ .replace(/&/g, '&')
|
|
|
+ .replace(/</g, '<')
|
|
|
+ .replace(/>/g, '>')
|
|
|
+ .replace(/"/g, '"');
|
|
|
+ }
|
|
|
+
|
|
|
+ function maxTreeDepth(nodes, depth) {
|
|
|
+ depth = depth || 1;
|
|
|
+ if (!nodes.length) return 0;
|
|
|
+ let max = depth;
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.children && node.children.length) {
|
|
|
+ max = Math.max(max, maxTreeDepth(node.children, depth + 1));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return max;
|
|
|
+ }
|
|
|
+
|
|
|
+ function filterTreeByDim(nodes, dim) {
|
|
|
+ const out = [];
|
|
|
+ for (const node of nodes) {
|
|
|
+ const children = filterTreeByDim(node.children || [], dim);
|
|
|
+ const hasData = ((node.counts && node.counts[dim]) || 0) > 0;
|
|
|
+ if (hasData || children.length) {
|
|
|
+ out.push(Object.assign({}, node, { children: children }));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ function collectWeights(nodes, dim, out) {
|
|
|
+ out = out || [];
|
|
|
+ for (const node of nodes) {
|
|
|
+ const w = node.weights && node.weights[dim];
|
|
|
+ const c = (node.counts && node.counts[dim]) || 0;
|
|
|
+ if (typeof w === 'number' && c > 0) out.push(w);
|
|
|
+ if (node.children && node.children.length) collectWeights(node.children, dim, out);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildWeightScale(values) {
|
|
|
+ if (!values.length) return [];
|
|
|
+ return values.slice().sort(function (a, b) { return a - b; });
|
|
|
+ }
|
|
|
+
|
|
|
+ function weightHeatT(weight, sortedScale) {
|
|
|
+ if (!sortedScale.length) return null;
|
|
|
+ const n = sortedScale.length;
|
|
|
+ let lo = 0, hi = n;
|
|
|
+ while (lo < hi) {
|
|
|
+ const mid = (lo + hi) >> 1;
|
|
|
+ if (sortedScale[mid] < weight) lo = mid + 1;
|
|
|
+ else hi = mid;
|
|
|
+ }
|
|
|
+ if (n === 1) return 1;
|
|
|
+ return Math.min(1, Math.max(0, lo / n));
|
|
|
+ }
|
|
|
+
|
|
|
+ function heatColor(t) {
|
|
|
+ if (t == null) return '#f1f5f9';
|
|
|
+ const clamp = Math.min(1, Math.max(0, t));
|
|
|
+ if (clamp <= 0.5) {
|
|
|
+ const u = clamp / 0.5;
|
|
|
+ const r = Math.round(239 + (255 - 239) * u);
|
|
|
+ const g = Math.round(68 + (255 - 68) * u);
|
|
|
+ const b = Math.round(68 + (255 - 68) * u);
|
|
|
+ return 'rgb(' + r + ', ' + g + ', ' + b + ')';
|
|
|
+ }
|
|
|
+ const u = (clamp - 0.5) / 0.5;
|
|
|
+ const r = Math.round(255 + (34 - 255) * u);
|
|
|
+ const g = Math.round(255 + (197 - 255) * u);
|
|
|
+ const b = Math.round(255 + (94 - 255) * u);
|
|
|
+ return 'rgb(' + r + ', ' + g + ', ' + b + ')';
|
|
|
+ }
|
|
|
+
|
|
|
+ function heatTextColor(t) {
|
|
|
+ if (t == null) return '#64748b';
|
|
|
+ return '#0f172a';
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatAvgScore(avg, count) {
|
|
|
+ if (count != null && count <= 0) return '—';
|
|
|
+ if (avg == null || Number.isNaN(avg)) return '—';
|
|
|
+ if (avg >= 100) return avg.toFixed(0);
|
|
|
+ if (avg >= 1) return avg.toFixed(1);
|
|
|
+ return avg.toFixed(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
|
|
|
+ function activeDim() { return isFullTree() ? null : state.activeTab; }
|
|
|
+
|
|
|
+ function displayNodes() {
|
|
|
+ const dim = activeDim();
|
|
|
+ if (!dim) return allNodes;
|
|
|
+ return filterTreeByDim(allNodes, dim);
|
|
|
+ }
|
|
|
+
|
|
|
+ function weightScale() {
|
|
|
+ const dim = activeDim();
|
|
|
+ if (!dim) return [];
|
|
|
+ return buildWeightScale(collectWeights(displayNodes(), dim));
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetExpandState() {
|
|
|
+ state.expandedMap = {};
|
|
|
+ state.collapsedMap = {};
|
|
|
+ }
|
|
|
+
|
|
|
+ function findDepth(nodes, id, depth) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.id === id) return depth;
|
|
|
+ const found = findDepth(node.children || [], id, depth + 1);
|
|
|
+ if (found != null) return found;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isExpanded(node, depth) {
|
|
|
+ if (!node.children || !node.children.length) return false;
|
|
|
+ if (state.collapsedMap[node.id]) return false;
|
|
|
+ if (state.expandedMap[node.id]) return true;
|
|
|
+ return depth < state.expandDepth;
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleNode(id) {
|
|
|
+ const wasCollapsed = !!state.collapsedMap[id];
|
|
|
+ const wasExpanded = !!state.expandedMap[id];
|
|
|
+ const nextExpanded = Object.assign({}, state.expandedMap);
|
|
|
+ const nextCollapsed = Object.assign({}, state.collapsedMap);
|
|
|
+ delete nextExpanded[id];
|
|
|
+ delete nextCollapsed[id];
|
|
|
+
|
|
|
+ if (wasCollapsed) {
|
|
|
+ nextExpanded[id] = true;
|
|
|
+ } else if (wasExpanded) {
|
|
|
+ nextCollapsed[id] = true;
|
|
|
+ } else {
|
|
|
+ const depth = findDepth(displayNodes(), id, 1);
|
|
|
+ const autoOpen = depth != null && depth < state.expandDepth;
|
|
|
+ if (autoOpen) nextCollapsed[id] = true;
|
|
|
+ else nextExpanded[id] = true;
|
|
|
+ }
|
|
|
+ state.expandedMap = nextExpanded;
|
|
|
+ state.collapsedMap = nextCollapsed;
|
|
|
+ renderTree();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderNode(node, depth, scale, dim) {
|
|
|
+ const hasChildren = !!(node.children && node.children.length);
|
|
|
+ const expanded = isExpanded(node, depth);
|
|
|
+ const demands = demandsByCategory[node.id] || [];
|
|
|
+ const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
|
|
|
+ const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
|
|
|
+ let heatT = null;
|
|
|
+ if (dim && count > 0 && weight != null) heatT = weightHeatT(weight, scale);
|
|
|
+ const bg = heatColor(heatT);
|
|
|
+ const color = heatTextColor(heatT);
|
|
|
+ const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
|
|
|
+ const muted = dim != null && heatT == null ? ' muted' : '';
|
|
|
+ const openCls = expanded ? ' open' : '';
|
|
|
+ const leafCls = hasChildren ? '' : ' leaf';
|
|
|
+ const name = node.name || '(未命名)';
|
|
|
+ let title;
|
|
|
+ if (!dim) title = name;
|
|
|
+ else if (count > 0) title = 'avg=' + weight + ' count=' + count;
|
|
|
+ else title = '无数据';
|
|
|
+
|
|
|
+ let html = '<div class="tree-node">';
|
|
|
+ html += '<div class="node-card' + leafCls + openCls + muted + '" style="background:' + bg +
|
|
|
+ ';color:' + color + ';border-color:' + borderColor + '" title="' + esc(title) + '">';
|
|
|
+ if (hasChildren) {
|
|
|
+ html += '<button class="toggle" type="button" data-toggle="' + node.id +
|
|
|
+ '" aria-expanded="' + expanded + '" title="' + (expanded ? '收起' : '展开') + '">' +
|
|
|
+ (expanded ? '−' : '+') + '</button>';
|
|
|
+ } else {
|
|
|
+ html += '<span class="toggle-spacer"></span>';
|
|
|
+ }
|
|
|
+ html += '<div class="node-main"><div class="node-text">';
|
|
|
+ html += '<span class="node-name">' + esc(name) + '</span>';
|
|
|
+ if (dim) {
|
|
|
+ html += '<span class="node-weight">' + esc(formatAvgScore(weight, count)) + '</span>';
|
|
|
+ }
|
|
|
+ html += '</div>';
|
|
|
+ if (demands.length) {
|
|
|
+ html += '<button class="inspect" type="button" data-inspect="' + node.id +
|
|
|
+ '" data-name="' + esc(name) + '" title="查看需求词">🔍</button>';
|
|
|
+ }
|
|
|
+ html += '</div></div>';
|
|
|
+
|
|
|
+ if (hasChildren && expanded) {
|
|
|
+ html += '<ul class="children">';
|
|
|
+ for (const child of node.children) {
|
|
|
+ html += '<li class="child">' + renderNode(child, depth + 1, scale, dim) + '</li>';
|
|
|
+ }
|
|
|
+ html += '</ul>';
|
|
|
+ }
|
|
|
+ html += '</div>';
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+
|
|
|
+ function headerLevels(treeMax) {
|
|
|
+ let count = Math.max(state.expandDepth, 1);
|
|
|
+ if (Object.keys(state.expandedMap).length > 0) {
|
|
|
+ count = Math.max(count, treeMax);
|
|
|
+ }
|
|
|
+ if (treeMax > 0) count = Math.min(count, treeMax);
|
|
|
+ const levels = [];
|
|
|
+ for (let i = 1; i <= count; i++) levels.push(i);
|
|
|
+ return levels;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTabs() {
|
|
|
+ const tabs = [{ key: FULL_TREE_KEY, label: '完整全局树' }].concat(
|
|
|
+ dims.map(function (d) { return { key: d.key, label: d.label }; })
|
|
|
+ );
|
|
|
+ els.tabs.innerHTML = tabs.map(function (t) {
|
|
|
+ const active = state.activeTab === t.key ? ' active' : '';
|
|
|
+ return '<button type="button" role="tab" class="tab' + active +
|
|
|
+ '" data-tab="' + esc(t.key) + '" aria-selected="' + (state.activeTab === t.key) + '">' +
|
|
|
+ esc(t.label) + '</button>';
|
|
|
+ }).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDepthOptions(treeMax) {
|
|
|
+ const max = Math.max(treeMax, DEFAULT_DEPTH);
|
|
|
+ let html = '';
|
|
|
+ for (let n = 1; n <= max; n++) {
|
|
|
+ html += '<option value="' + n + '"' + (n === state.expandDepth ? ' selected' : '') + '>' + n + '</option>';
|
|
|
+ }
|
|
|
+ els.depthSelect.innerHTML = html;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTree() {
|
|
|
+ const nodes = displayNodes();
|
|
|
+ const dim = activeDim();
|
|
|
+ const scale = weightScale();
|
|
|
+ const treeMax = maxTreeDepth(nodes);
|
|
|
+
|
|
|
+ renderTabs();
|
|
|
+ renderDepthOptions(treeMax);
|
|
|
+ els.legend.hidden = isFullTree();
|
|
|
+ els.hint.textContent = '共 ' + treeMax + ' 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚';
|
|
|
+
|
|
|
+ if (!nodes.length) {
|
|
|
+ els.empty.hidden = false;
|
|
|
+ els.treePanel.hidden = true;
|
|
|
+ els.empty.textContent = isFullTree() ? '暂无分类数据' : '该维度暂无有数据的节点';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ els.empty.hidden = true;
|
|
|
+ els.treePanel.hidden = false;
|
|
|
+ els.levelHeaders.innerHTML = headerLevels(treeMax).map(function (n) {
|
|
|
+ return '<div class="level-header">第 ' + n + ' 层</div>';
|
|
|
+ }).join('');
|
|
|
+ els.forest.innerHTML = nodes.map(function (node) {
|
|
|
+ return renderNode(node, 1, scale, dim);
|
|
|
+ }).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function openDrawer(categoryId, categoryName) {
|
|
|
+ const items = demandsByCategory[categoryId] || [];
|
|
|
+ els.drawerTitle.textContent = categoryName || '分类节点';
|
|
|
+ els.drawerSub.textContent = '共 ' + items.length + ' 条需求词';
|
|
|
+ if (!items.length) {
|
|
|
+ els.drawerBody.innerHTML = '<div class="drawer-empty">该节点暂无需求词</div>';
|
|
|
+ } else {
|
|
|
+ els.drawerBody.innerHTML =
|
|
|
+ '<table><thead><tr><th class="col-name">需求词</th><th class="col-reason">原因</th></tr></thead><tbody>' +
|
|
|
+ items.map(function (item) {
|
|
|
+ return '<tr><td>' + esc(item.name || '—') + '</td><td>' + esc(item.reason || '—') + '</td></tr>';
|
|
|
+ }).join('') +
|
|
|
+ '</tbody></table>';
|
|
|
+ }
|
|
|
+ els.overlay.hidden = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeDrawer() {
|
|
|
+ els.overlay.hidden = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ els.tabs.addEventListener('click', function (e) {
|
|
|
+ const btn = e.target.closest('[data-tab]');
|
|
|
+ if (!btn) return;
|
|
|
+ state.activeTab = btn.getAttribute('data-tab');
|
|
|
+ state.expandDepth = DEFAULT_DEPTH;
|
|
|
+ resetExpandState();
|
|
|
+ renderTree();
|
|
|
+ });
|
|
|
+
|
|
|
+ els.depthSelect.addEventListener('change', function () {
|
|
|
+ state.expandDepth = Number(els.depthSelect.value) || DEFAULT_DEPTH;
|
|
|
+ resetExpandState();
|
|
|
+ renderTree();
|
|
|
+ });
|
|
|
+
|
|
|
+ els.expandAll.addEventListener('click', function () {
|
|
|
+ state.expandDepth = maxTreeDepth(displayNodes()) || 1;
|
|
|
+ resetExpandState();
|
|
|
+ renderTree();
|
|
|
+ });
|
|
|
+
|
|
|
+ els.forest.addEventListener('click', function (e) {
|
|
|
+ const toggle = e.target.closest('[data-toggle]');
|
|
|
+ if (toggle) {
|
|
|
+ e.stopPropagation();
|
|
|
+ toggleNode(Number(toggle.getAttribute('data-toggle')));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const inspect = e.target.closest('[data-inspect]');
|
|
|
+ if (inspect) {
|
|
|
+ e.stopPropagation();
|
|
|
+ openDrawer(
|
|
|
+ Number(inspect.getAttribute('data-inspect')),
|
|
|
+ inspect.getAttribute('data-name') || '(未命名)'
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ els.drawerClose.addEventListener('click', closeDrawer);
|
|
|
+ els.overlay.addEventListener('click', function (e) {
|
|
|
+ if (e.target === els.overlay) closeDrawer();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Pan
|
|
|
+ let isPanning = false, panStartX = 0, panStartY = 0, panScrollLeft = 0, panScrollTop = 0;
|
|
|
+ function onPanMove(e) {
|
|
|
+ if (!isPanning) return;
|
|
|
+ els.treePanel.scrollLeft = panScrollLeft - (e.clientX - panStartX);
|
|
|
+ els.treePanel.scrollTop = panScrollTop - (e.clientY - panStartY);
|
|
|
+ }
|
|
|
+ function onPanEnd() {
|
|
|
+ isPanning = false;
|
|
|
+ els.treePanel.classList.remove('is-panning');
|
|
|
+ window.removeEventListener('mousemove', onPanMove);
|
|
|
+ window.removeEventListener('mouseup', onPanEnd);
|
|
|
+ }
|
|
|
+ els.treePanel.addEventListener('mousedown', function (e) {
|
|
|
+ if (e.button !== 0) return;
|
|
|
+ if (e.target.closest('button, select, a, input')) return;
|
|
|
+ isPanning = true;
|
|
|
+ panStartX = e.clientX;
|
|
|
+ panStartY = e.clientY;
|
|
|
+ panScrollLeft = els.treePanel.scrollLeft;
|
|
|
+ panScrollTop = els.treePanel.scrollTop;
|
|
|
+ els.treePanel.classList.add('is-panning');
|
|
|
+ window.addEventListener('mousemove', onPanMove);
|
|
|
+ window.addEventListener('mouseup', onPanEnd);
|
|
|
+ });
|
|
|
+
|
|
|
+ renderTree();
|
|
|
+})();
|
|
|
+`
|