فهرست منبع

feat(mode_workflow): 三tab单页前端(Dashboard/Dataset/聚类库)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
刘文武 4 روز پیش
والد
کامیت
aec624001d
1فایلهای تغییر یافته به همراه695 افزوده شده و 0 حذف شده
  1. 695 0
      examples/mode_workflow/index.html

+ 695 - 0
examples/mode_workflow/index.html

@@ -0,0 +1,695 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>mode_workflow · 解构工作台</title>
+<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@600;900&family=Noto+Sans+SC:wght@400;500;700&family=IBM+Plex+Mono:wght@500;700&display=swap" rel="stylesheet">
+<style>
+/* ── 工坊图纸:暖纸底 + 墨蓝印刷 ───────────────────────────────────────── */
+:root{
+  --paper:#f5f2e9; --card:#fffdf6; --ink:#1b2530; --ink-soft:#4c5b6d; --ink-faint:#8b94a3;
+  --line:#e4ddcb; --line-dark:#cdc4ab;
+  --navy:#1e3a5f; --navy-deep:#142940;          /* 需求区 */
+  --amber:#b45309; --amber-bg:#fff7e8;          /* 输入区 */
+  --teal:#0f6b5c;  --teal-bg:#eef8f3;           /* 实现区 */
+  --green:#14532d; --green-bg:#f0f7ec;          /* 输出区 */
+  --seal:#b3361d;                               /* 朱砂印 */
+  --infer:#fdf0d2; --infer-edge:#d97706;
+  --shadow:0 1px 2px rgba(27,37,48,.06),0 6px 18px -8px rgba(27,37,48,.18);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{font-size:14px}
+body{
+  font-family:'Noto Sans SC',sans-serif;color:var(--ink);background:var(--paper);
+  background-image:
+    repeating-linear-gradient(0deg,transparent 0 23px,rgba(30,58,95,.035) 23px 24px),
+    radial-gradient(1200px 500px at 85% -10%,rgba(180,83,9,.05),transparent 60%);
+  min-height:100vh;
+}
+.num{font-family:'IBM Plex Mono',monospace}
+h1,h2,.serif{font-family:'Noto Serif SC',serif}
+a{color:var(--navy)}
+button{font-family:inherit;cursor:pointer}
+
+/* ── 顶部 ── */
+header{
+  display:flex;align-items:center;gap:20px;padding:0 28px;height:62px;
+  background:var(--navy-deep);color:#f1ead8;position:sticky;top:0;z-index:40;
+  box-shadow:0 2px 12px rgba(20,41,64,.35);
+}
+.logo{display:flex;align-items:center;gap:12px}
+.logo .seal{
+  width:34px;height:34px;background:var(--seal);color:#fff;display:grid;place-items:center;
+  font-family:'Noto Serif SC',serif;font-weight:900;font-size:17px;border-radius:4px;
+  box-shadow:inset 0 0 0 2px rgba(255,255,255,.25);transform:rotate(-4deg);
+}
+.logo b{font-family:'Noto Serif SC',serif;font-weight:900;font-size:17px;letter-spacing:1px}
+.logo small{display:block;font-size:10px;color:#9eb0c5;letter-spacing:3px}
+nav{display:flex;gap:4px;margin-left:24px;height:100%}
+nav a{
+  display:flex;align-items:center;padding:0 18px;color:#aebdce;text-decoration:none;
+  font-weight:500;border-bottom:3px solid transparent;letter-spacing:.5px;
+}
+nav a.on{color:#fff;border-bottom-color:var(--seal);background:rgba(255,255,255,.04)}
+header .spacer{flex:1}
+header .hint{font-size:11px;color:#7e92a8;letter-spacing:1px}
+
+main{display:none;padding:24px 28px 80px;max-width:1640px;margin:0 auto}
+main.on{display:block}
+
+/* ── 通用卡片/标签 ── */
+.card{background:var(--card);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow)}
+.pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:11px;font-weight:500;
+  border:1px solid var(--line-dark);background:#faf7ee;color:var(--ink-soft);white-space:nowrap}
+.pill.navy{background:#e8eef6;border-color:#b9c9dd;color:var(--navy)}
+.pill.amber{background:var(--amber-bg);border-color:#ecc88a;color:var(--amber)}
+.pill.teal{background:var(--teal-bg);border-color:#a9d6c8;color:var(--teal)}
+.pill.red{background:#fbeae5;border-color:#e4ab9c;color:var(--seal)}
+.btn{
+  border:1px solid var(--line-dark);background:var(--card);color:var(--ink);
+  padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500;transition:.15s;
+}
+.btn:hover{border-color:var(--navy);color:var(--navy);transform:translateY(-1px)}
+.btn.primary{background:var(--navy);border-color:var(--navy);color:#fff}
+.btn.primary:hover{background:var(--navy-deep);color:#fff}
+.btn.seal{background:var(--seal);border-color:var(--seal);color:#fff}
+.btn.sm{padding:3px 10px;font-size:12px}
+.btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
+select,input[type=text],input[type=number]{
+  font-family:inherit;font-size:13px;padding:6px 9px;border:1px solid var(--line-dark);
+  border-radius:6px;background:#fff;color:var(--ink);outline:none;
+}
+select:focus,input:focus{border-color:var(--navy)}
+.empty{
+  text-align:center;color:var(--ink-faint);padding:48px 20px;font-size:13px;line-height:2;
+}
+.empty .glyph{font-family:'Noto Serif SC',serif;font-size:42px;color:var(--line-dark);display:block}
+
+/* ── Dashboard ── */
+.dash-section{margin-bottom:14px;display:flex;align-items:baseline;gap:10px}
+.dash-section h2{font-size:16px;font-weight:900;letter-spacing:2px}
+.dash-section .rule{flex:1;border-top:1px dashed var(--line-dark)}
+.dash-section .tag{font-size:10px;letter-spacing:2px;color:var(--ink-faint)}
+.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:26px}
+.stat{padding:16px 18px 14px;position:relative;overflow:hidden}
+.stat::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--navy)}
+.stat.a::after{background:var(--amber)} .stat.t::after{background:var(--teal)} .stat.r::after{background:var(--seal)}
+.stat .lbl{font-size:11px;letter-spacing:2px;color:var(--ink-soft);margin-bottom:8px}
+.stat .val{font-size:30px;font-weight:700;line-height:1}
+.stat .sub{font-size:11px;color:var(--ink-faint);margin-top:7px}
+.ring-row{display:flex;align-items:center;gap:14px}
+.ring{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;flex:none;
+  background:conic-gradient(var(--teal) calc(var(--p)*1%),#e8e2d2 0)}
+.ring>div{width:48px;height:48px;border-radius:50%;background:var(--card);display:grid;place-items:center;
+  font-size:12px;font-weight:700}
+.charts{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
+.chart-card{padding:14px 16px 8px}
+.chart-card h3{font-size:13px;font-weight:700;letter-spacing:1px;color:var(--ink-soft);margin-bottom:4px;
+  display:flex;align-items:baseline;gap:8px}
+.chart-card h3 .num{color:var(--seal);font-size:12px}
+.chart{width:100%}
+.span12{grid-column:span 12}.span6{grid-column:span 6}.span4{grid-column:span 4}
+@media(max-width:1100px){.span6,.span4{grid-column:span 12}}
+
+/* ── Dataset 三栏 ── */
+.ds-top{display:flex;align-items:center;gap:12px;margin-bottom:16px}
+.mode-switch{display:flex;border:1px solid var(--line-dark);border-radius:7px;overflow:hidden}
+.mode-switch button{border:0;background:transparent;padding:7px 22px;font-size:13px;font-weight:700;color:var(--ink-soft)}
+.mode-switch button.on{background:var(--navy);color:#fff}
+.ds-grid{display:grid;grid-template-columns:230px 360px 1fr;gap:16px;align-items:start}
+@media(max-width:1280px){.ds-grid{grid-template-columns:200px 320px 1fr}}
+.col-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
+  border-bottom:2px solid var(--ink);font-weight:700;font-size:13px;letter-spacing:1px}
+.col-head .n{font-size:11px;color:var(--ink-faint);font-weight:500}
+
+.qlist{max-height:78vh;overflow:auto}
+.qitem{padding:11px 14px;border-bottom:1px solid var(--line);cursor:pointer;transition:.12s}
+.qitem:hover{background:#faf6ea}
+.qitem.on{background:#eef2f8;box-shadow:inset 3px 0 0 var(--navy)}
+.qitem .qid{font-size:10px;letter-spacing:1px;color:var(--ink-faint)}
+.qitem .qt{font-weight:500;margin:3px 0 5px;line-height:1.4}
+.qitem .qm{font-size:11px;color:var(--ink-soft);display:flex;gap:10px}
+
+.plist{max-height:78vh;overflow:auto}
+.post{padding:11px 13px;border-bottom:1px solid var(--line);cursor:pointer;display:flex;gap:9px;transition:.12s}
+.post:hover{background:#faf6ea}
+.post.on{background:#eef2f8;box-shadow:inset 3px 0 0 var(--navy)}
+.post input{margin-top:3px;accent-color:var(--navy)}
+.post .pt{font-weight:500;line-height:1.45;font-size:13px}
+.post .pm{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;align-items:center}
+.post .score{font-weight:700;font-size:12px;color:var(--seal)}
+.plat{display:inline-block;padding:1px 7px;border-radius:3px;font-size:10px;font-weight:700;color:#fff}
+.plat.xhs{background:#d63a2f}.plat.gzh{background:#2e9939}.plat.zhihu{background:#1772f6}
+.plat.x{background:#15202b}.plat.other{background:#777}
+.done-dot{font-size:10px;color:var(--teal);font-weight:700}
+
+.xp{min-height:60vh}
+.xp-head{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:2px solid var(--ink);flex-wrap:wrap}
+.xp-head .st{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
+.xp-head .st em{color:var(--seal);font-style:normal}
+.xp-head .spacer{flex:1}
+.xp-body{padding:16px}
+
+/* 工序卡 */
+.proc{border:1px solid var(--line-dark);border-radius:8px;margin-bottom:22px;overflow:hidden;background:#fff}
+.proc-head{padding:13px 16px;border-bottom:1px solid var(--line);background:#fcfaf3}
+.proc-head .nm{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
+.proc-head .nm .pid{color:var(--seal);margin-right:6px;font-size:13px}
+.proc-head .pp{font-size:12px;color:var(--ink-soft);margin-top:5px;line-height:1.6}
+.proc-head .meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+.decl{display:grid;grid-template-columns:1fr 1fr;gap:0;border-bottom:1px solid var(--line)}
+.decl>div{padding:10px 16px}
+.decl>div+div{border-left:1px solid var(--line)}
+.decl .dl{font-size:10px;letter-spacing:2px;color:var(--ink-faint);margin-bottom:6px}
+.decl .di{font-size:12px;line-height:1.7}
+.decl .di b{font-weight:700}
+.steps{width:100%;border-collapse:collapse;font-size:12px}
+.steps th{padding:6px 8px;font-size:11px;font-weight:700;letter-spacing:1px;color:#fff;text-align:left}
+.steps thead tr:first-child th{text-align:center;font-size:12px;letter-spacing:4px;padding:7px 4px}
+.steps .h-req{background:var(--navy)} .steps .h-req2{background:#33547a}
+.steps .h-in{background:var(--amber)} .steps .h-in2{background:#cd7522}
+.steps .h-im{background:var(--teal)} .steps .h-im2{background:#2d8273}
+.steps .h-out{background:var(--green)} .steps .h-out2{background:#2e6b45}
+.steps td{padding:8px 9px;border:1px solid var(--line);vertical-align:top;line-height:1.6}
+.steps tbody tr:nth-child(odd) td{background:#fffdf6}
+.steps td.c-in{background:var(--amber-bg)!important}
+.steps td.c-out{background:var(--green-bg)!important}
+.steps .sid{font-family:'IBM Plex Mono',monospace;font-weight:700;color:var(--navy);white-space:nowrap}
+.steps .vtxt{color:var(--ink-soft);font-size:11.5px;max-width:340px;word-break:break-all}
+.inf{background:var(--infer)!important;position:relative;outline:1px dashed var(--infer-edge);outline-offset:-2px}
+.inf .ib{position:absolute;top:-1px;right:-1px;background:var(--infer-edge);color:#fff;font-size:9px;
+  padding:0 4px;border-radius:0 0 0 4px;font-weight:700}
+.anchor{font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:var(--ink-faint);white-space:nowrap}
+
+/* 工具卡 */
+.tool{border:1px solid var(--line-dark);border-radius:8px;margin-bottom:16px;overflow:hidden;background:#fff}
+.tool-head{display:flex;align-items:center;gap:10px;padding:11px 15px;background:var(--navy);color:#fff}
+.tool-head b{font-family:'Noto Serif SC',serif;font-size:14px;letter-spacing:.5px}
+.tool-head .pill{border-color:rgba(255,255,255,.4);background:rgba(255,255,255,.12);color:#ffe9b8}
+.tool-body{padding:13px 15px;display:grid;gap:10px}
+.trow{display:grid;grid-template-columns:78px 1fr;gap:10px;font-size:12.5px;line-height:1.7}
+.trow .k{color:var(--ink-faint);font-size:11px;letter-spacing:1px;padding-top:2px}
+.trow ul{padding-left:16px}
+.case{border:1px dashed var(--line-dark);border-radius:6px;padding:8px 10px;margin-top:4px;font-size:12px}
+.case .ck{color:var(--amber);font-weight:700;margin-right:6px}
+
+/* 任务面板 */
+#task-panel{
+  position:fixed;right:22px;bottom:22px;width:430px;max-height:55vh;z-index:60;
+  display:flex;flex-direction:column;border:1px solid var(--line-dark);border-radius:10px;
+  background:var(--card);box-shadow:0 16px 50px -12px rgba(20,41,64,.45);overflow:hidden;
+}
+#task-panel header{all:unset;display:flex;align-items:center;gap:9px;padding:10px 14px;
+  background:var(--navy-deep);color:#fff;font-size:13px;font-weight:700}
+#task-panel header .dot{width:8px;height:8px;border-radius:50%;background:#f5b942;animation:blink 1s infinite}
+#task-panel header .dot.done{background:#3fb27f;animation:none}
+#task-panel header .dot.failed{background:#e0492f;animation:none}
+@keyframes blink{50%{opacity:.3}}
+#task-panel header button{margin-left:auto;background:none;border:0;color:#9eb0c5;font-size:15px}
+#task-log{font-family:'IBM Plex Mono',monospace;font-size:11px;line-height:1.65;color:var(--ink-soft);
+  white-space:pre-wrap;overflow:auto;padding:12px 14px;background:#fbf8ef;flex:1}
+
+/* 弹窗 */
+.modal-bg{position:fixed;inset:0;background:rgba(20,30,40,.45);z-index:70;display:grid;place-items:center}
+.modal{width:460px;background:var(--card);border-radius:10px;overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,.4)}
+.modal h2{padding:14px 18px;background:var(--navy-deep);color:#fff;font-size:15px;letter-spacing:2px}
+.modal .mb{padding:18px;display:grid;gap:12px}
+.modal label{font-size:12px;color:var(--ink-soft);display:grid;gap:5px}
+.modal .mf{display:flex;justify-content:flex-end;gap:10px;padding:0 18px 18px}
+
+/* 聚类库占位 */
+.cluster-empty{display:grid;place-items:center;min-height:60vh}
+.cluster-empty .inner{text-align:center;color:var(--ink-faint)}
+.cluster-empty .stamp{
+  width:120px;height:120px;margin:0 auto 18px;border:3px solid var(--line-dark);border-radius:50%;
+  display:grid;place-items:center;font-family:'Noto Serif SC',serif;font-size:30px;font-weight:900;
+  color:var(--line-dark);transform:rotate(-8deg);letter-spacing:4px;
+}
+[hidden]{display:none!important}
+</style>
+</head>
+<body>
+
+<header>
+  <div class="logo">
+    <div class="seal">解</div>
+    <div><b>mode_workflow</b><small>解构工作台 · MODE WORKBENCH</small></div>
+  </div>
+  <nav id="nav">
+    <a href="#dashboard" data-tab="dashboard">Dashboard</a>
+    <a href="#dataset" data-tab="dataset">Dataset</a>
+    <a href="#cluster" data-tab="cluster">聚类库</a>
+  </nav>
+  <div class="spacer"></div>
+  <div class="hint">SEARCH · EXTRACT · ARCHIVE</div>
+</header>
+
+<main id="view-dashboard"></main>
+
+<main id="view-dataset">
+  <div class="ds-top">
+    <div class="mode-switch">
+      <button id="m-process" class="on">工序</button>
+      <button id="m-tools">工具</button>
+    </div>
+    <div style="flex:1"></div>
+    <button class="btn seal" id="btn-new-search">+ 新建搜索</button>
+  </div>
+  <div class="ds-grid">
+    <div class="card">
+      <div class="col-head">QUERY <span class="n" id="q-count"></span></div>
+      <div class="qlist" id="query-list"><div class="empty"><span class="glyph">空</span>暂无 query</div></div>
+    </div>
+    <div class="card">
+      <div class="col-head">帖子
+        <span style="display:flex;gap:8px;align-items:center">
+          <span class="n" id="p-count"></span>
+          <button class="btn sm" id="btn-batch" disabled>批量解构</button>
+        </span>
+      </div>
+      <div class="plist" id="post-list"><div class="empty"><span class="glyph">←</span>先选择左侧 query</div></div>
+    </div>
+    <div class="card xp">
+      <div class="xp-head" id="xp-head"><span class="st">解构结果</span></div>
+      <div class="xp-body" id="xp-body"><div class="empty"><span class="glyph">解</span>选择一个帖子查看解构结果</div></div>
+    </div>
+  </div>
+</main>
+
+<main id="view-cluster">
+  <div class="cluster-empty"><div class="inner">
+    <div class="stamp">聚类</div>
+    <div class="serif" style="font-size:18px;color:var(--ink-soft);font-weight:900;letter-spacing:3px">聚类库 · 敬请期待</div>
+    <div style="margin-top:8px;font-size:12px">跨帖工序 / 工具的聚类沉淀,将在此呈现</div>
+  </div></div>
+</main>
+
+<div id="task-panel" hidden>
+  <header><span class="dot" id="task-dot"></span><span id="task-title">任务</span>
+    <button onclick="hideTask()">✕</button></header>
+  <div id="task-log"></div>
+</div>
+
+<div class="modal-bg" id="search-modal" hidden>
+  <div class="modal">
+    <h2>新建搜索</h2>
+    <div class="mb">
+      <label>Query(评估锚点,必填)<input type="text" id="s-query" placeholder="如:AI 人像 图片 生成 怎么做"></label>
+      <label>同义措辞(可选,逗号分隔)<input type="text" id="s-syn" placeholder="如:AI 人像生成 教程,AI 写真 怎么做"></label>
+      <label>渠道<input type="text" id="s-plat" value="xhs,gzh"></label>
+      <label>每措辞每渠道上限<input type="number" id="s-max" value="10" min="1" max="50"></label>
+    </div>
+    <div class="mf">
+      <button class="btn" onclick="document.getElementById('search-modal').hidden=true">取消</button>
+      <button class="btn primary" id="s-go">开始搜索</button>
+    </div>
+  </div>
+</div>
+
+<script>
+/* ════ 基础 ════ */
+const $ = s => document.querySelector(s);
+const api = (p, opt) => fetch(p, opt).then(async r => {
+  if (!r.ok) throw Object.assign(new Error('api'), {status: r.status, body: await r.json().catch(() => ({}))});
+  return r.json();
+});
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const state = {tab:'dashboard', mode:'process', queryId:null, caseId:null, post:null,
+               version:null, selected:new Set(), queries:[], posts:[]};
+const PLAT_CLS = p => ({xhs:'xhs',gzh:'gzh',zhihu:'zhihu',x:'x'})[p] || 'other';
+const PLAT_NAME = p => ({xhs:'小红书',gzh:'公众号',zhihu:'知乎',x:'X'})[p] || p || '?';
+const MODELS_PROC = ['anthropic/claude-sonnet-4-6','google/gemini-3.1-flash-lite'];
+const MODELS_TOOL = ['google/gemini-3.1-flash-lite','anthropic/claude-sonnet-4-6'];
+
+/* ════ 路由 ════ */
+function route(){
+  state.tab = (location.hash || '#dashboard').slice(1);
+  if (!['dashboard','dataset','cluster'].includes(state.tab)) state.tab = 'dashboard';
+  document.querySelectorAll('nav a').forEach(a => a.classList.toggle('on', a.dataset.tab === state.tab));
+  document.querySelectorAll('main').forEach(m => m.classList.toggle('on', m.id === 'view-' + state.tab));
+  if (state.tab === 'dashboard') renderDashboard();
+  if (state.tab === 'dataset' && !state.queries.length) loadQueries();
+}
+window.addEventListener('hashchange', route);
+
+/* ════ Dashboard ════ */
+let chartRefs = [];
+async function renderDashboard(){
+  const v = $('#view-dashboard');
+  let d;
+  try { d = await api('/api/dashboard'); }
+  catch(e){ v.innerHTML = `<div class="card empty"><span class="glyph">!</span>Dashboard 加载失败:${esc(e.body?.error || e.status)}</div>`; return; }
+  const r = d.result, p = d.process_data;
+  const pct = (a,b) => b ? Math.round(a/b*100) : 0;
+  v.innerHTML = `
+  <div class="dash-section"><h2>结果数据</h2><div class="rule"></div><span class="tag">RESULTS</span></div>
+  <div class="cards">
+    <div class="card stat"><div class="lbl">采集帖子数量</div><div class="val num">${r.post_count}</div><div class="sub">search_data 全量</div></div>
+    <div class="card stat t"><div class="lbl">解构帖子数量</div><div class="val num">${r.extracted_post_count}</div><div class="sub">工序 ∪ 工具 已解构</div></div>
+    <div class="card stat a"><div class="lbl">工具数量</div><div class="val num">${r.tool_count}</div><div class="sub">mode_tools 去重工具名</div></div>
+    <div class="card stat r"><div class="lbl">内容树覆盖节点</div><div class="val num">${r.matrix_covered}<span style="font-size:15px;color:var(--ink-faint)"> / ${r.matrix_valid}</span></div>
+      <div class="sub">${pct(r.matrix_covered,r.matrix_valid)}% · 动作×类型有效节点</div></div>
+    <div class="card stat"><div class="lbl">实质覆盖度</div><div class="val num">${r.substance_count}</div><div class="sub">去重实质条目数</div></div>
+    <div class="card stat"><div class="lbl">形式覆盖度</div><div class="val num">${r.form_count}</div><div class="sub">去重形式条目数</div></div>
+  </div>
+  <div class="dash-section"><h2>过程数据</h2><div class="rule"></div><span class="tag">PROCESS</span></div>
+  <div class="cards">
+    <div class="card stat a"><div class="lbl">解构成本</div><div class="val num">$${p.total_cost}</div><div class="sub">单条均值 $${p.avg_cost} · 共 ${p.run_count} 次</div></div>
+    <div class="card stat a"><div class="lbl">解构耗时</div><div class="val num">${p.total_duration}<span style="font-size:14px">s</span></div><div class="sub">平均 ${p.avg_duration}s / 次</div></div>
+    <div class="card stat t"><div class="ring-row"><div class="ring" style="--p:${pct(p.process_progress.done,p.process_progress.total)}"><div>${pct(p.process_progress.done,p.process_progress.total)}%</div></div>
+      <div><div class="lbl">工序进度</div><div class="num" style="font-weight:700">${p.process_progress.done} / ${p.process_progress.total}</div><div class="sub">已解构 / 需解构</div></div></div></div>
+    <div class="card stat t"><div class="ring-row"><div class="ring" style="--p:${pct(p.tools_progress.done,p.tools_progress.total)}"><div>${pct(p.tools_progress.done,p.tools_progress.total)}%</div></div>
+      <div><div class="lbl">工具进度</div><div class="num" style="font-weight:700">${p.tools_progress.done} / ${p.tools_progress.total}</div><div class="sub">已解构 / 需解构</div></div></div></div>
+  </div>
+  <div class="charts">
+    <div class="card chart-card span12"><h3>内容树覆盖热力图 <span class="num">${r.matrix_covered}/${r.matrix_valid}</span><span style="font-weight:400;color:var(--ink-faint)">· 27 动作 × 50 类型</span></h3><div class="chart" id="ch-matrix" style="height:560px"></div></div>
+    <div class="card chart-card span6"><h3>工序提及工具 TOP10</h3><div class="chart" id="ch-via" style="height:300px"></div></div>
+    <div class="card chart-card span6"><h3>解构成本趋势</h3><div class="chart" id="ch-cost" style="height:300px"></div></div>
+    <div class="card chart-card span6"><h3>实质覆盖分布 <span class="num">${r.substance_count}</span></h3><div class="chart" id="ch-sub" style="height:320px"></div></div>
+    <div class="card chart-card span6"><h3>形式覆盖分布 <span class="num">${r.form_count}</span></h3><div class="chart" id="ch-form" style="height:320px"></div></div>
+  </div>`;
+  chartRefs.forEach(c => c.dispose()); chartRefs = [];
+  const mk = (id, opt) => { const c = echarts.init($(id)); c.setOption(opt); chartRefs.push(c); return c; };
+  const ink = '#1b2530', faint = '#8b94a3', grid = {left:8,right:16,top:8,bottom:8,containLabel:true};
+  /* 热力图 */
+  mk('#ch-matrix', {
+    tooltip:{formatter: pr => `${r.matrix_actions[pr.value[1]]} × ${r.matrix_types[pr.value[0]]}`},
+    grid:{left:8,right:16,top:8,bottom:60,containLabel:true},
+    xAxis:{type:'category',data:r.matrix_types,axisLabel:{rotate:60,fontSize:9,color:faint},splitArea:{show:true,areaStyle:{color:['#fcfaf3','#f6f2e6']}}},
+    yAxis:{type:'category',data:r.matrix_actions,axisLabel:{fontSize:10,color:ink},splitArea:{show:true}},
+    visualMap:{show:false,min:0,max:1,inRange:{color:['#f1ecdd','#b3361d']}},
+    series:[{type:'heatmap',data:r.matrix_cells.map(([a,t])=>[t,a,1]),itemStyle:{borderColor:'#fff',borderWidth:1}}]
+  });
+  const bar = (data, color) => ({
+    tooltip:{}, grid,
+    xAxis:{type:'value',axisLabel:{color:faint}},
+    yAxis:{type:'category',data:data.map(x=>x[0]).reverse(),axisLabel:{color:ink,fontSize:11,width:130,overflow:'truncate'}},
+    series:[{type:'bar',data:data.map(x=>x[1]).reverse(),itemStyle:{color,borderRadius:[0,3,3,0]},barMaxWidth:16,
+             label:{show:true,position:'right',color:faint,fontSize:10}}]
+  });
+  mk('#ch-via', r.via_top10.length ? bar(r.via_top10,'#1e3a5f') : emptyOpt());
+  mk('#ch-sub', r.substance_top.length ? bar(r.substance_top,'#b45309') : emptyOpt());
+  mk('#ch-form', r.form_top.length ? bar(r.form_top,'#0f6b5c') : emptyOpt());
+  mk('#ch-cost', p.cost_trend.length ? {
+    tooltip:{trigger:'axis'}, grid,
+    xAxis:{type:'category',data:p.cost_trend.map(x=>x.date),axisLabel:{color:faint}},
+    yAxis:{type:'value',axisLabel:{color:faint,formatter:'${value}'}},
+    series:[{type:'line',data:p.cost_trend.map(x=>x.cost),smooth:true,symbolSize:7,
+             lineStyle:{color:'#b3361d',width:2.5},itemStyle:{color:'#b3361d'},
+             areaStyle:{color:'rgba(179,54,29,.08)'}}]
+  } : emptyOpt());
+  function emptyOpt(){ return {title:{text:'暂无数据',left:'center',top:'middle',textStyle:{color:faint,fontSize:12,fontWeight:400}},xAxis:{show:false},yAxis:{show:false}}; }
+}
+
+/* ════ Dataset:query 列表 ════ */
+async function loadQueries(){
+  try { state.queries = await api('/api/queries'); } catch(e){ state.queries = []; }
+  renderQueries();
+}
+function renderQueries(){
+  $('#q-count').textContent = state.queries.length ? state.queries.length + ' 组' : '';
+  if (!state.queries.length){
+    $('#query-list').innerHTML = '<div class="empty"><span class="glyph">空</span>暂无 query<br>点右上「新建搜索」开始</div>'; return;
+  }
+  $('#query-list').innerHTML = state.queries.map(q => {
+    const done = state.mode === 'process' ? q.process_done : q.tools_done;
+    return `<div class="qitem ${q.query_id===state.queryId?'on':''}" onclick="selectQuery('${q.query_id}')">
+      <div class="qid">${q.query_id}</div>
+      <div class="qt">${esc(q.query_text || '(未命名)')}</div>
+      <div class="qm"><span class="num">${q.post_count} 帖</span><span>已解构 <b class="num">${done}</b></span></div>
+    </div>`;
+  }).join('');
+}
+async function selectQuery(qid){
+  state.queryId = qid; state.caseId = null; state.post = null; state.selected.clear();
+  renderQueries(); renderExtractEmpty();
+  $('#post-list').innerHTML = '<div class="empty">加载中…</div>';
+  try { state.posts = await api('/api/posts?query_id=' + encodeURIComponent(qid)); }
+  catch(e){ state.posts = []; }
+  renderPosts();
+}
+function renderPosts(){
+  $('#p-count').textContent = state.posts.length ? state.posts.length + ' 帖' : '';
+  updateBatchBtn();
+  if (!state.posts.length){
+    $('#post-list').innerHTML = '<div class="empty"><span class="glyph">空</span>该 query 暂无帖子</div>'; return;
+  }
+  $('#post-list').innerHTML = state.posts.map(p => {
+    const done = state.mode === 'process' ? p.has_process : p.has_tools;
+    const kt = (p.knowledge_type || []).map(k => `<span class="pill">${esc(k)}</span>`).join('');
+    return `<div class="post ${p.case_id===state.caseId?'on':''}" onclick="selectPost('${esc(p.case_id)}')">
+      <input type="checkbox" ${state.selected.has(p.case_id)?'checked':''}
+             onclick="event.stopPropagation();toggleSel('${esc(p.case_id)}',this.checked)">
+      <div style="flex:1">
+        <div class="pt">${esc(p.title || '(无标题)')}</div>
+        <div class="pm">
+          <span class="plat ${PLAT_CLS(p.platform)}">${PLAT_NAME(p.platform)}</span>
+          ${p.overall_score != null ? `<span class="score num">${p.overall_score}</span>` : ''}
+          ${kt}
+          ${done ? '<span class="done-dot">● 已解构</span>' : ''}
+        </div>
+      </div>
+    </div>`;
+  }).join('');
+}
+function toggleSel(cid, on){ on ? state.selected.add(cid) : state.selected.delete(cid); updateBatchBtn(); }
+function updateBatchBtn(){
+  const b = $('#btn-batch');
+  b.disabled = !state.selected.size;
+  b.textContent = state.selected.size ? `批量解构(${state.selected.size})` : '批量解构';
+}
+$('#btn-batch').onclick = () => state.selected.size && startExtract([...state.selected]);
+
+/* ════ Dataset:右栏解构结果 ════ */
+function renderExtractEmpty(){
+  $('#xp-head').innerHTML = '<span class="st">解构结果</span>';
+  $('#xp-body').innerHTML = '<div class="empty"><span class="glyph">解</span>选择一个帖子查看解构结果</div>';
+}
+async function selectPost(cid){
+  state.caseId = cid; state.version = null;
+  state.post = state.posts.find(p => p.case_id === cid) || null;
+  renderPosts();
+  await loadExtract();
+}
+async function loadExtract(){
+  if (!state.caseId) return renderExtractEmpty();
+  const isProc = state.mode === 'process';
+  const vURL = `/api/${isProc?'process':'tools'}_versions?case_id=` + encodeURIComponent(state.caseId);
+  const dURL = `/api/${isProc?'process':'tools'}?case_id=` + encodeURIComponent(state.caseId)
+             + (state.version ? '&version=' + encodeURIComponent(state.version) : '');
+  let versions = [], data = null, missing = false;
+  try { versions = await api(vURL); } catch(e){}
+  try { data = await api(dURL); } catch(e){ if (e.status === 404) missing = true; else throw e; }
+  renderExtractHead(versions, data, missing);
+  const body = $('#xp-body');
+  if (missing || !data){
+    body.innerHTML = `<div class="empty"><span class="glyph">未</span>该帖暂无${isProc?'工序':'工具'}解构<br><br>
+      <button class="btn primary" onclick="startExtract(['${esc(state.caseId)}'])">开始解构</button></div>`;
+    return;
+  }
+  state.version = data.version;
+  syncVersionSelect();
+  body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
+}
+function renderExtractHead(versions, data, missing){
+  const isProc = state.mode === 'process';
+  const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || '');
+  const opts = versions.map((v,i) =>
+    `<option value="${esc(v.version)}">${esc(v.version)}${i===0?' (最新)':''} · ${v.n}${isProc?'工序':'工具'}</option>`).join('');
+  const models = (isProc ? MODELS_PROC : MODELS_TOOL).map(m => `<option>${m}</option>`).join('');
+  $('#xp-head').innerHTML = `
+    <span class="st">大模型${isProc?'工序':'工具'}:<em>${missing?'未提取':'已提取'}</em></span>
+    <span style="font-size:12px;color:var(--ink-faint);max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title}</span>
+    <span class="spacer"></span>
+    ${versions.length ? `<select id="ver-sel">${opts}</select>` : ''}
+    <select id="model-sel" title="解构模型">${models}</select>
+    <button class="btn sm primary" onclick="startExtract(['${esc(state.caseId||'')}'])">${missing?'提取':'♻ 重新生成'}</button>`;
+  const vs = $('#ver-sel');
+  if (vs) vs.onchange = () => { state.version = vs.value; loadExtract(); };
+}
+function syncVersionSelect(){ const vs = $('#ver-sel'); if (vs && state.version) vs.value = state.version; }
+
+/* ── 工序渲染 ── */
+function renderProcedures(data){
+  const src = data.source || {};
+  const srcLine = src.title ? `<div class="pill" style="margin-bottom:14px">▸ 原文:${esc(src.title)}</div>` : '';
+  return srcLine + (data.procedures || []).map(p => {
+    const d = p.declarations || {};
+    const ins = (d.inputs || []).map(x =>
+      `<span class="pill amber">${esc(x.type || '')}</span> ${x.name?`<b>${esc(x.name)}</b>`:''} ${x.desc?`<span style="color:var(--ink-faint)">— ${esc(x.desc)}</span>`:''}`).join('<br>') || '<span style="color:var(--ink-faint)">无</span>';
+    const ret = d.returns ? `<span class="pill teal">${esc(d.returns.type || '')}</span>` : '<span style="color:var(--ink-faint)">无</span>';
+    return `<div class="proc">
+      <div class="proc-head">
+        <div class="nm"><span class="pid">工序 ${esc(p.id || '')}</span>${esc(p.name || '')}</div>
+        ${p.purpose ? `<div class="pp">#目的:${esc(p.purpose)}</div>` : ''}
+        <div class="meta">
+          ${p.category ? `<span class="pill red">类别:${esc(p.category)}</span>` : ''}
+          ${src.platform ? `<span class="pill">#平台:${esc(src.platform)}</span>` : ''}
+          ${src.author ? `<span class="pill">#作者:${esc(src.author)}</span>` : ''}
+          <span class="pill">case:${esc(data.case_id)}</span>
+          ${(p.tools_used||[]).map(t=>`<span class="pill teal">${esc(t)}</span>`).join('')}
+        </div>
+      </div>
+      <div class="decl">
+        <div><div class="dl">输入</div><div class="di">${ins}</div></div>
+        <div><div class="dl">返回</div><div class="di">${ret}</div></div>
+      </div>
+      ${renderSteps(p.steps || [])}
+    </div>`;
+  }).join('') || '<div class="empty">本版本无工序</div>';
+}
+function renderSteps(steps){
+  if (!steps.length) return '<div class="empty">无步骤</div>';
+  let rows = '';
+  for (const s of steps){
+    const ins = (s.inputs && s.inputs.length) ? s.inputs : [null];
+    const outs = (s.outputs && s.outputs.length) ? s.outputs : [null];
+    const n = Math.max(ins.length, outs.length);
+    for (let i = 0; i < n; i++){
+      rows += '<tr>';
+      if (i === 0){
+        rows += `<td rowspan="${n}" class="sid">${esc(s.id||'')}</td>
+          <td rowspan="${n}">${esc(s.directive || s.intent || '')}</td>
+          <td rowspan="${n}">${s.effect?`<span class="pill navy">${esc(s.effect)}</span>`:''}</td>
+          <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
+          <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
+      }
+      rows += ioCell(ins[i], 'in');
+      if (i === 0){
+        rows += `<td rowspan="${n}">${s.via?`<span class="pill teal">${esc(s.via)}</span>`:''}</td>
+          <td rowspan="${n}" class="vtxt">${esc(s.action||'')}</td>`;
+      }
+      rows += ioCell(outs[i], 'out');
+      rows += '</tr>';
+    }
+  }
+  return `<div style="overflow-x:auto"><table class="steps">
+    <thead>
+      <tr><th class="h-req" colspan="5">需 求</th><th class="h-in" colspan="3">输 入</th><th class="h-im" colspan="2">实 现</th><th class="h-out" colspan="3">输 出</th></tr>
+      <tr>
+        <th class="h-req2">#</th><th class="h-req2">目的</th><th class="h-req2">作用</th><th class="h-req2">实质</th><th class="h-req2">形式</th>
+        <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
+        <th class="h-im2">外部工具</th><th class="h-im2">动作</th>
+        <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
+      </tr>
+    </thead><tbody>${rows}</tbody></table></div>`;
+}
+function fmtSF(v){ return v == null ? '' : (Array.isArray(v) ? v.join('、') : v); }
+function ioCell(x, kind){
+  const cls = kind === 'in' ? 'c-in' : 'c-out';
+  if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
+  const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
+  const badge = x.inferred ? '<span class="ib">推</span>' : '';
+  return `<td class="${cls}"><span class="pill ${kind==='in'?'amber':'teal'}">${esc(x.type||'')}</span></td>
+    <td class="${cls}${inf}">${badge}<span class="vtxt">${esc(x.value||'')}</span></td>
+    <td class="${cls}"><span class="anchor">${esc(x.anchor||'')}</span></td>`;
+}
+
+/* ── 工具渲染 ── */
+function renderTools(data){
+  return (data.tools || []).map(t => {
+    const li = a => (a||[]).map(x=>`<li>${esc(x)}</li>`).join('');
+    const scope = a => (Array.isArray(a)?a:(a?[a]:[])).flatMap(x=>String(x).split('、')).map(x=>`<span class="pill amber">${esc(x)}</span>`).join(' ');
+    const fscope = a => (Array.isArray(a)?a:(a?[a]:[])).flatMap(x=>String(x).split('、')).map(x=>`<span class="pill teal">${esc(x)}</span>`).join(' ');
+    const cases = (t['案例']||[]).map(c=>`<div class="case">
+      <div><span class="ck">入</span>${esc(c['输入']||'')}</div>
+      <div><span class="ck">出</span>${esc(c['输出']||'')}</div>
+      ${c['效果']?`<div><span class="ck">效</span>${esc(c['效果'])}</div>`:''}</div>`).join('');
+    return `<div class="tool">
+      <div class="tool-head"><b>${esc(t['工具名称']||'(未命名工具)')}</b>
+        ${t['创作层级']?`<span class="pill">${esc(t['创作层级'])}</span>`:''}
+        ${t['最新更新时间']?`<span class="pill">更新:${esc(t['最新更新时间'])}</span>`:''}
+      </div>
+      <div class="tool-body">
+        ${t['实质作用域']?.length?`<div class="trow"><div class="k">实质作用域</div><div>${scope(t['实质作用域'])}</div></div>`:''}
+        ${t['形式作用域']?.length?`<div class="trow"><div class="k">形式作用域</div><div>${fscope(t['形式作用域'])}</div></div>`:''}
+        ${t['输入']?`<div class="trow"><div class="k">输入</div><div>${esc(t['输入'])}</div></div>`:''}
+        ${t['输出']?`<div class="trow"><div class="k">输出</div><div>${esc(t['输出'])}</div></div>`:''}
+        ${t['用法']?.length?`<div class="trow"><div class="k">用法</div><ul>${li(t['用法'])}</ul></div>`:''}
+        ${cases?`<div class="trow"><div class="k">案例</div><div>${cases}</div></div>`:''}
+        ${t['缺点']?.length?`<div class="trow"><div class="k">缺点</div><ul style="color:var(--seal)">${li(t['缺点'])}</ul></div>`:''}
+        ${t['来源链接']?`<div class="trow"><div class="k">来源</div><div><a href="${esc(t['来源链接'])}" target="_blank">${esc(t['来源链接'])}</a></div></div>`:''}
+      </div>
+    </div>`;
+  }).join('') || '<div class="empty">本版本无工具</div>';
+}
+
+/* ════ 解构任务 ════ */
+async function startExtract(caseIds){
+  if (!state.queryId || !caseIds.length) return;
+  const isProc = state.mode === 'process';
+  const model = $('#model-sel')?.value || (isProc ? MODELS_PROC[0] : MODELS_TOOL[0]);
+  try {
+    const r = await api(`/api/extract_${isProc?'process':'tools'}`, {
+      method:'POST', body: JSON.stringify({query_id: state.queryId, case_ids: caseIds, model})});
+    showTask(`${isProc?'工序':'工具'}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
+      state.selected.clear();
+      await selectQuery(state.queryId);
+      if (caseIds.includes(state.caseId)) { /* selectQuery 清了 caseId,恢复选中 */ }
+      if (caseIds.length === 1){ state.caseId = caseIds[0]; state.version = null; renderPosts(); await loadExtract(); }
+    });
+  } catch(e){ alert('任务启动失败:' + (e.body?.error || e.status)); }
+}
+
+/* ════ 任务面板 ════ */
+let pollTimer = null;
+function showTask(title, taskId, onDone){
+  $('#task-panel').hidden = false;
+  $('#task-title').textContent = title;
+  $('#task-dot').className = 'dot';
+  $('#task-log').textContent = '启动中…';
+  clearTimeout(pollTimer);
+  const poll = async () => {
+    let t;
+    try { t = await api('/api/task_status?task_id=' + encodeURIComponent(taskId)); }
+    catch(e){ $('#task-log').textContent = '状态查询失败'; return; }
+    const log = $('#task-log');
+    log.textContent = t.log_tail || '(暂无日志)';
+    log.scrollTop = log.scrollHeight;
+    if (t.status === 'running'){ pollTimer = setTimeout(poll, 2000); return; }
+    $('#task-dot').className = 'dot ' + t.status;
+    $('#task-title').textContent = title + (t.status === 'done' ? ' · 完成' : ' · 失败');
+    if (t.status === 'done' && onDone) onDone();
+  };
+  poll();
+}
+function hideTask(){ $('#task-panel').hidden = true; clearTimeout(pollTimer); }
+
+/* ════ 新建搜索 ════ */
+$('#btn-new-search').onclick = () => { $('#search-modal').hidden = false; $('#s-query').focus(); };
+$('#search-modal').onclick = e => { if (e.target === $('#search-modal')) $('#search-modal').hidden = true; };
+$('#s-go').onclick = async () => {
+  const query = $('#s-query').value.trim();
+  if (!query) return alert('请填写 query');
+  const body = {query, synonyms: $('#s-syn').value.trim(),
+                platforms: $('#s-plat').value.trim() || 'xhs,gzh',
+                max_count: parseInt($('#s-max').value) || 10};
+  try {
+    const r = await api('/api/run_search', {method:'POST', body: JSON.stringify(body)});
+    $('#search-modal').hidden = true;
+    showTask(`搜索 · ${r.query_id} ${query}`, r.task_id, async () => {
+      await loadQueries(); selectQuery(r.query_id);
+    });
+  } catch(e){ alert('搜索启动失败:' + (e.body?.error || e.status)); }
+};
+
+/* ════ 工序/工具子模式 ════ */
+$('#m-process').onclick = () => setMode('process');
+$('#m-tools').onclick = () => setMode('tools');
+function setMode(m){
+  if (state.mode === m) return;
+  state.mode = m; state.version = null;
+  $('#m-process').classList.toggle('on', m === 'process');
+  $('#m-tools').classList.toggle('on', m === 'tools');
+  renderQueries(); renderPosts();
+  state.caseId ? loadExtract() : renderExtractEmpty();
+}
+
+window.addEventListener('resize', () => chartRefs.forEach(c => c.resize()));
+route();
+</script>
+</body>
+</html>