index.html 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>mode_workflow · 解构工作台</title>
  7. <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  8. <link rel="preconnect" href="https://fonts.googleapis.com">
  9. <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">
  10. <style>
  11. /* ── 工坊图纸:暖纸底 + 墨蓝印刷 ───────────────────────────────────────── */
  12. :root{
  13. --paper:#f5f2e9; --card:#fffdf6; --ink:#1b2530; --ink-soft:#4c5b6d; --ink-faint:#8b94a3;
  14. --line:#e4ddcb; --line-dark:#cdc4ab;
  15. --navy:#1e3a5f; --navy-deep:#142940; /* 需求区 */
  16. --amber:#b45309; --amber-bg:#fff7e8; /* 输入区 */
  17. --teal:#0f6b5c; --teal-bg:#eef8f3; /* 实现区 */
  18. --green:#14532d; --green-bg:#f0f7ec; /* 输出区 */
  19. --seal:#b3361d; /* 朱砂印 */
  20. --infer:#fdf0d2; --infer-edge:#d97706;
  21. --shadow:0 1px 2px rgba(27,37,48,.06),0 6px 18px -8px rgba(27,37,48,.18);
  22. }
  23. *{box-sizing:border-box;margin:0;padding:0}
  24. html{font-size:14px}
  25. body{
  26. font-family:'Noto Sans SC',sans-serif;color:var(--ink);background:var(--paper);
  27. background-image:
  28. repeating-linear-gradient(0deg,transparent 0 23px,rgba(30,58,95,.035) 23px 24px),
  29. radial-gradient(1200px 500px at 85% -10%,rgba(180,83,9,.05),transparent 60%);
  30. min-height:100vh;
  31. }
  32. .num{font-family:'IBM Plex Mono',monospace}
  33. h1,h2,.serif{font-family:'Noto Serif SC',serif}
  34. a{color:var(--navy)}
  35. button{font-family:inherit;cursor:pointer}
  36. /* ── 顶部 ── */
  37. header{
  38. display:flex;align-items:center;gap:20px;padding:0 28px;height:62px;
  39. background:var(--navy-deep);color:#f1ead8;position:sticky;top:0;z-index:40;
  40. box-shadow:0 2px 12px rgba(20,41,64,.35);
  41. }
  42. .logo{display:flex;align-items:center;gap:12px}
  43. .logo .seal{
  44. width:34px;height:34px;background:var(--seal);color:#fff;display:grid;place-items:center;
  45. font-family:'Noto Serif SC',serif;font-weight:900;font-size:17px;border-radius:4px;
  46. box-shadow:inset 0 0 0 2px rgba(255,255,255,.25);transform:rotate(-4deg);
  47. }
  48. .logo b{font-family:'Noto Serif SC',serif;font-weight:900;font-size:17px;letter-spacing:1px}
  49. .logo small{display:block;font-size:10px;color:#9eb0c5;letter-spacing:3px}
  50. nav{display:flex;gap:4px;margin-left:24px;height:100%}
  51. nav a{
  52. display:flex;align-items:center;padding:0 18px;color:#aebdce;text-decoration:none;
  53. font-weight:500;border-bottom:3px solid transparent;letter-spacing:.5px;
  54. }
  55. nav a.on{color:#fff;border-bottom-color:var(--seal);background:rgba(255,255,255,.04)}
  56. header .spacer{flex:1}
  57. header .hint{font-size:11px;color:#7e92a8;letter-spacing:1px}
  58. main{display:none;padding:24px 28px 80px;max-width:1640px;margin:0 auto}
  59. main.on{display:block}
  60. /* ── 通用卡片/标签 ── */
  61. .card{background:var(--card);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow)}
  62. .pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:11px;font-weight:500;
  63. border:1px solid var(--line-dark);background:#faf7ee;color:var(--ink-soft);white-space:nowrap}
  64. .pill.navy{background:#e8eef6;border-color:#b9c9dd;color:var(--navy)}
  65. .pill.amber{background:var(--amber-bg);border-color:#ecc88a;color:var(--amber)}
  66. .pill.teal{background:var(--teal-bg);border-color:#a9d6c8;color:var(--teal)}
  67. .pill.red{background:#fbeae5;border-color:#e4ab9c;color:var(--seal)}
  68. .btn{
  69. border:1px solid var(--line-dark);background:var(--card);color:var(--ink);
  70. padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500;transition:.15s;
  71. }
  72. .btn:hover{border-color:var(--navy);color:var(--navy);transform:translateY(-1px)}
  73. .btn.primary{background:var(--navy);border-color:var(--navy);color:#fff}
  74. .btn.primary:hover{background:var(--navy-deep);color:#fff}
  75. .btn.seal{background:var(--seal);border-color:var(--seal);color:#fff}
  76. .btn.sm{padding:3px 10px;font-size:12px}
  77. .btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
  78. select,input[type=text],input[type=number]{
  79. font-family:inherit;font-size:13px;padding:6px 9px;border:1px solid var(--line-dark);
  80. border-radius:6px;background:#fff;color:var(--ink);outline:none;
  81. }
  82. select:focus,input:focus{border-color:var(--navy)}
  83. .empty{
  84. text-align:center;color:var(--ink-faint);padding:48px 20px;font-size:13px;line-height:2;
  85. }
  86. .empty .glyph{font-family:'Noto Serif SC',serif;font-size:42px;color:var(--line-dark);display:block}
  87. /* ── Dashboard ── */
  88. .dash-section{margin-bottom:14px;display:flex;align-items:baseline;gap:10px}
  89. .dash-section h2{font-size:16px;font-weight:900;letter-spacing:2px}
  90. .dash-section .rule{flex:1;border-top:1px dashed var(--line-dark)}
  91. .dash-section .tag{font-size:10px;letter-spacing:2px;color:var(--ink-faint)}
  92. .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:26px}
  93. .stat{padding:16px 18px 14px;position:relative;overflow:hidden}
  94. .stat::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--navy)}
  95. .stat.a::after{background:var(--amber)} .stat.t::after{background:var(--teal)} .stat.r::after{background:var(--seal)}
  96. .stat .lbl{font-size:11px;letter-spacing:2px;color:var(--ink-soft);margin-bottom:8px}
  97. .stat .val{font-size:30px;font-weight:700;line-height:1}
  98. .stat .sub{font-size:11px;color:var(--ink-faint);margin-top:7px}
  99. .ring-row{display:flex;align-items:center;gap:14px}
  100. .ring{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;flex:none;
  101. background:conic-gradient(var(--teal) calc(var(--p)*1%),#e8e2d2 0)}
  102. .ring>div{width:48px;height:48px;border-radius:50%;background:var(--card);display:grid;place-items:center;
  103. font-size:12px;font-weight:700}
  104. .charts{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
  105. .chart-card{padding:14px 16px 8px}
  106. .chart-card h3{font-size:13px;font-weight:700;letter-spacing:1px;color:var(--ink-soft);margin-bottom:4px;
  107. display:flex;align-items:baseline;gap:8px}
  108. .chart-card h3 .num{color:var(--seal);font-size:12px}
  109. .chart{width:100%}
  110. .span12{grid-column:span 12}.span6{grid-column:span 6}.span4{grid-column:span 4}
  111. @media(max-width:1100px){.span6,.span4{grid-column:span 12}}
  112. /* ── Dataset 三栏 ── */
  113. .ds-top{display:flex;align-items:center;gap:12px;margin-bottom:16px}
  114. .mode-switch{display:flex;border:1px solid var(--line-dark);border-radius:7px;overflow:hidden}
  115. .mode-switch button{border:0;background:transparent;padding:7px 22px;font-size:13px;font-weight:700;color:var(--ink-soft)}
  116. .mode-switch button.on{background:var(--navy);color:#fff}
  117. .ds-grid{display:grid;grid-template-columns:230px 360px 1fr;gap:16px;align-items:start}
  118. @media(max-width:1280px){.ds-grid{grid-template-columns:200px 320px 1fr}}
  119. .col-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
  120. border-bottom:2px solid var(--ink);font-weight:700;font-size:13px;letter-spacing:1px}
  121. .col-head .n{font-size:11px;color:var(--ink-faint);font-weight:500}
  122. .qlist{max-height:78vh;overflow:auto}
  123. .qitem{padding:11px 14px;border-bottom:1px solid var(--line);cursor:pointer;transition:.12s}
  124. .qitem:hover{background:#faf6ea}
  125. .qitem.on{background:#eef2f8;box-shadow:inset 3px 0 0 var(--navy)}
  126. .qitem .qid{font-size:10px;letter-spacing:1px;color:var(--ink-faint)}
  127. .qitem .qt{font-weight:500;margin:3px 0 5px;line-height:1.4}
  128. .qitem .qm{font-size:11px;color:var(--ink-soft);display:flex;gap:10px}
  129. .plist{max-height:78vh;overflow:auto}
  130. .post{padding:11px 13px;border-bottom:1px solid var(--line);cursor:pointer;display:flex;gap:9px;transition:.12s}
  131. .post:hover{background:#faf6ea}
  132. .post.on{background:#eef2f8;box-shadow:inset 3px 0 0 var(--navy)}
  133. .post input{margin-top:3px;accent-color:var(--navy)}
  134. .post .pt{font-weight:500;line-height:1.45;font-size:13px}
  135. .post .pm{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;align-items:center}
  136. .post .score{font-weight:700;font-size:12px;color:var(--seal)}
  137. .plat{display:inline-block;padding:1px 7px;border-radius:3px;font-size:10px;font-weight:700;color:#fff}
  138. .plat.xhs{background:#d63a2f}.plat.gzh{background:#2e9939}.plat.zhihu{background:#1772f6}
  139. .plat.x{background:#15202b}.plat.other{background:#777}
  140. .done-dot{font-size:10px;color:var(--teal);font-weight:700}
  141. .xp{min-height:60vh}
  142. .xp-head{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:2px solid var(--ink);flex-wrap:wrap}
  143. .xp-head .st{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
  144. .xp-head .st em{color:var(--seal);font-style:normal}
  145. .xp-head .spacer{flex:1}
  146. .xp-body{padding:16px}
  147. /* 工序卡 */
  148. .proc{border:1px solid var(--line-dark);border-radius:8px;margin-bottom:22px;overflow:hidden;background:#fff}
  149. .proc-head{padding:13px 16px;border-bottom:1px solid var(--line);background:#fcfaf3}
  150. .proc-head .nm{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
  151. .proc-head .nm .pid{color:var(--seal);margin-right:6px;font-size:13px}
  152. .proc-head .pp{font-size:12px;color:var(--ink-soft);margin-top:5px;line-height:1.6}
  153. .proc-head .meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
  154. .decl{display:grid;grid-template-columns:1fr 1fr;gap:0;border-bottom:1px solid var(--line)}
  155. .decl>div{padding:10px 16px}
  156. .decl>div+div{border-left:1px solid var(--line)}
  157. .decl .dl{font-size:10px;letter-spacing:2px;color:var(--ink-faint);margin-bottom:6px}
  158. .decl .di{font-size:12px;line-height:1.7}
  159. .decl .di b{font-weight:700}
  160. .steps{width:100%;border-collapse:collapse;font-size:12px}
  161. .steps th{padding:6px 8px;font-size:11px;font-weight:700;letter-spacing:1px;color:#fff;text-align:left}
  162. .steps thead tr:first-child th{text-align:center;font-size:12px;letter-spacing:4px;padding:7px 4px}
  163. .steps .h-req{background:var(--navy)} .steps .h-req2{background:#33547a}
  164. .steps .h-in{background:var(--amber)} .steps .h-in2{background:#cd7522}
  165. .steps .h-im{background:var(--teal)} .steps .h-im2{background:#2d8273}
  166. .steps .h-out{background:var(--green)} .steps .h-out2{background:#2e6b45}
  167. .steps td{padding:8px 9px;border:1px solid var(--line);vertical-align:top;line-height:1.6}
  168. .steps tbody tr:nth-child(odd) td{background:#fffdf6}
  169. .steps td.c-in{background:var(--amber-bg)!important}
  170. .steps td.c-out{background:var(--green-bg)!important}
  171. .steps .sid{font-family:'IBM Plex Mono',monospace;font-weight:700;color:var(--navy);white-space:nowrap}
  172. .steps .vtxt{color:var(--ink-soft);font-size:11.5px;max-width:340px;word-break:break-all}
  173. .inf{background:var(--infer)!important;position:relative;outline:1px dashed var(--infer-edge);outline-offset:-2px}
  174. .inf .ib{position:absolute;top:-1px;right:-1px;background:var(--infer-edge);color:#fff;font-size:9px;
  175. padding:0 4px;border-radius:0 0 0 4px;font-weight:700}
  176. .anchor{font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:var(--ink-faint);white-space:nowrap}
  177. /* 工具表格(移植自 fixed_query_eval:案例逐行 rowspan + 限高展开) */
  178. .mw-ttwrap{overflow-x:auto;border:1px solid #e6ded2;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,.05)}
  179. .mw-tt{border-collapse:separate;border-spacing:0;width:100%;min-width:1180px;background:#fff;font-size:12.5px}
  180. .mw-tt thead th{
  181. position:sticky;top:0;z-index:2;text-align:left;white-space:nowrap;
  182. background:linear-gradient(180deg,#2aa79b,#1c8076);color:#fff;font-weight:700;
  183. padding:10px 12px;letter-spacing:.3px;border-right:1px solid rgba(255,255,255,.18);
  184. }
  185. .mw-tt thead th:last-child{border-right:none}
  186. .mw-tt thead tr:first-child th{top:0}
  187. .mw-tt thead tr:nth-child(2) th{top:38px}
  188. .mw-tt .th-group{text-align:center}
  189. .mw-tt .th-sub{background:linear-gradient(180deg,#36bdb0,#23897f);font-weight:600}
  190. .mw-tt tbody td{padding:9px 12px;vertical-align:top;line-height:1.6;
  191. border-bottom:1px solid #f0eae0;border-right:1px solid #f5f0e8;color:#3a3a3a}
  192. .mw-tt tbody td:last-child{border-right:none}
  193. .mw-tt td.col-case{background:#fafdfc}
  194. .mw-tt tbody tr.tr-b td{background:#fbfaf6}
  195. .mw-tt tbody tr.tr-b td.col-case{background:#f6fbfa}
  196. .mw-tt tbody td.col-tool{font-weight:700;color:#176d64;white-space:nowrap;
  197. border-left:3px solid #2aa79b;background:#f3faf8}
  198. .mw-tt tbody tr:hover td.col-tool{background:#e3f4f0}
  199. .mw-tt ul{margin:0;padding-left:17px}
  200. .mw-tt ul li{margin:3px 0}
  201. .mw-tt ul li::marker{color:#2aa79b}
  202. .mw-tt .layer-badge{display:inline-block;font-weight:700;font-size:11px;padding:2px 10px;border-radius:20px;white-space:nowrap}
  203. .mw-tt .layer-badge.make{color:#0e7490;background:#d6f0ee}
  204. .mw-tt .layer-badge.create{color:#b8731a;background:#fef0db}
  205. .mw-tt .dash{color:#c9c2b6}
  206. .mw-tt .tcell{position:relative;max-height:7.8em;overflow:hidden;transition:max-height .15s}
  207. .mw-tt .tcell.clamped{cursor:zoom-in}
  208. .mw-tt .tcell.clamped::after{content:'▾ 展开';position:absolute;left:0;right:0;bottom:0;
  209. height:2.6em;display:flex;align-items:flex-end;justify-content:center;padding-bottom:2px;
  210. font-size:11px;font-weight:700;color:#176d64;
  211. background:linear-gradient(rgba(255,255,255,0),#fff 72%);pointer-events:none}
  212. .mw-tt tbody tr.tr-b .tcell.clamped::after{background:linear-gradient(rgba(251,250,246,0),#fbfaf6 72%)}
  213. .mw-tt td.col-case .tcell.clamped::after{background:linear-gradient(rgba(250,253,252,0),#fafdfc 72%)}
  214. .mw-tt tbody tr.tr-b td.col-case .tcell.clamped::after{background:linear-gradient(rgba(246,251,250,0),#f6fbfa 72%)}
  215. .mw-tt .tcell.open{max-height:none;cursor:zoom-out}
  216. .mw-tt .tcell.open::after{content:'';height:0}
  217. /* 任务面板 */
  218. #task-panel{
  219. position:fixed;right:22px;bottom:22px;width:430px;max-height:55vh;z-index:60;
  220. display:flex;flex-direction:column;border:1px solid var(--line-dark);border-radius:10px;
  221. background:var(--card);box-shadow:0 16px 50px -12px rgba(20,41,64,.45);overflow:hidden;
  222. }
  223. #task-panel header{all:unset;display:flex;align-items:center;gap:9px;padding:10px 14px;
  224. background:var(--navy-deep);color:#fff;font-size:13px;font-weight:700}
  225. #task-panel header .dot{width:8px;height:8px;border-radius:50%;background:#f5b942;animation:blink 1s infinite}
  226. #task-panel header .dot.done{background:#3fb27f;animation:none}
  227. #task-panel header .dot.failed{background:#e0492f;animation:none}
  228. @keyframes blink{50%{opacity:.3}}
  229. #task-panel header button{margin-left:auto;background:none;border:0;color:#9eb0c5;font-size:15px}
  230. #task-log{font-family:'IBM Plex Mono',monospace;font-size:11px;line-height:1.65;color:var(--ink-soft);
  231. white-space:pre-wrap;overflow:auto;padding:12px 14px;background:#fbf8ef;flex:1}
  232. /* 弹窗 */
  233. .modal-bg{position:fixed;inset:0;background:rgba(20,30,40,.45);z-index:70;display:grid;place-items:center}
  234. .modal{width:460px;background:var(--card);border-radius:10px;box-shadow:0 24px 80px rgba(0,0,0,.4)}
  235. .modal h2{padding:14px 18px;background:var(--navy-deep);color:#fff;font-size:15px;letter-spacing:2px;border-radius:10px 10px 0 0}
  236. .modal .mb{padding:18px;display:grid;gap:12px}
  237. .modal label{font-size:12px;color:var(--ink-soft);display:grid;gap:5px}
  238. .modal .mf{display:flex;justify-content:flex-end;gap:10px;padding:0 18px 18px}
  239. /* 渠道下拉多选 */
  240. .dd{position:relative}
  241. .dd-btn{width:100%;display:flex;justify-content:space-between;align-items:center;gap:8px;
  242. text-align:left;padding:7px 10px;border:1px solid var(--line-dark);border-radius:6px;
  243. background:#fff;font-size:13px;color:var(--ink)}
  244. .dd-btn .dd-arrow{color:var(--ink-faint);flex:none}
  245. .dd-btn:focus{border-color:var(--navy)}
  246. .dd-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:10;
  247. background:#fff;border:1px solid var(--line-dark);border-radius:8px;
  248. box-shadow:0 10px 30px rgba(20,41,64,.18);padding:6px;max-height:220px;overflow:auto}
  249. .modal label.dd-opt{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px 9px;
  250. border-radius:5px;font-size:13px;color:var(--ink);cursor:pointer}
  251. .dd-opt:hover{background:#f3f0e6}
  252. .dd-opt input{accent-color:var(--navy);margin:0;cursor:pointer}
  253. .dd-opt.sel{background:#eef2f8;font-weight:500}
  254. /* 聚类库占位 */
  255. .cluster-empty{display:grid;place-items:center;min-height:60vh}
  256. .cluster-empty .inner{text-align:center;color:var(--ink-faint)}
  257. .cluster-empty .stamp{
  258. width:120px;height:120px;margin:0 auto 18px;border:3px solid var(--line-dark);border-radius:50%;
  259. display:grid;place-items:center;font-family:'Noto Serif SC',serif;font-size:30px;font-weight:900;
  260. color:var(--line-dark);transform:rotate(-8deg);letter-spacing:4px;
  261. }
  262. [hidden]{display:none!important}
  263. </style>
  264. </head>
  265. <body>
  266. <header>
  267. <div class="logo">
  268. <div class="seal">解</div>
  269. <div><b>mode_workflow</b><small>解构工作台 · MODE WORKBENCH</small></div>
  270. </div>
  271. <nav id="nav">
  272. <a href="#dashboard" data-tab="dashboard">Dashboard</a>
  273. <a href="#dataset" data-tab="dataset">Dataset</a>
  274. <a href="#cluster" data-tab="cluster">聚类库</a>
  275. </nav>
  276. <div class="spacer"></div>
  277. <div class="hint">SEARCH · EXTRACT · ARCHIVE</div>
  278. </header>
  279. <main id="view-dashboard"></main>
  280. <main id="view-dataset">
  281. <div class="ds-top">
  282. <div class="mode-switch">
  283. <button id="m-process" class="on">工序</button>
  284. <button id="m-tools">工具</button>
  285. </div>
  286. <div style="flex:1"></div>
  287. <button class="btn seal" id="btn-new-search">+ 新建搜索</button>
  288. </div>
  289. <div class="ds-grid">
  290. <div class="card">
  291. <div class="col-head">QUERY <span class="n" id="q-count"></span></div>
  292. <div class="qlist" id="query-list"><div class="empty"><span class="glyph">空</span>暂无 query</div></div>
  293. </div>
  294. <div class="card">
  295. <div class="col-head">帖子
  296. <span style="display:flex;gap:8px;align-items:center">
  297. <span class="n" id="p-count"></span>
  298. <button class="btn sm" id="btn-batch" disabled>批量解构</button>
  299. </span>
  300. </div>
  301. <div class="plist" id="post-list"><div class="empty"><span class="glyph">←</span>先选择左侧 query</div></div>
  302. </div>
  303. <div class="card xp">
  304. <div class="xp-head" id="xp-head"><span class="st">解构结果</span></div>
  305. <div class="xp-body" id="xp-body"><div class="empty"><span class="glyph">解</span>选择一个帖子查看解构结果</div></div>
  306. </div>
  307. </div>
  308. </main>
  309. <main id="view-cluster">
  310. <div class="cluster-empty"><div class="inner">
  311. <div class="stamp">聚类</div>
  312. <div class="serif" style="font-size:18px;color:var(--ink-soft);font-weight:900;letter-spacing:3px">聚类库 · 敬请期待</div>
  313. <div style="margin-top:8px;font-size:12px">跨帖工序 / 工具的聚类沉淀,将在此呈现</div>
  314. </div></div>
  315. </main>
  316. <div id="task-panel" hidden>
  317. <header><span class="dot" id="task-dot"></span><span id="task-title">任务</span>
  318. <button onclick="hideTask()">✕</button></header>
  319. <div id="task-log"></div>
  320. </div>
  321. <div class="modal-bg" id="search-modal" hidden>
  322. <div class="modal">
  323. <h2>新建搜索</h2>
  324. <div class="mb">
  325. <label>Query(评估锚点,必填)<input type="text" id="s-query" placeholder="如:AI 人像 图片 生成 怎么做"></label>
  326. <label>解构方向<select id="s-mode"><option value="工序">工序</option><option value="工具">工具</option></select></label>
  327. <label>同义措辞(可选,逗号分隔)<input type="text" id="s-syn" placeholder="如:AI 人像生成 教程,AI 写真 怎么做"></label>
  328. <label>检索渠道(下拉多选)
  329. <div class="dd" id="s-plat-dd">
  330. <button type="button" class="dd-btn" id="s-plat-btn">选择渠道 ▾</button>
  331. <div class="dd-panel" id="s-plat-panel" hidden></div>
  332. </div>
  333. </label>
  334. <label>每措辞每渠道上限<input type="number" id="s-max" value="10" min="1" max="50"></label>
  335. </div>
  336. <div class="mf">
  337. <button class="btn" onclick="document.getElementById('search-modal').hidden=true">取消</button>
  338. <button class="btn primary" id="s-go">开始搜索</button>
  339. </div>
  340. </div>
  341. </div>
  342. <script>
  343. /* ════ 基础 ════ */
  344. const $ = s => document.querySelector(s);
  345. const api = (p, opt) => fetch(p, opt).then(async r => {
  346. if (!r.ok) throw Object.assign(new Error('api'), {status: r.status, body: await r.json().catch(() => ({}))});
  347. return r.json();
  348. });
  349. const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  350. const state = {tab:'dashboard', mode:'process', queryId:null, caseId:null, post:null,
  351. version:null, selected:new Set(), queries:[], posts:[]};
  352. const PLAT_CLS = p => ({xhs:'xhs',gzh:'gzh',zhihu:'zhihu',x:'x'})[p] || 'other';
  353. const PLAT_NAME = p => ({xhs:'小红书',gzh:'公众号',zhihu:'知乎',x:'X'})[p] || p || '?';
  354. const MODELS_PROC = ['anthropic/claude-sonnet-4-6','google/gemini-3.1-flash-lite'];
  355. const MODELS_TOOL = ['google/gemini-3.1-flash-lite','anthropic/claude-sonnet-4-6'];
  356. /* ════ 路由 ════ */
  357. function route(){
  358. state.tab = (location.hash || '#dashboard').slice(1);
  359. if (!['dashboard','dataset','cluster'].includes(state.tab)) state.tab = 'dashboard';
  360. document.querySelectorAll('nav a').forEach(a => a.classList.toggle('on', a.dataset.tab === state.tab));
  361. document.querySelectorAll('main').forEach(m => m.classList.toggle('on', m.id === 'view-' + state.tab));
  362. if (state.tab === 'dashboard') renderDashboard();
  363. if (state.tab === 'dataset' && !state.queries.length) loadQueries();
  364. }
  365. window.addEventListener('hashchange', route);
  366. /* ════ Dashboard ════ */
  367. let chartRefs = [];
  368. async function renderDashboard(){
  369. const v = $('#view-dashboard');
  370. let d;
  371. try { d = await api('/api/dashboard'); }
  372. catch(e){ v.innerHTML = `<div class="card empty"><span class="glyph">!</span>Dashboard 加载失败:${esc(e.body?.error || e.status)}</div>`; return; }
  373. const r = d.result, p = d.process_data;
  374. const pct = (a,b) => b ? Math.round(a/b*100) : 0;
  375. v.innerHTML = `
  376. <div class="dash-section"><h2>结果数据</h2><div class="rule"></div><span class="tag">RESULTS</span></div>
  377. <div class="cards">
  378. <div class="card stat"><div class="lbl">采集帖子数量</div><div class="val num">${r.post_count}</div><div class="sub">search_data 全量</div></div>
  379. <div class="card stat t"><div class="lbl">解构帖子数量</div><div class="val num">${r.extracted_post_count}</div><div class="sub">工序 ∪ 工具 已解构</div></div>
  380. <div class="card stat a"><div class="lbl">工具数量</div><div class="val num">${r.tool_count}</div><div class="sub">mode_tools 去重工具名</div></div>
  381. <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>
  382. <div class="sub">${pct(r.matrix_covered,r.matrix_valid)}% · 动作×类型有效节点</div></div>
  383. <div class="card stat"><div class="lbl">实质覆盖度</div><div class="val num">${r.substance_count}</div><div class="sub">去重实质条目数</div></div>
  384. <div class="card stat"><div class="lbl">形式覆盖度</div><div class="val num">${r.form_count}</div><div class="sub">去重形式条目数</div></div>
  385. </div>
  386. <div class="dash-section"><h2>过程数据</h2><div class="rule"></div><span class="tag">PROCESS</span></div>
  387. <div class="cards">
  388. <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>
  389. <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>
  390. <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>
  391. <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>
  392. <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>
  393. <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>
  394. </div>
  395. <div class="charts">
  396. <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>
  397. <div class="card chart-card span6"><h3>工序提及工具 TOP10</h3><div class="chart" id="ch-via" style="height:300px"></div></div>
  398. <div class="card chart-card span6"><h3>解构成本趋势</h3><div class="chart" id="ch-cost" style="height:300px"></div></div>
  399. <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>
  400. <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>
  401. </div>`;
  402. chartRefs.forEach(c => c.dispose()); chartRefs = [];
  403. const mk = (id, opt) => { const c = echarts.init($(id)); c.setOption(opt); chartRefs.push(c); return c; };
  404. const ink = '#1b2530', faint = '#8b94a3', grid = {left:8,right:16,top:8,bottom:8,containLabel:true};
  405. /* 热力图 */
  406. mk('#ch-matrix', {
  407. tooltip:{formatter: pr => `${r.matrix_actions[pr.value[1]]} × ${r.matrix_types[pr.value[0]]}`},
  408. grid:{left:8,right:16,top:8,bottom:60,containLabel:true},
  409. xAxis:{type:'category',data:r.matrix_types,axisLabel:{rotate:60,fontSize:9,color:faint},splitArea:{show:true,areaStyle:{color:['#fcfaf3','#f6f2e6']}}},
  410. yAxis:{type:'category',data:r.matrix_actions,axisLabel:{fontSize:10,color:ink},splitArea:{show:true}},
  411. visualMap:{show:false,min:0,max:1,inRange:{color:['#f1ecdd','#b3361d']}},
  412. series:[{type:'heatmap',data:r.matrix_cells.map(([a,t])=>[t,a,1]),itemStyle:{borderColor:'#fff',borderWidth:1}}]
  413. });
  414. const bar = (data, color) => ({
  415. tooltip:{}, grid,
  416. xAxis:{type:'value',axisLabel:{color:faint}},
  417. yAxis:{type:'category',data:data.map(x=>x[0]).reverse(),axisLabel:{color:ink,fontSize:11,width:130,overflow:'truncate'}},
  418. series:[{type:'bar',data:data.map(x=>x[1]).reverse(),itemStyle:{color,borderRadius:[0,3,3,0]},barMaxWidth:16,
  419. label:{show:true,position:'right',color:faint,fontSize:10}}]
  420. });
  421. mk('#ch-via', r.via_top10.length ? bar(r.via_top10,'#1e3a5f') : emptyOpt());
  422. mk('#ch-sub', r.substance_top.length ? bar(r.substance_top,'#b45309') : emptyOpt());
  423. mk('#ch-form', r.form_top.length ? bar(r.form_top,'#0f6b5c') : emptyOpt());
  424. mk('#ch-cost', p.cost_trend.length ? {
  425. tooltip:{trigger:'axis'}, grid,
  426. xAxis:{type:'category',data:p.cost_trend.map(x=>x.date),axisLabel:{color:faint}},
  427. yAxis:{type:'value',axisLabel:{color:faint,formatter:'${value}'}},
  428. series:[{type:'line',data:p.cost_trend.map(x=>x.cost),smooth:true,symbolSize:7,
  429. lineStyle:{color:'#b3361d',width:2.5},itemStyle:{color:'#b3361d'},
  430. areaStyle:{color:'rgba(179,54,29,.08)'}}]
  431. } : emptyOpt());
  432. function emptyOpt(){ return {title:{text:'暂无数据',left:'center',top:'middle',textStyle:{color:faint,fontSize:12,fontWeight:400}},xAxis:{show:false},yAxis:{show:false}}; }
  433. }
  434. /* ════ Dataset:query 列表 ════ */
  435. async function loadQueries(){
  436. try { state.queries = await api('/api/queries'); } catch(e){ state.queries = []; }
  437. renderQueries();
  438. }
  439. function renderQueries(){
  440. /* mode_type 过滤:工序 tab 显示「工序 + 通用(空)」,工具 tab 显示「工具 + 通用(空)」 */
  441. const want = state.mode === 'process' ? '工序' : '工具';
  442. const list = state.queries.filter(q => !q.mode_type || q.mode_type === want);
  443. $('#q-count').textContent = list.length ? list.length + ' 组' : '';
  444. if (!list.length){
  445. $('#query-list').innerHTML = '<div class="empty"><span class="glyph">空</span>暂无 query<br>点右上「新建搜索」开始</div>'; return;
  446. }
  447. $('#query-list').innerHTML = list.map(q => {
  448. const done = state.mode === 'process' ? q.process_done : q.tools_done;
  449. const mt = q.mode_type
  450. ? `<span class="pill ${q.mode_type==='工序'?'navy':'teal'}">${esc(q.mode_type)}</span>`
  451. : '<span class="pill">通用</span>';
  452. return `<div class="qitem ${q.query_id===state.queryId?'on':''}" onclick="selectQuery('${q.query_id}')">
  453. <div class="qid">${q.query_id} ${mt}</div>
  454. <div class="qt">${esc(q.query_text || '(未命名)')}</div>
  455. <div class="qm"><span class="num">${q.post_count} 帖</span><span>已解构 <b class="num">${done}</b></span></div>
  456. </div>`;
  457. }).join('');
  458. }
  459. async function selectQuery(qid){
  460. state.queryId = qid; state.caseId = null; state.post = null; state.selected.clear();
  461. renderQueries(); renderExtractEmpty();
  462. $('#post-list').innerHTML = '<div class="empty">加载中…</div>';
  463. try { state.posts = await api('/api/posts?query_id=' + encodeURIComponent(qid)); }
  464. catch(e){ state.posts = []; }
  465. renderPosts();
  466. }
  467. function renderPosts(){
  468. $('#p-count').textContent = state.posts.length ? state.posts.length + ' 帖' : '';
  469. updateBatchBtn();
  470. if (!state.posts.length){
  471. $('#post-list').innerHTML = '<div class="empty"><span class="glyph">空</span>该 query 暂无帖子</div>'; return;
  472. }
  473. $('#post-list').innerHTML = state.posts.map(p => {
  474. const done = state.mode === 'process' ? p.has_process : p.has_tools;
  475. const kt = (p.knowledge_type || []).map(k => `<span class="pill">${esc(k)}</span>`).join('');
  476. return `<div class="post ${p.case_id===state.caseId?'on':''}" onclick="selectPost('${esc(p.case_id)}')">
  477. <input type="checkbox" ${state.selected.has(p.case_id)?'checked':''}
  478. onclick="event.stopPropagation();toggleSel('${esc(p.case_id)}',this.checked)">
  479. <div style="flex:1">
  480. <div class="pt">${esc(p.title || '(无标题)')}</div>
  481. <div class="pm">
  482. <span class="plat ${PLAT_CLS(p.platform)}">${PLAT_NAME(p.platform)}</span>
  483. ${p.overall_score != null ? `<span class="score num">${p.overall_score}</span>` : ''}
  484. ${kt}
  485. ${done ? '<span class="done-dot">● 已解构</span>' : ''}
  486. </div>
  487. </div>
  488. </div>`;
  489. }).join('');
  490. }
  491. function toggleSel(cid, on){ on ? state.selected.add(cid) : state.selected.delete(cid); updateBatchBtn(); }
  492. function updateBatchBtn(){
  493. const b = $('#btn-batch');
  494. b.disabled = !state.selected.size;
  495. b.textContent = state.selected.size ? `批量解构(${state.selected.size})` : '批量解构';
  496. }
  497. $('#btn-batch').onclick = () => state.selected.size && startExtract([...state.selected]);
  498. /* ════ Dataset:右栏解构结果 ════ */
  499. function renderExtractEmpty(){
  500. $('#xp-head').innerHTML = '<span class="st">解构结果</span>';
  501. $('#xp-body').innerHTML = '<div class="empty"><span class="glyph">解</span>选择一个帖子查看解构结果</div>';
  502. }
  503. async function selectPost(cid){
  504. state.caseId = cid; state.version = null;
  505. state.post = state.posts.find(p => p.case_id === cid) || null;
  506. renderPosts();
  507. await loadExtract();
  508. }
  509. async function loadExtract(){
  510. if (!state.caseId) return renderExtractEmpty();
  511. const isProc = state.mode === 'process';
  512. const vURL = `/api/${isProc?'process':'tools'}_versions?case_id=` + encodeURIComponent(state.caseId);
  513. const dURL = `/api/${isProc?'process':'tools'}?case_id=` + encodeURIComponent(state.caseId)
  514. + (state.version ? '&version=' + encodeURIComponent(state.version) : '');
  515. let versions = [], data = null, missing = false;
  516. try { versions = await api(vURL); } catch(e){}
  517. try { data = await api(dURL); } catch(e){ if (e.status === 404) missing = true; else throw e; }
  518. renderExtractHead(versions, data, missing);
  519. const body = $('#xp-body');
  520. if (missing || !data){
  521. body.innerHTML = `<div class="empty"><span class="glyph">未</span>该帖暂无${isProc?'工序':'工具'}解构<br><br>
  522. <button class="btn primary" onclick="startExtract(['${esc(state.caseId)}'])">开始解构</button></div>`;
  523. return;
  524. }
  525. state.version = data.version;
  526. syncVersionSelect();
  527. body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
  528. if (!isProc) requestAnimationFrame(markClampedCells);
  529. }
  530. function renderExtractHead(versions, data, missing){
  531. const isProc = state.mode === 'process';
  532. const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || '');
  533. const opts = versions.map((v,i) =>
  534. `<option value="${esc(v.version)}">${esc(v.version)}${i===0?' (最新)':''} · ${v.n}${isProc?'工序':'工具'}</option>`).join('');
  535. const models = (isProc ? MODELS_PROC : MODELS_TOOL).map(m => `<option>${m}</option>`).join('');
  536. $('#xp-head').innerHTML = `
  537. <span class="st">大模型${isProc?'工序':'工具'}:<em>${missing?'未提取':'已提取'}</em></span>
  538. <span style="font-size:12px;color:var(--ink-faint);max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title}</span>
  539. <span class="spacer"></span>
  540. ${versions.length ? `<select id="ver-sel">${opts}</select>` : ''}
  541. <select id="model-sel" title="解构模型">${models}</select>
  542. <button class="btn sm primary" onclick="startExtract(['${esc(state.caseId||'')}'])">${missing?'提取':'♻ 重新生成'}</button>`;
  543. const vs = $('#ver-sel');
  544. if (vs) vs.onchange = () => { state.version = vs.value; loadExtract(); };
  545. }
  546. function syncVersionSelect(){ const vs = $('#ver-sel'); if (vs && state.version) vs.value = state.version; }
  547. /* ── 工序渲染 ── */
  548. function renderProcedures(data){
  549. const src = data.source || {};
  550. const srcLine = src.title ? `<div class="pill" style="margin-bottom:14px">▸ 原文:${esc(src.title)}</div>` : '';
  551. return srcLine + (data.procedures || []).map(p => {
  552. const d = p.declarations || {};
  553. const ins = (d.inputs || []).map(x =>
  554. `<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>';
  555. const ret = d.returns ? `<span class="pill teal">${esc(d.returns.type || '')}</span>` : '<span style="color:var(--ink-faint)">无</span>';
  556. return `<div class="proc">
  557. <div class="proc-head">
  558. <div class="nm"><span class="pid">工序 ${esc(p.id || '')}</span>${esc(p.name || '')}</div>
  559. ${p.purpose ? `<div class="pp">#目的:${esc(p.purpose)}</div>` : ''}
  560. <div class="meta">
  561. ${p.category ? `<span class="pill red">类别:${esc(p.category)}</span>` : ''}
  562. ${src.platform ? `<span class="pill">#平台:${esc(src.platform)}</span>` : ''}
  563. ${src.author ? `<span class="pill">#作者:${esc(src.author)}</span>` : ''}
  564. <span class="pill">case:${esc(data.case_id)}</span>
  565. ${(p.tools_used||[]).map(t=>`<span class="pill teal">${esc(t)}</span>`).join('')}
  566. </div>
  567. </div>
  568. <div class="decl">
  569. <div><div class="dl">输入</div><div class="di">${ins}</div></div>
  570. <div><div class="dl">返回</div><div class="di">${ret}</div></div>
  571. </div>
  572. ${renderSteps(p.steps || [])}
  573. </div>`;
  574. }).join('') || '<div class="empty">本版本无工序</div>';
  575. }
  576. function renderSteps(steps){
  577. if (!steps.length) return '<div class="empty">无步骤</div>';
  578. let rows = '';
  579. for (const s of steps){
  580. const ins = (s.inputs && s.inputs.length) ? s.inputs : [null];
  581. const outs = (s.outputs && s.outputs.length) ? s.outputs : [null];
  582. const n = Math.max(ins.length, outs.length);
  583. for (let i = 0; i < n; i++){
  584. rows += '<tr>';
  585. if (i === 0){
  586. rows += `<td rowspan="${n}" class="sid">${esc(s.id||'')}</td>
  587. <td rowspan="${n}">${esc(s.directive || s.intent || '')}</td>
  588. <td rowspan="${n}">${s.effect?`<span class="pill navy">${esc(s.effect)}</span>`:''}</td>
  589. <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
  590. <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
  591. }
  592. rows += ioCell(ins[i], 'in');
  593. if (i === 0){
  594. rows += `<td rowspan="${n}">${s.via?`<span class="pill teal">${esc(s.via)}</span>`:''}</td>
  595. <td rowspan="${n}" class="vtxt">${esc(s.action||'')}</td>`;
  596. }
  597. rows += ioCell(outs[i], 'out');
  598. rows += '</tr>';
  599. }
  600. }
  601. return `<div style="overflow-x:auto"><table class="steps">
  602. <thead>
  603. <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>
  604. <tr>
  605. <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>
  606. <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
  607. <th class="h-im2">外部工具</th><th class="h-im2">动作</th>
  608. <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
  609. </tr>
  610. </thead><tbody>${rows}</tbody></table></div>`;
  611. }
  612. function fmtSF(v){ return v == null ? '' : (Array.isArray(v) ? v.join('、') : v); }
  613. function ioCell(x, kind){
  614. const cls = kind === 'in' ? 'c-in' : 'c-out';
  615. if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
  616. const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
  617. const badge = x.inferred ? '<span class="ib">推</span>' : '';
  618. return `<td class="${cls}"><span class="pill ${kind==='in'?'amber':'teal'}">${esc(x.type||'')}</span></td>
  619. <td class="${cls}${inf}">${badge}<span class="vtxt">${esc(x.value||'')}</span></td>
  620. <td class="${cls}"><span class="anchor">${esc(x.anchor||'')}</span></td>`;
  621. }
  622. /* ── 工具渲染(表格,移植自 fixed_query_eval renderToolTable)── */
  623. const DASH = '<span class="dash">—</span>';
  624. function _toolCell(v){
  625. if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) return DASH;
  626. if (Array.isArray(v)) return '<ul>' + v.map(x => `<li>${esc(String(x))}</li>`).join('') + '</ul>';
  627. return esc(String(v));
  628. }
  629. function _scopeCell(v){
  630. /* 作用域是 JSON 数组:按「、」拼接成短语,不出列表 */
  631. if (v === null || v === undefined || (Array.isArray(v) && !v.length) || v === '') return DASH;
  632. return esc(Array.isArray(v) ? v.join('、') : String(v));
  633. }
  634. function _ttCell(inner, clampable){
  635. return clampable ? `<div class="tcell" onclick="this.classList.toggle('open')">${inner}</div>` : inner;
  636. }
  637. function _toolCellContent(c, t){
  638. let inner, cls = '', clampable = true, style = '';
  639. if (c === '工具名称'){
  640. cls = 'col-tool'; clampable = false; inner = `🔧 ${esc(t[c] || '(未命名)')}`;
  641. } else if (c === '来源链接'){
  642. clampable = false;
  643. inner = t[c] ? `<a href="${esc(t[c])}" target="_blank" style="color:#176d64;font-weight:600;">🔗 打开</a>` : DASH;
  644. } else if (c === '创作层级'){
  645. clampable = false;
  646. inner = t[c] ? `<span class="layer-badge ${t[c] === '制作层' ? 'make' : 'create'}">${esc(t[c])}</span>` : DASH;
  647. } else if (c === '实质作用域' || c === '形式作用域'){
  648. inner = _scopeCell(t[c]);
  649. } else {
  650. inner = _toolCell(t[c]);
  651. }
  652. if (['输入','输出','用法','缺点'].includes(c)) style = 'max-width:240px;';
  653. else if (c === '实质作用域' || c === '形式作用域') style = 'max-width:170px;';
  654. else if (!clampable) style = 'white-space:nowrap;';
  655. return {inner, cls, clampable, style};
  656. }
  657. function _td(c, t, rowspan){
  658. const {inner, cls, clampable, style} = _toolCellContent(c, t);
  659. const rs = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
  660. return `<td class="${cls}" style="${style}"${rs}>${_ttCell(inner, clampable)}</td>`;
  661. }
  662. function _caseTd(cse, key){
  663. const v = (cse && cse[key] != null && cse[key] !== '') ? esc(String(cse[key])) : DASH;
  664. return `<td class="col-case" style="max-width:210px;">${_ttCell(v, true)}</td>`;
  665. }
  666. function renderTools(data){
  667. const tools = data.tools || [];
  668. if (!tools.length) return '<div class="empty">本版本无工具</div>';
  669. /* 案例 group(输入/输出/效果)放在 用法 后、缺点 前;用 colspan/rowspan 做两层表头 */
  670. const before = ['工具名称','创作层级','实质作用域','形式作用域','输入','输出','用法'];
  671. const after = ['缺点','来源链接','最新更新时间'];
  672. const thead = `<thead>
  673. <tr>
  674. ${before.map(c => `<th rowspan="2">${c}</th>`).join('')}
  675. <th colspan="3" class="th-group">案例</th>
  676. ${after.map(c => `<th rowspan="2">${c}</th>`).join('')}
  677. </tr>
  678. <tr>${['输入','输出','效果'].map(c => `<th class="th-sub">${c}</th>`).join('')}</tr>
  679. </thead>`;
  680. const rows = tools.map((t, ti) => {
  681. const cases = (Array.isArray(t['案例']) && t['案例'].length) ? t['案例'] : [null];
  682. const K = cases.length;
  683. const par = ti % 2 ? 'tr-b' : 'tr-a';
  684. return cases.map((cse, i) => {
  685. const caseTds = `${_caseTd(cse,'输入')}${_caseTd(cse,'输出')}${_caseTd(cse,'效果')}`;
  686. if (i === 0){
  687. return `<tr class="${par}">${before.map(c => _td(c, t, K)).join('')}${caseTds}${after.map(c => _td(c, t, K)).join('')}</tr>`;
  688. }
  689. return `<tr class="${par}">${caseTds}</tr>`;
  690. }).join('');
  691. }).join('');
  692. return `<div class="mw-ttwrap"><table class="mw-tt">${thead}<tbody>${rows}</tbody></table></div>`;
  693. }
  694. /* 渲染后标记真正溢出的单元格(才显示蒙版+可点击) */
  695. function markClampedCells(){
  696. document.querySelectorAll('#xp-body .mw-tt .tcell').forEach(el => {
  697. if (!el.classList.contains('open') && el.scrollHeight > el.clientHeight + 2) el.classList.add('clamped');
  698. else if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clamped');
  699. });
  700. }
  701. /* ════ 解构任务 ════ */
  702. async function startExtract(caseIds){
  703. if (!state.queryId || !caseIds.length) return;
  704. const isProc = state.mode === 'process';
  705. const model = $('#model-sel')?.value || (isProc ? MODELS_PROC[0] : MODELS_TOOL[0]);
  706. try {
  707. const r = await api(`/api/extract_${isProc?'process':'tools'}`, {
  708. method:'POST', body: JSON.stringify({query_id: state.queryId, case_ids: caseIds, model})});
  709. showTask(`${isProc?'工序':'工具'}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
  710. state.selected.clear();
  711. await selectQuery(state.queryId);
  712. if (caseIds.includes(state.caseId)) { /* selectQuery 清了 caseId,恢复选中 */ }
  713. if (caseIds.length === 1){ state.caseId = caseIds[0]; state.version = null; renderPosts(); await loadExtract(); }
  714. });
  715. } catch(e){ alert('任务启动失败:' + (e.body?.error || e.status)); }
  716. }
  717. /* ════ 任务面板 ════ */
  718. let pollTimer = null;
  719. function showTask(title, taskId, onDone){
  720. $('#task-panel').hidden = false;
  721. $('#task-title').textContent = title;
  722. $('#task-dot').className = 'dot';
  723. $('#task-log').textContent = '启动中…';
  724. clearTimeout(pollTimer);
  725. const poll = async () => {
  726. let t;
  727. try { t = await api('/api/task_status?task_id=' + encodeURIComponent(taskId)); }
  728. catch(e){ $('#task-log').textContent = '状态查询失败'; return; }
  729. const log = $('#task-log');
  730. log.textContent = t.log_tail || '(暂无日志)';
  731. log.scrollTop = log.scrollHeight;
  732. if (t.status === 'running'){ pollTimer = setTimeout(poll, 2000); return; }
  733. $('#task-dot').className = 'dot ' + t.status;
  734. $('#task-title').textContent = title + (t.status === 'done' ? ' · 完成' : ' · 失败');
  735. if (t.status === 'done' && onDone) onDone();
  736. };
  737. poll();
  738. }
  739. function hideTask(){ $('#task-panel').hidden = true; clearTimeout(pollTimer); }
  740. /* ════ 新建搜索 ════ */
  741. /* 渠道下拉多选(选项同 search_eval:小红书/知乎/公众号/抖音/视频号/YouTube) */
  742. const CHANNELS = [
  743. {key:'xhs', name:'小红书', on:true}, {key:'zhihu', name:'知乎', on:true},
  744. {key:'gzh', name:'公众号'}, {key:'douyin', name:'抖音'},
  745. {key:'sph', name:'视频号'}, {key:'youtube', name:'YouTube'},
  746. ];
  747. $('#s-plat-panel').innerHTML = CHANNELS.map(c => `
  748. <label class="dd-opt ${c.on?'sel':''}">
  749. <input type="checkbox" value="${c.key}" ${c.on?'checked':''}>${c.name}
  750. </label>`).join('');
  751. function selectedPlatforms(){
  752. return [...document.querySelectorAll('#s-plat-panel input:checked')].map(x => x.value);
  753. }
  754. function syncPlatBtn(){
  755. const names = [...document.querySelectorAll('#s-plat-panel input:checked')]
  756. .map(x => CHANNELS.find(c => c.key === x.value).name);
  757. $('#s-plat-btn').innerHTML =
  758. `<span>${names.length ? esc(names.join('、')) : '选择渠道'}</span><span class="dd-arrow">▾</span>`;
  759. }
  760. $('#s-plat-btn').onclick = e => { e.stopPropagation(); $('#s-plat-panel').hidden = !$('#s-plat-panel').hidden; };
  761. $('#s-plat-panel').onclick = e => e.stopPropagation();
  762. $('#s-plat-panel').addEventListener('change', e => {
  763. e.target.closest('.dd-opt').classList.toggle('sel', e.target.checked);
  764. syncPlatBtn();
  765. });
  766. document.addEventListener('click', () => { $('#s-plat-panel').hidden = true; });
  767. syncPlatBtn();
  768. $('#btn-new-search').onclick = () => {
  769. $('#s-mode').value = state.mode === 'process' ? '工序' : '工具'; // 默认跟随当前子模式
  770. $('#search-modal').hidden = false; $('#s-query').focus();
  771. };
  772. $('#search-modal').onclick = e => { if (e.target === $('#search-modal')) $('#search-modal').hidden = true; };
  773. $('#s-go').onclick = async () => {
  774. const query = $('#s-query').value.trim();
  775. if (!query) return alert('请填写 query');
  776. const plats = selectedPlatforms();
  777. if (!plats.length) return alert('请至少选择一个检索渠道');
  778. const body = {query, synonyms: $('#s-syn').value.trim(),
  779. mode_type: $('#s-mode').value,
  780. platforms: plats.join(','),
  781. max_count: parseInt($('#s-max').value) || 10};
  782. try {
  783. const r = await api('/api/run_search', {method:'POST', body: JSON.stringify(body)});
  784. $('#search-modal').hidden = true;
  785. showTask(`搜索 · ${r.query_id} ${query}`, r.task_id, async () => {
  786. await loadQueries(); selectQuery(r.query_id);
  787. });
  788. } catch(e){ alert('搜索启动失败:' + (e.body?.error || e.status)); }
  789. };
  790. /* ════ 工序/工具子模式 ════ */
  791. $('#m-process').onclick = () => setMode('process');
  792. $('#m-tools').onclick = () => setMode('tools');
  793. function setMode(m){
  794. if (state.mode === m) return;
  795. state.mode = m; state.version = null;
  796. $('#m-process').classList.toggle('on', m === 'process');
  797. $('#m-tools').classList.toggle('on', m === 'tools');
  798. renderQueries(); renderPosts();
  799. state.caseId ? loadExtract() : renderExtractEmpty();
  800. }
  801. window.addEventListener('resize', () => chartRefs.forEach(c => c.resize()));
  802. route();
  803. </script>
  804. </body>
  805. </html>