index.html 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  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. --bg:#f4f4f1; --card:#ffffff; --ink:#1c2433; --ink-soft:#5a6577; --ink-faint:#9aa3b0;
  14. --line:#e8e6e0; --line-dark:#d4d1c8;
  15. --navy:#1e3a5f; --navy-deep:#13243a; /* 需求区 / 品牌 */
  16. --blue:#2563eb; --blue-bg:#eef3fe; /* 评分/交互强调 */
  17. --amber:#b45309; --amber-bg:#fff7e8; /* 输入区 */
  18. --teal:#0f6b5c; --teal-bg:#eef8f3; /* 实现区 */
  19. --green:#15803d; --green-bg:#effaf1; /* 输出区 / 采纳 */
  20. --seal:#bb3a22; /* 朱砂印 */
  21. --infer:#fdf0d2; --infer-edge:#d97706;
  22. --shadow:0 1px 2px rgba(20,30,46,.04),0 8px 24px -12px rgba(20,30,46,.12);
  23. --shadow-lg:0 4px 10px rgba(20,30,46,.06),0 24px 60px -20px rgba(20,30,46,.25);
  24. }
  25. *{box-sizing:border-box;margin:0;padding:0}
  26. html{font-size:14px}
  27. body{
  28. font-family:'Noto Sans SC',sans-serif;color:var(--ink);background:var(--bg);
  29. background-image:radial-gradient(900px 360px at 90% -8%,rgba(37,99,235,.045),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(--blue)}
  35. button{font-family:inherit;cursor:pointer}
  36. /* ── 顶部 ── */
  37. header{
  38. display:flex;align-items:center;gap:20px;padding:0 30px;height:60px;
  39. background:linear-gradient(135deg,#13243a,#1c3552);color:#eef2f8;position:sticky;top:0;z-index:40;
  40. box-shadow:0 2px 14px rgba(19,36,58,.28);
  41. }
  42. .logo{display:flex;align-items:center;gap:12px}
  43. .logo .seal{
  44. width:33px;height:33px;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:7px;
  46. box-shadow:inset 0 0 0 2px rgba(255,255,255,.22),0 3px 8px rgba(187,58,34,.35);transform:rotate(-4deg);
  47. }
  48. .logo b{font-family:'Noto Serif SC',serif;font-weight:900;font-size:16px;letter-spacing:1px}
  49. .logo small{display:block;font-size:10px;color:#93a7c0;letter-spacing:3px}
  50. nav{display:flex;gap:6px;margin-left:26px}
  51. nav a{
  52. display:flex;align-items:center;padding:7px 18px;color:#a8b9cd;text-decoration:none;
  53. font-weight:500;letter-spacing:.5px;border-radius:99px;transition:.15s;font-size:13px;
  54. }
  55. nav a:hover{color:#fff;background:rgba(255,255,255,.07)}
  56. nav a.on{color:#fff;background:rgba(255,255,255,.13);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}
  57. header .spacer{flex:1}
  58. header .hint{font-size:11px;color:#6e83a0;letter-spacing:2px}
  59. main{display:none;padding:24px 30px 80px;max-width:1920px;margin:0 auto}
  60. main.on{display:block}
  61. /* 聚类库:内嵌知识检索页,占满视口、无内边距 */
  62. #view-cluster{padding:0;max-width:none}
  63. #view-cluster.on{display:flex}
  64. #cluster-frame{border:0;width:100%;height:calc(100vh - 60px);display:block}
  65. /* ── 通用卡片/标签/按钮 ── */
  66. .card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}
  67. .pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:11px;font-weight:500;
  68. border:1px solid var(--line-dark);background:#f7f6f2;color:var(--ink-soft);white-space:nowrap}
  69. .pill.navy{background:#e8eef6;border-color:#bccbde;color:var(--navy)}
  70. .pill.amber{background:var(--amber-bg);border-color:#ecc88a;color:var(--amber)}
  71. .pill.teal{background:var(--teal-bg);border-color:#a9d6c8;color:var(--teal)}
  72. .pill.red{background:#fbeae5;border-color:#e4ab9c;color:var(--seal)}
  73. .pill.green{background:var(--green-bg);border-color:#a7d9b4;color:var(--green)}
  74. .btn{
  75. border:1px solid var(--line-dark);background:var(--card);color:var(--ink);
  76. padding:6px 14px;border-radius:8px;font-size:13px;font-weight:500;transition:.15s;
  77. }
  78. .btn:hover{border-color:var(--blue);color:var(--blue);box-shadow:0 2px 8px rgba(37,99,235,.12)}
  79. .btn.primary{background:var(--blue);border-color:var(--blue);color:#fff}
  80. .btn.primary:hover{background:#1d4fc4;color:#fff;box-shadow:0 4px 12px rgba(37,99,235,.3)}
  81. .btn.seal{background:var(--seal);border-color:var(--seal);color:#fff}
  82. .btn.seal:hover{background:#a02f1a;color:#fff;box-shadow:0 4px 12px rgba(187,58,34,.3)}
  83. .btn.sm{padding:3px 11px;font-size:12px}
  84. .btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
  85. select,input[type=text],input[type=number]{
  86. font-family:inherit;font-size:13px;padding:6px 10px;border:1px solid var(--line-dark);
  87. border-radius:8px;background:#fff;color:var(--ink);outline:none;transition:.15s;
  88. }
  89. select:focus,input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
  90. .empty{
  91. text-align:center;color:var(--ink-faint);padding:48px 20px;font-size:13px;line-height:2;
  92. }
  93. .empty .glyph{font-family:'Noto Serif SC',serif;font-size:42px;color:var(--line-dark);display:block}
  94. /* ── Dashboard ── */
  95. .dash-section{margin-bottom:14px;display:flex;align-items:baseline;gap:10px}
  96. .dash-section h2{font-size:16px;font-weight:900;letter-spacing:2px}
  97. .dash-section .rule{flex:1;border-top:1px dashed var(--line-dark)}
  98. .dash-section .tag{font-size:10px;letter-spacing:2px;color:var(--ink-faint)}
  99. .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:26px}
  100. .stat{padding:16px 18px 14px;position:relative;overflow:hidden}
  101. .stat::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--navy)}
  102. .stat.a::after{background:var(--amber)} .stat.t::after{background:var(--teal)} .stat.r::after{background:var(--seal)}
  103. .stat .lbl{font-size:11px;letter-spacing:2px;color:var(--ink-soft);margin-bottom:8px}
  104. .stat .val{font-size:30px;font-weight:700;line-height:1}
  105. .stat .sub{font-size:11px;color:var(--ink-faint);margin-top:7px}
  106. .stat .sub.plat-break{margin-top:3px;color:var(--ink-soft);font-weight:600}
  107. .ring-row{display:flex;align-items:center;gap:14px}
  108. .ring{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;flex:none;
  109. background:conic-gradient(var(--teal) calc(var(--p)*1%),#e9e7e0 0)}
  110. .ring>div{width:48px;height:48px;border-radius:50%;background:var(--card);display:grid;place-items:center;
  111. font-size:12px;font-weight:700}
  112. .charts{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}
  113. .chart-card{padding:14px 16px 8px}
  114. .chart-card h3{font-size:13px;font-weight:700;letter-spacing:1px;color:var(--ink-soft);margin-bottom:4px;
  115. display:flex;align-items:baseline;gap:8px}
  116. .chart-card h3 .num{color:var(--seal);font-size:12px}
  117. .chart{width:100%}
  118. .span12{grid-column:span 12}.span6{grid-column:span 6}.span4{grid-column:span 4}
  119. @media(max-width:1100px){.span6,.span4{grid-column:span 12}}
  120. /* ── Dataset 三栏 ── */
  121. .ds-top{display:flex;align-items:center;gap:12px;margin-bottom:16px}
  122. .mode-switch{display:flex;border:1px solid var(--line-dark);border-radius:10px;overflow:hidden;background:#fff}
  123. .mode-switch button{border:0;background:transparent;padding:7px 24px;font-size:13px;font-weight:700;color:var(--ink-soft);transition:.15s}
  124. .mode-switch button.on{background:var(--navy);color:#fff}
  125. .ds-grid{display:grid;grid-template-columns:235px 350px minmax(0,1fr);gap:16px;align-items:start}
  126. @media(max-width:1280px){.ds-grid{grid-template-columns:200px 320px minmax(0,1fr)}}
  127. .col-head{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;
  128. border-bottom:1px solid var(--line);font-weight:700;font-size:13px;letter-spacing:1px}
  129. .col-head .n{font-size:11px;color:var(--ink-faint);font-weight:500}
  130. .qlist{max-height:76vh;overflow:auto}
  131. .qitem{padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer;transition:.12s}
  132. .qitem:hover{background:#f8f8f4}
  133. .qitem.on{background:var(--blue-bg);box-shadow:inset 3px 0 0 var(--blue)}
  134. .qitem .qid{font-size:10px;letter-spacing:1px;color:var(--ink-faint);display:flex;gap:6px;align-items:center}
  135. .qitem .qt{font-weight:500;margin:4px 0 7px;line-height:1.45}
  136. .qitem .qm{font-size:11px;color:var(--ink-soft);display:flex;gap:10px;flex-wrap:wrap;align-items:center}
  137. .qitem .qm b.hit{color:var(--blue);font-size:13px}
  138. /* 渠道 tab */
  139. .plat-tabs{display:flex;gap:4px;padding:8px 10px;border-bottom:1px solid var(--line);
  140. overflow-x:auto;background:#fbfbf8;border-radius:0}
  141. .plat-tabs button{
  142. border:1px solid transparent;background:transparent;color:var(--ink-soft);
  143. padding:3px 11px;border-radius:99px;font-size:12px;font-weight:500;white-space:nowrap;transition:.15s;
  144. }
  145. .plat-tabs button:hover{background:#efeee8;color:var(--ink)}
  146. .plat-tabs button.on{background:var(--navy);color:#fff;font-weight:700}
  147. .plat-tabs button .c{font-size:10px;opacity:.75;margin-left:3px}
  148. /* 已解构筛选按钮:靠右,激活态用 teal 呼应「已解构」 */
  149. .plat-tabs .done-filter{margin-left:auto;flex:none;border:1px solid var(--line);
  150. display:inline-flex;align-items:center;gap:4px;color:var(--ink-soft)}
  151. .plat-tabs .done-filter:hover{background:#efeee8;color:var(--ink)}
  152. .plat-tabs .done-filter.on{background:var(--teal-bg,#e3f1ec);border-color:#a9d6c8;
  153. color:var(--teal);font-weight:700}
  154. .plat-tabs .done-filter .dot{font-size:8px}
  155. .plist{max-height:70vh;overflow:auto}
  156. .post{padding:12px 13px;border-bottom:1px solid var(--line);cursor:pointer;display:flex;gap:10px;transition:.12s;align-items:flex-start}
  157. .post:hover{background:#f8f8f4}
  158. .post.on{background:var(--blue-bg);box-shadow:inset 3px 0 0 var(--blue)}
  159. .post input{margin-top:4px;accent-color:var(--blue)}
  160. .post .pt{font-weight:500;line-height:1.45;font-size:13px}
  161. .post .pm{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px;align-items:center}
  162. .plat{display:inline-block;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;color:#fff}
  163. .plat.xhs{background:#d63a2f}.plat.gzh{background:#2e9939}.plat.zhihu{background:#1772f6}
  164. .plat.douyin{background:#170b1a}.plat.sph{background:#fa6d20}.plat.youtube{background:#c00}
  165. .plat.x{background:#15202b}.plat.other{background:#777}
  166. .plat-logo{display:inline-flex;line-height:0;flex:none}
  167. .plat-logo svg{display:block}
  168. .done-dot{font-size:10px;color:var(--teal);font-weight:700}
  169. /* 评分徽章:大、亮、可点 */
  170. .score-badge{
  171. flex:none;position:relative;font-family:'IBM Plex Mono',monospace;font-weight:700;font-size:16.5px;
  172. line-height:1;padding:8px 10px;border-radius:9px;cursor:pointer;transition:.15s;align-self:center;
  173. border:1px solid transparent;
  174. }
  175. .score-badge.s9{color:#fff;background:linear-gradient(135deg,#16a34a,#15803d);box-shadow:0 3px 10px rgba(22,163,74,.35)}
  176. .score-badge.s8{color:#fff;background:linear-gradient(135deg,#3b82f6,#2563eb);box-shadow:0 3px 10px rgba(37,99,235,.35)}
  177. .score-badge.s6{color:#fff;background:linear-gradient(135deg,#f59e0b,#d97706);box-shadow:0 3px 10px rgba(217,119,6,.32)}
  178. .score-badge.s0{color:var(--ink-soft);background:#eceae4;border-color:var(--line-dark)}
  179. .score-badge:hover{transform:translateY(-2px) scale(1.06)}
  180. .score-badge::after{
  181. content:'查看详情';position:absolute;left:50%;bottom:calc(100% + 6px);transform:translateX(-50%) scale(.92);
  182. background:var(--ink);color:#fff;font-family:'Noto Sans SC',sans-serif;font-size:10px;font-weight:500;
  183. padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none;transition:.15s;z-index:5;
  184. }
  185. .score-badge:hover::after{opacity:1;transform:translateX(-50%) scale(1)}
  186. .xp{min-height:60vh}
  187. .xp-head{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
  188. .xp-head .st{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
  189. .xp-head .st em{color:var(--seal);font-style:normal}
  190. .xp-head .spacer{flex:1}
  191. .xp-body{padding:16px}
  192. /* 工序卡 */
  193. .proc{border:1px solid var(--line);border-radius:10px;margin-bottom:22px;overflow:hidden;background:#fff;box-shadow:var(--shadow)}
  194. .proc-head{padding:14px 16px;border-bottom:1px solid var(--line);background:#fafaf6}
  195. .proc-head .nm{font-family:'Noto Serif SC',serif;font-weight:900;font-size:15px}
  196. .proc-head .nm .pid{color:var(--seal);margin-right:6px;font-size:13px}
  197. .proc-head .pp{font-size:12px;color:var(--ink-soft);margin-top:5px;line-height:1.6}
  198. .proc-head .meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
  199. .decl{display:grid;grid-template-columns:1fr 1fr;gap:0;border-bottom:1px solid var(--line)}
  200. .decl>div{padding:10px 16px}
  201. .decl>div+div{border-left:1px solid var(--line)}
  202. .decl .dl{font-size:10px;letter-spacing:2px;color:var(--ink-faint);margin-bottom:6px}
  203. .decl .di{font-size:12px;line-height:1.7}
  204. .decl .di b{font-weight:700}
  205. /* 步骤表:固定列宽撑开,宿主容器 overflow-x 滚动 */
  206. .steps{width:100%;min-width:1760px;table-layout:fixed;border-collapse:collapse;font-size:12px}
  207. .steps th{padding:6px 8px;font-size:11px;font-weight:700;letter-spacing:1px;color:#fff;text-align:left}
  208. .steps thead tr:first-child th{text-align:center;font-size:12px;letter-spacing:4px;padding:7px 4px}
  209. .steps .h-req{background:var(--navy)} .steps .h-req2{background:#33547a}
  210. .steps .h-in{background:var(--amber)} .steps .h-in2{background:#cd7522}
  211. .steps .h-im{background:var(--teal)} .steps .h-im2{background:#2d8273}
  212. .steps .h-out{background:var(--green)} .steps .h-out2{background:#2e6b45}
  213. .steps td{padding:8px 9px;border:1px solid var(--line);vertical-align:top;line-height:1.6}
  214. .steps tbody tr:nth-child(odd) td{background:#fdfdf9}
  215. .steps td.c-in{background:var(--amber-bg)!important}
  216. .steps td.c-out{background:var(--green-bg)!important}
  217. .steps .sid{font-family:'IBM Plex Mono',monospace;font-weight:700;color:var(--navy);white-space:nowrap}
  218. .steps .vtxt{color:var(--ink-soft);font-size:11.5px;word-break:break-all}
  219. .inf{background:var(--infer)!important;position:relative;outline:1px dashed var(--infer-edge);outline-offset:-2px}
  220. .inf .ib{position:absolute;top:-1px;right:-1px;background:var(--infer-edge);color:#fff;font-size:9px;
  221. padding:0 4px;border-radius:0 0 0 4px;font-weight:700}
  222. .anchor{font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:var(--ink-faint);word-break:break-all}
  223. /* 工具表格(移植自 fixed_query_eval:案例逐行 rowspan + 限高展开) */
  224. .mw-ttwrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)}
  225. .mw-tt{border-collapse:separate;border-spacing:0;width:100%;min-width:1180px;background:#fff;font-size:12.5px}
  226. .mw-tt thead th{
  227. position:sticky;top:0;z-index:2;text-align:left;white-space:nowrap;
  228. background:linear-gradient(180deg,#2aa79b,#1c8076);color:#fff;font-weight:700;
  229. padding:10px 12px;letter-spacing:.3px;border-right:1px solid rgba(255,255,255,.18);
  230. }
  231. .mw-tt thead th:last-child{border-right:none}
  232. .mw-tt thead tr:first-child th{top:0}
  233. .mw-tt thead tr:nth-child(2) th{top:38px}
  234. .mw-tt .th-group{text-align:center}
  235. .mw-tt .th-sub{background:linear-gradient(180deg,#36bdb0,#23897f);font-weight:600}
  236. .mw-tt tbody td{padding:9px 12px;vertical-align:top;line-height:1.6;
  237. border-bottom:1px solid #f0eee8;border-right:1px solid #f5f3ee;color:#3a3a3a}
  238. .mw-tt tbody td:last-child{border-right:none}
  239. .mw-tt td.col-case{background:#fafdfc}
  240. .mw-tt tbody tr.tr-b td{background:#fbfaf6}
  241. .mw-tt tbody tr.tr-b td.col-case{background:#f6fbfa}
  242. .mw-tt tbody td.col-tool{font-weight:700;color:#176d64;white-space:nowrap;
  243. border-left:3px solid #2aa79b;background:#f3faf8}
  244. .mw-tt tbody tr:hover td.col-tool{background:#e3f4f0}
  245. .mw-tt ul{margin:0;padding-left:17px}
  246. .mw-tt ul li{margin:3px 0}
  247. .mw-tt ul li::marker{color:#2aa79b}
  248. .mw-tt .layer-badge{display:inline-block;font-weight:700;font-size:11px;padding:2px 10px;border-radius:20px;white-space:nowrap}
  249. .mw-tt .layer-badge.make{color:#0e7490;background:#d6f0ee}
  250. .mw-tt .layer-badge.create{color:#b8731a;background:#fef0db}
  251. .mw-tt .dash{color:#c9c2b6}
  252. .mw-tt .tcell{position:relative;max-height:7.8em;overflow:hidden;transition:max-height .15s}
  253. .mw-tt .tcell.clamped{cursor:zoom-in}
  254. .mw-tt .tcell.clamped::after{content:'▾ 展开';position:absolute;left:0;right:0;bottom:0;
  255. height:2.6em;display:flex;align-items:flex-end;justify-content:center;padding-bottom:2px;
  256. font-size:11px;font-weight:700;color:#176d64;
  257. background:linear-gradient(rgba(255,255,255,0),#fff 72%);pointer-events:none}
  258. .mw-tt tbody tr.tr-b .tcell.clamped::after{background:linear-gradient(rgba(251,250,246,0),#fbfaf6 72%)}
  259. .mw-tt td.col-case .tcell.clamped::after{background:linear-gradient(rgba(250,253,252,0),#fafdfc 72%)}
  260. .mw-tt tbody tr.tr-b td.col-case .tcell.clamped::after{background:linear-gradient(rgba(246,251,250,0),#f6fbfa 72%)}
  261. .mw-tt .tcell.open{max-height:none;cursor:zoom-out}
  262. .mw-tt .tcell.open::after{content:'';height:0}
  263. /* ── 帖子详情弹层 ── */
  264. dialog#post-dialog{
  265. width:min(1100px,94vw);max-height:calc(100vh - 40px);border:none;border-radius:14px;
  266. padding:0;box-shadow:var(--shadow-lg);overflow:hidden;
  267. margin:auto; /* 全局 reset 清掉了 dialog 默认 margin:auto,这里补回才能居中 */
  268. }
  269. dialog#post-dialog::backdrop{background:rgba(19,30,46,.5);backdrop-filter:blur(2px)}
  270. .pd-wrap{display:flex;flex-direction:column;max-height:calc(100vh - 40px)}
  271. .pd-head{
  272. display:flex;justify-content:space-between;gap:14px;align-items:flex-start;
  273. padding:16px 20px;border-bottom:1px solid var(--line);background:#fff;flex:none;
  274. }
  275. .pd-head h3{font-family:'Noto Serif SC',serif;font-size:18px;line-height:1.35;margin-top:6px}
  276. .pd-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--ink-soft)}
  277. .pd-close{border:none;background:#f1f0ea;width:30px;height:30px;border-radius:8px;font-size:14px;
  278. color:var(--ink-soft);flex:none;transition:.15s}
  279. .pd-close:hover{background:var(--seal);color:#fff}
  280. .pd-body{display:grid;grid-template-columns:1.05fr .95fr;gap:20px;padding:18px 20px;overflow:auto}
  281. @media(max-width:920px){.pd-body{grid-template-columns:1fr}}
  282. .pd-body>section,.pd-body>aside{min-width:0}
  283. .pd-sec-title{font-weight:800;margin:16px 0 8px;font-size:13px}
  284. .pd-sec-title:first-child{margin-top:0}
  285. .pd-verdict{background:#eaf6fb;border-left:4px solid #2c9ec7;padding:10px 12px;color:#22566b;
  286. border-radius:6px;font-size:13px;line-height:1.6}
  287. .pd-raw{white-space:pre-wrap;background:#faf9f5;border:1px solid var(--line);border-radius:10px;
  288. padding:12px;max-height:320px;overflow:auto;color:#3d3f44;font-size:13px;line-height:1.7}
  289. .pd-images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
  290. .pd-images img{width:100%;max-height:250px;object-fit:contain;border:1px solid var(--line);
  291. border-radius:10px;background:#f3f2ed;cursor:zoom-in}
  292. .pd-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:12px}
  293. .pd-score-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
  294. .pd-score-head .t{font-weight:800;font-size:14px}
  295. .pd-overall{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:700;color:var(--ink-soft);
  296. background:#faf9f5;border:1px solid var(--line);padding:4px 12px;border-radius:8px}
  297. .pd-overall b{font-size:20px;color:var(--blue);font-weight:900;line-height:1}
  298. .sc-card{border:1px solid var(--line);border-radius:10px;background:#fff;overflow:hidden;margin-bottom:12px}
  299. .sc-card-head{display:flex;justify-content:space-between;align-items:center;gap:10px;
  300. padding:10px 12px;background:#faf9f5;border-bottom:1px solid var(--line);font-size:13px;font-weight:800}
  301. .sc-card-head .badge{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:5px;
  302. background:var(--navy);color:#fff;font-size:10px;font-weight:700;margin-right:7px}
  303. .sc-card-head .avg{font-size:12px;color:var(--ink-faint);font-weight:600}
  304. .sc-card-head .avg b{font-size:17px;color:var(--blue);font-weight:900}
  305. .sc-card-body{display:grid;gap:8px;padding:11px 12px}
  306. /* 维度分组小标题:参考 mode_procedure —— 一级虚线大写,二级点线常规 */
  307. .sc-sub{font-size:11px;font-weight:800;letter-spacing:.8px;color:var(--ink-faint);text-transform:uppercase;
  308. margin:14px 0 8px;padding-bottom:5px;border-bottom:1px dashed var(--line-dark)}
  309. .sc-card-body>.sc-sub:first-child{margin-top:2px}
  310. .sc-sub.lv2{font-size:11px;font-weight:700;letter-spacing:.3px;text-transform:none;color:var(--ink-soft);
  311. margin:11px 0 6px;padding-bottom:4px;border-bottom:1px dotted var(--line)}
  312. .sc-row{display:grid;grid-template-columns:118px 1fr 30px 16px;gap:9px;align-items:center;font-size:12.5px}
  313. .sc-row .meter{height:8px;border-radius:99px;background:#edebe4;overflow:hidden}
  314. .sc-row .meter span{display:block;height:100%;border-radius:99px;
  315. background:linear-gradient(90deg,#60a5fa,#2563eb)}
  316. .sc-row b{font-weight:700;text-align:right}
  317. .sc-row .info{color:var(--ink-faint);cursor:pointer;font-size:12px;opacity:.75;user-select:none}
  318. .sc-row .info:hover{color:var(--blue);opacity:1}
  319. /* 判定理由弹层:点击 ⓘ 时浮现,挂在 dialog 顶层 */
  320. .score-pop{position:fixed;z-index:90;width:288px;max-width:calc(100vw - 24px);background:var(--card);
  321. border:1px solid var(--line-dark);border-radius:10px;box-shadow:var(--shadow-lg);padding:12px 14px;
  322. font-size:12.5px;line-height:1.65;color:var(--ink-soft);animation:popIn .12s ease}
  323. .score-pop .sp-head{display:flex;align-items:center;gap:8px;margin-bottom:8px;
  324. padding-bottom:8px;border-bottom:1px solid var(--line)}
  325. .score-pop .sp-tag{font-size:9px;letter-spacing:1.5px;color:var(--ink-faint);font-weight:700;
  326. border:1px solid var(--line-dark);border-radius:5px;padding:2px 6px;white-space:nowrap}
  327. .score-pop .sp-name{font-weight:800;font-size:13px;color:var(--ink)}
  328. .score-pop .sp-score{margin-left:auto;font-size:16px;font-weight:900;color:var(--blue)}
  329. .score-pop .sp-body{white-space:pre-wrap}
  330. @keyframes popIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
  331. /* 任务面板 */
  332. #task-panel{
  333. position:fixed;right:22px;bottom:22px;width:440px;max-height:55vh;z-index:60;
  334. display:flex;flex-direction:column;border:1px solid var(--line-dark);border-radius:12px;
  335. background:var(--card);box-shadow:var(--shadow-lg);overflow:hidden;
  336. }
  337. #task-panel header{all:unset;display:flex;align-items:center;gap:9px;padding:11px 14px;
  338. background:var(--navy-deep);color:#fff;font-size:13px;font-weight:700}
  339. #task-panel header .dot{width:8px;height:8px;border-radius:50%;background:#f5b942;animation:blink 1s infinite}
  340. #task-panel header .dot.done{background:#3fb27f;animation:none}
  341. #task-panel header .dot.failed{background:#e0492f;animation:none}
  342. @keyframes blink{50%{opacity:.3}}
  343. #task-panel header button{margin-left:auto;background:none;border:0;color:#9eb0c5;font-size:15px}
  344. #task-panel header button:hover{color:#fff}
  345. #task-log{font-family:'IBM Plex Mono',monospace;font-size:11px;line-height:1.65;color:var(--ink-soft);
  346. white-space:pre-wrap;overflow:auto;padding:12px 14px;background:#fafaf6;flex:1}
  347. /* 弹窗(新建搜索) */
  348. .modal-bg{position:fixed;inset:0;background:rgba(19,30,46,.5);z-index:70;display:grid;place-items:center;backdrop-filter:blur(2px)}
  349. /* 不能 overflow:hidden,否则渠道下拉面板会被裁掉 */
  350. .modal{width:460px;background:var(--card);border-radius:14px;box-shadow:var(--shadow-lg)}
  351. .modal h2{padding:14px 18px;background:var(--navy-deep);color:#fff;font-size:15px;letter-spacing:2px;
  352. border-radius:14px 14px 0 0}
  353. .modal .mb{padding:18px;display:grid;gap:12px}
  354. .modal label{font-size:12px;color:var(--ink-soft);display:grid;gap:5px}
  355. .modal .mf{display:flex;justify-content:flex-end;gap:10px;padding:0 18px 18px}
  356. /* 渠道下拉多选 */
  357. .dd{position:relative}
  358. .dd-btn{width:100%;display:flex;justify-content:space-between;align-items:center;gap:8px;
  359. text-align:left;padding:7px 10px;border:1px solid var(--line-dark);border-radius:8px;
  360. background:#fff;font-size:13px;color:var(--ink)}
  361. .dd-btn .dd-arrow{color:var(--ink-faint);flex:none}
  362. .dd-btn:focus{border-color:var(--blue)}
  363. .dd-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:10;
  364. background:#fff;border:1px solid var(--line-dark);border-radius:10px;
  365. box-shadow:var(--shadow-lg);padding:6px;max-height:220px;overflow:auto}
  366. .modal label.dd-opt{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px 9px;
  367. border-radius:6px;font-size:13px;color:var(--ink);cursor:pointer}
  368. .dd-opt:hover{background:#f4f3ee}
  369. .dd-opt input{accent-color:var(--blue);margin:0;cursor:pointer}
  370. .dd-opt.sel{background:var(--blue-bg);font-weight:500}
  371. /* 聚类库占位 */
  372. .cluster-empty{display:grid;place-items:center;min-height:60vh}
  373. .cluster-empty .inner{text-align:center;color:var(--ink-faint)}
  374. .cluster-empty .stamp{
  375. width:120px;height:120px;margin:0 auto 18px;border:3px solid var(--line-dark);border-radius:50%;
  376. display:grid;place-items:center;font-family:'Noto Serif SC',serif;font-size:30px;font-weight:900;
  377. color:var(--line-dark);transform:rotate(-8deg);letter-spacing:4px;
  378. }
  379. [hidden]{display:none!important}
  380. </style>
  381. </head>
  382. <body>
  383. <header>
  384. <div class="logo">
  385. <div class="seal">解</div>
  386. <div><b>mode_workflow</b><small>解构工作台 · MODE WORKBENCH</small></div>
  387. </div>
  388. <nav id="nav">
  389. <a href="#dashboard" data-tab="dashboard">Dashboard</a>
  390. <a href="#dataset" data-tab="dataset">Dataset</a>
  391. <a href="#cluster" data-tab="cluster">聚类库</a>
  392. </nav>
  393. <div class="spacer"></div>
  394. <div class="hint">SEARCH · EXTRACT · ARCHIVE</div>
  395. </header>
  396. <main id="view-dashboard"></main>
  397. <main id="view-dataset">
  398. <div class="ds-top">
  399. <div class="mode-switch">
  400. <button id="m-process" class="on">工序</button>
  401. <button id="m-tools">工具</button>
  402. </div>
  403. <div style="flex:1"></div>
  404. <button class="btn seal" id="btn-new-search">+ 新建搜索</button>
  405. </div>
  406. <div class="ds-grid">
  407. <div class="card">
  408. <div class="col-head">QUERY <span class="n" id="q-count"></span></div>
  409. <div class="qlist" id="query-list"><div class="empty">暂无 query</div></div>
  410. </div>
  411. <div class="card">
  412. <div class="col-head">帖子
  413. <span style="display:flex;gap:8px;align-items:center">
  414. <span class="n" id="p-count"></span>
  415. <button class="btn sm" id="btn-batch" disabled hidden>批量解构</button>
  416. </span>
  417. </div>
  418. <div class="plat-tabs" id="plat-tabs" hidden></div>
  419. <div class="plist" id="post-list"><div class="empty"><span class="glyph">←</span>先选择左侧 query</div></div>
  420. </div>
  421. <div class="card xp">
  422. <div class="xp-head" id="xp-head"><span class="st">解构结果</span></div>
  423. <div class="xp-body" id="xp-body"><div class="empty"><span class="glyph">←</span>选择一个帖子查看解构结果</div></div>
  424. </div>
  425. </div>
  426. </main>
  427. <main id="view-cluster">
  428. <iframe id="cluster-frame" title="知识检索" loading="lazy"></iframe>
  429. </main>
  430. <div id="task-panel" hidden>
  431. <header><span class="dot" id="task-dot"></span><span id="task-title">任务</span>
  432. <button onclick="hideTask()">✕</button></header>
  433. <div id="task-log"></div>
  434. </div>
  435. <!-- 帖子详情弹层(评分详情参考 fixed_query_eval) -->
  436. <dialog id="post-dialog">
  437. <div class="pd-wrap">
  438. <div class="pd-head">
  439. <div style="min-width:0">
  440. <div class="pd-meta" id="pd-meta"></div>
  441. <h3 id="pd-title"></h3>
  442. </div>
  443. <button class="pd-close" onclick="document.getElementById('post-dialog').close()">✕</button>
  444. </div>
  445. <div class="pd-body">
  446. <section>
  447. <div id="pd-verdict"></div>
  448. <div class="pd-sec-title">抓取文本节选</div>
  449. <div class="pd-raw" id="pd-text"></div>
  450. <div class="pd-sec-title">图片预览</div>
  451. <div class="pd-images" id="pd-images"></div>
  452. <div class="pd-tags" id="pd-tags"></div>
  453. </section>
  454. <aside>
  455. <div class="pd-score-head">
  456. <span class="t">评分详情</span>
  457. <span class="pd-overall">综合评分 <b id="pd-overall">—</b></span>
  458. </div>
  459. <div id="pd-scores"></div>
  460. </aside>
  461. </div>
  462. </div>
  463. </dialog>
  464. <div class="modal-bg" id="search-modal" hidden>
  465. <div class="modal">
  466. <h2>新建搜索</h2>
  467. <div class="mb">
  468. <label>Query(评估锚点,必填)<input type="text" id="s-query" placeholder="如:AI 人像 图片 生成 怎么做"></label>
  469. <label>解构方向<select id="s-mode"><option value="工序">工序</option><option value="工具">工具</option></select></label>
  470. <label>同义措辞(可选,逗号分隔)<input type="text" id="s-syn" placeholder="如:AI 人像生成 教程,AI 写真 怎么做"></label>
  471. <label>检索渠道(下拉多选)
  472. <div class="dd" id="s-plat-dd">
  473. <button type="button" class="dd-btn" id="s-plat-btn">选择渠道 ▾</button>
  474. <div class="dd-panel" id="s-plat-panel" hidden></div>
  475. </div>
  476. </label>
  477. <label>每措辞每渠道上限<input type="number" id="s-max" value="10" min="1" max="50"></label>
  478. </div>
  479. <div class="mf">
  480. <button class="btn" onclick="document.getElementById('search-modal').hidden=true">取消</button>
  481. <button class="btn primary" id="s-go">开始搜索</button>
  482. </div>
  483. </div>
  484. </div>
  485. <script>
  486. /* ════ 基础 ════ */
  487. const $ = s => document.querySelector(s);
  488. const api = (p, opt) => fetch(p, opt).then(async r => {
  489. if (!r.ok) throw Object.assign(new Error('api'), {status: r.status, body: await r.json().catch(() => ({}))});
  490. return r.json();
  491. });
  492. const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  493. const state = {tab:'dashboard', mode:'process', queryId:null, caseId:null, post:null,
  494. version:null, platFilter:'all', doneFilter:true, selected:new Set(), queries:[], posts:[]};
  495. const PLAT_CLS = p => ({xhs:'xhs',gzh:'gzh',zhihu:'zhihu',douyin:'douyin',sph:'sph',youtube:'youtube',x:'x'})[p] || 'other';
  496. const PLAT_NAME = p => ({xhs:'小红书',gzh:'公众号',zhihu:'知乎',douyin:'抖音',sph:'视频号',youtube:'YouTube',x:'X'})[p] || p || '?';
  497. /* 渠道 logo 徽标(品牌色圆角方块,hover 显示渠道名) */
  498. const PLAT_LOGO = (p, size = 18) => {
  499. const glyph = (bg, ch) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24">
  500. <rect width="24" height="24" rx="5.5" fill="${bg}"/>
  501. <text x="12" y="16.6" font-size="12" font-weight="700" fill="#fff" text-anchor="middle"
  502. font-family="'Noto Sans SC',sans-serif">${ch}</text></svg>`;
  503. const svgs = {
  504. xhs: glyph('#ff2442', '红'),
  505. zhihu: glyph('#0084ff', '知'),
  506. gzh: glyph('#07c160', '公'),
  507. douyin: glyph('#161823', '抖'),
  508. sph: glyph('#fa6d20', '视'),
  509. x: glyph('#15202b', 'X'),
  510. youtube: `<svg width="${size}" height="${size}" viewBox="0 0 24 24">
  511. <rect width="24" height="24" rx="5.5" fill="#f00"/>
  512. <polygon points="9.8,7.8 17,12 9.8,16.2" fill="#fff"/></svg>`,
  513. };
  514. return svgs[p]
  515. ? `<span class="plat-logo" title="${PLAT_NAME(p)}">${svgs[p]}</span>`
  516. : `<span class="plat other">${esc(PLAT_NAME(p))}</span>`;
  517. };
  518. const MODELS_PROC = ['anthropic/claude-sonnet-4-6','google/gemini-3.1-flash-lite'];
  519. const MODELS_TOOL = ['google/gemini-3.1-flash-lite','anthropic/claude-sonnet-4-6'];
  520. const scoreCls = v => v == null ? 's0' : v >= 9 ? 's9' : v >= 8 ? 's8' : v >= 6 ? 's6' : 's0';
  521. /* ════ 路由 ════ */
  522. function route(){
  523. state.tab = (location.hash || '#dashboard').slice(1);
  524. if (!['dashboard','dataset','cluster'].includes(state.tab)) state.tab = 'dashboard';
  525. document.querySelectorAll('nav a').forEach(a => a.classList.toggle('on', a.dataset.tab === state.tab));
  526. document.querySelectorAll('main').forEach(m => m.classList.toggle('on', m.id === 'view-' + state.tab));
  527. if (state.tab === 'dashboard') renderDashboard();
  528. if (state.tab === 'dataset' && !state.queries.length) loadQueries();
  529. if (state.tab === 'cluster') { // 首次打开聚类库才加载内嵌检索页
  530. const f = $('#cluster-frame');
  531. if (f && !f.src) f.src = '/search.html';
  532. }
  533. }
  534. window.addEventListener('hashchange', route);
  535. /* ════ Dashboard ════ */
  536. let chartRefs = [];
  537. async function renderDashboard(){
  538. const v = $('#view-dashboard');
  539. let d;
  540. try { d = await api('/api/dashboard'); }
  541. catch(e){ v.innerHTML = `<div class="card empty"><span class="glyph">!</span>Dashboard 加载失败:${esc(e.body?.error || e.status)}</div>`; return; }
  542. const r = d.result, p = d.process_data;
  543. const pct = (a,b) => b ? Math.round(a/b*100) : 0;
  544. const platBreak = arr => (arr || []).map(([k,n]) => `${PLAT_NAME(k)} ${n}`).join(' · ') || '—';
  545. v.innerHTML = `
  546. <div class="dash-section"><h2>结果数据</h2><div class="rule"></div><span class="tag">RESULTS</span></div>
  547. <div class="cards">
  548. <div class="card stat"><div class="lbl">采集帖子数量</div><div class="val num">${r.post_count}</div><div class="sub">search_data 全量</div><div class="sub plat-break">${platBreak(r.collected_by_platform)}</div></div>
  549. <div class="card stat t"><div class="lbl">解构帖子数量</div><div class="val num">${r.extracted_post_count}</div><div class="sub">工序 ∪ 工具 已解构</div><div class="sub plat-break">${platBreak(r.extracted_by_platform)}</div></div>
  550. <div class="card stat a"><div class="lbl">工具数量</div><div class="val num">${r.tool_count}</div><div class="sub">mode_tools 去重工具名</div></div>
  551. <div class="card stat r"><div class="lbl">内容树覆盖节点</div><div class="val num">0<span style="font-size:15px;color:var(--ink-faint)"> / ${r.matrix_valid}</span></div>
  552. <div class="sub">0% · 动作×类型有效节点</div></div>
  553. <div class="card stat"><div class="lbl">实质数量</div><div class="val num">${r.substance_count}</div><div class="sub">去重实质条目数</div></div>
  554. <div class="card stat"><div class="lbl">形式数量</div><div class="val num">${r.form_count}</div><div class="sub">去重形式条目数</div></div>
  555. </div>
  556. <div class="dash-section"><h2>过程数据</h2><div class="rule"></div><span class="tag">PROCESS</span></div>
  557. <div class="cards">
  558. <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>
  559. <div class="card stat a"><div class="lbl">解构耗时</div><div class="val num">${(p.total_duration/3600).toFixed(1)}<span style="font-size:14px">h</span></div><div class="sub">平均 ${p.avg_duration}s / 次</div></div>
  560. <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>
  561. <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>
  562. <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>
  563. <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>
  564. </div>
  565. <div class="charts">
  566. <div class="card chart-card span6"><h3>工序提及工具 TOP10</h3><div class="chart" id="ch-via" style="height:300px"></div></div>
  567. <div class="card chart-card span6"><h3>解构成本趋势</h3><div class="chart" id="ch-cost" style="height:300px"></div></div>
  568. <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>
  569. <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>
  570. <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>
  571. </div>`;
  572. chartRefs.forEach(c => c.dispose()); chartRefs = [];
  573. const mk = (id, opt) => { const c = echarts.init($(id)); c.setOption(opt); chartRefs.push(c); return c; };
  574. const ink = '#1c2433', faint = '#9aa3b0', grid = {left:8,right:16,top:8,bottom:8,containLabel:true};
  575. /* 热力图 */
  576. mk('#ch-matrix', {
  577. tooltip:{formatter: pr => `${r.matrix_actions[pr.value[1]]} × ${r.matrix_types[pr.value[0]]}`},
  578. grid:{left:8,right:16,top:8,bottom:60,containLabel:true},
  579. xAxis:{type:'category',data:r.matrix_types,axisLabel:{rotate:60,fontSize:9,color:faint},splitArea:{show:true,areaStyle:{color:['#fcfcf9','#f5f4ef']}}},
  580. yAxis:{type:'category',data:r.matrix_actions,axisLabel:{fontSize:10,color:ink},splitArea:{show:true}},
  581. visualMap:{show:false,min:0,max:1,inRange:{color:['#f0efe8','#bb3a22']}},
  582. series:[{type:'heatmap',data:r.matrix_cells.map(([a,t])=>[t,a,1]),itemStyle:{borderColor:'#fff',borderWidth:1}}]
  583. });
  584. const bar = (data, color) => ({
  585. tooltip:{}, grid,
  586. xAxis:{type:'value',axisLabel:{color:faint}},
  587. yAxis:{type:'category',data:data.map(x=>x[0]).reverse(),axisLabel:{color:ink,fontSize:11,width:130,overflow:'truncate'}},
  588. series:[{type:'bar',data:data.map(x=>x[1]).reverse(),itemStyle:{color,borderRadius:[0,3,3,0]},barMaxWidth:16,
  589. label:{show:true,position:'right',color:faint,fontSize:10}}]
  590. });
  591. mk('#ch-via', r.via_top10.length ? bar(r.via_top10,'#1e3a5f') : emptyOpt());
  592. mk('#ch-sub', r.substance_top.length ? bar(r.substance_top,'#b45309') : emptyOpt());
  593. mk('#ch-form', r.form_top.length ? bar(r.form_top,'#0f6b5c') : emptyOpt());
  594. mk('#ch-cost', p.cost_trend.length ? {
  595. tooltip:{trigger:'axis'}, grid,
  596. xAxis:{type:'category',data:p.cost_trend.map(x=>x.date),axisLabel:{color:faint}},
  597. yAxis:{type:'value',axisLabel:{color:faint,formatter:'${value}'}},
  598. series:[{type:'line',data:p.cost_trend.map(x=>x.cost),smooth:true,symbolSize:7,
  599. lineStyle:{color:'#bb3a22',width:2.5},itemStyle:{color:'#bb3a22'},
  600. areaStyle:{color:'rgba(187,58,34,.08)'}}]
  601. } : emptyOpt());
  602. function emptyOpt(){ return {title:{text:'暂无数据',left:'center',top:'middle',textStyle:{color:faint,fontSize:12,fontWeight:400}},xAxis:{show:false},yAxis:{show:false}}; }
  603. }
  604. /* ════ Dataset:query 列表 ════ */
  605. async function loadQueries(){
  606. /* 工序/工具各自一张搜索表,query 列表按当前子模式拉取 */
  607. try { state.queries = await api('/api/queries?mode=' + state.mode); } catch(e){ state.queries = []; }
  608. renderQueries();
  609. /* 进入/切换子模式时默认选中第一个 query(进而联动第一帖与解构结果) */
  610. if (!state.queryId && state.queries.length) await selectQuery(state.queries[0].query_id);
  611. }
  612. function renderQueries(){
  613. const list = state.queries;
  614. $('#q-count').textContent = list.length ? list.length + ' 组' : '';
  615. if (!list.length){
  616. $('#query-list').innerHTML = '<div class="empty">暂无 query<br>点右上「新建搜索」开始</div>'; return;
  617. }
  618. $('#query-list').innerHTML = list.map(q => {
  619. const done = state.mode === 'process' ? q.process_done : q.tools_done;
  620. return `<div class="qitem ${q.query_id===state.queryId?'on':''}" onclick="selectQuery('${q.query_id}')">
  621. <div class="qid">${q.query_id}</div>
  622. <div class="qt">${esc(q.query_text || '(未命名)')}</div>
  623. <div class="qm">
  624. <span>采纳/命中 <b class="num hit">${q.hit_count ?? 0}</b></span>
  625. <span>总帖数 <b class="num">${q.post_count}</b></span>
  626. <span>已解构 <b class="num">${done}</b></span>
  627. </div>
  628. </div>`;
  629. }).join('');
  630. }
  631. async function selectQuery(qid){
  632. state.queryId = qid; state.caseId = null; state.post = null;
  633. state.platFilter = 'all'; state.selected.clear();
  634. renderQueries(); renderExtractEmpty();
  635. $('#post-list').innerHTML = '<div class="empty">加载中…</div>';
  636. try { state.posts = await api('/api/posts?mode=' + state.mode + '&query_id=' + encodeURIComponent(qid)); }
  637. catch(e){ state.posts = []; }
  638. renderPosts();
  639. /* 默认选中「可见列表」第一帖(已解构筛选下即第一个已解构帖),无可见帖则退回全部第一帖 */
  640. if (state.posts.length){
  641. const vis = visiblePosts();
  642. await selectPost((vis[0] || state.posts[0]).case_id);
  643. }
  644. }
  645. /* ════ Dataset:帖子列表(按渠道分 tab)════ */
  646. function setPlatFilter(p){ state.platFilter = p; renderPosts(); }
  647. function toggleDoneFilter(){ state.doneFilter = !state.doneFilter; renderPosts(); renderPostsAutoSelect(); }
  648. /* 当前可见帖(渠道 + 已解构筛选后),供列表渲染与「默认选中」共用 */
  649. function visiblePosts(){
  650. const isDone = p => state.mode === 'process' ? p.has_process : p.has_tools;
  651. let list = state.platFilter === 'all' ? state.posts : state.posts.filter(p => (p.platform || 'other') === state.platFilter);
  652. if (state.doneFilter) list = list.filter(isDone);
  653. return list;
  654. }
  655. /* 切换筛选后,若当前选中帖已被筛掉,则改选可见列表的第一帖 */
  656. async function renderPostsAutoSelect(){
  657. const vis = visiblePosts();
  658. if (vis.length && !vis.some(p => p.case_id === state.caseId)) await selectPost(vis[0].case_id);
  659. }
  660. function renderPosts(){
  661. const all = state.posts;
  662. updateBatchBtn();
  663. /* 渠道 tab + 已解构筛选 */
  664. const counts = {};
  665. all.forEach(p => { const k = p.platform || 'other'; counts[k] = (counts[k] || 0) + 1; });
  666. const plats = Object.keys(counts);
  667. if (state.platFilter !== 'all' && !counts[state.platFilter]) state.platFilter = 'all';
  668. const isDone = p => state.mode === 'process' ? p.has_process : p.has_tools;
  669. const platScoped = state.platFilter === 'all' ? all : all.filter(p => (p.platform || 'other') === state.platFilter);
  670. const doneN = platScoped.filter(isDone).length;
  671. const tabs = $('#plat-tabs');
  672. if (all.length){
  673. tabs.hidden = false;
  674. const btns = plats.length > 1
  675. ? [`<button class="${state.platFilter==='all'?'on':''}" onclick="setPlatFilter('all')">全部<span class="c">${all.length}</span></button>`]
  676. .concat(plats.map(k =>
  677. `<button class="${state.platFilter===k?'on':''}" onclick="setPlatFilter('${esc(k)}')">${PLAT_NAME(k)}<span class="c">${counts[k]}</span></button>`))
  678. : [];
  679. btns.push(`<button class="done-filter ${state.doneFilter?'on':''}" onclick="toggleDoneFilter()"
  680. title="${state.doneFilter?'当前只看已解构,点击显示全部':'点击只看已解构'}"><span class="dot">●</span>已解构<span class="c">${doneN}</span></button>`);
  681. tabs.innerHTML = btns.join('');
  682. } else { tabs.hidden = true; }
  683. const list = visiblePosts();
  684. $('#p-count').textContent = all.length
  685. ? ((state.platFilter==='all' && !state.doneFilter) ? `${all.length} 帖` : `${list.length} / ${all.length} 帖`) : '';
  686. if (!all.length){
  687. $('#post-list').innerHTML = '<div class="empty">该 query 暂无帖子</div>'; return;
  688. }
  689. if (!list.length){
  690. $('#post-list').innerHTML = `<div class="empty">${state.doneFilter ? '该筛选下暂无已解构帖子' : '该渠道暂无帖子'}</div>`; return;
  691. }
  692. $('#post-list').innerHTML = list.map(p => {
  693. const done = state.mode === 'process' ? p.has_process : p.has_tools;
  694. const kt = (p.knowledge_type || []).map(k => `<span class="pill">${esc(k)}</span>`).join('');
  695. const sb = p.overall_score != null
  696. ? `<span class="score-badge ${scoreCls(p.overall_score)} num"
  697. onclick="event.stopPropagation();openPostDetail('${esc(p.case_id)}')">${p.overall_score}</span>`
  698. : '';
  699. return `<div class="post ${p.case_id===state.caseId?'on':''}" onclick="selectPost('${esc(p.case_id)}')">
  700. <div style="flex:1;min-width:0">
  701. <div class="pt">${esc(p.title || '(无标题)')}</div>
  702. <div class="pm">
  703. ${PLAT_LOGO(p.platform)}
  704. ${p.adopted ? '<span class="pill green">采纳</span>' : ''}
  705. ${kt}
  706. ${done ? '<span class="done-dot">● 已解构</span>' : ''}
  707. </div>
  708. </div>
  709. ${sb}
  710. </div>`;
  711. }).join('');
  712. }
  713. function toggleSel(cid, on){ on ? state.selected.add(cid) : state.selected.delete(cid); updateBatchBtn(); }
  714. function updateBatchBtn(){
  715. const b = $('#btn-batch');
  716. b.disabled = !state.selected.size;
  717. b.textContent = state.selected.size ? `批量解构(${state.selected.size})` : '批量解构';
  718. }
  719. $('#btn-batch').onclick = () => state.selected.size && startExtract([...state.selected]);
  720. /* ════ 帖子详情弹层 ════ */
  721. /* 得分可能是字符串("1")或数字(10),统一解析;非数值返回 null */
  722. const scoreNum = v => { const n = (typeof v === 'number' || (typeof v === 'string' && v.trim() !== '')) ? Number(v) : NaN; return Number.isFinite(n) ? n : null; };
  723. function collectScores(node){
  724. let out = [];
  725. if (Array.isArray(node)) node.forEach(v => out = out.concat(collectScores(v)));
  726. else if (node && typeof node === 'object'){
  727. for (const [k, v] of Object.entries(node)){
  728. if (k === '得分'){ const n = scoreNum(v); if (n !== null) out.push(n); }
  729. else out = out.concat(collectScores(v));
  730. }
  731. }
  732. return out;
  733. }
  734. function scRow(label, v, reason){
  735. const n = scoreNum(v);
  736. const has = n !== null;
  737. return `<div class="sc-row">
  738. <span style="color:${has?'inherit':'var(--ink-faint)'}">${esc(label)}</span>
  739. <div class="meter"><span style="width:${has ? Math.min(n,10)*10 : 0}%"></span></div>
  740. <b class="num">${has ? n : '—'}</b>
  741. ${reason ? `<span class="info" data-label="${esc(label)}" data-reason="${esc(reason)}" data-score="${has?n:''}" onclick="showScorePop(this)">ⓘ</span>` : '<span></span>'}
  742. </div>`;
  743. }
  744. /* 递归渲染评分;depth>0 的分组标题用二级样式,形成层级 */
  745. function walkScores(node, depth){
  746. depth = depth || 0;
  747. let html = '';
  748. for (const [k, v] of Object.entries(node || {})){
  749. if (v && typeof v === 'object' && !Array.isArray(v) && '得分' in v){
  750. html += scRow(k, v['得分'], v['理由']);
  751. } else if (scoreNum(v) !== null){
  752. html += scRow(k, v);
  753. } else if (v && typeof v === 'object' && !Array.isArray(v)){
  754. const inner = walkScores(v, depth + 1);
  755. if (inner) html += `<div class="sc-sub${depth > 0 ? ' lv2' : ''}">${esc(k)}</div>` + inner;
  756. }
  757. }
  758. return html;
  759. }
  760. /* ── 质量评分:不跟随原始 JSON 嵌套,改用 mode_procedure 的规范分组与顺序 ──
  761. 固定维度 → 用例 → 工序 → 能力 → 工具;丢弃「字段完整性」「动态维度」等中间表头 */
  762. const QUALITY_ORDER = {
  763. '工序': ['流程完整性', '输入完整性', '实现完整性', '输出完整性', '泛化性'],
  764. '能力': ['输入完整性', '实现完整性', '输出完整性', '泛化性'],
  765. '工具': ['能力边界覆盖', '有效比较', '参数/接口具体性', '实操示例', '版本&限制'],
  766. };
  767. /* DFS 找到名为 name 的容器节点(工序/能力/工具) */
  768. function findScoreNode(n, name){
  769. if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
  770. if (name in n && n[name] && typeof n[name] === 'object' && !Array.isArray(n[name])) return n[name];
  771. for (const v of Object.values(n)){
  772. if (v && typeof v === 'object' && !Array.isArray(v)){
  773. const r = findScoreNode(v, name);
  774. if (r) return r;
  775. }
  776. }
  777. return null;
  778. }
  779. /* 递归收集子树叶子评分 [name,{得分,理由}],打平中间分组头 */
  780. function collectLeaves(node){
  781. let out = [];
  782. for (const [k, v] of Object.entries(node || {})){
  783. if (v && typeof v === 'object' && !Array.isArray(v) && '得分' in v) out.push([k, v]);
  784. else if (scoreNum(v) !== null) out.push([k, { 得分: v }]);
  785. else if (v && typeof v === 'object' && !Array.isArray(v)) out = out.concat(collectLeaves(v));
  786. }
  787. return out;
  788. }
  789. function renderQuality(qnode){
  790. /* 固定维度/用例 的维度名全局唯一,扁平索引即可取 */
  791. const flat = {};
  792. (function collect(n){
  793. for (const [k, v] of Object.entries(n || {})){
  794. if (v && typeof v === 'object' && !Array.isArray(v)){
  795. if ('得分' in v) flat[k] = v; else collect(v);
  796. } else if (scoreNum(v) !== null) flat[k] = { 得分: v };
  797. }
  798. })(qnode);
  799. const sub = t => `<div class="sc-sub">${esc(t)}</div>`;
  800. const rows = pairs => pairs.map(([n, o]) => scRow(n, o['得分'], o['理由'])).join('');
  801. const pick = names => names.filter(n => flat[n]).map(n => [n, flat[n]]);
  802. let html = '';
  803. let p = pick(['时效性', '热度性', '评论反馈']); // ① 固定维度(置顶)
  804. if (p.length) html += sub('固定维度') + rows(p);
  805. p = pick(['真实感', '真实感 (非AI)', '真实感(非AI)', '表现力']); // ② 用例
  806. if (p.length) html += sub('用例') + rows(p);
  807. for (const name of ['工序', '能力', '工具']){ // ③ 动态维度(按方向命名,内部规范排序)
  808. const node = findScoreNode(qnode, name);
  809. if (!node) continue;
  810. const leaves = collectLeaves(node);
  811. if (!leaves.length) continue;
  812. const order = QUALITY_ORDER[name] || [];
  813. leaves.sort((a, b) => (order.indexOf(a[0]) + 1 || 99) - (order.indexOf(b[0]) + 1 || 99));
  814. html += sub(name) + rows(leaves);
  815. }
  816. return html || '<span style="color:var(--ink-faint)">无评分</span>';
  817. }
  818. /* ── 判定理由弹层:点击 ⓘ 浮现,符合当前页面样式 ── */
  819. let _scorePop = null;
  820. function closeScorePop(){
  821. if (_scorePop){ _scorePop.remove(); _scorePop = null; }
  822. document.removeEventListener('mousedown', _scorePopOutside, true);
  823. }
  824. function _scorePopOutside(e){
  825. if (_scorePop && !_scorePop.contains(e.target) && !e.target.classList.contains('info')) closeScorePop();
  826. }
  827. function showScorePop(el){
  828. const reopen = _scorePop && _scorePop._anchor === el;
  829. closeScorePop();
  830. if (reopen) return; // 再次点击同一图标 → 收起
  831. const score = el.dataset.score;
  832. const pop = document.createElement('div');
  833. pop.className = 'score-pop';
  834. pop._anchor = el;
  835. pop.innerHTML = `<div class="sp-head">
  836. <span class="sp-tag">判定理由</span>
  837. <span class="sp-name">${esc(el.dataset.label || '')}</span>
  838. ${score !== '' && score != null ? `<span class="sp-score">${esc(score)}</span>` : ''}
  839. </div>
  840. <div class="sp-body">${esc(el.dataset.reason || '')}</div>`;
  841. // 挂在 dialog 内,确保位于 showModal 顶层之上
  842. (document.getElementById('post-dialog') || document.body).appendChild(pop);
  843. _scorePop = pop;
  844. // 定位:图标下方右对齐;越界则上翻 / 收边
  845. const r = el.getBoundingClientRect();
  846. let top = r.bottom + 8, left = r.right - pop.offsetWidth;
  847. if (left < 12) left = 12;
  848. if (top + pop.offsetHeight > window.innerHeight - 12) top = r.top - pop.offsetHeight - 8;
  849. if (top < 12) top = 12;
  850. pop.style.top = top + 'px';
  851. pop.style.left = left + 'px';
  852. setTimeout(() => document.addEventListener('mousedown', _scorePopOutside, true), 0);
  853. }
  854. document.getElementById('post-dialog')?.addEventListener('close', closeScorePop);
  855. function openPostDetail(cid){
  856. const p = state.posts.find(x => x.case_id === cid);
  857. if (!p) return;
  858. const e = p.llm_evaluation || {};
  859. const meta = [];
  860. meta.push(`${PLAT_LOGO(p.platform)}<span style="font-weight:600">${esc(PLAT_NAME(p.platform))}</span>`);
  861. meta.push(p.adopted ? '<span class="pill green">采纳/命中</span>' : '<span class="pill">未采纳</span>');
  862. if (p.publish_time) meta.push(`<span>${esc(String(p.publish_time).slice(0,16))}</span>`);
  863. if (p.like_count != null) meta.push(`<span>👍 ${p.like_count}</span>`);
  864. if (p.quality_grade) meta.push(`<span>质量 ${esc(p.quality_grade)} ${p.quality_score ?? ''}</span>`);
  865. if (p.url) meta.push(`<a href="${esc(p.url)}" target="_blank">原文 ↗</a>`);
  866. $('#pd-meta').innerHTML = meta.join('');
  867. $('#pd-title').textContent = p.title || '(无标题)';
  868. const verdict = e['判定理由'] || e['理由'] || '';
  869. $('#pd-verdict').innerHTML = verdict ? `<div class="pd-verdict">${esc(verdict)}</div>` : '';
  870. $('#pd-text').textContent = p.body || '(无正文)';
  871. const imgs = (p.images || []).filter(Boolean);
  872. $('#pd-images').innerHTML = imgs.length
  873. ? imgs.map(s => `<img src="${esc(s)}" referrerpolicy="no-referrer" loading="lazy"
  874. onclick="window.open(this.src)" onerror="this.style.opacity=.25">`).join('')
  875. : '<p style="color:var(--ink-faint);font-size:12px">搜索详情未返回图片。</p>';
  876. $('#pd-tags').innerHTML = [
  877. ...(p.knowledge_type || []).map(t => '类型:' + t),
  878. ...(p.found_by || []).map(q => '命中:' + q),
  879. ].map(t => `<span class="pill">${esc(t)}</span>`).join('');
  880. $('#pd-overall').innerHTML = p.overall_score != null
  881. ? `${p.overall_score}<span style="font-size:11px;color:var(--ink-faint);font-weight:500"> /10</span>` : '—';
  882. /* 评分卡:相关性 + 质量(均分 + 各维度,ⓘ hover 看理由) */
  883. $('#pd-scores').innerHTML = [['01','相关性'],['02','质量']].map(([no, key], i) => {
  884. const node = e[key];
  885. if (!node) return '';
  886. const vs = collectScores(node);
  887. const avg = vs.length ? (vs.reduce((a,b)=>a+b,0)/vs.length).toFixed(1) : 'N/A';
  888. return `<div class="sc-card">
  889. <div class="sc-card-head">
  890. <span><span class="badge">${no}</span>${esc(key)}</span>
  891. <span class="avg">均分 <b>${avg}</b>/10</span>
  892. </div>
  893. <div class="sc-card-body">${(key === '质量' ? renderQuality(node) : walkScores(node)) || '<span style="color:var(--ink-faint)">无评分</span>'}</div>
  894. </div>`;
  895. }).join('') || '<div class="empty">无评估数据</div>';
  896. $('#post-dialog').showModal();
  897. }
  898. /* ════ Dataset:右栏解构结果 ════ */
  899. function renderExtractEmpty(){
  900. $('#xp-head').innerHTML = '<span class="st">解构结果</span>';
  901. $('#xp-body').innerHTML = '<div class="empty"><span class="glyph">←</span>选择一个帖子查看解构结果</div>';
  902. }
  903. async function selectPost(cid){
  904. state.caseId = cid; state.version = null;
  905. state.post = state.posts.find(p => p.case_id === cid) || null;
  906. renderPosts();
  907. await loadExtract();
  908. }
  909. async function loadExtract(){
  910. if (!state.caseId) return renderExtractEmpty();
  911. const isProc = state.mode === 'process';
  912. const vURL = `/api/${isProc?'process':'tools'}_versions?case_id=` + encodeURIComponent(state.caseId);
  913. const dURL = `/api/${isProc?'process':'tools'}?case_id=` + encodeURIComponent(state.caseId)
  914. + (state.version ? '&version=' + encodeURIComponent(state.version) : '');
  915. let versions = [], data = null, missing = false;
  916. try { versions = await api(vURL); } catch(e){}
  917. try { data = await api(dURL); } catch(e){ if (e.status === 404) missing = true; else throw e; }
  918. renderExtractHead(versions, data, missing);
  919. const body = $('#xp-body');
  920. if (missing || !data){
  921. body.innerHTML = `<div class="empty">该帖暂无${isProc?'工序':'工具'}解构<br><br>
  922. <button class="btn primary" onclick="startExtract(['${esc(state.caseId)}'])">开始解构</button></div>`;
  923. return;
  924. }
  925. state.version = data.version;
  926. syncVersionSelect();
  927. body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
  928. if (!isProc) requestAnimationFrame(markClampedCells);
  929. }
  930. function renderExtractHead(versions, data, missing){
  931. const isProc = state.mode === 'process';
  932. const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || '');
  933. const opts = versions.map((v,i) =>
  934. `<option value="${esc(v.version)}">${esc(v.version)}${i===0?' (最新)':''} · ${v.n}${isProc?'工序':'工具'}</option>`).join('');
  935. const models = (isProc ? MODELS_PROC : MODELS_TOOL).map(m => `<option>${m}</option>`).join('');
  936. $('#xp-head').innerHTML = `
  937. <span class="st">大模型${isProc?'工序':'工具'}:<em>${missing?'未提取':'已提取'}</em></span>
  938. <span style="font-size:12px;color:var(--ink-faint);max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title}</span>
  939. <span class="spacer"></span>
  940. ${versions.length ? `<select id="ver-sel">${opts}</select>` : ''}
  941. <select id="model-sel" title="解构模型">${models}</select>
  942. <button class="btn sm primary" onclick="startExtract(['${esc(state.caseId||'')}'])">${missing?'提取':'♻ 重新生成'}</button>
  943. <button class="btn sm" onclick="showTaskPanel()" title="重新打开任务日志面板">📋 操作日志</button>`;
  944. const vs = $('#ver-sel');
  945. if (vs) vs.onchange = () => { state.version = vs.value; loadExtract(); };
  946. }
  947. function syncVersionSelect(){ const vs = $('#ver-sel'); if (vs && state.version) vs.value = state.version; }
  948. /* ── 工序渲染 ── */
  949. function renderProcedures(data){
  950. const src = data.source || {};
  951. const srcLine = src.title ? `<div class="pill" style="margin-bottom:14px">▸ 原文:${esc(src.title)}</div>` : '';
  952. return srcLine + (data.procedures || []).map(p => {
  953. const d = p.declarations || {};
  954. const ins = (d.inputs || []).map(x =>
  955. `<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>';
  956. const ret = d.returns ? `<span class="pill teal">${esc(d.returns.type || '')}</span>` : '<span style="color:var(--ink-faint)">无</span>';
  957. return `<div class="proc">
  958. <div class="proc-head">
  959. <div class="nm"><span class="pid">工序 ${esc(p.id || '')}</span>${esc(p.name || '')}</div>
  960. ${p.purpose ? `<div class="pp">#目的:${esc(p.purpose)}</div>` : ''}
  961. <div class="meta">
  962. ${p.category ? `<span class="pill red">类别:${esc(p.category)}</span>` : ''}
  963. ${src.platform ? `<span class="pill">#平台:${esc(src.platform)}</span>` : ''}
  964. ${src.author ? `<span class="pill">#作者:${esc(src.author)}</span>` : ''}
  965. <span class="pill">case:${esc(data.case_id)}</span>
  966. ${(p.tools_used||[]).map(t=>`<span class="pill teal">${esc(t)}</span>`).join('')}
  967. </div>
  968. </div>
  969. <div class="decl">
  970. <div><div class="dl">输入</div><div class="di">${ins}</div></div>
  971. <div><div class="dl">返回</div><div class="di">${ret}</div></div>
  972. </div>
  973. ${renderSteps(p.steps || [])}
  974. </div>`;
  975. }).join('') || '<div class="empty">本版本无工序</div>';
  976. }
  977. function renderSteps(steps){
  978. if (!steps.length) return '<div class="empty">无步骤</div>';
  979. let rows = '';
  980. for (const s of steps){
  981. const ins = (s.inputs && s.inputs.length) ? s.inputs : [null];
  982. const outs = (s.outputs && s.outputs.length) ? s.outputs : [null];
  983. const n = Math.max(ins.length, outs.length);
  984. for (let i = 0; i < n; i++){
  985. rows += '<tr>';
  986. if (i === 0){
  987. rows += `<td rowspan="${n}" class="sid">${esc(s.id||'')}</td>
  988. <td rowspan="${n}">${esc(s.directive || s.intent || '')}</td>
  989. <td rowspan="${n}">${s.effect?`<span class="pill navy">${esc(s.effect)}</span>`:''}</td>
  990. <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
  991. <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
  992. }
  993. rows += ioCell(ins[i], 'in');
  994. if (i === 0){
  995. rows += `<td rowspan="${n}">${s.via?`<span class="pill teal">${esc(s.via)}</span>`:''}</td>
  996. <td rowspan="${n}" class="vtxt">${esc(s.action||'')}</td>`;
  997. }
  998. rows += ioCell(outs[i], 'out');
  999. rows += '</tr>';
  1000. }
  1001. }
  1002. return `<div style="overflow-x:auto"><table class="steps">
  1003. <colgroup>
  1004. <col style="width:44px"><col style="width:200px"><col style="width:92px">
  1005. <col style="width:112px"><col style="width:100px">
  1006. <col style="width:112px"><col style="width:330px"><col style="width:92px">
  1007. <col style="width:118px"><col style="width:130px">
  1008. <col style="width:112px"><col style="width:360px"><col style="width:110px">
  1009. </colgroup>
  1010. <thead>
  1011. <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>
  1012. <tr>
  1013. <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>
  1014. <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
  1015. <th class="h-im2">外部工具</th><th class="h-im2">动作</th>
  1016. <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
  1017. </tr>
  1018. </thead><tbody>${rows}</tbody></table></div>`;
  1019. }
  1020. function fmtSF(v){ return v == null ? '' : (Array.isArray(v) ? v.join('、') : v); }
  1021. function ioCell(x, kind){
  1022. const cls = kind === 'in' ? 'c-in' : 'c-out';
  1023. if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
  1024. const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
  1025. const badge = x.inferred ? '<span class="ib">推</span>' : '';
  1026. return `<td class="${cls}"><span class="pill ${kind==='in'?'amber':'teal'}">${esc(x.type||'')}</span></td>
  1027. <td class="${cls}${inf}">${badge}<span class="vtxt">${esc(x.value||'')}</span></td>
  1028. <td class="${cls}"><span class="anchor">${esc(x.anchor||'')}</span></td>`;
  1029. }
  1030. /* ── 工具渲染(表格,移植自 fixed_query_eval renderToolTable)── */
  1031. const DASH = '<span class="dash">—</span>';
  1032. function _toolCell(v){
  1033. if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) return DASH;
  1034. if (Array.isArray(v)) return '<ul>' + v.map(x => `<li>${esc(String(x))}</li>`).join('') + '</ul>';
  1035. return esc(String(v));
  1036. }
  1037. function _scopeCell(v){
  1038. /* 作用域是 JSON 数组:按「、」拼接成短语,不出列表 */
  1039. if (v === null || v === undefined || (Array.isArray(v) && !v.length) || v === '') return DASH;
  1040. return esc(Array.isArray(v) ? v.join('、') : String(v));
  1041. }
  1042. function _ttCell(inner, clampable){
  1043. return clampable ? `<div class="tcell" onclick="this.classList.toggle('open')">${inner}</div>` : inner;
  1044. }
  1045. function _toolCellContent(c, t){
  1046. let inner, cls = '', clampable = true, style = '';
  1047. if (c === '工具名称'){
  1048. cls = 'col-tool'; clampable = false; inner = `🔧 ${esc(t[c] || '(未命名)')}`;
  1049. } else if (c === '来源链接'){
  1050. clampable = false;
  1051. inner = t[c] ? `<a href="${esc(t[c])}" target="_blank" style="color:#176d64;font-weight:600;">🔗 打开</a>` : DASH;
  1052. } else if (c === '创作层级'){
  1053. clampable = false;
  1054. inner = t[c] ? `<span class="layer-badge ${t[c] === '制作层' ? 'make' : 'create'}">${esc(t[c])}</span>` : DASH;
  1055. } else if (c === '实质作用域' || c === '形式作用域'){
  1056. inner = _scopeCell(t[c]);
  1057. } else {
  1058. inner = _toolCell(t[c]);
  1059. }
  1060. if (['输入','输出','用法','缺点'].includes(c)) style = 'max-width:240px;';
  1061. else if (c === '实质作用域' || c === '形式作用域') style = 'max-width:170px;';
  1062. else if (!clampable) style = 'white-space:nowrap;';
  1063. return {inner, cls, clampable, style};
  1064. }
  1065. function _td(c, t, rowspan){
  1066. const {inner, cls, clampable, style} = _toolCellContent(c, t);
  1067. const rs = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
  1068. return `<td class="${cls}" style="${style}"${rs}>${_ttCell(inner, clampable)}</td>`;
  1069. }
  1070. function _caseTd(cse, key){
  1071. const v = (cse && cse[key] != null && cse[key] !== '') ? esc(String(cse[key])) : DASH;
  1072. return `<td class="col-case" style="max-width:210px;">${_ttCell(v, true)}</td>`;
  1073. }
  1074. function renderTools(data){
  1075. const tools = data.tools || [];
  1076. if (!tools.length) return '<div class="empty">本版本无工具</div>';
  1077. /* 案例 group(输入/输出/效果)放在 用法 后、缺点 前;用 colspan/rowspan 做两层表头 */
  1078. const before = ['工具名称','创作层级','实质作用域','形式作用域','输入','输出','用法'];
  1079. const after = ['缺点','来源链接','最新更新时间'];
  1080. const thead = `<thead>
  1081. <tr>
  1082. ${before.map(c => `<th rowspan="2">${c}</th>`).join('')}
  1083. <th colspan="3" class="th-group">案例</th>
  1084. ${after.map(c => `<th rowspan="2">${c}</th>`).join('')}
  1085. </tr>
  1086. <tr>${['输入','输出','效果'].map(c => `<th class="th-sub">${c}</th>`).join('')}</tr>
  1087. </thead>`;
  1088. const rows = tools.map((t, ti) => {
  1089. const cases = (Array.isArray(t['案例']) && t['案例'].length) ? t['案例'] : [null];
  1090. const K = cases.length;
  1091. const par = ti % 2 ? 'tr-b' : 'tr-a';
  1092. return cases.map((cse, i) => {
  1093. const caseTds = `${_caseTd(cse,'输入')}${_caseTd(cse,'输出')}${_caseTd(cse,'效果')}`;
  1094. if (i === 0){
  1095. return `<tr class="${par}">${before.map(c => _td(c, t, K)).join('')}${caseTds}${after.map(c => _td(c, t, K)).join('')}</tr>`;
  1096. }
  1097. return `<tr class="${par}">${caseTds}</tr>`;
  1098. }).join('');
  1099. }).join('');
  1100. return `<div class="mw-ttwrap"><table class="mw-tt">${thead}<tbody>${rows}</tbody></table></div>`;
  1101. }
  1102. /* 渲染后标记真正溢出的单元格(才显示蒙版+可点击) */
  1103. function markClampedCells(){
  1104. document.querySelectorAll('#xp-body .mw-tt .tcell').forEach(el => {
  1105. if (!el.classList.contains('open') && el.scrollHeight > el.clientHeight + 2) el.classList.add('clamped');
  1106. else if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clamped');
  1107. });
  1108. }
  1109. /* ════ 解构任务 ════ */
  1110. async function startExtract(caseIds){
  1111. if (!state.queryId || !caseIds.length) return;
  1112. const isProc = state.mode === 'process';
  1113. const model = $('#model-sel')?.value || (isProc ? MODELS_PROC[0] : MODELS_TOOL[0]);
  1114. try {
  1115. const r = await api(`/api/extract_${isProc?'process':'tools'}`, {
  1116. method:'POST', body: JSON.stringify({query_id: state.queryId, case_ids: caseIds, model})});
  1117. showTask(`${isProc?'工序':'工具'}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
  1118. state.selected.clear();
  1119. await selectQuery(state.queryId);
  1120. if (caseIds.includes(state.caseId)) { /* selectQuery 清了 caseId,恢复选中 */ }
  1121. if (caseIds.length === 1){ state.caseId = caseIds[0]; state.version = null; renderPosts(); await loadExtract(); }
  1122. });
  1123. } catch(e){ alert('任务启动失败:' + (e.body?.error || e.status)); }
  1124. }
  1125. /* ════ 任务面板(✕ 只隐藏;「操作日志」按钮可随时唤回)════ */
  1126. let pollTimer = null, hasTask = false;
  1127. function showTask(title, taskId, onDone){
  1128. hasTask = true;
  1129. $('#task-panel').hidden = false;
  1130. $('#task-title').textContent = title;
  1131. $('#task-dot').className = 'dot';
  1132. $('#task-log').textContent = '启动中…';
  1133. clearTimeout(pollTimer);
  1134. const poll = async () => {
  1135. let t;
  1136. try { t = await api('/api/task_status?task_id=' + encodeURIComponent(taskId)); }
  1137. catch(e){ $('#task-log').textContent = '状态查询失败'; return; }
  1138. const log = $('#task-log');
  1139. log.textContent = t.log_tail || '(暂无日志)';
  1140. log.scrollTop = log.scrollHeight;
  1141. if (t.status === 'running'){ pollTimer = setTimeout(poll, 2000); return; }
  1142. $('#task-dot').className = 'dot ' + t.status;
  1143. $('#task-title').textContent = title + (t.status === 'done' ? ' · 完成' : ' · 失败');
  1144. if (t.status === 'done' && onDone) onDone();
  1145. };
  1146. poll();
  1147. }
  1148. /* ✕ 只隐藏面板,轮询照跑(任务完成后回调依然触发) */
  1149. function hideTask(){ $('#task-panel').hidden = true; }
  1150. function showTaskPanel(){
  1151. if (!hasTask) return alert('本次会话还没有任务日志');
  1152. $('#task-panel').hidden = false;
  1153. const log = $('#task-log'); log.scrollTop = log.scrollHeight;
  1154. }
  1155. /* ════ 新建搜索 ════ */
  1156. /* 渠道下拉多选(选项同 search_eval:小红书/知乎/公众号/抖音/视频号/YouTube) */
  1157. const CHANNELS = [
  1158. {key:'xhs', name:'小红书', on:true}, {key:'zhihu', name:'知乎', on:true},
  1159. {key:'gzh', name:'公众号'}, {key:'douyin', name:'抖音'},
  1160. {key:'sph', name:'视频号'}, {key:'youtube', name:'YouTube'},
  1161. ];
  1162. $('#s-plat-panel').innerHTML = CHANNELS.map(c => `
  1163. <label class="dd-opt ${c.on?'sel':''}">
  1164. <input type="checkbox" value="${c.key}" ${c.on?'checked':''}>${c.name}
  1165. </label>`).join('');
  1166. function selectedPlatforms(){
  1167. return [...document.querySelectorAll('#s-plat-panel input:checked')].map(x => x.value);
  1168. }
  1169. function syncPlatBtn(){
  1170. const names = [...document.querySelectorAll('#s-plat-panel input:checked')]
  1171. .map(x => CHANNELS.find(c => c.key === x.value).name);
  1172. $('#s-plat-btn').innerHTML =
  1173. `<span>${names.length ? esc(names.join('、')) : '选择渠道'}</span><span class="dd-arrow">▾</span>`;
  1174. }
  1175. $('#s-plat-btn').onclick = e => { e.stopPropagation(); $('#s-plat-panel').hidden = !$('#s-plat-panel').hidden; };
  1176. $('#s-plat-panel').onclick = e => e.stopPropagation();
  1177. $('#s-plat-panel').addEventListener('change', e => {
  1178. e.target.closest('.dd-opt').classList.toggle('sel', e.target.checked);
  1179. syncPlatBtn();
  1180. });
  1181. document.addEventListener('click', () => { $('#s-plat-panel').hidden = true; });
  1182. syncPlatBtn();
  1183. $('#btn-new-search').onclick = () => {
  1184. $('#s-mode').value = state.mode === 'process' ? '工序' : '工具'; // 默认跟随当前子模式
  1185. $('#search-modal').hidden = false; $('#s-query').focus();
  1186. };
  1187. $('#search-modal').onclick = e => { if (e.target === $('#search-modal')) $('#search-modal').hidden = true; };
  1188. $('#s-go').onclick = async () => {
  1189. const query = $('#s-query').value.trim();
  1190. if (!query) return alert('请填写 query');
  1191. const plats = selectedPlatforms();
  1192. if (!plats.length) return alert('请至少选择一个检索渠道');
  1193. const body = {query, synonyms: $('#s-syn').value.trim(),
  1194. mode_type: $('#s-mode').value,
  1195. platforms: plats.join(','),
  1196. max_count: parseInt($('#s-max').value) || 10};
  1197. try {
  1198. const r = await api('/api/run_search', {method:'POST', body: JSON.stringify(body)});
  1199. $('#search-modal').hidden = true;
  1200. showTask(`搜索 · ${r.query_id} ${query}`, r.task_id, async () => {
  1201. /* 搜索结果落在 s-mode 对应的表,完成后切到对应子模式再选中 */
  1202. const m = body.mode_type === '工具' ? 'tools' : 'process';
  1203. if (state.mode !== m){
  1204. state.mode = m;
  1205. $('#m-process').classList.toggle('on', m === 'process');
  1206. $('#m-tools').classList.toggle('on', m === 'tools');
  1207. }
  1208. await loadQueries(); selectQuery(r.query_id);
  1209. });
  1210. } catch(e){ alert('搜索启动失败:' + (e.body?.error || e.status)); }
  1211. };
  1212. /* ════ 工序/工具子模式 ════ */
  1213. $('#m-process').onclick = () => setMode('process');
  1214. $('#m-tools').onclick = () => setMode('tools');
  1215. function setMode(m){
  1216. if (state.mode === m) return;
  1217. state.mode = m; state.version = null;
  1218. /* 工序/工具是两张独立搜索表,切换时整体重置三栏 */
  1219. state.queryId = null; state.caseId = null; state.post = null;
  1220. state.posts = []; state.selected.clear(); state.platFilter = 'all';
  1221. $('#m-process').classList.toggle('on', m === 'process');
  1222. $('#m-tools').classList.toggle('on', m === 'tools');
  1223. $('#plat-tabs').hidden = true;
  1224. $('#p-count').textContent = '';
  1225. $('#post-list').innerHTML = '<div class="empty"><span class="glyph">←</span>先选择左侧 query</div>';
  1226. renderExtractEmpty();
  1227. loadQueries();
  1228. }
  1229. window.addEventListener('resize', () => chartRefs.forEach(c => c.resize()));
  1230. route();
  1231. </script>
  1232. </body>
  1233. </html>