@openprd/cli 0.1.10 → 0.1.11
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/schema/prd.schema.yaml +15 -0
- package/.openprd/templates/base/intake.md +13 -0
- package/.openprd/templates/base/prd.md +15 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/skills/openprd-benchmark-router/SKILL.md +2 -1
- package/skills/openprd-requirement-intake/SKILL.md +3 -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-shared/SKILL.md +1 -1
- package/src/agent-integration.js +6 -6
- package/src/brainstorm-artifacts.js +156 -8
- package/src/brainstorm-presentation.js +14 -4
- package/src/brainstorm.js +165 -2
- package/src/cli/quality-print.js +9 -3
- package/src/codex-hook-runner-template.mjs +6 -6
- package/src/diagram-core.js +384 -151
- package/src/html-artifacts.js +403 -50
- package/src/knowledge.js +59 -12
- package/src/prd-core.js +101 -2
- package/src/quality-html-artifact.js +30 -5
- package/src/quality.js +20 -10
- package/src/workspace-core.js +38 -0
- package/src/workspace-workflow.js +126 -9
package/src/diagram-core.js
CHANGED
|
@@ -88,17 +88,17 @@ function pickValue(primary, fallback) {
|
|
|
88
88
|
|
|
89
89
|
function theme(type) {
|
|
90
90
|
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: '#
|
|
91
|
+
frontend: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
|
|
92
|
+
backend: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
|
|
93
|
+
database: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
|
|
94
|
+
cloud: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
|
|
95
|
+
security: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
|
|
96
|
+
external: { fill: '#f8fafc', stroke: '#94a3b8', title: '#475569' },
|
|
97
|
+
user_action: { fill: '#eff6ff', stroke: '#2563eb', title: '#1d4ed8' },
|
|
98
|
+
system_process: { fill: '#f0fdfa', stroke: '#0f766e', title: '#0f766e' },
|
|
99
|
+
decision: { fill: '#fffbeb', stroke: '#b45309', title: '#b45309' },
|
|
100
|
+
error_path: { fill: '#fef2f2', stroke: '#dc2626', title: '#b91c1c' },
|
|
101
|
+
success: { fill: '#eef2ff', stroke: '#4f46e5', title: '#4338ca' },
|
|
102
102
|
};
|
|
103
103
|
return themes[type] ?? themes.external;
|
|
104
104
|
}
|
|
@@ -245,36 +245,32 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
245
245
|
<title>${escapeHtml(title)}</title>
|
|
246
246
|
<style>
|
|
247
247
|
:root {
|
|
248
|
-
color-scheme:
|
|
249
|
-
--bg: #
|
|
250
|
-
--panel:
|
|
251
|
-
--
|
|
252
|
-
--
|
|
253
|
-
--
|
|
248
|
+
color-scheme: light;
|
|
249
|
+
--bg: #f6f8fb;
|
|
250
|
+
--panel: #ffffff;
|
|
251
|
+
--panel-soft: #f9fafb;
|
|
252
|
+
--text: #172033;
|
|
253
|
+
--muted: #667085;
|
|
254
|
+
--line: #d8dee8;
|
|
254
255
|
}
|
|
255
256
|
* { box-sizing: border-box; }
|
|
256
257
|
body {
|
|
257
258
|
margin: 0;
|
|
258
|
-
font-family:
|
|
259
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
259
260
|
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;
|
|
261
|
+
background: var(--bg);
|
|
266
262
|
}
|
|
267
263
|
.page { max-width: 1240px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
268
264
|
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
269
265
|
.header-copy { display: flex; flex-direction: column; gap: 4px; }
|
|
270
266
|
.pulse {
|
|
271
|
-
width: 12px; height: 12px; border-radius: 999px; background: #
|
|
272
|
-
box-shadow: 0 0 0 0 rgba(
|
|
267
|
+
width: 12px; height: 12px; border-radius: 999px; background: #2563eb;
|
|
268
|
+
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); animation: pulse 2s infinite;
|
|
273
269
|
}
|
|
274
270
|
@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(
|
|
271
|
+
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); }
|
|
272
|
+
70% { box-shadow: 0 0 0 10px rgba(37, 99, 235, 0); }
|
|
273
|
+
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
|
278
274
|
}
|
|
279
275
|
.project-chip {
|
|
280
276
|
display: inline-flex;
|
|
@@ -283,32 +279,37 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
283
279
|
width: fit-content;
|
|
284
280
|
padding: 4px 10px;
|
|
285
281
|
border-radius: 999px;
|
|
286
|
-
border: 1px solid
|
|
287
|
-
background:
|
|
288
|
-
color:
|
|
282
|
+
border: 1px solid var(--line);
|
|
283
|
+
background: var(--panel);
|
|
284
|
+
color: var(--muted);
|
|
289
285
|
font-size: 11px;
|
|
290
286
|
}
|
|
291
|
-
h1 { margin: 0; font-size: 28px; }
|
|
287
|
+
h1 { margin: 0; font-size: 28px; font-weight: 700; }
|
|
292
288
|
.subtitle-block { margin: 6px 0 0 24px; color: var(--muted); font-size: 13px; }
|
|
293
289
|
.diagram-shell {
|
|
294
|
-
margin-top: 24px; border: 1px solid
|
|
295
|
-
padding: 20px; background:
|
|
290
|
+
margin-top: 24px; border: 1px solid var(--line); border-radius: 20px;
|
|
291
|
+
padding: 20px; background: var(--panel);
|
|
292
|
+
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
|
|
296
293
|
}
|
|
297
294
|
svg { width: 100%; height: auto; display: block; }
|
|
298
295
|
.node-title { font-size: 13px; font-weight: 700; }
|
|
299
|
-
.node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #
|
|
300
|
-
.detail { fill: #
|
|
296
|
+
.node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #334155; }
|
|
297
|
+
.detail { fill: #667085; }
|
|
298
|
+
.lane-chip { font-size: 9px; fill: #94a3b8; }
|
|
299
|
+
.legend-label { fill: #667085; }
|
|
300
|
+
.flow-label { fill: #334155; paint-order: stroke; stroke: #ffffff; stroke-width: 4px; stroke-linejoin: round; }
|
|
301
301
|
.summary-grid, .side-grid { display: grid; gap: 16px; margin-top: 24px; }
|
|
302
302
|
.summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
303
303
|
.side-grid { grid-template-columns: 1fr 1fr; }
|
|
304
304
|
.card {
|
|
305
|
-
border: 1px solid
|
|
305
|
+
border: 1px solid var(--line); border-radius: 16px;
|
|
306
306
|
background: var(--panel); padding: 14px 16px;
|
|
307
|
+
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
|
307
308
|
}
|
|
308
|
-
.card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; }
|
|
309
|
+
.card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; font-weight: 600; }
|
|
309
310
|
.dot { width: 10px; height: 10px; border-radius: 999px; }
|
|
310
|
-
ul { padding-left: 18px; margin: 0; color: #
|
|
311
|
-
.footer { margin-top: 18px; color: var(--muted); }
|
|
311
|
+
ul { padding-left: 18px; margin: 0; color: #475467; font-size: 12px; line-height: 1.65; }
|
|
312
|
+
.footer { margin-top: 18px; color: var(--muted); font-size: 12px; }
|
|
312
313
|
</style>
|
|
313
314
|
</head>
|
|
314
315
|
<body>
|
|
@@ -330,28 +331,273 @@ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup,
|
|
|
330
331
|
</html>`;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
function textUnits(text) {
|
|
335
|
+
let units = 0;
|
|
336
|
+
for (const ch of `${text ?? ''}`) {
|
|
337
|
+
units += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1;
|
|
338
|
+
}
|
|
339
|
+
return units;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function wrapTextLines(text, maxUnits, maxLines = 3) {
|
|
343
|
+
const value = `${text ?? ''}`.trim().replace(/\s+/g, ' ');
|
|
344
|
+
if (!value) return [];
|
|
345
|
+
const lines = [];
|
|
346
|
+
let current = '';
|
|
347
|
+
let currentUnits = 0;
|
|
348
|
+
for (const ch of value) {
|
|
349
|
+
const chUnits = textUnits(ch);
|
|
350
|
+
if (currentUnits + chUnits > maxUnits && current) {
|
|
351
|
+
lines.push(current);
|
|
352
|
+
current = ch === ' ' ? '' : ch;
|
|
353
|
+
currentUnits = ch === ' ' ? 0 : chUnits;
|
|
354
|
+
if (lines.length === maxLines) break;
|
|
355
|
+
} else {
|
|
356
|
+
current += ch;
|
|
357
|
+
currentUnits += chUnits;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (lines.length < maxLines && current) {
|
|
361
|
+
lines.push(current);
|
|
362
|
+
} else if (lines.length === maxLines && current) {
|
|
363
|
+
const last = lines[maxLines - 1];
|
|
364
|
+
lines[maxLines - 1] = `${last.slice(0, Math.max(0, last.length - 1))}…`;
|
|
365
|
+
}
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const FLOW_LAYOUT_DEFAULTS = {
|
|
370
|
+
boxWidth: 232,
|
|
371
|
+
gapX: 96,
|
|
372
|
+
gapY: 30,
|
|
373
|
+
marginX: 72,
|
|
374
|
+
marginY: 96,
|
|
375
|
+
titleHeight: 30,
|
|
376
|
+
lineHeight: 15,
|
|
377
|
+
subtitleMaxLines: 3,
|
|
378
|
+
detailMaxItems: 4,
|
|
379
|
+
bottomPadding: 14,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
function assignLayers(nodes, edges) {
|
|
383
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
384
|
+
const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
|
|
385
|
+
const layers = new Map(nodes.map((node) => [node.id, 0]));
|
|
386
|
+
const maxIterations = Math.max(4, nodes.length * nodes.length);
|
|
387
|
+
let changed = true;
|
|
388
|
+
let iterations = 0;
|
|
389
|
+
while (changed && iterations < maxIterations) {
|
|
390
|
+
changed = false;
|
|
391
|
+
iterations += 1;
|
|
392
|
+
for (const edge of validEdges) {
|
|
393
|
+
const required = layers.get(edge.from) + 1;
|
|
394
|
+
if (layers.get(edge.to) < required && required <= nodes.length) {
|
|
395
|
+
layers.set(edge.to, required);
|
|
396
|
+
changed = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { layers, validEdges };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function layoutFlowDiagram(nodes, edges, options = {}) {
|
|
404
|
+
const config = { ...FLOW_LAYOUT_DEFAULTS, ...options };
|
|
405
|
+
const { layers, validEdges } = assignLayers(nodes, edges);
|
|
406
|
+
|
|
407
|
+
const positioned = new Map();
|
|
408
|
+
for (const node of nodes) {
|
|
409
|
+
const subtitle = `${node.subtitle ?? ''}`.trim() === '待补充' ? '' : node.subtitle;
|
|
410
|
+
const subtitleLines = wrapTextLines(subtitle, 34, config.subtitleMaxLines);
|
|
411
|
+
const detailLines = (node.details ?? []).slice(0, config.detailMaxItems)
|
|
412
|
+
.filter((line) => `${line ?? ''}`.trim() !== '待补充')
|
|
413
|
+
.map((line) => wrapTextLines(line, 34, 1)[0])
|
|
414
|
+
.filter(Boolean);
|
|
415
|
+
const height = config.titleHeight
|
|
416
|
+
+ (subtitleLines.length * config.lineHeight)
|
|
417
|
+
+ (detailLines.length > 0 ? 6 + detailLines.length * config.lineHeight : 0)
|
|
418
|
+
+ config.bottomPadding;
|
|
419
|
+
positioned.set(node.id, {
|
|
420
|
+
node,
|
|
421
|
+
subtitleLines,
|
|
422
|
+
detailLines,
|
|
423
|
+
width: config.boxWidth,
|
|
424
|
+
height: Math.max(height, 64),
|
|
425
|
+
layer: layers.get(node.id) ?? 0,
|
|
426
|
+
x: 0,
|
|
427
|
+
y: 0,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const columns = new Map();
|
|
432
|
+
for (const entry of positioned.values()) {
|
|
433
|
+
if (!columns.has(entry.layer)) columns.set(entry.layer, []);
|
|
434
|
+
columns.get(entry.layer).push(entry);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const incoming = new Map();
|
|
438
|
+
for (const edge of validEdges) {
|
|
439
|
+
if (!incoming.has(edge.to)) incoming.set(edge.to, []);
|
|
440
|
+
incoming.get(edge.to).push(edge.from);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const layerKeys = [...columns.keys()].sort((a, b) => a - b);
|
|
444
|
+
let maxColumnHeight = 0;
|
|
445
|
+
for (const key of layerKeys) {
|
|
446
|
+
const column = columns.get(key);
|
|
447
|
+
column.sort((a, b) => {
|
|
448
|
+
const aPreds = incoming.get(a.node.id) ?? [];
|
|
449
|
+
const bPreds = incoming.get(b.node.id) ?? [];
|
|
450
|
+
const avg = (preds) => {
|
|
451
|
+
const ys = preds.map((id) => positioned.get(id)?.y ?? 0);
|
|
452
|
+
return ys.length > 0 ? ys.reduce((sum, y) => sum + y, 0) / ys.length : Number.MAX_SAFE_INTEGER;
|
|
453
|
+
};
|
|
454
|
+
const diff = avg(aPreds) - avg(bPreds);
|
|
455
|
+
if (diff !== 0) return diff;
|
|
456
|
+
return (a.node.type === 'error_path' ? 1 : 0) - (b.node.type === 'error_path' ? 1 : 0);
|
|
457
|
+
});
|
|
458
|
+
const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
|
|
459
|
+
maxColumnHeight = Math.max(maxColumnHeight, columnHeight);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const [index, key] of layerKeys.entries()) {
|
|
463
|
+
const column = columns.get(key);
|
|
464
|
+
const columnHeight = column.reduce((sum, entry) => sum + entry.height, 0) + Math.max(0, column.length - 1) * config.gapY;
|
|
465
|
+
let cursorY = config.marginY + (maxColumnHeight - columnHeight) / 2;
|
|
466
|
+
for (const entry of column) {
|
|
467
|
+
entry.x = config.marginX + index * (config.boxWidth + config.gapX);
|
|
468
|
+
entry.y = cursorY;
|
|
469
|
+
cursorY += entry.height + config.gapY;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const width = config.marginX * 2 + layerKeys.length * config.boxWidth + Math.max(0, layerKeys.length - 1) * config.gapX;
|
|
474
|
+
const height = config.marginY + maxColumnHeight + 96;
|
|
475
|
+
return { positioned, validEdges, width: Math.max(width, 760), height: Math.max(height, 360), config };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function routeFlowEdge(source, target, index, laneOffset) {
|
|
479
|
+
if (target.layer > source.layer) {
|
|
480
|
+
const startX = source.x + source.width;
|
|
481
|
+
const startY = source.y + source.height / 2;
|
|
482
|
+
const endX = target.x;
|
|
483
|
+
const endY = target.y + target.height / 2;
|
|
484
|
+
const midX = (startX + endX) / 2;
|
|
485
|
+
return {
|
|
486
|
+
path: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
|
|
487
|
+
labelX: midX,
|
|
488
|
+
labelY: (startY + endY) / 2 - 8 - laneOffset,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (target.layer === source.layer) {
|
|
492
|
+
const downward = target.y > source.y;
|
|
493
|
+
const startX = source.x + source.width / 2;
|
|
494
|
+
const startY = downward ? source.y + source.height : source.y;
|
|
495
|
+
const endX = target.x + target.width / 2;
|
|
496
|
+
const endY = downward ? target.y : target.y + target.height;
|
|
497
|
+
const midY = (startY + endY) / 2;
|
|
498
|
+
return {
|
|
499
|
+
path: `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`,
|
|
500
|
+
labelX: (startX + endX) / 2 + 10,
|
|
501
|
+
labelY: midY - laneOffset,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const startX = source.x;
|
|
505
|
+
const startY = source.y + source.height / 2;
|
|
506
|
+
const endX = target.x + target.width;
|
|
507
|
+
const endY = target.y + target.height / 2;
|
|
508
|
+
const drop = Math.max(startY, endY) + 56 + index * 14;
|
|
509
|
+
return {
|
|
510
|
+
path: `M ${startX} ${startY} C ${startX - 60} ${drop}, ${endX + 60} ${drop}, ${endX} ${endY}`,
|
|
511
|
+
labelX: (startX + endX) / 2,
|
|
512
|
+
labelY: drop - 8,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
333
516
|
function renderBox(node, layout) {
|
|
334
517
|
const nodeTheme = theme(node.type);
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
518
|
+
const subtitleLines = layout.subtitleLines ?? wrapTextLines(node.subtitle, 34, 3);
|
|
519
|
+
const detailLines = layout.detailLines
|
|
520
|
+
?? (node.details ?? []).slice(0, 4).map((line) => wrapTextLines(line, 34, 1)[0]).filter(Boolean);
|
|
521
|
+
const lineHeight = 15;
|
|
522
|
+
let cursorY = layout.y + 24;
|
|
523
|
+
const titleMarkup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(trimText(node.name, 30))}</text>`;
|
|
524
|
+
const laneMarkup = node.lane
|
|
525
|
+
? `<text x="${layout.x + layout.width - 14}" y="${layout.y + 22}" class="lane-chip" text-anchor="end">${escapeHtml(trimText(node.lane, 12))}</text>`
|
|
526
|
+
: '';
|
|
527
|
+
cursorY += 18;
|
|
528
|
+
const subtitleMarkup = subtitleLines.map((line) => {
|
|
529
|
+
const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="node-subtitle">${escapeHtml(line)}</text>`;
|
|
530
|
+
cursorY += lineHeight;
|
|
531
|
+
return markup;
|
|
532
|
+
}).join('');
|
|
533
|
+
if (detailLines.length > 0) cursorY += 6;
|
|
534
|
+
const detailMarkup = detailLines.map((line) => {
|
|
535
|
+
const markup = `<text x="${layout.x + 16}" y="${cursorY}" class="detail">${escapeHtml(line)}</text>`;
|
|
536
|
+
cursorY += lineHeight;
|
|
537
|
+
return markup;
|
|
538
|
+
}).join('');
|
|
339
539
|
|
|
340
540
|
return `
|
|
341
541
|
<g>
|
|
342
542
|
<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
|
-
|
|
543
|
+
${titleMarkup}
|
|
544
|
+
${laneMarkup}
|
|
545
|
+
${subtitleMarkup}
|
|
345
546
|
${detailMarkup}
|
|
346
547
|
</g>
|
|
347
548
|
`;
|
|
348
549
|
}
|
|
349
550
|
|
|
350
551
|
function renderArrow(def) {
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
552
|
+
const isError = def.type === 'security' || def.type === 'error_path';
|
|
553
|
+
const dashed = isError ? 'stroke-dasharray="6,4"' : '';
|
|
554
|
+
const stroke = isError ? '#dc2626' : '#64748b';
|
|
555
|
+
const marker = isError ? 'arrowhead-error' : 'arrowhead';
|
|
556
|
+
const label = trimText(def.label, 24);
|
|
557
|
+
const labelMarkup = label && label !== '待补充'
|
|
558
|
+
? `<text x="${def.labelX}" y="${def.labelY}" class="flow-label" text-anchor="middle">${escapeHtml(label)}</text>`
|
|
559
|
+
: '';
|
|
560
|
+
return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="1.8" ${dashed} marker-end="url(#${marker})"></path>
|
|
561
|
+
${labelMarkup}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const DIAGRAM_ARROW_DEFS = `
|
|
565
|
+
<defs>
|
|
566
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
567
|
+
<polygon points="0 0, 10 5, 0 10" fill="#64748b"></polygon>
|
|
568
|
+
</marker>
|
|
569
|
+
<marker id="arrowhead-error" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
570
|
+
<polygon points="0 0, 10 5, 0 10" fill="#dc2626"></polygon>
|
|
571
|
+
</marker>
|
|
572
|
+
</defs>`;
|
|
573
|
+
|
|
574
|
+
function renderFlowEdges(layout) {
|
|
575
|
+
const labelSlots = new Map();
|
|
576
|
+
return layout.validEdges.map((edge, index) => {
|
|
577
|
+
const source = layout.positioned.get(edge.from);
|
|
578
|
+
const target = layout.positioned.get(edge.to);
|
|
579
|
+
if (!source || !target) return '';
|
|
580
|
+
const slotKey = `${source.layer}->${target.layer === source.layer ? `${edge.to}` : target.layer}`;
|
|
581
|
+
const slot = labelSlots.get(slotKey) ?? 0;
|
|
582
|
+
labelSlots.set(slotKey, slot + 1);
|
|
583
|
+
const routed = routeFlowEdge(source, target, index, slot * 16);
|
|
584
|
+
return renderArrow({ ...routed, label: edge.label, type: edge.type });
|
|
585
|
+
}).join('\n');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function renderLegend(entries, originY) {
|
|
589
|
+
let cursorX = 54;
|
|
590
|
+
const items = entries.map(({ color, label }) => {
|
|
591
|
+
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>`;
|
|
592
|
+
cursorX += 20 + textUnits(label) * 7 + 36;
|
|
593
|
+
return markup;
|
|
594
|
+
}).join('');
|
|
595
|
+
return `
|
|
596
|
+
<g>
|
|
597
|
+
<text x="54" y="${originY}" class="legend-label">图例</text>
|
|
598
|
+
${items}
|
|
599
|
+
</g>
|
|
600
|
+
`;
|
|
355
601
|
}
|
|
356
602
|
|
|
357
603
|
function resolveProductLayerTitle(productType) {
|
|
@@ -494,57 +740,59 @@ export function buildArchitectureDiagramModel(snapshot) {
|
|
|
494
740
|
}
|
|
495
741
|
|
|
496
742
|
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)))
|
|
743
|
+
const components = Array.isArray(model.components) ? model.components : [];
|
|
744
|
+
const flows = Array.isArray(model.flows) ? model.flows : [];
|
|
745
|
+
const layout = layoutFlowDiagram(components, flows.map((flow) => ({
|
|
746
|
+
from: flow.source,
|
|
747
|
+
to: flow.target,
|
|
748
|
+
label: flow.label,
|
|
749
|
+
type: flow.type,
|
|
750
|
+
})), { boxWidth: 256 });
|
|
751
|
+
|
|
752
|
+
const componentMarkup = components
|
|
753
|
+
.map((component) => {
|
|
754
|
+
const entry = layout.positioned.get(component.id);
|
|
755
|
+
return entry ? renderBox(component, entry) : '';
|
|
756
|
+
})
|
|
526
757
|
.join('\n');
|
|
527
|
-
const
|
|
758
|
+
const edgeMarkup = renderFlowEdges(layout);
|
|
759
|
+
|
|
760
|
+
const internalEntries = components
|
|
761
|
+
.filter((component) => component.type !== 'external')
|
|
762
|
+
.map((component) => layout.positioned.get(component.id))
|
|
763
|
+
.filter(Boolean);
|
|
764
|
+
let boundaryMarkup = '';
|
|
765
|
+
let boundaryBottom = layout.height - 96;
|
|
766
|
+
if (internalEntries.length > 0) {
|
|
767
|
+
const minX = Math.min(...internalEntries.map((entry) => entry.x)) - 26;
|
|
768
|
+
const minY = Math.min(...internalEntries.map((entry) => entry.y)) - 34;
|
|
769
|
+
const maxX = Math.max(...internalEntries.map((entry) => entry.x + entry.width)) + 26;
|
|
770
|
+
const maxY = Math.max(...internalEntries.map((entry) => entry.y + entry.height)) + 26;
|
|
771
|
+
boundaryBottom = Math.max(boundaryBottom, maxY);
|
|
772
|
+
boundaryMarkup = `
|
|
773
|
+
<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>
|
|
774
|
+
<text x="${minX + 18}" y="${minY + 22}" class="legend-label">方案边界</text>
|
|
775
|
+
`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const legendY = boundaryBottom + 34;
|
|
779
|
+
const legendMarkup = renderLegend([
|
|
780
|
+
{ color: '#2563eb', label: '体验' },
|
|
781
|
+
{ color: '#0f766e', label: '核心逻辑' },
|
|
782
|
+
{ color: '#4f46e5', label: '验证' },
|
|
783
|
+
{ color: '#b45309', label: '依赖' },
|
|
784
|
+
{ color: '#dc2626', label: '约束' },
|
|
785
|
+
{ color: '#94a3b8', label: '外部/用户' },
|
|
786
|
+
], legendY);
|
|
787
|
+
|
|
788
|
+
const svgHeight = legendY + 48;
|
|
528
789
|
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}
|
|
790
|
+
<svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
|
|
791
|
+
${DIAGRAM_ARROW_DEFS}
|
|
792
|
+
${boundaryMarkup}
|
|
793
|
+
${edgeMarkup}
|
|
538
794
|
${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>
|
|
795
|
+
${legendMarkup}
|
|
548
796
|
</svg>
|
|
549
797
|
`;
|
|
550
798
|
|
|
@@ -685,62 +933,47 @@ export function buildProductFlowDiagramModel(snapshot) {
|
|
|
685
933
|
}
|
|
686
934
|
|
|
687
935
|
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)))
|
|
936
|
+
const steps = Array.isArray(model.steps) ? model.steps : [];
|
|
937
|
+
const transitions = Array.isArray(model.transitions) ? model.transitions : [];
|
|
938
|
+
const layout = layoutFlowDiagram(steps, transitions.map((transition) => ({
|
|
939
|
+
from: transition.from,
|
|
940
|
+
to: transition.to,
|
|
941
|
+
label: transition.label,
|
|
942
|
+
type: transition.type,
|
|
943
|
+
})));
|
|
944
|
+
|
|
945
|
+
const stepMarkup = steps
|
|
946
|
+
.map((step) => {
|
|
947
|
+
const entry = layout.positioned.get(step.id);
|
|
948
|
+
return entry ? renderBox(step, entry) : '';
|
|
949
|
+
})
|
|
716
950
|
.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');
|
|
951
|
+
const edgeMarkup = renderFlowEdges(layout);
|
|
723
952
|
|
|
953
|
+
const boundary = {
|
|
954
|
+
x: layout.config.marginX - 24,
|
|
955
|
+
y: layout.config.marginY - 36,
|
|
956
|
+
width: layout.width - (layout.config.marginX - 24) * 2,
|
|
957
|
+
height: layout.height - layout.config.marginY - 36,
|
|
958
|
+
};
|
|
959
|
+
const legendY = boundary.y + boundary.height + 30;
|
|
960
|
+
const legendMarkup = renderLegend([
|
|
961
|
+
{ color: '#2563eb', label: '用户动作' },
|
|
962
|
+
{ color: '#0f766e', label: '系统处理' },
|
|
963
|
+
{ color: '#b45309', label: '决策' },
|
|
964
|
+
{ color: '#4f46e5', label: '成功' },
|
|
965
|
+
{ color: '#dc2626', label: '错误/恢复' },
|
|
966
|
+
], legendY);
|
|
967
|
+
|
|
968
|
+
const svgHeight = legendY + 48;
|
|
724
969
|
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}
|
|
970
|
+
<svg viewBox="0 0 ${layout.width} ${svgHeight}" role="img" aria-label="${escapeHtml(model.title)}">
|
|
971
|
+
${DIAGRAM_ARROW_DEFS}
|
|
972
|
+
<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>
|
|
973
|
+
<text x="${boundary.x + 18}" y="${boundary.y + 24}" class="legend-label">产品流程边界</text>
|
|
974
|
+
${edgeMarkup}
|
|
735
975
|
${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>
|
|
976
|
+
${legendMarkup}
|
|
744
977
|
</svg>
|
|
745
978
|
`;
|
|
746
979
|
|