@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.
- package/.openprd/README.md +82 -0
- package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
- package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
- package/.openprd/benchmarks/index.md +37 -0
- package/.openprd/benchmarks/sources.yaml +56 -0
- package/.openprd/config.yaml +50 -0
- package/.openprd/discovery/config.json +21 -0
- package/.openprd/engagements/active/flows.md +30 -0
- package/.openprd/engagements/active/handoff.md +9 -0
- package/.openprd/engagements/active/intake.md +15 -0
- package/.openprd/engagements/active/prd.md +161 -0
- package/.openprd/engagements/active/review.html +61 -0
- package/.openprd/engagements/active/roles.md +21 -0
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
- package/.openprd/exports/.gitkeep +0 -0
- package/.openprd/knowledge/index.json +7 -0
- package/.openprd/quality/config.json +229 -0
- package/.openprd/reviews/v0001.html +1256 -0
- package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
- package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
- package/.openprd/schema/prd.schema.yaml +121 -0
- package/.openprd/sessions/.gitkeep +0 -0
- package/.openprd/standards/config.json +88 -0
- package/.openprd/standards/file-manual-template.md +28 -0
- package/.openprd/standards/folder-readme-template.md +28 -0
- package/.openprd/state/.gitkeep +0 -0
- package/.openprd/state/changes.json +12 -0
- package/.openprd/state/current.json +169 -0
- package/.openprd/state/version-index.json +15 -0
- package/.openprd/state/versions/.gitkeep +0 -0
- package/.openprd/state/versions/v0001.json +121 -0
- package/.openprd/state/versions/v0001.md +161 -0
- package/.openprd/templates/agent/intake.md +6 -0
- package/.openprd/templates/agent/prd.md +21 -0
- package/.openprd/templates/b2b/intake.md +6 -0
- package/.openprd/templates/b2b/prd.md +24 -0
- package/.openprd/templates/base/intake.md +18 -0
- package/.openprd/templates/base/prd.md +67 -0
- package/.openprd/templates/company/README.md +10 -0
- package/.openprd/templates/consumer/intake.md +6 -0
- package/.openprd/templates/consumer/prd.md +19 -0
- package/.openprd/templates/diagram/architecture.contract.json +53 -0
- package/.openprd/templates/diagram/product-flow.contract.json +76 -0
- package/.openprd/templates/industry/README.md +16 -0
- package/.openprd/templates/manifest.yaml +27 -0
- package/.openprd/templates/project/README.md +14 -0
- package/.openprd/templates/session/README.md +14 -0
- package/AGENTS.md +44 -0
- package/CONTRIBUTING.md +30 -0
- package/LICENSE +21 -0
- package/README.md +727 -0
- package/README_CN.md +583 -0
- package/SECURITY.md +23 -0
- package/bin/openprd.js +5 -0
- package/docs/assets/openprd-capability-overview-en.png +0 -0
- package/docs/assets/openprd-capability-overview-zh.png +0 -0
- package/docs/assets/openprd-learning-html.png +0 -0
- package/docs/assets/openprd-quality-html.png +0 -0
- package/docs/assets/openprd-review-html.png +0 -0
- package/docs/assets/openprd-scenario-overview.png +0 -0
- package/docs/assets/openprd-scenario-overview.svg +114 -0
- package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
- package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
- package/package.json +59 -0
- package/scripts/openprd-dev-check.mjs +5 -0
- package/scripts/openprd-review-presentation.mjs +82 -0
- package/skills/openprd-benchmark-router/SKILL.md +92 -0
- package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
- package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
- package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
- package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
- package/skills/openprd-diagram-review/SKILL.md +91 -0
- package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
- package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
- package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
- package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
- package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
- package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
- package/skills/openprd-discovery-loop/SKILL.md +196 -0
- package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
- package/skills/openprd-harness/SKILL.md +179 -0
- package/skills/openprd-harness/agents/openai.yaml +4 -0
- package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
- package/skills/openprd-harness/references/command-map.md +71 -0
- package/skills/openprd-harness/references/examples.md +26 -0
- package/skills/openprd-harness/references/usage-guide.md +335 -0
- package/skills/openprd-harness/references/workflow-gates.md +51 -0
- package/skills/openprd-learning-review/SKILL.md +75 -0
- package/skills/openprd-learning-review/agents/openai.yaml +4 -0
- package/skills/openprd-learning-review/references/content-contract.md +125 -0
- package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
- package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
- package/skills/openprd-learning-review/references/genre-library.md +43 -0
- package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
- package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
- package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
- package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
- package/skills/openprd-quality/SKILL.md +101 -0
- package/skills/openprd-requirement-intake/SKILL.md +76 -0
- package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
- package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
- package/skills/openprd-router/SKILL.md +40 -0
- package/skills/openprd-shared/SKILL.md +142 -0
- package/skills/openprd-shared/agents/openai.yaml +4 -0
- package/skills/openprd-shared/references/language-and-review.md +50 -0
- package/skills/openprd-shared/references/operating-rules.md +65 -0
- package/skills/openprd-shared/references/skill-architecture.md +70 -0
- package/skills/openprd-standards/SKILL.md +79 -0
- package/skills/openprd-standards/agents/openai.yaml +4 -0
- package/src/agent-integration.js +1717 -0
- package/src/benchmark.js +873 -0
- package/src/cli/args.js +460 -0
- package/src/cli/print.js +1423 -0
- package/src/codex-hook-runner-template.mjs +2422 -0
- package/src/dev-standards.js +372 -0
- package/src/diagram-core.js +1047 -0
- package/src/diagram-workspace.js +262 -0
- package/src/discovery.js +709 -0
- package/src/fleet.js +531 -0
- package/src/fs-utils.js +83 -0
- package/src/growth.js +545 -0
- package/src/html-artifacts.js +3803 -0
- package/src/knowledge.js +668 -0
- package/src/language-policy.js +142 -0
- package/src/learning-review.js +1655 -0
- package/src/loop.js +1290 -0
- package/src/openprd.js +1136 -0
- package/src/openspec/change-lifecycle.js +359 -0
- package/src/openspec/change-validate.js +248 -0
- package/src/openspec/constants.js +12 -0
- package/src/openspec/execute.js +300 -0
- package/src/openspec/generate.js +692 -0
- package/src/openspec/paths.js +111 -0
- package/src/openspec/tasks.js +352 -0
- package/src/prd-core.js +656 -0
- package/src/quality-html-artifact.js +1414 -0
- package/src/quality-learning.js +658 -0
- package/src/quality.js +1262 -0
- package/src/review-presentation.js +240 -0
- package/src/run-harness.js +1470 -0
- package/src/self-update.js +329 -0
- package/src/session-binding.js +140 -0
- package/src/source-inventory.js +224 -0
- package/src/standards.js +914 -0
- package/src/time.js +33 -0
- package/src/visual-compare.js +216 -0
- package/src/work-unit-migration.js +232 -0
- package/src/work-unit.js +88 -0
- package/src/workspace-core.js +1706 -0
- package/src/workspace-registry.js +162 -0
- package/src/workspace-workflow.js +1797 -0
|
@@ -0,0 +1,3803 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { cjoin, writeText } from './fs-utils.js';
|
|
5
|
+
import { renderQualityEvalArtifact as renderQualityEvalArtifactV2 } from './quality-html-artifact.js';
|
|
6
|
+
|
|
7
|
+
function escapeHtml(value) {
|
|
8
|
+
return String(value ?? '')
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function leafName(value) {
|
|
17
|
+
return String(value ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? String(value ?? '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listMarkup(items, emptyText = '暂无') {
|
|
21
|
+
const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
|
|
22
|
+
if (normalized.length === 0) {
|
|
23
|
+
return `<li class="empty">${escapeHtml(emptyText)}</li>`;
|
|
24
|
+
}
|
|
25
|
+
return normalized.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function card(title, body) {
|
|
29
|
+
return `
|
|
30
|
+
<section class="card">
|
|
31
|
+
<div class="card-header">${escapeHtml(title)}</div>
|
|
32
|
+
<div class="card-body">${body}</div>
|
|
33
|
+
</section>
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [], footer = '', statusBadge = null, topMeta = [] }) {
|
|
38
|
+
return `<!DOCTYPE html>
|
|
39
|
+
<html lang="zh-CN">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="UTF-8" />
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
43
|
+
<title>${escapeHtml(title)}</title>
|
|
44
|
+
<style>
|
|
45
|
+
:root {
|
|
46
|
+
color-scheme: light;
|
|
47
|
+
--bg: #f7f4ed;
|
|
48
|
+
--panel: rgba(255,255,255,0.88);
|
|
49
|
+
--text: #1f2937;
|
|
50
|
+
--muted: #6b7280;
|
|
51
|
+
--line: rgba(31,41,55,0.12);
|
|
52
|
+
--accent: #d97706;
|
|
53
|
+
--accent-soft: rgba(217,119,6,0.12);
|
|
54
|
+
--danger: #dc2626;
|
|
55
|
+
--danger-soft: rgba(220,38,38,0.08);
|
|
56
|
+
--ok: #15803d;
|
|
57
|
+
--ok-soft: rgba(21,128,61,0.08);
|
|
58
|
+
--mono: "JetBrains Mono","SFMono-Regular",Menlo,monospace;
|
|
59
|
+
--serif: "Iowan Old Style","Palatino Linotype","Book Antiqua",Palatino,serif;
|
|
60
|
+
}
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
body {
|
|
63
|
+
margin: 0;
|
|
64
|
+
background:
|
|
65
|
+
radial-gradient(circle at top left, rgba(217,119,6,0.08), transparent 25%),
|
|
66
|
+
linear-gradient(180deg, #faf8f2 0%, var(--bg) 100%);
|
|
67
|
+
color: var(--text);
|
|
68
|
+
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
69
|
+
}
|
|
70
|
+
.page {
|
|
71
|
+
max-width: 1180px;
|
|
72
|
+
margin: 0 auto;
|
|
73
|
+
padding: 32px 24px 56px;
|
|
74
|
+
}
|
|
75
|
+
.hero {
|
|
76
|
+
display: grid;
|
|
77
|
+
gap: 16px;
|
|
78
|
+
margin-bottom: 28px;
|
|
79
|
+
}
|
|
80
|
+
.eyebrow {
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
width: fit-content;
|
|
83
|
+
padding: 6px 12px;
|
|
84
|
+
border-radius: 999px;
|
|
85
|
+
border: 1px solid var(--line);
|
|
86
|
+
background: rgba(255,255,255,0.72);
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
letter-spacing: 0.08em;
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
}
|
|
92
|
+
.hero-topline {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
gap: 10px;
|
|
96
|
+
align-items: center;
|
|
97
|
+
}
|
|
98
|
+
.status-badge {
|
|
99
|
+
display: inline-flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 8px;
|
|
102
|
+
width: fit-content;
|
|
103
|
+
padding: 8px 14px;
|
|
104
|
+
border-radius: 999px;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
font-weight: 800;
|
|
107
|
+
letter-spacing: 0.03em;
|
|
108
|
+
border: 2px solid transparent;
|
|
109
|
+
box-shadow: 0 10px 24px rgba(15,23,42,0.08);
|
|
110
|
+
}
|
|
111
|
+
.status-badge::before {
|
|
112
|
+
content: "";
|
|
113
|
+
width: 9px;
|
|
114
|
+
height: 9px;
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
background: currentColor;
|
|
117
|
+
box-shadow: 0 0 0 4px rgba(255,255,255,0.35);
|
|
118
|
+
}
|
|
119
|
+
.status-pass {
|
|
120
|
+
color: #166534;
|
|
121
|
+
background: #dcfce7;
|
|
122
|
+
border-color: #22c55e;
|
|
123
|
+
}
|
|
124
|
+
.status-fail {
|
|
125
|
+
color: #991b1b;
|
|
126
|
+
background: #fee2e2;
|
|
127
|
+
border-color: #ef4444;
|
|
128
|
+
}
|
|
129
|
+
.status-warn {
|
|
130
|
+
color: #92400e;
|
|
131
|
+
background: #fef3c7;
|
|
132
|
+
border-color: #f59e0b;
|
|
133
|
+
}
|
|
134
|
+
.mini-status {
|
|
135
|
+
padding: 4px 10px;
|
|
136
|
+
font-size: 11px;
|
|
137
|
+
border-width: 1.5px;
|
|
138
|
+
box-shadow: none;
|
|
139
|
+
}
|
|
140
|
+
.mini-status::before {
|
|
141
|
+
width: 7px;
|
|
142
|
+
height: 7px;
|
|
143
|
+
box-shadow: none;
|
|
144
|
+
}
|
|
145
|
+
h1 {
|
|
146
|
+
margin: 0;
|
|
147
|
+
font-size: clamp(34px, 5vw, 56px);
|
|
148
|
+
line-height: 1;
|
|
149
|
+
font-family: var(--serif);
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
}
|
|
152
|
+
.subtitle {
|
|
153
|
+
max-width: 880px;
|
|
154
|
+
margin: 0;
|
|
155
|
+
color: var(--muted);
|
|
156
|
+
font-size: 18px;
|
|
157
|
+
line-height: 1.7;
|
|
158
|
+
}
|
|
159
|
+
.summary-grid,
|
|
160
|
+
.section-grid {
|
|
161
|
+
display: grid;
|
|
162
|
+
gap: 16px;
|
|
163
|
+
}
|
|
164
|
+
.summary-grid {
|
|
165
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
166
|
+
margin-bottom: 28px;
|
|
167
|
+
}
|
|
168
|
+
.evidence-grid {
|
|
169
|
+
display: grid;
|
|
170
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
171
|
+
gap: 12px;
|
|
172
|
+
margin-bottom: 14px;
|
|
173
|
+
}
|
|
174
|
+
.section-grid {
|
|
175
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
176
|
+
}
|
|
177
|
+
.card {
|
|
178
|
+
border: 1px solid var(--line);
|
|
179
|
+
border-radius: 20px;
|
|
180
|
+
background: var(--panel);
|
|
181
|
+
backdrop-filter: blur(8px);
|
|
182
|
+
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
}
|
|
185
|
+
.card-header {
|
|
186
|
+
padding: 14px 18px 0;
|
|
187
|
+
font-size: 12px;
|
|
188
|
+
letter-spacing: 0.08em;
|
|
189
|
+
color: var(--muted);
|
|
190
|
+
text-transform: uppercase;
|
|
191
|
+
}
|
|
192
|
+
.card-body {
|
|
193
|
+
padding: 12px 18px 18px;
|
|
194
|
+
}
|
|
195
|
+
.metric {
|
|
196
|
+
font-size: 30px;
|
|
197
|
+
line-height: 1.1;
|
|
198
|
+
font-family: var(--serif);
|
|
199
|
+
font-weight: 600;
|
|
200
|
+
}
|
|
201
|
+
.metric-sub {
|
|
202
|
+
margin-top: 8px;
|
|
203
|
+
color: var(--muted);
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
line-height: 1.5;
|
|
206
|
+
}
|
|
207
|
+
.mini-metric {
|
|
208
|
+
padding: 10px 12px;
|
|
209
|
+
border-radius: 12px;
|
|
210
|
+
border: 1px solid var(--line);
|
|
211
|
+
background: rgba(255,255,255,0.72);
|
|
212
|
+
}
|
|
213
|
+
.mini-metric-value {
|
|
214
|
+
font-size: 18px;
|
|
215
|
+
line-height: 1.25;
|
|
216
|
+
font-weight: 750;
|
|
217
|
+
word-break: break-word;
|
|
218
|
+
}
|
|
219
|
+
.mini-metric-label {
|
|
220
|
+
margin-bottom: 5px;
|
|
221
|
+
color: var(--muted);
|
|
222
|
+
font-size: 12px;
|
|
223
|
+
}
|
|
224
|
+
.mini-metric-sub {
|
|
225
|
+
margin-top: 5px;
|
|
226
|
+
color: var(--muted);
|
|
227
|
+
font-size: 12px;
|
|
228
|
+
line-height: 1.45;
|
|
229
|
+
word-break: break-word;
|
|
230
|
+
}
|
|
231
|
+
ul {
|
|
232
|
+
margin: 0;
|
|
233
|
+
padding-left: 18px;
|
|
234
|
+
line-height: 1.7;
|
|
235
|
+
}
|
|
236
|
+
li + li { margin-top: 8px; }
|
|
237
|
+
.empty { color: var(--muted); }
|
|
238
|
+
.qa-item,
|
|
239
|
+
.option-item,
|
|
240
|
+
.export-item,
|
|
241
|
+
.evidence-item {
|
|
242
|
+
padding: 12px 14px;
|
|
243
|
+
border-radius: 14px;
|
|
244
|
+
border: 1px solid var(--line);
|
|
245
|
+
background: rgba(255,255,255,0.7);
|
|
246
|
+
}
|
|
247
|
+
.qa-label,
|
|
248
|
+
.option-title,
|
|
249
|
+
.export-title,
|
|
250
|
+
.evidence-title {
|
|
251
|
+
font-weight: 600;
|
|
252
|
+
}
|
|
253
|
+
.qa-status-row {
|
|
254
|
+
display: flex;
|
|
255
|
+
justify-content: flex-start;
|
|
256
|
+
margin-top: 8px;
|
|
257
|
+
}
|
|
258
|
+
.qa-meta,
|
|
259
|
+
.option-meta,
|
|
260
|
+
.export-meta,
|
|
261
|
+
.evidence-meta {
|
|
262
|
+
margin-top: 6px;
|
|
263
|
+
color: var(--muted);
|
|
264
|
+
font-size: 13px;
|
|
265
|
+
line-height: 1.5;
|
|
266
|
+
}
|
|
267
|
+
.warning {
|
|
268
|
+
border-color: rgba(220,38,38,0.18);
|
|
269
|
+
background: var(--danger-soft);
|
|
270
|
+
}
|
|
271
|
+
.success {
|
|
272
|
+
border-color: rgba(21,128,61,0.18);
|
|
273
|
+
background: var(--ok-soft);
|
|
274
|
+
}
|
|
275
|
+
.chip-row {
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-wrap: wrap;
|
|
278
|
+
gap: 8px;
|
|
279
|
+
}
|
|
280
|
+
.chip {
|
|
281
|
+
display: inline-flex;
|
|
282
|
+
align-items: center;
|
|
283
|
+
padding: 6px 10px;
|
|
284
|
+
border-radius: 999px;
|
|
285
|
+
border: 1px solid var(--line);
|
|
286
|
+
background: white;
|
|
287
|
+
color: var(--muted);
|
|
288
|
+
font-size: 12px;
|
|
289
|
+
font-family: var(--mono);
|
|
290
|
+
}
|
|
291
|
+
.code-block {
|
|
292
|
+
overflow-x: auto;
|
|
293
|
+
padding: 14px;
|
|
294
|
+
border-radius: 14px;
|
|
295
|
+
background: #161b22;
|
|
296
|
+
color: #e5e7eb;
|
|
297
|
+
font-family: var(--mono);
|
|
298
|
+
font-size: 13px;
|
|
299
|
+
line-height: 1.6;
|
|
300
|
+
}
|
|
301
|
+
.footer {
|
|
302
|
+
margin-top: 28px;
|
|
303
|
+
color: var(--muted);
|
|
304
|
+
font-size: 13px;
|
|
305
|
+
}
|
|
306
|
+
@media (max-width: 700px) {
|
|
307
|
+
.page { padding: 20px 14px 40px; }
|
|
308
|
+
.subtitle { font-size: 16px; }
|
|
309
|
+
}
|
|
310
|
+
</style>
|
|
311
|
+
</head>
|
|
312
|
+
<body>
|
|
313
|
+
<main class="page">
|
|
314
|
+
<header class="hero">
|
|
315
|
+
<div class="hero-topline">
|
|
316
|
+
<div class="eyebrow">${escapeHtml(eyebrow)}</div>
|
|
317
|
+
${statusBadge ? `<div class="status-badge ${escapeHtml(statusBadge.className)}">${escapeHtml(statusBadge.label)}</div>` : ''}
|
|
318
|
+
</div>
|
|
319
|
+
<h1>${escapeHtml(title)}</h1>
|
|
320
|
+
${topMeta.length ? `<div class="top-meta">${topMeta.map((item) => `<div class="meta-chip">${escapeHtml(item)}</div>`).join('')}</div>` : ''}
|
|
321
|
+
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
|
322
|
+
</header>
|
|
323
|
+
<section class="summary-grid">${summaryCards.join('\n')}</section>
|
|
324
|
+
<section class="section-grid">${sections.join('\n')}</section>
|
|
325
|
+
${footer ? `<div class="footer">${escapeHtml(footer)}</div>` : ''}
|
|
326
|
+
<script>
|
|
327
|
+
document.querySelectorAll('[data-copy-target]').forEach((button) => {
|
|
328
|
+
button.addEventListener('click', async () => {
|
|
329
|
+
const block = button.closest('.export-item')?.querySelector('[data-copy-block]');
|
|
330
|
+
if (!block) return;
|
|
331
|
+
await navigator.clipboard.writeText(block.textContent || '');
|
|
332
|
+
const old = button.textContent;
|
|
333
|
+
button.textContent = '✓ 已复制';
|
|
334
|
+
setTimeout(() => { button.textContent = old; }, 1200);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
</script>
|
|
338
|
+
</main>
|
|
339
|
+
</body>
|
|
340
|
+
</html>`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function metricCard(title, metric, subtext) {
|
|
344
|
+
return card(title, `
|
|
345
|
+
<div class="metric">${escapeHtml(metric)}</div>
|
|
346
|
+
<div class="metric-sub">${escapeHtml(subtext)}</div>
|
|
347
|
+
`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function slugify(value, fallback = 'artifact') {
|
|
351
|
+
const slug = String(value ?? '')
|
|
352
|
+
.toLowerCase()
|
|
353
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
354
|
+
.replace(/^-+|-+$/g, '')
|
|
355
|
+
.slice(0, 80);
|
|
356
|
+
return slug || fallback;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function formatClarificationQuestion(item) {
|
|
360
|
+
return `
|
|
361
|
+
<div class="qa-item ${item.reason === 'missing' ? 'warning' : ''}">
|
|
362
|
+
<div class="qa-label">${escapeHtml(item.prompt)}</div>
|
|
363
|
+
<div class="qa-meta">来源: ${escapeHtml(item.reason)} · 字段: ${escapeHtml(item.id)}</div>
|
|
364
|
+
</div>
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function formatOption(option) {
|
|
369
|
+
return `
|
|
370
|
+
<div class="option-item">
|
|
371
|
+
<div class="option-title">${escapeHtml(option.title)}</div>
|
|
372
|
+
<div class="option-meta">${escapeHtml(option.summary)}</div>
|
|
373
|
+
<ul>${listMarkup(option.tradeoffs, '暂无明确 tradeoff')}</ul>
|
|
374
|
+
</div>
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function formatExportItem(item) {
|
|
379
|
+
return `
|
|
380
|
+
<div class="export-item success">
|
|
381
|
+
<div class="export-title">${escapeHtml(item.title)}</div>
|
|
382
|
+
<div class="export-meta">${escapeHtml(item.description)}</div>
|
|
383
|
+
<div class="code-block" data-copy-block>${escapeHtml(item.payload)}</div>
|
|
384
|
+
<div class="actions">
|
|
385
|
+
<button type="button" class="copy-button" data-copy-target>⧉ 复制</button>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function formatEvidenceItem(item) {
|
|
392
|
+
return `
|
|
393
|
+
<div class="evidence-item">
|
|
394
|
+
<div class="evidence-title">${escapeHtml(item.title)}</div>
|
|
395
|
+
<div class="evidence-meta">${escapeHtml(item.description)}</div>
|
|
396
|
+
<ul>${listMarkup(item.items, '暂无')}</ul>
|
|
397
|
+
</div>
|
|
398
|
+
`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
export function buildReviewExportPayload(snapshot) {
|
|
403
|
+
const sections = snapshot.sections ?? {};
|
|
404
|
+
const presentation = buildReviewPresentationFeedback(snapshot);
|
|
405
|
+
return {
|
|
406
|
+
versionId: snapshot.versionId,
|
|
407
|
+
title: snapshot.title,
|
|
408
|
+
digest: snapshot.digest ?? null,
|
|
409
|
+
workUnitId: snapshot.workUnitId ?? null,
|
|
410
|
+
targetRoot: snapshot.targetRoot ?? null,
|
|
411
|
+
reviewStatus: 'pending-confirmation',
|
|
412
|
+
recommendedActions: [
|
|
413
|
+
'确认问题与目标',
|
|
414
|
+
'确认范围内 / 范围外',
|
|
415
|
+
'确认主流程与失败路径',
|
|
416
|
+
'确认关键风险与开放问题',
|
|
417
|
+
],
|
|
418
|
+
sectionKeys: Object.keys(sections),
|
|
419
|
+
presentationContract: presentation.contract,
|
|
420
|
+
presentationFeedback: presentation.violations,
|
|
421
|
+
exportedAt: new Date().toISOString(),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const REVIEW_PRESENTATION_CONTRACT = {
|
|
426
|
+
intent: '这些限制用于反馈给 Agent 重新概括,不由 HTML 模板截断原文。',
|
|
427
|
+
expectedDataShape: {
|
|
428
|
+
reviewPresentation: {
|
|
429
|
+
diagram: {
|
|
430
|
+
type: 'map',
|
|
431
|
+
note: '默认用关系图;只有确认为线性流程时改为 flow,并用 flowEdges 明确哪些节点有箭头。',
|
|
432
|
+
},
|
|
433
|
+
mapNodes: {
|
|
434
|
+
problem: { title: '问题定义', text: '30 字以内的图中正文' },
|
|
435
|
+
goal: { title: '15 字以内标题', text: '30 字以内的图中正文' },
|
|
436
|
+
scope: { title: '15 字以内标题', text: '30 字以内的图中正文' },
|
|
437
|
+
flow: { title: '15 字以内标题', text: '30 字以内的图中正文' },
|
|
438
|
+
risk: { title: '15 字以内标题', text: '30 字以内的图中正文' },
|
|
439
|
+
},
|
|
440
|
+
flowNodes: [
|
|
441
|
+
{ id: 'step1', text: '30 字以内的流程卡片正文' },
|
|
442
|
+
{ id: 'step2', text: '30 字以内的流程卡片正文' },
|
|
443
|
+
{ id: 'step3', text: '30 字以内的流程卡片正文' },
|
|
444
|
+
],
|
|
445
|
+
flowEdges: [
|
|
446
|
+
{ from: 'step1', to: 'step2' },
|
|
447
|
+
{ from: 'step2', to: 'step3' },
|
|
448
|
+
],
|
|
449
|
+
panels: {
|
|
450
|
+
flow: [
|
|
451
|
+
{ summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
|
|
452
|
+
],
|
|
453
|
+
function: [
|
|
454
|
+
{ summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
|
|
455
|
+
],
|
|
456
|
+
guardrail: [
|
|
457
|
+
{ summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
|
|
458
|
+
],
|
|
459
|
+
risk: [
|
|
460
|
+
{ summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
|
|
461
|
+
],
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
rules: [
|
|
466
|
+
{
|
|
467
|
+
id: 'review-map-card-text',
|
|
468
|
+
area: '需求关系图 / 需求流程图',
|
|
469
|
+
target: '图中每个卡片的正文',
|
|
470
|
+
maxChars: 30,
|
|
471
|
+
action: '请写入 reviewPresentation.mapNodes.*.text 或 reviewPresentation.flowNodes[].text,重写成用户一眼能扫懂的短句,不要靠省略号或截断。',
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
id: 'review-map-card-title',
|
|
475
|
+
area: '需求关系图 / 需求流程图',
|
|
476
|
+
target: '图中卡片标题胶囊',
|
|
477
|
+
maxChars: 15,
|
|
478
|
+
action: '请写入 reviewPresentation.mapNodes.*.title,重写成短标题,优先使用业务词,不使用内部技术词。',
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
id: 'review-highlight-chip',
|
|
482
|
+
area: '四个评审卡片',
|
|
483
|
+
target: '重点摘要胶囊',
|
|
484
|
+
maxChars: 15,
|
|
485
|
+
action: '请重写成短标签,保留结论,不要堆叠长句。',
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'review-panel-detail-format',
|
|
489
|
+
area: '四个评审卡片',
|
|
490
|
+
target: '明细分点',
|
|
491
|
+
format: '- **摘要内容**:明细一句话',
|
|
492
|
+
action: '请写入 reviewPresentation.panels.<kind>[],把每个明细改写为“加粗短摘要 + 一句话说明”,方便用户先扫重点再读细节。',
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
export function buildReviewPresentationFeedback(snapshot) {
|
|
498
|
+
const sectionsData = snapshot.sections ?? {};
|
|
499
|
+
const violations = [];
|
|
500
|
+
const addViolation = ({ ruleId, area, target, value, maxChars, jsonPath = null }) => {
|
|
501
|
+
const text = normalizedReviewVisibleText(value);
|
|
502
|
+
const currentChars = reviewVisibleChars(text);
|
|
503
|
+
if (currentChars <= maxChars) return;
|
|
504
|
+
violations.push({
|
|
505
|
+
ruleId,
|
|
506
|
+
area,
|
|
507
|
+
target,
|
|
508
|
+
jsonPath,
|
|
509
|
+
currentChars,
|
|
510
|
+
maxChars,
|
|
511
|
+
currentText: text,
|
|
512
|
+
action: '请让 Agent 重新提炼这段内容,生成更短、更完整的表达;不要由 HTML 模板直接裁剪。',
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const primaryFlows = reviewList(sectionsData.scenarios?.primaryFlows);
|
|
517
|
+
if (reviewPresentationDiagramType(snapshot) === 'flow' && primaryFlows.length >= 2) {
|
|
518
|
+
primaryFlows.slice(0, 4).forEach((item, index) => {
|
|
519
|
+
addViolation({
|
|
520
|
+
ruleId: 'review-map-card-text',
|
|
521
|
+
area: '需求流程图',
|
|
522
|
+
target: `流程卡片 ${index + 1}`,
|
|
523
|
+
value: reviewPresentationFlowNode(snapshot, index, reviewMapText(item)),
|
|
524
|
+
maxChars: 30,
|
|
525
|
+
jsonPath: `reviewPresentation.flowNodes[${index}].text`,
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
} else {
|
|
529
|
+
const relationshipNodes = [
|
|
530
|
+
['problem', '问题定义', sectionsData.problem?.problemStatement || '待确认问题定义'],
|
|
531
|
+
['goal', '目标', firstReviewMapValue(sectionsData.goals?.goals, sectionsData.goals?.successMetrics, '待确认目标')],
|
|
532
|
+
['scope', '范围', firstReviewMapValue(sectionsData.scope?.inScope, sectionsData.scope?.outOfScope, '待确认范围')],
|
|
533
|
+
['flow', '流程', firstReviewMapValue(sectionsData.scenarios?.primaryFlows, sectionsData.scenarios?.edgeCases, '待确认流程')],
|
|
534
|
+
['risk', '风险', firstReviewMapValue(sectionsData.risks?.risks, sectionsData.risks?.openQuestions, '待确认风险')],
|
|
535
|
+
];
|
|
536
|
+
relationshipNodes.forEach(([key, fallbackLabel, fallbackValue]) => {
|
|
537
|
+
const node = reviewPresentationMapNode(snapshot, key, fallbackLabel, reviewMapText(fallbackValue));
|
|
538
|
+
addViolation({
|
|
539
|
+
ruleId: 'review-map-card-title',
|
|
540
|
+
area: '需求关系图',
|
|
541
|
+
target: `${fallbackLabel}卡片标题`,
|
|
542
|
+
value: node.label,
|
|
543
|
+
maxChars: 15,
|
|
544
|
+
jsonPath: `reviewPresentation.mapNodes.${key}.title`,
|
|
545
|
+
});
|
|
546
|
+
addViolation({
|
|
547
|
+
ruleId: 'review-map-card-text',
|
|
548
|
+
area: '需求关系图',
|
|
549
|
+
target: `${fallbackLabel}卡片正文`,
|
|
550
|
+
value: node.value,
|
|
551
|
+
maxChars: 30,
|
|
552
|
+
jsonPath: `reviewPresentation.mapNodes.${key}.text`,
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
reviewPanelDetailGroups(sectionsData).forEach((group) => {
|
|
558
|
+
const panelItems = reviewPresentationPanelItems(snapshot, group.kind, group.items);
|
|
559
|
+
group.items.forEach((_item, index) => {
|
|
560
|
+
if (hasReviewPresentationPanel(snapshot, group.kind)) return;
|
|
561
|
+
violations.push({
|
|
562
|
+
ruleId: 'review-panel-detail-format',
|
|
563
|
+
area: group.area,
|
|
564
|
+
target: `明细 ${index + 1}`,
|
|
565
|
+
jsonPath: `reviewPresentation.panels.${group.kind}[${index}]`,
|
|
566
|
+
expectedFormat: '- **摘要内容**:明细一句话',
|
|
567
|
+
currentText: normalizedReviewVisibleText(group.items[index]),
|
|
568
|
+
action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式为 { "summary": "15 字内标签", "detail": "一句话说明" }。`,
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
panelItems.forEach((item, index) => {
|
|
572
|
+
const parsed = parseReviewPanelDetail(item);
|
|
573
|
+
addViolation({
|
|
574
|
+
ruleId: 'review-highlight-chip',
|
|
575
|
+
area: `${group.area}重点摘要`,
|
|
576
|
+
target: `明细 ${index + 1}摘要`,
|
|
577
|
+
value: parsed.summary,
|
|
578
|
+
maxChars: 15,
|
|
579
|
+
jsonPath: `reviewPresentation.panels.${group.kind}[${index}].summary`,
|
|
580
|
+
});
|
|
581
|
+
if (isStructuredReviewPanelDetail(item)) return;
|
|
582
|
+
violations.push({
|
|
583
|
+
ruleId: 'review-panel-detail-format',
|
|
584
|
+
area: group.area,
|
|
585
|
+
target: `明细 ${index + 1}`,
|
|
586
|
+
jsonPath: `reviewPresentation.panels.${group.kind}[${index}]`,
|
|
587
|
+
expectedFormat: '- **摘要内容**:明细一句话',
|
|
588
|
+
currentText: normalizedReviewVisibleText(item),
|
|
589
|
+
action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式为 { "summary": "15 字内标签", "detail": "一句话说明" }。`,
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
contract: REVIEW_PRESENTATION_CONTRACT,
|
|
596
|
+
violations,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function normalizedReviewVisibleText(value) {
|
|
601
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function reviewVisibleChars(value) {
|
|
605
|
+
return Array.from(normalizedReviewVisibleText(value)).length;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function reviewPresentation(snapshot) {
|
|
609
|
+
const presentation = snapshot?.reviewPresentation;
|
|
610
|
+
return presentation && typeof presentation === 'object' && !Array.isArray(presentation) ? presentation : {};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function reviewPresentationMapNodes(snapshot) {
|
|
614
|
+
const nodes = reviewPresentation(snapshot).mapNodes;
|
|
615
|
+
return nodes && typeof nodes === 'object' && !Array.isArray(nodes) ? nodes : {};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function reviewPresentationMapNode(snapshot, key, fallbackLabel, fallbackValue) {
|
|
619
|
+
const node = reviewPresentationMapNodes(snapshot)[key];
|
|
620
|
+
const candidate = node && typeof node === 'object' && !Array.isArray(node) ? node : {};
|
|
621
|
+
return {
|
|
622
|
+
label: normalizedReviewVisibleText(candidate.title ?? candidate.label ?? fallbackLabel) || fallbackLabel,
|
|
623
|
+
value: normalizedReviewVisibleText(candidate.text ?? candidate.value ?? fallbackValue) || fallbackValue,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function reviewPresentationFlowNode(snapshot, index, fallbackValue) {
|
|
628
|
+
const nodes = reviewPresentation(snapshot).flowNodes;
|
|
629
|
+
const node = Array.isArray(nodes) ? nodes[index] : null;
|
|
630
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
631
|
+
return fallbackValue;
|
|
632
|
+
}
|
|
633
|
+
return normalizedReviewVisibleText(node.text ?? node.value ?? fallbackValue) || fallbackValue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function reviewPresentationDiagramType(snapshot) {
|
|
637
|
+
const diagram = reviewPresentation(snapshot).diagram;
|
|
638
|
+
if (!diagram || typeof diagram !== 'object' || Array.isArray(diagram)) {
|
|
639
|
+
return 'map';
|
|
640
|
+
}
|
|
641
|
+
return diagram.type === 'flow' ? 'flow' : 'map';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function reviewPresentationFlowNodeId(snapshot, index) {
|
|
645
|
+
const nodes = reviewPresentation(snapshot).flowNodes;
|
|
646
|
+
const node = Array.isArray(nodes) ? nodes[index] : null;
|
|
647
|
+
return node && typeof node === 'object' && !Array.isArray(node)
|
|
648
|
+
? normalizedReviewVisibleText(node.id ?? node.key ?? `step${index + 1}`)
|
|
649
|
+
: `step${index + 1}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function reviewPresentationFlowEdges(snapshot) {
|
|
653
|
+
const edges = reviewPresentation(snapshot).flowEdges;
|
|
654
|
+
if (!Array.isArray(edges)) return [];
|
|
655
|
+
return edges
|
|
656
|
+
.map((edge) => edge && typeof edge === 'object' && !Array.isArray(edge)
|
|
657
|
+
? {
|
|
658
|
+
from: normalizedReviewVisibleText(edge.from ?? edge.fromId ?? edge.source ?? edge.sourceId),
|
|
659
|
+
to: normalizedReviewVisibleText(edge.to ?? edge.toId ?? edge.target ?? edge.targetId),
|
|
660
|
+
}
|
|
661
|
+
: null)
|
|
662
|
+
.filter((edge) => edge?.from && edge?.to);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function reviewPresentationPanels(snapshot) {
|
|
666
|
+
const panels = reviewPresentation(snapshot).panels;
|
|
667
|
+
return panels && typeof panels === 'object' && !Array.isArray(panels) ? panels : {};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function hasReviewPresentationPanel(snapshot, kind) {
|
|
671
|
+
return Array.isArray(reviewPresentationPanels(snapshot)[kind]);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function normalizeReviewPresentationPanelItem(item) {
|
|
675
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
676
|
+
const summary = normalizedReviewVisibleText(item.summary ?? item.title ?? item.label);
|
|
677
|
+
const detail = normalizedReviewVisibleText(item.detail ?? item.text ?? item.value);
|
|
678
|
+
if (summary && detail) {
|
|
679
|
+
return `**${summary}**:${detail}`;
|
|
680
|
+
}
|
|
681
|
+
return summary || detail;
|
|
682
|
+
}
|
|
683
|
+
return normalizedReviewVisibleText(item);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function reviewPresentationPanelItems(snapshot, kind, fallbackItems) {
|
|
687
|
+
const items = reviewPresentationPanels(snapshot)[kind];
|
|
688
|
+
if (!Array.isArray(items)) {
|
|
689
|
+
return fallbackItems;
|
|
690
|
+
}
|
|
691
|
+
return items.map(normalizeReviewPresentationPanelItem).filter(Boolean);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function reviewPanelDetailGroups(sectionsData) {
|
|
695
|
+
return [
|
|
696
|
+
{
|
|
697
|
+
kind: 'flow',
|
|
698
|
+
area: '主流程与边界情况',
|
|
699
|
+
items: [
|
|
700
|
+
...reviewList(sectionsData.scenarios?.primaryFlows),
|
|
701
|
+
...reviewList(sectionsData.scenarios?.edgeCases),
|
|
702
|
+
...reviewList(sectionsData.scenarios?.failureModes),
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
kind: 'function',
|
|
707
|
+
area: '功能与约束',
|
|
708
|
+
items: [
|
|
709
|
+
...reviewList(sectionsData.requirements?.functional),
|
|
710
|
+
...reviewList(sectionsData.requirements?.nonFunctional),
|
|
711
|
+
...reviewList(sectionsData.constraints?.technical),
|
|
712
|
+
...reviewList(sectionsData.constraints?.compliance),
|
|
713
|
+
...reviewList(sectionsData.constraints?.dependencies),
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
kind: 'guardrail',
|
|
718
|
+
area: '业务成本与滥用护栏',
|
|
719
|
+
items: [
|
|
720
|
+
...reviewList(sectionsData.businessGuardrails?.rateLimits),
|
|
721
|
+
...reviewList(sectionsData.businessGuardrails?.abusePrevention),
|
|
722
|
+
...reviewList(sectionsData.businessGuardrails?.costControls),
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
kind: 'risk',
|
|
727
|
+
area: '开放问题与风险',
|
|
728
|
+
items: [
|
|
729
|
+
...reviewList(sectionsData.risks?.risks),
|
|
730
|
+
...reviewList(sectionsData.risks?.openQuestions),
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isStructuredReviewPanelDetail(value) {
|
|
737
|
+
const text = normalizedReviewVisibleText(value);
|
|
738
|
+
return /^\*\*[^*]{1,24}\*\*\s*[::]\s*\S+/u.test(text);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function reviewList(items) {
|
|
742
|
+
return Array.isArray(items) ? items.filter(Boolean).map((item) => String(item).trim()).filter(Boolean) : [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function splitSvgLines(value, maxChars = 17) {
|
|
746
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim() || '待补充';
|
|
747
|
+
const tokens = text.match(/[A-Za-z0-9_./:-]+|[\u4e00-\u9fff]|[^\s]/g) ?? [text];
|
|
748
|
+
const lines = [];
|
|
749
|
+
let line = '';
|
|
750
|
+
let length = 0;
|
|
751
|
+
const visualLength = (token) => /^[A-Za-z0-9_./:-]+$/.test(token)
|
|
752
|
+
? Math.max(1, token.length * 0.62)
|
|
753
|
+
: 1;
|
|
754
|
+
for (const token of tokens) {
|
|
755
|
+
const nextLength = visualLength(token);
|
|
756
|
+
if (line && length + nextLength > maxChars) {
|
|
757
|
+
lines.push(line);
|
|
758
|
+
line = token;
|
|
759
|
+
length = nextLength;
|
|
760
|
+
} else {
|
|
761
|
+
line += token;
|
|
762
|
+
length += nextLength;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (line) {
|
|
766
|
+
lines.push(line);
|
|
767
|
+
}
|
|
768
|
+
return lines;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function svgText(value, x, y, className, maxChars = 17, lineHeight = 16, anchor = 'middle') {
|
|
772
|
+
const lines = splitSvgLines(value, maxChars);
|
|
773
|
+
return `<text class="${className}" x="${x}" y="${y}" text-anchor="${anchor}">${lines.map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join('')}</text>`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function reviewIcon(kind) {
|
|
777
|
+
const icons = {
|
|
778
|
+
flow: '<svg viewBox="0 0 24 24" role="img" aria-label="流程"><path d="M5 6.5h6.4a3.6 3.6 0 0 1 3.6 3.6v.8" /><path d="M15 17.5H8.6A3.6 3.6 0 0 1 5 13.9v-.8" /><path d="m12 8.5 3-3 3 3" /><path d="m8 15.5-3 3-3-3" /></svg>',
|
|
779
|
+
function: '<svg viewBox="0 0 24 24" role="img" aria-label="功能"><path d="M5 7h14" /><path d="M5 12h14" /><path d="M5 17h14" /><circle cx="8" cy="7" r="2" /><circle cx="16" cy="12" r="2" /><circle cx="11" cy="17" r="2" /></svg>',
|
|
780
|
+
guardrail: '<svg viewBox="0 0 24 24" role="img" aria-label="护栏"><path d="M12 3 5 6v5c0 4.4 2.8 8.4 7 9.8 4.2-1.4 7-5.4 7-9.8V6l-7-3Z" /><path d="M9 12.2 11 14l4-4.4" /></svg>',
|
|
781
|
+
risk: '<svg viewBox="0 0 24 24" role="img" aria-label="风险"><path d="M12 4 3.5 19h17L12 4Z" /><path d="M12 9v4" /><path d="M12 16.5h.01" /></svg>',
|
|
782
|
+
map: '<svg viewBox="0 0 24 24" role="img" aria-label="图谱"><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>',
|
|
783
|
+
};
|
|
784
|
+
return `<span class="review-icon review-icon-${escapeHtml(kind)}" aria-hidden="true">${icons[kind] ?? icons.flow}</span>`;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function renderReviewOverview(snapshot, sectionsData) {
|
|
788
|
+
const problem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
|
|
789
|
+
return `
|
|
790
|
+
<section class="review-overview" aria-labelledby="reviewOverviewTitle">
|
|
791
|
+
<div class="review-overview-copy">
|
|
792
|
+
<p class="review-kicker">需求概览</p>
|
|
793
|
+
<h1 id="reviewOverviewTitle">${escapeHtml(snapshot.title || 'PRD 评审')}</h1>
|
|
794
|
+
<p class="review-problem">${escapeHtml(problem)}</p>
|
|
795
|
+
</div>
|
|
796
|
+
</section>
|
|
797
|
+
`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function renderReviewFlowSvg(snapshot, sectionsData) {
|
|
801
|
+
const flowItems = reviewList(sectionsData.scenarios?.primaryFlows);
|
|
802
|
+
if (reviewPresentationDiagramType(snapshot) !== 'flow' || flowItems.length < 2) {
|
|
803
|
+
return renderReviewMindMapSvg(snapshot, sectionsData);
|
|
804
|
+
}
|
|
805
|
+
const nodes = (flowItems.length ? flowItems : [
|
|
806
|
+
'确认问题定义',
|
|
807
|
+
'确认范围与边界',
|
|
808
|
+
'确认主流程',
|
|
809
|
+
'确认风险与开放问题',
|
|
810
|
+
]).slice(0, 4);
|
|
811
|
+
const positions = [116, 360, 604, 848].slice(0, nodes.length);
|
|
812
|
+
const nodeIds = nodes.map((_item, index) => reviewPresentationFlowNodeId(snapshot, index));
|
|
813
|
+
const edges = reviewPresentationFlowEdges(snapshot);
|
|
814
|
+
const arrows = edges.map((edge) => {
|
|
815
|
+
const fromIndex = nodeIds.indexOf(edge.from);
|
|
816
|
+
const toIndex = nodeIds.indexOf(edge.to);
|
|
817
|
+
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return '';
|
|
818
|
+
const fromX = positions[fromIndex] + (fromIndex < toIndex ? 112 : -112);
|
|
819
|
+
const toX = positions[toIndex] + (fromIndex < toIndex ? -118 : 118);
|
|
820
|
+
const y = fromIndex === toIndex ? 124 : 124;
|
|
821
|
+
return `<path class="review-map-arrow" d="M ${fromX} ${y} H ${toX}" marker-end="url(#reviewArrow)" />`;
|
|
822
|
+
}).join('');
|
|
823
|
+
const nodeMarkup = nodes.map((item, index) => `
|
|
824
|
+
<g>
|
|
825
|
+
<rect class="review-map-node node-${index + 1}" x="${positions[index] - 104}" y="72" width="208" height="118" rx="8" />
|
|
826
|
+
<text class="review-map-step" x="${positions[index] - 78}" y="102">${index + 1}</text>
|
|
827
|
+
${svgText(reviewMapCardText(reviewPresentationFlowNode(snapshot, index, reviewMapText(item))), positions[index], 126, 'review-map-label', 13, 15)}
|
|
828
|
+
</g>
|
|
829
|
+
`).join('');
|
|
830
|
+
const overflowNote = flowItems.length > nodes.length
|
|
831
|
+
? `<p class="review-map-note">还有 ${flowItems.length - nodes.length} 条流程在下方“主流程与边界情况”里查看。</p>`
|
|
832
|
+
: '';
|
|
833
|
+
return `
|
|
834
|
+
<section class="review-map" aria-labelledby="reviewMapTitle">
|
|
835
|
+
<div class="review-section-heading">
|
|
836
|
+
${reviewIcon('map')}
|
|
837
|
+
<div>
|
|
838
|
+
<h2 id="reviewMapTitle">需求流程图</h2>
|
|
839
|
+
</div>
|
|
840
|
+
</div>
|
|
841
|
+
<div class="review-map-canvas">
|
|
842
|
+
<svg viewBox="0 0 960 280" role="img" aria-label="需求流程图" preserveAspectRatio="xMidYMid meet">
|
|
843
|
+
<defs>
|
|
844
|
+
<marker id="reviewArrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
|
|
845
|
+
<path d="M 0 0 L 12 6 L 0 12 z" fill="#4f46e5" />
|
|
846
|
+
</marker>
|
|
847
|
+
</defs>
|
|
848
|
+
<rect class="review-map-bg" x="2" y="2" width="956" height="276" rx="8" />
|
|
849
|
+
${arrows}
|
|
850
|
+
${nodeMarkup}
|
|
851
|
+
</svg>
|
|
852
|
+
</div>
|
|
853
|
+
${overflowNote}
|
|
854
|
+
</section>
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function renderReviewMindMapSvg(snapshot, sectionsData) {
|
|
859
|
+
const problem = sectionsData.problem?.problemStatement || '待确认问题定义';
|
|
860
|
+
const center = { x: 480, y: 168 };
|
|
861
|
+
const nodes = [
|
|
862
|
+
{
|
|
863
|
+
key: 'goal',
|
|
864
|
+
label: '目标',
|
|
865
|
+
value: firstReviewMapValue(sectionsData.goals?.goals, sectionsData.goals?.successMetrics, '待确认目标'),
|
|
866
|
+
x: 250,
|
|
867
|
+
y: 94,
|
|
868
|
+
className: 'node-1',
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
key: 'scope',
|
|
872
|
+
label: '范围',
|
|
873
|
+
value: firstReviewMapValue(sectionsData.scope?.inScope, sectionsData.scope?.outOfScope, '待确认范围'),
|
|
874
|
+
x: 710,
|
|
875
|
+
y: 94,
|
|
876
|
+
className: 'node-2',
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
key: 'flow',
|
|
880
|
+
label: '流程',
|
|
881
|
+
value: firstReviewMapValue(sectionsData.scenarios?.primaryFlows, sectionsData.scenarios?.edgeCases, '待确认流程'),
|
|
882
|
+
x: 250,
|
|
883
|
+
y: 242,
|
|
884
|
+
className: 'node-3',
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
key: 'risk',
|
|
888
|
+
label: '风险',
|
|
889
|
+
value: firstReviewMapValue(sectionsData.risks?.risks, sectionsData.risks?.openQuestions, '待确认风险'),
|
|
890
|
+
x: 710,
|
|
891
|
+
y: 242,
|
|
892
|
+
className: 'node-4',
|
|
893
|
+
},
|
|
894
|
+
];
|
|
895
|
+
const links = nodes.map((node) => `<path class="review-map-link" d="M ${center.x} ${center.y} L ${node.x} ${node.y}" />`).join('');
|
|
896
|
+
const satelliteNodes = nodes.map((node) => {
|
|
897
|
+
const displayNode = reviewPresentationMapNode(snapshot, node.key, node.label, reviewMapText(node.value));
|
|
898
|
+
return `
|
|
899
|
+
<g>
|
|
900
|
+
<rect class="review-map-node ${node.className}" x="${node.x - 122}" y="${node.y - 43}" width="244" height="86" rx="8" />
|
|
901
|
+
${reviewMapTagPill(displayNode.label, node.x, node.y - 22, node.className)}
|
|
902
|
+
${svgText(reviewMapCardText(displayNode.value), node.x - 94, node.y + 6, 'review-map-label', 15, 14, 'start')}
|
|
903
|
+
</g>
|
|
904
|
+
`;
|
|
905
|
+
}).join('');
|
|
906
|
+
const centerDisplayNode = reviewPresentationMapNode(snapshot, 'problem', '问题定义', reviewMapText(problem));
|
|
907
|
+
const centerNode = `
|
|
908
|
+
<g class="review-map-center-group">
|
|
909
|
+
<rect class="review-map-center" x="330" y="124" width="300" height="88" rx="8" />
|
|
910
|
+
${reviewMapTagPill(centerDisplayNode.label, center.x, 146, 'center')}
|
|
911
|
+
${svgText(reviewMapCardText(centerDisplayNode.value), 360, 176, 'review-map-label center', 16, 14, 'start')}
|
|
912
|
+
</g>
|
|
913
|
+
`;
|
|
914
|
+
return `
|
|
915
|
+
<section class="review-map" aria-labelledby="reviewMapTitle">
|
|
916
|
+
<div class="review-section-heading">
|
|
917
|
+
${reviewIcon('map')}
|
|
918
|
+
<div>
|
|
919
|
+
<h2 id="reviewMapTitle">需求关系图</h2>
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
922
|
+
<div class="review-map-canvas">
|
|
923
|
+
<svg viewBox="0 0 960 336" role="img" aria-label="需求关系图" preserveAspectRatio="xMidYMid meet">
|
|
924
|
+
<rect class="review-map-bg" x="2" y="2" width="956" height="332" rx="8" />
|
|
925
|
+
${links}
|
|
926
|
+
${satelliteNodes}
|
|
927
|
+
${centerNode}
|
|
928
|
+
</svg>
|
|
929
|
+
</div>
|
|
930
|
+
</section>
|
|
931
|
+
`;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function reviewMapTagPill(label, x, y, className) {
|
|
935
|
+
const text = trimReviewChipBoundary(label) || '未命名';
|
|
936
|
+
const width = Math.max(54, Array.from(text).length * 14 + 26);
|
|
937
|
+
return `
|
|
938
|
+
<rect class="review-map-tag-pill ${escapeHtml(className)}" x="${x - width / 2}" y="${y - 13}" width="${width}" height="26" rx="13" />
|
|
939
|
+
<text class="review-map-tag ${escapeHtml(className)}" x="${x}" y="${y + 4}" text-anchor="middle">${escapeHtml(text)}</text>
|
|
940
|
+
`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function firstReviewMapValue(primaryItems, secondaryItems, fallback) {
|
|
944
|
+
return reviewList(primaryItems)[0] ?? reviewList(secondaryItems)[0] ?? fallback;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function reviewMapText(value) {
|
|
948
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim() || '待补充';
|
|
949
|
+
return text.split(/[。!?!?]/).map((item) => item.trim()).find(Boolean) ?? text;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function reviewMapCardText(value) {
|
|
953
|
+
return reviewMapText(value);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function trimReviewChipBoundary(value) {
|
|
957
|
+
return String(value ?? '')
|
|
958
|
+
.replace(/\s+/g, ' ')
|
|
959
|
+
.trim()
|
|
960
|
+
.replace(/[\s/||::,,、;;.!??。-]+$/u, '')
|
|
961
|
+
.trim();
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function condensedReviewChipLabel(value) {
|
|
965
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
966
|
+
const rules = [
|
|
967
|
+
{ pattern: /截图|红框|口径|用户预期|预期不一致/u, label: '确认分类口径' },
|
|
968
|
+
{ pattern: /Playwright/i, label: 'Playwright 验证' },
|
|
969
|
+
{ pattern: /Host API/i, label: '不新增 Host API' },
|
|
970
|
+
{ pattern: /用量|额度|成本/u, label: '用量额度不变' },
|
|
971
|
+
{ pattern: /后台任务|重复触发|轮询/u, label: '不新增后台任务' },
|
|
972
|
+
{ pattern: /窄屏|响应式/u, label: '窄屏响应式' },
|
|
973
|
+
{ pattern: /滚动|稳定性/u, label: '滚动稳定性' },
|
|
974
|
+
{ pattern: /CSS|样式/i, label: 'CSS 样式' },
|
|
975
|
+
];
|
|
976
|
+
return rules.find((rule) => rule.pattern.test(text))?.label ?? null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function summarizeReviewChip(value, maxLength = 15) {
|
|
980
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
981
|
+
if (!text) return '';
|
|
982
|
+
const parsed = parseReviewPanelDetail(text);
|
|
983
|
+
if (parsed.summary && parsed.detail && parsed.summary.length <= maxLength) {
|
|
984
|
+
return parsed.summary;
|
|
985
|
+
}
|
|
986
|
+
const clauses = text.split(/[。;;,,、.!??]/).map((item) => item.trim()).filter(Boolean);
|
|
987
|
+
const compact =
|
|
988
|
+
clauses.find((item) => item.length >= 4 && item.length <= maxLength) ??
|
|
989
|
+
condensedReviewChipLabel(text) ??
|
|
990
|
+
clauses.find((item) => item.length >= 4) ??
|
|
991
|
+
clauses[0] ??
|
|
992
|
+
text;
|
|
993
|
+
return trimReviewChipBoundary(compact);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function reviewHighlightChips(items, emptyText) {
|
|
997
|
+
const chips = [];
|
|
998
|
+
for (const item of reviewList(items)) {
|
|
999
|
+
const chip = summarizeReviewChip(item);
|
|
1000
|
+
if (chip && !chips.includes(chip)) {
|
|
1001
|
+
chips.push(chip);
|
|
1002
|
+
}
|
|
1003
|
+
if (chips.length >= 4) break;
|
|
1004
|
+
}
|
|
1005
|
+
if (chips.length === 0) {
|
|
1006
|
+
return `<span class="review-chip empty">${escapeHtml(emptyText)}</span>`;
|
|
1007
|
+
}
|
|
1008
|
+
return chips.map((chip) => `<span class="review-chip">${escapeHtml(chip)}</span>`).join('');
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function reviewJourneyLabel(items, fallback) {
|
|
1012
|
+
const text = reviewList(items)[0] ?? fallback;
|
|
1013
|
+
return summarizeReviewChip(text, 18) || fallback;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function reviewJourneyClauses(items) {
|
|
1017
|
+
return reviewList(items)
|
|
1018
|
+
.flatMap((item) => item.split(/[。;;.!??]/))
|
|
1019
|
+
.flatMap((item) => item.split(/[,,]/))
|
|
1020
|
+
.map((item) => item.trim())
|
|
1021
|
+
.filter((item) => item.length >= 4);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function renderReviewJourneySvg({ primaryFlows, edgeCases, failureModes }) {
|
|
1025
|
+
const primary = reviewList(primaryFlows);
|
|
1026
|
+
const edges = reviewList(edgeCases);
|
|
1027
|
+
const failures = reviewList(failureModes);
|
|
1028
|
+
const primaryClauses = reviewJourneyClauses(primary);
|
|
1029
|
+
const journey = reviewJourneyLabel(primaryClauses.length ? primaryClauses : primary, '待确认用户入口');
|
|
1030
|
+
const step = reviewJourneyLabel(primaryClauses.slice(1).length ? primaryClauses.slice(1) : primary.slice(1), '待确认关键步骤');
|
|
1031
|
+
const outcome = reviewJourneyLabel(primaryClauses.slice(2).length ? primaryClauses.slice(2) : primary.slice(2), '待确认完成状态');
|
|
1032
|
+
const boundary = reviewJourneyLabel(edges, '待确认边界情况');
|
|
1033
|
+
const recovery = reviewJourneyLabel(failures.length ? failures : edges.slice(1), '待确认恢复路径');
|
|
1034
|
+
return `
|
|
1035
|
+
<div class="review-journey-map" aria-label="主流程小图">
|
|
1036
|
+
<svg viewBox="0 0 680 320" role="img" aria-label="用户旅程、关键步骤、边界情况和恢复路径" preserveAspectRatio="xMidYMid meet">
|
|
1037
|
+
<defs>
|
|
1038
|
+
<marker id="reviewJourneyArrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
|
|
1039
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0d9488" />
|
|
1040
|
+
</marker>
|
|
1041
|
+
</defs>
|
|
1042
|
+
<rect class="review-journey-bg" x="2" y="2" width="676" height="316" rx="8" />
|
|
1043
|
+
<path class="review-journey-arrow" d="M 202 88 H 248" marker-end="url(#reviewJourneyArrow)" />
|
|
1044
|
+
<path class="review-journey-arrow" d="M 432 88 H 478" marker-end="url(#reviewJourneyArrow)" />
|
|
1045
|
+
<path class="review-journey-arrow branch" d="M 340 134 V 164 H 236 V 190" marker-end="url(#reviewJourneyArrow)" />
|
|
1046
|
+
<path class="review-journey-arrow branch" d="M 340 134 V 164 H 454 V 190" marker-end="url(#reviewJourneyArrow)" />
|
|
1047
|
+
<g>
|
|
1048
|
+
<rect class="review-journey-node stage-journey" x="26" y="40" width="176" height="96" rx="8" />
|
|
1049
|
+
<circle class="review-journey-dot stage-journey" cx="56" cy="64" r="12" />
|
|
1050
|
+
<text class="review-journey-number" x="56" y="64" text-anchor="middle">1</text>
|
|
1051
|
+
<text class="review-journey-tag" x="114" y="66" text-anchor="middle">用户旅程</text>
|
|
1052
|
+
${svgText(journey, 114, 92, 'review-journey-label', 12, 13)}
|
|
1053
|
+
</g>
|
|
1054
|
+
<g>
|
|
1055
|
+
<rect class="review-journey-node stage-step" x="252" y="40" width="176" height="96" rx="8" />
|
|
1056
|
+
<circle class="review-journey-dot stage-step" cx="282" cy="64" r="12" />
|
|
1057
|
+
<text class="review-journey-number" x="282" y="64" text-anchor="middle">2</text>
|
|
1058
|
+
<text class="review-journey-tag" x="340" y="66" text-anchor="middle">关键步骤</text>
|
|
1059
|
+
${svgText(step, 340, 92, 'review-journey-label', 12, 13)}
|
|
1060
|
+
</g>
|
|
1061
|
+
<g>
|
|
1062
|
+
<rect class="review-journey-node stage-outcome" x="478" y="40" width="176" height="96" rx="8" />
|
|
1063
|
+
<circle class="review-journey-dot stage-outcome" cx="508" cy="64" r="12" />
|
|
1064
|
+
<text class="review-journey-number" x="508" y="64" text-anchor="middle">3</text>
|
|
1065
|
+
<text class="review-journey-tag" x="566" y="66" text-anchor="middle">结果确认</text>
|
|
1066
|
+
${svgText(outcome, 566, 92, 'review-journey-label', 12, 13)}
|
|
1067
|
+
</g>
|
|
1068
|
+
<g>
|
|
1069
|
+
<rect class="review-journey-node stage-boundary" x="126" y="194" width="220" height="88" rx="8" />
|
|
1070
|
+
<circle class="review-journey-dot stage-boundary" cx="158" cy="218" r="12" />
|
|
1071
|
+
<text class="review-journey-number" x="158" y="218" text-anchor="middle">B</text>
|
|
1072
|
+
<text class="review-journey-tag" x="236" y="220" text-anchor="middle">边界情况</text>
|
|
1073
|
+
${svgText(boundary, 236, 246, 'review-journey-label', 15, 13)}
|
|
1074
|
+
</g>
|
|
1075
|
+
<g>
|
|
1076
|
+
<rect class="review-journey-node stage-recovery" x="356" y="194" width="220" height="88" rx="8" />
|
|
1077
|
+
<circle class="review-journey-dot stage-recovery" cx="388" cy="218" r="12" />
|
|
1078
|
+
<text class="review-journey-number" x="388" y="218" text-anchor="middle">R</text>
|
|
1079
|
+
<text class="review-journey-tag" x="466" y="220" text-anchor="middle">恢复路径</text>
|
|
1080
|
+
${svgText(recovery, 466, 246, 'review-journey-label', 15, 13)}
|
|
1081
|
+
</g>
|
|
1082
|
+
</svg>
|
|
1083
|
+
</div>
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function reviewSubtitleText(value) {
|
|
1088
|
+
return String(value ?? '').replace(/[。.]$/u, '');
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function renderReviewPanel({ kind, title, description, items, emptyText, visual = '' }) {
|
|
1092
|
+
return `
|
|
1093
|
+
<section class="review-panel review-panel-${escapeHtml(kind)}">
|
|
1094
|
+
<header class="review-panel-head">
|
|
1095
|
+
${reviewIcon(kind)}
|
|
1096
|
+
<div>
|
|
1097
|
+
<h3>${escapeHtml(title)}</h3>
|
|
1098
|
+
<p>${escapeHtml(reviewSubtitleText(description))}</p>
|
|
1099
|
+
</div>
|
|
1100
|
+
</header>
|
|
1101
|
+
<div class="review-chip-row" aria-label="${escapeHtml(title)}重点摘要">
|
|
1102
|
+
${reviewHighlightChips(items, emptyText)}
|
|
1103
|
+
</div>
|
|
1104
|
+
${visual}
|
|
1105
|
+
<ul class="review-panel-list">${reviewPanelListMarkup(items, emptyText)}</ul>
|
|
1106
|
+
</section>
|
|
1107
|
+
`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function reviewPanelListMarkup(items, emptyText = '暂无') {
|
|
1111
|
+
const normalized = Array.isArray(items) ? items.filter(Boolean).map((item) => String(item).trim()).filter(Boolean) : [];
|
|
1112
|
+
if (normalized.length === 0) {
|
|
1113
|
+
return `<li class="empty">${escapeHtml(emptyText)}</li>`;
|
|
1114
|
+
}
|
|
1115
|
+
return normalized.map((item) => {
|
|
1116
|
+
const parsed = parseReviewPanelDetail(item);
|
|
1117
|
+
return `<li><strong class="review-detail-summary">${escapeHtml(parsed.summary)}</strong><span class="review-detail-body">:${escapeHtml(parsed.detail)}</span></li>`;
|
|
1118
|
+
}).join('');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function parseReviewPanelDetail(value) {
|
|
1122
|
+
const text = normalizedReviewVisibleText(value);
|
|
1123
|
+
const markdown = text.match(/^\*\*([^*]+)\*\*\s*[::]\s*(.+)$/u);
|
|
1124
|
+
if (markdown) {
|
|
1125
|
+
return {
|
|
1126
|
+
summary: markdown[1].trim(),
|
|
1127
|
+
detail: markdown[2].trim(),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const plain = text.match(/^([^::]{2,18})[::]\s*(.+)$/u);
|
|
1131
|
+
if (plain) {
|
|
1132
|
+
return {
|
|
1133
|
+
summary: plain[1].trim(),
|
|
1134
|
+
detail: plain[2].trim(),
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
summary: reviewDetailSummary(text),
|
|
1139
|
+
detail: text,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function reviewDetailSummary(value) {
|
|
1144
|
+
const text = normalizedReviewVisibleText(value);
|
|
1145
|
+
const clause = text.split(/[。;;,,、.!??]/u).map((item) => item.trim()).find((item) => item.length >= 2 && item.length <= 18);
|
|
1146
|
+
return condensedReviewChipLabel(text) ?? clause ?? '重点说明';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function reviewCopyBundle({ label, command, payload, message = null }) {
|
|
1150
|
+
return [
|
|
1151
|
+
`OpenPrD Review: ${label}`,
|
|
1152
|
+
message ?? null,
|
|
1153
|
+
command ? '命令:' : null,
|
|
1154
|
+
command,
|
|
1155
|
+
'上下文:',
|
|
1156
|
+
payload,
|
|
1157
|
+
].filter(Boolean).join('\n\n');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function shellQuote(value) {
|
|
1161
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function reviewCommand(snapshot, status, notes = null) {
|
|
1165
|
+
const parts = ['openprd review . --mark', status];
|
|
1166
|
+
if (snapshot.versionId) {
|
|
1167
|
+
parts.push('--version', shellQuote(snapshot.versionId));
|
|
1168
|
+
}
|
|
1169
|
+
if (snapshot.digest) {
|
|
1170
|
+
parts.push('--digest', shellQuote(snapshot.digest));
|
|
1171
|
+
}
|
|
1172
|
+
if (snapshot.workUnitId) {
|
|
1173
|
+
parts.push('--work-unit', shellQuote(snapshot.workUnitId));
|
|
1174
|
+
}
|
|
1175
|
+
if (notes) {
|
|
1176
|
+
parts.push('--notes', shellQuote(notes));
|
|
1177
|
+
}
|
|
1178
|
+
return parts.join(' ');
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function renderReviewDecision(snapshot) {
|
|
1182
|
+
const payload = JSON.stringify(buildReviewExportPayload(snapshot), null, 2);
|
|
1183
|
+
const confirmCommand = reviewCommand(snapshot, 'confirmed');
|
|
1184
|
+
const reviseCommand = reviewCommand(snapshot, 'needs-revision', '说明需要调整的点');
|
|
1185
|
+
const confirmCopy = reviewCopyBundle({ label: '认可方案', command: confirmCommand, payload });
|
|
1186
|
+
const reviseCopy = reviewCopyBundle({ label: '需要调整', command: reviseCommand, payload });
|
|
1187
|
+
return `
|
|
1188
|
+
<nav class="review-bottom-bar" aria-label="评审决定">
|
|
1189
|
+
<div class="review-bottom-bar-inner">
|
|
1190
|
+
<button type="button" class="review-bottom-action revise" data-copy-value="${escapeHtml(reviseCopy)}" title="${escapeHtml(reviseCommand)}">
|
|
1191
|
+
需要调整
|
|
1192
|
+
</button>
|
|
1193
|
+
<button type="button" class="review-bottom-action confirm" data-copy-value="${escapeHtml(confirmCopy)}" title="${escapeHtml(confirmCommand)}">
|
|
1194
|
+
认可方案
|
|
1195
|
+
</button>
|
|
1196
|
+
</div>
|
|
1197
|
+
</nav>
|
|
1198
|
+
`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function renderReviewPage({ snapshot, sectionsData }) {
|
|
1202
|
+
const primaryFlows = reviewList(sectionsData.scenarios?.primaryFlows);
|
|
1203
|
+
const edgeCases = reviewList(sectionsData.scenarios?.edgeCases);
|
|
1204
|
+
const failureModes = reviewList(sectionsData.scenarios?.failureModes);
|
|
1205
|
+
const flowPanelItems = reviewPresentationPanelItems(snapshot, 'flow', [
|
|
1206
|
+
...primaryFlows,
|
|
1207
|
+
...edgeCases,
|
|
1208
|
+
...failureModes,
|
|
1209
|
+
]);
|
|
1210
|
+
const functionPanelItems = reviewPresentationPanelItems(snapshot, 'function', [
|
|
1211
|
+
...reviewList(sectionsData.requirements?.functional),
|
|
1212
|
+
...reviewList(sectionsData.requirements?.nonFunctional),
|
|
1213
|
+
...reviewList(sectionsData.constraints?.dependencies),
|
|
1214
|
+
]);
|
|
1215
|
+
const guardrailPanelItems = reviewPresentationPanelItems(snapshot, 'guardrail', [
|
|
1216
|
+
...reviewList(sectionsData.businessGuardrails?.costDrivers),
|
|
1217
|
+
...reviewList(sectionsData.businessGuardrails?.usageLimits),
|
|
1218
|
+
...reviewList(sectionsData.businessGuardrails?.abusePrevention),
|
|
1219
|
+
...reviewList(sectionsData.businessGuardrails?.monitoringSignals),
|
|
1220
|
+
...reviewList(sectionsData.businessGuardrails?.alertThresholds),
|
|
1221
|
+
...reviewList(sectionsData.businessGuardrails?.stopLossActions),
|
|
1222
|
+
]);
|
|
1223
|
+
const riskPanelItems = reviewPresentationPanelItems(snapshot, 'risk', [
|
|
1224
|
+
...reviewList(sectionsData.risks?.assumptions),
|
|
1225
|
+
...reviewList(sectionsData.risks?.risks),
|
|
1226
|
+
...reviewList(sectionsData.risks?.openQuestions),
|
|
1227
|
+
]);
|
|
1228
|
+
const panels = [
|
|
1229
|
+
renderReviewPanel({
|
|
1230
|
+
kind: 'flow',
|
|
1231
|
+
title: '主流程与边界情况',
|
|
1232
|
+
description: '确认用户旅程、关键步骤和恢复路径是否已经讲清楚,能否进入实现前确认',
|
|
1233
|
+
emptyText: '暂无主流程、边界情况或失败路径。',
|
|
1234
|
+
visual: renderReviewJourneySvg({ primaryFlows, edgeCases, failureModes }),
|
|
1235
|
+
items: flowPanelItems,
|
|
1236
|
+
}),
|
|
1237
|
+
renderReviewPanel({
|
|
1238
|
+
kind: 'function',
|
|
1239
|
+
title: '功能与约束',
|
|
1240
|
+
description: '区分必须交付、非功能要求和当前依赖假设',
|
|
1241
|
+
emptyText: '暂无功能、非功能要求或依赖约束。',
|
|
1242
|
+
items: functionPanelItems,
|
|
1243
|
+
}),
|
|
1244
|
+
renderReviewPanel({
|
|
1245
|
+
kind: 'guardrail',
|
|
1246
|
+
title: '业务成本与滥用护栏',
|
|
1247
|
+
description: '涉及免费额度、消耗型成本或第三方调用时,先确认限制、报警和止损动作',
|
|
1248
|
+
emptyText: '暂无业务成本或滥用护栏。',
|
|
1249
|
+
items: guardrailPanelItems,
|
|
1250
|
+
}),
|
|
1251
|
+
renderReviewPanel({
|
|
1252
|
+
kind: 'risk',
|
|
1253
|
+
title: '开放问题与风险',
|
|
1254
|
+
description: '需求定稿前还没关掉的问题要留在这里,不要默默假定解决',
|
|
1255
|
+
emptyText: '暂无假设、风险或开放问题。',
|
|
1256
|
+
items: riskPanelItems,
|
|
1257
|
+
}),
|
|
1258
|
+
];
|
|
1259
|
+
return `<!DOCTYPE html>
|
|
1260
|
+
<html lang="zh-CN">
|
|
1261
|
+
<head>
|
|
1262
|
+
<meta charset="UTF-8" />
|
|
1263
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1264
|
+
<title>${escapeHtml(snapshot.title || 'PRD 评审')}</title>
|
|
1265
|
+
<style>
|
|
1266
|
+
:root {
|
|
1267
|
+
color-scheme: light;
|
|
1268
|
+
--review-bg: #f6f8fb;
|
|
1269
|
+
--review-panel: #ffffff;
|
|
1270
|
+
--review-panel-soft: #f9fafb;
|
|
1271
|
+
--review-text: #172033;
|
|
1272
|
+
--review-muted: #667085;
|
|
1273
|
+
--review-line: #d8dee8;
|
|
1274
|
+
--review-blue: #2563eb;
|
|
1275
|
+
--review-teal: #0f766e;
|
|
1276
|
+
--review-indigo: #4f46e5;
|
|
1277
|
+
--review-amber: #b45309;
|
|
1278
|
+
--review-red: #dc2626;
|
|
1279
|
+
--review-green: #15803d;
|
|
1280
|
+
--review-mono: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
|
|
1281
|
+
}
|
|
1282
|
+
* { box-sizing: border-box; }
|
|
1283
|
+
body {
|
|
1284
|
+
margin: 0;
|
|
1285
|
+
background: var(--review-bg);
|
|
1286
|
+
color: var(--review-text);
|
|
1287
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1288
|
+
overflow-x: hidden;
|
|
1289
|
+
}
|
|
1290
|
+
.review-page {
|
|
1291
|
+
max-width: 1220px;
|
|
1292
|
+
margin: 0 auto;
|
|
1293
|
+
padding: 28px 22px 120px;
|
|
1294
|
+
}
|
|
1295
|
+
.review-topbar {
|
|
1296
|
+
display: flex;
|
|
1297
|
+
align-items: center;
|
|
1298
|
+
justify-content: flex-start;
|
|
1299
|
+
gap: 16px;
|
|
1300
|
+
margin-bottom: 16px;
|
|
1301
|
+
}
|
|
1302
|
+
.review-brand {
|
|
1303
|
+
display: inline-flex;
|
|
1304
|
+
align-items: center;
|
|
1305
|
+
min-height: 34px;
|
|
1306
|
+
border: 1px solid var(--review-line);
|
|
1307
|
+
border-radius: 999px;
|
|
1308
|
+
background: var(--review-panel);
|
|
1309
|
+
color: var(--review-muted);
|
|
1310
|
+
padding: 0 12px;
|
|
1311
|
+
font-size: 13px;
|
|
1312
|
+
font-weight: 700;
|
|
1313
|
+
letter-spacing: 0;
|
|
1314
|
+
}
|
|
1315
|
+
.review-kicker {
|
|
1316
|
+
margin: 0 0 6px;
|
|
1317
|
+
color: var(--review-muted);
|
|
1318
|
+
font-size: 13px;
|
|
1319
|
+
font-weight: 800;
|
|
1320
|
+
letter-spacing: 0;
|
|
1321
|
+
}
|
|
1322
|
+
.review-overview,
|
|
1323
|
+
.review-map {
|
|
1324
|
+
border: 1px solid var(--review-line);
|
|
1325
|
+
border-radius: 8px;
|
|
1326
|
+
background: var(--review-panel);
|
|
1327
|
+
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.06);
|
|
1328
|
+
}
|
|
1329
|
+
.review-overview {
|
|
1330
|
+
display: block;
|
|
1331
|
+
padding: 24px;
|
|
1332
|
+
}
|
|
1333
|
+
.review-overview-copy,
|
|
1334
|
+
.review-panel {
|
|
1335
|
+
min-width: 0;
|
|
1336
|
+
}
|
|
1337
|
+
.review-overview h1,
|
|
1338
|
+
.review-map h2,
|
|
1339
|
+
.review-panel h3 {
|
|
1340
|
+
margin: 0;
|
|
1341
|
+
color: var(--review-text);
|
|
1342
|
+
letter-spacing: 0;
|
|
1343
|
+
overflow-wrap: anywhere;
|
|
1344
|
+
}
|
|
1345
|
+
.review-overview h1 {
|
|
1346
|
+
font-size: 32px;
|
|
1347
|
+
line-height: 1.16;
|
|
1348
|
+
word-break: break-word;
|
|
1349
|
+
}
|
|
1350
|
+
.review-problem {
|
|
1351
|
+
max-width: 760px;
|
|
1352
|
+
margin: 12px 0 0;
|
|
1353
|
+
color: var(--review-muted);
|
|
1354
|
+
font-size: 16px;
|
|
1355
|
+
line-height: 1.75;
|
|
1356
|
+
overflow-wrap: anywhere;
|
|
1357
|
+
}
|
|
1358
|
+
.review-map {
|
|
1359
|
+
margin-top: 18px;
|
|
1360
|
+
padding: 20px;
|
|
1361
|
+
}
|
|
1362
|
+
.review-section-heading,
|
|
1363
|
+
.review-panel-head {
|
|
1364
|
+
display: flex;
|
|
1365
|
+
gap: 12px;
|
|
1366
|
+
align-items: flex-start;
|
|
1367
|
+
}
|
|
1368
|
+
.review-section-heading h2 {
|
|
1369
|
+
font-size: 22px;
|
|
1370
|
+
}
|
|
1371
|
+
.review-icon {
|
|
1372
|
+
flex: 0 0 auto;
|
|
1373
|
+
display: inline-flex;
|
|
1374
|
+
width: 38px;
|
|
1375
|
+
height: 38px;
|
|
1376
|
+
align-items: center;
|
|
1377
|
+
justify-content: center;
|
|
1378
|
+
border-radius: 8px;
|
|
1379
|
+
}
|
|
1380
|
+
.review-icon svg {
|
|
1381
|
+
width: 22px;
|
|
1382
|
+
height: 22px;
|
|
1383
|
+
fill: none;
|
|
1384
|
+
stroke: currentColor;
|
|
1385
|
+
stroke-width: 2;
|
|
1386
|
+
stroke-linecap: round;
|
|
1387
|
+
stroke-linejoin: round;
|
|
1388
|
+
}
|
|
1389
|
+
.review-icon-map { color: var(--review-indigo); background: #eef2ff; }
|
|
1390
|
+
.review-icon-flow { color: var(--review-teal); background: #ccfbf1; }
|
|
1391
|
+
.review-icon-function { color: var(--review-blue); background: #dbeafe; }
|
|
1392
|
+
.review-icon-guardrail { color: var(--review-amber); background: #fef3c7; }
|
|
1393
|
+
.review-icon-risk { color: var(--review-red); background: #fee2e2; }
|
|
1394
|
+
.review-map-canvas {
|
|
1395
|
+
margin-top: 14px;
|
|
1396
|
+
overflow-x: auto;
|
|
1397
|
+
max-width: 100%;
|
|
1398
|
+
}
|
|
1399
|
+
.review-map-canvas svg {
|
|
1400
|
+
display: block;
|
|
1401
|
+
width: 100%;
|
|
1402
|
+
min-width: 680px;
|
|
1403
|
+
height: auto;
|
|
1404
|
+
}
|
|
1405
|
+
.review-map-bg {
|
|
1406
|
+
fill: #f8fafc;
|
|
1407
|
+
stroke: #e2e8f0;
|
|
1408
|
+
}
|
|
1409
|
+
.review-map-arrow {
|
|
1410
|
+
fill: none;
|
|
1411
|
+
stroke: var(--review-indigo);
|
|
1412
|
+
stroke-width: 3;
|
|
1413
|
+
stroke-linecap: round;
|
|
1414
|
+
}
|
|
1415
|
+
.review-map-link {
|
|
1416
|
+
fill: none;
|
|
1417
|
+
stroke: #a5b4fc;
|
|
1418
|
+
stroke-width: 2.5;
|
|
1419
|
+
stroke-linecap: round;
|
|
1420
|
+
}
|
|
1421
|
+
.review-map-node {
|
|
1422
|
+
fill: #ffffff;
|
|
1423
|
+
stroke: #cbd5e1;
|
|
1424
|
+
stroke-width: 1.5;
|
|
1425
|
+
filter: drop-shadow(0 10px 16px rgba(15, 23, 42, 0.08));
|
|
1426
|
+
}
|
|
1427
|
+
.review-map-center {
|
|
1428
|
+
fill: #eef2ff;
|
|
1429
|
+
stroke: #818cf8;
|
|
1430
|
+
stroke-width: 1.5;
|
|
1431
|
+
filter: drop-shadow(0 14px 18px rgba(79, 70, 229, 0.12));
|
|
1432
|
+
}
|
|
1433
|
+
.review-map-node.node-1 { stroke: #99f6e4; }
|
|
1434
|
+
.review-map-node.node-2 { stroke: #bfdbfe; }
|
|
1435
|
+
.review-map-node.node-3 { stroke: #fde68a; }
|
|
1436
|
+
.review-map-node.node-4 { stroke: #fecaca; }
|
|
1437
|
+
.review-map-step {
|
|
1438
|
+
fill: var(--review-indigo);
|
|
1439
|
+
font-size: 13px;
|
|
1440
|
+
font-weight: 800;
|
|
1441
|
+
}
|
|
1442
|
+
.review-map-tag {
|
|
1443
|
+
fill: var(--review-muted);
|
|
1444
|
+
font-size: 11px;
|
|
1445
|
+
font-weight: 800;
|
|
1446
|
+
}
|
|
1447
|
+
.review-map-tag-pill {
|
|
1448
|
+
fill: #f8fafc;
|
|
1449
|
+
stroke: #cbd5e1;
|
|
1450
|
+
stroke-width: 1;
|
|
1451
|
+
}
|
|
1452
|
+
.review-map-tag-pill.center { fill: #e0e7ff; stroke: #a5b4fc; }
|
|
1453
|
+
.review-map-tag-pill.node-1 { fill: #ccfbf1; stroke: #5eead4; }
|
|
1454
|
+
.review-map-tag-pill.node-2 { fill: #dbeafe; stroke: #93c5fd; }
|
|
1455
|
+
.review-map-tag-pill.node-3 { fill: #fef3c7; stroke: #facc15; }
|
|
1456
|
+
.review-map-tag-pill.node-4 { fill: #fee2e2; stroke: #fca5a5; }
|
|
1457
|
+
.review-map-tag.center { fill: var(--review-indigo); }
|
|
1458
|
+
.review-map-tag.node-1 { fill: #0f766e; }
|
|
1459
|
+
.review-map-tag.node-2 { fill: #2563eb; }
|
|
1460
|
+
.review-map-tag.node-3 { fill: #b45309; }
|
|
1461
|
+
.review-map-tag.node-4 { fill: #dc2626; }
|
|
1462
|
+
.review-map-label {
|
|
1463
|
+
fill: var(--review-text);
|
|
1464
|
+
font-size: 12px;
|
|
1465
|
+
font-weight: 680;
|
|
1466
|
+
}
|
|
1467
|
+
.review-map-note {
|
|
1468
|
+
margin: 10px 0 0;
|
|
1469
|
+
color: var(--review-muted);
|
|
1470
|
+
font-size: 13px;
|
|
1471
|
+
}
|
|
1472
|
+
.review-panel-grid {
|
|
1473
|
+
display: grid;
|
|
1474
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1475
|
+
gap: 16px;
|
|
1476
|
+
margin-top: 18px;
|
|
1477
|
+
}
|
|
1478
|
+
.review-panel {
|
|
1479
|
+
min-height: 260px;
|
|
1480
|
+
border: 1px solid var(--review-line);
|
|
1481
|
+
border-radius: 8px;
|
|
1482
|
+
background: var(--review-panel);
|
|
1483
|
+
padding: 18px;
|
|
1484
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
|
1485
|
+
}
|
|
1486
|
+
.review-panel h3 {
|
|
1487
|
+
font-size: 20px;
|
|
1488
|
+
}
|
|
1489
|
+
.review-panel-head p {
|
|
1490
|
+
margin: 5px 0 0;
|
|
1491
|
+
color: var(--review-muted);
|
|
1492
|
+
font-size: 14px;
|
|
1493
|
+
line-height: 1.55;
|
|
1494
|
+
}
|
|
1495
|
+
.review-chip-row {
|
|
1496
|
+
display: flex;
|
|
1497
|
+
flex-wrap: wrap;
|
|
1498
|
+
gap: 8px;
|
|
1499
|
+
margin-top: 16px;
|
|
1500
|
+
padding: 12px;
|
|
1501
|
+
border-radius: 8px;
|
|
1502
|
+
background: var(--review-panel-soft);
|
|
1503
|
+
border: 1px solid var(--review-line);
|
|
1504
|
+
}
|
|
1505
|
+
.review-chip {
|
|
1506
|
+
display: inline-flex;
|
|
1507
|
+
align-items: center;
|
|
1508
|
+
width: fit-content;
|
|
1509
|
+
max-width: 100%;
|
|
1510
|
+
min-height: 28px;
|
|
1511
|
+
padding: 5px 10px;
|
|
1512
|
+
border-radius: 999px;
|
|
1513
|
+
border: 1px solid var(--review-line);
|
|
1514
|
+
background: #ffffff;
|
|
1515
|
+
color: var(--review-text);
|
|
1516
|
+
font-size: 13px;
|
|
1517
|
+
font-weight: 750;
|
|
1518
|
+
line-height: 1.25;
|
|
1519
|
+
white-space: nowrap;
|
|
1520
|
+
overflow-wrap: normal;
|
|
1521
|
+
word-break: keep-all;
|
|
1522
|
+
}
|
|
1523
|
+
.review-panel-flow .review-chip { border-color: #99f6e4; background: #f0fdfa; color: #115e59; }
|
|
1524
|
+
.review-panel-function .review-chip { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
|
|
1525
|
+
.review-panel-guardrail .review-chip { border-color: #fde68a; background: #fffbeb; color: #92400e; }
|
|
1526
|
+
.review-panel-risk .review-chip { border-color: #fecaca; background: #fff1f2; color: #991b1b; }
|
|
1527
|
+
.review-chip.empty {
|
|
1528
|
+
color: var(--review-muted);
|
|
1529
|
+
background: #ffffff;
|
|
1530
|
+
border-color: var(--review-line);
|
|
1531
|
+
}
|
|
1532
|
+
.review-journey-map {
|
|
1533
|
+
margin-top: 12px;
|
|
1534
|
+
border: 1px solid var(--review-line);
|
|
1535
|
+
border-radius: 8px;
|
|
1536
|
+
background: #f8fafc;
|
|
1537
|
+
overflow-x: auto;
|
|
1538
|
+
overflow-y: hidden;
|
|
1539
|
+
}
|
|
1540
|
+
.review-journey-map svg {
|
|
1541
|
+
display: block;
|
|
1542
|
+
width: 100%;
|
|
1543
|
+
min-width: 0;
|
|
1544
|
+
min-height: 230px;
|
|
1545
|
+
}
|
|
1546
|
+
.review-journey-bg {
|
|
1547
|
+
fill: #fbfdff;
|
|
1548
|
+
stroke: none;
|
|
1549
|
+
}
|
|
1550
|
+
.review-journey-arrow {
|
|
1551
|
+
fill: none;
|
|
1552
|
+
stroke: #0d9488;
|
|
1553
|
+
stroke-width: 2;
|
|
1554
|
+
stroke-linecap: round;
|
|
1555
|
+
}
|
|
1556
|
+
.review-journey-arrow.branch {
|
|
1557
|
+
stroke: #94a3b8;
|
|
1558
|
+
stroke-dasharray: 5 6;
|
|
1559
|
+
}
|
|
1560
|
+
.review-journey-node {
|
|
1561
|
+
fill: #ffffff;
|
|
1562
|
+
stroke-width: 1.6;
|
|
1563
|
+
filter: drop-shadow(0 10px 18px rgba(15, 23, 42, 0.08));
|
|
1564
|
+
}
|
|
1565
|
+
.review-journey-node.stage-journey { stroke: #5eead4; }
|
|
1566
|
+
.review-journey-node.stage-step { stroke: #93c5fd; }
|
|
1567
|
+
.review-journey-node.stage-outcome { stroke: #a5b4fc; }
|
|
1568
|
+
.review-journey-node.stage-boundary { stroke: #fde68a; }
|
|
1569
|
+
.review-journey-node.stage-recovery { stroke: #fecaca; }
|
|
1570
|
+
.review-journey-dot {
|
|
1571
|
+
fill: #0f172a;
|
|
1572
|
+
}
|
|
1573
|
+
.review-journey-dot.stage-journey { fill: #0d9488; }
|
|
1574
|
+
.review-journey-dot.stage-step { fill: #2563eb; }
|
|
1575
|
+
.review-journey-dot.stage-outcome { fill: #4f46e5; }
|
|
1576
|
+
.review-journey-dot.stage-boundary { fill: #ca8a04; }
|
|
1577
|
+
.review-journey-dot.stage-recovery { fill: #dc2626; }
|
|
1578
|
+
.review-journey-number {
|
|
1579
|
+
fill: #ffffff;
|
|
1580
|
+
font-size: 11px;
|
|
1581
|
+
font-weight: 850;
|
|
1582
|
+
dominant-baseline: central;
|
|
1583
|
+
}
|
|
1584
|
+
.review-journey-tag {
|
|
1585
|
+
fill: #64748b;
|
|
1586
|
+
font-size: 12px;
|
|
1587
|
+
font-weight: 850;
|
|
1588
|
+
}
|
|
1589
|
+
.review-journey-label {
|
|
1590
|
+
fill: #0f172a;
|
|
1591
|
+
font-size: 12px;
|
|
1592
|
+
font-weight: 760;
|
|
1593
|
+
}
|
|
1594
|
+
.review-panel-list {
|
|
1595
|
+
margin: 16px 0 0;
|
|
1596
|
+
padding-left: 18px;
|
|
1597
|
+
color: var(--review-text);
|
|
1598
|
+
font-size: 15px;
|
|
1599
|
+
line-height: 1.72;
|
|
1600
|
+
overflow-wrap: anywhere;
|
|
1601
|
+
}
|
|
1602
|
+
.review-panel-list li + li {
|
|
1603
|
+
margin-top: 9px;
|
|
1604
|
+
}
|
|
1605
|
+
.review-detail-summary {
|
|
1606
|
+
font-weight: 850;
|
|
1607
|
+
color: var(--review-text);
|
|
1608
|
+
}
|
|
1609
|
+
.review-detail-body {
|
|
1610
|
+
color: var(--review-text);
|
|
1611
|
+
}
|
|
1612
|
+
.review-panel-list .empty {
|
|
1613
|
+
color: var(--review-muted);
|
|
1614
|
+
}
|
|
1615
|
+
.review-bottom-bar {
|
|
1616
|
+
position: fixed;
|
|
1617
|
+
left: 0;
|
|
1618
|
+
right: 0;
|
|
1619
|
+
bottom: 0;
|
|
1620
|
+
z-index: 30;
|
|
1621
|
+
padding: 12px 22px calc(12px + env(safe-area-inset-bottom));
|
|
1622
|
+
border-top: 1px solid var(--review-line);
|
|
1623
|
+
background: rgba(246, 248, 251, 0.94);
|
|
1624
|
+
box-shadow: 0 -14px 32px rgba(15, 23, 42, 0.08);
|
|
1625
|
+
backdrop-filter: blur(14px);
|
|
1626
|
+
}
|
|
1627
|
+
.review-bottom-bar-inner {
|
|
1628
|
+
display: flex;
|
|
1629
|
+
justify-content: flex-end;
|
|
1630
|
+
gap: 12px;
|
|
1631
|
+
max-width: 1220px;
|
|
1632
|
+
margin: 0 auto;
|
|
1633
|
+
}
|
|
1634
|
+
.review-bottom-action {
|
|
1635
|
+
cursor: pointer;
|
|
1636
|
+
display: inline-flex;
|
|
1637
|
+
align-items: center;
|
|
1638
|
+
justify-content: center;
|
|
1639
|
+
min-width: 152px;
|
|
1640
|
+
min-height: 48px;
|
|
1641
|
+
border: 1px solid transparent;
|
|
1642
|
+
border-radius: 12px;
|
|
1643
|
+
padding: 0 20px;
|
|
1644
|
+
font: inherit;
|
|
1645
|
+
font-size: 16px;
|
|
1646
|
+
font-weight: 850;
|
|
1647
|
+
letter-spacing: 0;
|
|
1648
|
+
line-height: 1;
|
|
1649
|
+
white-space: nowrap;
|
|
1650
|
+
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
|
|
1651
|
+
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
|
1652
|
+
}
|
|
1653
|
+
.review-bottom-action.revise {
|
|
1654
|
+
border-color: #fecaca;
|
|
1655
|
+
background: #fff1f2;
|
|
1656
|
+
color: #b42318;
|
|
1657
|
+
}
|
|
1658
|
+
.review-bottom-action.confirm {
|
|
1659
|
+
border-color: #bbf7d0;
|
|
1660
|
+
background: #ecfdf3;
|
|
1661
|
+
color: #067647;
|
|
1662
|
+
}
|
|
1663
|
+
.review-bottom-action:hover,
|
|
1664
|
+
.review-bottom-action:focus-visible {
|
|
1665
|
+
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.1);
|
|
1666
|
+
transform: translateY(-1px);
|
|
1667
|
+
outline: none;
|
|
1668
|
+
}
|
|
1669
|
+
.review-bottom-action.revise:hover,
|
|
1670
|
+
.review-bottom-action.revise:focus-visible {
|
|
1671
|
+
border-color: #fda4af;
|
|
1672
|
+
background: #ffe4e6;
|
|
1673
|
+
}
|
|
1674
|
+
.review-bottom-action.confirm:hover,
|
|
1675
|
+
.review-bottom-action.confirm:focus-visible {
|
|
1676
|
+
border-color: #86efac;
|
|
1677
|
+
background: #dcfce7;
|
|
1678
|
+
}
|
|
1679
|
+
.review-bottom-action:active {
|
|
1680
|
+
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
|
|
1681
|
+
transform: translateY(0);
|
|
1682
|
+
}
|
|
1683
|
+
@media (max-width: 860px) {
|
|
1684
|
+
.review-overview {
|
|
1685
|
+
grid-template-columns: 1fr;
|
|
1686
|
+
}
|
|
1687
|
+
.review-panel-grid {
|
|
1688
|
+
grid-template-columns: 1fr;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
@media (max-width: 620px) {
|
|
1692
|
+
.review-page { padding: 18px 12px 128px; }
|
|
1693
|
+
.review-topbar { align-items: flex-start; flex-direction: column; }
|
|
1694
|
+
.review-overview { padding: 18px; }
|
|
1695
|
+
.review-overview h1 {
|
|
1696
|
+
font-size: 26px;
|
|
1697
|
+
word-break: break-all;
|
|
1698
|
+
}
|
|
1699
|
+
.review-problem { word-break: break-all; }
|
|
1700
|
+
.review-map-canvas svg { min-width: 0; }
|
|
1701
|
+
.review-journey-map svg { min-width: 620px; }
|
|
1702
|
+
.review-section-heading h2 { font-size: 20px; }
|
|
1703
|
+
.review-bottom-bar { padding-inline: 12px; }
|
|
1704
|
+
.review-bottom-bar-inner {
|
|
1705
|
+
display: grid;
|
|
1706
|
+
grid-template-columns: 1fr 1fr;
|
|
1707
|
+
gap: 8px;
|
|
1708
|
+
}
|
|
1709
|
+
.review-bottom-action {
|
|
1710
|
+
justify-content: center;
|
|
1711
|
+
padding-inline: 10px;
|
|
1712
|
+
font-size: 15px;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
</style>
|
|
1716
|
+
</head>
|
|
1717
|
+
<body>
|
|
1718
|
+
<main class="review-page">
|
|
1719
|
+
<header class="review-topbar">
|
|
1720
|
+
<div class="review-brand">OpenPrd / 评审面板</div>
|
|
1721
|
+
</header>
|
|
1722
|
+
${renderReviewOverview(snapshot, sectionsData)}
|
|
1723
|
+
${renderReviewFlowSvg(snapshot, sectionsData)}
|
|
1724
|
+
<section class="review-panel-grid" aria-label="固定评审项">
|
|
1725
|
+
${panels.join('\n')}
|
|
1726
|
+
</section>
|
|
1727
|
+
${renderReviewDecision(snapshot)}
|
|
1728
|
+
<script>
|
|
1729
|
+
async function copyReviewText(text) {
|
|
1730
|
+
try {
|
|
1731
|
+
await navigator.clipboard.writeText(text);
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
const textarea = document.createElement('textarea');
|
|
1734
|
+
textarea.value = text;
|
|
1735
|
+
textarea.setAttribute('readonly', '');
|
|
1736
|
+
textarea.style.position = 'fixed';
|
|
1737
|
+
textarea.style.left = '-9999px';
|
|
1738
|
+
document.body.appendChild(textarea);
|
|
1739
|
+
textarea.select();
|
|
1740
|
+
document.execCommand('copy');
|
|
1741
|
+
textarea.remove();
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
function flashCopied(button) {
|
|
1745
|
+
const old = button.innerHTML;
|
|
1746
|
+
button.textContent = '已复制';
|
|
1747
|
+
setTimeout(() => { button.innerHTML = old; }, 1200);
|
|
1748
|
+
}
|
|
1749
|
+
document.querySelectorAll('[data-copy-value]').forEach((button) => {
|
|
1750
|
+
button.addEventListener('click', async () => {
|
|
1751
|
+
await copyReviewText(button.dataset.copyValue || '');
|
|
1752
|
+
flashCopied(button);
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
</script>
|
|
1756
|
+
</main>
|
|
1757
|
+
</body>
|
|
1758
|
+
</html>`;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function toYamlLines(value, depth = 0) {
|
|
1762
|
+
const indent = ' '.repeat(depth);
|
|
1763
|
+
const scalar = (input) => JSON.stringify(String(input ?? ''));
|
|
1764
|
+
if (Array.isArray(value)) {
|
|
1765
|
+
if (value.length === 0) return [`${indent}[]`];
|
|
1766
|
+
return value.flatMap((item) => {
|
|
1767
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
1768
|
+
const [firstKey] = Object.keys(item);
|
|
1769
|
+
const nested = toYamlLines(item[firstKey], depth + 1);
|
|
1770
|
+
return [`${indent}- ${firstKey}:`, ...nested];
|
|
1771
|
+
}
|
|
1772
|
+
return [`${indent}- ${scalar(item)}`];
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
if (value && typeof value === 'object') {
|
|
1776
|
+
return Object.entries(value).flatMap(([key, entry]) => {
|
|
1777
|
+
if (Array.isArray(entry) || (entry && typeof entry === 'object')) {
|
|
1778
|
+
return [`${indent}${key}:`, ...toYamlLines(entry, depth + 1)];
|
|
1779
|
+
}
|
|
1780
|
+
return [`${indent}${key}: ${scalar(entry)}`];
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
return [`${indent}${scalar(value)}`];
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function renderArtifactFrontmatter(value) {
|
|
1787
|
+
const lines = ['---'];
|
|
1788
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1789
|
+
if (Array.isArray(entry) || (entry && typeof entry === 'object')) {
|
|
1790
|
+
lines.push(`${key}:`);
|
|
1791
|
+
lines.push(...toYamlLines(entry, 1));
|
|
1792
|
+
} else {
|
|
1793
|
+
lines.push(`${key}: ${String(entry ?? '')}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
lines.push('---', '');
|
|
1797
|
+
return lines.join('\n');
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function playgroundFieldDefinitions() {
|
|
1801
|
+
return [
|
|
1802
|
+
{ key: 'problemStatement', label: '问题定义', kind: 'text' },
|
|
1803
|
+
{ key: 'goals', label: '目标', kind: 'list' },
|
|
1804
|
+
{ key: 'successMetrics', label: '成功指标', kind: 'list' },
|
|
1805
|
+
{ key: 'inScope', label: '范围内', kind: 'list' },
|
|
1806
|
+
{ key: 'outOfScope', label: '范围外', kind: 'list' },
|
|
1807
|
+
{ key: 'primaryFlows', label: '主流程', kind: 'list' },
|
|
1808
|
+
{ key: 'openQuestions', label: '开放问题', kind: 'list' },
|
|
1809
|
+
];
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
export function renderPlaygroundMarkdown({ snapshot, state }) {
|
|
1813
|
+
const capturePatch = {
|
|
1814
|
+
'problem.problemStatement': { value: state.problemStatement, source: 'user-confirmed' },
|
|
1815
|
+
'goals.goals': { value: state.goals, source: 'user-confirmed' },
|
|
1816
|
+
'goals.successMetrics': { value: state.successMetrics, source: 'user-confirmed' },
|
|
1817
|
+
'scope.inScope': { value: state.inScope, source: 'user-confirmed' },
|
|
1818
|
+
'scope.outOfScope': { value: state.outOfScope, source: 'user-confirmed' },
|
|
1819
|
+
'scenarios.primaryFlows': { value: state.primaryFlows, source: 'user-confirmed' },
|
|
1820
|
+
'risks.openQuestions': { value: state.openQuestions, source: 'user-confirmed' },
|
|
1821
|
+
};
|
|
1822
|
+
const frontmatter = renderArtifactFrontmatter({
|
|
1823
|
+
schema: 'openprd.artifact.v1',
|
|
1824
|
+
kind: 'playground',
|
|
1825
|
+
versionId: snapshot.versionId,
|
|
1826
|
+
title: snapshot.title,
|
|
1827
|
+
capturePatch,
|
|
1828
|
+
editableState: state,
|
|
1829
|
+
});
|
|
1830
|
+
return `${frontmatter}# 调试数据\n\n## 问题定义\n\n${state.problemStatement || '待补充'}\n\n## 目标\n\n${state.goals.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n\n## 成功指标\n\n${state.successMetrics.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n\n## 范围内\n\n${state.inScope.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n\n## 范围外\n\n${state.outOfScope.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n\n## 主流程\n\n${state.primaryFlows.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n\n## 开放问题\n\n${state.openQuestions.map((item) => `- ${item}`).join('\n') || '- 待补充'}\n`;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
export function renderPlaygroundPatch({ state }) {
|
|
1834
|
+
return {
|
|
1835
|
+
'problem.problemStatement': { value: state.problemStatement, source: 'user-confirmed' },
|
|
1836
|
+
'goals.goals': { value: state.goals, source: 'user-confirmed' },
|
|
1837
|
+
'goals.successMetrics': { value: state.successMetrics, source: 'user-confirmed' },
|
|
1838
|
+
'scope.inScope': { value: state.inScope, source: 'user-confirmed' },
|
|
1839
|
+
'scope.outOfScope': { value: state.outOfScope, source: 'user-confirmed' },
|
|
1840
|
+
'scenarios.primaryFlows': { value: state.primaryFlows, source: 'user-confirmed' },
|
|
1841
|
+
'risks.openQuestions': { value: state.openQuestions, source: 'user-confirmed' },
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
export function renderPlaygroundArtifact({ snapshot, state, markdownPath, patchPath }) {
|
|
1846
|
+
const fields = playgroundFieldDefinitions();
|
|
1847
|
+
const formControls = fields.map((field) => `
|
|
1848
|
+
<label class="card">
|
|
1849
|
+
<div class="card-header">${escapeHtml(field.label)}</div>
|
|
1850
|
+
<div class="card-body">
|
|
1851
|
+
${field.kind === 'text'
|
|
1852
|
+
? `<textarea data-field="${field.key}" rows="4">${escapeHtml(state[field.key] ?? '')}</textarea>`
|
|
1853
|
+
: `<textarea data-field="${field.key}" rows="6">${escapeHtml((state[field.key] ?? []).join('\n'))}</textarea>`}
|
|
1854
|
+
</div>
|
|
1855
|
+
</label>
|
|
1856
|
+
`).join('\n');
|
|
1857
|
+
|
|
1858
|
+
const initialMarkdown = renderPlaygroundMarkdown({ snapshot, state });
|
|
1859
|
+
const initialPatch = renderPlaygroundPatch({ state });
|
|
1860
|
+
|
|
1861
|
+
return `<!DOCTYPE html>
|
|
1862
|
+
<html lang="zh-CN">
|
|
1863
|
+
<head>
|
|
1864
|
+
<meta charset="UTF-8" />
|
|
1865
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1866
|
+
<title>${escapeHtml(snapshot.title)} Playground</title>
|
|
1867
|
+
<style>
|
|
1868
|
+
:root {
|
|
1869
|
+
color-scheme: light;
|
|
1870
|
+
--bg: #fffaf0;
|
|
1871
|
+
--panel: #ffffff;
|
|
1872
|
+
--line: rgba(15,23,42,0.12);
|
|
1873
|
+
--text: #1f2937;
|
|
1874
|
+
--muted: #6b7280;
|
|
1875
|
+
--accent: #0f766e;
|
|
1876
|
+
}
|
|
1877
|
+
* { box-sizing: border-box; }
|
|
1878
|
+
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
1879
|
+
.page { max-width: 1320px; margin: 0 auto; padding: 24px; }
|
|
1880
|
+
h1 { margin: 0 0 8px; font-size: 42px; }
|
|
1881
|
+
.subtitle { margin: 0 0 20px; color: var(--muted); line-height: 1.7; }
|
|
1882
|
+
.chip-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
|
1883
|
+
.chip { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; padding: 6px 10px; background: #fff; color: var(--muted); font-size: 12px; }
|
|
1884
|
+
.layout { display: grid; grid-template-columns: minmax(320px, 0.95fr) minmax(360px, 1.05fr); gap: 16px; }
|
|
1885
|
+
.form-grid { display: grid; gap: 14px; }
|
|
1886
|
+
.card { border: 1px solid var(--line); border-radius: 18px; background: var(--panel); overflow: hidden; }
|
|
1887
|
+
.card-header { padding: 12px 16px 0; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
1888
|
+
.card-body { padding: 12px 16px 16px; }
|
|
1889
|
+
textarea { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 12px; font: inherit; line-height: 1.6; resize: vertical; }
|
|
1890
|
+
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
|
1891
|
+
button { border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; background: #fff; cursor: pointer; }
|
|
1892
|
+
.primary { background: #0f766e; color: #fff; border-color: #0f766e; }
|
|
1893
|
+
pre { margin: 0; border-radius: 14px; background: #111827; color: #e5e7eb; padding: 14px; overflow: auto; white-space: pre-wrap; line-height: 1.6; font-size: 13px; }
|
|
1894
|
+
.hint { color: var(--muted); font-size: 13px; line-height: 1.6; }
|
|
1895
|
+
.top-meta {
|
|
1896
|
+
display: flex;
|
|
1897
|
+
flex-wrap: wrap;
|
|
1898
|
+
gap: 8px;
|
|
1899
|
+
margin-top: -4px;
|
|
1900
|
+
}
|
|
1901
|
+
.meta-chip {
|
|
1902
|
+
display: inline-flex;
|
|
1903
|
+
width: fit-content;
|
|
1904
|
+
padding: 6px 10px;
|
|
1905
|
+
border-radius: 999px;
|
|
1906
|
+
border: 1px solid var(--line);
|
|
1907
|
+
background: rgba(255,255,255,0.85);
|
|
1908
|
+
color: var(--muted);
|
|
1909
|
+
font-size: 12px;
|
|
1910
|
+
}
|
|
1911
|
+
.actions {
|
|
1912
|
+
display: flex;
|
|
1913
|
+
justify-content: flex-end;
|
|
1914
|
+
margin-top: 12px;
|
|
1915
|
+
}
|
|
1916
|
+
.copy-button {
|
|
1917
|
+
display: inline-flex;
|
|
1918
|
+
align-items: center;
|
|
1919
|
+
gap: 8px;
|
|
1920
|
+
border: 1px solid rgba(15,23,42,0.18);
|
|
1921
|
+
border-radius: 999px;
|
|
1922
|
+
background: #fff;
|
|
1923
|
+
color: var(--text);
|
|
1924
|
+
padding: 9px 14px;
|
|
1925
|
+
font: inherit;
|
|
1926
|
+
font-weight: 700;
|
|
1927
|
+
cursor: pointer;
|
|
1928
|
+
}
|
|
1929
|
+
.copy-button:hover {
|
|
1930
|
+
border-color: var(--accent);
|
|
1931
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
1932
|
+
}
|
|
1933
|
+
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } }
|
|
1934
|
+
</style>
|
|
1935
|
+
</head>
|
|
1936
|
+
<body>
|
|
1937
|
+
<main class="page">
|
|
1938
|
+
<h1>${escapeHtml(snapshot.title)} 调试面板</h1>
|
|
1939
|
+
<p class="subtitle">左侧调整关键 PRD 参数,右侧会实时生成 Markdown 数据源和 capture patch。你可以复制 Markdown、复制 patch,或下载文件后再用 <code>openprd capture --artifact-markdown</code> 导回工作区。</p>
|
|
1940
|
+
<div class="chip-row">
|
|
1941
|
+
<span class="chip">版本: ${escapeHtml(snapshot.versionId)}</span>
|
|
1942
|
+
<span class="chip">Markdown 数据源: ${escapeHtml(markdownPath)}</span>
|
|
1943
|
+
<span class="chip">捕获补丁: ${escapeHtml(patchPath)}</span>
|
|
1944
|
+
</div>
|
|
1945
|
+
<section class="layout">
|
|
1946
|
+
<div class="form-grid">${formControls}
|
|
1947
|
+
<div class="card">
|
|
1948
|
+
<div class="card-header">操作</div>
|
|
1949
|
+
<div class="card-body">
|
|
1950
|
+
<div class="actions">
|
|
1951
|
+
<button id="copyMarkdown" class="primary">复制更新后的 Markdown</button>
|
|
1952
|
+
<button id="copyPatch">复制捕获补丁 JSON</button>
|
|
1953
|
+
<button id="downloadMarkdown">下载 data.md</button>
|
|
1954
|
+
<button id="downloadPatch">下载 capture-patch.json</button>
|
|
1955
|
+
</div>
|
|
1956
|
+
<p class="hint">推荐流程:在这里微调参数 -> 复制或下载 Markdown / patch -> 运行 <code>openprd capture . --artifact-markdown <data.md></code> 或使用 JSON patch 导回。</p>
|
|
1957
|
+
</div>
|
|
1958
|
+
</div>
|
|
1959
|
+
</div>
|
|
1960
|
+
<div class="form-grid">
|
|
1961
|
+
<div class="card">
|
|
1962
|
+
<div class="card-header">Markdown 数据源</div>
|
|
1963
|
+
<div class="card-body"><pre id="markdownPreview">${escapeHtml(initialMarkdown)}</pre></div>
|
|
1964
|
+
</div>
|
|
1965
|
+
<div class="card">
|
|
1966
|
+
<div class="card-header">捕获补丁 JSON</div>
|
|
1967
|
+
<div class="card-body"><pre id="patchPreview">${escapeHtml(JSON.stringify(initialPatch, null, 2))}</pre></div>
|
|
1968
|
+
</div>
|
|
1969
|
+
</div>
|
|
1970
|
+
</section>
|
|
1971
|
+
</main>
|
|
1972
|
+
<script>
|
|
1973
|
+
const fields = ${JSON.stringify(fields)};
|
|
1974
|
+
const state = ${JSON.stringify(state)};
|
|
1975
|
+
const markdownPreview = document.getElementById('markdownPreview');
|
|
1976
|
+
const patchPreview = document.getElementById('patchPreview');
|
|
1977
|
+
|
|
1978
|
+
function splitList(value) {
|
|
1979
|
+
return String(value || '').split(/\\n+/).map((item) => item.trim()).filter(Boolean);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function yamlValue(value, depth = 0) {
|
|
1983
|
+
const indent = ' '.repeat(depth);
|
|
1984
|
+
if (Array.isArray(value)) {
|
|
1985
|
+
if (value.length === 0) return [indent + '[]'];
|
|
1986
|
+
return value.map((item) => indent + '- ' + JSON.stringify(String(item ?? '')));
|
|
1987
|
+
}
|
|
1988
|
+
if (value && typeof value === 'object') {
|
|
1989
|
+
return Object.entries(value).flatMap(([key, entry]) => {
|
|
1990
|
+
if (Array.isArray(entry) || (entry && typeof entry === 'object')) {
|
|
1991
|
+
return [indent + key + ':', ...yamlValue(entry, depth + 1)];
|
|
1992
|
+
}
|
|
1993
|
+
return [indent + key + ': ' + JSON.stringify(String(entry ?? ''))];
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
return [indent + JSON.stringify(String(value ?? ''))];
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function buildPatch() {
|
|
2000
|
+
return {
|
|
2001
|
+
"problem.problemStatement": { value: state.problemStatement, source: "user-confirmed" },
|
|
2002
|
+
"goals.goals": { value: state.goals, source: "user-confirmed" },
|
|
2003
|
+
"goals.successMetrics": { value: state.successMetrics, source: "user-confirmed" },
|
|
2004
|
+
"scope.inScope": { value: state.inScope, source: "user-confirmed" },
|
|
2005
|
+
"scope.outOfScope": { value: state.outOfScope, source: "user-confirmed" },
|
|
2006
|
+
"scenarios.primaryFlows": { value: state.primaryFlows, source: "user-confirmed" },
|
|
2007
|
+
"risks.openQuestions": { value: state.openQuestions, source: "user-confirmed" }
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function buildMarkdown() {
|
|
2012
|
+
const patch = buildPatch();
|
|
2013
|
+
const frontmatter = ['---',
|
|
2014
|
+
'schema: openprd.artifact.v1',
|
|
2015
|
+
'kind: playground',
|
|
2016
|
+
'versionId: ${escapeHtml(snapshot.versionId)}',
|
|
2017
|
+
'title: ${escapeHtml(snapshot.title)}',
|
|
2018
|
+
'capturePatch:',
|
|
2019
|
+
...yamlValue(patch, 1),
|
|
2020
|
+
'editableState:',
|
|
2021
|
+
...yamlValue(state, 1),
|
|
2022
|
+
'---',
|
|
2023
|
+
'',
|
|
2024
|
+
'# 调试数据',
|
|
2025
|
+
'',
|
|
2026
|
+
'## 问题定义',
|
|
2027
|
+
'',
|
|
2028
|
+
state.problemStatement || '待补充',
|
|
2029
|
+
'',
|
|
2030
|
+
'## 目标',
|
|
2031
|
+
'',
|
|
2032
|
+
...(state.goals.length ? state.goals.map((item) => '- ' + item) : ['- 待补充']),
|
|
2033
|
+
'',
|
|
2034
|
+
'## 成功指标',
|
|
2035
|
+
'',
|
|
2036
|
+
...(state.successMetrics.length ? state.successMetrics.map((item) => '- ' + item) : ['- 待补充']),
|
|
2037
|
+
'',
|
|
2038
|
+
'## 范围内',
|
|
2039
|
+
'',
|
|
2040
|
+
...(state.inScope.length ? state.inScope.map((item) => '- ' + item) : ['- 待补充']),
|
|
2041
|
+
'',
|
|
2042
|
+
'## 范围外',
|
|
2043
|
+
'',
|
|
2044
|
+
...(state.outOfScope.length ? state.outOfScope.map((item) => '- ' + item) : ['- 待补充']),
|
|
2045
|
+
'',
|
|
2046
|
+
'## 主流程',
|
|
2047
|
+
'',
|
|
2048
|
+
...(state.primaryFlows.length ? state.primaryFlows.map((item) => '- ' + item) : ['- 待补充']),
|
|
2049
|
+
'',
|
|
2050
|
+
'## 开放问题',
|
|
2051
|
+
'',
|
|
2052
|
+
...(state.openQuestions.length ? state.openQuestions.map((item) => '- ' + item) : ['- 待补充']),
|
|
2053
|
+
''
|
|
2054
|
+
];
|
|
2055
|
+
return frontmatter.join('\\n');
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function refreshOutputs() {
|
|
2059
|
+
markdownPreview.textContent = buildMarkdown();
|
|
2060
|
+
patchPreview.textContent = JSON.stringify(buildPatch(), null, 2);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
document.querySelectorAll('textarea[data-field]').forEach((textarea) => {
|
|
2064
|
+
textarea.addEventListener('input', () => {
|
|
2065
|
+
const field = textarea.dataset.field;
|
|
2066
|
+
const definition = fields.find((item) => item.key === field);
|
|
2067
|
+
state[field] = definition.kind === 'text' ? textarea.value.trim() : splitList(textarea.value);
|
|
2068
|
+
refreshOutputs();
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
async function copyText(text) {
|
|
2073
|
+
await navigator.clipboard.writeText(text);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
document.getElementById('copyMarkdown').addEventListener('click', () => copyText(markdownPreview.textContent));
|
|
2077
|
+
document.getElementById('copyPatch').addEventListener('click', () => copyText(patchPreview.textContent));
|
|
2078
|
+
|
|
2079
|
+
function download(name, text) {
|
|
2080
|
+
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
2081
|
+
const url = URL.createObjectURL(blob);
|
|
2082
|
+
const link = document.createElement('a');
|
|
2083
|
+
link.href = url;
|
|
2084
|
+
link.download = name;
|
|
2085
|
+
link.click();
|
|
2086
|
+
URL.revokeObjectURL(url);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
document.getElementById('downloadMarkdown').addEventListener('click', () => download('playground.data.md', markdownPreview.textContent));
|
|
2090
|
+
document.getElementById('downloadPatch').addEventListener('click', () => download('playground.capture-patch.json', patchPreview.textContent));
|
|
2091
|
+
|
|
2092
|
+
refreshOutputs();
|
|
2093
|
+
</script>
|
|
2094
|
+
</body>
|
|
2095
|
+
</html>`;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
export function renderReviewArtifact({ snapshot }) {
|
|
2099
|
+
const sectionsData = snapshot.sections ?? {};
|
|
2100
|
+
return renderReviewPage({ snapshot, sectionsData });
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
export function renderRegressionArtifact({ task, report }) {
|
|
2104
|
+
const passed = report.summary.failed === 0;
|
|
2105
|
+
const summaryCards = [
|
|
2106
|
+
metricCard('任务', task.id, task.title),
|
|
2107
|
+
metricCard('验证方式', report.kind || 'command', report.verifyCommand || '未指定'),
|
|
2108
|
+
metricCard('通过用例', `${report.summary.passed}/${report.summary.total}`, '本次回归通过的测试用例数量'),
|
|
2109
|
+
metricCard('失败用例', `${report.summary.failed}`, '需要继续修复或补证据的测试用例数量'),
|
|
2110
|
+
];
|
|
2111
|
+
|
|
2112
|
+
const sections = [
|
|
2113
|
+
card('回归用例清单', report.cases.map((item) => `
|
|
2114
|
+
<div class="qa-item ${item.passed ? 'success' : 'warning'}">
|
|
2115
|
+
<div class="qa-label">${escapeHtml(item.id)} · ${escapeHtml(item.title)}</div>
|
|
2116
|
+
<div class="qa-status-row">
|
|
2117
|
+
<div class="status-badge mini-status ${item.passed ? 'status-pass' : 'status-fail'}">${item.passed ? '通过' : '未通过'}</div>
|
|
2118
|
+
</div>
|
|
2119
|
+
<div class="qa-meta">预期: ${escapeHtml(item.expected)}</div>
|
|
2120
|
+
<div class="qa-meta">结果: ${escapeHtml(item.actual)}</div>
|
|
2121
|
+
<div class="qa-meta">证据: ${escapeHtml(leafName(item.evidence))}</div>
|
|
2122
|
+
</div>
|
|
2123
|
+
`).join('\n')),
|
|
2124
|
+
...(report.screenshots?.length ? [
|
|
2125
|
+
card('截图证据', report.screenshots.map((item) => `
|
|
2126
|
+
<div class="evidence-item">
|
|
2127
|
+
<div class="card-body"><img src="${escapeHtml(item.url)}" alt="截图证据" style="max-width:100%; border-radius:12px; border:1px solid rgba(15,23,42,0.12);" /></div>
|
|
2128
|
+
</div>
|
|
2129
|
+
`).join('\n')),
|
|
2130
|
+
] : []),
|
|
2131
|
+
formatExportItem({
|
|
2132
|
+
title: '结构化回归结论',
|
|
2133
|
+
description: '供后续 commit、handoff、回归复跑或汇总报告使用。',
|
|
2134
|
+
payload: JSON.stringify(report, null, 2),
|
|
2135
|
+
}),
|
|
2136
|
+
];
|
|
2137
|
+
|
|
2138
|
+
return pageShell({
|
|
2139
|
+
eyebrow: 'OpenPrd / 回归报告',
|
|
2140
|
+
title: `${task.id} 回归验证`,
|
|
2141
|
+
subtitle: '执行结果必须沉淀成结构化回归资产,而不是只把 verify 命令跑一遍。',
|
|
2142
|
+
statusBadge: passed
|
|
2143
|
+
? { label: '通过', className: 'status-pass' }
|
|
2144
|
+
: { label: '未通过', className: 'status-fail' },
|
|
2145
|
+
topMeta: [
|
|
2146
|
+
`任务来源: ${task.changeId}`,
|
|
2147
|
+
],
|
|
2148
|
+
summaryCards,
|
|
2149
|
+
sections,
|
|
2150
|
+
footer: '',
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function qualityStatusLabel(status) {
|
|
2155
|
+
if (status === 'pass') return '通过';
|
|
2156
|
+
if (status === 'fail') return '失败';
|
|
2157
|
+
if (status === 'needs-evidence') return '需补证据';
|
|
2158
|
+
if (status === 'advisory') return '建议关注';
|
|
2159
|
+
if (status === 'waived') return '已豁免';
|
|
2160
|
+
return '需关注';
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function qualityStatusClass(status) {
|
|
2164
|
+
if (status === 'pass') return 'success';
|
|
2165
|
+
if (status === 'fail') return 'warning';
|
|
2166
|
+
return 'warning';
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function qualityBadgeClass(status) {
|
|
2170
|
+
if (status === 'production-ready') return 'status-pass';
|
|
2171
|
+
if (status === 'failed') return 'status-fail';
|
|
2172
|
+
return 'status-warn';
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function miniMetric(title, metric, subtext) {
|
|
2176
|
+
return `
|
|
2177
|
+
<div class="mini-metric">
|
|
2178
|
+
<div class="mini-metric-label">${escapeHtml(title)}</div>
|
|
2179
|
+
<div class="mini-metric-value">${escapeHtml(metric)}</div>
|
|
2180
|
+
<div class="mini-metric-sub">${escapeHtml(subtext)}</div>
|
|
2181
|
+
</div>
|
|
2182
|
+
`;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function auditStatusClass(status) {
|
|
2186
|
+
if (status === 'pass' || status === 'production-ready' || status === 'waived') return 'audit-pass';
|
|
2187
|
+
if (status === 'fail' || status === 'failed' || status === 'needs-attention') return 'audit-fail';
|
|
2188
|
+
if (status === 'needs-evidence') return 'audit-evidence';
|
|
2189
|
+
return 'audit-advisory';
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function auditGateDecision(gate) {
|
|
2193
|
+
if (gate.required && gate.status === 'pass') return '本期必测块已通过';
|
|
2194
|
+
if (gate.required && gate.status === 'waived') return '已豁免,需保留依据';
|
|
2195
|
+
if (gate.required) return '本期必测未通过,不能宣称就绪';
|
|
2196
|
+
if (gate.status === 'pass') return '按风险确认项已有证据';
|
|
2197
|
+
if (gate.status === 'advisory') return '当前可选,风险进入范围后升级';
|
|
2198
|
+
return '当前不阻断,建议补证据';
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function auditChips(items, emptyText = '无') {
|
|
2202
|
+
const list = Array.isArray(items) ? items.filter(Boolean) : [];
|
|
2203
|
+
if (list.length === 0) return `<span class="audit-chip muted">${escapeHtml(emptyText)}</span>`;
|
|
2204
|
+
return list.map((item) => `<span class="audit-chip">${escapeHtml(item)}</span>`).join('\n');
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function auditActionItems(report) {
|
|
2208
|
+
const requiredGates = report.gates.filter((gate) => gate.required);
|
|
2209
|
+
const failingRequired = requiredGates.filter((gate) => !['pass', 'waived'].includes(gate.status));
|
|
2210
|
+
const advisory = report.gates.filter((gate) => !gate.required && gate.status !== 'pass');
|
|
2211
|
+
if (failingRequired.length > 0) {
|
|
2212
|
+
return failingRequired.map((gate) => `${reviewerGateDisplay(gate)}: ${gate.warnings[0] ?? gate.evidence?.summary ?? '补齐必需证据后再继续'}`);
|
|
2213
|
+
}
|
|
2214
|
+
if (advisory.length > 0) {
|
|
2215
|
+
return advisory.map((gate) => `${reviewerGateDisplay(gate)}: ${auditGateDecision(gate)}`);
|
|
2216
|
+
}
|
|
2217
|
+
return ['当前本期必测块全部通过;继续保留本次执行证据,交付前复跑 openprd run --verify。'];
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function auditEvidenceRows(report) {
|
|
2221
|
+
return report.gates.flatMap((gate) => {
|
|
2222
|
+
const sources = gate.evidence?.sources ?? [];
|
|
2223
|
+
if (sources.length === 0) {
|
|
2224
|
+
return [{
|
|
2225
|
+
gate,
|
|
2226
|
+
source: '未提供',
|
|
2227
|
+
path: gate.required ? '缺少必需证据' : '当前场景未要求',
|
|
2228
|
+
empty: true,
|
|
2229
|
+
}];
|
|
2230
|
+
}
|
|
2231
|
+
return sources.map((source) => ({
|
|
2232
|
+
gate,
|
|
2233
|
+
source: source.source ?? 'evidence',
|
|
2234
|
+
path: source.path ?? 'unknown',
|
|
2235
|
+
empty: false,
|
|
2236
|
+
}));
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function reviewerGateFocus(gate) {
|
|
2241
|
+
const focusByGate = {
|
|
2242
|
+
smoke: '看主流程和最容易出错的失败路径是否真的跑过,而不是只写了测试文件。',
|
|
2243
|
+
'feature-coverage': '看本次需求、任务和验收点是否都被覆盖;如果没有激活任务,要确认这是不是合理状态。',
|
|
2244
|
+
'business-guardrails': '看成本、免费额度、滥用、报警和止损动作是否讲清楚,避免上线后失控。',
|
|
2245
|
+
traceability: '看出问题时能不能从用户动作追到请求、任务和错误,方便复现和定位。',
|
|
2246
|
+
redaction: '看日志、截图、报告和错误信息里是否可能泄露用户隐私、密钥或敏感业务数据。',
|
|
2247
|
+
'normal-performance': '看普通规模下是否会卡顿、超时、资源异常,是否有可比较的基线。',
|
|
2248
|
+
'extreme-performance': '看大数据、并发、异常输入或边界规模下是否有兜底,不只是跑小样本。',
|
|
2249
|
+
knowledge: '看这次发现的问题是否值得沉淀,避免下次 Agent 或团队重复踩同一个坑。',
|
|
2250
|
+
};
|
|
2251
|
+
return focusByGate[gate.id] ?? '看这个测试块是否和本次需求相关,证据是否来自本次执行。';
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function reviewerGateQuestion(gate) {
|
|
2255
|
+
if (gate.required && gate.status === 'pass') {
|
|
2256
|
+
return '你可以抽查证据是否对应本次需求;如果证据太泛,要求 Agent 补本次执行记录。';
|
|
2257
|
+
}
|
|
2258
|
+
if (gate.required) {
|
|
2259
|
+
return '这里不能放行。请要求 Agent 补证据、修复问题,并重新生成回归测试报告。';
|
|
2260
|
+
}
|
|
2261
|
+
if (gate.status === 'pass') {
|
|
2262
|
+
return '当前不阻断;你只需要判断这项是否和本次风险相关,必要时抽查证据。';
|
|
2263
|
+
}
|
|
2264
|
+
return '你要决定是否接受本次不补;如果准备发布或风险变高,应要求升级为本期必测。';
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function reviewerScenarioLabel(tag) {
|
|
2268
|
+
const labels = {
|
|
2269
|
+
core: '基础验证',
|
|
2270
|
+
frontend: '界面体验',
|
|
2271
|
+
desktop: '桌面端体验',
|
|
2272
|
+
backend: '服务与数据处理',
|
|
2273
|
+
businessCost: '成本与滥用风险',
|
|
2274
|
+
security: '隐私与安全',
|
|
2275
|
+
performance: '性能风险',
|
|
2276
|
+
extreme: '极端场景',
|
|
2277
|
+
release: '上线交付',
|
|
2278
|
+
legacy: '历史兼容',
|
|
2279
|
+
};
|
|
2280
|
+
return labels[tag] ?? tag;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function reviewerGateLabel(report, gateId) {
|
|
2284
|
+
const gate = report.gates.find((item) => item.id === gateId);
|
|
2285
|
+
return reviewerGateDisplay(gate ?? { id: gateId, label: gateId });
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function reviewerGateDisplay(gate) {
|
|
2289
|
+
if (gate.id === 'knowledge') return '经验沉淀';
|
|
2290
|
+
return String(gate.label ?? gate.id).replace(/\s*Skill\s*/g, ' ');
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
function reviewerEnforcementLabel(value) {
|
|
2294
|
+
if (value === 'blocking') return '严格阻断';
|
|
2295
|
+
if (value === 'advisory') return '建议模式';
|
|
2296
|
+
return value ?? '未标明';
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function reviewerPolicyLabels(report) {
|
|
2300
|
+
const policy = report.qualityPolicy ?? { scenarioTags: [], requiredGates: [], optionalGates: [] };
|
|
2301
|
+
return {
|
|
2302
|
+
scenarioLabels: policy.scenarioTags.map(reviewerScenarioLabel),
|
|
2303
|
+
requiredLabels: policy.requiredGates.map((gateId) => reviewerGateLabel(report, gateId)),
|
|
2304
|
+
optionalLabels: policy.optionalGates.map((gateId) => reviewerGateLabel(report, gateId)),
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function reviewerDecisionPayload(report, actionItems) {
|
|
2309
|
+
const labels = reviewerPolicyLabels(report);
|
|
2310
|
+
const failingRequired = report.gates
|
|
2311
|
+
.filter((gate) => gate.required && !['pass', 'waived'].includes(gate.status))
|
|
2312
|
+
.map(reviewerGateDisplay);
|
|
2313
|
+
const advisory = report.gates
|
|
2314
|
+
.filter((gate) => !gate.required && gate.status !== 'pass')
|
|
2315
|
+
.map(reviewerGateDisplay);
|
|
2316
|
+
return [
|
|
2317
|
+
`我看了回归测试报告 ${report.id},我的确认意见如下:`,
|
|
2318
|
+
'',
|
|
2319
|
+
`1. 场景判断:${labels.scenarioLabels.join(', ') || '未标明'}。我认为这个场景【接受 / 不接受】,原因是:`,
|
|
2320
|
+
`2. 本期必测块:${labels.requiredLabels.join(', ') || '无'}。我认为这些回归结果【可信 / 不可信】,需要补充:`,
|
|
2321
|
+
`3. 需确认遗漏:${advisory.join(', ') || '无'}。我选择【不属于本期,可延期 / 属于本期,需要现在补齐】。`,
|
|
2322
|
+
`4. 放行结论:${failingRequired.length === 0 ? '本期必测块可以继续,但请按我的选择处理需确认遗漏。' : `我不同意放行,未通过项包括:${failingRequired.join(', ')}`}`,
|
|
2323
|
+
'',
|
|
2324
|
+
'我希望 Agent 接下来处理:',
|
|
2325
|
+
...actionItems.map((item) => `- ${item}`),
|
|
2326
|
+
].join('\n');
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function reviewerEvidencePayload(report) {
|
|
2330
|
+
const missing = report.gates.filter((gate) => gate.status !== 'pass' || !gate.evidence?.present);
|
|
2331
|
+
return [
|
|
2332
|
+
`请根据回归测试报告 ${report.id} 补齐或解释以下回归遗漏:`,
|
|
2333
|
+
'',
|
|
2334
|
+
...missing.map((gate) => [
|
|
2335
|
+
`- ${reviewerGateDisplay(gate)}(${gate.required ? '本期必测' : '按风险确认'},当前状态:${qualityStatusLabel(gate.status)})`,
|
|
2336
|
+
` 我要确认:${reviewerGateFocus(gate)}`,
|
|
2337
|
+
` 你需要补充:${regressionHumanText(gate.warnings[0] ?? gate.evidence?.summary ?? '本次执行证据、覆盖范围和判断理由')}`,
|
|
2338
|
+
].join('\n')),
|
|
2339
|
+
'',
|
|
2340
|
+
'补完后请重新运行 openprd quality . --verify 和 openprd run . --verify,并给我新的报告链接。',
|
|
2341
|
+
].join('\n');
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
function reviewerScenarioPayload(report) {
|
|
2345
|
+
const labels = reviewerPolicyLabels(report);
|
|
2346
|
+
return [
|
|
2347
|
+
`我想重新确认回归测试场景。当前报告 ${report.id} 的场景是:${labels.scenarioLabels.join(', ') || '未标明'}。`,
|
|
2348
|
+
'',
|
|
2349
|
+
'请你重新判断:',
|
|
2350
|
+
'- 这个需求是否其实应该按上线交付 / 隐私与安全 / 性能风险 / 服务与数据处理 / 极端场景处理?',
|
|
2351
|
+
'- 哪些按风险确认的测试块应该升级为本期必测?',
|
|
2352
|
+
'- 如果仍保持当前场景,请用面向评审者的语言解释为什么。',
|
|
2353
|
+
'',
|
|
2354
|
+
`当前本期必测块:${labels.requiredLabels.join(', ') || '无'}`,
|
|
2355
|
+
].join('\n');
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function reviewCopyCard(title, description, payload) {
|
|
2359
|
+
return `
|
|
2360
|
+
<div class="review-copy">
|
|
2361
|
+
<div class="review-copy-head">
|
|
2362
|
+
<div>
|
|
2363
|
+
<strong>${escapeHtml(title)}</strong>
|
|
2364
|
+
<span>${escapeHtml(description)}</span>
|
|
2365
|
+
</div>
|
|
2366
|
+
<button type="button" data-copy-nearest>复制回对话</button>
|
|
2367
|
+
</div>
|
|
2368
|
+
<textarea readonly data-review-copy>${escapeHtml(payload)}</textarea>
|
|
2369
|
+
</div>
|
|
2370
|
+
`;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function regressionGateCopyPayload(gate, report) {
|
|
2374
|
+
const warnings = gate.warnings.map(regressionHumanText);
|
|
2375
|
+
const missingText = warnings.length > 0
|
|
2376
|
+
? warnings.map((item) => `- ${item}`).join('\n')
|
|
2377
|
+
: `- ${gate.evidence?.summary ?? '请补充本次执行证据和判断理由'}`;
|
|
2378
|
+
return [
|
|
2379
|
+
`请处理回归测试报告 ${report.id} 里的这项问题:${reviewerGateDisplay(gate)}`,
|
|
2380
|
+
'',
|
|
2381
|
+
`当前状态:${regressionResultLabel(gate)}`,
|
|
2382
|
+
`本期要求:${gate.required ? '本期必须处理' : '请判断是否属于本期'}`,
|
|
2383
|
+
`我关心的是:${regressionBlockDescription(gate)}`,
|
|
2384
|
+
'',
|
|
2385
|
+
'当前问题:',
|
|
2386
|
+
missingText,
|
|
2387
|
+
'',
|
|
2388
|
+
'请你接下来:',
|
|
2389
|
+
'1. 如果属于本期需求,请直接修复、补测或补证据。',
|
|
2390
|
+
'2. 如果你认为可以延期,请用需求视角说明原因、影响和后续条件。',
|
|
2391
|
+
'3. 处理后重新运行 openprd quality . --verify 和 openprd run . --verify,并给我新的报告链接。',
|
|
2392
|
+
].join('\n');
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function regressionGateHints(gate) {
|
|
2396
|
+
if (gate.required && gate.status !== 'pass') {
|
|
2397
|
+
return [
|
|
2398
|
+
'不要让用户决策是否修;先按本期必测块修复或补证据。',
|
|
2399
|
+
'补完后重新生成报告,确认必须修复数归零。',
|
|
2400
|
+
];
|
|
2401
|
+
}
|
|
2402
|
+
if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
|
|
2403
|
+
return [
|
|
2404
|
+
'如果这是具体需求交付,先补 active change/tasks.md。',
|
|
2405
|
+
'把新增、修改、删除、异常路径拆成可验收任务后再回归。',
|
|
2406
|
+
];
|
|
2407
|
+
}
|
|
2408
|
+
if (gate.status === 'needs-evidence') {
|
|
2409
|
+
return [
|
|
2410
|
+
'先确认项目是否只是有能力但缺本次执行证据。',
|
|
2411
|
+
'如果属于本期风险,补一次实际运行记录,而不是只引用脚本存在。',
|
|
2412
|
+
];
|
|
2413
|
+
}
|
|
2414
|
+
return [
|
|
2415
|
+
'先判断它是否真的属于本期需求风险。',
|
|
2416
|
+
'属于本期就补测;不属于本期才写清延期理由和触发条件。',
|
|
2417
|
+
];
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function regressionGateSimpleSuggestion(gate) {
|
|
2421
|
+
if (gate.required && gate.status !== 'pass') return '先修复或补证据,再重跑报告。';
|
|
2422
|
+
if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
|
|
2423
|
+
return '具体需求交付时先补 tasks.md。';
|
|
2424
|
+
}
|
|
2425
|
+
if (gate.status === 'needs-evidence') return '如果属于本期,就补本次执行证据。';
|
|
2426
|
+
return '相关就补测,不相关就写清延期理由。';
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function regressionResultLabel(gate) {
|
|
2430
|
+
if (gate.status === 'pass') return '已通过';
|
|
2431
|
+
if (gate.required) return '未通过';
|
|
2432
|
+
if (gate.status === 'needs-evidence') return '缺少证据';
|
|
2433
|
+
if (gate.status === 'advisory') return '需确认是否本期处理';
|
|
2434
|
+
return qualityStatusLabel(gate.status);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function regressionResultClass(gate) {
|
|
2438
|
+
if (gate.status === 'pass') return 'audit-pass';
|
|
2439
|
+
if (gate.required) return 'audit-fail';
|
|
2440
|
+
if (gate.status === 'needs-evidence') return 'audit-evidence';
|
|
2441
|
+
return 'audit-advisory';
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function regressionExpectation(gate) {
|
|
2445
|
+
if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
|
|
2446
|
+
return '全项目检查可接受;具体需求交付时必须有任务拆解';
|
|
2447
|
+
}
|
|
2448
|
+
if (gate.required) return '本期必须通过';
|
|
2449
|
+
return '本期默认不阻断;若需求涉及此风险,应升级为本期测试';
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function regressionTreatment(gate) {
|
|
2453
|
+
if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
|
|
2454
|
+
return '如果这是具体需求交付,应先补 active change/tasks,否则无法证明新增/修改/删除等需求项逐项回归。';
|
|
2455
|
+
}
|
|
2456
|
+
if (gate.required && gate.status === 'pass') return '不需要人工评审;保留证据即可继续。';
|
|
2457
|
+
if (gate.required) return '应当现在修复或补证据,修完后重新生成报告。';
|
|
2458
|
+
if (gate.status === 'pass') return '已覆盖,可作为辅助证据保留。';
|
|
2459
|
+
return '需要判断是否属于本期需求;属于就现在补测,不属于才记录为后续风险。';
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function regressionBlockDescription(gate) {
|
|
2463
|
+
const descriptions = {
|
|
2464
|
+
smoke: '核心路径能否跑通,至少覆盖主流程和最关键的失败路径。',
|
|
2465
|
+
'feature-coverage': '需求拆解项是否全部完成,验收点是否有对应回归。',
|
|
2466
|
+
'business-guardrails': '成本、额度、滥用、报警、止损是否有明确保护。',
|
|
2467
|
+
traceability: '出问题时是否能追到用户动作、请求、任务和错误。',
|
|
2468
|
+
redaction: '报告、日志和错误信息是否会暴露隐私、密钥或敏感数据。',
|
|
2469
|
+
'normal-performance': '普通规模下是否可用、不卡顿、不超时。',
|
|
2470
|
+
'extreme-performance': '大数据、并发、异常输入、边界规模是否有兜底。',
|
|
2471
|
+
knowledge: '本次问题是否需要沉淀成经验,避免下次重复漏测。',
|
|
2472
|
+
};
|
|
2473
|
+
return descriptions[gate.id] ?? reviewerGateFocus(gate);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function regressionHumanText(value) {
|
|
2477
|
+
return String(value ?? '')
|
|
2478
|
+
.replace(/阻断此门禁/g, '作为本期必测阻断')
|
|
2479
|
+
.replace(/必需门禁/g, '本期必测块')
|
|
2480
|
+
.replace(/可选门禁/g, '按风险确认项')
|
|
2481
|
+
.replace(/门禁/g, '测试块');
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function regressionTaskStatus(task) {
|
|
2485
|
+
if (task.done) return '已完成';
|
|
2486
|
+
if (task.blocked) return '阻塞';
|
|
2487
|
+
return '未完成';
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function regressionRequirementRows(activeTasks, report) {
|
|
2491
|
+
const tasks = activeTasks.tasks ?? [];
|
|
2492
|
+
const requiredGates = report.gates.filter((gate) => gate.required);
|
|
2493
|
+
const requiredPassed = requiredGates.filter((gate) => ['pass', 'waived'].includes(gate.status)).length;
|
|
2494
|
+
const failingRequired = requiredGates.filter((gate) => !['pass', 'waived'].includes(gate.status));
|
|
2495
|
+
const advisoryGates = report.gates.filter((gate) => !gate.required && gate.status !== 'pass');
|
|
2496
|
+
if (tasks.length === 0) {
|
|
2497
|
+
return `
|
|
2498
|
+
<tr class="audit-evidence">
|
|
2499
|
+
<td>当前没有激活需求任务</td>
|
|
2500
|
+
<td>项目级必测 ${escapeHtml(`${requiredPassed}/${requiredGates.length}`)} 通过</td>
|
|
2501
|
+
<td>如果这是具体需求交付,应先生成或保留 tasks.md,再逐项回归。</td>
|
|
2502
|
+
</tr>
|
|
2503
|
+
`;
|
|
2504
|
+
}
|
|
2505
|
+
return tasks.map((task) => {
|
|
2506
|
+
const statusClass = !task.done || failingRequired.length > 0
|
|
2507
|
+
? 'audit-fail'
|
|
2508
|
+
: advisoryGates.length > 0
|
|
2509
|
+
? 'audit-advisory'
|
|
2510
|
+
: 'audit-pass';
|
|
2511
|
+
const conclusion = !task.done
|
|
2512
|
+
? '不能放行,应完成或明确延期原因。'
|
|
2513
|
+
: failingRequired.length > 0
|
|
2514
|
+
? '不能放行,仍有本期必测块未通过。'
|
|
2515
|
+
: advisoryGates.length > 0
|
|
2516
|
+
? '功能已完成;需确认风险项是否属于本期。'
|
|
2517
|
+
: '通过,无需人工评审。';
|
|
2518
|
+
return `
|
|
2519
|
+
<tr class="${statusClass}">
|
|
2520
|
+
<td>
|
|
2521
|
+
<strong>${escapeHtml(task.title)}</strong>
|
|
2522
|
+
<span><code>${escapeHtml(`${task.source}:${task.line}`)}</code></span>
|
|
2523
|
+
</td>
|
|
2524
|
+
<td>${escapeHtml(regressionTaskStatus(task))} · 必测 ${escapeHtml(`${requiredPassed}/${requiredGates.length}`)}</td>
|
|
2525
|
+
<td>${escapeHtml(conclusion)}</td>
|
|
2526
|
+
</tr>
|
|
2527
|
+
`;
|
|
2528
|
+
}).join('\n');
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function regressionGateSummaryCards(report) {
|
|
2532
|
+
return report.gates.map((gate) => `
|
|
2533
|
+
<div class="audit-block-card ${regressionResultClass(gate)}">
|
|
2534
|
+
<div class="audit-block-card-head">
|
|
2535
|
+
<strong>${escapeHtml(reviewerGateDisplay(gate))}</strong>
|
|
2536
|
+
<span class="audit-status ${regressionResultClass(gate)}">${escapeHtml(regressionResultLabel(gate))}</span>
|
|
2537
|
+
</div>
|
|
2538
|
+
<div class="audit-block-meta">
|
|
2539
|
+
<span>${gate.required ? '本期必测' : '按风险确认'}</span>
|
|
2540
|
+
<span>${gate.evidence?.present ? `${gate.evidence.sources.length} 条证据` : '缺少本次证据'}</span>
|
|
2541
|
+
</div>
|
|
2542
|
+
</div>
|
|
2543
|
+
`).join('\n');
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function regressionExceptionItems(report) {
|
|
2547
|
+
const items = report.gates.filter((gate) => gate.required ? gate.status !== 'pass' : gate.status !== 'pass');
|
|
2548
|
+
if (items.length === 0) {
|
|
2549
|
+
return '<div class="audit-empty">没有未通过或需确认的回归块。查看者只需要确认报告对应的是本次需求即可。</div>';
|
|
2550
|
+
}
|
|
2551
|
+
return items.map((gate) => `
|
|
2552
|
+
<div class="audit-risk-card ${regressionResultClass(gate)}">
|
|
2553
|
+
<div class="audit-risk-card-head">
|
|
2554
|
+
<div>
|
|
2555
|
+
<strong>${escapeHtml(reviewerGateDisplay(gate))}</strong>
|
|
2556
|
+
<span>${escapeHtml(regressionResultLabel(gate))}</span>
|
|
2557
|
+
</div>
|
|
2558
|
+
<button type="button" data-copy-nearest>复制给 Agent</button>
|
|
2559
|
+
</div>
|
|
2560
|
+
<p>${escapeHtml(regressionBlockDescription(gate))}</p>
|
|
2561
|
+
<div class="qa-meta">${escapeHtml(regressionGateSimpleSuggestion(gate))}</div>
|
|
2562
|
+
<textarea readonly class="copy-source" data-review-copy>${escapeHtml(regressionGateCopyPayload(gate, report))}</textarea>
|
|
2563
|
+
</div>
|
|
2564
|
+
`).join('\n');
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
export function renderQualityEvalArtifact({ report }) {
|
|
2568
|
+
return renderQualityEvalArtifactV2({ report });
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function learningSourceAnchor(sourceId) {
|
|
2572
|
+
return `source-${slugify(sourceId, 'source')}`;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function learningAssetUrl(rawPath) {
|
|
2576
|
+
const value = String(rawPath ?? '').trim();
|
|
2577
|
+
if (!value) return null;
|
|
2578
|
+
if (/^(?:https?:|data:|file:)/i.test(value)) return value;
|
|
2579
|
+
if (path.isAbsolute(value)) return pathToFileURL(value).href;
|
|
2580
|
+
return encodeURI(value.split(path.sep).join('/'));
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function formatLearningParagraphs(paragraphs) {
|
|
2584
|
+
const list = Array.isArray(paragraphs) ? paragraphs.filter(Boolean) : [];
|
|
2585
|
+
return list.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join('\n');
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function formatLearningEvidenceChips(sourceIds) {
|
|
2589
|
+
const list = Array.isArray(sourceIds) ? sourceIds.filter(Boolean) : [];
|
|
2590
|
+
if (list.length === 0) {
|
|
2591
|
+
return '<span class="evidence-chip muted">暂无证据引用</span>';
|
|
2592
|
+
}
|
|
2593
|
+
return list.map((id) => `
|
|
2594
|
+
<span class="evidence-chip">${escapeHtml(id)}</span>
|
|
2595
|
+
`).join('\n');
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function formatLearningRetrievalBlocks(blocks, chapterId) {
|
|
2599
|
+
const list = Array.isArray(blocks) ? blocks.filter(Boolean) : [];
|
|
2600
|
+
if (list.length === 0) return '';
|
|
2601
|
+
return `
|
|
2602
|
+
<section class="learning-block retrieval" id="${escapeHtml(chapterId)}-retrieval">
|
|
2603
|
+
<h4>检索练习</h4>
|
|
2604
|
+
${list.map((block, index) => `
|
|
2605
|
+
<details class="retrieval-item" id="${escapeHtml(chapterId)}-retrieval-${index + 1}">
|
|
2606
|
+
<summary><span>R${index + 1}</span>${escapeHtml(block.prompt)}</summary>
|
|
2607
|
+
${block.hint ? `<div class="retrieval-hint">提示: ${escapeHtml(block.hint)}</div>` : ''}
|
|
2608
|
+
<div class="retrieval-answer">参考答案: ${escapeHtml(block.answer)}</div>
|
|
2609
|
+
</details>
|
|
2610
|
+
`).join('\n')}
|
|
2611
|
+
</section>
|
|
2612
|
+
`;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
function formatLearningWorkedExamples(examples, chapterId) {
|
|
2616
|
+
const list = Array.isArray(examples) ? examples.filter(Boolean) : [];
|
|
2617
|
+
if (list.length === 0) return '';
|
|
2618
|
+
return `
|
|
2619
|
+
<section class="learning-block worked" id="${escapeHtml(chapterId)}-worked">
|
|
2620
|
+
<h4>工作示例</h4>
|
|
2621
|
+
${list.map((example, index) => `
|
|
2622
|
+
<div class="worked-item" id="${escapeHtml(chapterId)}-worked-${index + 1}">
|
|
2623
|
+
<div class="worked-title">${escapeHtml(example.title)}</div>
|
|
2624
|
+
<p>${escapeHtml(example.scenario)}</p>
|
|
2625
|
+
<ol>${listMarkup(example.steps, '暂无步骤')}</ol>
|
|
2626
|
+
${example.principle ? `<div class="worked-principle">原则: ${escapeHtml(example.principle)}</div>` : ''}
|
|
2627
|
+
</div>
|
|
2628
|
+
`).join('\n')}
|
|
2629
|
+
</section>
|
|
2630
|
+
`;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function formatLearningVisualExplainer(explainer, chapterId) {
|
|
2634
|
+
if (!explainer || typeof explainer !== 'object') return '';
|
|
2635
|
+
const takeaways = Array.isArray(explainer.takeaways) ? explainer.takeaways.filter(Boolean) : [];
|
|
2636
|
+
const imageUrl = learningAssetUrl(explainer.image?.path);
|
|
2637
|
+
const hasImage = Boolean(imageUrl);
|
|
2638
|
+
return `
|
|
2639
|
+
<section class="learning-block visual" id="${escapeHtml(chapterId)}-visual">
|
|
2640
|
+
<div class="visual-header">
|
|
2641
|
+
<div class="visual-kicker">一眼看懂</div>
|
|
2642
|
+
<h4>${escapeHtml(explainer.title ?? '图文解释')}</h4>
|
|
2643
|
+
</div>
|
|
2644
|
+
<div class="visual-grid${hasImage ? ' has-image' : ''}">
|
|
2645
|
+
${hasImage ? `
|
|
2646
|
+
<figure class="visual-figure">
|
|
2647
|
+
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(explainer.image?.alt ?? explainer.title ?? 'visual explainer')}" loading="lazy" />
|
|
2648
|
+
${explainer.image?.caption ? `<figcaption>${escapeHtml(explainer.image.caption)}</figcaption>` : ''}
|
|
2649
|
+
</figure>
|
|
2650
|
+
` : ''}
|
|
2651
|
+
<div class="visual-copy">
|
|
2652
|
+
<div class="visual-note">
|
|
2653
|
+
<div class="visual-label">比喻</div>
|
|
2654
|
+
<p>${escapeHtml(explainer.analogy ?? '')}</p>
|
|
2655
|
+
</div>
|
|
2656
|
+
<div class="visual-note">
|
|
2657
|
+
<div class="visual-label">场景</div>
|
|
2658
|
+
<p>${escapeHtml(explainer.scene ?? '')}</p>
|
|
2659
|
+
</div>
|
|
2660
|
+
<div class="visual-note">
|
|
2661
|
+
<div class="visual-label">为什么这张图有用</div>
|
|
2662
|
+
<p>${escapeHtml(explainer.whyItMatters ?? '')}</p>
|
|
2663
|
+
</div>
|
|
2664
|
+
${takeaways.length > 0 ? `
|
|
2665
|
+
<div class="visual-note">
|
|
2666
|
+
<div class="visual-label">看图重点</div>
|
|
2667
|
+
<ul class="visual-takeaways">${listMarkup(takeaways, '暂无重点')}</ul>
|
|
2668
|
+
</div>
|
|
2669
|
+
` : ''}
|
|
2670
|
+
</div>
|
|
2671
|
+
</div>
|
|
2672
|
+
</section>
|
|
2673
|
+
`;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function formatLearningEvidenceDetails(chapter, sourcesById) {
|
|
2677
|
+
const ids = Array.isArray(chapter.evidenceIds) ? chapter.evidenceIds.filter(Boolean) : [];
|
|
2678
|
+
if (ids.length === 0) return '';
|
|
2679
|
+
return `
|
|
2680
|
+
<details class="chapter-evidence" id="${escapeHtml(chapter.id)}-evidence">
|
|
2681
|
+
<summary>
|
|
2682
|
+
<span class="evidence-summary-title">本章出处</span>
|
|
2683
|
+
<span class="evidence-summary-count">${ids.length} 个来源</span>
|
|
2684
|
+
</summary>
|
|
2685
|
+
<div class="evidence-mini-list">
|
|
2686
|
+
${ids.map((id) => {
|
|
2687
|
+
const source = sourcesById.get(id);
|
|
2688
|
+
return `
|
|
2689
|
+
<div class="evidence-mini-card">
|
|
2690
|
+
<strong>${escapeHtml(source?.title ?? id)}</strong>
|
|
2691
|
+
<span>${escapeHtml(source?.relativePath ?? source?.path ?? id)}</span>
|
|
2692
|
+
${source?.summary ? `<p>${escapeHtml(source.summary)}</p>` : ''}
|
|
2693
|
+
</div>
|
|
2694
|
+
`;
|
|
2695
|
+
}).join('\n')}
|
|
2696
|
+
</div>
|
|
2697
|
+
</details>
|
|
2698
|
+
`;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function formatLearningChapter(chapter, index, sourcesById) {
|
|
2702
|
+
return `
|
|
2703
|
+
<section class="chapter${index === 0 ? ' active' : ''}" id="${escapeHtml(chapter.id)}" data-chapter-index="${index}"${index === 0 ? '' : ' hidden'}>
|
|
2704
|
+
<div class="chapter-kicker" id="${escapeHtml(chapter.id)}-reading">第 ${index + 1} 章 · ${escapeHtml(chapter.label)}</div>
|
|
2705
|
+
<h2>${escapeHtml(chapter.semanticTitle)}</h2>
|
|
2706
|
+
<p class="chapter-summary">${escapeHtml(chapter.summary)}</p>
|
|
2707
|
+
${formatLearningVisualExplainer(chapter.visualExplainer, chapter.id)}
|
|
2708
|
+
${formatLearningParagraphs(chapter.paragraphs)}
|
|
2709
|
+
${formatLearningRetrievalBlocks(chapter.retrievalBlocks, chapter.id)}
|
|
2710
|
+
${formatLearningWorkedExamples(chapter.workedExamples, chapter.id)}
|
|
2711
|
+
${formatLearningEvidenceDetails(chapter, sourcesById)}
|
|
2712
|
+
</section>
|
|
2713
|
+
`;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function formatLearningOutlineNode(node, indexPath = '1', activeChapterId = null) {
|
|
2717
|
+
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
|
2718
|
+
const label = `
|
|
2719
|
+
<span class="outline-jump depth-${escapeHtml(node.depth ?? 1)}${node.id === activeChapterId ? ' active' : ''}" data-target-id="${escapeHtml(node.id)}">
|
|
2720
|
+
<span class="outline-number">${escapeHtml(indexPath)}</span>
|
|
2721
|
+
<span class="outline-copy">
|
|
2722
|
+
<strong>${escapeHtml(node.title)}</strong>
|
|
2723
|
+
${node.subtitle ? `<small>${escapeHtml(node.subtitle)}</small>` : ''}
|
|
2724
|
+
</span>
|
|
2725
|
+
</span>
|
|
2726
|
+
`;
|
|
2727
|
+
if (!hasChildren) return `<li>${label}</li>`;
|
|
2728
|
+
return `
|
|
2729
|
+
<li>
|
|
2730
|
+
<details class="outline-branch" open>
|
|
2731
|
+
<summary>${label}</summary>
|
|
2732
|
+
<ol>
|
|
2733
|
+
${node.children.map((child, childIndex) => formatLearningOutlineNode(child, `${indexPath}.${childIndex + 1}`, activeChapterId)).join('\n')}
|
|
2734
|
+
</ol>
|
|
2735
|
+
</details>
|
|
2736
|
+
</li>
|
|
2737
|
+
`;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function formatLearningSource(source) {
|
|
2741
|
+
return `
|
|
2742
|
+
<section class="source-card" id="${escapeHtml(learningSourceAnchor(source.id))}">
|
|
2743
|
+
<div class="source-title">${escapeHtml(source.title)}</div>
|
|
2744
|
+
<div class="source-meta">${escapeHtml(source.type)} · ${escapeHtml(source.relativePath ?? source.path ?? '')}</div>
|
|
2745
|
+
${source.summary ? `<p>${escapeHtml(source.summary)}</p>` : ''}
|
|
2746
|
+
${source.excerpt ? `<pre>${escapeHtml(source.excerpt)}</pre>` : ''}
|
|
2747
|
+
<div class="source-digest">digest: ${escapeHtml(source.digest ?? 'none')}</div>
|
|
2748
|
+
</section>
|
|
2749
|
+
`;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function formatLearningClaim(claim) {
|
|
2753
|
+
return `
|
|
2754
|
+
<div class="claim-item">
|
|
2755
|
+
<div class="claim-statement">${escapeHtml(claim.statement)}</div>
|
|
2756
|
+
<div class="claim-meta">confidence: ${escapeHtml(claim.confidence ?? 'unknown')} · sources: ${(claim.sourceIds ?? []).map((id) => escapeHtml(id)).join(', ') || 'none'}</div>
|
|
2757
|
+
</div>
|
|
2758
|
+
`;
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
function formatLearningEmptyState(content, packageMeta, evidenceManifest) {
|
|
2762
|
+
const promptPath = content.agentPromptPath ?? packageMeta?.paths?.agentPrompt ?? null;
|
|
2763
|
+
const contextPath = content.agentContextPath ?? packageMeta?.paths?.agentContext ?? null;
|
|
2764
|
+
const contentPath = content.packagePaths?.contentJson ?? packageMeta?.paths?.contentJson ?? null;
|
|
2765
|
+
const assetsDir = content.packagePaths?.assetsDir ?? packageMeta?.paths?.assetsDir ?? null;
|
|
2766
|
+
const renderCommand = contentPath ? `openprd learn . --content-json ${contentPath} --open` : null;
|
|
2767
|
+
const sourceCount = evidenceManifest?.sourceCount ?? (evidenceManifest?.sources?.length ?? 0);
|
|
2768
|
+
const claimCount = evidenceManifest?.claimCount ?? (evidenceManifest?.claims?.length ?? 0);
|
|
2769
|
+
const gapCount = Array.isArray(evidenceManifest?.gaps) ? evidenceManifest.gaps.length : 0;
|
|
2770
|
+
return `
|
|
2771
|
+
<section class="empty-reader" id="agent-authoring">
|
|
2772
|
+
<p class="chapter-kicker">证据包待写作</p>
|
|
2773
|
+
<h2>还没有生成可阅读正文</h2>
|
|
2774
|
+
<p>这一步只完成了学习包归档和证据收集。真正给人阅读的标题、大纲、章节、检索练习和工作示例,还需要由 Agent 根据证据写入内容 JSON 后再渲染。</p>
|
|
2775
|
+
<div class="stat-grid">
|
|
2776
|
+
<div class="stat"><div class="stat-value">${sourceCount}</div><div class="stat-label">份证据来源</div></div>
|
|
2777
|
+
<div class="stat"><div class="stat-value">${claimCount}</div><div class="stat-label">条结构化判断</div></div>
|
|
2778
|
+
<div class="stat"><div class="stat-value">${gapCount}</div><div class="stat-label">个待补缺口</div></div>
|
|
2779
|
+
</div>
|
|
2780
|
+
<ol class="empty-steps">
|
|
2781
|
+
<li>让 Agent 读取写作提示、上下文和证据清单。</li>
|
|
2782
|
+
<li>由 Agent 把标题、目录、章节正文、检索练习、工作示例和需要的 visualExplainer 写进 <code>learning-content.json</code>。</li>
|
|
2783
|
+
<li>写完后重新执行渲染命令,再打开阅读器查看成品。</li>
|
|
2784
|
+
</ol>
|
|
2785
|
+
<div class="empty-paths">
|
|
2786
|
+
${promptPath ? `<div><strong>写作提示</strong><span>${escapeHtml(promptPath)}</span></div>` : ''}
|
|
2787
|
+
${contextPath ? `<div><strong>上下文</strong><span>${escapeHtml(contextPath)}</span></div>` : ''}
|
|
2788
|
+
${contentPath ? `<div><strong>内容 JSON</strong><span>${escapeHtml(contentPath)}</span></div>` : ''}
|
|
2789
|
+
${assetsDir ? `<div><strong>图片素材目录</strong><span>${escapeHtml(assetsDir)}</span></div>` : ''}
|
|
2790
|
+
${renderCommand ? `<div><strong>重渲染命令</strong><span>${escapeHtml(renderCommand)}</span></div>` : ''}
|
|
2791
|
+
</div>
|
|
2792
|
+
</section>
|
|
2793
|
+
`;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
export function renderLearningArtifact({ packageMeta, content, evidenceManifest }) {
|
|
2797
|
+
const chapters = Array.isArray(content.chapters) ? content.chapters : [];
|
|
2798
|
+
const sources = Array.isArray(evidenceManifest.sources) ? evidenceManifest.sources : [];
|
|
2799
|
+
const claims = Array.isArray(evidenceManifest.claims) ? evidenceManifest.claims : [];
|
|
2800
|
+
const gaps = Array.isArray(evidenceManifest.gaps) ? evidenceManifest.gaps : [];
|
|
2801
|
+
const isAwaitingAgent = content.authoringStatus === 'awaiting-agent-content' || chapters.length === 0;
|
|
2802
|
+
const title = content.title || packageMeta?.title || 'OpenPrd 复盘学习包';
|
|
2803
|
+
const outline = Array.isArray(content.outline) && content.outline.length > 0
|
|
2804
|
+
? content.outline
|
|
2805
|
+
: chapters.map((chapter, index) => ({
|
|
2806
|
+
id: chapter.id,
|
|
2807
|
+
depth: 1,
|
|
2808
|
+
title: `第 ${index + 1} 章 · ${chapter.label}`,
|
|
2809
|
+
subtitle: chapter.semanticTitle,
|
|
2810
|
+
children: [],
|
|
2811
|
+
}));
|
|
2812
|
+
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
|
2813
|
+
const initialChapterId = chapters[0]?.id ?? outline[0]?.id ?? null;
|
|
2814
|
+
const initialProgressPercent = chapters.length > 0 ? String((1 / chapters.length) * 100) : '0';
|
|
2815
|
+
|
|
2816
|
+
return `<!DOCTYPE html>
|
|
2817
|
+
<html lang="zh-CN">
|
|
2818
|
+
<head>
|
|
2819
|
+
<meta charset="UTF-8" />
|
|
2820
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2821
|
+
<title>${escapeHtml(title)}</title>
|
|
2822
|
+
<style>
|
|
2823
|
+
:root {
|
|
2824
|
+
color-scheme: light;
|
|
2825
|
+
--bg: #f6fbff;
|
|
2826
|
+
--bg-deep: #eef6ff;
|
|
2827
|
+
--paper: #ffffff;
|
|
2828
|
+
--panel: rgba(255, 255, 255, 0.96);
|
|
2829
|
+
--ink: #171411;
|
|
2830
|
+
--text: #1f2b3d;
|
|
2831
|
+
--muted: #66758b;
|
|
2832
|
+
--line: rgba(121, 151, 194, 0.28);
|
|
2833
|
+
--line-strong: rgba(91, 126, 177, 0.32);
|
|
2834
|
+
--accent: #ef7b43;
|
|
2835
|
+
--accent-deep: #d95f26;
|
|
2836
|
+
--accent-soft: #fff2e8;
|
|
2837
|
+
--amber: #8a5a2b;
|
|
2838
|
+
--amber-soft: #f6e7d4;
|
|
2839
|
+
--jade: #ef7b43;
|
|
2840
|
+
--wash: #f5f9ff;
|
|
2841
|
+
--danger-soft: rgba(220,38,38,0.08);
|
|
2842
|
+
--reader-scale: 1;
|
|
2843
|
+
--mono: "JetBrains Mono","SFMono-Regular",Menlo,monospace;
|
|
2844
|
+
--serif: "Songti SC","Noto Serif CJK SC","Iowan Old Style","Palatino Linotype",serif;
|
|
2845
|
+
--ui: "Avenir Next","Gill Sans","Trebuchet MS",sans-serif;
|
|
2846
|
+
}
|
|
2847
|
+
* { box-sizing: border-box; }
|
|
2848
|
+
html { scroll-behavior: smooth; }
|
|
2849
|
+
body {
|
|
2850
|
+
margin: 0;
|
|
2851
|
+
background:
|
|
2852
|
+
linear-gradient(90deg, rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
|
|
2853
|
+
linear-gradient(rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
|
|
2854
|
+
radial-gradient(circle at top, rgba(255,255,255,0.82), transparent 30%),
|
|
2855
|
+
linear-gradient(180deg, #fbfdff 0%, var(--bg) 50%, var(--bg-deep) 100%);
|
|
2856
|
+
background-size: 56px 56px, 56px 56px, auto, auto;
|
|
2857
|
+
color: var(--text);
|
|
2858
|
+
font-family: var(--ui);
|
|
2859
|
+
overflow: hidden;
|
|
2860
|
+
}
|
|
2861
|
+
.shell {
|
|
2862
|
+
display: grid;
|
|
2863
|
+
grid-template-columns: minmax(280px, 330px) minmax(0, 980px);
|
|
2864
|
+
gap: 18px;
|
|
2865
|
+
max-width: 1340px;
|
|
2866
|
+
height: 100vh;
|
|
2867
|
+
margin: 0 auto;
|
|
2868
|
+
padding: 18px;
|
|
2869
|
+
}
|
|
2870
|
+
.side-panel,
|
|
2871
|
+
.reader {
|
|
2872
|
+
border: 1px solid var(--line);
|
|
2873
|
+
border-radius: 18px;
|
|
2874
|
+
background: var(--panel);
|
|
2875
|
+
box-shadow: 0 20px 50px rgba(92, 122, 168, 0.14);
|
|
2876
|
+
}
|
|
2877
|
+
.side-panel {
|
|
2878
|
+
position: sticky;
|
|
2879
|
+
top: 18px;
|
|
2880
|
+
align-self: start;
|
|
2881
|
+
max-height: calc(100vh - 36px);
|
|
2882
|
+
overflow: auto;
|
|
2883
|
+
padding: 18px;
|
|
2884
|
+
background:
|
|
2885
|
+
linear-gradient(180deg, rgba(255,255,255,0.985), rgba(252,254,255,0.985)),
|
|
2886
|
+
var(--panel);
|
|
2887
|
+
}
|
|
2888
|
+
.reader {
|
|
2889
|
+
min-width: 0;
|
|
2890
|
+
background: var(--paper);
|
|
2891
|
+
overflow: hidden;
|
|
2892
|
+
position: relative;
|
|
2893
|
+
display: grid;
|
|
2894
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
2895
|
+
height: calc(100vh - 36px);
|
|
2896
|
+
}
|
|
2897
|
+
.reader-header {
|
|
2898
|
+
border-bottom: 1px solid var(--line);
|
|
2899
|
+
background:
|
|
2900
|
+
linear-gradient(135deg, rgba(255,255,255,0.995), rgba(249,252,255,0.98)),
|
|
2901
|
+
var(--paper);
|
|
2902
|
+
padding: 16px 30px 10px;
|
|
2903
|
+
}
|
|
2904
|
+
.reader-scroll {
|
|
2905
|
+
min-height: 0;
|
|
2906
|
+
overflow-y: auto;
|
|
2907
|
+
overscroll-behavior: contain;
|
|
2908
|
+
scrollbar-gutter: stable;
|
|
2909
|
+
scroll-padding-top: 24px;
|
|
2910
|
+
}
|
|
2911
|
+
.eyebrow {
|
|
2912
|
+
margin: 0 0 8px;
|
|
2913
|
+
color: var(--accent);
|
|
2914
|
+
font-size: 13px;
|
|
2915
|
+
font-weight: 800;
|
|
2916
|
+
text-transform: uppercase;
|
|
2917
|
+
letter-spacing: 0;
|
|
2918
|
+
}
|
|
2919
|
+
h1 {
|
|
2920
|
+
margin: 0;
|
|
2921
|
+
font-family: var(--serif);
|
|
2922
|
+
font-size: clamp(27px, 3.2vw, 36px);
|
|
2923
|
+
line-height: 1.14;
|
|
2924
|
+
font-weight: 700;
|
|
2925
|
+
letter-spacing: 0.01em;
|
|
2926
|
+
color: var(--ink);
|
|
2927
|
+
}
|
|
2928
|
+
.subtitle {
|
|
2929
|
+
margin: 10px 0 0;
|
|
2930
|
+
color: var(--muted);
|
|
2931
|
+
line-height: 1.55;
|
|
2932
|
+
font-size: 15px;
|
|
2933
|
+
}
|
|
2934
|
+
.meta-row,
|
|
2935
|
+
.controls,
|
|
2936
|
+
.chapter-evidence {
|
|
2937
|
+
display: flex;
|
|
2938
|
+
flex-wrap: wrap;
|
|
2939
|
+
gap: 8px;
|
|
2940
|
+
align-items: center;
|
|
2941
|
+
}
|
|
2942
|
+
.meta-row { margin-top: 8px; }
|
|
2943
|
+
.meta-details {
|
|
2944
|
+
margin-top: 10px;
|
|
2945
|
+
color: var(--muted);
|
|
2946
|
+
font-size: 12px;
|
|
2947
|
+
}
|
|
2948
|
+
.meta-details summary,
|
|
2949
|
+
.retrieval-item summary,
|
|
2950
|
+
.chapter-evidence summary {
|
|
2951
|
+
list-style: none;
|
|
2952
|
+
}
|
|
2953
|
+
.meta-details summary::-webkit-details-marker,
|
|
2954
|
+
.retrieval-item summary::-webkit-details-marker,
|
|
2955
|
+
.chapter-evidence summary::-webkit-details-marker {
|
|
2956
|
+
display: none;
|
|
2957
|
+
}
|
|
2958
|
+
.meta-details summary {
|
|
2959
|
+
width: fit-content;
|
|
2960
|
+
cursor: pointer;
|
|
2961
|
+
color: var(--accent-deep);
|
|
2962
|
+
font-weight: 650;
|
|
2963
|
+
line-height: 1.4;
|
|
2964
|
+
display: inline-flex;
|
|
2965
|
+
align-items: center;
|
|
2966
|
+
gap: 8px;
|
|
2967
|
+
}
|
|
2968
|
+
.meta-details summary::before,
|
|
2969
|
+
.retrieval-item summary::before,
|
|
2970
|
+
.chapter-evidence summary::before {
|
|
2971
|
+
content: "▸";
|
|
2972
|
+
display: inline-flex;
|
|
2973
|
+
align-items: center;
|
|
2974
|
+
justify-content: center;
|
|
2975
|
+
width: 12px;
|
|
2976
|
+
color: var(--accent-deep);
|
|
2977
|
+
font-size: 11px;
|
|
2978
|
+
transform-origin: 50% 50%;
|
|
2979
|
+
transition: transform 120ms ease;
|
|
2980
|
+
}
|
|
2981
|
+
.meta-details[open] summary::before,
|
|
2982
|
+
.retrieval-item[open] summary::before,
|
|
2983
|
+
.chapter-evidence[open] summary::before {
|
|
2984
|
+
transform: rotate(90deg);
|
|
2985
|
+
}
|
|
2986
|
+
.meta-pill,
|
|
2987
|
+
.evidence-chip {
|
|
2988
|
+
display: inline-flex;
|
|
2989
|
+
width: fit-content;
|
|
2990
|
+
border: 1px solid var(--line);
|
|
2991
|
+
border-radius: 999px;
|
|
2992
|
+
padding: 4px 8px;
|
|
2993
|
+
background: rgba(255,255,255,0.86);
|
|
2994
|
+
color: var(--muted);
|
|
2995
|
+
font-size: 10.5px;
|
|
2996
|
+
text-decoration: none;
|
|
2997
|
+
}
|
|
2998
|
+
.evidence-chip {
|
|
2999
|
+
color: var(--accent);
|
|
3000
|
+
background: var(--accent-soft);
|
|
3001
|
+
border-color: rgba(239,123,67,0.22);
|
|
3002
|
+
}
|
|
3003
|
+
.evidence-chip.muted {
|
|
3004
|
+
color: var(--muted);
|
|
3005
|
+
background: #f8fafc;
|
|
3006
|
+
}
|
|
3007
|
+
.controls {
|
|
3008
|
+
justify-content: space-between;
|
|
3009
|
+
margin-top: 9px;
|
|
3010
|
+
border-top: 1px solid var(--line);
|
|
3011
|
+
padding-top: 9px;
|
|
3012
|
+
background: transparent;
|
|
3013
|
+
gap: 14px;
|
|
3014
|
+
}
|
|
3015
|
+
.button-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
3016
|
+
button {
|
|
3017
|
+
border: 1px solid var(--line);
|
|
3018
|
+
border-radius: 999px;
|
|
3019
|
+
padding: 7px 10px;
|
|
3020
|
+
background: rgba(255, 255, 255, 0.96);
|
|
3021
|
+
color: var(--text);
|
|
3022
|
+
font: inherit;
|
|
3023
|
+
font-size: 14px;
|
|
3024
|
+
cursor: pointer;
|
|
3025
|
+
}
|
|
3026
|
+
button:hover { border-color: var(--accent); }
|
|
3027
|
+
button:disabled { color: var(--muted); cursor: not-allowed; opacity: 0.58; }
|
|
3028
|
+
.progress-wrap {
|
|
3029
|
+
min-width: 180px;
|
|
3030
|
+
flex: 1;
|
|
3031
|
+
}
|
|
3032
|
+
.progress-meta {
|
|
3033
|
+
display: flex;
|
|
3034
|
+
justify-content: space-between;
|
|
3035
|
+
gap: 10px;
|
|
3036
|
+
color: var(--muted);
|
|
3037
|
+
font-size: 12px;
|
|
3038
|
+
margin-bottom: 6px;
|
|
3039
|
+
}
|
|
3040
|
+
.progress-track {
|
|
3041
|
+
height: 7px;
|
|
3042
|
+
border-radius: 999px;
|
|
3043
|
+
background: #e5dfd4;
|
|
3044
|
+
overflow: hidden;
|
|
3045
|
+
}
|
|
3046
|
+
.progress-bar {
|
|
3047
|
+
height: 100%;
|
|
3048
|
+
width: 0%;
|
|
3049
|
+
border-radius: inherit;
|
|
3050
|
+
background: var(--accent);
|
|
3051
|
+
transition: width 180ms ease;
|
|
3052
|
+
}
|
|
3053
|
+
.toc-title,
|
|
3054
|
+
.panel-title {
|
|
3055
|
+
margin: 0 0 12px;
|
|
3056
|
+
font-size: 14px;
|
|
3057
|
+
font-weight: 800;
|
|
3058
|
+
color: var(--accent-deep);
|
|
3059
|
+
}
|
|
3060
|
+
.toc-subtitle {
|
|
3061
|
+
margin: -4px 0 16px;
|
|
3062
|
+
color: var(--muted);
|
|
3063
|
+
line-height: 1.6;
|
|
3064
|
+
font-size: 13px;
|
|
3065
|
+
}
|
|
3066
|
+
.outline-list,
|
|
3067
|
+
.outline-list ol {
|
|
3068
|
+
list-style: none;
|
|
3069
|
+
margin: 0;
|
|
3070
|
+
padding: 0;
|
|
3071
|
+
}
|
|
3072
|
+
.outline-list ol {
|
|
3073
|
+
margin-left: 12px;
|
|
3074
|
+
padding-left: 12px;
|
|
3075
|
+
border-left: 1px solid var(--line);
|
|
3076
|
+
}
|
|
3077
|
+
.outline-branch summary {
|
|
3078
|
+
list-style: none;
|
|
3079
|
+
}
|
|
3080
|
+
.outline-branch summary::-webkit-details-marker { display: none; }
|
|
3081
|
+
.outline-jump {
|
|
3082
|
+
display: grid;
|
|
3083
|
+
grid-template-columns: 42px 1fr;
|
|
3084
|
+
gap: 10px;
|
|
3085
|
+
width: 100%;
|
|
3086
|
+
text-align: left;
|
|
3087
|
+
border-color: transparent;
|
|
3088
|
+
background: transparent;
|
|
3089
|
+
color: var(--text);
|
|
3090
|
+
line-height: 1.45;
|
|
3091
|
+
padding: 9px 8px;
|
|
3092
|
+
border-radius: 12px;
|
|
3093
|
+
border: 1px solid transparent;
|
|
3094
|
+
cursor: pointer;
|
|
3095
|
+
}
|
|
3096
|
+
.outline-jump:hover {
|
|
3097
|
+
border-color: rgba(239,123,67,0.18);
|
|
3098
|
+
background: rgba(255, 246, 239, 0.78);
|
|
3099
|
+
color: var(--accent-deep);
|
|
3100
|
+
}
|
|
3101
|
+
.outline-jump.active {
|
|
3102
|
+
border-color: rgba(239,123,67,0.24);
|
|
3103
|
+
background: linear-gradient(180deg, rgba(255,246,239,0.96), rgba(255,250,245,0.98));
|
|
3104
|
+
color: var(--accent-deep);
|
|
3105
|
+
}
|
|
3106
|
+
.outline-jump.active .outline-number,
|
|
3107
|
+
.outline-jump.active .outline-copy strong {
|
|
3108
|
+
color: var(--accent-deep);
|
|
3109
|
+
}
|
|
3110
|
+
.outline-jump.active .outline-copy small {
|
|
3111
|
+
color: #b27044;
|
|
3112
|
+
}
|
|
3113
|
+
.outline-number {
|
|
3114
|
+
color: var(--amber);
|
|
3115
|
+
font-family: var(--serif);
|
|
3116
|
+
font-weight: 800;
|
|
3117
|
+
}
|
|
3118
|
+
.outline-copy strong,
|
|
3119
|
+
.outline-copy small {
|
|
3120
|
+
display: block;
|
|
3121
|
+
}
|
|
3122
|
+
.outline-copy strong {
|
|
3123
|
+
font-weight: 700;
|
|
3124
|
+
}
|
|
3125
|
+
.outline-copy small {
|
|
3126
|
+
margin-top: 3px;
|
|
3127
|
+
color: var(--muted);
|
|
3128
|
+
font-size: 12px;
|
|
3129
|
+
}
|
|
3130
|
+
.stat-grid {
|
|
3131
|
+
display: grid;
|
|
3132
|
+
grid-template-columns: 1fr;
|
|
3133
|
+
gap: 8px;
|
|
3134
|
+
margin-top: 16px;
|
|
3135
|
+
}
|
|
3136
|
+
.stat {
|
|
3137
|
+
border: 1px solid var(--line);
|
|
3138
|
+
border-radius: 14px;
|
|
3139
|
+
padding: 10px;
|
|
3140
|
+
background: var(--wash);
|
|
3141
|
+
}
|
|
3142
|
+
.stat-value {
|
|
3143
|
+
font-family: var(--serif);
|
|
3144
|
+
font-size: 26px;
|
|
3145
|
+
font-weight: 700;
|
|
3146
|
+
}
|
|
3147
|
+
.stat-label {
|
|
3148
|
+
color: var(--muted);
|
|
3149
|
+
font-size: 12px;
|
|
3150
|
+
margin-top: 2px;
|
|
3151
|
+
}
|
|
3152
|
+
.chapter {
|
|
3153
|
+
padding: 38px 52px 54px;
|
|
3154
|
+
min-height: 100%;
|
|
3155
|
+
}
|
|
3156
|
+
.chapter[hidden] { display: none; }
|
|
3157
|
+
.chapter.active {
|
|
3158
|
+
box-shadow: none;
|
|
3159
|
+
}
|
|
3160
|
+
.chapter-kicker {
|
|
3161
|
+
color: var(--accent-deep);
|
|
3162
|
+
font-size: 13px;
|
|
3163
|
+
font-weight: 700;
|
|
3164
|
+
letter-spacing: 0.04em;
|
|
3165
|
+
margin-bottom: 8px;
|
|
3166
|
+
}
|
|
3167
|
+
.chapter h2 {
|
|
3168
|
+
margin: 0 0 12px;
|
|
3169
|
+
font-family: var(--serif);
|
|
3170
|
+
font-size: 38px;
|
|
3171
|
+
line-height: 1.24;
|
|
3172
|
+
font-weight: 600;
|
|
3173
|
+
letter-spacing: 0.01em;
|
|
3174
|
+
}
|
|
3175
|
+
.chapter-summary {
|
|
3176
|
+
margin: 0 0 20px;
|
|
3177
|
+
color: var(--muted);
|
|
3178
|
+
font-size: 15px;
|
|
3179
|
+
line-height: 1.8;
|
|
3180
|
+
max-width: 36em;
|
|
3181
|
+
}
|
|
3182
|
+
.chapter > p {
|
|
3183
|
+
max-width: 42em;
|
|
3184
|
+
}
|
|
3185
|
+
.chapter p,
|
|
3186
|
+
.learning-block p,
|
|
3187
|
+
.source-card p {
|
|
3188
|
+
font-size: calc(17px * var(--reader-scale));
|
|
3189
|
+
line-height: 1.85;
|
|
3190
|
+
}
|
|
3191
|
+
.learning-block {
|
|
3192
|
+
margin: 34px 0 0;
|
|
3193
|
+
border: 0;
|
|
3194
|
+
border-top: 1px solid var(--line);
|
|
3195
|
+
border-radius: 0;
|
|
3196
|
+
padding: 22px 0 0;
|
|
3197
|
+
background: transparent;
|
|
3198
|
+
}
|
|
3199
|
+
.learning-block h4 {
|
|
3200
|
+
margin: 0 0 14px;
|
|
3201
|
+
font-family: var(--serif);
|
|
3202
|
+
font-size: 24px;
|
|
3203
|
+
line-height: 1.3;
|
|
3204
|
+
font-weight: 600;
|
|
3205
|
+
letter-spacing: 0.01em;
|
|
3206
|
+
}
|
|
3207
|
+
.learning-block.retrieval,
|
|
3208
|
+
.learning-block.worked,
|
|
3209
|
+
.learning-block.visual {
|
|
3210
|
+
border-top-color: rgba(239,123,67,0.2);
|
|
3211
|
+
}
|
|
3212
|
+
.learning-block.visual {
|
|
3213
|
+
padding-top: 26px;
|
|
3214
|
+
}
|
|
3215
|
+
.visual-header {
|
|
3216
|
+
display: grid;
|
|
3217
|
+
gap: 6px;
|
|
3218
|
+
margin-bottom: 18px;
|
|
3219
|
+
}
|
|
3220
|
+
.visual-kicker {
|
|
3221
|
+
color: var(--accent-deep);
|
|
3222
|
+
font-size: 12px;
|
|
3223
|
+
font-weight: 800;
|
|
3224
|
+
letter-spacing: 0.12em;
|
|
3225
|
+
}
|
|
3226
|
+
.visual-header h4 {
|
|
3227
|
+
margin: 0;
|
|
3228
|
+
font-size: 30px;
|
|
3229
|
+
line-height: 1.28;
|
|
3230
|
+
font-weight: 600;
|
|
3231
|
+
}
|
|
3232
|
+
.visual-grid {
|
|
3233
|
+
display: grid;
|
|
3234
|
+
gap: 26px;
|
|
3235
|
+
}
|
|
3236
|
+
.visual-grid.has-image {
|
|
3237
|
+
grid-template-columns: minmax(0, 1.28fr) minmax(240px, 320px);
|
|
3238
|
+
align-items: start;
|
|
3239
|
+
}
|
|
3240
|
+
.visual-copy {
|
|
3241
|
+
display: grid;
|
|
3242
|
+
gap: 0;
|
|
3243
|
+
border-left: 1px solid var(--line);
|
|
3244
|
+
padding-left: 22px;
|
|
3245
|
+
}
|
|
3246
|
+
.visual-note {
|
|
3247
|
+
border: 0;
|
|
3248
|
+
border-radius: 0;
|
|
3249
|
+
background: transparent;
|
|
3250
|
+
padding: 0 0 16px;
|
|
3251
|
+
}
|
|
3252
|
+
.visual-note + .visual-note {
|
|
3253
|
+
border-top: 1px solid rgba(121, 151, 194, 0.22);
|
|
3254
|
+
padding-top: 16px;
|
|
3255
|
+
}
|
|
3256
|
+
.visual-label {
|
|
3257
|
+
color: var(--accent-deep);
|
|
3258
|
+
font-size: 11px;
|
|
3259
|
+
font-weight: 700;
|
|
3260
|
+
letter-spacing: 0.12em;
|
|
3261
|
+
margin-bottom: 8px;
|
|
3262
|
+
}
|
|
3263
|
+
.visual-note p {
|
|
3264
|
+
margin: 0;
|
|
3265
|
+
}
|
|
3266
|
+
.visual-takeaways {
|
|
3267
|
+
margin: 0;
|
|
3268
|
+
padding-left: 20px;
|
|
3269
|
+
}
|
|
3270
|
+
.visual-figure {
|
|
3271
|
+
margin: 0;
|
|
3272
|
+
border: 1px solid rgba(121, 151, 194, 0.2);
|
|
3273
|
+
border-radius: 16px;
|
|
3274
|
+
overflow: hidden;
|
|
3275
|
+
background: rgba(255,255,255,0.98);
|
|
3276
|
+
box-shadow: 0 18px 42px rgba(91, 126, 177, 0.08);
|
|
3277
|
+
}
|
|
3278
|
+
.visual-figure img {
|
|
3279
|
+
display: block;
|
|
3280
|
+
width: 100%;
|
|
3281
|
+
height: auto;
|
|
3282
|
+
background:
|
|
3283
|
+
linear-gradient(90deg, rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
|
|
3284
|
+
linear-gradient(rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
|
|
3285
|
+
#f8fbff;
|
|
3286
|
+
background-size: 24px 24px, 24px 24px, auto;
|
|
3287
|
+
}
|
|
3288
|
+
.visual-figure figcaption {
|
|
3289
|
+
padding: 12px 14px 14px;
|
|
3290
|
+
border-top: 1px solid rgba(121, 151, 194, 0.16);
|
|
3291
|
+
color: var(--muted);
|
|
3292
|
+
font-size: 12px;
|
|
3293
|
+
line-height: 1.6;
|
|
3294
|
+
}
|
|
3295
|
+
.retrieval-item {
|
|
3296
|
+
border-top: 1px solid var(--line);
|
|
3297
|
+
padding: 16px 0;
|
|
3298
|
+
}
|
|
3299
|
+
.retrieval-item:first-of-type { border-top: 0; }
|
|
3300
|
+
.retrieval-item summary {
|
|
3301
|
+
cursor: pointer;
|
|
3302
|
+
font-weight: 650;
|
|
3303
|
+
line-height: 1.6;
|
|
3304
|
+
display: flex;
|
|
3305
|
+
gap: 10px;
|
|
3306
|
+
align-items: flex-start;
|
|
3307
|
+
}
|
|
3308
|
+
.retrieval-item summary span {
|
|
3309
|
+
display: inline-flex;
|
|
3310
|
+
color: var(--accent);
|
|
3311
|
+
font-family: var(--mono);
|
|
3312
|
+
font-size: 11px;
|
|
3313
|
+
min-width: 24px;
|
|
3314
|
+
padding-top: 2px;
|
|
3315
|
+
}
|
|
3316
|
+
.retrieval-hint,
|
|
3317
|
+
.retrieval-answer {
|
|
3318
|
+
color: var(--muted);
|
|
3319
|
+
line-height: 1.7;
|
|
3320
|
+
margin-top: 8px;
|
|
3321
|
+
margin-left: 34px;
|
|
3322
|
+
}
|
|
3323
|
+
.worked-item {
|
|
3324
|
+
padding: 18px 0;
|
|
3325
|
+
border-top: 1px solid var(--line);
|
|
3326
|
+
}
|
|
3327
|
+
.worked-item:first-of-type {
|
|
3328
|
+
padding-top: 6px;
|
|
3329
|
+
border-top: 0;
|
|
3330
|
+
}
|
|
3331
|
+
.worked-title {
|
|
3332
|
+
font-family: var(--serif);
|
|
3333
|
+
font-size: 24px;
|
|
3334
|
+
font-weight: 600;
|
|
3335
|
+
line-height: 1.35;
|
|
3336
|
+
}
|
|
3337
|
+
.worked-principle {
|
|
3338
|
+
color: var(--muted);
|
|
3339
|
+
line-height: 1.7;
|
|
3340
|
+
margin-top: 12px;
|
|
3341
|
+
padding-top: 12px;
|
|
3342
|
+
border-top: 1px solid rgba(239,123,67,0.16);
|
|
3343
|
+
}
|
|
3344
|
+
ol,
|
|
3345
|
+
ul {
|
|
3346
|
+
margin: 10px 0 0;
|
|
3347
|
+
padding-left: 20px;
|
|
3348
|
+
line-height: 1.75;
|
|
3349
|
+
}
|
|
3350
|
+
.chapter-evidence {
|
|
3351
|
+
display: block;
|
|
3352
|
+
margin-top: 24px;
|
|
3353
|
+
padding-top: 16px;
|
|
3354
|
+
border-top: 1px solid var(--line);
|
|
3355
|
+
}
|
|
3356
|
+
.chapter-evidence summary {
|
|
3357
|
+
cursor: pointer;
|
|
3358
|
+
display: flex;
|
|
3359
|
+
flex-wrap: wrap;
|
|
3360
|
+
align-items: center;
|
|
3361
|
+
gap: 10px;
|
|
3362
|
+
color: var(--muted);
|
|
3363
|
+
}
|
|
3364
|
+
.evidence-summary-title {
|
|
3365
|
+
color: var(--accent-deep);
|
|
3366
|
+
font-size: 13px;
|
|
3367
|
+
font-weight: 700;
|
|
3368
|
+
letter-spacing: 0.04em;
|
|
3369
|
+
}
|
|
3370
|
+
.evidence-summary-count {
|
|
3371
|
+
color: var(--muted);
|
|
3372
|
+
font-size: 12px;
|
|
3373
|
+
}
|
|
3374
|
+
.evidence-mini-list {
|
|
3375
|
+
display: grid;
|
|
3376
|
+
gap: 0;
|
|
3377
|
+
margin-top: 12px;
|
|
3378
|
+
}
|
|
3379
|
+
.evidence-mini-card {
|
|
3380
|
+
border: 0;
|
|
3381
|
+
border-top: 1px solid rgba(121, 151, 194, 0.16);
|
|
3382
|
+
border-radius: 0;
|
|
3383
|
+
background: transparent;
|
|
3384
|
+
padding: 12px 0;
|
|
3385
|
+
}
|
|
3386
|
+
.evidence-mini-card:first-child {
|
|
3387
|
+
border-top: 0;
|
|
3388
|
+
padding-top: 0;
|
|
3389
|
+
}
|
|
3390
|
+
.evidence-mini-card strong,
|
|
3391
|
+
.evidence-mini-card span {
|
|
3392
|
+
display: block;
|
|
3393
|
+
}
|
|
3394
|
+
.evidence-mini-card strong {
|
|
3395
|
+
font-weight: 650;
|
|
3396
|
+
font-size: 14px;
|
|
3397
|
+
line-height: 1.5;
|
|
3398
|
+
}
|
|
3399
|
+
.evidence-mini-card span {
|
|
3400
|
+
color: var(--muted);
|
|
3401
|
+
font-family: var(--mono);
|
|
3402
|
+
font-size: 11px;
|
|
3403
|
+
letter-spacing: 0.02em;
|
|
3404
|
+
margin-top: 4px;
|
|
3405
|
+
}
|
|
3406
|
+
.evidence-mini-card p {
|
|
3407
|
+
margin: 6px 0 0;
|
|
3408
|
+
color: var(--muted);
|
|
3409
|
+
font-size: 13px;
|
|
3410
|
+
line-height: 1.6;
|
|
3411
|
+
}
|
|
3412
|
+
.source-card,
|
|
3413
|
+
.claim-item,
|
|
3414
|
+
.gap-item {
|
|
3415
|
+
border: 1px solid var(--line);
|
|
3416
|
+
border-radius: 10px;
|
|
3417
|
+
padding: 12px;
|
|
3418
|
+
background: #fffefa;
|
|
3419
|
+
margin-bottom: 10px;
|
|
3420
|
+
}
|
|
3421
|
+
.source-title,
|
|
3422
|
+
.claim-statement {
|
|
3423
|
+
font-weight: 800;
|
|
3424
|
+
line-height: 1.5;
|
|
3425
|
+
}
|
|
3426
|
+
.source-meta,
|
|
3427
|
+
.claim-meta,
|
|
3428
|
+
.source-digest {
|
|
3429
|
+
color: var(--muted);
|
|
3430
|
+
font-size: 12px;
|
|
3431
|
+
line-height: 1.6;
|
|
3432
|
+
margin-top: 4px;
|
|
3433
|
+
}
|
|
3434
|
+
.source-card pre {
|
|
3435
|
+
margin: 10px 0 0;
|
|
3436
|
+
white-space: pre-wrap;
|
|
3437
|
+
word-break: break-word;
|
|
3438
|
+
border-radius: 8px;
|
|
3439
|
+
background: #111827;
|
|
3440
|
+
color: #e5e7eb;
|
|
3441
|
+
padding: 10px;
|
|
3442
|
+
font-family: var(--mono);
|
|
3443
|
+
font-size: 12px;
|
|
3444
|
+
line-height: 1.5;
|
|
3445
|
+
}
|
|
3446
|
+
.gap-item {
|
|
3447
|
+
border-color: rgba(220,38,38,0.16);
|
|
3448
|
+
background: var(--danger-soft);
|
|
3449
|
+
}
|
|
3450
|
+
.empty-reader {
|
|
3451
|
+
margin: 38px 52px 54px;
|
|
3452
|
+
padding: 28px;
|
|
3453
|
+
border: 1px dashed var(--line-strong);
|
|
3454
|
+
border-radius: 16px;
|
|
3455
|
+
background: var(--wash);
|
|
3456
|
+
}
|
|
3457
|
+
.empty-reader h2 {
|
|
3458
|
+
margin: 0 0 12px;
|
|
3459
|
+
font-family: var(--serif);
|
|
3460
|
+
font-size: 34px;
|
|
3461
|
+
line-height: 1.2;
|
|
3462
|
+
}
|
|
3463
|
+
.empty-reader p {
|
|
3464
|
+
margin: 0;
|
|
3465
|
+
color: var(--muted);
|
|
3466
|
+
font-size: 17px;
|
|
3467
|
+
line-height: 1.8;
|
|
3468
|
+
}
|
|
3469
|
+
.empty-steps {
|
|
3470
|
+
margin: 18px 0 0;
|
|
3471
|
+
padding-left: 22px;
|
|
3472
|
+
color: var(--muted);
|
|
3473
|
+
line-height: 1.8;
|
|
3474
|
+
}
|
|
3475
|
+
.empty-steps li + li {
|
|
3476
|
+
margin-top: 6px;
|
|
3477
|
+
}
|
|
3478
|
+
.empty-paths {
|
|
3479
|
+
display: grid;
|
|
3480
|
+
gap: 10px;
|
|
3481
|
+
margin-top: 18px;
|
|
3482
|
+
}
|
|
3483
|
+
.empty-paths div {
|
|
3484
|
+
display: grid;
|
|
3485
|
+
gap: 4px;
|
|
3486
|
+
border: 1px solid var(--line);
|
|
3487
|
+
border-radius: 12px;
|
|
3488
|
+
padding: 10px 12px;
|
|
3489
|
+
background: #fffefa;
|
|
3490
|
+
}
|
|
3491
|
+
.empty-paths strong {
|
|
3492
|
+
color: var(--accent-deep);
|
|
3493
|
+
font-size: 13px;
|
|
3494
|
+
}
|
|
3495
|
+
.empty-paths span {
|
|
3496
|
+
color: var(--muted);
|
|
3497
|
+
font-family: var(--mono);
|
|
3498
|
+
font-size: 12px;
|
|
3499
|
+
overflow-wrap: anywhere;
|
|
3500
|
+
}
|
|
3501
|
+
@media (max-width: 1120px) {
|
|
3502
|
+
body { overflow: auto; }
|
|
3503
|
+
.shell { grid-template-columns: 1fr; height: auto; min-height: 100vh; padding: 12px; }
|
|
3504
|
+
.side-panel {
|
|
3505
|
+
position: static;
|
|
3506
|
+
max-height: none;
|
|
3507
|
+
}
|
|
3508
|
+
.reader { height: auto; }
|
|
3509
|
+
.reader-scroll { height: auto; overflow: visible; }
|
|
3510
|
+
.chapter { min-height: auto; padding: 24px 20px 30px; }
|
|
3511
|
+
.visual-grid.has-image { grid-template-columns: 1fr; }
|
|
3512
|
+
.visual-copy {
|
|
3513
|
+
border-left: 0;
|
|
3514
|
+
border-top: 1px solid var(--line);
|
|
3515
|
+
padding-left: 0;
|
|
3516
|
+
padding-top: 18px;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
@media (max-width: 700px) {
|
|
3520
|
+
.reader-header { padding: 18px 20px 12px; }
|
|
3521
|
+
h1 { font-size: 30px; }
|
|
3522
|
+
.chapter h2 { font-size: 28px; }
|
|
3523
|
+
.learning-block h4,
|
|
3524
|
+
.visual-header h4,
|
|
3525
|
+
.worked-title {
|
|
3526
|
+
font-size: 24px;
|
|
3527
|
+
}
|
|
3528
|
+
.stat-grid { grid-template-columns: 1fr; }
|
|
3529
|
+
.controls { display: grid; gap: 12px; }
|
|
3530
|
+
.chapter { padding: 30px 22px 38px; }
|
|
3531
|
+
}
|
|
3532
|
+
</style>
|
|
3533
|
+
</head>
|
|
3534
|
+
<body>
|
|
3535
|
+
<main class="shell">
|
|
3536
|
+
<aside class="side-panel">
|
|
3537
|
+
<p class="toc-title">书籍大纲</p>
|
|
3538
|
+
<p class="toc-subtitle">最多三层展开。先读章名,再进入心法、练习与示例。</p>
|
|
3539
|
+
<ol class="outline-list">
|
|
3540
|
+
${outline.length > 0 ? outline.map((node, index) => formatLearningOutlineNode(node, `${index + 1}`, initialChapterId)).join('\n') : '<li><span class="outline-jump"><span class="outline-number">0</span><span class="outline-copy"><strong>证据包待写作</strong><small>正文完成后显示目录</small></span></span></li>'}
|
|
3541
|
+
</ol>
|
|
3542
|
+
</aside>
|
|
3543
|
+
|
|
3544
|
+
<article class="reader">
|
|
3545
|
+
<header class="reader-header">
|
|
3546
|
+
<p class="eyebrow">OpenPrd 复盘学习 · ${escapeHtml(content.genre?.label ?? '默认题材')}</p>
|
|
3547
|
+
<h1>${escapeHtml(title)}</h1>
|
|
3548
|
+
<p class="subtitle">${escapeHtml(content.subtitle ?? '')}</p>
|
|
3549
|
+
<details class="meta-details">
|
|
3550
|
+
<summary>生成信息</summary>
|
|
3551
|
+
<div class="meta-row">
|
|
3552
|
+
<span class="meta-pill">topic: ${escapeHtml(content.topic ?? '未指定')}</span>
|
|
3553
|
+
<span class="meta-pill">genre: ${escapeHtml(content.genre?.id ?? 'unknown')}</span>
|
|
3554
|
+
<span class="meta-pill">风格: ${escapeHtml(content.stylePromptPack?.styleId ?? packageMeta?.styleId ?? 'default')}</span>
|
|
3555
|
+
<span class="meta-pill">trigger: ${escapeHtml(packageMeta?.trigger ?? content.trigger ?? 'manual')}</span>
|
|
3556
|
+
</div>
|
|
3557
|
+
</details>
|
|
3558
|
+
<div class="controls">
|
|
3559
|
+
<div class="button-row">
|
|
3560
|
+
<button type="button" id="prevChapter" disabled>上一章</button>
|
|
3561
|
+
<button type="button" id="nextChapter"${chapters.length <= 1 ? ' disabled' : ''}>下一章</button>
|
|
3562
|
+
<button type="button" id="smallerText">A-</button>
|
|
3563
|
+
<button type="button" id="largerText">A+</button>
|
|
3564
|
+
</div>
|
|
3565
|
+
<div class="progress-wrap">
|
|
3566
|
+
<div class="progress-meta">
|
|
3567
|
+
<span id="progressTitle">阅读进度</span>
|
|
3568
|
+
<span id="progressText">${chapters.length > 0 ? `1/${chapters.length}` : '0/0'}</span>
|
|
3569
|
+
</div>
|
|
3570
|
+
<div class="progress-track"><div class="progress-bar" id="progressBar" style="width: ${initialProgressPercent}%"></div></div>
|
|
3571
|
+
</div>
|
|
3572
|
+
</div>
|
|
3573
|
+
</header>
|
|
3574
|
+
<div class="reader-scroll" tabindex="0" aria-label="OpenPrd 复盘学习阅读器 · 当前章节正文">
|
|
3575
|
+
${chapters.length > 0 ? chapters.map((chapter, index) => formatLearningChapter(chapter, index, sourcesById)).join('\n') : formatLearningEmptyState(content, packageMeta, evidenceManifest)}
|
|
3576
|
+
</div>
|
|
3577
|
+
</article>
|
|
3578
|
+
</main>
|
|
3579
|
+
<script>
|
|
3580
|
+
const scrollRoot = document.querySelector('.reader-scroll');
|
|
3581
|
+
const chapters = Array.from(document.querySelectorAll('.chapter'));
|
|
3582
|
+
const outlineItems = Array.from(document.querySelectorAll('[data-target-id]'));
|
|
3583
|
+
const prevButton = document.getElementById('prevChapter');
|
|
3584
|
+
const nextButton = document.getElementById('nextChapter');
|
|
3585
|
+
const progressBar = document.getElementById('progressBar');
|
|
3586
|
+
const progressText = document.getElementById('progressText');
|
|
3587
|
+
let activeIndex = 0;
|
|
3588
|
+
let fontScale = Number(localStorage.getItem('openprd-learning-font-scale') || '1');
|
|
3589
|
+
|
|
3590
|
+
function clamp(value, min, max) {
|
|
3591
|
+
return Math.max(min, Math.min(max, value));
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
function applyFontScale() {
|
|
3595
|
+
fontScale = clamp(fontScale, 0.9, 1.25);
|
|
3596
|
+
document.documentElement.style.setProperty('--reader-scale', String(fontScale));
|
|
3597
|
+
localStorage.setItem('openprd-learning-font-scale', String(fontScale));
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
function setActive(index, shouldScroll = false) {
|
|
3601
|
+
if (chapters.length === 0) return;
|
|
3602
|
+
activeIndex = clamp(index, 0, chapters.length - 1);
|
|
3603
|
+
chapters.forEach((chapter, chapterIndex) => {
|
|
3604
|
+
const isActive = chapterIndex === activeIndex;
|
|
3605
|
+
chapter.hidden = !isActive;
|
|
3606
|
+
chapter.classList.toggle('active', isActive);
|
|
3607
|
+
});
|
|
3608
|
+
const activeChapterId = chapters[activeIndex].id;
|
|
3609
|
+
outlineItems.forEach((item) => item.classList.toggle('active', item.dataset.targetId === activeChapterId));
|
|
3610
|
+
prevButton.disabled = activeIndex === 0;
|
|
3611
|
+
nextButton.disabled = activeIndex === chapters.length - 1;
|
|
3612
|
+
progressText.textContent = String(activeIndex + 1) + '/' + String(chapters.length);
|
|
3613
|
+
progressBar.style.width = String(((activeIndex + 1) / chapters.length) * 100) + '%';
|
|
3614
|
+
if (shouldScroll) {
|
|
3615
|
+
scrollRoot?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
function scrollToReaderTarget(target) {
|
|
3620
|
+
if (!target || !scrollRoot) return;
|
|
3621
|
+
const rootTop = scrollRoot.getBoundingClientRect().top;
|
|
3622
|
+
const targetTop = target.getBoundingClientRect().top;
|
|
3623
|
+
scrollRoot.scrollTo({
|
|
3624
|
+
top: scrollRoot.scrollTop + targetTop - rootTop - 18,
|
|
3625
|
+
behavior: 'smooth',
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
outlineItems.forEach((item) => {
|
|
3630
|
+
item.addEventListener('click', () => {
|
|
3631
|
+
const target = document.getElementById(item.dataset.targetId);
|
|
3632
|
+
if (!target) return;
|
|
3633
|
+
const chapterIndex = chapters.findIndex((chapter) => chapter.id === target.id || chapter.contains(target));
|
|
3634
|
+
if (chapterIndex >= 0) setActive(chapterIndex, false);
|
|
3635
|
+
scrollToReaderTarget(target);
|
|
3636
|
+
});
|
|
3637
|
+
});
|
|
3638
|
+
prevButton.addEventListener('click', () => setActive(activeIndex - 1, true));
|
|
3639
|
+
nextButton.addEventListener('click', () => setActive(activeIndex + 1, true));
|
|
3640
|
+
document.getElementById('smallerText').addEventListener('click', () => {
|
|
3641
|
+
fontScale -= 0.05;
|
|
3642
|
+
applyFontScale();
|
|
3643
|
+
});
|
|
3644
|
+
document.getElementById('largerText').addEventListener('click', () => {
|
|
3645
|
+
fontScale += 0.05;
|
|
3646
|
+
applyFontScale();
|
|
3647
|
+
});
|
|
3648
|
+
document.addEventListener('keydown', (event) => {
|
|
3649
|
+
if (event.key === 'ArrowRight' || event.key === 'PageDown') setActive(activeIndex + 1, true);
|
|
3650
|
+
if (event.key === 'ArrowLeft' || event.key === 'PageUp') setActive(activeIndex - 1, true);
|
|
3651
|
+
});
|
|
3652
|
+
|
|
3653
|
+
applyFontScale();
|
|
3654
|
+
setActive(0, false);
|
|
3655
|
+
</script>
|
|
3656
|
+
</body>
|
|
3657
|
+
</html>`;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
export async function writeHtmlArtifact(filePath, html) {
|
|
3661
|
+
await writeText(filePath, html);
|
|
3662
|
+
return filePath;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
export async function openArtifactInBrowser(filePath) {
|
|
3666
|
+
const platform = process.platform;
|
|
3667
|
+
const command = platform === 'darwin'
|
|
3668
|
+
? 'open'
|
|
3669
|
+
: platform === 'win32'
|
|
3670
|
+
? 'cmd'
|
|
3671
|
+
: 'xdg-open';
|
|
3672
|
+
const args = platform === 'win32'
|
|
3673
|
+
? ['/c', 'start', '', filePath]
|
|
3674
|
+
: [filePath];
|
|
3675
|
+
const child = spawn(command, args, {
|
|
3676
|
+
detached: true,
|
|
3677
|
+
stdio: 'ignore',
|
|
3678
|
+
});
|
|
3679
|
+
child.unref();
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
export function canonicalReviewPath(ws, versionId) {
|
|
3683
|
+
return cjoin(ws.workspaceRoot, 'reviews', `${slugify(versionId, 'review')}.html`);
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
function toRelativeHref(fromFilePath, targetFilePath) {
|
|
3687
|
+
const relative = path.relative(path.dirname(fromFilePath), targetFilePath) || path.basename(targetFilePath);
|
|
3688
|
+
return relative.split(path.sep).join('/');
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
export function renderReviewEntryHtml({ entryPath, reviewPath, title = 'OpenPrd Review' }) {
|
|
3692
|
+
const href = escapeHtml(toRelativeHref(entryPath, reviewPath));
|
|
3693
|
+
return `<!DOCTYPE html>
|
|
3694
|
+
<html lang="zh-CN">
|
|
3695
|
+
<head>
|
|
3696
|
+
<meta charset="UTF-8" />
|
|
3697
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3698
|
+
<meta http-equiv="refresh" content="0; url=${href}" />
|
|
3699
|
+
<title>${escapeHtml(title)}</title>
|
|
3700
|
+
<style>
|
|
3701
|
+
:root {
|
|
3702
|
+
color-scheme: light;
|
|
3703
|
+
--bg: #f8fafc;
|
|
3704
|
+
--panel: #ffffff;
|
|
3705
|
+
--text: #111827;
|
|
3706
|
+
--muted: #6b7280;
|
|
3707
|
+
--line: rgba(17,24,39,0.12);
|
|
3708
|
+
--accent: #2563eb;
|
|
3709
|
+
}
|
|
3710
|
+
* { box-sizing: border-box; }
|
|
3711
|
+
body {
|
|
3712
|
+
margin: 0;
|
|
3713
|
+
min-height: 100vh;
|
|
3714
|
+
display: grid;
|
|
3715
|
+
place-items: center;
|
|
3716
|
+
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
|
3717
|
+
color: var(--text);
|
|
3718
|
+
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
3719
|
+
}
|
|
3720
|
+
.panel {
|
|
3721
|
+
width: min(560px, calc(100vw - 32px));
|
|
3722
|
+
padding: 28px 24px;
|
|
3723
|
+
border: 1px solid var(--line);
|
|
3724
|
+
border-radius: 16px;
|
|
3725
|
+
background: var(--panel);
|
|
3726
|
+
box-shadow: 0 18px 40px rgba(15,23,42,0.08);
|
|
3727
|
+
}
|
|
3728
|
+
h1 {
|
|
3729
|
+
margin: 0 0 10px;
|
|
3730
|
+
font-size: 24px;
|
|
3731
|
+
line-height: 1.25;
|
|
3732
|
+
}
|
|
3733
|
+
p {
|
|
3734
|
+
margin: 0 0 12px;
|
|
3735
|
+
color: var(--muted);
|
|
3736
|
+
line-height: 1.6;
|
|
3737
|
+
}
|
|
3738
|
+
a {
|
|
3739
|
+
color: var(--accent);
|
|
3740
|
+
font-weight: 700;
|
|
3741
|
+
text-decoration: none;
|
|
3742
|
+
}
|
|
3743
|
+
a:hover { text-decoration: underline; }
|
|
3744
|
+
</style>
|
|
3745
|
+
</head>
|
|
3746
|
+
<body>
|
|
3747
|
+
<main class="panel">
|
|
3748
|
+
<h1>${escapeHtml(title)}</h1>
|
|
3749
|
+
<p>这个入口只保留当前评审稿的固定路径,页面会自动跳转到最新的版本化评审文件。</p>
|
|
3750
|
+
<p><a href="${href}">如果没有自动跳转,点这里打开评审面板</a></p>
|
|
3751
|
+
</main>
|
|
3752
|
+
</body>
|
|
3753
|
+
</html>`;
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
|
|
3757
|
+
export function defaultReviewArtifactPath(ws) {
|
|
3758
|
+
return cjoin(ws.workspaceRoot, 'engagements', 'active', 'review.html');
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
export function defaultRegressionArtifactPath(projectRoot, taskId) {
|
|
3762
|
+
return cjoin(projectRoot, '.openprd', 'harness', 'test-reports', `${taskId.replace(/[^a-zA-Z0-9._-]/g, '_')}.html`);
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
export function artifactBundleDir(ws, artifactId) {
|
|
3766
|
+
return cjoin(ws.paths.artifactsActiveDir, slugify(artifactId));
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
export function artifactBundlePaths(ws, artifactId) {
|
|
3770
|
+
const dir = artifactBundleDir(ws, artifactId);
|
|
3771
|
+
return {
|
|
3772
|
+
dir,
|
|
3773
|
+
html: cjoin(dir, 'artifact.html'),
|
|
3774
|
+
markdown: cjoin(dir, 'data.md'),
|
|
3775
|
+
patch: cjoin(dir, 'capture-patch.json'),
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
export function learningPackagePaths(ws, packageId) {
|
|
3780
|
+
const dir = cjoin(ws.paths.learningArchiveDir, slugify(packageId, 'learning-package'));
|
|
3781
|
+
return {
|
|
3782
|
+
dir,
|
|
3783
|
+
readerHtml: cjoin(dir, 'reader.html'),
|
|
3784
|
+
assetsDir: cjoin(dir, 'assets'),
|
|
3785
|
+
packageJson: cjoin(dir, 'learning-package.json'),
|
|
3786
|
+
contentJson: cjoin(dir, 'learning-content.json'),
|
|
3787
|
+
contentMarkdown: cjoin(dir, 'learning-content.md'),
|
|
3788
|
+
evidenceManifest: cjoin(dir, 'evidence-manifest.json'),
|
|
3789
|
+
agentContext: cjoin(dir, 'agent-context.json'),
|
|
3790
|
+
agentPrompt: cjoin(dir, 'agent-prompt.md'),
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
export function renderMarkdownDataDocument({ title, sections }) {
|
|
3795
|
+
const lines = [`# ${title}`, ''];
|
|
3796
|
+
for (const section of sections) {
|
|
3797
|
+
lines.push(`## ${section.title}`);
|
|
3798
|
+
lines.push('');
|
|
3799
|
+
lines.push(...section.lines);
|
|
3800
|
+
lines.push('');
|
|
3801
|
+
}
|
|
3802
|
+
return `${lines.join('\n')}\n`;
|
|
3803
|
+
}
|