選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

195 行
22 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. // Evidence is owned by the actual UI/API test runs. Missing evidence stays pending.
  5. // This script only generates the two report documents and never calls a business API.
  6. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905')
  7. fs.mkdirSync(reportDir, { recursive: true })
  8. const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
  9. const sensitive = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
  10. const cleanText = value => String(value ?? '')
  11. .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
  12. .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
  13. .replace(/\b((?:access|refresh)?[_-]?token|password|passwd|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
  14. .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
  15. function sanitize(value) {
  16. if (typeof value === 'string') return cleanText(value)
  17. if (Array.isArray(value)) return value.map(sanitize)
  18. if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
  19. .filter(([key]) => !sensitive.test(key)).map(([key, item]) => [key, sanitize(item)]))
  20. return value
  21. }
  22. const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
  23. const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
  24. const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  25. const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
  26. const state = value => /^(PASS|PASSED)$/i.test(String(value)) ? 'PASS'
  27. : /^(FAIL|FAILED)$/i.test(String(value)) ? 'FAIL'
  28. : /^(BLOCKED|LIMITED)$/i.test(String(value)) ? 'BLOCKED'
  29. : /^PASS_WITH_LIMITATIONS$/i.test(String(value)) ? 'PASS_WITH_LIMITATIONS' : 'PENDING'
  30. const label = value => ({ PASS: '通过', FAIL: '失败', BLOCKED: '受限(需功能接线)', PASS_WITH_LIMITATIONS: '部分链路已验证,页面闭环受限', PENDING: '待补充证据' })[state(value)]
  31. const badge = value => `<span class="badge ${state(value).toLowerCase()}">${label(value)}</span>`
  32. const details = (value, title = '查看脱敏详细记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>`
  33. const bullets = items => `<ul>${items.map(item => `<li>${html(pretty(item))}</li>`).join('')}</ul>`
  34. const mdBullets = items => items.map(item => `- ${md(pretty(item))}`).join('\n')
  35. const sources = [
  36. { file: 'evidence.json', title: '浏览器编排、重新打开及教学入口实测', kind: 'UI' },
  37. { file: 'api-evidence.json', title: '正式教学 API:人工事件与红蓝岗位运行', kind: 'API' },
  38. ].map(source => {
  39. const absolute = path.join(reportDir, source.file)
  40. const data = fs.existsSync(absolute) ? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null
  41. const checks = array(data?.checks || data?.results || data?.tests).filter(item => item && !item.historical && !item.superseded)
  42. const status = checks.some(check => state(check.status) === 'FAIL') ? 'FAIL'
  43. : data?.status ? state(data.status) === 'PASS' && checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED' : state(data.status)
  44. : checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED'
  45. : checks.length && checks.every(check => state(check.status) === 'PASS') ? 'PASS' : 'PENDING'
  46. return { ...source, data, checks, status }
  47. })
  48. const ui = sources[0].data || {}
  49. const api = sources[1].data || {}
  50. const projects = array(ui.projects || ui.trainingProjects || ui.retainedProjects)
  51. const versionMismatches = Object.entries(api.references || {}).flatMap(([channel, reference]) => {
  52. if (!reference) return []
  53. const project = projects.find(item => String(item.id || item.projectId) === String(reference.projectId))
  54. if (!projects.length) return []
  55. const expected = project?.publishedVersionId || project?.finalVersionId
  56. if (project?.apiTestVersionId && project?.apiVersionNote && String(project.apiTestVersionId) === String(reference.versionId)) return []
  57. return expected && String(expected) === String(reference.versionId) ? [] : [{ channel, projectId: reference.projectId, uiPublishedVersionId: expected || '未记录', apiVersionId: reference.versionId }]
  58. })
  59. const checks = sources.flatMap(source => source.checks)
  60. const counts = { passed: checks.filter(check => state(check.status) === 'PASS').length, blocked: checks.filter(check => state(check.status) === 'BLOCKED').length, failed: checks.filter(check => state(check.status) === 'FAIL').length, pending: checks.filter(check => state(check.status) === 'PENDING').length }
  61. const overall = sources.some(source => source.status === 'FAIL') || counts.failed ? 'FAIL'
  62. : sources.some(source => source.status === 'PENDING') || counts.pending || versionMismatches.length ? 'PENDING'
  63. : counts.blocked || sources.some(source => ['BLOCKED', 'PASS_WITH_LIMITATIONS'].includes(source.status)) ? 'PASS_WITH_LIMITATIONS' : 'PASS'
  64. const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations)).map(item => [pretty(item), item])).values()]
  65. const followups = array(ui.confirmationItems || ui.followUpIssues || ui.featureConfirmations)
  66. const commands = sources.flatMap(source => array(source.data?.commands))
  67. const imagePattern = /^screenshots\/.+\.(?:png|jpe?g|webp)$/i
  68. function imageRefs(value, inheritedCaption = '') {
  69. if (typeof value === 'string') return imagePattern.test(value.replaceAll('\\', '/')) ? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : []
  70. if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption))
  71. if (!value || typeof value !== 'object') return []
  72. const caption = value.caption || value.title || value.label || value.name || inheritedCaption
  73. return Object.values(value).flatMap(item => imageRefs(item, caption))
  74. }
  75. const images = [...new Map(sources.flatMap(source => imageRefs(source.data)).map(item => [item.path, item])).values()]
  76. for (const image of images) {
  77. const absolute = path.resolve(reportDir, image.path)
  78. const relative = path.relative(reportDir, absolute)
  79. if (path.isAbsolute(image.path) || relative.startsWith('..') || path.isAbsolute(relative) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) throw new Error(`截图不存在或路径非法:${image.path}`)
  80. const real = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute))
  81. if (real.startsWith('..') || path.isAbsolute(real)) throw new Error(`截图越出目录:${image.path}`)
  82. }
  83. const imageUrl = value => value.split('/').map(encodeURIComponent).join('/')
  84. const figure = image => `<figure><a href="${html(imageUrl(image.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(image.path))}" alt="${html(image.caption || image.path)}" loading="lazy"></a><figcaption>${html(image.caption || image.path)} · 点击查看原图</figcaption></figure>`
  85. const mdFigure = image => `[![${md(image.caption || image.path)}](${imageUrl(image.path)})](${imageUrl(image.path)})`
  86. const stages = [
  87. { title: '1 · 资料', items: [
  88. '实际查看《维修训练内容设计.pdf》第 1–3、5–6 页扫描件。第 1–2 页将虚拟、混合现实、AI 指导下的实装组织为递进训练,流程包含构建场景、编排任务、组班下发、学员训练与数据报告。',
  89. '本次使用狐狸与牛奶卡车测试素材验证软件链路,不将其视为原文装备维修内容或真实维修能力考核。原文已查看页面没有单列红蓝对抗规则;对抗依据现有教学实施、渠道合同、命令幂等及步骤事实文档验证。',
  90. '合同来源:unreal_tran_api/wiki/14-教学实施模块.md、19-训练定义渠道合同.md、20-教学命令幂等.md、21-教学步骤运行事实.md。',
  91. ] },
  92. { title: '2 · 产品原型', items: [
  93. '沿用现有训练编排工作台与模式配置表单,分别建立实装和对抗内容;保存、发布后重新打开检查步骤、分值、封面与模式参数。',
  94. '实装默认运行入口为离线识别回放;对抗默认入口为本地工作台。分别记录界面实际行为,再通过正式 API 验证服务端教学闭环。',
  95. ] },
  96. { title: '3 · 设计', items: [
  97. '实装配置三步人工确认,分值 30 / 30 / 40;完成进度按步骤推导为 33 / 67 / 100。验证失败重试、顺序约束、事件身份及重复事件处理。',
  98. '对抗配置红蓝两队、每队两人,步骤和提交均由 COMMANDER 执行;OPERATOR 用于岗位拒绝测试。验证同队共享运行、跨队隔离、服务器时限、提交与教员评定。',
  99. '任务固定引用已发布训练版本;只创建本次开发测试数据。人工确认和客户端模拟参数均明确标识为测试,不连接真实工位、PLC、摄像头或视觉服务。',
  100. ] },
  101. { title: '4 · 开发', items: array(ui.development || ui.fixes).length ? array(ui.development || ui.fixes) : [
  102. '本轮业务修复与验证结果等待浏览器 evidence.json;本报告生成器不会修改业务数据或将待实施功能标为已完成。',
  103. ] },
  104. ]
  105. const scope = [
  106. '本报告区分编排 UI、默认教学 UI 和正式教学 API 三类证据;API 成功不能证明默认 UI 已连接该服务端运行。',
  107. '实装的设备或视觉事件链路未接真实设备验证;人工确认测试不能替代实机联调、安全联锁或实际操作技能考核。',
  108. '对抗的默认本地工作台与正式服务端任务分别留证;无证据时不声称多人跨浏览器实时协作或正式比赛胜负已联调。',
  109. '正式 API 对抗由四个隔离学员会话依次操作;检查服务端截止时间已生效,未等待整局自然超时,不作为多人并发或网络同步压力测试。',
  110. '100 分评定仅验证接口和结果留痕,不代表学员实际维修能力。测试不会向他人发送消息或下发真实设备指令。',
  111. ]
  112. const modeRows = [
  113. ['实装实训', 'PHYSICAL', '正式 API 的 MANUAL 人工事件', '三步顺序、失败重试、来源权限、事件幂等、提交与评定', '默认离线识别页;设备 / 视觉接入另行联调'],
  114. ['对抗训练', 'CONFRONTATION', '正式 API 的红蓝两队共享运行', '岗位权限、两队隔离、顺序、时限、命令幂等、提交与评定', '默认本地工作台;不混同正式 API 运行'],
  115. ]
  116. function tableHtml(headers, rows) { return `<div class="table-scroll"><table><thead><tr>${headers.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${html(recorded(value))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>` }
  117. function tableMd(headers, rows) { return `| ${headers.map(md).join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(value => md(recorded(value))).join(' | ')} |`).join('\n')}` }
  118. const modeHeaders = ['模式', '正式渠道', '本轮 API 验证方法', '必检项', '默认界面边界']
  119. const projectHeaders = ['训练 ID', '名称', '模式', '状态', '当前发布版本', 'API 已测版本', '步骤数']
  120. const projectRows = projects.map(project => [project.id || project.projectId, project.name || project.title, project.trainingMode || project.mode || project.channel, project.finalStatus || project.status, project.publishedVersionId || project.finalVersionId, Object.values(api.references || {}).find(reference => reference && String(reference.projectId) === String(project.id || project.projectId))?.versionId, project.stepCount ?? project.steps?.length])
  121. const runHeaders = ['任务 ID', '运行 ID', '渠道', '队伍', '状态', '完成度', '评定分']
  122. const runRows = array(api.runs).map(run => [run.assignmentId, run.id, run.channel, run.team, run.status, run.progressPercent, run.score])
  123. function sourceHtml(source) {
  124. if (!source.data) return `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)},未计为通过。</p></article>`
  125. return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3><p class="muted">证据类型:${source.kind} · ${html(source.file)}</p>${source.checks.map(check => `<div class="check"><h4>${html(check.title || check.name || '检查')} ${badge(check.status)}</h4>${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>`
  126. }
  127. function sourceMd(source) {
  128. return `### ${md(source.data?.title || source.title)} · ${label(source.status)}\n\n证据类型:${source.kind}。\n\n${source.data ? source.checks.map(check => `- ${md(check.title || check.name || '检查')}:${label(check.status)}`).join('\n') : `等待 ${source.file},未计为通过。`}\n\n${source.data ? `<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>` : ''}`
  129. }
  130. const summary = `证据组 ${sources.filter(source => source.status !== 'PENDING').length}/${sources.length} 已完成;检查通过 ${counts.passed},受限 ${counts.blocked},失败 ${counts.failed},待验证 ${counts.pending};已校验截图 ${images.length} 张。`
  131. const conclusion = overall === 'PASS_WITH_LIMITATIONS' ? '编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。' : ''
  132. const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
  133. const outputHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>实装与对抗训练实测报告 · 2026-09-05</title><style>
  134. :root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending,.blocked,.pass_with_limitations{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px;min-width:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd;min-width:0}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;min-width:90px}th{background:#eef6f7}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}p,li,figcaption,summary{overflow-wrap:anywhere}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}.gallery{grid-template-columns:1fr}}
  135. h1,h2,h3,h4{overflow-wrap:anywhere}.check{min-width:0}
  136. </style></head><body><main><header><h1>实装与对抗训练实测报告 ${badge(overall)}</h1><p>编排 UI · 默认教学入口 · 正式教学 API</p>${conclusion ? `<p>${html(conclusion)}</p>` : ''}<p>2026-09-05 · ${html(ui.baseURL || api.baseURL || 'http://127.0.0.1:6180')}</p><p>${html(summary)}</p></header><nav><a href="#method">资料与实施</a><a href="#data">保留数据</a><a href="#tests">测试证据</a><a href="#images">截图</a><a href="#scope">范围与待确认项</a><a href="report.md">Markdown 报告</a></nav>
  137. ${overall === 'PENDING' ? '<p class="note">报告草稿:最终证据尚不完整,不以配置完成或单独 API 成功替代界面全流程验证。</p>' : ''}
  138. <section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div><h3 style="margin-top:24px">两种模式的验证边界</h3>${tableHtml(modeHeaders, modeRows)}</section>
  139. <section id="data"><h2>保留开发测试数据</h2>${projects.length ? tableHtml(projectHeaders, projectRows) + projects.map(project => `${project.apiVersionNote ? `<p class="note">训练 ${html(project.id || project.projectId)}:${html(project.apiVersionNote)}</p>` : ''}${details(project, `训练 ${project.id || project.projectId} · 发布与配置记录`)}`).join('') : '<p class="note">等待 UI 最终项目记录。</p>'}<h3>正式教学任务与运行</h3>${runRows.length ? tableHtml(runHeaders, runRows) : '<p class="note">等待 API 运行结果。</p>'}${api.retainedData ? details(api.retainedData, '本次新增数据 ID') : ''}<p class="muted">随机测试密码与登录会话不写入报告;保留新建数据用于追溯。</p></section>
  140. <section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p>${versionMismatches.length ? `<p class="note">UI 与 API 证据引用的发布版本尚未一致,不能标记完整通过。</p>${details(versionMismatches)}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>测试命令与构建</h3>${commands.map(command => `<div class="check"><h4>${html(command.command || command.name)} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('')}` : ''}</section>
  141. <section id="images"><h2>真实浏览器截图</h2><p class="muted">截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。</p>${images.length ? `<div class="gallery">${images.map(figure).join('')}</div>` : '<p class="note">等待 evidence.json 引用实际截图。</p>'}</section>
  142. <section id="scope"><h2>范围、限制与待确认项</h2>${bullets(scope)}${limitations.length ? `<h3>实测边界与限制</h3>${bullets(limitations)}` : ''}<h3>需要另行确认的功能范围</h3>${followups.length ? bullets(followups) : '<p>本栏等待最终证据中的功能范围结论;默认教学 UI 与正式运行的接线现状需按实际结果评估,不自动扩展实现。</p>'}</section><footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · evidence.json / api-evidence.json · 图片经本地文件校验</footer></main></body></html>`
  143. const outputMd = `# 实装与对抗训练实测报告 · 2026-09-05
  144. 状态:**${label(overall)}**。${summary}
  145. ${conclusion}
  146. 范围:编排 UI、默认教学入口、正式教学 API 分开验证。
  147. ${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')}
  148. ## 两种模式的验证边界
  149. ${tableMd(modeHeaders, modeRows)}
  150. ## 保留开发测试数据
  151. ${projects.length ? tableMd(projectHeaders, projectRows) : '等待最终 UI 项目记录。'}
  152. ${projects.filter(project => project.apiVersionNote).map(project => `> 训练 ${md(project.id || project.projectId)}:${md(project.apiVersionNote)}`).join('\n\n')}
  153. ### 正式教学任务与运行
  154. ${runRows.length ? tableMd(runHeaders, runRows) : '等待 API 运行结果。'}
  155. 随机测试密码与登录会话不写入报告;保留新建数据用于追溯。
  156. ## 5 · 测试
  157. ${versionMismatches.length ? `> UI 与 API 发布版本不一致,未计为完整通过。\n\n<pre>${html(pretty(versionMismatches))}</pre>\n` : ''}
  158. ${sources.map(sourceMd).join('\n\n')}
  159. ${commands.length ? `### 测试命令与构建\n\n${commands.map(command => `- ${md(command.command || command.name)}:${label(command.status)}。${md(command.details || command.result || '')}`).join('\n')}` : ''}
  160. ## 真实浏览器截图
  161. ${images.length ? images.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图。'}
  162. ## 范围、限制与待确认项
  163. ${mdBullets(scope)}
  164. ${limitations.length ? `### 实测边界与限制\n\n${mdBullets(limitations)}\n` : ''}
  165. ### 需要另行确认的功能范围
  166. ${followups.length ? mdBullets(followups) : '等待最终证据中的功能范围结论;默认教学 UI 与正式运行接线现状按实际结果评估,不自动扩展实现。'}
  167. 报告生成:${generatedAt}(Asia/Shanghai)。
  168. `
  169. fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
  170. fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
  171. console.log(JSON.stringify({ report: 'reports/training-modes-20260905/index.html', status: overall, checks: counts, images: images.length, sources: sources.map(({ file, status }) => ({ file, status })) }))