@openprd/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.openprd/README.md +82 -0
  2. package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
  3. package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
  4. package/.openprd/benchmarks/index.md +37 -0
  5. package/.openprd/benchmarks/sources.yaml +56 -0
  6. package/.openprd/config.yaml +50 -0
  7. package/.openprd/discovery/config.json +21 -0
  8. package/.openprd/engagements/active/flows.md +30 -0
  9. package/.openprd/engagements/active/handoff.md +9 -0
  10. package/.openprd/engagements/active/intake.md +15 -0
  11. package/.openprd/engagements/active/prd.md +161 -0
  12. package/.openprd/engagements/active/review.html +61 -0
  13. package/.openprd/engagements/active/roles.md +21 -0
  14. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
  15. package/.openprd/exports/.gitkeep +0 -0
  16. package/.openprd/knowledge/index.json +7 -0
  17. package/.openprd/quality/config.json +229 -0
  18. package/.openprd/reviews/v0001.html +1256 -0
  19. package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
  20. package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
  21. package/.openprd/schema/prd.schema.yaml +121 -0
  22. package/.openprd/sessions/.gitkeep +0 -0
  23. package/.openprd/standards/config.json +88 -0
  24. package/.openprd/standards/file-manual-template.md +28 -0
  25. package/.openprd/standards/folder-readme-template.md +28 -0
  26. package/.openprd/state/.gitkeep +0 -0
  27. package/.openprd/state/changes.json +12 -0
  28. package/.openprd/state/current.json +169 -0
  29. package/.openprd/state/version-index.json +15 -0
  30. package/.openprd/state/versions/.gitkeep +0 -0
  31. package/.openprd/state/versions/v0001.json +121 -0
  32. package/.openprd/state/versions/v0001.md +161 -0
  33. package/.openprd/templates/agent/intake.md +6 -0
  34. package/.openprd/templates/agent/prd.md +21 -0
  35. package/.openprd/templates/b2b/intake.md +6 -0
  36. package/.openprd/templates/b2b/prd.md +24 -0
  37. package/.openprd/templates/base/intake.md +18 -0
  38. package/.openprd/templates/base/prd.md +67 -0
  39. package/.openprd/templates/company/README.md +10 -0
  40. package/.openprd/templates/consumer/intake.md +6 -0
  41. package/.openprd/templates/consumer/prd.md +19 -0
  42. package/.openprd/templates/diagram/architecture.contract.json +53 -0
  43. package/.openprd/templates/diagram/product-flow.contract.json +76 -0
  44. package/.openprd/templates/industry/README.md +16 -0
  45. package/.openprd/templates/manifest.yaml +27 -0
  46. package/.openprd/templates/project/README.md +14 -0
  47. package/.openprd/templates/session/README.md +14 -0
  48. package/AGENTS.md +44 -0
  49. package/CONTRIBUTING.md +30 -0
  50. package/LICENSE +21 -0
  51. package/README.md +727 -0
  52. package/README_CN.md +583 -0
  53. package/SECURITY.md +23 -0
  54. package/bin/openprd.js +5 -0
  55. package/docs/assets/openprd-capability-overview-en.png +0 -0
  56. package/docs/assets/openprd-capability-overview-zh.png +0 -0
  57. package/docs/assets/openprd-learning-html.png +0 -0
  58. package/docs/assets/openprd-quality-html.png +0 -0
  59. package/docs/assets/openprd-review-html.png +0 -0
  60. package/docs/assets/openprd-scenario-overview.png +0 -0
  61. package/docs/assets/openprd-scenario-overview.svg +114 -0
  62. package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
  63. package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
  64. package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
  65. package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
  66. package/package.json +59 -0
  67. package/scripts/openprd-dev-check.mjs +5 -0
  68. package/scripts/openprd-review-presentation.mjs +82 -0
  69. package/skills/openprd-benchmark-router/SKILL.md +92 -0
  70. package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
  71. package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
  72. package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
  73. package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
  74. package/skills/openprd-diagram-review/SKILL.md +91 -0
  75. package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
  76. package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
  77. package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
  78. package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
  79. package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
  80. package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +196 -0
  82. package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
  83. package/skills/openprd-harness/SKILL.md +179 -0
  84. package/skills/openprd-harness/agents/openai.yaml +4 -0
  85. package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
  86. package/skills/openprd-harness/references/command-map.md +71 -0
  87. package/skills/openprd-harness/references/examples.md +26 -0
  88. package/skills/openprd-harness/references/usage-guide.md +335 -0
  89. package/skills/openprd-harness/references/workflow-gates.md +51 -0
  90. package/skills/openprd-learning-review/SKILL.md +75 -0
  91. package/skills/openprd-learning-review/agents/openai.yaml +4 -0
  92. package/skills/openprd-learning-review/references/content-contract.md +125 -0
  93. package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
  94. package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
  95. package/skills/openprd-learning-review/references/genre-library.md +43 -0
  96. package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
  97. package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
  98. package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
  99. package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
  100. package/skills/openprd-quality/SKILL.md +101 -0
  101. package/skills/openprd-requirement-intake/SKILL.md +76 -0
  102. package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
  103. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
  104. package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
  105. package/skills/openprd-router/SKILL.md +40 -0
  106. package/skills/openprd-shared/SKILL.md +142 -0
  107. package/skills/openprd-shared/agents/openai.yaml +4 -0
  108. package/skills/openprd-shared/references/language-and-review.md +50 -0
  109. package/skills/openprd-shared/references/operating-rules.md +65 -0
  110. package/skills/openprd-shared/references/skill-architecture.md +70 -0
  111. package/skills/openprd-standards/SKILL.md +79 -0
  112. package/skills/openprd-standards/agents/openai.yaml +4 -0
  113. package/src/agent-integration.js +1717 -0
  114. package/src/benchmark.js +873 -0
  115. package/src/cli/args.js +460 -0
  116. package/src/cli/print.js +1423 -0
  117. package/src/codex-hook-runner-template.mjs +2422 -0
  118. package/src/dev-standards.js +372 -0
  119. package/src/diagram-core.js +1047 -0
  120. package/src/diagram-workspace.js +262 -0
  121. package/src/discovery.js +709 -0
  122. package/src/fleet.js +531 -0
  123. package/src/fs-utils.js +83 -0
  124. package/src/growth.js +545 -0
  125. package/src/html-artifacts.js +3803 -0
  126. package/src/knowledge.js +668 -0
  127. package/src/language-policy.js +142 -0
  128. package/src/learning-review.js +1655 -0
  129. package/src/loop.js +1290 -0
  130. package/src/openprd.js +1136 -0
  131. package/src/openspec/change-lifecycle.js +359 -0
  132. package/src/openspec/change-validate.js +248 -0
  133. package/src/openspec/constants.js +12 -0
  134. package/src/openspec/execute.js +300 -0
  135. package/src/openspec/generate.js +692 -0
  136. package/src/openspec/paths.js +111 -0
  137. package/src/openspec/tasks.js +352 -0
  138. package/src/prd-core.js +656 -0
  139. package/src/quality-html-artifact.js +1414 -0
  140. package/src/quality-learning.js +658 -0
  141. package/src/quality.js +1262 -0
  142. package/src/review-presentation.js +240 -0
  143. package/src/run-harness.js +1470 -0
  144. package/src/self-update.js +329 -0
  145. package/src/session-binding.js +140 -0
  146. package/src/source-inventory.js +224 -0
  147. package/src/standards.js +914 -0
  148. package/src/time.js +33 -0
  149. package/src/visual-compare.js +216 -0
  150. package/src/work-unit-migration.js +232 -0
  151. package/src/work-unit.js +88 -0
  152. package/src/workspace-core.js +1706 -0
  153. package/src/workspace-registry.js +162 -0
  154. package/src/workspace-workflow.js +1797 -0
