extraction-viewer.html 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  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>能力 / 工序 提取查看器</title>
  7. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  8. <style>
  9. * { box-sizing: border-box; }
  10. html, body { height: 100%; }
  11. body {
  12. margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
  13. "Microsoft YaHei", system-ui, sans-serif;
  14. background: #f5f5f7; color: #1d1d1f;
  15. }
  16. header {
  17. padding: 12px 20px; background: rgba(255,255,255,0.94);
  18. backdrop-filter: blur(12px); border-bottom: 1px solid #e5e5e7;
  19. position: sticky; top: 0; z-index: 10;
  20. display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
  21. }
  22. header h1 { margin: 0; font-size: 16px; font-weight: 600; }
  23. .tabs { display: flex; gap: 4px; }
  24. .tab {
  25. padding: 5px 12px; font-size: 13px; border-radius: 7px;
  26. border: 1px solid #d2d2d7; background: #fbfbfd; cursor: pointer;
  27. font-family: inherit; color: #1d1d1f;
  28. }
  29. .tab.active { background: #1d1d1f; color: white; border-color: #1d1d1f; }
  30. .search {
  31. flex: 1; min-width: 180px; max-width: 320px;
  32. padding: 6px 12px; border: 1px solid #d2d2d7; border-radius: 8px;
  33. font-size: 13px; background: #fbfbfd;
  34. }
  35. .search:focus { outline: none; border-color: #0071e3; background: white; }
  36. .meta { color: #86868b; font-size: 12px; }
  37. .layout {
  38. display: grid; grid-template-columns: 280px 1fr;
  39. height: calc(100vh - 53px);
  40. }
  41. .sidebar {
  42. border-right: 1px solid #e5e5e7; background: #fafafa;
  43. overflow-y: auto;
  44. }
  45. .sb-item {
  46. padding: 10px 14px; border-bottom: 1px solid #ececef;
  47. cursor: pointer; font-size: 13px; line-height: 1.4;
  48. }
  49. .sb-item:hover { background: #f0f0f3; }
  50. .sb-item.active { background: #e8f0fe; border-left: 3px solid #0071e3; padding-left: 11px; }
  51. .sb-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #515154; font-size: 11px; }
  52. .sb-title { color: #1d1d1f; margin-top: 2px; }
  53. .sb-badge {
  54. display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 4px;
  55. background: #ececef; color: #515154; margin-left: 4px; vertical-align: middle;
  56. }
  57. .sb-badge.skip { background: #ffe5e5; color: #b30000; }
  58. .sb-badge.no-strategy { background: #fff3cd; color: #8a6d00; }
  59. .main { overflow-y: auto; padding: 24px 32px 60px; }
  60. .empty { color: #86868b; padding: 40px; text-align: center; }
  61. .head { margin-bottom: 18px; }
  62. .head h2 {
  63. margin: 0 0 6px; font-size: 22px; font-weight: 600; line-height: 1.3;
  64. }
  65. .head .row {
  66. display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: #515154;
  67. margin-top: 4px;
  68. }
  69. .head a { color: #0071e3; text-decoration: none; }
  70. .head a:hover { text-decoration: underline; }
  71. .stats {
  72. display: flex; gap: 14px; flex-wrap: wrap;
  73. background: #fff; border: 1px solid #e5e5e7; border-radius: 10px;
  74. padding: 10px 14px; margin-bottom: 20px;
  75. font-size: 12px; color: #515154;
  76. }
  77. .stats b { color: #1d1d1f; font-weight: 600; }
  78. .skip-banner {
  79. background: #fff3cd; border: 1px solid #ffe69c; color: #8a6d00;
  80. padding: 12px 16px; border-radius: 10px; margin-bottom: 20px;
  81. font-size: 13px;
  82. }
  83. .section {
  84. margin-bottom: 28px;
  85. }
  86. .section-title {
  87. font-size: 13px; font-weight: 600; color: #515154; text-transform: uppercase;
  88. letter-spacing: 0.6px; margin: 0 0 10px;
  89. display: flex; align-items: center; gap: 8px;
  90. }
  91. .section-title .count {
  92. background: #ececef; color: #515154; font-size: 11px;
  93. padding: 1px 7px; border-radius: 999px; letter-spacing: 0;
  94. font-weight: 500; text-transform: none;
  95. }
  96. .item-card {
  97. background: white; border: 1px solid #e5e5e7; border-radius: 10px;
  98. padding: 16px 18px; margin-bottom: 12px;
  99. }
  100. .item-card.strategy { border-left: 3px solid #0071e3; }
  101. .item-card.capability { border-left: 3px solid #34c759; }
  102. .item-card h3 {
  103. margin: 0 0 8px; font-size: 15px; font-weight: 600; line-height: 1.4;
  104. display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  105. }
  106. .heading-apply {
  107. display: inline-flex; gap: 4px; flex-wrap: wrap;
  108. vertical-align: middle;
  109. }
  110. .heading-apply-chip {
  111. display: inline-flex; align-items: center;
  112. border: 1px solid #d2d2d7; background: #f5f5f7; color: #515154;
  113. border-radius: 999px; padding: 1px 7px;
  114. font-size: 11px; line-height: 17px; font-weight: 500;
  115. }
  116. .item-card .field {
  117. display: grid; grid-template-columns: 60px 1fr; gap: 10px;
  118. font-size: 13px; line-height: 1.6; margin-bottom: 4px;
  119. }
  120. .item-card .field .label {
  121. color: #86868b; font-size: 11px; padding-top: 3px;
  122. }
  123. .item-card .field .value { color: #1d1d1f; }
  124. .item-card ul { margin: 4px 0; padding-left: 18px; }
  125. .item-card li { line-height: 1.55; margin-bottom: 2px; }
  126. .item-card .body-text { white-space: pre-wrap; word-break: break-word; }
  127. .post-ids { display: inline-flex; gap: 4px; flex-wrap: wrap; }
  128. .post-id {
  129. font-family: ui-monospace, "SF Mono", Menlo, monospace;
  130. font-size: 11px; padding: 1px 6px; border-radius: 4px;
  131. background: #e8f0fe; color: #0050a0;
  132. }
  133. .steps { margin: 6px 0 0; padding: 0; list-style: none; }
  134. .step {
  135. border-left: 2px solid #d2d2d7; padding: 6px 0 6px 12px; margin-left: 4px;
  136. margin-bottom: 6px;
  137. }
  138. .step-head {
  139. font-size: 13px; font-weight: 600; color: #1d1d1f; margin-bottom: 2px;
  140. }
  141. .step-head .order {
  142. display: inline-block; min-width: 18px; height: 18px; line-height: 18px;
  143. text-align: center; background: #1d1d1f; color: white;
  144. font-size: 10px; border-radius: 50%; margin-right: 8px; padding: 0 5px;
  145. }
  146. .step-body { font-size: 13px; color: #515154; line-height: 1.6; }
  147. details summary { cursor: pointer; color: #515154; font-size: 12px; padding: 4px 0; }
  148. details[open] summary { color: #1d1d1f; }
  149. pre.raw {
  150. background: #1d1d1f; color: #e5e5e7; padding: 12px 14px;
  151. border-radius: 8px; overflow-x: auto; font-size: 11px; line-height: 1.5;
  152. margin: 6px 0 0;
  153. }
  154. .hidden { display: none !important; }
  155. /* source posts */
  156. .post-card {
  157. background: #fff; border: 1px solid #e5e5e7; border-radius: 10px;
  158. margin-bottom: 12px; overflow: hidden;
  159. }
  160. .post-card[open] { box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
  161. .post-summary {
  162. list-style: none; cursor: pointer; padding: 12px 16px;
  163. display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  164. user-select: none;
  165. }
  166. .post-summary::-webkit-details-marker { display: none; }
  167. .post-summary::before {
  168. content: "▸"; color: #86868b; font-size: 11px; transition: transform .15s;
  169. display: inline-block; width: 12px;
  170. }
  171. .post-card[open] > .post-summary::before { transform: rotate(90deg); }
  172. .post-summary .pid {
  173. font-family: ui-monospace, "SF Mono", Menlo, monospace;
  174. background: #e8f0fe; color: #0050a0;
  175. font-size: 11px; padding: 2px 8px; border-radius: 4px;
  176. }
  177. .post-summary .ptitle { font-size: 14px; font-weight: 500; color: #1d1d1f; }
  178. .post-summary .pmeta { font-size: 11px; color: #86868b; margin-left: auto; }
  179. .post-summary .channel-badge {
  180. font-size: 10px; padding: 1px 6px; border-radius: 4px;
  181. background: #ececef; color: #515154; text-transform: uppercase;
  182. }
  183. .post-body-wrap { padding: 4px 16px 16px; border-top: 1px solid #ececef; }
  184. .post-body-wrap .pdesc {
  185. font-size: 12px; color: #515154; padding: 10px 12px;
  186. background: #fafafa; border-left: 3px solid #d2d2d7;
  187. border-radius: 4px; margin: 12px 0;
  188. }
  189. .post-body-wrap .pmd {
  190. font-size: 13px; line-height: 1.7; color: #1d1d1f;
  191. white-space: pre-wrap; word-break: break-word;
  192. }
  193. .post-body-wrap .pmd p { margin: 0 0 8px; }
  194. .post-body-wrap .pmd h1, .post-body-wrap .pmd h2, .post-body-wrap .pmd h3 {
  195. font-size: 14px; margin: 12px 0 6px;
  196. }
  197. .post-body-wrap .pmd code {
  198. background: #f5f5f7; padding: 1px 5px; border-radius: 3px;
  199. font-size: 12px;
  200. }
  201. .post-body-wrap .pmd pre {
  202. background: #1d1d1f; color: #e5e5e7; padding: 10px 12px;
  203. border-radius: 6px; font-size: 12px; overflow-x: auto;
  204. }
  205. .post-body-wrap .pmd pre code { background: transparent; padding: 0; }
  206. .post-images {
  207. display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  208. gap: 8px; margin-top: 12px;
  209. }
  210. .post-images img {
  211. width: 100%; aspect-ratio: 1/1; object-fit: cover;
  212. background: #1d1d1f; border-radius: 6px; cursor: zoom-in;
  213. transition: transform .12s;
  214. }
  215. .post-images img:hover { transform: scale(1.02); }
  216. .post-link { font-size: 12px; color: #0071e3; text-decoration: none; }
  217. .post-link:hover { text-decoration: underline; }
  218. .post-missing {
  219. color: #b30000; font-size: 12px; padding: 10px 16px;
  220. }
  221. /* lightbox */
  222. .lightbox {
  223. position: fixed; inset: 0; background: rgba(0,0,0,0.85);
  224. display: none; align-items: center; justify-content: center;
  225. z-index: 100; cursor: zoom-out;
  226. }
  227. .lightbox.show { display: flex; }
  228. .lightbox img {
  229. max-width: 95vw; max-height: 95vh; object-fit: contain;
  230. }
  231. /* clickable source-post chip */
  232. .post-id.clickable { cursor: pointer; }
  233. .post-id.clickable:hover { background: #cce0fc; }
  234. /* field-spec hover tooltip (来自 capability_strategy_fields.csv) */
  235. [data-tip] {
  236. cursor: help; border-bottom: 1px dotted #b0b0b5;
  237. position: relative;
  238. /* 让 inline 元素仍能被 position:absolute 子元素锚定 */
  239. display: inline-block;
  240. }
  241. [data-tip]:hover { color: #0071e3; border-bottom-color: #0071e3; }
  242. [data-tip]:hover::after {
  243. content: attr(data-tip);
  244. position: absolute; z-index: 50; left: 0; top: 100%;
  245. margin-top: 6px;
  246. background: #1d1d1f; color: #f5f5f7;
  247. padding: 10px 12px; border-radius: 8px;
  248. font-size: 12px; line-height: 1.65; font-weight: normal;
  249. text-transform: none; letter-spacing: 0;
  250. white-space: pre-wrap; word-break: break-word;
  251. width: max-content; max-width: 480px;
  252. box-shadow: 0 6px 20px rgba(0,0,0,0.25);
  253. pointer-events: none;
  254. }
  255. /* stage chips (preprocess/generate/refine) */
  256. .stage-chips { display: inline-flex; gap: 4px; flex-wrap: wrap; }
  257. .stage-chip {
  258. font-size: 10.5px; line-height: 1.5; font-weight: 600;
  259. padding: 1px 8px; border-radius: 999px;
  260. text-transform: uppercase; letter-spacing: 0.4px;
  261. }
  262. .stage-preprocess { background: #f1e6ff; color: #6020b0; border: 1px solid #d8c0f0; }
  263. .stage-generate { background: #e0eaff; color: #0050a0; border: 1px solid #b8cdf0; }
  264. .stage-refine { background: #d6f3df; color: #1b6e34; border: 1px solid #a8dab8; }
  265. /* inputs / outputs structured list */
  266. ul.io-list { list-style: none; margin: 2px 0 0; padding: 0; }
  267. ul.io-list li { line-height: 1.6; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; }
  268. .data-type {
  269. font-size: 11px; flex-shrink: 0;
  270. padding: 1px 7px; border-radius: 4px;
  271. background: #ececef; color: #515154;
  272. font-family: ui-monospace, "SF Mono", Menlo, monospace;
  273. }
  274. .io-desc { color: #1d1d1f; font-size: 13px; }
  275. /* unstructured_what */
  276. ul.unstructured-list { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
  277. ul.unstructured-list li {
  278. font-size: 12px; padding: 2px 8px; border-radius: 4px;
  279. background: #fff0f5; color: #a02050;
  280. border: 1px dashed #f0c0d0;
  281. }
  282. /* element badge inside apply-path */
  283. .apply-element {
  284. margin-left: 4px; padding: 0 5px; border-radius: 3px;
  285. background: #e0eaff; color: #0050a0; font-weight: 600;
  286. }
  287. /* tools chip */
  288. .tool-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
  289. .tool-chip {
  290. display: inline-block;
  291. font-size: 11px; line-height: 1.5;
  292. padding: 1px 8px; border-radius: 4px;
  293. background: #fff5d9; color: #8a5a00;
  294. border: 1px solid #f4d780;
  295. }
  296. .step-tools { margin-top: 6px; }
  297. /* apply_to: 树路径 chip + hover 看 id/rationale */
  298. .apply-to { display: flex; flex-direction: column; gap: 5px; }
  299. .apply-group { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
  300. .apply-group-name {
  301. font-size: 11px; padding: 2px 8px; border-radius: 4px;
  302. background: #f0e8ff; color: #6020b0; font-weight: 500;
  303. flex-shrink: 0;
  304. }
  305. .apply-path {
  306. font-family: ui-monospace, "SF Mono", Menlo, monospace;
  307. font-size: 11px; color: #1d1d1f; line-height: 1.5;
  308. background: #fafafa; padding: 2px 7px; border-radius: 4px;
  309. border: 1px solid #ececef;
  310. }
  311. /* override [data-tip] dotted underline so the chip border stays uniform */
  312. .apply-path[data-tip] { border-bottom: 1px solid #ececef; }
  313. .apply-path[data-tip]:hover {
  314. color: #0050a0; background: #e8f0fe;
  315. border-color: #0071e3; border-bottom-color: #0071e3;
  316. }
  317. </style>
  318. </head>
  319. <body>
  320. <header>
  321. <h1>能力 / 工序 提取查看器</h1>
  322. <div class="tabs">
  323. <button class="tab active" data-source="capability">extracted/capability/<span class="meta" id="cnt-capability"></span></button>
  324. <button class="tab" data-source="strategy">extracted/strategy/<span class="meta" id="cnt-strategy"></span></button>
  325. <button class="tab" data-source="batch_extracted">batch_extracted/<span class="meta" id="cnt-batch"></span></button>
  326. </div>
  327. <input type="search" class="search" id="search" placeholder="搜索标题 / 能力 / 工序 / 关键词">
  328. <span class="meta" id="status"></span>
  329. </header>
  330. <div class="layout">
  331. <aside class="sidebar" id="sidebar"></aside>
  332. <main class="main" id="main">
  333. <div class="empty">从左侧选择一项查看</div>
  334. </main>
  335. </div>
  336. <div class="lightbox" id="lightbox"><img id="lightbox-img" alt=""></div>
  337. <script>
  338. const SOURCES = {
  339. capability: { dir: 'extracted/capability', files: [] },
  340. strategy: { dir: 'extracted/strategy', files: [] },
  341. batch_extracted: { dir: 'batch_extracted', files: [] },
  342. };
  343. let currentSource = 'capability';
  344. let currentFile = null;
  345. let dataCache = {}; // `${dir}/${file}` -> parsed JSON
  346. let searchQuery = '';
  347. let postsByIndex = {}; // index (number) -> result.json item
  348. let resultLoaded = false;
  349. let fieldSpecs = {}; // { capability: {field: spec}, strategy: {...} }
  350. const $ = (id) => document.getElementById(id);
  351. const sidebar = $('sidebar');
  352. const main = $('main');
  353. const statusEl = $('status');
  354. // 静态 build 模式:window.__BUNDLED__ = { result: [...], extracted: {file:data,...}, batch_extracted: {...} }
  355. const BUNDLED = (typeof window !== 'undefined' && window.__BUNDLED__) || null;
  356. function sortFiles(files) {
  357. return [...files].sort((a, b) => {
  358. const na = parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
  359. const nb = parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
  360. return na - nb || a.localeCompare(b);
  361. });
  362. }
  363. // ---- discovery: bundled mode → keys; dev mode → parse python3 -m http.server listing ----
  364. async function discover(dir) {
  365. if (BUNDLED && BUNDLED[dir]) return sortFiles(Object.keys(BUNDLED[dir]));
  366. try {
  367. const r = await fetch(`${dir}/`);
  368. if (!r.ok) return [];
  369. const html = await r.text();
  370. const matches = [...html.matchAll(/href="([^"]+\.json)"/g)];
  371. const files = matches.map(m => decodeURIComponent(m[1]))
  372. .filter(f => !f.startsWith('/') && !f.includes('/'));
  373. return sortFiles(files);
  374. } catch (e) {
  375. return [];
  376. }
  377. }
  378. async function loadFile(dir, file) {
  379. const key = `${dir}/${file}`;
  380. if (dataCache[key]) return dataCache[key];
  381. if (BUNDLED && BUNDLED[dir] && BUNDLED[dir][file]) {
  382. dataCache[key] = BUNDLED[dir][file];
  383. return dataCache[key];
  384. }
  385. const r = await fetch(key);
  386. const j = await r.json();
  387. dataCache[key] = j;
  388. return j;
  389. }
  390. // 极简 CSV parser (RFC4180: 双引号转义、逗号分隔、可跨行)
  391. function parseCsv(text) {
  392. const rows = [];
  393. let row = [], cell = '', inQ = false, i = 0;
  394. while (i < text.length) {
  395. const c = text[i];
  396. if (inQ) {
  397. if (c === '"') {
  398. if (text[i+1] === '"') { cell += '"'; i += 2; continue; }
  399. inQ = false; i++;
  400. } else { cell += c; i++; }
  401. } else {
  402. if (c === '"') { inQ = true; i++; }
  403. else if (c === ',') { row.push(cell); cell = ''; i++; }
  404. else if (c === '\r') { i++; }
  405. else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++; }
  406. else { cell += c; i++; }
  407. }
  408. }
  409. if (cell.length || row.length) { row.push(cell); rows.push(row); }
  410. return rows;
  411. }
  412. // 能力 → capability, 工序 → strategy(与 JSON 数据中的 entity 对齐)
  413. const ENTITY_NAME_MAP = { '能力': 'capability', '工序': 'strategy' };
  414. async function loadFieldSpecs() {
  415. let csvText = null;
  416. if (BUNDLED && typeof BUNDLED.field_specs_csv === 'string') {
  417. csvText = BUNDLED.field_specs_csv;
  418. } else {
  419. try {
  420. const r = await fetch('capability_strategy_fields.csv');
  421. if (r.ok) csvText = await r.text();
  422. } catch (e) { /* ignore */ }
  423. }
  424. if (!csvText) return;
  425. // 去 BOM
  426. if (csvText.charCodeAt(0) === 0xFEFF) csvText = csvText.slice(1);
  427. const rows = parseCsv(csvText).filter(r => r.length && r.some(c => c !== ''));
  428. if (!rows.length) return;
  429. const header = rows[0].map(c => (c || '').trim());
  430. const cols = {};
  431. header.forEach((h, i) => { if (h) cols[h] = i; });
  432. // entity 列: 旧版叫 'entity', 新版第一列没表头
  433. const entityIdx = cols['entity'] != null ? cols['entity'] : 0;
  434. // group 列: 旧版 'group', 新版 'usage'
  435. const groupIdx = cols['group'] ?? cols['usage'] ?? -1;
  436. const fieldIdx = cols['field'];
  437. const typeIdx = cols['type'];
  438. const nullIdx = cols['nullable'];
  439. const descIdx = cols['description'];
  440. const promptIdx = cols['prompt'] ?? -1;
  441. const statusIdx = cols['status'];
  442. if (fieldIdx == null) return;
  443. let curEntity = null; // forward-fill: 新 CSV 中 entity 只在每段第一行写一次
  444. for (let i = 1; i < rows.length; i++) {
  445. const r = rows[i];
  446. const rawEnt = (r[entityIdx] || '').trim();
  447. if (rawEnt) curEntity = ENTITY_NAME_MAP[rawEnt] || rawEnt;
  448. const ent = curEntity;
  449. const f = (r[fieldIdx] || '').trim();
  450. if (!ent || !f) continue;
  451. if (!fieldSpecs[ent]) fieldSpecs[ent] = {};
  452. fieldSpecs[ent][f] = {
  453. type: r[typeIdx] || '',
  454. nullable: r[nullIdx] || '',
  455. status: r[statusIdx] || '',
  456. group: groupIdx >= 0 ? (r[groupIdx] || '') : '',
  457. description: r[descIdx] || '',
  458. prompt: promptIdx >= 0 ? (r[promptIdx] || '') : '',
  459. };
  460. }
  461. }
  462. function buildTip(entity, field) {
  463. let s = fieldSpecs[entity]?.[field];
  464. let fallbackFromEntity = null;
  465. if (!s) {
  466. // 跨 entity 兜底: method 在 capability schema 但 strategy 数据也用; body 反之
  467. const other = entity === 'capability' ? 'strategy' : 'capability';
  468. s = fieldSpecs[other]?.[field];
  469. if (s) fallbackFromEntity = other;
  470. }
  471. if (!s) return null;
  472. const head = [
  473. s.type,
  474. s.nullable === 'N' ? 'NOT NULL' : (s.nullable === 'Y' ? 'nullable' : ''),
  475. s.group ? `[${s.group}]` : '',
  476. s.status && s.status !== 'existing' ? `(${s.status})` : '',
  477. ].filter(Boolean).join(' · ');
  478. const note = fallbackFromEntity
  479. ? `(schema 中此字段属 ${fallbackFromEntity}, 此处复用说明)\n` : '';
  480. const parts = [];
  481. if (head) parts.push(head);
  482. if (s.description) parts.push(s.description);
  483. if (s.prompt) parts.push('— prompt 提示 —\n' + s.prompt);
  484. return note + parts.join('\n\n');
  485. }
  486. async function loadResultJson() {
  487. if (resultLoaded) return;
  488. try {
  489. let arr;
  490. if (BUNDLED && BUNDLED.result) {
  491. arr = BUNDLED.result;
  492. } else {
  493. const r = await fetch('result.json');
  494. if (!r.ok) { resultLoaded = true; return; }
  495. arr = await r.json();
  496. }
  497. for (const post of arr) {
  498. if (post && post.index != null) postsByIndex[post.index] = post;
  499. }
  500. } catch (e) { /* leave map empty */ }
  501. resultLoaded = true;
  502. }
  503. const CHANNEL_LABEL = {
  504. xhs: '小红书', zhihu: '知乎', youtube: 'YouTube', bili: 'B站',
  505. gzh: '公众号', weibo: '微博', douyin: '抖音', toutiao: '头条',
  506. sph: '视频号', github: 'GitHub', x: 'X', other: '其它',
  507. };
  508. function pidToIndex(pid) {
  509. // "p1" -> 1
  510. const m = String(pid || '').match(/^p(\d+)$/i);
  511. return m ? parseInt(m[1], 10) : null;
  512. }
  513. // ---- rendering ----
  514. function escapeHtml(s) {
  515. if (s == null) return '';
  516. return String(s).replace(/[&<>"']/g, c => ({
  517. '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
  518. })[c]);
  519. }
  520. function renderIO(io) {
  521. // v2: 字符串; v3: [{data_type, description}, ...]
  522. if (io == null || io === '') return '';
  523. if (typeof io === 'string') return escapeHtml(io);
  524. if (!Array.isArray(io) || !io.length) return '';
  525. return `<ul class="io-list">${io.map(it => {
  526. const dt = (it && it.data_type) || '';
  527. const desc = (it && it.description) || '';
  528. return `<li>${dt ? `<span class="data-type">${escapeHtml(dt)}</span>` : ''}<span class="io-desc">${escapeHtml(desc)}</span></li>`;
  529. }).join('')}</ul>`;
  530. }
  531. function renderStages(stages) {
  532. if (!stages || !stages.length) return '';
  533. return `<span class="stage-chips">${stages.map(s => {
  534. const safe = String(s || '').replace(/[^a-z]/gi, '');
  535. return `<span class="stage-chip stage-${escapeHtml(safe)}">${escapeHtml(s)}</span>`;
  536. }).join('')}</span>`;
  537. }
  538. function renderUnstructured(items) {
  539. if (!items || !items.length) return '';
  540. return `<ul class="unstructured-list">${items.map(t =>
  541. `<li>${escapeHtml(t)}</li>`).join('')}</ul>`;
  542. }
  543. function renderTools(tools) {
  544. if (!tools || !tools.length) return '';
  545. return `<span class="tool-chips">${
  546. tools.map(t => `<span class="tool-chip">${escapeHtml(t)}</span>`).join('')
  547. }</span>`;
  548. }
  549. function renderApplyTo(applyTo) {
  550. if (!applyTo || typeof applyTo !== 'object') return '';
  551. const groups = Object.entries(applyTo).filter(([, v]) => Array.isArray(v) && v.length);
  552. if (!groups.length) return '';
  553. return `<div class="apply-to">${groups.map(([gname, items]) => `
  554. <div class="apply-group">
  555. <span class="apply-group-name">${escapeHtml(gname)}</span>
  556. ${items.map(it => {
  557. // v3: {category_id, category_path, element, rationale}; v2: {id, path, rationale}
  558. const id = it && (it.category_id ?? it.id);
  559. const path = (it && (it.category_path ?? it.path)) || '';
  560. const element = it && it.element;
  561. const rationale = (it && it.rationale) || '';
  562. const idLabel = id != null ? `id: ${id}` : '';
  563. const tip = [idLabel, rationale].filter(Boolean).join('\n\n');
  564. const display = element
  565. ? `${escapeHtml(path)}<span class="apply-element">${escapeHtml(element)}</span>`
  566. : escapeHtml(path || '(no path)');
  567. return `<span class="apply-path"${tip ? ` data-tip="${escapeHtml(tip)}"` : ''}>${display}</span>`;
  568. }).join('')}
  569. </div>`).join('')}</div>`;
  570. }
  571. function lastPathSegment(path) {
  572. const parts = String(path || '').split('/').map(s => s.trim()).filter(Boolean);
  573. return parts.length ? parts[parts.length - 1] : '';
  574. }
  575. function applyToEndLabels(applyTo) {
  576. if (!applyTo || typeof applyTo !== 'object') return [];
  577. const labels = [];
  578. const seen = new Set();
  579. for (const items of Object.values(applyTo)) {
  580. if (!Array.isArray(items)) continue;
  581. for (const it of items) {
  582. const label = (it && it.element) || lastPathSegment(it && (it.category_path ?? it.path));
  583. if (!label || seen.has(label)) continue;
  584. seen.add(label);
  585. labels.push(label);
  586. }
  587. }
  588. return labels;
  589. }
  590. function renderEffects(effects) {
  591. if (!effects || !effects.length) return '';
  592. return `<ul>${effects.map(e => `<li>${escapeHtml(e)}</li>`).join('')}</ul>`;
  593. }
  594. function renderPostIds(ids) {
  595. if (!ids || !ids.length) return '';
  596. return `<span class="post-ids">${
  597. ids.map(id => {
  598. const idx = pidToIndex(id);
  599. const exists = idx != null && postsByIndex[idx];
  600. const cls = exists ? 'post-id clickable' : 'post-id';
  601. const data = exists ? ` data-jump="${escapeHtml(id)}"` : '';
  602. return `<span class="${cls}"${data}>${escapeHtml(id)}</span>`;
  603. }).join('')
  604. }</span>`;
  605. }
  606. function renderSourcePost(pid, idx) {
  607. const post = postsByIndex[idx];
  608. if (!post) {
  609. return `<details class="post-card" id="post-${escapeHtml(pid)}">
  610. <summary class="post-summary">
  611. <span class="pid">${escapeHtml(pid)}</span>
  612. <span class="ptitle post-missing">result.json 中未找到 index=${escapeHtml(idx)}</span>
  613. </summary>
  614. </details>`;
  615. }
  616. const channelLbl = CHANNEL_LABEL[post.channel] || post.channel || '';
  617. const bodyMd = post.body
  618. ? (window.marked ? marked.parse(post.body) : escapeHtml(post.body))
  619. : '<em style="color:#86868b">(无正文)</em>';
  620. const images = (post.images || []).map(src =>
  621. `<img src="${escapeHtml(src)}" alt="" loading="lazy" data-zoom>`
  622. ).join('');
  623. const headerMeta = [];
  624. if (post.author) headerMeta.push(escapeHtml(post.author));
  625. if (post.feedback) {
  626. const fb = post.feedback;
  627. const parts = [];
  628. if (fb.like_count != null) parts.push(`♥ ${fb.like_count}`);
  629. if (fb.view_count != null) parts.push(`▷ ${fb.view_count}`);
  630. if (fb.comment_count != null) parts.push(`💬 ${fb.comment_count}`);
  631. if (fb.collect_count != null) parts.push(`☆ ${fb.collect_count}`);
  632. if (parts.length) headerMeta.push(parts.join(' · '));
  633. }
  634. return `<details class="post-card" id="post-${escapeHtml(pid)}">
  635. <summary class="post-summary">
  636. <span class="pid">${escapeHtml(pid)} · #${escapeHtml(idx)}</span>
  637. ${channelLbl ? `<span class="channel-badge">${escapeHtml(channelLbl)}</span>` : ''}
  638. <span class="ptitle">${escapeHtml(post.title || '(无标题)')}</span>
  639. ${headerMeta.length ? `<span class="pmeta">${headerMeta.join(' · ')}</span>` : ''}
  640. </summary>
  641. <div class="post-body-wrap">
  642. ${post.url ? `<a class="post-link" href="${escapeHtml(post.url)}" target="_blank" rel="noopener">原文 ↗</a>` : ''}
  643. ${post.description ? `<div class="pdesc">${escapeHtml(post.description)}</div>` : ''}
  644. <div class="pmd">${bodyMd}</div>
  645. ${images ? `<div class="post-images">${images}</div>` : ''}
  646. </div>
  647. </details>`;
  648. }
  649. function renderSourcePosts(pids) {
  650. const valid = (pids || [])
  651. .map(pid => ({ pid, idx: pidToIndex(pid) }))
  652. .filter(x => x.idx != null);
  653. if (!valid.length) return '';
  654. const missing = valid.filter(x => !postsByIndex[x.idx]).length;
  655. const note = missing
  656. ? ` <span class="meta" style="font-size:11px">(缺失 ${missing})</span>` : '';
  657. return `<div class="section">
  658. <div class="section-title">原始内容 / Source posts <span class="count">${valid.length}</span>${note}</div>
  659. ${valid.map(x => renderSourcePost(x.pid, x.idx)).join('')}
  660. </div>`;
  661. }
  662. function renderField(label, value, entity) {
  663. if (value == null || value === '') return '';
  664. const tip = entity ? buildTip(entity, label) : null;
  665. const inner = tip
  666. ? `<span data-tip="${escapeHtml(tip)}">${escapeHtml(label)}</span>`
  667. : escapeHtml(label);
  668. return `<div class="field"><div class="label">${inner}</div><div class="value">${value}</div></div>`;
  669. }
  670. // 标题优先用 method,并把 apply_to 的末端显示为标签
  671. function renderHeading(item, entity) {
  672. let text = '', fieldName = null;
  673. if (item && item.method) { text = item.method; fieldName = 'method'; }
  674. else if (item && item.name) { text = item.name; fieldName = 'name'; }
  675. else { text = '(无标题)'; }
  676. const tip = fieldName ? buildTip(entity, fieldName) : null;
  677. const main = tip
  678. ? `<span data-tip="${escapeHtml(tip)}">${escapeHtml(text)}</span>`
  679. : escapeHtml(text);
  680. const ends = applyToEndLabels(item && item.apply_to);
  681. const suffix = ends.length
  682. ? `<span class="heading-apply">${ends.map(x => `<span class="heading-apply-chip">${escapeHtml(x)}</span>`).join('')}</span>`
  683. : '';
  684. return `<h3>${main}${suffix}</h3>`;
  685. }
  686. function renderSteps(steps) {
  687. if (!steps || !steps.length) return '';
  688. const sorted = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
  689. return `<ol class="steps">${sorted.map(s => `
  690. <li class="step">
  691. <div class="step-head"><span class="order">${escapeHtml(s.order ?? '?')}</span>${escapeHtml(s.summary || s.method || '')}</div>
  692. ${s.body ? `<div class="step-body">${escapeHtml(s.body)}</div>` : ''}
  693. ${(s.tools && s.tools.length) || (s.stage && s.stage.length) ? `<div class="step-tools">
  694. ${s.stage && s.stage.length ? renderStages(s.stage) : ''}
  695. ${s.tools && s.tools.length ? renderTools(s.tools) : ''}
  696. </div>` : ''}
  697. </li>`).join('')}</ol>`;
  698. }
  699. function renderStrategy(strat) {
  700. const E = 'strategy';
  701. const showMethodRow = false;
  702. return `
  703. <div class="item-card strategy">
  704. ${renderHeading(strat, E)}
  705. ${strat.apply_to ? renderField('apply_to', renderApplyTo(strat.apply_to), E) : ''}
  706. ${renderPostIds(strat.source_post_ids)}
  707. ${strat.stage && strat.stage.length ? renderField('stage', renderStages(strat.stage), E) : ''}
  708. ${showMethodRow ? renderField('method', escapeHtml(strat.method), E) : ''}
  709. ${strat.effects && strat.effects.length ? renderField('effects', renderEffects(strat.effects), E) : ''}
  710. ${strat.steps && strat.steps.length ? renderField('steps', renderSteps(strat.steps), E) : ''}
  711. ${strat.body ? renderField('body', `<div class="body-text">${escapeHtml(strat.body)}</div>`, E) : ''}
  712. ${renderField('inputs', renderIO(strat.inputs), E)}
  713. ${renderField('outputs', renderIO(strat.outputs), E)}
  714. ${strat.tools && strat.tools.length ? renderField('tools', renderTools(strat.tools), E) : ''}
  715. ${renderField('criterion', escapeHtml(strat.criterion), E)}
  716. ${strat.unstructured_what && strat.unstructured_what.length ? renderField('unstructured_what', renderUnstructured(strat.unstructured_what), E) : ''}
  717. </div>`;
  718. }
  719. function renderCapability(cap) {
  720. const E = 'capability';
  721. const showMethodRow = false;
  722. return `
  723. <div class="item-card capability">
  724. ${renderHeading(cap, E)}
  725. ${cap.apply_to ? renderField('apply_to', renderApplyTo(cap.apply_to), E) : ''}
  726. ${renderPostIds(cap.source_post_ids)}
  727. ${cap.stage && cap.stage.length ? renderField('stage', renderStages(cap.stage), E) : ''}
  728. ${showMethodRow ? renderField('method', escapeHtml(cap.method), E) : ''}
  729. ${cap.effects && cap.effects.length ? renderField('effects', renderEffects(cap.effects), E) : ''}
  730. ${cap.body ? renderField('body', `<div class="body-text">${escapeHtml(cap.body)}</div>`, E) : ''}
  731. ${renderField('inputs', renderIO(cap.inputs), E)}
  732. ${renderField('outputs', renderIO(cap.outputs), E)}
  733. ${cap.tools && cap.tools.length ? renderField('tools', renderTools(cap.tools), E) : ''}
  734. ${renderField('criterion', escapeHtml(cap.criterion), E)}
  735. ${cap.unstructured_what && cap.unstructured_what.length ? renderField('unstructured_what', renderUnstructured(cap.unstructured_what), E) : ''}
  736. </div>`;
  737. }
  738. function renderItem(file, data) {
  739. const isBatch = currentSource === 'batch_extracted';
  740. const isCapTab = currentSource === 'capability';
  741. const isStratTab = currentSource === 'strategy';
  742. const ext = data.extraction || {};
  743. // header
  744. const title = isBatch
  745. ? `<span class="sb-id">${escapeHtml(data.group_id ?? file)}</span> ${escapeHtml(data.method_label ?? '')}`
  746. : `<span class="sb-id">#${escapeHtml(data.index ?? file)}</span> ${escapeHtml(data.title ?? '')}`;
  747. const headerRow = [];
  748. if (data.url) headerRow.push(`<a href="${escapeHtml(data.url)}" target="_blank" rel="noopener">原文 ↗</a>`);
  749. if (isBatch && data.member_post_ids) headerRow.push(`成员: ${renderPostIds(data.member_post_ids)}`);
  750. if (data.user_kept != null) headerRow.push(`user_kept: ${data.user_kept ? '✓' : '✗'}`);
  751. // stats
  752. const s = data.stats || {};
  753. const statsItems = [];
  754. if (data.elapsed_sec != null) statsItems.push(`<span><b>${data.elapsed_sec.toFixed(1)}s</b> elapsed</span>`);
  755. if (s.num_turns != null) statsItems.push(`<span><b>${s.num_turns}</b> turns</span>`);
  756. if (s.tool_calls) statsItems.push(`<span><b>${s.tool_calls.length}</b> tool calls</span>`);
  757. if (s.thinking_chunks != null) statsItems.push(`<span><b>${s.thinking_chunks}</b> thinking</span>`);
  758. if (s.total_cost_usd != null) statsItems.push(`<span><b>$${s.total_cost_usd.toFixed(4)}</b></span>`);
  759. if (s.stop_reason) statsItems.push(`<span>stop: ${escapeHtml(s.stop_reason)}</span>`);
  760. if (s.is_error) statsItems.push(`<span style="color:#b30000"><b>ERROR</b></span>`);
  761. // source posts
  762. const sourcePids = isBatch
  763. ? (data.member_post_ids || [])
  764. : (data.index != null ? [`p${data.index}`] : []);
  765. const sourceSection = renderSourcePosts(sourcePids);
  766. // skip / skipped_posts
  767. const skipBanner = ext.skip
  768. ? `<div class="skip-banner">已跳过提取 — ${escapeHtml(ext.skip_reason || '(无原因)')}</div>`
  769. : '';
  770. const skippedBatch = (ext.skipped_posts && ext.skipped_posts.length)
  771. ? `<div class="skip-banner">本组中跳过的 post: ${ext.skipped_posts.map(p =>
  772. typeof p === 'string' ? escapeHtml(p) : escapeHtml(JSON.stringify(p))
  773. ).join(', ')}</div>`
  774. : '';
  775. // 决定哪些 section 渲染
  776. // - capability tab: 只渲染能力
  777. // - strategy tab: 只渲染工序
  778. // - batch: 两者都渲染
  779. const showStrategy = !isCapTab;
  780. const showCapabilities = !isStratTab;
  781. // strategies
  782. const strategies = isBatch ? (ext.strategies || []) : (ext.strategy ? [ext.strategy] : []);
  783. const stratSection = !showStrategy ? '' : (strategies.length
  784. ? `<div class="section">
  785. <div class="section-title">工序 / Strategy <span class="count">${strategies.length}</span></div>
  786. ${strategies.map(renderStrategy).join('')}
  787. </div>`
  788. : (ext.skip ? '' : `<div class="section">
  789. <div class="section-title">工序 / Strategy <span class="count">0</span></div>
  790. <div class="meta" style="font-size:13px">(未提取出工序${isStratTab ? ':原帖只是单一技法分享,没有端到端流程' : ''})</div>
  791. </div>`));
  792. // capabilities
  793. const caps = ext.capabilities || [];
  794. const capSection = !showCapabilities ? '' : (caps.length
  795. ? `<div class="section">
  796. <div class="section-title">能力 / Capabilities <span class="count">${caps.length}</span></div>
  797. ${caps.map(renderCapability).join('')}
  798. </div>`
  799. : (ext.skip ? '' : `<div class="section">
  800. <div class="section-title">能力 / Capabilities <span class="count">0</span></div>
  801. <div class="meta" style="font-size:13px">(未提取出能力)</div>
  802. </div>`));
  803. // raw
  804. const raw = `<details><summary>查看 raw JSON</summary><pre class="raw">${escapeHtml(JSON.stringify(data, null, 2))}</pre></details>`;
  805. main.innerHTML = `
  806. <div class="head">
  807. <h2>${title}</h2>
  808. ${headerRow.length ? `<div class="row">${headerRow.join(' · ')}</div>` : ''}
  809. </div>
  810. ${statsItems.length ? `<div class="stats">${statsItems.join('')}</div>` : ''}
  811. ${skipBanner}
  812. ${skippedBatch}
  813. ${sourceSection}
  814. ${stratSection}
  815. ${capSection}
  816. ${raw}
  817. `;
  818. main.scrollTop = 0;
  819. // post-id chip → jump to source post
  820. main.querySelectorAll('.post-id.clickable[data-jump]').forEach(el => {
  821. el.addEventListener('click', (e) => {
  822. e.stopPropagation();
  823. const target = main.querySelector(`#post-${CSS.escape(el.dataset.jump)}`);
  824. if (target) {
  825. target.open = true;
  826. target.scrollIntoView({ behavior: 'smooth', block: 'start' });
  827. }
  828. });
  829. });
  830. // image → lightbox
  831. main.querySelectorAll('img[data-zoom]').forEach(img => {
  832. img.addEventListener('click', (e) => {
  833. e.stopPropagation();
  834. const lb = $('lightbox');
  835. $('lightbox-img').src = img.src;
  836. lb.classList.add('show');
  837. });
  838. });
  839. }
  840. // ---- sidebar ----
  841. function matchesSearch(file, data, q) {
  842. if (!q) return true;
  843. q = q.toLowerCase();
  844. const blob = JSON.stringify(data).toLowerCase();
  845. return blob.includes(q) || file.toLowerCase().includes(q);
  846. }
  847. function renderSidebar() {
  848. const src = SOURCES[currentSource];
  849. const isBatch = currentSource === 'batch_extracted';
  850. const isCapTab = currentSource === 'capability';
  851. const isStratTab = currentSource === 'strategy';
  852. if (!src.files.length) {
  853. sidebar.innerHTML = `<div class="empty">${src.dir}/ 没有 JSON 文件</div>`;
  854. return;
  855. }
  856. const items = src.files.map(file => {
  857. const data = dataCache[`${src.dir}/${file}`];
  858. if (!data) {
  859. return `<div class="sb-item" data-file="${escapeHtml(file)}">
  860. <div class="sb-id">${escapeHtml(file)}</div>
  861. <div class="sb-title meta">加载中…</div>
  862. </div>`;
  863. }
  864. if (!matchesSearch(file, data, searchQuery)) return '';
  865. const ext = data.extraction || {};
  866. const id = isBatch ? (data.group_id ?? file) : (data.index ?? file);
  867. const title = isBatch ? (data.method_label ?? '(无标签)') : (data.title ?? '(无标题)');
  868. const strategies = isBatch ? (ext.strategies || []) : (ext.strategy ? [ext.strategy] : []);
  869. const caps = ext.capabilities || [];
  870. const badges = [];
  871. if (ext.skip) badges.push(`<span class="sb-badge skip">skip</span>`);
  872. if (!isCapTab) badges.push(`<span class="sb-badge">工序 ${strategies.length}</span>`);
  873. if (!isStratTab) badges.push(`<span class="sb-badge">能力 ${caps.length}</span>`);
  874. const isActive = file === currentFile ? 'active' : '';
  875. return `<div class="sb-item ${isActive}" data-file="${escapeHtml(file)}">
  876. <div class="sb-id">${escapeHtml(id)} · ${escapeHtml(file)} ${badges.join('')}</div>
  877. <div class="sb-title">${escapeHtml(title).slice(0, 80)}</div>
  878. </div>`;
  879. }).filter(Boolean).join('');
  880. sidebar.innerHTML = items || `<div class="empty">无匹配项</div>`;
  881. sidebar.querySelectorAll('.sb-item').forEach(el => {
  882. el.addEventListener('click', () => selectFile(el.dataset.file));
  883. });
  884. }
  885. async function selectFile(file) {
  886. currentFile = file;
  887. const dir = SOURCES[currentSource].dir;
  888. try {
  889. const data = await loadFile(dir, file);
  890. renderItem(file, data);
  891. } catch (e) {
  892. main.innerHTML = `<div class="empty">加载失败: ${escapeHtml(e.message)}</div>`;
  893. }
  894. renderSidebar();
  895. }
  896. async function switchSource(source) {
  897. currentSource = source;
  898. currentFile = null;
  899. document.querySelectorAll('.tab').forEach(t => {
  900. t.classList.toggle('active', t.dataset.source === source);
  901. });
  902. main.innerHTML = `<div class="empty">从左侧选择一项查看</div>`;
  903. const src = SOURCES[source];
  904. if (!src.files.length) {
  905. src.files = await discover(src.dir);
  906. }
  907. // preload all (small per-file payloads, makes search work)
  908. await Promise.all(src.files.map(f => loadFile(src.dir, f).catch(() => null)));
  909. renderSidebar();
  910. updateCounts();
  911. if (src.files.length) selectFile(src.files[0]);
  912. }
  913. function updateCounts() {
  914. const fmt = (n) => n ? ` (${n})` : '';
  915. $('cnt-capability').textContent = fmt(SOURCES.capability.files.length);
  916. $('cnt-strategy').textContent = fmt(SOURCES.strategy.files.length);
  917. $('cnt-batch').textContent = fmt(SOURCES.batch_extracted.files.length);
  918. }
  919. // ---- events ----
  920. document.querySelectorAll('.tab').forEach(t => {
  921. t.addEventListener('click', () => switchSource(t.dataset.source));
  922. });
  923. $('search').addEventListener('input', (e) => {
  924. searchQuery = e.target.value.trim();
  925. renderSidebar();
  926. });
  927. $('lightbox').addEventListener('click', () => {
  928. $('lightbox').classList.remove('show');
  929. $('lightbox-img').src = '';
  930. });
  931. document.addEventListener('keydown', (e) => {
  932. if (e.key === 'Escape') {
  933. $('lightbox').classList.remove('show');
  934. $('lightbox-img').src = '';
  935. }
  936. });
  937. // ---- boot ----
  938. (async () => {
  939. await Promise.all([loadResultJson(), loadFieldSpecs()]);
  940. for (const key of Object.keys(SOURCES)) {
  941. SOURCES[key].files = await discover(SOURCES[key].dir);
  942. }
  943. updateCounts();
  944. await switchSource('capability');
  945. })();
  946. </script>
  947. </body>
  948. </html>