@openprd/cli 0.1.10 → 0.1.18
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/benchmarks/index.md +17 -15
- package/.openprd/benchmarks/sources.yaml +53 -1
- package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
- package/.openprd/changes/adaptive-project-framing/design.md +47 -0
- package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
- package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
- package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
- package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
- package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
- package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
- package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
- package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
- package/.openprd/changes/openprd/.openprd.yaml +2 -0
- package/.openprd/changes/openprd/design.md +46 -0
- package/.openprd/changes/openprd/proposal.md +36 -0
- package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd/task-events.jsonl +23 -0
- package/.openprd/changes/openprd/tasks.md +242 -0
- package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
- package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
- package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
- package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
- package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
- package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
- package/.openprd/changes/test-strategy-router/design.md +65 -0
- package/.openprd/changes/test-strategy-router/proposal.md +45 -0
- package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
- package/.openprd/changes/test-strategy-router/tasks.md +132 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
- package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
- package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
- package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
- package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
- package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
- package/.openprd/design/README.md +11 -6
- package/.openprd/design/README_EN.md +4 -1
- package/.openprd/design/active/asset-spec.md +6 -0
- package/.openprd/design/active/direction-plan.md +5 -5
- package/.openprd/design/active/selected-direction.md +2 -0
- package/.openprd/design/anti-slop.md +26 -1
- package/.openprd/design/assets/surface-presets.md +6 -0
- package/.openprd/design/checklists/ui-quality-gate.md +7 -0
- package/.openprd/design/lenses/frontend-lenses.md +28 -0
- package/.openprd/design/recipes/content-experience.md +8 -1
- package/.openprd/design/recipes/operational-tool.md +7 -0
- package/.openprd/design/recipes/product-launch.md +8 -1
- package/.openprd/design/templates/README.md +6 -3
- package/.openprd/design/templates/README_EN.md +6 -3
- package/.openprd/design/templates/ops-dashboard.html +8 -1
- package/.openprd/design/templates/product-launch.html +2 -1
- package/.openprd/design/themes/theme-catalog.json +23 -5
- package/.openprd/engagements/active/prd.md +103 -100
- package/.openprd/schema/prd.schema.yaml +15 -0
- package/.openprd/standards/config.json +7 -1
- package/.openprd/templates/base/intake.md +13 -0
- package/.openprd/templates/base/prd.md +15 -0
- package/AGENTS.md +14 -4
- package/README.md +107 -14
- package/README_EN.md +131 -13
- package/package.json +1 -1
- package/scripts/dev-check-wrapup-copy.mjs +46 -21
- package/skills/openprd-benchmark-router/SKILL.md +2 -1
- package/skills/openprd-diagram-review/SKILL.md +27 -2
- package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-frontend-design/SKILL.md +44 -20
- package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
- package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
- package/skills/openprd-harness/SKILL.md +22 -16
- package/skills/openprd-quality/SKILL.md +10 -8
- package/skills/openprd-requirement-intake/SKILL.md +5 -3
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
- package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
- package/skills/openprd-router/SKILL.md +2 -1
- package/skills/openprd-shared/SKILL.md +27 -17
- package/skills/openprd-standards/SKILL.md +4 -2
- package/src/agent-canonical-content.js +808 -0
- package/src/agent-integration.js +259 -778
- package/src/brainstorm-artifacts.js +156 -8
- package/src/brainstorm-presentation.js +14 -4
- package/src/brainstorm.js +165 -2
- package/src/canvas-app.html.js +2910 -0
- package/src/canvas-i18n.js +274 -0
- package/src/canvas-workspace.js +2271 -0
- package/src/cli/args.js +72 -4
- package/src/cli/basic-print.js +3 -0
- package/src/cli/canvas-print.js +106 -0
- package/src/cli/doctor-print.js +20 -0
- package/src/cli/print.js +2 -0
- package/src/cli/quality-commands.js +222 -0
- package/src/cli/quality-print.js +37 -5
- package/src/cli/run-print.js +5 -0
- package/src/cli/workflow-print.js +3 -0
- package/src/codex-app-server-relay.js +251 -0
- package/src/codex-hook-runner-template.mjs +963 -65
- package/src/design-starter-support.js +2 -2
- package/src/design-starter.js +39 -3
- package/src/dev-standards.js +125 -10
- package/src/diagram-core.js +390 -171
- package/src/discovery.js +2 -8
- package/src/docs-compact.js +125 -0
- package/src/execution-strategy.js +1 -1
- package/src/fleet.js +9 -3
- package/src/guide.js +227 -0
- package/src/html-artifacts.js +825 -51
- package/src/knowledge.js +101 -20
- package/src/language-policy.js +63 -3
- package/src/learning-review.js +1 -1
- package/src/loop.js +1 -1
- package/src/openprd.js +68 -131
- package/src/openspec/change-validate.js +2 -2
- package/src/openspec/constants.js +6 -7
- package/src/openspec/execute.js +2 -2
- package/src/openspec/generate.js +318 -102
- package/src/openspec/migration.js +168 -0
- package/src/openspec/paths.js +1 -22
- package/src/prd-core.js +102 -3
- package/src/quality-html-artifact.js +30 -5
- package/src/quality-visual-review.js +257 -14
- package/src/quality.js +66 -18
- package/src/review-presentation.js +60 -0
- package/src/run-harness.js +41 -3
- package/src/session-evidence.js +363 -0
- package/src/standards.js +64 -0
- package/src/test-strategy.js +7 -4
- package/src/visual-compare-centering.js +622 -0
- package/src/visual-compare-core.js +1123 -0
- package/src/visual-compare.js +620 -698
- package/src/workspace-core.js +132 -4
- package/src/workspace-workflow.js +172 -10
package/src/diagram-core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timestamp } from './time.js';
|
|
2
|
+
import { isEnglishHeavyText, normalizeOutputLocale } from './language-policy.js';
|
|
2
3
|
|
|
3
4
|
function escapeHtml(value) {
|
|
4
5
|
return `${value ?? ''}`
|
|
@@ -88,17 +89,17 @@ function pickValue(primary, fallback) {
|
|
|
88
89
|
|
|
89
90
|
function theme(type) {
|
|
90
91
|
const themes = {
|
|
91
|
-
frontend: { fill: '#
|
|
92
|
-
backend: { fill: '#
|
|
93
|
-
database: { fill: '#
|
|
94
|
-
cloud: { fill: '#
|
|
95
|
-
security: { fill: '#
|
|
96
|
-
external: { fill: '#
|
|
97
|
-
user_action: { fill: '#
|
|
98
|
-
system_process: { fill: '#
|
|
99
|
-
decision: { fill: '#
|
|
100
|
-
error_path: { fill: '#
|
|
101
|
-
success: { fill: '#
|
|
92
|
+
frontend: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
|
|
93
|
+
backend: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
|
|
94
|
+
database: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
|
|
95
|
+
cloud: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
|
|
96
|
+
security: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
|
|
97
|
+
external: { fill: '#f8fafc', stroke: '#94a3b8', title: '#475569' },
|
|
98
|
+
user_action: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
|
|
99
|
+
system_process: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
|
|
100
|
+
decision: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
|
|
101
|
+
error_path: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
|
|
102
|
+
success: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
|
|
102
103
|
};
|
|
103
104
|
return themes[type] ?? themes.external;
|
|
104
105
|
}
|
|
@@ -120,28 +121,13 @@ function normalizePanel(panel, fallbackTitle = '评审备注', fallbackColor = '
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
function normalizeLocale(contract) {
|
|
123
|
-
return pickValue(contract?.locale, contract?.lang ?? 'zh-CN');
|
|
124
|
+
return normalizeOutputLocale(pickValue(contract?.locale, contract?.lang ?? 'zh-CN'));
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
function normalizeReviewStatus(value) {
|
|
127
128
|
return pickValue(value, 'pending-confirmation');
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
function hasCjk(text) {
|
|
131
|
-
return /[\u3400-\u9fff]/.test(text);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function englishWords(text) {
|
|
135
|
-
return `${text ?? ''}`.match(/[A-Za-z][A-Za-z0-9+_.-]*/g) ?? [];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function isEnglishHeavyText(text) {
|
|
139
|
-
const value = `${text ?? ''}`.trim();
|
|
140
|
-
if (!value || hasCjk(value)) return false;
|
|
141
|
-
const words = englishWords(value);
|
|
142
|
-
return words.length >= 4;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
131
|
function collectDiagramTexts(model) {
|
|
146
132
|
const entries = [];
|
|
147
133
|
const push = (path, value) => {
|
|
@@ -181,7 +167,7 @@ function collectDiagramTexts(model) {
|
|
|
181
167
|
}
|
|
182
168
|
|
|
183
169
|
export function validateDiagramLanguage(model) {
|
|
184
|
-
const locale =
|
|
170
|
+
const locale = normalizeOutputLocale(model?.locale ?? 'zh-CN').toLowerCase();
|
|
185
171
|
if (!locale.startsWith('zh')) {
|
|
186
172
|
return { valid: true, errors: [] };
|
|
187
173
|
}
|
|
@@ -245,36 +231,32 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
245
231
|
<title>${escapeHtml(title)}</title>
|
|
246
232
|
<style>
|
|
247
233
|
:root {
|
|
248
|
-
color-scheme:
|
|
249
|
-
--bg: #
|
|
250
|
-
--panel:
|
|
251
|
-
--
|
|
252
|
-
--
|
|
253
|
-
--
|
|
234
|
+
color-scheme: light;
|
|
235
|
+
--bg: #f6f8fb;
|
|
236
|
+
--panel: #ffffff;
|
|
237
|
+
--panel-soft: #f9fafb;
|
|
238
|
+
--text: #172033;
|
|
239
|
+
--muted: #667085;
|
|
240
|
+
--line: #d8dee8;
|
|
254
241
|
}
|
|
255
242
|
* { box-sizing: border-box; }
|
|
256
243
|
body {
|
|
257
244
|
margin: 0;
|
|
258
|
-
font-family:
|
|
245
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
259
246
|
color: var(--text);
|
|
260
|
-
background:
|
|
261
|
-
linear-gradient(var(--grid) 1px, transparent 1px),
|
|
262
|
-
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
|
|
263
|
-
radial-gradient(circle at top, rgba(34, 211, 238, 0.12), transparent 30%),
|
|
264
|
-
var(--bg);
|
|
265
|
-
background-size: 40px 40px, 40px 40px, 100% 100%, auto;
|
|
247
|
+
background: var(--bg);
|
|
266
248
|
}
|
|
267
249
|
.page { max-width: 1240px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
268
250
|
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
269
251
|
.header-copy { display: flex; flex-direction: column; gap: 4px; }
|
|
270
252
|
.pulse {
|
|
271
|
-
width: 12px; height: 12px; border-radius: 999px; background: #
|
|
272
|
-
box-shadow: 0 0 0 0 rgba(
|
|
253
|
+
width: 12px; height: 12px; border-radius: 999px; background: #2563eb;
|
|
254
|
+
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); animation: pulse 2s infinite;
|
|
273
255
|
}
|
|
274
256
|
@keyframes pulse {
|
|
275
|
-
0% { box-shadow: 0 0 0 0 rgba(
|
|
276
|
-
70% { box-shadow: 0 0 0 10px rgba(
|
|
277
|
-
100% { box-shadow: 0 0 0 0 rgba(
|
|
257
|
+
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); }
|
|
258
|
+
70% { box-shadow: 0 0 0 10px rgba(37, 99, 235, 0); }
|
|
259
|
+
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
|
278
260
|
}
|
|
279
261
|
.project-chip {
|
|
280
262
|
display: inline-flex;
|
|
@@ -283,32 +265,37 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
283
265
|
width: fit-content;
|
|
284
266
|
padding: 4px 10px;
|
|
285
267
|
border-radius: 999px;
|
|
286
|
-
border: 1px solid
|
|
287
|
-
background:
|
|
288
|
-
color:
|
|
268
|
+
border: 1px solid var(--line);
|
|
269
|
+
background: var(--panel);
|
|
270
|
+
color: var(--muted);
|
|
289
271
|
font-size: 11px;
|
|
290
272
|
}
|
|
291
|
-
h1 { margin: 0; font-size: 28px; }
|
|
273
|
+
h1 { margin: 0; font-size: 28px; font-weight: 700; }
|
|
292
274
|
.subtitle-block { margin: 6px 0 0 24px; color: var(--muted); font-size: 13px; }
|
|
293
275
|
.diagram-shell {
|
|
294
|
-
margin-top: 24px; border: 1px solid
|
|
295
|
-
padding: 20px; background:
|
|
276
|
+
margin-top: 24px; border: 1px solid var(--line); border-radius: 20px;
|
|
277
|
+
padding: 20px; background: var(--panel);
|
|
278
|
+
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
|
|
296
279
|
}
|
|
297
280
|
svg { width: 100%; height: auto; display: block; }
|
|
298
281
|
.node-title { font-size: 13px; font-weight: 700; }
|
|
299
|
-
.node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #
|
|
300
|
-
.detail { fill: #
|
|
282
|
+
.node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #334155; }
|
|
283
|
+
.detail { fill: #667085; }
|
|
284
|
+
.lane-chip { font-size: 9px; fill: #94a3b8; }
|
|
285
|
+
.legend-label { fill: #667085; }
|
|
286
|
+
.flow-label { fill: #334155; paint-order: stroke; stroke: #ffffff; stroke-width: 4px; stroke-linejoin: round; }
|
|
301
287
|
.summary-grid, .side-grid { display: grid; gap: 16px; margin-top: 24px; }
|
|
302
288
|
.summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
303
289
|
.side-grid { grid-template-columns: 1fr 1fr; }
|
|
304
290
|
.card {
|
|
305
|
-
border: 1px solid
|
|
291
|
+
border: 1px solid var(--line); border-radius: 16px;
|
|
306
292
|
background: var(--panel); padding: 14px 16px;
|
|
293
|
+
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
|
307
294
|
}
|
|
308
|
-
.card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; }
|
|
295
|
+
.card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; font-weight: 600; }
|
|
309
296
|
.dot { width: 10px; height: 10px; border-radius: 999px; }
|
|
310
|
-
ul { padding-left: 18px; margin: 0; color: #
|
|
311
|
-
.footer { margin-top: 18px; color: var(--muted); }
|
|
297
|
+
ul { padding-left: 18px; margin: 0; color: #475467; font-size: 12px; line-height: 1.65; }
|
|
298
|
+
.footer { margin-top: 18px; color: var(--muted); font-size: 12px; }
|
|
312
299
|
</style>
|
|
313
300
|
</head>
|
|
314
301
|
<body>
|
|
@@ -330,28 +317,273 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
330
317
|
</html>`;
|
|
331
318
|
}
|
|
332
319
|
|
|
320
|
+
function textUnits(text) {
|
|
321
|
+
let units = 0;
|
|
322
|
+
for (const ch of `${text ?? ''}`) {
|
|
323
|
+
units += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1;
|
|
324
|
+
}
|
|
325
|
+
return units;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function wrapTextLines(text, maxUnits, maxLines = 3) {
|
|
329
|
+
const value = `${text ?? ''}`.trim().replace(/\s+/g, ' ');
|
|
330
|
+
if (!value) return [];
|
|
331
|
+
const lines = [];
|
|
332
|
+
let current = '';
|
|
333
|
+
let currentUnits = 0;
|
|
334
|
+
for (const ch of value) {
|
|
335
|
+
const chUnits = textUnits(ch);
|
|
336
|
+
if (currentUnits + chUnits > maxUnits && current) {
|
|
337
|
+
lines.push(current);
|
|
338
|
+
current = ch === ' ' ? '' : ch;
|
|
339
|
+
currentUnits = ch === ' ' ? 0 : chUnits;
|
|
340
|
+
if (lines.length === maxLines) break;
|
|
341
|
+
} else {
|
|
342
|
+
current += ch;
|
|
343
|
+
currentUnits += chUnits;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (lines.length < maxLines && current) {
|
|
347
|
+
lines.push(current);
|
|
348
|
+
} else if (lines.length === maxLines && current) {
|
|
349
|
+
const last = lines[maxLines - 1];
|
|
350
|
+
lines[maxLines - 1] = `${last.slice(0, Math.max(0, last.length - 1))}…`;
|
|
351
|
+
}
|
|
352
|
+
return lines;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const FLOW_LAYOUT_DEFAULTS = {
|
|
356
|
+
boxWidth: 232,
|
|
357
|
+
gapX: 96,
|
|
358
|
+
gapY: 30,
|
|
359
|
+
marginX: 72,
|
|
360
|
+
marginY: 96,
|
|
361
|
+
titleHeight: 30,
|
|
362
|
+
lineHeight: 15,
|
|
363
|
+
subtitleMaxLines: 3,
|
|
364
|
+
detailMaxItems: 4,
|
|
365
|
+
bottomPadding: 14,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
function assignLayers(nodes, edges) {
|
|
369
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
370
|
+
const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
|
|
371
|
+
const layers = new Map(nodes.map((node) => [node.id, 0]));
|
|
372
|
+
const maxIterations = Math.max(4, nodes.length * nodes.length);
|
|
373
|
+
let changed = true;
|
|
374
|
+
let iterations = 0;
|
|
375
|
+
while (changed && iterations < maxIterations) {
|
|
376
|
+
changed = false;
|
|
377
|
+
iterations += 1;
|
|
378
|
+
for (const edge of validEdges) {
|
|
379
|
+
const required = layers.get(edge.from) + 1;
|
|
380
|
+
if (layers.get(edge.to) < required && required <= nodes.length) {
|
|
381
|
+
layers.set(edge.to, required);
|
|
382
|
+
changed = true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { layers, validEdges };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function layoutFlowDiagram(nodes, edges, options = {}) {
|
|
390
|
+
const config = { ...FLOW_LAYOUT_DEFAULTS, ...options };
|
|
391
|
+
const { layers, validEdges } = assignLayers(nodes, edges);
|
|
392
|
+
|
|
393
|
+
const positioned = new Map();
|
|
394
|
+
for (const node of nodes) {
|
|
395
|
+
const subtitle = `${node.subtitle ?? ''}`.trim() === '待补充' ? '' : node.subtitle;
|
|
396
|
+
const subtitleLines = wrapTextLines(subtitle, 34, config.subtitleMaxLines);
|
|
397
|
+
const detailLines = (node.details ?? []).slice(0, config.detailMaxItems)
|
|
398
|
+
.filter((line) => `${line ?? ''}`.trim() !== '待补充')
|
|
399
|
+
.map((line) => wrapTextLines(line, 34, 1)[0])
|
|
400
|
+
.filter(Boolean);
|
|
401
|
+
const height = config.titleHeight
|
|
402
|
+
+ (subtitleLines.length * config.lineHeight)
|
|
403
|
+
+ (detailLines.length > 0 ? 6 + detailLines.length * config.lineHeight : 0)
|
|
404
|
+
+ config.bottomPadding;
|
|
405
|
+
positioned.set(node.id, {
|
|
406
|
+
node,
|
|
407
|
+
subtitleLines,
|
|
408
|
+
detailLines,
|
|
409
|
+
width: config.boxWidth,
|
|
410
|
+
height: Math.max(height, 64),
|
|
411
|
+
layer: layers.get(node.id) ?? 0,
|
|
412
|
+
x: 0,
|
|
413
|
+
y: 0,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const columns = new Map();
|
|
418
|
+
for (const entry of positioned.values()) {
|
|
419
|
+
if (!columns.has(entry.layer)) columns.set(entry.layer, []);
|
|
420
|
+
columns.get(entry.layer).push(entry);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const incoming = new Map();
|
|
424
|
+
for (const edge of validEdges) {
|
|
425
|
+
if (!incoming.has(edge.to)) incoming.set(edge.to, []);
|
|
426
|
+
incoming.get(edge.to).push(edge.from);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const layerKeys = [...columns.keys()].sort((a, b) => a - b);
|
|
430
|
+
let maxColumnHeight = 0;
|
|
431
|
+
for (const key of layerKeys) {
|
|
432
|
+
const column = columns.get(key);
|
|
433
|
+
column.sort((a, b) => {
|
|
434
|
+
const aPreds = incoming.get(a.node.id) ?? [];
|
|
435
|
+
const bPreds = incoming.get(b.node.id) ?? [];
|
|
436
|
+
const avg = (preds) => {
|
|
437
|
+
const ys = preds.map((id) => positioned.get(id)?.y ?? 0);
|
|
438
|
+
return ys.length > 0 ? ys.reduce((sum, y) => sum + y, 0) / ys.length : Number.MAX_SAFE_INTEGER;
|
|
439
|
+
};
|
|
440
|
+
const diff = avg(aPreds) - avg(bPreds);
|
|
441
|
+
if (diff !== 0) return diff;
|
|
442
|
+
return (a.node.type === 'error_path' ? 1 : 0) - (b.node.type === 'error_path' ? 1 : 0);
|
|
443
|
+
});
|
|
444
|
+
const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
|
|
445
|
+
maxColumnHeight = Math.max(maxColumnHeight, columnHeight);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
for (const [index, key] of layerKeys.entries()) {
|
|
449
|
+
const column = columns.get(key);
|
|
450
|
+
const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
|
|
451
|
+
let cursorY = config.marginY + (maxColumnHeight - columnHeight) / 2;
|
|
452
|
+
for (const entry of column) {
|
|
453
|
+
entry.x = config.marginX + index * (config.boxWidth + config.gapX);
|
|
454
|
+
entry.y = cursorY;
|
|
455
|
+
cursorY += entry.height + config.gapY;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const width = config.marginX * 2 + layerKeys.length * config.boxWidth + Math.max(0, layerKeys.length - 1) * config.gapX;
|
|
460
|
+
const height = config.marginY + maxColumnHeight + 96;
|
|
461
|
+
return { positioned, validEdges, width: Math.max(width, 760), height: Math.max(height, 360), config };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function routeFlowEdge(source, target, index, laneOffset) {
|
|
465
|
+
if (target.layer > source.layer) {
|
|
466
|
+
const startX = source.x + source.width;
|
|
467
|
+
const startY = source.y + source.height / 2;
|
|
468
|
+
const endX = target.x;
|
|
469
|
+
const endY = target.y + target.height / 2;
|
|
470
|
+
const midX = (startX + endX) / 2;
|
|
471
|
+
return {
|
|
472
|
+
path: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
|
|
473
|
+
labelX: midX,
|
|
474
|
+
labelY: (startY + endY) / 2 - 8 - laneOffset,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (target.layer === source.layer) {
|
|
478
|
+
const downward = target.y > source.y;
|
|
479
|
+
const startX = source.x + source.width / 2;
|
|
480
|
+
const startY = downward ? source.y + source.height : source.y;
|
|
481
|
+
const endX = target.x + target.width / 2;
|
|
482
|
+
const endY = downward ? target.y : target.y + target.height;
|
|
483
|
+
const midY = (startY + endY) / 2;
|
|
484
|
+
return {
|
|
485
|
+
path: `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`,
|
|
486
|
+
labelX: (startX + endX) / 2 + 10,
|
|
487
|
+
labelY: midY - laneOffset,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const startX = source.x;
|
|
491
|
+
const startY = source.y + source.height / 2;
|
|
492
|
+
const endX = target.x + target.width;
|
|
493
|
+
const endY = target.y + target.height / 2;
|
|
494
|
+
const drop = Math.max(startY, endY) + 56 + index * 14;
|
|
495
|
+
return {
|
|
496
|
+
path: `M ${startX} ${startY} C ${startX - 60} ${drop}, ${endX + 60} ${drop}, ${endX} ${endY}`,
|
|
497
|
+
labelX: (startX + endX) / 2,
|
|
498
|
+
labelY: drop - 8,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
333
502
|
function renderBox(node, layout) {
|
|
334
503
|
const nodeTheme = theme(node.type);
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
504
|
+
const subtitleLines = layout.subtitleLines ?? wrapTextLines(node.subtitle, 34, 3);
|
|
505
|
+
const detailLines = layout.detailLines
|
|
506
|
+
?? (node.details ?? []).slice(0, 4).map((line) => wrapTextLines(line, 34, 1)[0]).filter(Boolean);
|
|
507
|
+
const lineHeight = 15;
|
|
508
|
+
let cursorY = layout.y + 24;
|
|
509
|
+
const titleMarkup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(trimText(node.name, 30))}</text>`;
|
|
510
|
+
const laneMarkup = node.lane
|
|
511
|
+
? `<text x="${layout.x + layout.width - 14}" y="${layout.y + 22}" class="lane-chip" text-anchor="end">${escapeHtml(trimText(node.lane, 12))}</text>`
|
|
512
|
+
: '';
|
|
513
|
+
cursorY += 18;
|
|
514
|
+
const subtitleMarkup = subtitleLines.map((line) => {
|
|
515
|
+
const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-subtitle">${escapeHtml(line)}</text>`;
|
|
516
|
+
cursorY += lineHeight;
|
|
517
|
+
return markup;
|
|
518
|
+
}).join('');
|
|
519
|
+
if (detailLines.length > 0) cursorY += 6;
|
|
520
|
+
const detailMarkup = detailLines.map((line) => {
|
|
521
|
+
const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="detail">${escapeHtml(line)}</text>`;
|
|
522
|
+
cursorY += lineHeight;
|
|
523
|
+
return markup;
|
|
524
|
+
}).join('');
|
|
339
525
|
|
|
340
526
|
return `
|
|
341
527
|
<g>
|
|
342
528
|
<rect x="${layout.x}" y="${layout.y}" width="${layout.width}" height="${layout.height}" rx="14" fill="${nodeTheme.fill}" fill-opacity="0.92" stroke="${nodeTheme.stroke}" stroke-width="1.5"></rect>
|
|
343
|
-
|
|
344
|
-
|
|
529
|
+
${titleMarkup}
|
|
530
|
+
${laneMarkup}
|
|
531
|
+
${subtitleMarkup}
|
|
345
532
|
${detailMarkup}
|
|
346
533
|
</g>
|
|
347
534
|
`;
|
|
348
535
|
}
|
|
349
536
|
|
|
350
537
|
function renderArrow(def) {
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
538
|
+
const isError = def.type === 'security' || def.type === 'error_path';
|
|
539
|
+
const dashed = isError ? 'stroke-dasharray="6,4"' : '';
|
|
540
|
+
const stroke = isError ? '#dc2626' : '#64748b';
|
|
541
|
+
const marker = isError ? 'arrowhead-error' : 'arrowhead';
|
|
542
|
+
const label = trimText(def.label, 24);
|
|
543
|
+
const labelMarkup = label && label !== '待补充'
|
|
544
|
+
? `<text x="${def.labelX}" y="${def.labelY}" class="flow-label" text-anchor="middle">${escapeHtml(label)}</text>`
|
|
545
|
+
: '';
|
|
546
|
+
return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="1.8" ${dashed} marker-end="url(#${marker})"></path>
|
|
547
|
+
${labelMarkup}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const DIAGRAM_ARROW_DEFS = `
|
|
551
|
+
<defs>
|
|
552
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
553
|
+
<polygon points="0 0, 10 5, 0 10" fill="#64748b"></polygon>
|
|
554
|
+
</marker>
|
|
555
|
+
<marker id="arrowhead-error" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
556
|
+
<polygon points="0 0, 10 5, 0 10" fill="#dc2626"></polygon>
|
|
557
|
+
</marker>
|
|
558
|
+
</defs>`;
|
|
559
|
+
|
|
560
|
+
function renderFlowEdges(layout) {
|
|
561
|
+
const labelSlots = new Map();
|
|
562
|
+
return layout.validEdges.map((edge, index) => {
|
|
563
|
+
const source = layout.positioned.get(edge.from);
|
|
564
|
+
const target = layout.positioned.get(edge.to);
|
|
565
|
+
if (!source || !target) return '';
|
|
566
|
+
const slotKey = `${source.layer}->${target.layer === source.layer ? `${edge.to}` : target.layer}`;
|
|
567
|
+
const slot = labelSlots.get(slotKey) ?? 0;
|
|
568
|
+
labelSlots.set(slotKey, slot + 1);
|
|
569
|
+
const routed = routeFlowEdge(source, target, index, slot * 16);
|
|
570
|
+
return renderArrow({ ...routed, label: edge.label, type: edge.type });
|
|
571
|
+
}).join('\n');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function renderLegend(entries, originY) {
|
|
575
|
+
let cursorX = 54;
|
|
576
|
+
const items = entries.map(({ color, label }) => {
|
|
577
|
+
const markup = `<rect x="${cursorX}" y="${originY + 14}" width="12" height="12" rx="3" fill="${color}"></rect><text x="${cursorX + 20}" y="${originY + 24}" class="legend-label">${escapeHtml(label)}</text>`;
|
|
578
|
+
cursorX += 20 + textUnits(label) * 7 + 36;
|
|
579
|
+
return markup;
|
|
580
|
+
}).join('');
|
|
581
|
+
return `
|
|
582
|
+
<g>
|
|
583
|
+
<text x="54" y="${originY}" class="legend-label">图例</text>
|
|
584
|
+
${items}
|
|
585
|
+
</g>
|
|
586
|
+
`;
|
|
355
587
|
}
|
|
356
588
|
|
|
357
589
|
function resolveProductLayerTitle(productType) {
|
|
@@ -363,7 +595,7 @@ function resolveProductLayerTitle(productType) {
|
|
|
363
595
|
|
|
364
596
|
function buildArchitectureComponents(snapshot) {
|
|
365
597
|
const { sections } = snapshot;
|
|
366
|
-
const reviewTarget = sections.handoff.targetSystem ?? '
|
|
598
|
+
const reviewTarget = sections.handoff.targetSystem ?? 'OpenPrd';
|
|
367
599
|
return [
|
|
368
600
|
{
|
|
369
601
|
id: 'users',
|
|
@@ -487,64 +719,66 @@ export function buildArchitectureDiagramModel(snapshot) {
|
|
|
487
719
|
productType: snapshot.productType ?? '未分类',
|
|
488
720
|
owner: snapshot.owner,
|
|
489
721
|
versionId: snapshot.versionId,
|
|
490
|
-
targetSystem: snapshot.sections.handoff.targetSystem ?? '
|
|
722
|
+
targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenPrd',
|
|
491
723
|
reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
|
|
492
724
|
},
|
|
493
725
|
};
|
|
494
726
|
}
|
|
495
727
|
|
|
496
728
|
export function renderArchitectureDiagramHtml(model) {
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
{ x: 610, y: 448, width: 290, height: 120 },
|
|
512
|
-
];
|
|
513
|
-
|
|
514
|
-
const arrows = [
|
|
515
|
-
{ path: 'M 540 144 C 540 182, 215 176, 215 228', label: model.flows[0]?.label ?? '用户流程', labelX: 312, labelY: 176, type: model.flows[0]?.type ?? 'standard' },
|
|
516
|
-
{ path: 'M 360 288 L 395 288', label: model.flows[1]?.label ?? '产品动作', labelX: 366, labelY: 276, type: model.flows[1]?.type ?? 'standard' },
|
|
517
|
-
{ path: 'M 685 288 L 720 288', label: model.flows[4]?.label ?? '成功标准', labelX: 694, labelY: 276, type: model.flows[4]?.type ?? 'standard' },
|
|
518
|
-
{ path: 'M 540 348 C 540 392, 325 396, 325 448', label: model.flows[2]?.label ?? '依赖', labelX: 300, labelY: 392, type: model.flows[2]?.type ?? 'standard' },
|
|
519
|
-
{ path: 'M 540 348 C 540 392, 755 396, 755 448', label: model.flows[3]?.label ?? '约束', labelX: 692, labelY: 392, type: model.flows[3]?.type ?? 'security' },
|
|
520
|
-
{ path: 'M 470 568 C 470 610, 820 610, 820 348', label: model.flows[5]?.label ?? '运营就绪', labelX: 596, labelY: 614, type: model.flows[5]?.type ?? 'standard' },
|
|
521
|
-
{ path: 'M 820 568 C 920 612, 920 416, 865 348', label: model.flows[6]?.label ?? '评审确认', labelX: 850, labelY: 612, type: model.flows[6]?.type ?? 'security' },
|
|
522
|
-
];
|
|
523
|
-
|
|
524
|
-
const componentMarkup = model.components
|
|
525
|
-
.map((component, index) => renderBox(component, layouts[component.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
|
|
729
|
+
const components = Array.isArray(model.components) ? model.components : [];
|
|
730
|
+
const flows = Array.isArray(model.flows) ? model.flows : [];
|
|
731
|
+
const layout = layoutFlowDiagram(components, flows.map((flow) => ({
|
|
732
|
+
from: flow.source,
|
|
733
|
+
to: flow.target,
|
|
734
|
+
label: flow.label,
|
|
735
|
+
type: flow.type,
|
|
736
|
+
})), { boxWidth: 256 });
|
|
737
|
+
|
|
738
|
+
const componentMarkup = components
|
|
739
|
+
.map((component) => {
|
|
740
|
+
const entry = layout.positioned.get(component.id);
|
|
741
|
+
return entry ? renderBox(component, entry) : '';
|
|
742
|
+
})
|
|
526
743
|
.join('\n');
|
|
527
|
-
const
|
|
744
|
+
const edgeMarkup = renderFlowEdges(layout);
|
|
745
|
+
|
|
746
|
+
const internalEntries = components
|
|
747
|
+
.filter((component) => component.type !== 'external')
|
|
748
|
+
.map((component) => layout.positioned.get(component.id))
|
|
749
|
+
.filter(Boolean);
|
|
750
|
+
let boundaryMarkup = '';
|
|
751
|
+
let boundaryBottom = layout.height - 96;
|
|
752
|
+
if (internalEntries.length > 0) {
|
|
753
|
+
const minX = Math.min(...internalEntries.map((entry) => entry.x)) - 26;
|
|
754
|
+
const minY = Math.min(...internalEntries.map((entry) => entry.y)) - 34;
|
|
755
|
+
const maxX = Math.max(...internalEntries.map((entry) => entry.x + entry.width)) + 26;
|
|
756
|
+
const maxY = Math.max(...internalEntries.map((entry) => entry.y + entry.height)) + 26;
|
|
757
|
+
boundaryBottom = Math.max(boundaryBottom, maxY);
|
|
758
|
+
boundaryMarkup = `
|
|
759
|
+
<rect x="${minX}" y="${minY}" width="${maxX - minX}" height="${maxY - minY}" rx="18" fill="none" stroke="#b45309" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
760
|
+
<text x="${minX + 18}" y="${minY + 22}" class="legend-label">方案边界</text>
|
|
761
|
+
`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const legendY = boundaryBottom + 34;
|
|
765
|
+
const legendMarkup = renderLegend([
|
|
766
|
+
{ color: '#2563eb', label: '体验' },
|
|
767
|
+
{ color: '#0f766e', label: '核心逻辑' },
|
|
768
|
+
{ color: '#4f46e5', label: '验证' },
|
|
769
|
+
{ color: '#b45309', label: '依赖' },
|
|
770
|
+
{ color: '#dc2626', label: '约束' },
|
|
771
|
+
{ color: '#94a3b8', label: '外部/用户' },
|
|
772
|
+
], legendY);
|
|
773
|
+
|
|
774
|
+
const svgHeight = legendY + 48;
|
|
528
775
|
const svgMarkup = `
|
|
529
|
-
<svg viewBox="0 0
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
</marker>
|
|
534
|
-
</defs>
|
|
535
|
-
<rect x="40" y="172" width="1000" height="430" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
536
|
-
<text x="58" y="194" class="legend-label">方案边界</text>
|
|
537
|
-
${arrowMarkup}
|
|
776
|
+
<svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
|
|
777
|
+
${DIAGRAM_ARROW_DEFS}
|
|
778
|
+
${boundaryMarkup}
|
|
779
|
+
${edgeMarkup}
|
|
538
780
|
${componentMarkup}
|
|
539
|
-
|
|
540
|
-
<text x="54" y="652" class="legend-label">图例</text>
|
|
541
|
-
<rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">体验</text>
|
|
542
|
-
<rect x="182" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="202" y="676" class="legend-label">核心逻辑</text>
|
|
543
|
-
<rect x="330" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="350" y="676" class="legend-label">验证</text>
|
|
544
|
-
<rect x="476" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="496" y="676" class="legend-label">依赖</text>
|
|
545
|
-
<rect x="640" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="660" y="676" class="legend-label">约束</text>
|
|
546
|
-
<rect x="798" y="666" width="12" height="12" rx="3" fill="#94a3b8"></rect><text x="818" y="676" class="legend-label">外部/用户</text>
|
|
547
|
-
</g>
|
|
781
|
+
${legendMarkup}
|
|
548
782
|
</svg>
|
|
549
783
|
`;
|
|
550
784
|
|
|
@@ -678,69 +912,54 @@ export function buildProductFlowDiagramModel(snapshot) {
|
|
|
678
912
|
productType: snapshot.productType ?? '未分类',
|
|
679
913
|
owner: snapshot.owner,
|
|
680
914
|
versionId: snapshot.versionId,
|
|
681
|
-
targetSystem: snapshot.sections.handoff.targetSystem ?? '
|
|
915
|
+
targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenPrd',
|
|
682
916
|
reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
|
|
683
917
|
},
|
|
684
918
|
};
|
|
685
919
|
}
|
|
686
920
|
|
|
687
921
|
export function renderProductFlowDiagramHtml(model) {
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const laneMarkup = [
|
|
704
|
-
{ y: 118, label: '用户/触发' },
|
|
705
|
-
{ y: 220, label: '核心流程' },
|
|
706
|
-
{ y: 438, label: '结果/恢复' },
|
|
707
|
-
].map((lane) => `
|
|
708
|
-
<g>
|
|
709
|
-
<line x1="70" y1="${lane.y}" x2="1020" y2="${lane.y}" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"></line>
|
|
710
|
-
<text x="74" y="${lane.y - 8}" class="legend-label">${escapeHtml(lane.label)}</text>
|
|
711
|
-
</g>
|
|
712
|
-
`).join('\n');
|
|
713
|
-
|
|
714
|
-
const stepMarkup = model.steps
|
|
715
|
-
.map((step, index) => renderBox(step, layouts[step.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
|
|
922
|
+
const steps = Array.isArray(model.steps) ? model.steps : [];
|
|
923
|
+
const transitions = Array.isArray(model.transitions) ? model.transitions : [];
|
|
924
|
+
const layout = layoutFlowDiagram(steps, transitions.map((transition) => ({
|
|
925
|
+
from: transition.from,
|
|
926
|
+
to: transition.to,
|
|
927
|
+
label: transition.label,
|
|
928
|
+
type: transition.type,
|
|
929
|
+
})));
|
|
930
|
+
|
|
931
|
+
const stepMarkup = steps
|
|
932
|
+
.map((step) => {
|
|
933
|
+
const entry = layout.positioned.get(step.id);
|
|
934
|
+
return entry ? renderBox(step, entry) : '';
|
|
935
|
+
})
|
|
716
936
|
.join('\n');
|
|
717
|
-
const
|
|
718
|
-
{ path: 'M 280 306 L 330 306', label: model.transitions[0]?.label ?? '开始', labelX: 288, labelY: 294, type: model.transitions[0]?.type ?? 'standard' },
|
|
719
|
-
{ path: 'M 540 306 L 590 306', label: model.transitions[1]?.label ?? '核心步骤', labelX: 546, labelY: 294, type: model.transitions[1]?.type ?? 'standard' },
|
|
720
|
-
{ path: 'M 770 282 C 800 240, 820 220, 820 196', label: model.transitions[2]?.label ?? '成功路径', labelX: 786, labelY: 226, type: model.transitions[2]?.type ?? 'standard' },
|
|
721
|
-
{ path: 'M 770 330 C 800 370, 820 400, 820 420', label: model.transitions[3]?.label ?? '失败路径', labelX: 786, labelY: 388, type: model.transitions[3]?.type ?? 'error_path' },
|
|
722
|
-
].map(renderArrow).join('\n');
|
|
937
|
+
const edgeMarkup = renderFlowEdges(layout);
|
|
723
938
|
|
|
939
|
+
const boundary = {
|
|
940
|
+
x: layout.config.marginX - 24,
|
|
941
|
+
y: layout.config.marginY - 36,
|
|
942
|
+
width: layout.width - (layout.config.marginX - 24) * 2,
|
|
943
|
+
height: layout.height - layout.config.marginY - 36,
|
|
944
|
+
};
|
|
945
|
+
const legendY = boundary.y + boundary.height + 30;
|
|
946
|
+
const legendMarkup = renderLegend([
|
|
947
|
+
{ color: '#2563eb', label: '用户动作' },
|
|
948
|
+
{ color: '#0f766e', label: '系统处理' },
|
|
949
|
+
{ color: '#b45309', label: '决策' },
|
|
950
|
+
{ color: '#4f46e5', label: '成功' },
|
|
951
|
+
{ color: '#dc2626', label: '错误/恢复' },
|
|
952
|
+
], legendY);
|
|
953
|
+
|
|
954
|
+
const svgHeight = legendY + 48;
|
|
724
955
|
const svgMarkup = `
|
|
725
|
-
<svg viewBox="0 0
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
</defs>
|
|
731
|
-
<rect x="56" y="92" width="968" height="520" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
732
|
-
<text x="74" y="118" class="legend-label">产品流程边界</text>
|
|
733
|
-
${laneMarkup}
|
|
734
|
-
${transitions}
|
|
956
|
+
<svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
|
|
957
|
+
${DIAGRAM_ARROW_DEFS}
|
|
958
|
+
<rect x="${boundary.x}" y="${boundary.y}" width="${boundary.width}" height="${boundary.height}" rx="18" fill="none" stroke="#b45309" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
959
|
+
<text x="${boundary.x + 18}" y="${boundary.y + 24}" class="legend-label">产品流程边界</text>
|
|
960
|
+
${edgeMarkup}
|
|
735
961
|
${stepMarkup}
|
|
736
|
-
|
|
737
|
-
<text x="54" y="652" class="legend-label">图例</text>
|
|
738
|
-
<rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">用户动作</text>
|
|
739
|
-
<rect x="196" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="216" y="676" class="legend-label">系统处理</text>
|
|
740
|
-
<rect x="372" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="392" y="676" class="legend-label">决策</text>
|
|
741
|
-
<rect x="516" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="536" y="676" class="legend-label">成功</text>
|
|
742
|
-
<rect x="648" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="668" y="676" class="legend-label">错误/恢复</text>
|
|
743
|
-
</g>
|
|
962
|
+
${legendMarkup}
|
|
744
963
|
</svg>
|
|
745
964
|
`;
|
|
746
965
|
|