@@ -0,0 +1,1414 @@
1
+ function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replace(/&/g, '&')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+ }
9
+
10
+ const QUALITY_REPORT_FRAMEWORK = {
11
+ intent: '质量报告面向业务和产品评审者,只展示能帮助判断是否继续推进的信息。',
12
+ layout: [
13
+ '回归结论概览:一句话说明能否继续,展示必测、待处理、确认项和验证材料数量',
14
+ '回归流程图:由 OpenPrD 工具生成固定步骤,展示成本护栏、冒烟测试、任务覆盖、风险复核、验证材料和最终结论',
15
+ '测试覆盖图:由 OpenPrD 工具生成固定槽位,表达检查范围、必测结果、待处理、验证材料和最终判断',
16
+ '四个固定模块:本期必测结果、需要处理或确认、验证材料、执行环境与覆盖',
17
+ '固定底部操作栏:只保留需要补测和认可回归两个动作',
18
+ '折叠详情:保留表格、证据链、结构化数据和框架约束',
19
+ ],
20
+ contentRules: [
21
+ '所有标题和说明使用普通产品语言,避免 gate、production-ready、EVO、runtime、schema 等内部词',
22
+ '流程图步骤标题、状态和旁路原因均由工具生成,单项控制在 15 字以内',
23
+ '流程图中未通过或待确认步骤自动挂旁路原因卡,不让 Agent 手动画线或自由写长句',
24
+ '图中每个卡片正文控制在 30 字以内,由生成逻辑先总结,不靠 CSS 截断,也不让 Agent 直接手写 SVG',
25
+ '图中卡片标题控制在 15 字以内,并使用胶囊样式',
26
+ '四个模块的明细统一为“加粗摘要 + 一句话说明”',
27
+ '异常为空时给出明确空状态,不让用户猜测是不是漏了内容',
28
+ ],
29
+ };
30
+
31
+ function compactText(value) {
32
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
33
+ }
34
+
35
+ function humanText(value) {
36
+ return compactText(value)
37
+ .replace(/production-ready/g, '可继续')
38
+ .replace(/needs-attention/g, '需处理')
39
+ .replace(/needs-evidence/g, '需补证据')
40
+ .replace(/EVO/g, '交付')
41
+ .replace(/runtime/g, '运行过程')
42
+ .replace(/schema/g, '结构')
43
+ .replace(/门禁/g, '测试块')
44
+ .replace(/项目级巡检/g, '全项目检查')
45
+ .replace(/需求级回归/g, '当前需求检查')
46
+ .replace(/\s*Skill\s*/g, '经验');
47
+ }
48
+
49
+ function gateDisplay(gate) {
50
+ if (gate?.id === 'knowledge') return '经验沉淀';
51
+ return humanText(gate?.label ?? gate?.id ?? '测试块');
52
+ }
53
+
54
+ function statusLabel(status) {
55
+ if (status === 'pass') return '已通过';
56
+ if (status === 'fail') return '失败';
57
+ if (status === 'needs-evidence') return '缺少证据';
58
+ if (status === 'advisory') return '需确认';
59
+ if (status === 'waived') return '已豁免';
60
+ return '需关注';
61
+ }
62
+
63
+ function toneForGate(gate) {
64
+ if (gate.status === 'pass' || gate.status === 'waived') return 'pass';
65
+ if (gate.required) return 'fail';
66
+ if (gate.status === 'needs-evidence') return 'warn';
67
+ return 'note';
68
+ }
69
+
70
+ function scenarioLabel(tag) {
71
+ const labels = {
72
+ core: '基础验证',
73
+ frontend: '界面体验',
74
+ desktop: '桌面端体验',
75
+ backend: '服务与数据处理',
76
+ businessCost: '成本与滥用风险',
77
+ security: '隐私与安全',
78
+ performance: '性能风险',
79
+ extreme: '极端场景',
80
+ release: '上线交付',
81
+ legacy: '历史兼容',
82
+ };
83
+ return labels[tag] ?? tag;
84
+ }
85
+
86
+ function policyLabels(report) {
87
+ const policy = report.qualityPolicy ?? { scenarioTags: [], requiredGates: [], optionalGates: [] };
88
+ const gateById = new Map((report.gates ?? []).map((gate) => [gate.id, gate]));
89
+ const labelFor = (id) => gateDisplay(gateById.get(id) ?? { id });
90
+ return {
91
+ scenarioLabels: policy.scenarioTags.map(scenarioLabel),
92
+ requiredLabels: policy.requiredGates.map(labelFor),
93
+ optionalLabels: policy.optionalGates.map(labelFor),
94
+ };
95
+ }
96
+
97
+ function gateDescription(gate) {
98
+ const descriptions = {
99
+ smoke: '核心路径能否跑通,至少覆盖主流程和关键失败路径',
100
+ 'feature-coverage': '需求拆解项是否全部完成,验收点是否有对应回归',
101
+ 'business-guardrails': '成本、额度、滥用、报警和止损是否讲清楚',
102
+ traceability: '出问题时是否能追到用户动作、请求、任务和错误',
103
+ redaction: '报告、日志和错误信息是否会暴露敏感信息',
104
+ 'normal-performance': '普通规模下是否可用、不卡顿、不超时',
105
+ 'extreme-performance': '大数据、并发、异常输入或边界规模是否有兜底',
106
+ knowledge: '本次问题是否需要沉淀经验,避免下次重复漏测',
107
+ };
108
+ return descriptions[gate.id] ?? '确认这项测试是否和本次需求相关,证据是否来自本次执行';
109
+ }
110
+
111
+ function gateTreatment(gate) {
112
+ if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
113
+ return '全项目检查可继续;具体需求交付时要补任务拆解';
114
+ }
115
+ if (gate.required && gate.status === 'pass') return '保留证据即可继续';
116
+ if (gate.required) return '现在修复或补证据,完成后重新生成报告';
117
+ if (gate.status === 'pass') return '已覆盖,可作为辅助证据保留';
118
+ return '判断是否属于本期;属于就补测,不属于才记录延期原因';
119
+ }
120
+
121
+ function gateSummary(gate) {
122
+ const label = gateDisplay(gate);
123
+ if (gate.status === 'pass') return `${label}已通过`;
124
+ if (gate.required) return `${label}未通过`;
125
+ if (gate.status === 'needs-evidence') return `${label}缺证据`;
126
+ return `${label}需确认`;
127
+ }
128
+
129
+ function evidenceRows(report) {
130
+ return (report.gates ?? []).flatMap((gate) => {
131
+ const sources = gate.evidence?.sources ?? [];
132
+ if (sources.length === 0) {
133
+ return [{
134
+ gate,
135
+ source: '未提供',
136
+ path: gate.required ? '缺少必需证据' : '当前场景未要求',
137
+ empty: true,
138
+ }];
139
+ }
140
+ return sources.map((source) => ({
141
+ gate,
142
+ source: source.source ?? 'evidence',
143
+ path: source.path ?? 'unknown',
144
+ empty: false,
145
+ }));
146
+ });
147
+ }
148
+
149
+ function evidenceCount(report) {
150
+ return evidenceRows(report).filter((row) => !row.empty).length;
151
+ }
152
+
153
+ function activeTasks(report) {
154
+ return report.evalHarness?.featureCoverage?.activeTasks ?? {
155
+ activeChange: report.summary?.activeChange ?? null,
156
+ total: 0,
157
+ done: 0,
158
+ pending: 0,
159
+ tasks: [],
160
+ };
161
+ }
162
+
163
+ function requiredGates(report) {
164
+ return (report.gates ?? []).filter((gate) => gate.required);
165
+ }
166
+
167
+ function passedRequired(gates) {
168
+ return gates.filter((gate) => ['pass', 'waived'].includes(gate.status)).length;
169
+ }
170
+
171
+ function actionItems(report) {
172
+ const required = requiredGates(report);
173
+ const failing = required.filter((gate) => !['pass', 'waived'].includes(gate.status));
174
+ const advisory = (report.gates ?? []).filter((gate) => !gate.required && gate.status !== 'pass');
175
+ if (failing.length > 0) {
176
+ return failing.map((gate) => `${gateDisplay(gate)}:${humanText(gate.warnings?.[0] ?? gate.evidence?.summary ?? '补齐证据后再继续')}`);
177
+ }
178
+ if (advisory.length > 0) {
179
+ return advisory.map((gate) => `${gateDisplay(gate)}:判断是否属于本期,属于就补测,不属于就说明延期原因`);
180
+ }
181
+ return ['本期必测全部通过;继续保留本次证据,交付前再复跑一次验证'];
182
+ }
183
+
184
+ function chip(text, tone = 'neutral') {
185
+ return `<span class="quality-chip ${escapeHtml(tone)}">${escapeHtml(text)}</span>`;
186
+ }
187
+
188
+ function chipRow(items, emptyText = '暂无') {
189
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
190
+ if (list.length === 0) return chip(emptyText, 'muted');
191
+ return list.join('\n');
192
+ }
193
+
194
+ function icon(kind) {
195
+ const icons = {
196
+ map: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14" /><path d="M5 8h14" /><path d="M7 16h10" /><circle cx="12" cy="5" r="2" /><circle cx="5" cy="8" r="2" /><circle cx="19" cy="8" r="2" /><circle cx="7" cy="16" r="2" /><circle cx="17" cy="16" r="2" /></svg>',
197
+ check: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>',
198
+ alert: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 3.5 19h17L12 4Z" /><path d="M12 9v4" /><path d="M12 16.5h.01" /></svg>',
199
+ evidence: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l4 4v14H7z" /><path d="M14 3v5h4" /><path d="M9 13h6" /><path d="M9 17h4" /></svg>',
200
+ environment: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16" /><path d="M4 12h16" /><path d="M4 17h16" /><circle cx="8" cy="7" r="2" /><circle cx="16" cy="12" r="2" /><circle cx="11" cy="17" r="2" /></svg>',
201
+ };
202
+ return `<span class="quality-icon quality-icon-${escapeHtml(kind)}">${icons[kind] ?? icons.check}</span>`;
203
+ }
204
+
205
+ function splitSvgLines(value, maxChars = 17) {
206
+ const text = compactText(value) || '待补充';
207
+ const tokens = text.match(/[A-Za-z0-9_./:-]+|[\u4e00-\u9fff]|[^\s]/g) ?? [text];
208
+ const lines = [];
209
+ let line = '';
210
+ let length = 0;
211
+ const visualLength = (token) => /^[A-Za-z0-9_./:-]+$/.test(token)
212
+ ? Math.max(1, token.length * 0.62)
213
+ : 1;
214
+ for (const token of tokens) {
215
+ const nextLength = visualLength(token);
216
+ if (line && length + nextLength > maxChars) {
217
+ lines.push(line);
218
+ line = token;
219
+ length = nextLength;
220
+ } else {
221
+ line += token;
222
+ length += nextLength;
223
+ }
224
+ }
225
+ if (line) lines.push(line);
226
+ return lines;
227
+ }
228
+
229
+ function svgTextBlock(value, x, centerY, className, maxChars = 16, lineHeight = 16, anchor = 'start') {
230
+ const lines = splitSvgLines(value, maxChars);
231
+ const firstY = centerY - ((lines.length - 1) * lineHeight) / 2;
232
+ return `<text class="${className}" x="${x}" y="${firstY}" text-anchor="${anchor}" dominant-baseline="middle" alignment-baseline="middle">${lines.map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join('')}</text>`;
233
+ }
234
+
235
+ function svgPill({ x, y, width, height, tone, label }) {
236
+ const centerX = x + width / 2;
237
+ const centerY = y + height / 2;
238
+ return `
239
+ <rect class="quality-map-pill ${escapeHtml(tone)}" x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" />
240
+ <text class="quality-map-pill-text ${escapeHtml(tone)}" x="${centerX}" y="${centerY}" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle">${escapeHtml(label)}</text>
241
+ `;
242
+ }
243
+
244
+ function gateById(report, id) {
245
+ return (report.gates ?? []).find((gate) => gate.id === id) ?? null;
246
+ }
247
+
248
+ function flowStateFromGate(gate) {
249
+ if (!gate) return 'pass';
250
+ if (gate.status === 'pass' || gate.status === 'waived') return 'pass';
251
+ if (gate.required) return 'fail';
252
+ return 'warn';
253
+ }
254
+
255
+ function flowStatusText(state) {
256
+ if (state === 'pass') return '已通过';
257
+ if (state === 'fail') return '未通过';
258
+ return '待确认';
259
+ }
260
+
261
+ function flowReasonForGate(title, gate, state) {
262
+ if (state === 'pass') return null;
263
+ if (!gate) return `${title}待补`;
264
+ if (state === 'fail') return `${title}需补测`;
265
+ return `${title}需确认`;
266
+ }
267
+
268
+ function flowStepFromGate(report, title, id, options = {}) {
269
+ const gate = gateById(report, id);
270
+ const missingState = options.missingState ?? 'pass';
271
+ const missingStatus = options.missingStatus ?? '未涉及';
272
+ const state = flowStateFromGate(gate);
273
+ return {
274
+ title,
275
+ state: gate ? state : missingState,
276
+ status: gate ? flowStatusText(state) : missingStatus,
277
+ reason: gate ? flowReasonForGate(title, gate, state) : (missingState === 'pass' ? null : `${title}待补`),
278
+ };
279
+ }
280
+
281
+ function riskFlowReason(advisory) {
282
+ if (advisory.length === 0) return null;
283
+ const first = gateDisplay(advisory[0]).replace(/需确认$/u, '');
284
+ return advisory.length === 1 ? `${first}待确认` : `${first}等${advisory.length}项`;
285
+ }
286
+
287
+ function regressionFlowModel({ report, advisory }) {
288
+ const evidenceTotal = evidenceCount(report);
289
+ const riskState = advisory.length > 0 ? 'warn' : 'pass';
290
+ const evidenceState = evidenceTotal > 0 ? 'pass' : 'fail';
291
+ const conclusionState = report.readiness?.productionReady === true ? 'pass' : 'fail';
292
+ return [
293
+ flowStepFromGate(report, '成本护栏', 'business-guardrails'),
294
+ flowStepFromGate(report, '冒烟测试', 'smoke', { missingState: 'fail', missingStatus: '未找到测试' }),
295
+ flowStepFromGate(report, '任务覆盖', 'feature-coverage', { missingState: 'fail', missingStatus: '未找到任务' }),
296
+ {
297
+ title: '风险复核',
298
+ state: riskState,
299
+ status: riskState === 'pass' ? '已通过' : `确认 ${advisory.length} 项`,
300
+ reason: riskFlowReason(advisory),
301
+ },
302
+ {
303
+ title: '验证留存',
304
+ state: evidenceState,
305
+ status: evidenceState === 'pass' ? `${evidenceTotal} 条材料` : '缺少材料',
306
+ reason: evidenceState === 'pass' ? null : '补验证材料',
307
+ },
308
+ {
309
+ title: '最终结论',
310
+ state: conclusionState,
311
+ status: conclusionState === 'pass' ? '可以继续' : '先补测',
312
+ reason: conclusionState === 'pass' ? null : '补完再继续',
313
+ },
314
+ ];
315
+ }
316
+
317
+ function flowTone(state) {
318
+ if (state === 'pass') return 'pass';
319
+ if (state === 'fail') return 'fail';
320
+ return 'warn';
321
+ }
322
+
323
+ function regressionFlow({ report, advisory }) {
324
+ const steps = regressionFlowModel({ report, advisory });
325
+ const width = 112;
326
+ const gap = 36;
327
+ const startX = 52;
328
+ const y = 32;
329
+ const noteY = 132;
330
+ return `
331
+ <div class="quality-flow-canvas" aria-label="回归流程图">
332
+ <svg viewBox="0 0 960 210" role="img" aria-label="回归流程图" preserveAspectRatio="xMidYMid meet">
333
+ <defs>
334
+ <marker id="quality-flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
335
+ <path d="M0,0 L8,4 L0,8 Z" class="quality-flow-arrow-head" />
336
+ </marker>
337
+ </defs>
338
+ <rect class="quality-map-bg" x="2" y="2" width="956" height="206" rx="8" />
339
+ ${steps.map((step, index) => {
340
+ const x = startX + index * (width + gap);
341
+ const centerX = x + width / 2;
342
+ const nextX = x + width + 10;
343
+ const line = index < steps.length - 1
344
+ ? `<path class="quality-flow-link" d="M ${nextX} ${y + 44} H ${startX + (index + 1) * (width + gap) - 10}" />`
345
+ : '';
346
+ const note = step.reason
347
+ ? `
348
+ <path class="quality-flow-note-link ${flowTone(step.state)}" d="M ${centerX} ${y + 88} V ${noteY}" />
349
+ <g>
350
+ <rect class="quality-flow-note ${flowTone(step.state)}" x="${x - 6}" y="${noteY}" width="${width + 12}" height="42" rx="8" />
351
+ ${svgTextBlock(step.reason, centerX, noteY + 21, 'quality-flow-note-text', 15, 14, 'middle')}
352
+ </g>
353
+ `
354
+ : '';
355
+ return `
356
+ ${line}
357
+ <g>
358
+ <rect class="quality-flow-step ${flowTone(step.state)}" x="${x}" y="${y}" width="${width}" height="88" rx="8" />
359
+ <circle class="quality-flow-index ${flowTone(step.state)}" cx="${x + 5}" cy="${y + 5}" r="10" />
360
+ <text class="quality-flow-index-text" x="${x + 5}" y="${y + 5}" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle">${index + 1}</text>
361
+ ${svgPill({ x: centerX - 39, y: y + 13, width: 78, height: 24, tone: flowTone(step.state), label: step.title })}
362
+ ${svgTextBlock(step.status, centerX, y + 61, 'quality-flow-status', 15, 14, 'middle')}
363
+ </g>
364
+ ${note}
365
+ `;
366
+ }).join('\n')}
367
+ </svg>
368
+ </div>
369
+ `;
370
+ }
371
+
372
+ function decisionText(report, required, requiredPassed, failingRequired, tasks) {
373
+ if (report.readiness?.productionReady !== true) {
374
+ return `有 ${failingRequired.length} 项必须先处理,补测后再继续`;
375
+ }
376
+ if (tasks.total > 0) {
377
+ return `需求任务 ${tasks.done}/${tasks.total} 完成,本期必测 ${requiredPassed}/${required.length} 通过`;
378
+ }
379
+ return `全项目检查通过,本期必测 ${requiredPassed}/${required.length} 通过`;
380
+ }
381
+
382
+ function reportScopeText(tasks) {
383
+ return tasks.activeChange ? '当前需求检查' : '全项目检查';
384
+ }
385
+
386
+ function coverageDiagramModel({ report, tasks, required, requiredPassed, failingRequired, advisory }) {
387
+ const productionReady = report.readiness?.productionReady === true;
388
+ const evidenceTotal = evidenceCount(report);
389
+ return {
390
+ scope: {
391
+ title: '检查范围',
392
+ body: reportScopeText(tasks),
393
+ },
394
+ required: {
395
+ title: '必测结果',
396
+ body: `${requiredPassed}/${required.length} 项通过`,
397
+ },
398
+ attention: {
399
+ title: '待处理',
400
+ body: failingRequired.length > 0 ? `${failingRequired.length} 项需补测` : '没有必须修复',
401
+ },
402
+ evidence: {
403
+ title: '验证材料',
404
+ body: evidenceTotal > 0 ? `已留 ${evidenceTotal} 条` : '还缺验证材料',
405
+ },
406
+ final: {
407
+ title: '结论',
408
+ body: productionReady ? '可以继续' : '先补测',
409
+ sub: advisory.length > 0 ? `还需确认 ${advisory.length} 项` : '无需额外确认',
410
+ },
411
+ };
412
+ }
413
+
414
+ function coverageMap({ report, tasks, required, requiredPassed, failingRequired, advisory }) {
415
+ const diagram = coverageDiagramModel({ report, tasks, required, requiredPassed, failingRequired, advisory });
416
+ return `
417
+ <section class="quality-map" aria-labelledby="qualityMapTitle">
418
+ <div class="quality-section-heading">
419
+ ${icon('map')}
420
+ <div>
421
+ <h2 id="qualityMapTitle">回归流程与覆盖图</h2>
422
+ <p>先看测试流程是否走完,再看覆盖范围和结论</p>
423
+ </div>
424
+ </div>
425
+ <div class="quality-map-subheading">回归流程图</div>
426
+ ${regressionFlow({ report, advisory })}
427
+ <div class="quality-map-subheading">测试覆盖图</div>
428
+ <div class="quality-map-canvas">
429
+ <svg viewBox="0 0 960 300" role="img" aria-label="测试覆盖图" preserveAspectRatio="xMidYMid meet">
430
+ <rect class="quality-map-bg" x="2" y="2" width="956" height="296" rx="8" />
431
+ <path class="quality-map-link" d="M 252 93 H 708" />
432
+ <path class="quality-map-link" d="M 480 92 V 204 H 252" />
433
+ <path class="quality-map-link" d="M 480 204 H 708" />
434
+ <g>
435
+ <rect class="quality-map-node scope" x="112" y="48" width="280" height="88" rx="8" />
436
+ ${svgPill({ x: 198, y: 36, width: 108, height: 30, tone: 'scope', label: diagram.scope.title })}
437
+ ${svgTextBlock(diagram.scope.body, 144, 94, 'quality-map-label', 16, 16, 'start')}
438
+ </g>
439
+ <g>
440
+ <rect class="quality-map-node required" x="568" y="48" width="280" height="88" rx="8" />
441
+ ${svgPill({ x: 654, y: 36, width: 108, height: 30, tone: 'required', label: diagram.required.title })}
442
+ ${svgTextBlock(diagram.required.body, 600, 94, 'quality-map-label', 16, 16, 'start')}
443
+ </g>
444
+ <g>
445
+ <rect class="quality-map-node attention" x="112" y="188" width="280" height="82" rx="8" />
446
+ ${svgPill({ x: 198, y: 176, width: 108, height: 30, tone: 'attention', label: diagram.attention.title })}
447
+ ${svgTextBlock(diagram.attention.body, 144, 229, 'quality-map-label', 16, 16, 'start')}
448
+ </g>
449
+ <g>
450
+ <rect class="quality-map-node evidence" x="568" y="188" width="280" height="82" rx="8" />
451
+ ${svgPill({ x: 650, y: 176, width: 116, height: 30, tone: 'evidence', label: diagram.evidence.title })}
452
+ ${svgTextBlock(diagram.evidence.body, 600, 229, 'quality-map-label', 16, 16, 'start')}
453
+ </g>
454
+ <g>
455
+ <rect class="quality-map-center" x="340" y="112" width="280" height="84" rx="8" />
456
+ ${svgPill({ x: 426, y: 100, width: 108, height: 30, tone: 'center', label: diagram.final.title })}
457
+ ${svgTextBlock(diagram.final.body, 372, 154, 'quality-map-label center', 17, 16, 'start')}
458
+ <text class="quality-map-sub" x="480" y="181" text-anchor="middle">${escapeHtml(diagram.final.sub)}</text>
459
+ </g>
460
+ </svg>
461
+ </div>
462
+ </section>
463
+ `;
464
+ }
465
+
466
+ function detailList(items, emptyText) {
467
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
468
+ if (list.length === 0) {
469
+ return `<ul class="quality-list"><li class="empty">${escapeHtml(emptyText)}</li></ul>`;
470
+ }
471
+ return `
472
+ <ul class="quality-list">
473
+ ${list.map((item) => `
474
+ <li><strong>${escapeHtml(item.summary)}</strong><span>:${escapeHtml(item.detail)}</span></li>
475
+ `).join('\n')}
476
+ </ul>
477
+ `;
478
+ }
479
+
480
+ function requiredItems(required) {
481
+ return required.map((gate) => ({
482
+ summary: gateSummary(gate),
483
+ detail: `${gate.required ? '本期必测' : '按风险确认'},${gate.evidence?.summary ?? '等待补充本次证据'}。${gateTreatment(gate)}`,
484
+ }));
485
+ }
486
+
487
+ function exceptionItems(report) {
488
+ return (report.gates ?? [])
489
+ .filter((gate) => gate.status !== 'pass' && gate.status !== 'waived')
490
+ .map((gate) => ({
491
+ summary: gateSummary(gate),
492
+ detail: humanText(gate.warnings?.[0] ?? gateTreatment(gate)).replace(/[。.]$/u, ''),
493
+ }));
494
+ }
495
+
496
+ function evidenceItems(report) {
497
+ return evidenceRows(report)
498
+ .filter((row) => !row.empty)
499
+ .slice(0, 5)
500
+ .map((row) => ({
501
+ summary: gateDisplay(row.gate),
502
+ detail: `${row.source},${row.path}`,
503
+ }));
504
+ }
505
+
506
+ function environmentItems(report) {
507
+ const evalHarness = report.evalHarness;
508
+ const obs = report.observability;
509
+ const knowledge = report.knowledge;
510
+ const businessGuardrails = report.businessGuardrails;
511
+ return [
512
+ {
513
+ summary: evalHarness.smoke.present ? '主流程验证可用' : '主流程验证缺失',
514
+ detail: evalHarness.smoke.commands.join(' / ') || '还没有发现可直接复跑的验证入口',
515
+ },
516
+ {
517
+ summary: '任务覆盖',
518
+ detail: `已完成 ${evalHarness.featureCoverage.activeTasks.done}/${evalHarness.featureCoverage.activeTasks.total},待处理 ${evalHarness.featureCoverage.activeTasks.pending}`,
519
+ },
520
+ {
521
+ summary: '问题追踪',
522
+ detail: obs.correlationFields.length > 0 ? `检测到 ${obs.correlationFields.length} 个追踪字段` : '暂未发现足够的问题追踪线索',
523
+ },
524
+ {
525
+ summary: '成本护栏',
526
+ detail: businessGuardrails.missingEvidence.length > 0 ? businessGuardrails.missingEvidence.join(';') : '当前没有发现成本护栏缺口',
527
+ },
528
+ {
529
+ summary: '经验沉淀',
530
+ detail: knowledge.skills.length > 0 ? `已有 ${knowledge.skills.length} 个项目经验` : '首次稳定问题修复后应沉淀经验',
531
+ },
532
+ ];
533
+ }
534
+
535
+ function panel({ kind, title, description, chips, items, emptyText }) {
536
+ return `
537
+ <section class="quality-panel quality-panel-${escapeHtml(kind)}">
538
+ <header class="quality-panel-head">
539
+ ${icon(kind)}
540
+ <div>
541
+ <h2>${escapeHtml(title)}</h2>
542
+ <p>${escapeHtml(description)}</p>
543
+ </div>
544
+ </header>
545
+ <div class="quality-chip-row">${chipRow(chips, '暂无重点')}</div>
546
+ ${detailList(items, emptyText)}
547
+ </section>
548
+ `;
549
+ }
550
+
551
+ function tableRowsForTasks(tasks, report) {
552
+ const tasksList = tasks.tasks ?? [];
553
+ const required = requiredGates(report);
554
+ const requiredDone = passedRequired(required);
555
+ const failing = required.filter((gate) => !['pass', 'waived'].includes(gate.status));
556
+ const advisory = (report.gates ?? []).filter((gate) => !gate.required && gate.status !== 'pass');
557
+ if (tasksList.length === 0) {
558
+ return `
559
+ <tr>
560
+ <td>当前没有激活需求任务</td>
561
+ <td>项目级必测 ${escapeHtml(`${requiredDone}/${required.length}`)} 通过</td>
562
+ <td>如果这是具体需求交付,应先生成或保留任务清单,再逐项回归。</td>
563
+ </tr>
564
+ `;
565
+ }
566
+ return tasksList.map((task) => {
567
+ const conclusion = !task.done
568
+ ? '不能认可,应完成或明确延期原因。'
569
+ : failing.length > 0
570
+ ? '不能认可,仍有本期必测未通过。'
571
+ : advisory.length > 0
572
+ ? '功能已完成;需确认风险项是否属于本期。'
573
+ : '通过,无需人工评审。';
574
+ return `
575
+ <tr>
576
+ <td>
577
+ <strong>${escapeHtml(task.title)}</strong>
578
+ <span><code>${escapeHtml(`${task.source}:${task.line}`)}</code></span>
579
+ </td>
580
+ <td>${escapeHtml(task.done ? '已完成' : task.blocked ? '阻塞' : '未完成')} · 必测 ${escapeHtml(`${requiredDone}/${required.length}`)}</td>
581
+ <td>${escapeHtml(conclusion)}</td>
582
+ </tr>
583
+ `;
584
+ }).join('\n');
585
+ }
586
+
587
+ function tableRowsForGates(report) {
588
+ return (report.gates ?? []).map((gate) => `
589
+ <tr>
590
+ <td>
591
+ <strong>${escapeHtml(gateDisplay(gate))}</strong>
592
+ <span>${escapeHtml(gateDescription(gate))}</span>
593
+ </td>
594
+ <td>${chip(gate.required ? '本期必测' : '按风险确认', gate.required ? 'fail' : 'note')}</td>
595
+ <td><span class="quality-status ${toneForGate(gate)}">${escapeHtml(statusLabel(gate.status))}</span></td>
596
+ <td>
597
+ <strong>${gate.evidence?.present ? `${gate.evidence.sources.length} 条` : '缺证据'}</strong>
598
+ <span>${escapeHtml(gate.evidence?.summary ?? '未找到本次执行证据')}</span>
599
+ </td>
600
+ <td>${escapeHtml(gateTreatment(gate))}</td>
601
+ </tr>
602
+ `).join('\n');
603
+ }
604
+
605
+ function tableRowsForEvidence(report) {
606
+ return evidenceRows(report).map((row) => `
607
+ <tr>
608
+ <td>${escapeHtml(gateDisplay(row.gate))}</td>
609
+ <td>${escapeHtml(row.source)}</td>
610
+ <td><code>${escapeHtml(row.path)}</code></td>
611
+ <td>${row.gate.required ? '本期必测' : '按风险确认'}</td>
612
+ </tr>
613
+ `).join('\n');
614
+ }
615
+
616
+ function copyContext(report) {
617
+ const required = requiredGates(report);
618
+ const requiredDone = passedRequired(required);
619
+ return JSON.stringify({
620
+ reportId: report.id,
621
+ generatedAt: report.generatedAt,
622
+ decision: report.readiness?.productionReady ? '认可回归' : '需要补测',
623
+ activeChange: report.summary?.activeChange,
624
+ required: `${requiredDone}/${required.length}`,
625
+ needsAction: required
626
+ .filter((gate) => !['pass', 'waived'].includes(gate.status))
627
+ .map(gateDisplay),
628
+ needsConfirmation: (report.gates ?? [])
629
+ .filter((gate) => !gate.required && gate.status !== 'pass')
630
+ .map(gateDisplay),
631
+ }, null, 2);
632
+ }
633
+
634
+ function bottomCopy(report, action) {
635
+ const context = copyContext(report);
636
+ if (action === 'confirm') {
637
+ return [
638
+ 'OpenPrD Quality: 认可回归',
639
+ '',
640
+ '我认可这份回归测试报告,请按这个结论继续推进。',
641
+ '',
642
+ '上下文:',
643
+ context,
644
+ ].join('\n');
645
+ }
646
+ return [
647
+ 'OpenPrD Quality: 需要补测',
648
+ '',
649
+ '我认为这份回归报告还不能直接认可。请先处理下面的遗漏,补完后重新生成报告给我评审。',
650
+ '',
651
+ '建议动作:',
652
+ ...actionItems(report).map((item) => `- ${item}`),
653
+ '',
654
+ '复跑要求:',
655
+ '- openprd quality . --verify',
656
+ '- openprd run . --verify',
657
+ '',
658
+ '上下文:',
659
+ context,
660
+ ].join('\n');
661
+ }
662
+
663
+ function bottomBar(report) {
664
+ return `
665
+ <nav class="quality-bottom-bar" aria-label="回归决定">
666
+ <div class="quality-bottom-bar-inner">
667
+ <button type="button" class="quality-bottom-action revise" data-copy-value="${escapeHtml(bottomCopy(report, 'revise'))}">需要补测</button>
668
+ <button type="button" class="quality-bottom-action confirm" data-copy-value="${escapeHtml(bottomCopy(report, 'confirm'))}">认可回归</button>
669
+ </div>
670
+ </nav>
671
+ `;
672
+ }
673
+
674
+ function frameworkSection(report) {
675
+ return `
676
+ <details class="quality-details-section">
677
+ <summary>给 Agent 的质量报告框架</summary>
678
+ <div class="quality-details-body">
679
+ <section class="quality-detail-section">
680
+ <h2>框架约束</h2>
681
+ <p>这部分用于后续把质量报告沉淀为稳定模板,让 Agent 按结构补充内容,而不是让页面临时裁剪文本。</p>
682
+ <pre class="quality-json">${escapeHtml(JSON.stringify({
683
+ reportId: report.id,
684
+ framework: QUALITY_REPORT_FRAMEWORK,
685
+ }, null, 2))}</pre>
686
+ </section>
687
+ </div>
688
+ </details>
689
+ `;
690
+ }
691
+
692
+ function styles() {
693
+ return `
694
+ :root {
695
+ color-scheme: light;
696
+ --quality-bg: #f6f8fb;
697
+ --quality-panel: #ffffff;
698
+ --quality-soft: #f9fafb;
699
+ --quality-text: #172033;
700
+ --quality-muted: #667085;
701
+ --quality-line: #d8dee8;
702
+ --quality-blue: #2563eb;
703
+ --quality-teal: #0f766e;
704
+ --quality-indigo: #4f46e5;
705
+ --quality-amber: #b45309;
706
+ --quality-red: #dc2626;
707
+ --quality-green: #15803d;
708
+ --quality-mono: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
709
+ }
710
+ * { box-sizing: border-box; }
711
+ body {
712
+ margin: 0;
713
+ background: var(--quality-bg);
714
+ color: var(--quality-text);
715
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
716
+ overflow-x: hidden;
717
+ }
718
+ .quality-page {
719
+ max-width: 1220px;
720
+ margin: 0 auto;
721
+ padding: 28px 22px 126px;
722
+ }
723
+ .quality-topbar {
724
+ display: flex;
725
+ align-items: center;
726
+ justify-content: space-between;
727
+ gap: 16px;
728
+ margin-bottom: 16px;
729
+ }
730
+ .quality-brand {
731
+ display: inline-flex;
732
+ align-items: center;
733
+ min-height: 34px;
734
+ border: 1px solid var(--quality-line);
735
+ border-radius: 999px;
736
+ background: var(--quality-panel);
737
+ color: var(--quality-muted);
738
+ padding: 0 12px;
739
+ font-size: 13px;
740
+ font-weight: 750;
741
+ }
742
+ .quality-top-meta,
743
+ .quality-summary-row,
744
+ .quality-chip-row {
745
+ display: flex;
746
+ flex-wrap: wrap;
747
+ gap: 8px;
748
+ }
749
+ .quality-top-meta { justify-content: flex-end; }
750
+ .quality-overview,
751
+ .quality-map,
752
+ .quality-panel,
753
+ .quality-details-section,
754
+ .quality-appendix {
755
+ border: 1px solid var(--quality-line);
756
+ border-radius: 8px;
757
+ background: var(--quality-panel);
758
+ box-shadow: 0 16px 34px rgba(15, 23, 42, 0.06);
759
+ }
760
+ .quality-overview { padding: 24px; }
761
+ .quality-kicker {
762
+ margin: 0 0 6px;
763
+ color: var(--quality-muted);
764
+ font-size: 13px;
765
+ font-weight: 850;
766
+ }
767
+ .quality-overview h1,
768
+ .quality-map h2,
769
+ .quality-panel h2,
770
+ .quality-detail-section h2 {
771
+ margin: 0;
772
+ color: var(--quality-text);
773
+ letter-spacing: 0;
774
+ }
775
+ .quality-overview h1 {
776
+ font-size: 34px;
777
+ line-height: 1.16;
778
+ }
779
+ .quality-subtitle {
780
+ margin: 12px 0 0;
781
+ max-width: 880px;
782
+ color: var(--quality-muted);
783
+ font-size: 16px;
784
+ line-height: 1.7;
785
+ }
786
+ .quality-summary-row { margin-top: 18px; }
787
+ .quality-chip {
788
+ display: inline-flex;
789
+ align-items: center;
790
+ width: fit-content;
791
+ min-height: 30px;
792
+ padding: 5px 10px;
793
+ border-radius: 999px;
794
+ border: 1px solid var(--quality-line);
795
+ background: #ffffff;
796
+ color: var(--quality-text);
797
+ font-size: 13px;
798
+ font-weight: 760;
799
+ line-height: 1.25;
800
+ white-space: nowrap;
801
+ }
802
+ .quality-chip.pass { border-color: #bbf7d0; background: #ecfdf3; color: var(--quality-green); }
803
+ .quality-chip.fail { border-color: #fecaca; background: #fff1f2; color: var(--quality-red); }
804
+ .quality-chip.warn { border-color: #fde68a; background: #fffbeb; color: var(--quality-amber); }
805
+ .quality-chip.note { border-color: #bfdbfe; background: #eff6ff; color: var(--quality-blue); }
806
+ .quality-chip.muted { color: var(--quality-muted); background: #ffffff; }
807
+ .quality-section-heading,
808
+ .quality-panel-head {
809
+ display: flex;
810
+ align-items: flex-start;
811
+ gap: 12px;
812
+ }
813
+ .quality-icon {
814
+ flex: 0 0 auto;
815
+ display: inline-flex;
816
+ width: 38px;
817
+ height: 38px;
818
+ align-items: center;
819
+ justify-content: center;
820
+ border-radius: 8px;
821
+ }
822
+ .quality-icon svg {
823
+ width: 22px;
824
+ height: 22px;
825
+ fill: none;
826
+ stroke: currentColor;
827
+ stroke-width: 2;
828
+ stroke-linecap: round;
829
+ stroke-linejoin: round;
830
+ }
831
+ .quality-icon-map { color: var(--quality-indigo); background: #eef2ff; }
832
+ .quality-icon-check { color: var(--quality-teal); background: #ccfbf1; }
833
+ .quality-icon-alert { color: var(--quality-red); background: #fee2e2; }
834
+ .quality-icon-evidence { color: var(--quality-blue); background: #dbeafe; }
835
+ .quality-icon-environment { color: var(--quality-amber); background: #fef3c7; }
836
+ .quality-map {
837
+ margin-top: 18px;
838
+ padding: 20px;
839
+ }
840
+ .quality-map h2 { font-size: 22px; }
841
+ .quality-section-heading p {
842
+ margin: 5px 0 0;
843
+ color: var(--quality-muted);
844
+ font-size: 14px;
845
+ line-height: 1.55;
846
+ }
847
+ .quality-map-subheading {
848
+ margin-top: 16px;
849
+ color: var(--quality-muted);
850
+ font-size: 13px;
851
+ font-weight: 850;
852
+ }
853
+ .quality-map-canvas,
854
+ .quality-flow-canvas {
855
+ margin-top: 14px;
856
+ overflow-x: auto;
857
+ max-width: 100%;
858
+ }
859
+ .quality-map-canvas svg,
860
+ .quality-flow-canvas svg {
861
+ display: block;
862
+ width: 100%;
863
+ min-width: 700px;
864
+ height: auto;
865
+ }
866
+ .quality-map-bg { fill: #f8fafc; stroke: #e2e8f0; }
867
+ .quality-map-link {
868
+ fill: none;
869
+ stroke: #a5b4fc;
870
+ stroke-width: 2.5;
871
+ stroke-linecap: round;
872
+ }
873
+ .quality-map-node,
874
+ .quality-map-center {
875
+ fill: #ffffff;
876
+ stroke: #cbd5e1;
877
+ stroke-width: 1.6;
878
+ filter: drop-shadow(0 12px 18px rgba(15, 23, 42, 0.08));
879
+ }
880
+ .quality-map-center {
881
+ fill: #eef2ff;
882
+ stroke: #818cf8;
883
+ }
884
+ .quality-map-node.scope { stroke: #5eead4; }
885
+ .quality-map-node.required { stroke: #93c5fd; }
886
+ .quality-map-node.attention { stroke: #fde68a; }
887
+ .quality-map-node.evidence { stroke: #fecaca; }
888
+ .quality-map-pill {
889
+ fill: #ffffff;
890
+ stroke-width: 1.4;
891
+ }
892
+ .quality-map-pill.scope { fill: #ccfbf1; stroke: #5eead4; }
893
+ .quality-map-pill.required { fill: #dbeafe; stroke: #93c5fd; }
894
+ .quality-map-pill.attention { fill: #fef3c7; stroke: #facc15; }
895
+ .quality-map-pill.evidence { fill: #fee2e2; stroke: #fca5a5; }
896
+ .quality-map-pill.center { fill: #e0e7ff; stroke: #a5b4fc; }
897
+ .quality-map-pill.pass { fill: #dcfce7; stroke: #86efac; }
898
+ .quality-map-pill.fail { fill: #fee2e2; stroke: #fca5a5; }
899
+ .quality-map-pill.warn { fill: #fef3c7; stroke: #facc15; }
900
+ .quality-map-pill-text {
901
+ font-size: 12px;
902
+ font-weight: 850;
903
+ dominant-baseline: middle;
904
+ alignment-baseline: middle;
905
+ }
906
+ .quality-map-pill-text.scope { fill: #0f766e; }
907
+ .quality-map-pill-text.required { fill: #2563eb; }
908
+ .quality-map-pill-text.attention { fill: #b45309; }
909
+ .quality-map-pill-text.evidence { fill: #dc2626; }
910
+ .quality-map-pill-text.center { fill: #4f46e5; }
911
+ .quality-map-pill-text.pass { fill: var(--quality-green); }
912
+ .quality-map-pill-text.fail { fill: var(--quality-red); }
913
+ .quality-map-pill-text.warn { fill: var(--quality-amber); }
914
+ .quality-map-label {
915
+ fill: var(--quality-text);
916
+ font-size: 14px;
917
+ font-weight: 780;
918
+ }
919
+ .quality-map-label.center { font-size: 15px; }
920
+ .quality-map-sub {
921
+ fill: var(--quality-muted);
922
+ font-size: 12px;
923
+ font-weight: 750;
924
+ }
925
+ .quality-flow-link {
926
+ fill: none;
927
+ stroke: #cbd5e1;
928
+ stroke-width: 2.2;
929
+ stroke-linecap: round;
930
+ marker-end: url(#quality-flow-arrow);
931
+ }
932
+ .quality-flow-arrow-head { fill: #94a3b8; }
933
+ .quality-flow-step {
934
+ fill: #ffffff;
935
+ stroke-width: 1.6;
936
+ filter: drop-shadow(0 10px 16px rgba(15, 23, 42, 0.07));
937
+ }
938
+ .quality-flow-step.pass { stroke: #86efac; }
939
+ .quality-flow-step.fail { stroke: #fca5a5; fill: #fff7f7; }
940
+ .quality-flow-step.warn { stroke: #facc15; fill: #fffbeb; }
941
+ .quality-flow-index.pass { fill: #10b981; }
942
+ .quality-flow-index.fail { fill: #ef4444; }
943
+ .quality-flow-index.warn { fill: #d97706; }
944
+ .quality-flow-index {
945
+ stroke: #ffffff;
946
+ stroke-width: 2.5;
947
+ }
948
+ .quality-flow-index-text {
949
+ fill: #ffffff;
950
+ font-size: 11px;
951
+ font-weight: 900;
952
+ }
953
+ .quality-flow-status {
954
+ fill: var(--quality-text);
955
+ font-size: 13px;
956
+ font-weight: 850;
957
+ }
958
+ .quality-flow-note-link {
959
+ fill: none;
960
+ stroke-width: 1.8;
961
+ stroke-dasharray: 4 5;
962
+ stroke-linecap: round;
963
+ }
964
+ .quality-flow-note-link.fail { stroke: #f87171; }
965
+ .quality-flow-note-link.warn { stroke: #f59e0b; }
966
+ .quality-flow-note {
967
+ stroke-width: 1.5;
968
+ filter: drop-shadow(0 10px 16px rgba(15, 23, 42, 0.06));
969
+ }
970
+ .quality-flow-note.fail { fill: #fff1f2; stroke: #fca5a5; }
971
+ .quality-flow-note.warn { fill: #fffbeb; stroke: #facc15; }
972
+ .quality-flow-note-text {
973
+ fill: var(--quality-text);
974
+ font-size: 12px;
975
+ font-weight: 800;
976
+ }
977
+ .quality-panel-grid {
978
+ display: grid;
979
+ grid-template-columns: repeat(2, minmax(0, 1fr));
980
+ gap: 16px;
981
+ margin-top: 18px;
982
+ }
983
+ .quality-panel {
984
+ min-height: 280px;
985
+ padding: 18px;
986
+ }
987
+ .quality-panel h2 { font-size: 20px; }
988
+ .quality-panel-head p {
989
+ margin: 5px 0 0;
990
+ color: var(--quality-muted);
991
+ font-size: 14px;
992
+ line-height: 1.55;
993
+ }
994
+ .quality-chip-row {
995
+ margin-top: 16px;
996
+ padding: 12px;
997
+ border: 1px solid var(--quality-line);
998
+ border-radius: 8px;
999
+ background: var(--quality-soft);
1000
+ }
1001
+ .quality-list {
1002
+ margin: 16px 0 0;
1003
+ padding-left: 18px;
1004
+ color: var(--quality-text);
1005
+ font-size: 15px;
1006
+ line-height: 1.72;
1007
+ overflow-wrap: anywhere;
1008
+ }
1009
+ .quality-list li + li { margin-top: 9px; }
1010
+ .quality-list strong {
1011
+ font-weight: 850;
1012
+ color: var(--quality-text);
1013
+ }
1014
+ .quality-list span { color: var(--quality-text); }
1015
+ .quality-list .empty { color: var(--quality-muted); }
1016
+ .quality-status {
1017
+ display: inline-flex;
1018
+ align-items: center;
1019
+ min-height: 28px;
1020
+ padding: 5px 9px;
1021
+ border-radius: 999px;
1022
+ border: 1px solid var(--quality-line);
1023
+ font-size: 12px;
1024
+ font-weight: 850;
1025
+ }
1026
+ .quality-status.pass { border-color: #bbf7d0; background: #ecfdf3; color: var(--quality-green); }
1027
+ .quality-status.fail { border-color: #fecaca; background: #fff1f2; color: var(--quality-red); }
1028
+ .quality-status.warn { border-color: #fde68a; background: #fffbeb; color: var(--quality-amber); }
1029
+ .quality-status.note { border-color: #bfdbfe; background: #eff6ff; color: var(--quality-blue); }
1030
+ .quality-details-section,
1031
+ .quality-appendix {
1032
+ margin-top: 16px;
1033
+ overflow: hidden;
1034
+ }
1035
+ .quality-details-section > summary,
1036
+ .quality-appendix > summary {
1037
+ cursor: pointer;
1038
+ list-style: none;
1039
+ padding: 16px 18px;
1040
+ color: var(--quality-text);
1041
+ font-weight: 850;
1042
+ }
1043
+ .quality-details-section > summary::-webkit-details-marker,
1044
+ .quality-appendix > summary::-webkit-details-marker { display: none; }
1045
+ .quality-details-section > summary::after,
1046
+ .quality-appendix > summary::after {
1047
+ content: "展开";
1048
+ float: right;
1049
+ color: var(--quality-muted);
1050
+ font-size: 12px;
1051
+ font-weight: 750;
1052
+ }
1053
+ .quality-details-section[open] > summary::after,
1054
+ .quality-appendix[open] > summary::after { content: "收起"; }
1055
+ .quality-details-body {
1056
+ border-top: 1px solid var(--quality-line);
1057
+ padding: 0 16px 16px;
1058
+ }
1059
+ .quality-detail-section {
1060
+ margin-top: 16px;
1061
+ border: 1px solid var(--quality-line);
1062
+ border-radius: 8px;
1063
+ background: var(--quality-panel);
1064
+ overflow: hidden;
1065
+ }
1066
+ .quality-detail-section h2 {
1067
+ padding: 14px 16px 0;
1068
+ font-size: 18px;
1069
+ }
1070
+ .quality-detail-section p {
1071
+ margin: 6px 0 0;
1072
+ padding: 0 16px 14px;
1073
+ color: var(--quality-muted);
1074
+ font-size: 13px;
1075
+ line-height: 1.55;
1076
+ }
1077
+ .quality-table {
1078
+ width: 100%;
1079
+ border-collapse: collapse;
1080
+ }
1081
+ .quality-table th,
1082
+ .quality-table td {
1083
+ padding: 13px 16px;
1084
+ border-top: 1px solid var(--quality-line);
1085
+ text-align: left;
1086
+ vertical-align: top;
1087
+ font-size: 13px;
1088
+ }
1089
+ .quality-table th {
1090
+ color: var(--quality-muted);
1091
+ background: var(--quality-soft);
1092
+ font-weight: 750;
1093
+ }
1094
+ .quality-table td strong,
1095
+ .quality-table td span {
1096
+ display: block;
1097
+ }
1098
+ .quality-table td span {
1099
+ margin-top: 4px;
1100
+ color: var(--quality-muted);
1101
+ }
1102
+ code {
1103
+ color: var(--quality-blue);
1104
+ font-family: var(--quality-mono);
1105
+ font-size: 12px;
1106
+ word-break: break-word;
1107
+ }
1108
+ .quality-json {
1109
+ max-height: 520px;
1110
+ overflow: auto;
1111
+ margin: 0;
1112
+ padding: 16px;
1113
+ border-top: 1px solid var(--quality-line);
1114
+ background: var(--quality-soft);
1115
+ color: var(--quality-text);
1116
+ font-family: var(--quality-mono);
1117
+ font-size: 12px;
1118
+ line-height: 1.6;
1119
+ }
1120
+ .quality-bottom-bar {
1121
+ position: fixed;
1122
+ left: 0;
1123
+ right: 0;
1124
+ bottom: 0;
1125
+ z-index: 30;
1126
+ padding: 12px 22px calc(12px + env(safe-area-inset-bottom));
1127
+ border-top: 1px solid var(--quality-line);
1128
+ background: rgba(246, 248, 251, 0.94);
1129
+ box-shadow: 0 -14px 32px rgba(15, 23, 42, 0.08);
1130
+ backdrop-filter: blur(14px);
1131
+ }
1132
+ .quality-bottom-bar-inner {
1133
+ display: flex;
1134
+ justify-content: flex-end;
1135
+ gap: 12px;
1136
+ max-width: 1220px;
1137
+ margin: 0 auto;
1138
+ }
1139
+ .quality-bottom-action {
1140
+ cursor: pointer;
1141
+ display: inline-flex;
1142
+ align-items: center;
1143
+ justify-content: center;
1144
+ min-width: 152px;
1145
+ min-height: 48px;
1146
+ border: 1px solid transparent;
1147
+ border-radius: 12px;
1148
+ padding: 0 20px;
1149
+ font: inherit;
1150
+ font-size: 16px;
1151
+ font-weight: 850;
1152
+ line-height: 1;
1153
+ white-space: nowrap;
1154
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
1155
+ transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
1156
+ }
1157
+ .quality-bottom-action.revise {
1158
+ border-color: #fecaca;
1159
+ background: #fff1f2;
1160
+ color: #b42318;
1161
+ }
1162
+ .quality-bottom-action.confirm {
1163
+ border-color: #bbf7d0;
1164
+ background: #ecfdf3;
1165
+ color: #067647;
1166
+ }
1167
+ .quality-bottom-action:hover,
1168
+ .quality-bottom-action:focus-visible {
1169
+ box-shadow: 0 10px 22px rgba(15, 23, 42, 0.1);
1170
+ transform: translateY(-1px);
1171
+ outline: none;
1172
+ }
1173
+ .quality-bottom-action.revise:hover,
1174
+ .quality-bottom-action.revise:focus-visible {
1175
+ border-color: #fda4af;
1176
+ background: #ffe4e6;
1177
+ }
1178
+ .quality-bottom-action.confirm:hover,
1179
+ .quality-bottom-action.confirm:focus-visible {
1180
+ border-color: #86efac;
1181
+ background: #dcfce7;
1182
+ }
1183
+ .quality-bottom-action:active {
1184
+ box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
1185
+ transform: translateY(0);
1186
+ }
1187
+ @media (max-width: 860px) {
1188
+ .quality-topbar { align-items: flex-start; flex-direction: column; }
1189
+ .quality-top-meta { justify-content: flex-start; }
1190
+ .quality-panel-grid { grid-template-columns: 1fr; }
1191
+ .quality-map-canvas svg,
1192
+ .quality-flow-canvas svg { min-width: 680px; }
1193
+ }
1194
+ @media (max-width: 620px) {
1195
+ .quality-page { padding: 18px 12px 128px; }
1196
+ .quality-overview { padding: 18px; }
1197
+ .quality-overview h1 { font-size: 28px; }
1198
+ .quality-map { padding: 16px; }
1199
+ .quality-panel { padding: 16px; }
1200
+ .quality-table { display: block; overflow-x: auto; white-space: nowrap; }
1201
+ .quality-bottom-bar { padding-inline: 12px; }
1202
+ .quality-bottom-bar-inner {
1203
+ display: grid;
1204
+ grid-template-columns: 1fr 1fr;
1205
+ gap: 8px;
1206
+ }
1207
+ .quality-bottom-action {
1208
+ justify-content: center;
1209
+ min-width: 0;
1210
+ padding-inline: 10px;
1211
+ font-size: 15px;
1212
+ }
1213
+ }
1214
+ `;
1215
+ }
1216
+
1217
+ function qualityScript() {
1218
+ return `
1219
+ async function copyQualityText(text, button) {
1220
+ try {
1221
+ if (navigator.clipboard && window.isSecureContext) {
1222
+ await navigator.clipboard.writeText(text);
1223
+ } else {
1224
+ const textarea = document.createElement('textarea');
1225
+ textarea.value = text;
1226
+ document.body.appendChild(textarea);
1227
+ textarea.focus();
1228
+ textarea.select();
1229
+ document.execCommand('copy');
1230
+ textarea.remove();
1231
+ }
1232
+ const old = button.textContent;
1233
+ button.textContent = '已复制';
1234
+ setTimeout(() => { button.textContent = old; }, 1200);
1235
+ } catch (error) {
1236
+ button.textContent = '请手动复制';
1237
+ }
1238
+ }
1239
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
1240
+ button.addEventListener('click', () => copyQualityText(button.dataset.copyValue || '', button));
1241
+ });
1242
+ `;
1243
+ }
1244
+
1245
+ export function renderQualityEvalArtifact({ report }) {
1246
+ const tasks = activeTasks(report);
1247
+ const required = requiredGates(report);
1248
+ const requiredDone = passedRequired(required);
1249
+ const failingRequired = required.filter((gate) => !['pass', 'waived'].includes(gate.status));
1250
+ const advisory = (report.gates ?? []).filter((gate) => !gate.required && gate.status !== 'pass');
1251
+ const labels = policyLabels(report);
1252
+ const productionReady = report.readiness?.productionReady === true;
1253
+ const decisionLabel = productionReady ? '整体通过' : '先处理问题';
1254
+ const decisionDetail = decisionText(report, required, requiredDone, failingRequired, tasks);
1255
+ const rawJson = JSON.stringify(report, null, 2);
1256
+
1257
+ const panels = [
1258
+ panel({
1259
+ kind: 'check',
1260
+ title: '本期必测结果',
1261
+ description: '先看必须覆盖的测试是否通过,没通过就不要继续',
1262
+ chips: required.map((gate) => chip(gateSummary(gate), toneForGate(gate))),
1263
+ items: requiredItems(required),
1264
+ emptyText: '当前没有被判定为本期必测的测试块。',
1265
+ }),
1266
+ panel({
1267
+ kind: 'alert',
1268
+ title: '需要处理 / 需确认',
1269
+ description: '只把不通过或需要业务取舍的内容放在这里',
1270
+ chips: (report.gates ?? [])
1271
+ .filter((gate) => gate.status !== 'pass' && gate.status !== 'waived')
1272
+ .map((gate) => chip(gateSummary(gate), toneForGate(gate))),
1273
+ items: exceptionItems(report),
1274
+ emptyText: '没有未通过或需确认项,可以重点确认报告是否对应本次需求。',
1275
+ }),
1276
+ panel({
1277
+ kind: 'evidence',
1278
+ title: '验证材料',
1279
+ description: '确认结论不是只看通过标记,而是能追到本次证据',
1280
+ chips: [
1281
+ chip(`${evidenceCount(report)} 条验证材料`, evidenceCount(report) > 0 ? 'pass' : 'warn'),
1282
+ chip(`扫描 ${report.summary.filesScanned} 个文件`, 'neutral'),
1283
+ chip(reportScopeText(tasks), 'note'),
1284
+ ],
1285
+ items: evidenceItems(report),
1286
+ emptyText: '还没有找到本次执行证据。',
1287
+ }),
1288
+ panel({
1289
+ kind: 'environment',
1290
+ title: '执行环境与覆盖',
1291
+ description: '区分项目具备测试能力,和这次是否真的留下证据',
1292
+ chips: [
1293
+ chip(report.evalHarness.smoke.present ? '主流程验证可用' : '缺主流程验证', report.evalHarness.smoke.present ? 'pass' : 'warn'),
1294
+ chip(report.observability.correlationFields.length > 0 ? '问题可追踪' : '追踪线索不足', report.observability.correlationFields.length > 0 ? 'pass' : 'warn'),
1295
+ chip(report.businessGuardrails.missingEvidence.length > 0 ? '成本护栏待补' : '成本护栏完整', report.businessGuardrails.missingEvidence.length > 0 ? 'warn' : 'pass'),
1296
+ ],
1297
+ items: environmentItems(report),
1298
+ emptyText: '还没有检测到执行环境信息。',
1299
+ }),
1300
+ ].join('\n');
1301
+
1302
+ return `<!DOCTYPE html>
1303
+ <html lang="zh-CN">
1304
+ <head>
1305
+ <meta charset="UTF-8" />
1306
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1307
+ <title>回归测试报告 ${escapeHtml(report.id)}</title>
1308
+ <style>${styles()}</style>
1309
+ </head>
1310
+ <body>
1311
+ <main class="quality-page">
1312
+ <header class="quality-topbar">
1313
+ <div class="quality-brand">OpenPrd / 回归测试报告</div>
1314
+ <div class="quality-top-meta">
1315
+ ${chip(report.generatedAt, 'neutral')}
1316
+ ${chip(report.id, 'note')}
1317
+ </div>
1318
+ </header>
1319
+
1320
+ <section class="quality-overview" aria-labelledby="qualityOverviewTitle">
1321
+ <p class="quality-kicker">回归结论概览</p>
1322
+ <h1 id="qualityOverviewTitle">${escapeHtml(decisionLabel)}</h1>
1323
+ <p class="quality-subtitle">${escapeHtml(decisionDetail)}</p>
1324
+ <div class="quality-summary-row">
1325
+ ${chip(`本期必测 ${requiredDone}/${required.length}`, failingRequired.length === 0 ? 'pass' : 'fail')}
1326
+ ${chip(`需要处理 ${failingRequired.length}`, failingRequired.length === 0 ? 'pass' : 'fail')}
1327
+ ${chip(`需确认 ${advisory.length}`, advisory.length === 0 ? 'pass' : 'warn')}
1328
+ ${chip(`验证材料 ${evidenceCount(report)} 条`, evidenceCount(report) > 0 ? 'pass' : 'warn')}
1329
+ ${chip(reportScopeText(tasks), 'note')}
1330
+ </div>
1331
+ </section>
1332
+
1333
+ ${coverageMap({ report, tasks, required, requiredPassed: requiredDone, failingRequired, advisory })}
1334
+
1335
+ <section class="quality-panel-grid" aria-label="回归测试固定模块">
1336
+ ${panels}
1337
+ </section>
1338
+
1339
+ <details class="quality-details-section">
1340
+ <summary>更多细节</summary>
1341
+ <div class="quality-details-body">
1342
+ <section class="quality-detail-section">
1343
+ <h2>本次范围</h2>
1344
+ <p>场景、必测项和按风险确认项。</p>
1345
+ <div class="quality-chip-row">
1346
+ ${chipRow(labels.scenarioLabels.map((item) => chip(item, 'note')))}
1347
+ ${chipRow(labels.requiredLabels.map((item) => chip(`本期必测:${item}`, 'fail')))}
1348
+ ${chipRow(labels.optionalLabels.map((item) => chip(`按风险确认:${item}`, 'note')))}
1349
+ </div>
1350
+ </section>
1351
+
1352
+ <section class="quality-detail-section">
1353
+ <h2>需求模块</h2>
1354
+ <p>只看交付范围是否逐项验收。</p>
1355
+ <table class="quality-table">
1356
+ <thead>
1357
+ <tr>
1358
+ <th>需求模块</th>
1359
+ <th>结果</th>
1360
+ <th>结论</th>
1361
+ </tr>
1362
+ </thead>
1363
+ <tbody>${tableRowsForTasks(tasks, report)}</tbody>
1364
+ </table>
1365
+ </section>
1366
+
1367
+ <section class="quality-detail-section">
1368
+ <h2>测试块回归明细</h2>
1369
+ <p>按本期需求相关的测试块展示证据和处理方式。</p>
1370
+ <table class="quality-table">
1371
+ <thead>
1372
+ <tr>
1373
+ <th>测试块</th>
1374
+ <th>本期要求</th>
1375
+ <th>状态</th>
1376
+ <th>本次证据</th>
1377
+ <th>处理方式</th>
1378
+ </tr>
1379
+ </thead>
1380
+ <tbody>${tableRowsForGates(report)}</tbody>
1381
+ </table>
1382
+ </section>
1383
+
1384
+ <section class="quality-detail-section">
1385
+ <h2>证据链</h2>
1386
+ <p>把每个测试块映射到本次报告使用的证据源,方便复核。</p>
1387
+ <table class="quality-table">
1388
+ <thead>
1389
+ <tr>
1390
+ <th>测试块</th>
1391
+ <th>来源</th>
1392
+ <th>路径或信号</th>
1393
+ <th>本期要求</th>
1394
+ </tr>
1395
+ </thead>
1396
+ <tbody>${tableRowsForEvidence(report)}</tbody>
1397
+ </table>
1398
+ </section>
1399
+ </div>
1400
+ </details>
1401
+
1402
+ ${frameworkSection(report)}
1403
+
1404
+ <details class="quality-appendix">
1405
+ <summary>附录:结构化 JSON、基线和扫描细节</summary>
1406
+ <pre class="quality-json">${escapeHtml(rawJson)}</pre>
1407
+ </details>
1408
+
1409
+ ${bottomBar(report)}
1410
+ </main>
1411
+ <script>${qualityScript()}</script>
1412
+ </body>
1413
+ </html>`;
1414
+ }