/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断) * * 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。 * 面板 DOM 挂在 ctx.pointPanelEl(unmount 由控制器调用 closePointPanel 清理)。 */ import { fetchPointDetailApi } from '../../api.js'; import { showToast } from '../../components/toast.js'; import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js'; import { escapeHtml, pointStatusBadge, pointMethodBadge, fmtRelc, fmtDur, fmtDateTime, fmtFullTs, } from '../../utils/format.js'; import { isAbortError } from '../../utils/errors.js'; import { ICONS } from '../../utils/icons.js'; function buildPanelShell(ctx, name) { const backdrop = document.createElement('div'); backdrop.className = 'point-panel-backdrop'; backdrop.innerHTML = ` `; backdrop.addEventListener('click', (e) => { if (e.target === backdrop) { closePointPanel(ctx); return; } if (e.target.closest('.point-panel-close')) { closePointPanel(ctx); return; } if (e.target.closest('[data-panel-copy]')) { navigator.clipboard?.writeText(name).then( () => showToast('点名已复制', 'success'), () => showToast('复制失败,请手动复制', 'info') ); return; } // 种子来源点名跳查:换载面板内容到该点 const seedLink = e.target.closest('[data-panel-point]'); if (seedLink) { e.preventDefault(); openPointPanel(ctx, seedLink.getAttribute('data-panel-point')); } }); document.body.appendChild(backdrop); setupFocusTrap(backdrop.querySelector('.point-panel')); return backdrop; } export function closePointPanel(ctx) { if (!ctx.pointPanelEl) return; const panel = ctx.pointPanelEl.querySelector('.point-panel'); if (panel) releaseFocusTrap(panel); ctx.pointPanelEl.remove(); ctx.pointPanelEl = null; } export async function openPointPanel(ctx, pointName) { if (!ctx.name) return; closePointPanel(ctx); ctx.pointPanelEl = buildPanelShell(ctx, pointName); const body = ctx.pointPanelEl.querySelector('#point-panel-body'); const badges = ctx.pointPanelEl.querySelector('#point-panel-badges'); try { const res = await fetchPointDetailApi(ctx.name, pointName, { signal: ctx.abortCtl?.signal }); if (res.status === 404) { body.innerHTML = '

该点不存在(可能已被删除)

'; return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (!json.success || !json.data) throw new Error(json.message || '无数据'); const { point, attempts, conv } = json.data; badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(point.success_method)}`; body.innerHTML = ''; body.appendChild(buildAttemptSection(attempts)); body.appendChild(buildConvSection(conv)); } catch (err) { if (isAbortError(err)) return; body.innerHTML = `

加载失败:${escapeHtml(err.message)}(请关闭面板后重试,或检查网络)

`; } } function buildAttemptSection(attempts) { const sec = document.createElement('section'); sec.className = 'point-section'; const list = attempts || []; const rows = list.map((a, i) => { const ok = a.status === 'completed'; const seedHtml = a.seed_point_name ? `${escapeHtml(a.seed_point_name)}` : '—'; const resultHtml = ok ? '成功' : `${escapeHtml(a.status)}`; return ` ${i + 1} ${pointMethodBadge(a.task_type)} ${seedHtml} ${fmtRelc(a.max_relc)} ${resultHtml} ${escapeHtml(a.node_id || '—')} ${fmtDateTime(a.completed_at)} ${fmtDur(a.elapsed_sec)} `; }).join('') || '尚无尝试记录(点未被派发)'; sec.innerHTML = `

尝试历史(${list.length})

${rows}
#方法种子来源max_relc结果节点完成时间耗时
`; // 最近一次失败尝试的错误文本:textContent 渲染,杜绝 XSS const lastErr = [...list].reverse().find(a => a.error_message); if (lastErr) { const pre = document.createElement('pre'); pre.className = 'code-pre-wrap point-error'; pre.textContent = `最近错误:${lastErr.error_message}`; sec.appendChild(pre); } return sec; } function buildConvSection(conv) { const sec = document.createElement('section'); sec.className = 'point-section'; if (!conv) { sec.innerHTML = `

阶段链诊断

诊断文件不可用(conv.json 缺失或解析失败)

`; return sec; } const stagesHtml = (conv.stages || []).map(st => { // 阶段状态图标用 SVG(与全局图标族一致),不用 emoji——emoji 跨平台渲染不一、 // 无法由 CSS 控色,在暗色模式下尤其违和(no-emoji-icons)。 const icon = st.converged ? ICONS.checkCircle({ size: 14, cls: 'stage-icon stage-icon-ok' }) : ICONS.warn({ size: 14, cls: 'stage-icon stage-icon-warn' }); const relc = st.best_max_relc != null ? Number(st.best_max_relc).toExponential(2) : '—'; const iter = st.last_iter != null ? ` · ${st.last_iter} iter` : ''; const depth = st.worst_depth != null ? ` · 最差深度 ${st.worst_depth}/${st.n_depths ?? '?'}` : ''; const secs = st.elapsed_sec != null ? ` · ${Math.round(st.elapsed_sec)}s` : ''; return `
${icon} ${escapeHtml(st.label)} relc ${relc} · chmax ${st.chmax ?? '—'}${iter}${depth}${secs}
`; }).join('') || '

无阶段数据

'; const seedTxt = conv.seed ? escapeHtml(String(conv.seed).split('/').pop()) : '无(冷启动)'; // 半失败可见性(审查 #2 后续):大气已收敛但光谱失败时,converged 仍为 true 而 // synspec_rc/synspec_error 非空——必须把这两个量展示出来,否则半失败点无从排查。 const synspecRc = conv.synspec_rc != null ? `${conv.synspec_rc}` : '—'; const synspecErrHtml = conv.synspec_error ? `${escapeHtml(conv.synspec_error)}` : '—'; sec.innerHTML = `

阶段链诊断

${stagesHtml}
最终 max_relc
${conv.final_max_relc != null ? Number(conv.final_max_relc).toExponential(3) : '—'}
收敛阈值 chmax
${conv.final_chmax ?? '—'}
种子
${seedTxt}
总耗时
${conv.elapsed_sec != null ? `${Math.round(conv.elapsed_sec)}s` : '—'}
synspec 耗时
${conv.synspec_sec != null ? `${Math.round(conv.synspec_sec)}s` : '—'}
synspec 返回码
${synspecRc}
synspec 错误
${synspecErrHtml}
大气 NaN
${conv.atmosphere_has_nan ? '' : '否'}
`; return sec; }