您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

185 行
29 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. // Render existing, sanitized evidence only. Never read private account fixtures,
  5. // backups, logs or credentials. Missing/failing evidence cannot become a pass.
  6. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/role-permissions-20260906')
  7. const sensitiveKey = /^(?:password|passwd|secret|accessToken|refreshToken|token|authorization|cookie|credential|uploadTicket|apiKey|privateKey)$/i
  8. const cleanText = value => String(value ?? '')
  9. .replace(/\u001b\[[0-9;]*m/g, '')
  10. .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
  11. .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
  12. .replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|api[_-]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
  13. const sanitize = value => typeof value === 'string' ? cleanText(value)
  14. : Array.isArray(value) ? value.map(sanitize)
  15. : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value)
  16. .filter(([key]) => !sensitiveKey.test(key)).map(([key, item]) => [key, sanitize(item)])) : value
  17. const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
  18. const uri = value => value.split('/').map(encodeURIComponent).join('/')
  19. const array = value => Array.isArray(value) ? value : []
  20. const files = new Map()
  21. function readJSON(name) {
  22. const filename = path.join(reportDir, name)
  23. if (!fs.existsSync(filename)) { files.set(name, { state: 'PENDING' }); return null }
  24. try {
  25. const data = sanitize(JSON.parse(fs.readFileSync(filename, 'utf8').replace(/^\uFEFF/, '')))
  26. files.set(name, { state: 'PRESENT' })
  27. return data
  28. } catch (error) {
  29. files.set(name, { state: 'ERROR', error: cleanText(error.message) })
  30. return null
  31. }
  32. }
  33. const api = readJSON('api-checks.json')
  34. const boundaries = readJSON('maintenance-boundaries.json')
  35. const results = readJSON('results.json')
  36. const validation = readJSON('validation.json')
  37. const cleanup = readJSON('cleanup.json')
  38. const before = readJSON('data/role-permissions-audit-20260906.json')
  39. const contract = readJSON('data/granular-permissions-contract.json')
  40. const states = { PASS: '通过', FAIL: '失败', PENDING: '待完成', SKIP: '跳过', WARN: '需复核', PRESENT: '已记录', ERROR: '读取失败' }
  41. function state(value) {
  42. if (value === true || value === 0) return 'PASS'
  43. if (value === false) return 'FAIL'
  44. const text = String(value ?? '').toUpperCase()
  45. if (['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'COMPLETE', 'COMPLETED'].includes(text)) return 'PASS'
  46. if (['FAIL', 'FAILED', 'ERROR', 'UNEXPECTED', 'TIMEDOUT', 'INTERRUPTED'].includes(text)) return 'FAIL'
  47. if (['SKIP', 'SKIPPED'].includes(text)) return 'SKIP'
  48. if (['WARN', 'WARNING', 'FLAKY'].includes(text)) return 'WARN'
  49. return 'PENDING'
  50. }
  51. const badge = (value, label) => `<span class="badge ${value.toLowerCase()}">${html(label || states[value] || value)}</span>`
  52. function checkRows(data) {
  53. return array(data?.checks).map(check => ({ title: check.name || check.title || '未命名检查', status: state(check.result ?? check.status), details: check.details || check.note || '', http: typeof check.status === 'number' ? check.status : null }))
  54. }
  55. function count(rows) {
  56. return { total: rows.length, pass: rows.filter(row => row.status === 'PASS').length, fail: rows.filter(row => row.status === 'FAIL').length, pending: rows.filter(row => !['PASS', 'FAIL'].includes(row.status)).length }
  57. }
  58. function e2eRows(suites, parents = []) {
  59. return array(suites).flatMap(suite => [
  60. ...array(suite.specs).flatMap(spec => array(spec.tests).map((test, index) => {
  61. const attempts = array(test.results)
  62. const last = attempts.at(-1)
  63. const outcome = test.status === 'unexpected' ? 'FAIL' : test.status === 'flaky' ? 'WARN' : state(last?.status)
  64. return {
  65. title: [...parents, spec.title, test.projectName || (array(spec.tests).length > 1 ? `运行 ${index + 1}` : '')].filter(Boolean).join(' · '),
  66. status: outcome, duration: last?.duration, attempts: attempts.length,
  67. errors: array(last?.errors).map(error => cleanText(error.message || error.value || error)),
  68. attachments: array(last?.attachments).filter(item => item.contentType?.startsWith('image/')).map(item => item.name),
  69. }
  70. })),
  71. ...e2eRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)),
  72. ])
  73. }
  74. const apiRows = checkRows(api)
  75. const boundaryRows = checkRows(boundaries)
  76. const browserRows = e2eRows(results?.suites)
  77. const apiCount = count(apiRows)
  78. const boundaryCount = count(boundaryRows)
  79. const browserCount = count(browserRows)
  80. const e2eFinal = validation?.e2eComplete === true || validation?.finalRun === true || validation?.e2e?.complete === true
  81. const explicitFailure = value => value && typeof value === 'object' && (state(value.overallStatus ?? value.status ?? value.result ?? value.exitCode) === 'FAIL'
  82. || Object.values(value).some(item => Array.isArray(item) ? item.some(explicitFailure) : item && typeof item === 'object' && explicitFailure(item)))
  83. const hasFailure = apiCount.fail + boundaryCount.fail + browserCount.fail > 0 || array(results?.errors).length > 0
  84. || Number(results?.stats?.unexpected || 0) > 0 || explicitFailure(validation) || explicitFailure(cleanup)
  85. || explicitFailure(api) || explicitFailure(boundaries) || [...files.values()].some(file => file.state === 'ERROR')
  86. const fullyVerified = !hasFailure && apiRows.length > 0 && boundaryRows.length > 0 && browserRows.length > 0
  87. && apiCount.pending + boundaryCount.pending + browserCount.pending === 0 && e2eFinal
  88. && [...files.values()].every(file => file.state === 'PRESENT')
  89. && validation && state(validation.overallStatus ?? validation.status) === 'PASS'
  90. && cleanup && state(cleanup.overallStatus ?? cleanup.status ?? cleanup.result) === 'PASS'
  91. const overall = hasFailure ? 'FAIL' : fullyVerified ? 'PASS' : 'PENDING'
  92. const overallTitle = overall === 'FAIL' ? '存在失败,尚未完成验收' : overall === 'PASS' ? '本轮记录的验收项已通过' : '证据整理中,最终验收尚未完成'
  93. const overallDetail = overall === 'FAIL' ? '以下失败来自当前证据文件。历史截图、单测通过或其他接口通过均不能覆盖这次失败。'
  94. : overall === 'PASS' ? '真实接口、浏览器操作与构建/测试记录分别列示;测试覆盖范围和 OFD 本地入口边界仍需结合下文阅读。'
  95. : '当前结果按文件原样展示。缺失文件、未确认的最终 E2E 或尚未完成的清理,不计为通过。'
  96. const pretty = value => `<pre>${html(JSON.stringify(value, null, 2))}</pre>`
  97. const link = (name, label = name) => fs.existsSync(path.join(reportDir, name))
  98. ? `<a href="${uri(name)}">${html(label)}</a>` : `<span class="muted">${html(label)} · 待提供</span>`
  99. const pendingFile = name => `<div class="notice pending">${badge(files.get(name)?.state || 'PENDING')} ${html(name)}${files.get(name)?.error ? `:${html(files.get(name).error)}` : ' 尚未提供完整结果。'}</div>`
  100. const table = (headers, rows) => `<div class="table-wrap"><table><thead><tr>${headers.map(item => `<th>${html(item)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(item => `<td>${item}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
  101. function checksBlock(title, name, data, rows) {
  102. const stats = count(rows)
  103. return `<article class="panel"><h3>${html(title)}</h3>${data ? `<p>${badge(stats.fail ? 'FAIL' : stats.pending || !stats.total ? 'PENDING' : 'PASS')} <strong>${stats.pass} / ${stats.total}</strong> 项通过${stats.fail ? `,${stats.fail} 项失败` : ''} · ${link(name, 'JSON 证据')}</p>
  104. <details${stats.fail ? ' open' : ''}><summary>逐项检查结果</summary>${table(['检查项目', '结果', '补充证据'], rows.map(row => [html(row.title), badge(row.status), html([row.http ? `HTTP ${row.http}` : '', row.details].filter(Boolean).join(' · '))]))}</details>
  105. ${array(data.limitations).length ? `<div class="notice warn">${array(data.limitations).map(html).join('<br>')}</div>` : ''}` : pendingFile(name)}</article>`
  106. }
  107. const captions = {
  108. '01-admin-fixed-permissions': ['管理员固定授权', '验收后于 12:10:50(北京时间)等待完整加载后补拍:管理员权限面板展示固定授权,模型操作节点与保存授权被禁用。补拍仅执行读取,原始 Playwright 附件保持原记录。'],
  109. '02-action-adds-page-permission': ['操作自动补齐查看权限', '勾选模型新建时,同步勾选模型页面,避免产生不可达的操作权限。'],
  110. '03-removing-page-confirms-dependent-actions': ['取消页面权限需确认', '移除模型查看时提示其依赖操作会一起移除,取消后保留原勾选。'],
  111. '04-custom-reader-model-list': ['自定义只读角色·首次加载', '截图捕获了只读状态,但列表仍有加载遮罩,不能单独证明完整列表已加载。模型查看返回 200、新建返回 403 的结论见真实 API 断言。'],
  112. '05-avatar-role-switch-dialog': ['头像菜单切换角色', '仅列出当前账号已分配的有效工作身份,并标识当前身份。'],
  113. '06-custom-editor-model-list': ['切换后按新授权显示操作', '切换至获得模型新增权限的自定义角色后,新建入口可见。'],
  114. '07-failed-switch-keeps-current-role': ['切换接口失败保留原身份', '注入角色接口 503 响应后显示短消息,原身份和当前页面保留。'],
  115. '08-role-refresh-failure-clears-old-view': ['身份重取失败不回放旧页面', '角色切换已提交但身份接口失败时回到登录入口,不继续显示旧身份业务页面。'],
  116. '09-unsaved-training-blocks-role-switch': ['未保存训练阻止切换', '修改训练名称后切换角色触发离开确认;取消时不提交角色切换请求,修改仍保留。'],
  117. '09b-save-failure-blocks-role-switch': ['保存失败阻止角色切换', '模拟训练保存接口 503 后保留原身份与输入,角色切换请求未提交。'],
  118. '10-stale-permission-response-is-ignored': ['忽略过期权限响应', '权限编辑切换上下文后的延迟响应不应覆盖当前角色。此截图的通过状态以关联测试记录为准。'],
  119. '11-permission-save-survives-list-refresh-failure': ['保存成功后列表刷新失败', '权限写入成功与随后列表刷新失败分别处理,避免误报授权未保存。此截图的通过状态以关联测试记录为准。'],
  120. }
  121. const screenshotDir = path.join(reportDir, 'screenshots')
  122. const screenshots = fs.existsSync(screenshotDir) ? fs.readdirSync(screenshotDir).filter(name => /\.(?:png|jpe?g|webp)$/i.test(name)).sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true })) : []
  123. function screenshotCard(name) {
  124. const stem = name.replace(/\.[^.]+$/, '')
  125. const match = browserRows.find(row => row.attachments.includes(stem))
  126. const caption = captions[stem] || (/(?:save|saving).*(?:fail|block)|failed.*save/i.test(stem)
  127. ? ['保存失败阻止角色切换', '模拟训练保存失败后确认仍保留未保存内容与原身份,角色切换请求未提交。']
  128. : [stem, match ? `关联浏览器用例:${match.title}` : '过程截图;当前 results.json 尚未关联此图,不能据此认定用例通过。'])
  129. return `<figure><a class="shot" href="${uri(`screenshots/${name}`)}" data-caption="${html(caption[0])}"><img src="${uri(`screenshots/${name}`)}" alt="${html(caption[0])}" loading="lazy" width="1280" height="720"></a><figcaption><div>${badge(match?.status || 'PENDING', match ? `当前 E2E ${states[match.status]}` : '过程截图 · 未关联当前结果')}<span class="filename">${html(name)}</span></div><h4>${html(caption[0])}</h4><p>${html(caption[1])}</p></figcaption></figure>`
  130. }
  131. const generatedAt = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'medium', timeStyle: 'medium', hour12: false }).format(new Date())
  132. const oldRoleRows = array(before?.roles).map(role => [html(`${role.name} · ${role.code}`), html(role.dynamicAll ? '动态全部叶子权限' : `${array(role.permissionCodes).length} 项显式权限`), `<code>${html(role.dataScope)}</code>`, html(role.code === 'admin' ? '旧权限集合全选,但内容写仍被教员身份门槛阻止。' : role.code === 'teacher' ? '保留原有制作、教学能力;未获授予的资源管理不自动新增。' : '保留本人或本人队组的学习与执行能力。')])
  133. const typeRows = [['模型', 'model'], ['场景', 'scene'], ['训练编排', 'training'], ['作业指导书', 'ofd']].map(([name, type]) => [html(name), `<code>content.${type}.create</code>`, `<code>content.${type}.update</code>`, `<code>content.${type}.delete</code>`, `<code>content.${type}.publish</code>`, type === 'ofd' ? '<code>content.ofd.review</code>' : '不新增审批功能'])
  134. const evidenceLinks = ['authorization-design.md', 'data/role-permissions-audit-20260906.json', 'data/granular-permissions-contract.json', 'api-checks.json', 'maintenance-boundaries.json', 'results.json', 'validation.json', 'cleanup.json']
  135. const e2eHTML = results ? `${!e2eFinal ? '<div class="notice pending">这是当前 results.json 的实际结果;最终完整 E2E 尚未标记完成,可能只是一轮针对性复跑。</div>' : ''}
  136. <p>${badge(browserCount.fail || array(results.errors).length ? 'FAIL' : browserCount.pending || !browserCount.total ? 'PENDING' : 'PASS')} <strong>${browserCount.pass} / ${browserCount.total}</strong> 项通过,${browserCount.fail} 项失败,${browserCount.pending} 项待完成/跳过/需复核 · ${link('results.json', '完整 JSON')} · 开始于 ${html(results.stats?.startTime || '未记录')}</p>
  137. ${table(['浏览器用例', '结果', '耗时', '失败证据'], browserRows.map(row => [html(row.title), badge(row.status), row.duration === undefined ? '未记录' : `${(row.duration / 1000).toFixed(1)} 秒`, row.errors.length ? `<details open><summary>错误详情</summary><pre>${html(row.errors.join('\n\n'))}</pre></details>` : row.attempts > 1 ? `运行 ${row.attempts} 次` : '—']))}
  138. ${array(results.errors).length ? `<div class="notice fail"><strong>测试运行器报错</strong>${pretty(results.errors)}</div>` : ''}` : pendingFile('results.json')
  139. const document = `<!doctype html>
  140. <html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>权限与角色切换验收 · 2026-09-06</title>
  141. <style>
  142. :root{color-scheme:light;--bg:#f3f6fa;--ink:#192b3f;--muted:#66788b;--line:#dce5ed;--blue:#2459ae;--green:#087452;--red:#b42636;--amber:#875600}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}a{color:var(--blue);text-underline-offset:3px}a:hover{text-decoration-thickness:2px}button{font:inherit}header{background:#112d48;color:#fff;padding:44px max(24px,calc((100vw - 1320px)/2)) 38px}.eyebrow{color:#a8c9e8;font-size:13px;letter-spacing:.12em}h1{margin:8px 0 12px;font-size:clamp(27px,3vw,40px);line-height:1.3}header p{max-width:950px;color:#d0dfed}header .meta{font-size:13px;color:#a9bfd2}.nav{position:sticky;top:0;z-index:3;background:rgba(255,255,255,.96);border-bottom:1px solid var(--line);display:flex;justify-content:center;gap:30px;padding:12px 20px;backdrop-filter:blur(10px)}.nav a{font-weight:650;text-decoration:none;color:var(--ink)}main{max-width:1368px;padding:26px 24px 60px;margin:auto}section{scroll-margin-top:72px;margin:32px 0}.section-label{color:var(--blue);font-size:12px;font-weight:750;letter-spacing:.1em}h2{font-size:25px;margin:0 0 16px}h3{font-size:19px;margin:0 0 10px}h4{font-size:17px;margin:8px 0 2px}p{margin:8px 0 14px}.panel,.stage{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px;margin:16px 0}.notice{border:1px solid var(--line);border-left:4px solid #c28b2e;border-radius:8px;padding:14px 18px;margin:16px 0;background:#fffaf0}.notice.pass{background:#edf8f2;border-color:#79b99b}.notice.fail{background:#fff0f1;border-color:#d57681}.notice strong{display:block;font-size:18px}.notice .badge{margin-right:7px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.stat{background:#fff;border:1px solid var(--line);border-radius:10px;padding:17px 20px}.stat b{display:block;font-size:28px;line-height:1.4}.stat span,.muted,.filename{color:var(--muted);font-size:13px}.stat small{display:block;color:var(--muted)}.badge{display:inline-block;border-radius:6px;padding:2px 8px;font-size:12px;line-height:1.65;white-space:nowrap;font-weight:650;background:#edf1f5;color:#556a7c}.badge.pass{background:#dcf4e9;color:var(--green)}.badge.fail,.badge.error{background:#ffe1e4;color:var(--red)}.badge.pending,.badge.warn,.badge.skip{background:#fff0cb;color:var(--amber)}.grid-two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.grid-two .panel{margin:0}.table-wrap{overflow:auto;margin:12px 0}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;padding:12px 14px;border-bottom:1px solid var(--line)}td code,th{white-space:nowrap}th{background:#f0f5fa;color:#435b70;font-weight:650}td:first-child{min-width:155px}tbody tr:last-child td{border-bottom:0}code{font:12px/1.65 Consolas,monospace;overflow-wrap:anywhere;background:#eef3f7;padding:2px 4px;border-radius:3px}pre{margin:12px 0;max-height:480px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f3f6fa;border:1px solid var(--line);border-radius:6px;padding:16px;font:12px/1.65 Consolas,"Microsoft YaHei",monospace}details{margin-top:12px}summary{cursor:pointer;font-weight:650;color:var(--blue);padding:4px 0}ul,ol{padding-left:24px}li{margin:6px 0}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff}.shot{display:block;background:#e7eef5;padding:8px;cursor:zoom-in}.shot img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:contain;background:#e7eef5}figcaption{padding:15px 17px}figcaption p{font-size:14px;color:#4d6478;margin-bottom:0}.filename{display:block;margin-top:5px;overflow-wrap:anywhere}.links{display:flex;flex-wrap:wrap;gap:10px 24px}.links a{overflow-wrap:anywhere}footer{border-top:1px solid var(--line);margin-top:35px;padding-top:18px;color:var(--muted);font-size:13px}dialog{border:0;border-radius:10px;padding:0;width:min(96vw,1800px);max-width:none;max-height:96vh;background:#102238;color:white}dialog::backdrop{background:rgba(4,13,25,.88)}.dialog-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 18px;gap:20px}dialog button{border:1px solid #7b93ab;border-radius:5px;background:transparent;color:white;padding:3px 14px;cursor:pointer}dialog img{display:block;width:100%;max-height:84vh;object-fit:contain}dialog a{color:#c4e4ff}.dialog-caption{font-size:14px}@media(max-width:850px){.stats{grid-template-columns:repeat(2,1fr)}.grid-two,.gallery{grid-template-columns:1fr}.nav{gap:16px;justify-content:flex-start;overflow:auto;white-space:nowrap;font-size:13px}header{padding:30px 20px}main{padding:18px 14px 40px}.stage,.panel{padding:18px}h2{font-size:22px}}@media print{.nav,dialog{display:none}.panel,.stage,figure{break-inside:avoid}header{background:white;color:var(--ink);padding:20px}header p,header .meta{color:var(--muted)}main{max-width:none;padding:0}details{display:block}a{color:inherit}.gallery{grid-template-columns:repeat(2,1fr)}}
  143. </style></head><body>
  144. <header><div class="eyebrow">装备数字车间 · 权限治理与工作身份</div><h1>权限与角色切换验收报告</h1><p>按资料、产品原型、设计、开发、测试梳理本轮调整。页面与操作使用真实权限;管理员采用固定授权;内部维护与普通业务隔离;工作身份切换保护未保存内容。</p><div class="meta">日期 2026-09-06 · 生成时间 ${html(generatedAt)}(北京时间) · 证据来自当前报告目录</div></header>
  145. <nav class="nav" aria-label="报告章节"><a href="#sources">01 资料</a><a href="#prototype">02 产品原型</a><a href="#design">03 设计</a><a href="#development">04 开发</a><a href="#testing">05 测试</a><a href="#artifacts">证据文件</a></nav>
  146. <main><div class="notice ${overall.toLowerCase()}" role="status">${badge(overall)}<strong>${overallTitle}</strong><p>${overallDetail}</p></div>
  147. <div class="stats"><div class="stat"><span>真实 API · 权限主流程</span><b>${api ? `${apiCount.pass} / ${apiCount.total}` : '待完成'}</b><small>接口断言,不计作浏览器用例</small></div><div class="stat"><span>真实 API · 维护隔离边界</span><b>${boundaries ? `${boundaryCount.pass} / ${boundaryCount.total}` : '待完成'}</b><small>目录、审计、角色与写入保护</small></div><div class="stat"><span>浏览器 E2E · 当前结果</span><b>${results ? `${browserCount.pass} / ${browserCount.total}` : '待完成'}</b><small>${e2eFinal ? '已标记为最终完整运行' : '最终完整运行尚未确认'}</small></div><div class="stat"><span>构建 / 单测 / DDL</span><b>${validation ? states[state(validation.overallStatus ?? validation.status)] : '待完成'}</b><small>独立统计,详见验证记录</small></div></div>
  148. <section id="sources"><div class="section-label">01 · 资料</div><h2>以旧系统实际能力为迁移边界</h2><div class="stage"><p>旧环境采用 <code>${html(before?.authorizationMode || '未记录')}</code>。管理员曾动态获得全部叶子权限,但内容写又被教员身份门槛阻止;本轮把这种不一致改为明确的页面权限与操作权限。</p>${before ? table(['旧角色', '旧授权', '数据范围', '迁移依据'], oldRoleRows) : pendingFile('data/role-permissions-audit-20260906.json')}<p>动作合同记录 ${array(contract?.permissions).length || '待记录'} 个新增动作和 ${array(contract?.deprecated).length || '待记录'} 个停用共用动作码。旧角色仅按原已持有权限和所属页面展开,自定义角色不自动补齐其他业务能力。</p><div class="links">${link('data/role-permissions-audit-20260906.json', '脱敏旧角色快照')}${link('data/granular-permissions-contract.json', '细分动作合同')}${link('authorization-design.md', '完整中文设计与验收约定')}</div></div></section>
  149. <section id="prototype"><div class="section-label">02 · 产品原型</div><h2>保持入口,把“能查看”和“能操作”说明白</h2><div class="grid-two"><article class="panel"><h3>页面与工作区</h3><p>三个登录身份入口保留。列表的新建、复制、删除、发布按各类内容权限显示;没有修改权限的三维工作区仍可查看,编辑、导入、自动保存和恢复写入受到控制。</p><p>管理员授权界面展示固定清单,不能在普通角色编辑中改动固定模板。勾选动作自动补齐所属页面;取消页面先确认依赖动作。</p></article><article class="panel"><h3>头像与个人中心</h3><p>只列出当前账号已分配且有效的工作身份。单角色生效模式下,切换前确认各工作区未保存内容;成功后重新加载身份、菜单和页签。</p><p>切换请求失败保留原页面。切换已提交但新身份加载失败时,不继续使用旧身份业务缓存。</p></article></div></section>
  150. <section id="design"><div class="section-label">03 · 设计</div><h2>页面、动作、身份和数据范围分别约束</h2><div class="stage">${table(['内容类型', '新建 / 复制', '编辑 / 上传 / 封面', '删除工程', '发布 / 撤回发布', '批准 / 驳回'], typeRows)}<p>保存后发布需要修改与发布两项权限。只有发布权限时,提交已保存的服务端版本;OFD 还须复用与当前内容指纹一致的已保存交付物,缺失或过期时由编辑者处理。</p>${table(['迁移后角色', '默认保留能力', '明确边界'], [
  151. ['管理员', '固定普通管理授权、系统配置与既有教学/资源管理', '不自动拥有内容写、学员执行/提交或内部维护权限。'],
  152. ['教员', '原有四类内容制作、教学管理与运行能力', '原无 manage 的资源/知识管理不因拆码获得 CRUD。'],
  153. ['学员', '本人/本人队组的领取、执行、提交、指导书学习与反馈', 'SELF 范围、参与人、队伍、岗位和正式考试投影继续生效。'],
  154. ['内部维护', '受保护账号与有效内部角色共同确认后获得维护能力', '普通人员/角色目录隐藏;内部审计与业务审计分别读取。'],
  155. ].map(row => row.map(html)))}<p>任务中的教员、学员、观察员成员身份与平台角色名称解耦;成员身份不授予平台动作权限。业务参与范围、固定版本、乐观锁、发布前置条件继续由服务端校验。</p></div></section>
  156. <section id="development"><div class="section-label">04 · 开发</div><h2>实现范围与需要保留的边界</h2><div class="stage"><ul><li>内容四类动作、教学各渠道、资源管理真实操作分别授权;停用旧共用动作码,不设置永久兼容放行。</li><li>模型同源宿主桥接、场景与训练运行时、正式工作台及 OFD 本地入口同步只读和操作门控;模型静态生成包未在本轮重做。</li><li>所有工作区参与角色切换确认。场景加载或资源上传先阻止离开;已开始的截图上传、保存、发布等待完整结束再核查未保存状态。</li><li>前一工作区选择放弃、后一工作区取消时仍保留草稿。角色接口成功后整页进入总览,新启动重新取身份和菜单,页签按用户与角色隔离。</li><li>036 迁移固定管理员模板、细分已有角色能力并增加审计范围;维护凭据只经私有初始化流程设置,报告和 DDL 不包含凭据。</li></ul><div class="notice warn"><strong>OFD 本地入口的边界</strong><p>默认 OFD 原型仍使用本地存储。本轮接入真实会话和本地写权限保护,未将整个原型业务迁移至 API;不能将前端限制描述成完整服务端隔离、跨设备协作或新增审批功能。</p></div></div></section>
  157. <section id="testing"><div class="section-label">05 · 测试</div><h2>真实接口、浏览器、构建与单测分别验收</h2>
  158. ${checksBlock('A. 真实 API:权限配置与多角色主流程', 'api-checks.json', api, apiRows)}
  159. ${checksBlock('B. 真实 API:内部维护与审计隔离边界', 'maintenance-boundaries.json', boundaries, boundaryRows)}
  160. <article class="panel"><h3>C. 浏览器 E2E:可见交互与异常路径</h3>${e2eHTML}<p class="muted">角色切换/身份获取/保存失败等异常路径使用浏览器定向响应模拟。它们验证前端保留状态与阻断行为,不表示真实服务发生过这些故障。</p></article>
  161. <article class="panel"><h3>D. 构建、聚焦单测与 DDL</h3>${validation ? `<p>${link('validation.json', '完整验证记录')}。下方按实际执行的命令与结果展示,不把单测数量并入 E2E 或真实 API 数量。</p>${pretty(validation)}` : pendingFile('validation.json')}</article>
  162. <article class="panel"><h3>E. 临时测试数据与清理</h3>${cleanup ? `<p>${link('cleanup.json', '清理证据')}。业务模型、场景与训练的保留/恢复情况以记录为准。</p>${pretty(cleanup)}` : pendingFile('cleanup.json')}</article>
  163. <h3>截图证据 · 点击放大</h3><p class="muted">截图只证明相应画面。标记为“过程截图”的文件未关联当前结果,不计入当前通过数量。</p><div class="gallery">${screenshots.length ? screenshots.map(screenshotCard).join('') : '<div class="notice pending">尚未生成截图。</div>'}</div></section>
  164. <section id="artifacts"><div class="section-label">证据文件</div><h2>可随 Git 交付的材料</h2><div class="panel"><div class="links">${evidenceLinks.map(name => link(name)).join('')}</div><p class="muted">data/ 仅包含脱敏角色/权限快照与动作合同。本生成器不读取或复制账号配置、私有测试 fixtures、数据库备份、服务日志或会话凭据。</p></div></section>
  165. <footer>结果只反映本报告目录当前证据。重新执行 <code>node tools/build-role-permissions-report.mjs</code> 可刷新报告;新增文件不会自动被当成通过。</footer></main>
  166. <dialog id="image-dialog" aria-label="放大截图"><div class="dialog-bar"><span class="dialog-caption"></span><div><a id="original-image" target="_blank" rel="noopener">打开原图</a> <button id="close-image" type="button">关闭 ×</button></div></div><img alt=""></dialog>
  167. <script>
  168. const viewer=document.getElementById('image-dialog'), large=viewer.querySelector('img');
  169. document.querySelectorAll('a.shot').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();large.src=link.href;large.alt=link.dataset.caption;viewer.querySelector('.dialog-caption').textContent=link.dataset.caption;document.getElementById('original-image').href=link.href;viewer.showModal()}));
  170. document.getElementById('close-image').addEventListener('click',()=>viewer.close());
  171. viewer.addEventListener('click',event=>{if(event.target===viewer)viewer.close()});
  172. </script></body></html>`
  173. fs.writeFileSync(path.join(reportDir, 'index.html'), document, 'utf8')
  174. console.log(JSON.stringify({ output: path.join(reportDir, 'index.html'), overall, api: apiCount, maintenance: boundaryCount, e2e: browserCount, e2eFinal, screenshots: screenshots.length, missing: [...files].filter(([, item]) => item.state !== 'PRESENT').map(([name, item]) => ({ name, ...item })) }, null, 2))