@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.
@@ -88,17 +88,17 @@ function pickValue(primary, fallback) {
88
88
 
89
89
  function theme(type) {
90
90
  const themes = {
91
- frontend: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
92
- backend: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
93
- database: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
94
- cloud: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
95
- security: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
96
- external: { fill: '#0f172a', stroke: '#94a3b8', title: '#e2e8f0' },
97
- user_action: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
98
- system_process: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
99
- decision: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
100
- error_path: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
101
- success: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
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: dark;
249
- --bg: #020617;
250
- --panel: rgba(15, 23, 42, 0.92);
251
- --text: #e2e8f0;
252
- --muted: #94a3b8;
253
- --grid: rgba(148, 163, 184, 0.12);
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: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
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: #22d3ee;
272
- box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); animation: pulse 2s infinite;
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(34, 211, 238, 0.7); }
276
- 70% { box-shadow: 0 0 0 10px rgba(34, 211, 238, 0); }
277
- 100% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0); }
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 rgba(148, 163, 184, 0.24);
287
- background: rgba(15, 23, 42, 0.72);
288
- color: #cbd5e1;
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 rgba(148, 163, 184, 0.18); border-radius: 20px;
295
- padding: 20px; background: rgba(2, 6, 23, 0.72); backdrop-filter: blur(6px);
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: #cbd5e1; }
300
- .detail { fill: #94a3b8; }
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 rgba(148, 163, 184, 0.18); border-radius: 16px;
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: #cbd5e1; font-size: 12px; line-height: 1.65; }
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 detailLines = (node.details ?? []).slice(0, 4);
336
- const detailMarkup = detailLines.map((line, index) => (
337
- `<text x="${layout.x + 16}" y="${layout.y + 54 + (index * 16)}" class="detail">${escapeHtml(trimText(line, 42))}</text>`
338
- )).join('');
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
- <text x="${layout.x + 16}" y="${layout.y + 28}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(node.name)}</text>
344
- <text x="${layout.x + 16}" y="${layout.y + 44}" class="node-subtitle">${escapeHtml(trimText(node.subtitle, 48))}</text>
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 dashed = def.type === 'security' || def.type === 'error_path' ? 'stroke-dasharray="6,4"' : '';
352
- const stroke = def.type === 'security' || def.type === 'error_path' ? '#fb7185' : '#7dd3fc';
353
- return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="2" ${dashed} marker-end="url(#arrowhead)"></path>
354
- <text x="${def.labelX}" y="${def.labelY}" class="flow-label">${escapeHtml(def.label)}</text>`;
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 layouts = {
498
- users: { x: 390, y: 48, width: 300, height: 96 },
499
- experience: { x: 70, y: 228, width: 290, height: 120 },
500
- core: { x: 395, y: 228, width: 290, height: 120 },
501
- delivery: { x: 720, y: 228, width: 290, height: 120 },
502
- integrations: { x: 180, y: 448, width: 290, height: 120 },
503
- governance: { x: 610, y: 448, width: 290, height: 120 },
504
- };
505
- const fallbackLayouts = [
506
- { x: 390, y: 48, width: 300, height: 96 },
507
- { x: 70, y: 228, width: 290, height: 120 },
508
- { x: 395, y: 228, width: 290, height: 120 },
509
- { x: 720, y: 228, width: 290, height: 120 },
510
- { x: 180, y: 448, width: 290, height: 120 },
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 arrowMarkup = arrows.map(renderArrow).join('\n');
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 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
530
- <defs>
531
- <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
532
- <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
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
- <g>
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 layouts = {
689
- entry: { x: 90, y: 250, width: 190, height: 112 },
690
- experience: { x: 330, y: 250, width: 210, height: 112 },
691
- decision: { x: 590, y: 245, width: 180, height: 122 },
692
- success: { x: 820, y: 140, width: 180, height: 112 },
693
- failure: { x: 820, y: 360, width: 180, height: 122 },
694
- };
695
- const fallbackLayouts = [
696
- { x: 90, y: 250, width: 190, height: 112 },
697
- { x: 330, y: 250, width: 210, height: 112 },
698
- { x: 590, y: 245, width: 180, height: 122 },
699
- { x: 820, y: 140, width: 180, height: 112 },
700
- { x: 820, y: 360, width: 180, height: 122 },
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 transitions = [
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 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
726
- <defs>
727
- <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
728
- <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
729
- </marker>
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
- <g>
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