render.js 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. function renderJSON(obj) {
  2. if (obj === null) return `<span class="json-null">null</span>`;
  3. if (typeof obj === 'number') return `<span class="json-number">${obj}</span>`;
  4. if (typeof obj === 'boolean') return `<span class="json-boolean">${obj}</span>`;
  5. if (typeof obj === 'string') {
  6. const escaped = obj.replace(/</g, '&lt;').replace(/>/g, '&gt;');
  7. return `<span class="json-string">"${escaped}"</span>`;
  8. }
  9. if (Array.isArray(obj)) {
  10. if (obj.length === 0) return '[]';
  11. let html = '<div class="json-array">[<div class="json-children">';
  12. obj.forEach((val, i) => {
  13. html += `<div class="json-item">${renderJSON(val)}${i < obj.length - 1 ? ',' : ''}</div>`;
  14. });
  15. html += '</div>]</div>';
  16. return html;
  17. }
  18. if (typeof obj === 'object') {
  19. const keys = Object.keys(obj);
  20. if (keys.length === 0) return '{}';
  21. let html = '<div class="json-object">{<div class="json-children">';
  22. keys.forEach((k, i) => {
  23. html += `<div class="json-prop"><span class="json-key">"${k}"</span>: ${renderJSON(obj[k])}${i < keys.length - 1 ? ',' : ''}</div>`;
  24. });
  25. html += '</div>}</div>';
  26. return html;
  27. }
  28. return String(obj);
  29. }
  30. function renderDataOrRaw(dataObj, renderFunc) {
  31. if (!dataObj) return '<p style="color:var(--text-muted)">无可用数据</p>';
  32. let safeRaw = "";
  33. if (dataObj.error) {
  34. safeRaw = dataObj.raw_content ? dataObj.raw_content.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "文件为空。";
  35. return `<div style="padding:1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:8px;">
  36. <h3 style="color:var(--danger); margin-bottom:0.5rem">⚠️ JSON 解析失败</h3>
  37. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  38. </div>`;
  39. } else {
  40. safeRaw = JSON.stringify(dataObj, null, 2).replace(/</g, "&lt;").replace(/>/g, "&gt;");
  41. }
  42. // Check global toggle state
  43. const isRaw = document.getElementById('global-json-toggle') && document.getElementById('global-json-toggle').checked;
  44. return `
  45. <div class="data-view-container">
  46. <div class="data-view-ui" style="${isRaw ? 'display:none;' : ''}">
  47. ${renderFunc(dataObj)}
  48. </div>
  49. <div class="data-view-raw" style="${isRaw ? '' : 'display:none;'}">
  50. <div style="padding:1rem; background:rgba(0, 0, 0, 0.05); border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px;">
  51. <h3 style="color:var(--text-muted); margin-bottom:0.5rem">📝 JSON 原文</h3>
  52. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  53. </div>
  54. </div>
  55. </div>`;
  56. }
  57. function renderRawCases(rawCasesObj) {
  58. if (!rawCasesObj || Object.keys(rawCasesObj).length === 0) return '无原始案例数据';
  59. const reqId = requirements[currentSelectedIndex]?.id || "001";
  60. // Build maps
  61. const sourceMap = {};
  62. const sourceData = rawCasesObj['source'];
  63. const sourceList = sourceData ? (Array.isArray(sourceData) ? sourceData : sourceData.sources) : null;
  64. if (sourceList) {
  65. sourceList.forEach(s => {
  66. const sId = s.case_id || (s._raw && s._raw.case_id);
  67. const sUrl = s.source_url || s.url;
  68. if (sId) sourceMap[sId] = s;
  69. if (sUrl) sourceMap[sUrl] = s;
  70. });
  71. }
  72. const detailMap = {};
  73. const detailMapByUrl = {};
  74. // Cache context for modal
  75. window._currentRawCasesContext = {
  76. rawCasesObj, sourceMap, detailMap, detailMapByUrl, reqId
  77. };
  78. let totalStatsHtml = '';
  79. const detailedCaseObj = rawCasesObj['case'] || rawCasesObj['case_detailed'];
  80. if (detailedCaseObj) {
  81. const cd = detailedCaseObj;
  82. let uniqueCases = new Set();
  83. let calcWorkflow = 0;
  84. let calcCapabilities = 0;
  85. if (cd.cases) {
  86. cd.cases.forEach(c => {
  87. const cId = c.case_id || (c._raw && c._raw.case_id);
  88. const cUrl = c.source_url || c.url;
  89. const uniqueKey = cId || cUrl || Math.random().toString();
  90. uniqueCases.add(uniqueKey);
  91. const workflowGroups = getWorkflowGroups(c);
  92. const capabilityItems = getCapabilityItems(c);
  93. if (workflowGroups.length > 0) calcWorkflow += workflowGroups.length;
  94. if (capabilityItems.length > 0) calcCapabilities += capabilityItems.length;
  95. if (cId) {
  96. if (workflowGroups.length > 0) detailMap[cId] = { ...detailMap[cId], workflow_groups: c.workflow_groups };
  97. }
  98. if (cUrl) {
  99. if (workflowGroups.length > 0) detailMapByUrl[cUrl] = { ...detailMapByUrl[cUrl], workflow_groups: c.workflow_groups };
  100. }
  101. });
  102. }
  103. const displayTotal = uniqueCases.size > 0 ? uniqueCases.size : (cd.total !== undefined ? cd.total : 0);
  104. const displayWorkflowSuccess = calcWorkflow > 0 ? calcWorkflow : (cd.workflow_success !== undefined && cd.workflow_success !== null ? cd.workflow_success : (cd.success !== undefined && cd.success !== null ? cd.success : 0));
  105. const displayCapabilitiesSuccess = calcCapabilities > 0 ? calcCapabilities : (cd.capabilities_success !== undefined && cd.capabilities_success !== null ? cd.capabilities_success : 0);
  106. if (cd.total !== undefined || uniqueCases.size > 0) {
  107. totalStatsHtml = `<div style="display:flex; gap:1rem; margin-bottom:1rem; padding:0.5rem 1rem; background:rgba(0, 0, 0, 0.03); border-radius:8px; align-items:center;">
  108. <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
  109. <span style="font-size:1.3rem; color:var(--text-main); font-weight:bold; line-height:1;">${displayTotal}</span>
  110. <span style="color:var(--text-muted); font-size:0.75rem;">未被过滤的帖子总数</span>
  111. </div>
  112. <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
  113. <span style="font-size:1.3rem; color:var(--success); font-weight:bold; line-height:1;">${displayWorkflowSuccess}</span>
  114. <span style="color:var(--text-muted); font-size:0.75rem;">工序提取成功数</span>
  115. </div>
  116. <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
  117. <span style="font-size:1.3rem; color:var(--accent-primary); font-weight:bold; line-height:1;">${displayCapabilitiesSuccess}</span>
  118. <span style="color:var(--text-muted); font-size:0.75rem;">能力提取成功数</span>
  119. </div>
  120. </div>`;
  121. }
  122. }
  123. let filteredStatsHtml = '';
  124. if (rawCasesObj['filtered_cases']) {
  125. const fObj = rawCasesObj['filtered_cases'];
  126. let totalFiltered = 0;
  127. if (fObj.total !== undefined) totalFiltered = fObj.total;
  128. else if (fObj.cases) totalFiltered = fObj.cases.length;
  129. else if (fObj.sources) totalFiltered = fObj.sources.length;
  130. else if (fObj.by_reason) {
  131. Object.values(fObj.by_reason).forEach(r => {
  132. if (r.sources) totalFiltered += r.sources.length;
  133. else if (r.cases) totalFiltered += r.cases.length;
  134. });
  135. } else if (Array.isArray(fObj)) totalFiltered = fObj.length;
  136. filteredStatsHtml = `<div style="display:flex; gap:1rem; margin-bottom:1rem; padding:0.5rem 1rem; background:rgba(0, 0, 0, 0.03); border-radius:8px; align-items:center;">
  137. <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
  138. <span style="font-size:1.3rem; color:var(--text-main); font-weight:bold; line-height:1;">${totalFiltered}</span>
  139. <span style="color:var(--text-muted); font-size:0.75rem;">被过滤的帖子数</span>
  140. </div>
  141. </div>`;
  142. }
  143. const allPlatforms = Object.keys(rawCasesObj).filter(p => p !== 'source' && p !== 'case_detailed' && p !== 'case' && p !== 'images');
  144. const channelPlatforms = allPlatforms.filter(p => p !== 'filtered_cases' && p !== 'source_ex');
  145. const hasFiltered = allPlatforms.includes('filtered_cases');
  146. const hasExternal = allPlatforms.includes('source_ex');
  147. let html = '';
  148. html += `<div class="sub-tabs">`;
  149. html += `<button class="sub-tab-btn active" onclick="selectSubTab('total')">TOTAL</button>`;
  150. if (hasFiltered) html += `<button class="sub-tab-btn" onclick="selectSubTab('filtered_cases')">FILTERED</button>`;
  151. if (hasExternal) html += `<button class="sub-tab-btn" onclick="selectSubTab('source_ex')">EXTERNAL</button>`;
  152. html += `<button class="sub-tab-btn" id="btn-upload-source-ex" style="background-color: var(--primary); margin-left: auto;">+ 上传 source_ex</button>`;
  153. html += `<input type="file" id="input-upload-source-ex" accept=".json" style="display:none;">`;
  154. html += `</div><div class="sub-tab-contents">`;
  155. const renderPaneContent = (pList, paneType) => {
  156. let paneHtml = '';
  157. if (paneType === 'total' && typeof totalStatsHtml !== 'undefined' && totalStatsHtml) {
  158. paneHtml += totalStatsHtml;
  159. } else if (paneType === 'filtered_cases' && typeof filteredStatsHtml !== 'undefined' && filteredStatsHtml) {
  160. paneHtml += filteredStatsHtml;
  161. }
  162. let totalCases = 0;
  163. let seenIds = new Set();
  164. let groupedHtml = {};
  165. const getGroupKey = (c, p) => (p === 'filtered_cases' && c.filter_reason) ? `🚫 过滤原因: ${c.filter_reason}` : 'default';
  166. let allCases = [];
  167. pList.forEach(p => {
  168. if (!rawCasesObj[p]) return;
  169. if (rawCasesObj[p].error) {
  170. const safeRaw = rawCasesObj[p].raw_content ? rawCasesObj[p].raw_content.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "文件为空。";
  171. paneHtml += `<div style="padding:1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:8px; margin-bottom:1rem;">
  172. <h3 style="color:var(--danger); margin-bottom:0.5rem">⚠️ ${p} JSON 解析失败</h3>
  173. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  174. </div>`;
  175. return;
  176. }
  177. if (rawCasesObj[p].reason && p !== 'filtered_cases') {
  178. paneHtml += `<div style="padding:0.5rem 1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:6px; margin-bottom:1rem; color:var(--danger); font-size:0.9rem;">🛑 ${p} 提示: ${rawCasesObj[p].reason}</div>`;
  179. }
  180. let cases = [];
  181. if (Array.isArray(rawCasesObj[p])) {
  182. cases = rawCasesObj[p];
  183. } else if (rawCasesObj[p].cases) {
  184. cases = rawCasesObj[p].cases;
  185. } else if (rawCasesObj[p].sources) {
  186. cases = rawCasesObj[p].sources;
  187. } else if (rawCasesObj[p].by_reason) {
  188. Object.entries(rawCasesObj[p].by_reason).forEach(([reasonKey, reasonObj]) => {
  189. if (reasonObj.sources && Array.isArray(reasonObj.sources)) {
  190. reasonObj.sources.forEach(src => {
  191. if (!src.filter_reason) src.filter_reason = reasonKey;
  192. cases.push(src);
  193. });
  194. }
  195. });
  196. }
  197. if (cases.length > 0) {
  198. if (!rawCasesObj['source'] && p !== 'source_ex' && p !== 'filtered_cases' && p !== 'source') {
  199. paneHtml += `<div style="padding:1rem; background:rgba(0, 0, 0, 0.05); border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px; margin-bottom:1rem;">
  200. <h3 style="color:var(--text-main); margin-bottom:0.5rem">📝 ${p} 原始爬取数据 (未进行 1.5 数据源提取)</h3>
  201. <div style="font-family: monospace; font-size: 0.85rem; overflow-x: auto">${renderJSON(rawCasesObj[p])}</div>
  202. </div>`;
  203. return;
  204. }
  205. cases.forEach((c, idx) => {
  206. allCases.push({ c: c, p: p, originalIdx: idx });
  207. });
  208. }
  209. });
  210. allCases.sort((aObj, bObj) => {
  211. const getScore = (item) => {
  212. const iId = item.case_id || (item._raw && item._raw.case_id) || (item.post && item.post.channel_content_id);
  213. const iUrl = item.source_url || item.url || (item.post && item.post.link);
  214. const mapped = sourceMap[iId] || sourceMap[iUrl] || (item._raw && sourceMap[item._raw.case_id]) || item;
  215. return mapped.evaluation && mapped.evaluation.quality ? (mapped.evaluation.quality.overall_score || 0) : 0;
  216. };
  217. return getScore(bObj.c) - getScore(aObj.c);
  218. });
  219. allCases.forEach(itemObj => {
  220. const c = itemObj.c;
  221. const p = itemObj.p;
  222. const idx = itemObj.originalIdx;
  223. totalCases++;
  224. const cId = c.case_id || (c._raw && c._raw.case_id) || (c.post && c.post.channel_content_id) || `temp_${p}_${idx}`;
  225. const cUrl = c.source_url || c.url || (c.post && c.post.link) || '';
  226. const groupKey = getGroupKey(c, p);
  227. if (!groupedHtml[groupKey]) groupedHtml[groupKey] = '';
  228. if (cId || cUrl || c.post) {
  229. const mappedS = sourceMap[cId] || sourceMap[cUrl] || (c._raw && sourceMap[c._raw.case_id]);
  230. if (p !== 'filtered_cases' && p !== 'source' && p !== 'source_ex' && !mappedS) return;
  231. if (cId && seenIds.has(cId)) return;
  232. if (cId) seenIds.add(cId);
  233. const s = mappedS || c;
  234. const post = s.post || s || {};
  235. const images = post.images || [];
  236. const xImages = post.image_url_list || [];
  237. const ytThumbnails = post.thumbnails && post.thumbnails.length > 0 ? [post.thumbnails[post.thumbnails.length - 1].url] : [];
  238. const allImages = [...images, ...xImages.map(img => img.image_url), ...ytThumbnails].filter(Boolean);
  239. const coverImgUrl = allImages.length > 0 ? `/output/${reqId}/raw_cases/images/${cId}/00.jpg` : '';
  240. const fallbackImgUrl = allImages.length > 0 ? allImages[0] : '';
  241. const title = post.title || c.title || post.desc || (post.body_text ? post.body_text.substring(0, 30) + '...' : '') || cId || '无标题';
  242. const author = post.channel_account_name || s.author || '-';
  243. const likes = post.like_count !== undefined ? post.like_count : (post.likes !== undefined ? post.likes : '-');
  244. const snippetStr = (post.body_text || post.body || '').substring(0, 100);
  245. const snippetHtml = snippetStr ? `<div class="masonry-card-snippet">${snippetStr.replace(/</g, "&lt;").replace(/>/g, "&gt;")}...</div>` : '';
  246. const platBadge = p.startsWith('case_') ? `<span class="tag" style="position:absolute; top:8px; left:8px; background:rgba(0,0,0,0.6); color:#fff; padding:2px 6px; border-radius:4px; font-size:0.7em; z-index:2; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.2);">${p.replace('case_', '').toUpperCase()}</span>` : '';
  247. const score = s.evaluation && s.evaluation.quality ? s.evaluation.quality.overall_score : null;
  248. let scoreBadge = '';
  249. if (score !== null && score !== undefined) {
  250. let color = score >= 80 ? '#10b981' : (score >= 60 ? '#f59e0b' : '#ef4444');
  251. scoreBadge = `<div style="position:absolute; top:8px; right:8px; background:rgba(0,0,0,0.7); color:${color}; font-weight:bold; padding:2px 8px; border-radius:12px; font-size:0.8em; z-index:2; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.2);">⭐️ ${score}</div>`;
  252. }
  253. let actionBtn = '';
  254. if (p === 'source_ex') {
  255. const isImported = !!sourceMap[cId] || !!sourceMap[cUrl];
  256. if (isImported) {
  257. actionBtn = `<button disabled style="position:absolute; top:8px; right:8px; background:#10b981; color:#fff; border:none; padding:4px 8px; border-radius:4px; font-size:0.7em; z-index:3; cursor:not-allowed; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">✅ 已导入</button>`;
  258. } else {
  259. actionBtn = `<button onclick="importExternalCase(event, '${cId}')" style="position:absolute; top:8px; right:8px; background:var(--accent-primary); color:#fff; border:none; padding:4px 8px; border-radius:4px; font-size:0.7em; z-index:3; cursor:pointer; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">📥 导入 Source</button>`;
  260. }
  261. }
  262. groupedHtml[groupKey] += `<div class="masonry-card" style="position:relative;" onclick="openCaseDetail('${p}', ${idx})">
  263. ${platBadge}
  264. ${scoreBadge}
  265. ${actionBtn}
  266. ${allImages.length > 0 ? `<img class="cover-img" src="${coverImgUrl}" onerror="this.onerror=null; this.src='${fallbackImgUrl}';">` : ''}
  267. <div class="masonry-card-info">
  268. <div class="masonry-card-title">${title}</div>
  269. ${allImages.length === 0 ? snippetHtml : ''}
  270. <div class="masonry-card-stats">
  271. <div class="masonry-card-author">👤 ${author}</div>
  272. <div class="masonry-card-likes">❤️ ${likes}</div>
  273. </div>
  274. </div>
  275. </div>`;
  276. } else {
  277. groupedHtml[groupKey] += `<div class="masonry-card" style="padding:12px; font-family:monospace; font-size:0.8em;" onclick="openCaseDetail('${p}', ${idx})">
  278. 📝 旧版格式 / 解析失败<br>点击查看详情
  279. </div>`;
  280. }
  281. });
  282. if (totalCases === 0 && pList.length > 0 && !paneHtml.includes('解析失败') && !paneHtml.includes('未进行')) {
  283. paneHtml += `<div style="padding:1rem; color:var(--text-muted); text-align:center;">暂无数据</div>`;
  284. } else {
  285. Object.entries(groupedHtml).forEach(([groupName, gHtml]) => {
  286. if (gHtml) {
  287. if (groupName !== 'default') {
  288. paneHtml += `<h3 style="margin-top: 1rem; margin-bottom: 0.5rem; font-size: 1.1rem; color: var(--text-main); border-left: 4px solid var(--accent-primary); padding-left: 8px;">${groupName}</h3>`;
  289. }
  290. paneHtml += `<div class="masonry-grid">${gHtml}</div>`;
  291. }
  292. });
  293. }
  294. return paneHtml;
  295. };
  296. html += `<div id="sub-tab-total" class="sub-tab-pane">${renderPaneContent([...channelPlatforms, 'source'], 'total')}</div>`;
  297. if (hasFiltered) html += `<div id="sub-tab-filtered_cases" class="sub-tab-pane hidden">${renderPaneContent(['filtered_cases'], 'filtered_cases')}</div>`;
  298. if (hasExternal) {
  299. html += `<div id="sub-tab-source_ex" class="sub-tab-pane hidden">`;
  300. html += `<div style="margin-bottom: 1rem; text-align: right;"><button onclick="importAllExternalCases()" class="btn btn-primary btn-small">📥 全部导入 Source</button></div>`;
  301. html += renderPaneContent(['source_ex'], 'source_ex');
  302. html += `</div>`;
  303. }
  304. html += `</div>`;
  305. return html;
  306. }
  307. window.importExternalCase = async function (e, caseId) {
  308. e.stopPropagation();
  309. if (!confirm('确定要将该外部数据导入到 source 吗?')) return;
  310. try {
  311. const res = await fetch(`/api/requirements/${currentSelectedIndex}/import_source_ex`, {
  312. method: 'POST',
  313. headers: { 'Content-Type': 'application/json' },
  314. body: JSON.stringify({ case_id: caseId })
  315. });
  316. const data = await res.json();
  317. if (data.status === 'success') {
  318. fetchRequirementData(currentSelectedIndex); // Refresh data
  319. } else {
  320. alert('导入失败: ' + (data.detail || data.message || JSON.stringify(data)));
  321. }
  322. } catch (err) {
  323. alert('导入出错: ' + err);
  324. }
  325. };
  326. window.importAllExternalCases = async function () {
  327. if (!confirm('确定要将所有外部数据全部导入到 source 吗?')) return;
  328. try {
  329. const res = await fetch(`/api/requirements/${currentSelectedIndex}/import_all_source_ex`, {
  330. method: 'POST'
  331. });
  332. const data = await res.json();
  333. if (data.status === 'success') {
  334. alert(`成功导入 ${data.count} 条数据!`);
  335. fetchRequirementData(currentSelectedIndex); // Refresh data
  336. } else {
  337. alert('全部导入失败: ' + (data.detail || data.message || JSON.stringify(data)));
  338. }
  339. } catch (err) {
  340. alert('导入出错: ' + err);
  341. }
  342. };
  343. function selectSubTab(tabName) {
  344. document.querySelectorAll('#json-raw .sub-tab-btn').forEach(btn => {
  345. btn.classList.remove('active');
  346. });
  347. const activeBtn = document.querySelector(`#json-raw .sub-tab-btn[data-target="sub-tab-${tabName}"]`);
  348. if (activeBtn) activeBtn.classList.add('active');
  349. document.querySelectorAll('#json-raw .sub-tab-pane').forEach(pane => {
  350. pane.classList.add('hidden');
  351. });
  352. document.getElementById(`sub-tab-${tabName}`).classList.remove('hidden');
  353. }
  354. window.deleteClusterItems = async function (indices) {
  355. if (currentSelectedIndex === null) return;
  356. const data = window.dataCache[currentSelectedIndex]?.cluster;
  357. if (!data) return;
  358. let newData = Array.isArray(data) ? [...data] : { ...data };
  359. let count = 0;
  360. // Sort indices descending to avoid shifting issues if it's an array
  361. indices.sort((a, b) => b - a);
  362. if (Array.isArray(newData)) {
  363. indices.forEach(i => { newData.splice(i, 1); count++; });
  364. } else {
  365. const keys = Object.keys(newData);
  366. indices.forEach(i => { delete newData[keys[i]]; count++; });
  367. }
  368. if (!confirm(`确定要删除选中的 ${count} 个项目吗?`)) return;
  369. try {
  370. const res = await fetch(`/api/requirements/${currentSelectedIndex}/save_cluster`, {
  371. method: 'POST',
  372. body: JSON.stringify(newData),
  373. headers: { 'Content-Type': 'application/json' }
  374. });
  375. if (res.ok) {
  376. fetchRequirementData(currentSelectedIndex);
  377. } else {
  378. alert('删除失败');
  379. }
  380. } catch (e) {
  381. console.error(e);
  382. alert('删除失败');
  383. }
  384. };
  385. window.deleteSingleCluster = function (idx) {
  386. deleteClusterItems([idx]);
  387. };
  388. window.deleteSelectedClusters = function () {
  389. const checkboxes = document.querySelectorAll('.cluster-checkbox:checked');
  390. const indices = Array.from(checkboxes).map(cb => parseInt(cb.dataset.idx));
  391. if (indices.length === 0) {
  392. alert('请先选择要删除的项目');
  393. return;
  394. }
  395. deleteClusterItems(indices);
  396. };
  397. window.clearAllClusters = async function () {
  398. if (currentSelectedIndex === null) return;
  399. if (!confirm('确定要清空全部聚类结果吗?')) return;
  400. try {
  401. const res = await fetch(`/api/requirements/${currentSelectedIndex}/save_cluster`, {
  402. method: 'POST',
  403. body: JSON.stringify(Array.isArray(window.dataCache[currentSelectedIndex]?.cluster) ? [] : {}),
  404. headers: { 'Content-Type': 'application/json' }
  405. });
  406. if (res.ok) {
  407. fetchRequirementData(currentSelectedIndex);
  408. } else {
  409. alert('清空失败');
  410. }
  411. } catch (e) {
  412. console.error(e);
  413. alert('清空失败');
  414. }
  415. };
  416. function renderClusterDeletable(clusterData) {
  417. if (!clusterData || (Array.isArray(clusterData) && clusterData.length === 0) || (typeof clusterData === 'object' && Object.keys(clusterData).length === 0)) {
  418. return `<div style="color:var(--text-muted); padding:2rem; text-align:center;">暂无聚类结果数据,请导入 JSON 文件</div>`;
  419. }
  420. let html = `<div style="margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
  421. <button class="btn btn-danger btn-small" onclick="deleteSelectedClusters()" style="background-color: var(--danger); border-color: var(--danger); color: white;">🗑️ 删除选中项</button>
  422. <button class="btn btn-secondary btn-small" onclick="clearAllClusters()">🧹 清空全部</button>
  423. </div>`;
  424. html += `<div style="display:flex; flex-direction:column; gap:1rem;">`;
  425. const items = Array.isArray(clusterData) ? clusterData : Object.entries(clusterData).map(([k, v]) => ({ key: k, value: v }));
  426. items.forEach((item, idx) => {
  427. const displayData = Array.isArray(clusterData) ? item : item.value;
  428. const displayKey = Array.isArray(clusterData) ? `导入记录 #${idx + 1}` : `Key: ${item.key}`;
  429. html += `<div style="background: rgba(0,0,0,0.02); border: 1px solid var(--border-glass); border-radius: 8px; padding: 1rem;">
  430. <div style="display:flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; border-bottom: 1px solid rgba(0,0,0,0.05); padding-bottom: 0.5rem;">
  431. <label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-weight:bold; color:var(--text-main);">
  432. <input type="checkbox" class="cluster-checkbox" data-idx="${idx}" style="cursor:pointer; width:16px; height:16px;">
  433. ${displayKey}
  434. </label>
  435. <button class="btn btn-secondary btn-small" onclick="deleteSingleCluster(${idx})" style="color:var(--danger); border-color:rgba(239, 68, 68, 0.2); background:rgba(239, 68, 68, 0.05);">❌ 删除此项</button>
  436. </div>
  437. <div style="background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 0.85rem; overflow-x: auto; max-height: 400px;">
  438. ${renderJSON(displayData)}
  439. </div>
  440. </div>`;
  441. });
  442. html += `</div>`;
  443. return html;
  444. }
  445. window.selectGenericSubTab = function (prefix, targetId) {
  446. const parentContainer = document.getElementById(`container-${prefix}`);
  447. if (!parentContainer) return;
  448. parentContainer.querySelectorAll('.sub-tab-btn').forEach(btn => {
  449. btn.classList.remove('active');
  450. });
  451. const activeBtn = parentContainer.querySelector(`[data-target="${targetId}"]`);
  452. if (activeBtn) activeBtn.classList.add('active');
  453. parentContainer.querySelectorAll('.sub-tab-pane').forEach(pane => {
  454. pane.classList.add('hidden');
  455. });
  456. const activePane = document.getElementById(targetId);
  457. if (activePane) activePane.classList.remove('hidden');
  458. };
  459. function renderWithSubTabs(dataMain, dataTemp, renderFn, tabPrefix) {
  460. if (!dataTemp || Object.keys(dataTemp).length === 0) {
  461. return renderDataOrRaw(dataMain, renderFn);
  462. }
  463. const mainHtml = renderDataOrRaw(dataMain, renderFn);
  464. const tempHtml = renderDataOrRaw(dataTemp, renderFn);
  465. return `<div id="container-${tabPrefix}">
  466. <div class="sub-tabs">
  467. <button class="sub-tab-btn active" data-target="sub-tab-${tabPrefix}-main" onclick="selectGenericSubTab('${tabPrefix}', 'sub-tab-${tabPrefix}-main')">最终版本</button>
  468. <button class="sub-tab-btn" data-target="sub-tab-${tabPrefix}-temp" onclick="selectGenericSubTab('${tabPrefix}', 'sub-tab-${tabPrefix}-temp')">中间过程 (Temp)</button>
  469. </div>
  470. <div class="sub-tab-contents">
  471. <div id="sub-tab-${tabPrefix}-main" class="sub-tab-pane">
  472. ${mainHtml}
  473. </div>
  474. <div id="sub-tab-${tabPrefix}-temp" class="sub-tab-pane hidden">
  475. ${tempHtml}
  476. </div>
  477. </div>
  478. </div>`;
  479. }
  480. function renderCaseTags(caseRefs) {
  481. if (!caseRefs || caseRefs.length === 0) return '';
  482. let html = `<div class="tags-container" style="gap:0.8rem; flex-wrap:wrap; margin-top: 0.5rem;">`;
  483. caseRefs.forEach(ref => {
  484. let caseId = null;
  485. let title = ref;
  486. const matchNew = ref.match(/^([a-z]+)_([^\s::]+)(?:[::\s]+(.*))?/);
  487. if (matchNew && matchNew[1] !== 'case') {
  488. caseId = `${matchNew[1]}_${matchNew[2]}`;
  489. title = matchNew[3] || ref;
  490. } else {
  491. const matchA = ref.match(/^case_([a-z]+)_([a-zA-Z0-9]+)(?:[::\s]+(.*))?/);
  492. if (matchA) {
  493. caseId = `${matchA[1]}-case_${matchA[2]}`;
  494. title = matchA[3] || ref;
  495. } else {
  496. const matchB = ref.match(/^([a-z]+)[\/\s](case_[a-zA-Z0-9]+)(?:[::\s]+(.*))?/);
  497. if (matchB) {
  498. caseId = `${matchB[1]}-${matchB[2]}`;
  499. title = matchB[3] || ref;
  500. } else {
  501. const matchC = ref.match(/^case_([a-zA-Z0-9]+)_([a-z]+)(?:[::\s]+(.*))?/);
  502. if (matchC) {
  503. caseId = `${matchC[2]}-case_${matchC[1]}`;
  504. title = matchC[3] || ref;
  505. }
  506. }
  507. }
  508. }
  509. if (caseId) {
  510. html += `<a href="#" onclick="jumpToCase('${caseId}'); return false;" class="badge-emoji primary" style="text-decoration:none; white-space:normal; text-align:left; line-height:1.4">
  511. <strong>🔍 ${caseId.replace('-', ' ')}</strong><br>
  512. <span style="font-size:0.75rem">${title.substring(0, 40) + (title.length > 40 ? '...' : '')}</span>
  513. </a>`;
  514. } else {
  515. html += `<span class="badge-emoji" style="white-space:normal; text-align:left; line-height:1.4; font-size:0.75rem">${ref}</span>`;
  516. }
  517. });
  518. html += `</div>`;
  519. return html;
  520. }
  521. function renderCapabilities(capsObj) {
  522. if (!capsObj || (!capsObj.extracted_capabilities && !capsObj.capabilities)) {
  523. return `<div class="data-card" style="border-color: var(--warning)"><div class="card-header"><div class="card-title">⚠️ 未知或非标准格式 (Capabilities)</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(capsObj)}</div></div>`;
  524. }
  525. const caps = capsObj.capabilities || capsObj.extracted_capabilities;
  526. if (caps.length === 0) return '<p>未提取到能力。</p>';
  527. let html = ``;
  528. caps.forEach(cap => {
  529. if (!cap.name && !cap.能力名称 && !cap.description && !cap.能力描述 && !cap.id) {
  530. html += `<div class="data-card"><div class="card-header"><div class="card-title">⚠️ 非标准能力项</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(cap)}</div></div>`;
  531. return;
  532. }
  533. const isNew = cap.is_new ? '<span class="badge-emoji success">✨ 新能力</span>' : '';
  534. html += `<div class="data-card">
  535. <div class="card-header">
  536. <div class="card-title">⚡ [${cap.id || 'N/A'}] ${cap.name || cap.能力名称 || '未命名'}</div>
  537. ${isNew}
  538. </div>
  539. <div class="card-body">
  540. <p>${cap.description || cap.能力描述 || ''}</p>`;
  541. if (cap.enriched_details) {
  542. const ed = cap.enriched_details;
  543. if (ed.execution_process) {
  544. html += `<div class="card-section"><div class="section-title">🚀 执行流程</div><p style="white-space:pre-wrap;">${ed.execution_process}</p></div>`;
  545. }
  546. if (ed.core_parameters) {
  547. html += `<div class="card-section"><div class="section-title">⚙️ 核心参数</div><p style="white-space:pre-wrap;">${ed.core_parameters}</p></div>`;
  548. }
  549. if (ed.effects) {
  550. html += `<div class="card-section"><div class="section-title">✨ 影响效果</div><p style="white-space:pre-wrap;">${ed.effects}</p></div>`;
  551. }
  552. if (ed.visual_notes) {
  553. html += `<div class="card-section"><div class="section-title">🖼️ 视觉备注</div><p style="white-space:pre-wrap;">${ed.visual_notes}</p></div>`;
  554. }
  555. } else {
  556. html += `<div class="card-section"><div class="section-title">✨ 影响 (Effects)</div><ul>`;
  557. if (cap.effects) cap.effects.forEach(eff => html += `<li>${eff}</li>`);
  558. html += `</ul></div>`;
  559. if (cap.implements && Object.keys(cap.implements).length > 0) {
  560. html += `<div class="card-section"><div class="section-title">🛠️ 实现工具 (Tools)</div><div class="tags-container">`;
  561. for (const [tool, args] of Object.entries(cap.implements)) {
  562. html += `<span class="badge-emoji primary" title="${args}">🔧 ${tool}</span>`;
  563. }
  564. html += `</div></div>`;
  565. }
  566. }
  567. if (cap.case_references && cap.case_references.length > 0) {
  568. html += `<div class="card-section"><div class="section-title">📌 来源案例</div>${renderCaseTags(cap.case_references)}</div>`;
  569. }
  570. html += `</div></div>`;
  571. });
  572. return html;
  573. }
  574. function renderFragmentsGrid(fragments) {
  575. if (!fragments || fragments.length === 0) return '<p>没有片段数据。</p>';
  576. window.allFragmentsMap = {}; // Reset map
  577. let html = `<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; padding: 10px;">`;
  578. fragments.forEach(f => {
  579. let cid = f.capability_id || ('temp_cap_' + Math.random().toString(36).substring(7));
  580. window.allFragmentsMap[cid] = f;
  581. let tools = f.tools && f.tools.length > 0 ? f.tools.join(', ') : '无工具';
  582. let actionDesc = f.action && f.action.description ? f.action.description : (f.capability_id || 'unknown');
  583. let bodyText = f.body || f.body_excerpt || f.rationale || '';
  584. if (bodyText.length > 180) bodyText = bodyText.substring(0, 180) + '...';
  585. let inputs = f.inputs ? f.inputs.map(i => i.modality || '').filter(Boolean).join('+') : '';
  586. let outputs = f.outputs ? f.outputs.map(o => o.modality || '').filter(Boolean).join('+') : '';
  587. let ioStr = (inputs && outputs) ? `${inputs} -> ${outputs}` : (f.action && f.action.description ? f.action.description : '转换');
  588. let applyHtml = '';
  589. if (f.apply_to) {
  590. Object.values(f.apply_to).forEach(arr => {
  591. arr.forEach(item => {
  592. const parts = item.category_path ? item.category_path.split('/') : [];
  593. const label = parts.length > 0 ? parts[parts.length - 1] : '标签';
  594. applyHtml += `<span class="frag-apply-pill">${label}</span>`;
  595. });
  596. });
  597. }
  598. const caseShort = f._caseId ? f._caseId.split('-').pop() : 'case';
  599. html += `<div class="frag" onclick="openFragDetail('${cid}')" style="cursor:pointer;">
  600. <div class="frag-head">
  601. <span class="case-badge" onclick="jumpToCase('${f._caseId}'); event.stopPropagation();" style="cursor:pointer" title="点击跳转案例">${caseShort}</span>
  602. <span class="frag-badge">${f._workflowId || 'w'}</span>
  603. <span style="margin-left: auto; font-weight: 600; color: var(--accent-primary, #3b82f6);">${ioStr}</span>
  604. </div>
  605. <div class="frag-sig">${actionDesc} [${tools}]</div>
  606. <div class="frag-body">${bodyText}</div>
  607. ${applyHtml ? `<div class="frag-apply">${applyHtml}</div>` : ''}
  608. </div>`;
  609. });
  610. html += `</div>`;
  611. return html;
  612. }
  613. window.openFragDetail = function(fragId) {
  614. const f = window.allFragmentsMap[fragId];
  615. if (!f) return;
  616. const modal = document.getElementById('frag-detail-modal');
  617. const body = document.getElementById('frag-detail-modal-body');
  618. let html = `<div style="background:#fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">`;
  619. let inputs = f.inputs ? f.inputs.map(i => i.modality || '').filter(Boolean).join('+') : '';
  620. let outputs = f.outputs ? f.outputs.map(o => o.modality || '').filter(Boolean).join('+') : '';
  621. let ioStr = (inputs && outputs) ? `${inputs} → ${outputs}` : (f.action && f.action.description ? f.action.description : '转换');
  622. html += `<div style="display:flex; gap: 8px; margin-bottom: 16px;">
  623. <span class="case-badge">${f._caseId ? f._caseId.split('-').pop() : 'case'}</span>
  624. <span class="frag-badge">${f._workflowId || 'w'}</span>
  625. <span class="badge-emoji warning">${f.capability_id || 'unknown'}</span>
  626. </div>`;
  627. html += `<h4 style="margin: 0 0 10px 0; color: #334155;">I/O 模态</h4>`;
  628. html += `<div style="font-size: 14px; margin-bottom: 8px;"><strong>${ioStr}</strong></div>`;
  629. if (f.inputs && f.inputs.length > 0) {
  630. html += `<div style="font-size: 13px; color: #64748b; margin-bottom: 4px;">IN <span style="margin-left: 8px;">${f.inputs.map(i => `<span style="background: #e0e7ff; color: #3730a3; padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${i.description || '输入'} [${i.modality || '未知'}]</span>`).join('')}</span></div>`;
  631. }
  632. if (f.outputs && f.outputs.length > 0) {
  633. html += `<div style="font-size: 13px; color: #64748b; margin-bottom: 16px;">OUT <span style="margin-left: 8px;">${f.outputs.map(o => `<span style="background: #fce7f3; color: #9d174d; padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${o.description || '输出'} [${o.modality || '未知'}]</span>`).join('')}</span></div>`;
  634. }
  635. if (f.body || f.body_excerpt || f.rationale) {
  636. html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">BODY</h4>`;
  637. html += `<div style="background: #f8fafc; padding: 12px; border-left: 4px solid #cbd5e1; border-radius: 4px; font-size: 14px; color: #475569; line-height: 1.6; white-space: pre-wrap;">${f.body || f.body_excerpt || f.rationale}</div>`;
  638. }
  639. if (f.apply_to) {
  640. html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">APPLY TO</h4>`;
  641. Object.keys(f.apply_to).forEach(k => {
  642. f.apply_to[k].forEach(item => {
  643. html += `<div style="margin-bottom: 12px;">
  644. <div style="font-size: 13px; font-weight: 600; color: #2563eb; margin-bottom: 4px;">${item.category_path || k}</div>
  645. <div style="font-size: 13px; color: #64748b; padding-left: 10px; border-left: 2px solid #e2e8f0;">${item.rationale || item.body_excerpt || '无推理说明'}</div>
  646. </div>`;
  647. });
  648. });
  649. }
  650. if (f.suggest_apply_to && f.suggest_apply_to.length > 0) {
  651. html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">SUGGEST APPLY TO <span style="font-size: 12px; font-weight: normal; color: #8b5cf6;">(建议新增节点)</span></h4>`;
  652. f.suggest_apply_to.forEach(item => {
  653. html += `<div style="margin-bottom: 12px;">
  654. <div style="font-size: 13px; font-weight: 600; color: #8b5cf6; margin-bottom: 4px;">${item.path || '新节点'}</div>
  655. <div style="font-size: 13px; color: #64748b; padding-left: 10px; border-left: 2px solid #e2e8f0;">${item.rationale || item.body_excerpt || '无推理说明'}</div>
  656. </div>`;
  657. });
  658. }
  659. if (f.effects && f.effects.length > 0) {
  660. html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">EFFECTS</h4>`;
  661. f.effects.forEach((eff, i) => {
  662. html += `<div style="background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 8px;">
  663. <div style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">#${i} ${eff.statement || 'Effect'}</div>
  664. <div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>判定标准:</strong> ${eff.criteria || '无'}</div>
  665. <div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>判定方式:</strong> ${eff.judge_method || '未知'}</div>`;
  666. if (eff.negative_examples && eff.negative_examples.length > 0) {
  667. html += `<div style="font-size: 13px; color: #475569; margin-top: 8px;"><strong>反例:</strong><ul style="margin: 4px 0 0 20px;">${eff.negative_examples.map(ex => `<li>${ex}</li>`).join('')}</ul></div>`;
  668. }
  669. html += `</div>`;
  670. });
  671. }
  672. html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">其他</h4>`;
  673. let tools = f.tools && f.tools.length > 0 ? f.tools.join(', ') : '无';
  674. html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>tools:</strong> ${tools}</div>`;
  675. if (f.artifact_type) html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>artifact_type:</strong> ${f.artifact_type}</div>`;
  676. if (f.control_target) html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>control_target:</strong> ${Array.isArray(f.control_target) ? f.control_target.join(', ') : f.control_target}</div>`;
  677. html += `</div>`;
  678. body.innerHTML = html;
  679. modal.classList.remove('hidden');
  680. };
  681. function renderBlueprint(bpObj) {
  682. if (!bpObj || (!bpObj.blueprints && !bpObj.clusters)) {
  683. return `<div class="data-card" style="border-color: var(--warning)"><div class="card-header"><div class="card-title">⚠️ 未知或非标准格式 (Blueprint)</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(bpObj)}</div></div>`;
  684. }
  685. let html = ``;
  686. // New process.json format
  687. if (bpObj.clusters) {
  688. html += `<div style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;">
  689. <h3 style="color:var(--text-main); margin-bottom:0.8rem">🎯 需求</h3>
  690. <p style="color:var(--text-muted)">${bpObj.requirement || ''}</p>
  691. </div>`;
  692. bpObj.clusters.forEach(c => {
  693. if (!c.cluster_id && !c.cluster_name && !c.工序步骤) {
  694. html += `<div class="data-card"><div class="card-header"><div class="card-title">⚠️ 非标准聚类项</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(c)}</div></div>`;
  695. return;
  696. }
  697. const score = c.score || 0;
  698. const deg = score * 360;
  699. const scoreClass = score >= 0.8 ? '' : (score >= 0.5 ? 'medium' : 'low');
  700. html += `<div class="data-card">
  701. <div class="card-header">
  702. <div class="card-title">🧩 [${c.cluster_id || 'N/A'}] ${c.cluster_name || '未命名'}</div>
  703. </div>
  704. <div class="card-body">
  705. <div class="score-container" style="margin-bottom:1rem">
  706. <div class="score-circle ${scoreClass}" style="--score-deg: ${deg}deg"><span>${Math.round(score * 100)}%</span></div>
  707. <div class="score-text"><strong>匹配度得分</strong></div>
  708. </div>
  709. <div class="card-section"><div class="section-title">🧠 解释 (Explanation)</div>
  710. <p>${c.explanation || ''}</p></div>
  711. <div class="card-section"><div class="section-title">📍 工序步骤</div><div class="phase-list">`;
  712. if (c.工序步骤) c.工序步骤.forEach(step => {
  713. html += `<div class="phase-item">
  714. <div class="phase-title">步骤 ${step.步骤序号}</div>
  715. <div>${step.步骤描述 || ''}</div>
  716. </div>`;
  717. });
  718. html += `</div></div>`;
  719. if (c.关联案例 && c.关联案例.length > 0) {
  720. html += `<div class="card-section"><div class="section-title">📌 关联案例</div>${renderCaseTags(c.关联案例)}</div>`;
  721. }
  722. html += `</div></div>`;
  723. });
  724. return html;
  725. }
  726. // Old blueprint format
  727. if (bpObj.distilled_cases && bpObj.distilled_cases.length > 0) {
  728. html += `<div style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;">
  729. <h3 style="color:var(--text-main); margin-bottom:0.8rem">📚 蓝图来源案例</h3>
  730. <div class="tags-container" style="gap:0.8rem">`;
  731. bpObj.distilled_cases.forEach(c => {
  732. let targetId = c.id;
  733. const matchA = targetId.match(/^case_([a-z]+)_(\d+)/);
  734. if (matchA) {
  735. targetId = `${matchA[1]}-case_${matchA[2]}`;
  736. } else {
  737. const matchB = targetId.match(/^([a-z]+)[\/\s](case_\d+)/);
  738. if (matchB) {
  739. targetId = `${matchB[1]}-${matchB[2]}`;
  740. } else {
  741. const matchC = targetId.match(/^case_(\d+)_([a-z]+)/);
  742. if (matchC) targetId = `${matchC[2]}-case_${matchC[1]}`;
  743. }
  744. }
  745. html += `<a href="#" onclick="jumpToCase('${targetId}'); return false;" class="badge-emoji primary" style="text-decoration:none; white-space:normal; text-align:left; line-height:1.4">
  746. <strong>🔍 ${c.id}</strong><br>
  747. <span style="font-size:0.75rem">${c.title ? c.title.substring(0, 40) + (c.title.length > 40 ? '...' : '') : 'View Source'}</span>
  748. </a>`;
  749. });
  750. html += `</div></div>`;
  751. }
  752. if (bpObj.blueprints) bpObj.blueprints.forEach(bp => {
  753. if (!bp.name && !bp.phases) {
  754. html += `<div class="data-card"><div class="card-header"><div class="card-title">⚠️ 非标准蓝图项</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(bp)}</div></div>`;
  755. return;
  756. }
  757. html += `<div class="data-card">
  758. <div class="card-header">
  759. <div class="card-title">🗺️ ${bp.name || '未命名'}</div>
  760. </div>
  761. <div class="card-body">
  762. <div class="card-section"><div class="section-title">🧠 推理逻辑 (Reasoning)</div>
  763. <p>${bp.reasoning || ''}</p></div>
  764. <div class="card-section"><div class="section-title">📍 阶段 (Phases)</div><div class="phase-list">`;
  765. if (bp.phases) bp.phases.forEach(ph => {
  766. html += `<div class="phase-item">
  767. <div class="phase-title">${ph.phase || ''}</div>
  768. <div>${ph.description || ''}</div>
  769. </div>`;
  770. });
  771. html += `</div></div></div>`;
  772. });
  773. return html;
  774. }
  775. function renderStrategy(stratObj) {
  776. if (!stratObj || (!stratObj.strategies && !stratObj.workflow)) {
  777. return `<div class="data-card" style="border-color: var(--warning)"><div class="card-header"><div class="card-title">⚠️ 未知或非标准格式 (Strategy)</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(stratObj)}</div></div>`;
  778. }
  779. let html = `<div style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;">
  780. <h3 style="color:var(--text-main); margin-bottom:0.5rem">🎯 需求描述</h3>
  781. <p style="color:var(--text-muted)">${stratObj.requirement || ''}</p>
  782. </div>`;
  783. // New Workflow Format
  784. if (stratObj.workflow) {
  785. stratObj.workflow.forEach(strat => {
  786. if (!strat.cluster_id && !strat.cluster_name && !strat.steps) {
  787. html += `<div class="data-card"><div class="card-header"><div class="card-title">⚠️ 非标准工作流项</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(strat)}</div></div>`;
  788. return;
  789. }
  790. const score = strat.score || 0;
  791. const deg = score * 360;
  792. const scoreClass = score >= 0.8 ? '' : (score >= 0.5 ? 'medium' : 'low');
  793. let scoreHtml = `<div class="score-container">
  794. <div class="score-circle ${scoreClass}" style="--score-deg: ${deg}deg"><span>${Math.round(score * 100)}%</span></div>
  795. <div class="score-text"><strong>匹配得分</strong><br>${strat.explanation || ''}</div>
  796. </div>`;
  797. html += `<div class="data-card">
  798. <div class="card-header">
  799. <div class="card-title">🎯 [${strat.cluster_id}] ${strat.cluster_name || '未命名'}</div>
  800. </div>
  801. <div class="card-body">
  802. ${scoreHtml}
  803. ${(strat.关联案例 && strat.关联案例.length > 0) ? `<div class="card-section" style="margin-bottom: 1rem;"><div class="section-title">📌 关联案例</div>${renderCaseTags(strat.关联案例)}</div>` : ''}
  804. <div class="card-section"><div class="section-title">🧱 工作流步骤</div><div class="phase-list">`;
  805. if (strat.steps) strat.steps.forEach(step => {
  806. html += `<div class="phase-item">
  807. <div class="phase-title">步骤 ${step.步骤序号}</div>
  808. <div style="margin-bottom:0.5rem">${step.步骤描述}</div>
  809. <div class="tags-container">`;
  810. if (step.capabilities) {
  811. step.capabilities.forEach(cap => {
  812. html += `<div style="margin-top: 0.5rem; padding: 0.5rem; background: rgba(0, 0, 0, 0.05); border-radius: 4px; width: 100%;">
  813. <div style="font-weight: bold; margin-bottom: 0.3rem;">⚡ [${cap.id}] ${cap.name}</div>
  814. <div style="font-size: 0.8rem; color: var(--text-muted);">${cap.description}</div>
  815. ${(cap.case_references && cap.case_references.length > 0) ? `<div style="margin-top: 0.5rem;">${renderCaseTags(cap.case_references)}</div>` : ''}
  816. </div>`;
  817. });
  818. }
  819. html += `</div></div>`;
  820. });
  821. html += `</div></div></div></div>`;
  822. });
  823. return html;
  824. }
  825. if (stratObj.strategies) {
  826. stratObj.strategies.sort((a, b) => (b.is_selected === true) - (a.is_selected === true));
  827. stratObj.strategies.forEach(strat => {
  828. if (!strat.name && !strat.workflow_outline) {
  829. html += `<div class="data-card"><div class="card-header"><div class="card-title">⚠️ 非标准策略项</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(strat)}</div></div>`;
  830. return;
  831. }
  832. const isSelected = strat.is_selected;
  833. const icon = isSelected ? '🎯' : '🥈';
  834. const badge = isSelected ? '<span class="badge-emoji success">⭐ 被选中的策略</span>' : '<span class="badge-emoji warning">备选策略</span>';
  835. let scoreHtml = '';
  836. if (strat.coverage_score !== undefined) {
  837. const score = strat.coverage_score;
  838. const deg = score * 360;
  839. const scoreClass = score >= 0.8 ? '' : (score >= 0.5 ? 'medium' : 'low');
  840. scoreHtml = `<div class="score-container">
  841. <div class="score-circle ${scoreClass}" style="--score-deg: ${deg}deg"><span>${Math.round(score * 100)}%</span></div>
  842. <div class="score-text"><strong>覆盖率得分</strong><br>${strat.coverage_explanation || ''}</div>
  843. </div>`;
  844. }
  845. html += `<div class="data-card" style="${isSelected ? 'border-color: var(--accent-primary); box-shadow: 0 0 15px rgba(59,130,246,0.1);' : ''}">
  846. <div class="card-header">
  847. <div class="card-title">${icon} ${strat.name || '未命名'}</div>
  848. ${badge}
  849. </div>
  850. <div class="card-body">
  851. ${scoreHtml}
  852. <div class="tags-container" style="margin-bottom:1rem">
  853. <span class="badge-emoji">📥 来源: ${strat.source || 'N/A'}</span>
  854. </div>`;
  855. if (strat.reasoning) html += `<div class="card-section"><div class="section-title">🧠 推理逻辑 (Reasoning)</div><p>${strat.reasoning}</p></div>`;
  856. if (strat.why_not) html += `<div class="card-section"><div class="section-title">❌ 为何未被选中</div><p>${strat.why_not}</p></div>`;
  857. if (strat.workflow_outline && strat.workflow_outline.length > 0) {
  858. html += `<div class="card-section"><div class="section-title">🧱 工作流大纲</div><div class="phase-list">`;
  859. strat.workflow_outline.forEach(wo => {
  860. html += `<div class="phase-item">
  861. <div class="phase-title">${wo.phase}</div>
  862. <div style="margin-bottom:0.5rem">${wo.description}</div>
  863. <div class="tags-container">`;
  864. if (wo.capabilities) {
  865. wo.capabilities.forEach(cap => html += `<span class="badge-emoji primary">⚡ ${cap.name}</span>`);
  866. }
  867. html += `</div></div>`;
  868. });
  869. html += `</div></div>`;
  870. }
  871. html += `</div></div>`;
  872. });
  873. }
  874. if (stratObj.uncovered_requirements && stratObj.uncovered_requirements.length > 0) {
  875. html += `<div class="data-card" style="border-color: var(--danger)">
  876. <div class="card-header"><div class="card-title">⚠️ Uncovered Requirements</div></div>
  877. <div class="card-body"><ul>`;
  878. stratObj.uncovered_requirements.forEach(req => html += `<li>${req}</li>`);
  879. html += `</ul></div></div>`;
  880. }
  881. return html;
  882. }