Files
AgentLab/backend/app/report_export/runtime.js
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

476 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 报告导出的浏览器端:点开正文里的一个数字 / 名称 / 引文,展示它的出处链(口径、来源、计算方式)。
* 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事:
* ① 填页面头部(报告节点、文档工件、封存状态、运行信息);
* ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板;
* ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
* 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、
* 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。
* 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
(() => {
'use strict'
// ------------------------------------------------------------------ 文案
const T = {
schema: (s) => `文件格式 ${s}`,
stateLabel: {
deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实',
unjudged: '未裁判', candidate: '猜测的来源',
},
kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' },
segTitle: (kind, text) => `${kind}「${text}」`,
metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点',
metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间',
classLabel: { formal: '正式运行', exploratory: '探索运行' },
statusLabel: { succeeded: '已完成', failed: '已失败', cancelled: '已取消', running: '进行中', waiting: '等待审批' },
docHashBad: '这份报告文档与哈希不一致(疑似被修改),正文仅供参考,其中的口径和出处未经核对。',
sealNone: '这次运行导出时尚未封存:下列出处来自导出那一刻的系统记录,未经封存核对。',
sealOk: (hash) => `运行已封存,清单哈希 ${hash}。要向系统外的人证明文件未被修改,请导出「可验证报告」(记录页 · 凭证详情),拿封存哈希去系统对照。`,
sealBroken: '封存核对不一致:事件在封存后被修改、删除或插入,下列出处未经核对。',
noChain: '这一处没有可展示的出处链。',
// 指标步骤
metric: '口径卡指标', dName: '指标', dValue: '值', dCaliber: '口径', dExpression: '原式(计算方式)',
dSubstituted: '代入式(代入输入后的算式)', dInputs: '输入', dRecompute: '复算', dUnit: '单位',
dSource: '来源', dPinned: '口径来源', dUpgrade: '升版处置', dDual: '双盲复算', dArtifact: '工件',
dNode: '来源节点', dStatus: '状态', dDecimals: '小数位',
recomputeOk: '复算一致', recomputeBad: '复算不一致:代入式的计算结果与记录值不符', recomputeNone: '无法复算',
// 输入追没追到原始数据(查询快照里的格、运行输入):和「有出处」(确定性复算一致)是两件事,同证据面板的说法
dOrigin: '原始数据', originTraced: '输入都追到了原始数据(查询快照里的格或运行输入)',
originStop: (path, label) => `${path} 的出处追到「${label}」为止,没追到原始数据`,
originUnknown: (path) => `${path} 无法确定来自哪个节点,没追到原始数据`,
incomplete: '结果不完整', incompleteWhy: (t) => `基于被截断的查询结果计算,结果不完整${t ? `:${t}` : ''}`,
sqlCheckFailed: 'SQL 检查未通过', sqlCheckWhy: (t) => `所依据的查询未通过 SQL 检查,结果不可靠${t ? `:${t}` : ''}`,
missingValue: '缺少输入:口径卡本次未计算出该指标的值',
dualLabel: { agreed: '一致', disagreed: '不一致', unchecked: '无法复算', skipped: '未复算' },
dualMore: (t) => `(${t})`,
// 输入步骤
input: '输入', dPath: '路径', dFrom: '取自', dQuery: '查询', dCell: '位置', dMasked: '已遮罩',
maskNote: '取自设置了遮罩的列,值不随文件显示。遮罩只减少暴露,不是安全边界',
maskUnverified: (t) => `遮没遮核对不了:${t}`,
truncated: '查询结果已截断,该字段只含取回的部分行',
// 查询步骤
query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`,
dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分',
truncatedRows: '查询结果被截断,取回的行不完整',
merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告',
mergeNoLineage: '没有逐格来历',
dRowOf: (n) => `第 ${n + 1} 行`,
// 其余步骤
runInput: '运行输入', dField: '字段', entity: '表或字段', quote: '引文', dNameEntity: '名称',
dTable: '表', dColumn: '列', dFields: '字段', dFieldsMore: (n) => `另 ${n} 个字段`,
dQueries: '出现在哪几次查询', dChecked: '查过的地方', dClosest: '最接近的已知名称',
dContent: '原文', dMatch: '比对', dCollection: '知识库', dWhy: '原因',
present: '在', absent: '不在',
matchOk: '原文一致', matchBad: '与原文不一致', matchNone: '无法比对',
// 完整性(每一步都可带)
dIntegrity: '核对', sealedOk: '已封存 · 哈希核对一致', sealedNo: '不在封存范围内',
hashBad: '工件哈希核对失败', hashNone: '未能读取工件',
note: '说明',
close: '关闭', openAria: (text) => `查看出处:${text}`,
}
const MASKED = '已遮罩'
const NONE = '—'
// ------------------------------------------------------------------ 工具
const $ = (id) => document.getElementById(id)
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const own = (obj, key) => (isObj(obj) && typeof key === 'string' && Object.prototype.hasOwnProperty.call(obj, key)
? obj[key] : undefined)
const textOf = (v) => (typeof v === 'string' ? v : isObj(v) || Array.isArray(v) ? JSON.stringify(v) : String(v))
const short = (id) => (typeof id === 'string' && id.length > 12 ? `${id.slice(0, 12)}…` : String(id ?? ''))
const pad = (n) => String(n).padStart(2, '0')
function h(tag, props, ...kids) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(props || {})) {
if (value == null || value === false) continue
if (key === 'class') el.className = value
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value)
else el.setAttribute(key, value === true ? '' : String(value))
}
for (const kid of kids.flat(Infinity)) {
if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : String(kid))
}
return el
}
/** 时间按读者所在时区显示(文件会被转给别的时区的人),后面注明时区 */
function when(iso) {
if (!iso) return NONE
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
const offset = -d.getTimezoneOffset()
const zone = `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad(Math.abs(offset) % 60)}` : ''}`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}(${zone})`
}
const valueText = (v) => (v == null ? '空值(NULL)' : typeof v === 'string' ? `「${v}」` : textOf(v))
function kv(rows) {
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
}
const badge = (kind, text) => h('span', { class: 'badge', 'data-kind': kind }, text)
const ok = (good) => (good === true ? badge('ok', '✓') : good === false ? badge('bad', '✗') : badge('na', '—'))
// ------------------------------------------------------------------ 数据
const view = { data: null, active: null, back: null }
function parseData() {
try {
const data = JSON.parse($('report-data').textContent)
if (isObj(data) && isObj(data.meta)) return data
} catch (e) {
// 数据块坏了:正文还在(服务端渲染),只是点不开出处
}
return null
}
// ------------------------------------------------------------------ 头部
function renderHead(data) {
const meta = data.meta
$('report-label').textContent = `报告节点「${meta.report?.label || meta.report?.node_id || NONE}」`
$('doc-id').textContent = meta.doc_artifact ? `文档 ${short(meta.doc_artifact)}` : ''
if (meta.doc_hash_ok === false) {
const warn = $('doc-warn')
warn.textContent = T.docHashBad
warn.hidden = false
}
const label = (map, value) => map[value] || (value ? String(value) : NONE)
$('meta').replaceChildren(...[...[
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaClass, label(T.classLabel, meta.run_class)],
[T.metaStatus, label(T.statusLabel, meta.status)],
[T.metaReport, String(meta.report?.label || meta.report?.node_id || NONE)],
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
[T.metaExported, when(meta.exported_at)],
].filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])])
const seal = meta.seal || {}
const box = $('seal-text')
if (!seal.sealed) box.textContent = T.sealNone
else if (seal.ok === false) box.textContent = T.sealBroken
else box.textContent = T.sealOk(String(seal.manifest_hash || ''))
$('foot-schema').textContent = T.schema(String(data.schema || ''))
}
// ------------------------------------------------------------------ 每一步的渲染
const nodeLabel = (id) => {
const node = own(view.data.nodes, id)
return isObj(node) && node.label ? String(node.label) : (id ? String(id) : NONE)
}
/** 指标步骤:名称、值、口径(口径卡、版本、钉住的来源、升版处置)、原式、代入式、输入、复算、双盲复算 */
function metricPart(step) {
const body = []
const rows = [
[T.dName, step.name || step.metric || NONE],
step.unit ? [T.dUnit, String(step.unit)] : null,
step.decimals != null ? [T.dDecimals, String(step.decimals)] : null,
]
body.push(kv(rows))
// 口径:口径卡名 + 版本;钉在别的工作流上的写明来自哪一版
const caliber = [`${step.caliber || NONE}`, step.version ? ` ${step.version}` : ''].join('')
const source = isObj(step.source) ? step.source : null
body.push(kv([
[T.dCaliber, h('span', {}, `口径卡「${caliber}」`)],
source ? [T.dPinned, `工作流「${source.workflow_name || source.workflow_id}」的 ${source.caliber} @ ${source.caliber_version}`] : null,
]))
if (source && isObj(step.caliber_upgrade)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dUpgrade}:钉在 ${step.caliber_upgrade.pinned || NONE},上游最新 ${step.caliber_upgrade.latest || NONE},${step.caliber_upgrade.policy_label || step.caliber_upgrade.policy || NONE}`))
}
if (step.status === 'missing_input') body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.missingValue))
if (step.incomplete) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.incompleteWhy(step.incomplete_reason)))
if (step.sql_check_failed) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.sqlCheckWhy(step.sql_check_reason)))
// 计算方式:原式 + 代入式
body.push(h('p', { class: 'sub' }, T.dExpression), h('pre', { class: 'sql' }, String(step.expression || NONE)))
body.push(h('p', { class: 'sub' }, T.dSubstituted), h('pre', { class: 'sql' }, String(step.substituted || NONE)))
const untraced = Array.isArray(step.untraced) ? step.untraced.filter(isObj) : null
body.push(h('div', {}, kv([
[T.dRecompute, step.recompute_ok == null ? T.recomputeNone
: step.recompute_ok ? T.recomputeOk : T.recomputeBad],
// 卡上没有输入、导出之前的服务端没给时不写
untraced ? [T.dOrigin, untraced.length
? untraced.map((u) => (u.node_id ? T.originStop(String(u.path || NONE), u.label || nodeLabel(u.node_id))
: T.originUnknown(String(u.path || NONE)))).join(';')
: T.originTraced] : null,
])))
// 双盲复算(期 4):复核过这个指标才有
if (isObj(step.dual_check)) {
const d = step.dual_check
const more = d.reason || (d.blind_rendered ? `盲算值 ${d.blind_rendered}` : '')
body.push(h('div', { style: 'margin-top:8px' }, kv([
[T.dDual, `${T.dualLabel[d.status] || d.status || NONE}${more ? T.dualMore(more) : ''}`],
])))
}
return h('section', { class: 'part', 'data-step': 'metric' }, h('h3', {}, T.metric),
h('div', { class: 'part-body' }, ...body))
}
/** 输入步骤:路径、值(遮罩的写「已遮罩」)、取自哪个节点、哪次查询的哪一格 */
function inputPart(step) {
const masked = Array.isArray(step.masked) && step.masked.length
const rows = [
[T.dPath, h('code', {}, String(step.path || NONE))],
[T.dValue, step.value === MASKED ? T.dMasked : valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.cell ? [T.dCell, String(step.cell)] : null,
step.via ? [T.dFrom, step.via === 'tool_cell' ? '查询结果的一格' : step.via === 'agent_field' ? 'Agent 交来的字段'
: step.via === 'position' ? '按位置取的一格' : String(step.via)] : null,
]
const bits = []
if (masked) bits.push(badge('na', `${T.dColumnsMasked}:${step.masked.join('、')}`))
if (step.mask_unverified) bits.push(badge('na', T.maskUnverified(step.mask_unverified)))
if (step.status === 'truncated') bits.push(badge('wait', T.truncated))
return h('section', { class: 'part', 'data-step': 'input' },
h('h3', {}, `${T.input}「${step.path || NONE}」`),
h('div', { class: 'part-body' }, kv(rows),
masked ? h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.maskNote) : null,
bits.length ? h('div', { class: 'chips' }, ...bits) : null))
}
/** 查询步骤:SQL、数据源、被引用的行(高亮那一格)、遮罩列、SQL 检查、合并视图 */
function queryPart(step) {
const body = []
const rows = [
step.source ? [T.dSourceName, String(step.source)] : null,
step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.alias ? [T.dQuery, String(step.alias)] : null,
step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql))
if (step.mask_note) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(step.mask_note)))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
// 被引用的行:前后各带几行,引用的那几格高亮;遮罩列写「已遮罩」
if (Array.isArray(step.rows) && step.rows.length && Array.isArray(step.columns)) {
const offset = Number.isInteger(step.row_offset) ? step.row_offset : 0
const index = Array.isArray(step.row_index) ? step.row_index : step.rows.map((_, i) => i + offset)
const cells = (step.highlight && Array.isArray(step.highlight.cells) ? step.highlight.cells : [])
const hit = new Set(cells.map((c) => `${c[0]}:${c[1]}`))
const hitCols = new Set(cells.map((c) => String(c[1])))
const masked = new Set((step.masked || []).map(String))
const table = h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, step.columns.map((c) => h('th', { class: hitCols.has(String(c)) ? 'hit' : null }, String(c))))),
h('tbody', {}, step.rows.map((row, i) => {
const r = index[i]
const record = Array.isArray(row) ? row : isObj(row) ? step.columns.map((c) => own(row, c)) : [row]
return h('tr', {}, record.map((v, j) => h('td', {
class: [hit.has(`${r}:${step.columns[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : '']
.filter(Boolean).join(' ') || null,
}, masked.has(String(step.columns[j])) ? MASKED : v == null ? NONE : textOf(v))))
}))))
body.push(h('p', { class: 'sub' }, `${T.dRows}(${T.dRowOf(offset)} 起,引用的格高亮)`), table)
if (step.window_truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.windowCut))
if (step.truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.truncatedRows))
}
// SQL 检查(查出问题才有)
if (Array.isArray(step.checks) && step.checks.length) {
body.push(h('p', { class: 'sub' }, T.dChecks),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.checks.map((c) => h('li', {}, `${c.level || 'error'} · ${c.code || ''}:${c.message || ''}`))))
}
// 合并查询:输入、合并 SQL、逐格来历、警告
if (isObj(step.merge)) {
body.push(h('p', { class: 'sub' }, T.merge))
if (Array.isArray(step.merge.inputs) && step.merge.inputs.length) {
body.push(h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.inputs.map((i) => h('li', {},
`${i.query || i.alias || NONE} · ${i.label || nodeLabel(i.node_id)} · ${i.rows ?? '?'} 行${i.sealed === false ? ' · 未封存' : ''}`))))
}
if (Array.isArray(step.merge.traced) && step.merge.traced.length) {
body.push(h('p', { class: 'sub' }, T.mergeTraced),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.traced.map((t) => h('li', {}, t.input
? `第 ${t.cell[0] + 1} 行「${t.cell[1]}」← ${t.query || t.input} 的 ${T.dRowOf(t.row)}「${t.column}」`
: `第 ${t.cell[0] + 1} 行「${t.cell[1]}」:${t.note || T.mergeNoLineage}`))))
}
if (Array.isArray(step.merge.warnings) && step.merge.warnings.length) {
body.push(h('p', { class: 'sub' }, T.mergeWarnings),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.warnings.map((w) => h('li', {}, String(w.message || '')))))
}
}
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'query' },
h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`),
h('div', { class: 'part-body' }, ...body))
}
/** 运行输入步骤 */
function runInputPart(step) {
return h('section', { class: 'part', 'data-step': 'run_input' }, h('h3', {}, T.runInput),
h('div', { class: 'part-body' }, kv([
[T.dField, String(step.field || NONE)],
[T.dValue, valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
])))
}
/** 实体(表、字段)步骤:出现在哪几次查询、表结构快照、字段清单;可疑实体的查证和最接近的名字 */
function entityPart(step) {
const body = []
const rows = [
[T.dNameEntity, step.name || NONE],
step.table ? [T.dTable, String(step.table)] : null,
step.column ? [T.dColumn, String(step.column)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (step.reason) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.reason)))
if (Array.isArray(step.sources) && step.sources.length) {
body.push(h('p', { class: 'sub' }, T.dQueries),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.sources.map((s) => h('li', {},
`${s.kind === 'schema' ? '表结构快照' : '查询结果'} ${s.alias || short(s.artifact)} · ${s.sealed ? '已封存' : '未封存'}${s.hash_ok === false ? ' · 哈希核对失败' : ''}${s.present === false ? ` · 名字${T.absent}` : ''}`))))
}
if (Array.isArray(step.fields) && step.fields.length) {
body.push(h('p', { class: 'sub' }, T.dFields),
h('p', { style: 'margin:4px 0 0;font-size:13px' },
step.fields.map((f) => `${f.name}${f.type ? `(${f.type})` : ''}`).join('、')
+ (step.fields_more ? T.dFieldsMore(step.fields_more) : '')))
}
if (step.checked && (step.checked.schemas || step.checked.queries)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dChecked}:表结构快照 ${step.checked.schemas} 份、查询 ${step.checked.queries} 次,都没有这个名字`))
}
if (Array.isArray(step.closest) && step.closest.length) {
body.push(h('p', { class: 'sub' }, T.dClosest),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.closest.map((c) => h('li', {}, `${c.name}${c.table ? `(表 ${c.table})` : ''}`))))
}
return h('section', { class: 'part', 'data-step': 'entity' }, h('h3', {}, `${T.entity}「${step.name || NONE}」`),
h('div', { class: 'part-body' }, ...body))
}
/** 引文步骤:原话在哪次检索的哪条命中里,带上原文好高亮 */
function quotePart(step) {
const body = []
const rows = [
[T.dContent, String(step.text || NONE)],
step.collection ? [T.dCollection, String(step.collection)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.content === 'string' && step.content) {
const match = isObj(step.match) ? step.match : {}
const start = Number.isInteger(match.start) ? match.start : null
const end = Number.isInteger(match.end) ? match.end : null
const frag = document.createDocumentFragment()
if (start != null && end != null && 0 <= start && start <= end && end <= step.content.length) {
frag.append(step.content.slice(0, start))
frag.append(h('mark', { class: 'hit', style: 'padding:0 1px;border-radius:3px' }, step.content.slice(start, end)))
frag.append(step.content.slice(end))
} else {
frag.append(step.content)
}
body.push(h('p', { class: 'sub' }, T.dContent), h('blockquote', { style: 'margin:4px 0 0' }, frag))
}
body.push(kv([[T.dMatch, step.match_ok === true ? T.matchOk : step.match_ok === false ? T.matchBad : T.matchNone]]))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'quote' }, h('h3', {}, `${T.quote}「${String(step.text || '').slice(0, 24)}…」`),
h('div', { class: 'part-body' }, ...body))
}
const PARTS = {
metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart,
entity: entityPart, quote: quotePart,
}
// ------------------------------------------------------------------ 面板
function detailOf(seg, chain, note) {
const kind = T.kindLabel[seg.dataset.kind] || '片段'
const state = seg.dataset.state || ''
const head = h('section', { class: 'part' }, h('h3', {}, `${kind}「${seg.textContent}」`),
h('div', { class: 'part-body' }, kv([
[T.dStatus, T.stateLabel[state] || state || NONE],
[T.dNode, nodeLabel((view.data.meta.report || {}).node_id)],
])))
const parts = [head]
if (chain && chain.length) {
for (const step of chain) {
const render = PARTS[step.step]
if (render) parts.push(render(step))
}
} else {
parts.push(h('p', { class: 'muted' }, T.noChain))
}
if (note) parts.push(h('section', { class: 'part' }, h('h3', {}, T.note), h('div', { class: 'part-body' }, note)))
return parts
}
function openSeg(seg) {
const panel = $('detail')
if (panel.hidden) view.back = document.activeElement
if (view.active) view.active.classList.remove('is-active')
view.active = seg
seg.classList.add('is-active')
const chain = view.data ? (view.data.chains || {})[(view.data.refs || {})[seg.dataset.seg]] : undefined
const note = view.data ? (view.data.notes || {})[seg.dataset.seg] : undefined
$('detail-title').textContent = `${T.segTitle(T.kindLabel[seg.dataset.kind] || '片段', seg.textContent)}`
$('detail-body').replaceChildren(...detailOf(seg, Array.isArray(chain) ? chain : null, note))
panel.hidden = false
panel.setAttribute('data-open', seg.dataset.seg)
document.documentElement.classList.add('detail-open')
$('detail-close').focus({ preventScroll: true })
const sheet = panel.getBoundingClientRect()
const spot = seg.getBoundingClientRect()
if (sheet.top > 0) {
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
} else {
seg.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
function closeDetail() {
const panel = $('detail')
if (panel.hidden) return
panel.hidden = true
panel.removeAttribute('data-open')
document.documentElement.classList.remove('detail-open')
if (view.active) view.active.classList.remove('is-active')
if (view.back && typeof view.back.focus === 'function') view.back.focus()
view.active = null
}
// ------------------------------------------------------------------ 启动
function main() {
const data = parseData()
if (data) {
view.data = data
renderHead(data)
}
const body = $('report')
body.addEventListener('click', (e) => {
const seg = e.target.closest('button[data-seg]')
if (seg) openSeg(seg)
})
$('detail-close').addEventListener('click', closeDetail)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail() })
document.documentElement.setAttribute('data-js', '')
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main, { once: true })
else main()
})()