diff --git a/RuoYi-Vue/easycode-web/src/views/DiagramCenterView.vue b/RuoYi-Vue/easycode-web/src/views/DiagramCenterView.vue index 6ea6dd6..79ac249 100644 --- a/RuoYi-Vue/easycode-web/src/views/DiagramCenterView.vue +++ b/RuoYi-Vue/easycode-web/src/views/DiagramCenterView.vue @@ -15,12 +15,13 @@
+
+
+
+ {{ useCaseDiagram.title }} + {{ useCaseStatsText }} +
+
+ SVG + PNG +
+
+ +
+ + +
+
+ {{ warning }} +
+ +
+ + + + + + + + + {{ useCaseDiagram.title }} + + + + + + + {{ relation.label }} + + + + + + + + + {{ actor.name }} + + + + + + + {{ item.text }} + + + +
+
+
+
+ +
+
+
+ {{ sequenceDiagram.title }} + {{ sequenceStatsText }} +
+
+ SVG + PNG +
+
+ +
+ + +
+
+ {{ error }} +
+ +
+ + + + + + + + + + + + {{ sequenceDiagram.title }} + + + + + + + + + + + + {{ frame.type }} + + + {{ sequenceFrameLabel(frame) }} + + + + [{{ frame.elseLabel || '否则' }}] + + + + + + + + + + + + + {{ shortText(message.text, 28) }} + + + + + + + + + + + +
+
+
+
+ route.params.projectId) +const DIAGRAM_QUERY_ALIASES = { + er: 'er', + module: 'module', + 'module-diagram': 'module', + architecture: 'architecture', + flowchart: 'flowchart', + use_case: 'use_case', + usecase: 'use_case', + sequence: 'sequence', + three_line_table: 'three_line_table', + ai: 'ai' +} + +function normalizeInitialDiagram(value) { + const key = Array.isArray(value) ? value[0] : value + return DIAGRAM_QUERY_ALIASES[String(key || '')] || 'three_line_table' +} + const loading = ref(false) const savingDiagram = ref(false) const generatingAiDiagram = ref(false) -const activeDiagram = ref('er') +const activeDiagram = ref(normalizeInitialDiagram(route.query.diagram)) const project = reactive({}) const database = ref({ tables: [], erDiagram: {} }) const erDraft = ref({ positions: {}, relations: [], deletedEdgeIds: [] }) @@ -506,6 +816,8 @@ const architectureSvgRef = ref(null) const architectureDsl = ref('') const architectureBlackWhite = ref(false) const flowchartDsl = ref('') +const useCaseDsl = ref('') +const sequenceDsl = ref('') const savedDiagrams = ref([]) const moduleDiagramSourceMode = ref('blueprint') const moduleDiagramOutlineText = ref('') @@ -543,14 +855,27 @@ const flowchartStatsText = computed(() => { return `${stats.nodeCount} 节点 · ${stats.edgeCount} 连线` }) const flowchartLabeledEdges = computed(() => flowchartDiagram.value.edges.filter((edge) => edge.label)) +const parsedUseCase = computed(() => parseUseCaseDsl(useCaseDsl.value)) +const useCaseDiagram = computed(() => buildUseCaseDiagram(parsedUseCase.value, { + title: `${project.projectName || '项目'}用例图` +})) +const useCaseStatsText = computed(() => { + const stats = useCaseDiagram.value.stats + return `${stats.actorCount} 参与者 · ${stats.useCaseCount} 用例` +}) +const parsedSequence = computed(() => parseSequenceDsl(sequenceDsl.value)) +const sequenceDiagram = computed(() => buildSequenceDiagram(parsedSequence.value, { + title: `${project.projectName || '项目'}时序图` +})) +const sequenceStatsText = computed(() => { + const stats = sequenceDiagram.value.stats + return `${stats.participantCount} 参与者 · ${stats.messageCount} 消息` +}) const currentGraph = computed(() => { if (activeDiagram.value === 'ai') { return normalizeGraph(aiDiagram.value, 'AI 图表') } - if (activeDiagram.value === 'overview') { - return buildOverviewGraph() - } return buildArchitectureGraph() }) @@ -618,6 +943,31 @@ function flowchartTextY(node, lineIndex) { return node.y + node.height / 2 - ((lines.length - 1) * 8) + lineIndex * 17 } +function sequenceFrameTagPath(frame) { + const width = frame.type === 'loop' ? 46 : 42 + return `M ${frame.x} ${frame.y} H ${frame.x + width} L ${frame.x + width - 12} ${frame.y + 24} H ${frame.x} Z` +} + +function sequenceFrameLabel(frame) { + return `[${frame.label || (frame.type === 'loop' ? '循环' : '条件分支')}]` +} + +function sequenceActorPath(participant) { + const x = participant.x + return `M ${x} 87 L ${x} 113 M ${x - 16} 96 L ${x + 16} 96 M ${x} 113 L ${x - 15} 132 M ${x} 113 L ${x + 15} 132` +} + +function useCaseRelationPath(relation) { + return relation.path || '' +} + +function useCaseActorPath(actor) { + const cx = actor.x + actor.width / 2 + const headBottom = actor.y + 23 + const bodyBottom = actor.y + 56 + return `M ${cx} ${headBottom} L ${cx} ${bodyBottom} M ${cx - 24} ${actor.y + 36} L ${cx + 24} ${actor.y + 36} M ${cx} ${bodyBottom} L ${cx - 22} ${actor.y + 82} M ${cx} ${bodyBottom} L ${cx + 22} ${actor.y + 82}` +} + function normalizeGraph(source, fallbackTitle) { const nodes = Array.isArray(source?.nodes) ? source.nodes : [] const positionedNodes = nodes.map((node, index) => ({ @@ -669,33 +1019,6 @@ function buildArchitectureGraph() { }, '系统架构图') } -function buildOverviewGraph() { - const tableNodes = database.value.tables.slice(0, 8).map((table) => ({ - id: `table_${table.tableName}`, - label: table.tableComment || table.tableName, - lane: table.tableName - })) - return normalizeGraph({ - title: '项目总览图', - description: '汇总项目、模块、数据库和预览生成链路。', - nodes: [ - { id: 'project', label: project.projectName || '项目草稿', lane: '需求' }, - { id: 'blueprint', label: '系统模块设计', lane: '蓝图' }, - { id: 'loop', label: '业务闭环设计', lane: '流程' }, - { id: 'database', label: '数据库设计', lane: `${database.value.tables.length || 0} 张表` }, - { id: 'preview', label: '源码预览/下载', lane: '交付' }, - ...tableNodes - ], - edges: [ - { source: 'project', target: 'blueprint', label: '规划' }, - { source: 'blueprint', target: 'loop', label: '闭环' }, - { source: 'loop', target: 'database', label: '落表' }, - { source: 'database', target: 'preview', label: '生成' }, - ...tableNodes.map((node) => ({ source: 'database', target: node.id, label: '包含' })) - ] - }, '项目总览图') -} - async function loadData() { if (!projectId.value) return loading.value = true @@ -707,7 +1030,9 @@ async function loadData() { ]) Object.assign(project, projectResult || {}) database.value = normalizeDatabase(databaseResult) - savedDiagrams.value = Array.isArray(diagramsResult) ? diagramsResult : [] + savedDiagrams.value = Array.isArray(diagramsResult) + ? diagramsResult.filter((diagram) => diagram.diagramType !== 'overview') + : [] applySavedDrafts() aiForm.title = `${project.projectName || '项目'}业务流程图` } catch (error) { @@ -782,7 +1107,19 @@ function applySavedDrafts() { } else { flowchartDsl.value = buildDefaultFlowchartDsl(project) } - const savedAi = savedDiagrams.value.find((item) => isAiDiagramType(item.diagramType)) + const savedUseCase = savedDiagrams.value.find((item) => item.diagramType === 'use_case') + if (savedUseCase) { + applySavedUseCaseDiagram(savedUseCase) + } else { + useCaseDsl.value = buildDefaultUseCaseDsl(project) + } + const savedSequence = savedDiagrams.value.find((item) => item.diagramType === 'sequence') + if (savedSequence) { + applySavedSequenceDiagram(savedSequence) + } else { + sequenceDsl.value = buildDefaultSequenceDsl(project) + } + const savedAi = savedDiagrams.value.find((item) => item.diagramType !== 'use_case' && isAiDiagramType(item.diagramType)) if (savedAi) { applySavedAiDiagram(savedAi) } @@ -807,6 +1144,16 @@ function loadSavedDiagram(diagram) { applySavedFlowchartDiagram(diagram) return } + if (diagram.diagramType === 'use_case') { + activeDiagram.value = 'use_case' + applySavedUseCaseDiagram(diagram) + return + } + if (diagram.diagramType === 'sequence') { + activeDiagram.value = 'sequence' + applySavedSequenceDiagram(diagram) + return + } if (diagram.diagramType === 'module') { activeDiagram.value = 'module' applySavedModuleDiagram(diagram) @@ -844,6 +1191,20 @@ function applySavedFlowchartDiagram(diagram) { : buildDefaultFlowchartDsl(project) } +function applySavedUseCaseDiagram(diagram) { + const payload = parseJson(diagram.diagramJson, {}) + useCaseDsl.value = typeof payload.dsl === 'string' + ? payload.dsl + : buildDefaultUseCaseDsl(project) +} + +function applySavedSequenceDiagram(diagram) { + const payload = parseJson(diagram.diagramJson, {}) + sequenceDsl.value = typeof payload.dsl === 'string' + ? payload.dsl + : buildDefaultSequenceDsl(project) +} + function applySavedAiDiagram(diagram) { const payload = parseJson(diagram.diagramJson, {}) aiDiagram.value = { @@ -960,6 +1321,32 @@ function currentDiagramPayload() { }) } } + if (activeDiagram.value === 'use_case') { + const existing = savedDiagrams.value.find((item) => item.diagramType === 'use_case') + return { + diagramId: existing?.diagramId, + diagramType: 'use_case', + title: '用例图', + description: 'DSL 驱动的 UML 用例图草稿', + diagramJson: JSON.stringify({ + dsl: useCaseDsl.value, + graph: useCaseDiagram.value + }) + } + } + if (activeDiagram.value === 'sequence') { + const existing = savedDiagrams.value.find((item) => item.diagramType === 'sequence') + return { + diagramId: existing?.diagramId, + diagramType: 'sequence', + title: '时序图', + description: 'DSL 驱动的系统交互时序图草稿', + diagramJson: JSON.stringify({ + dsl: sequenceDsl.value, + graph: sequenceDiagram.value + }) + } + } if (activeDiagram.value === 'three_line_table') { const existing = savedDiagrams.value.find((item) => item.diagramType === 'three_line_table') return { @@ -1063,6 +1450,54 @@ function refreshFlowchartPreview() { ElMessage.success('预览已更新') } +function clearUseCaseDsl() { + useCaseDsl.value = '' +} + +function loadUseCaseExample() { + useCaseDsl.value = USE_CASE_EXAMPLE_DSL + ElMessage.success('示例 DSL 已加载') +} + +function regenerateUseCaseDsl() { + useCaseDsl.value = buildDefaultUseCaseDsl(project) + ElMessage.success('已根据当前项目重新生成用例图 DSL') +} + +function refreshUseCasePreview() { + if (!useCaseDiagram.value.useCases.length) { + ElMessage.warning('请先在 DSL 中添加用例') + return + } + ElMessage.success('预览已更新') +} + +function clearSequenceDsl() { + sequenceDsl.value = '' +} + +function loadSequenceExample() { + sequenceDsl.value = SEQUENCE_EXAMPLE_DSL + ElMessage.success('示例 DSL 已加载') +} + +function regenerateSequenceDsl() { + sequenceDsl.value = buildDefaultSequenceDsl(project) + ElMessage.success('已根据当前项目重新生成时序图 DSL') +} + +function refreshSequencePreview() { + if (!sequenceDiagram.value.messages.length) { + ElMessage.warning('请先在 DSL 中添加时序消息') + return + } + if (parsedSequence.value.errors.length) { + ElMessage.warning('DSL 还有格式提示,请修正后再使用') + return + } + ElMessage.success('预览已更新') +} + function graphSvgText() { const svg = graphSvgRef.value if (!svg) return '' @@ -1091,6 +1526,26 @@ function downloadFlowchartSvg() { saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${flowchartDiagram.value.title || '流程图'}.svg`) } +function useCaseSvgText() { + return buildUseCaseExportSvg(useCaseDiagram.value) +} + +function downloadUseCaseSvg() { + const text = useCaseSvgText() + if (!text) return + saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${useCaseDiagram.value.title || '用例图'}.svg`) +} + +function sequenceSvgText() { + return buildSequenceExportSvg(sequenceDiagram.value) +} + +function downloadSequenceSvg() { + const text = sequenceSvgText() + if (!text) return + saveBlob(new Blob([text], { type: 'image/svg+xml;charset=utf-8' }), `${sequenceDiagram.value.title || '时序图'}.svg`) +} + async function downloadArchitecturePng() { const text = architectureSvgText() if (!text) return @@ -1149,6 +1604,64 @@ async function downloadFlowchartPng() { } } +async function downloadUseCasePng() { + const text = useCaseSvgText() + if (!text) return + const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const image = new Image() + image.decoding = 'async' + try { + await new Promise((resolve, reject) => { + image.onload = resolve + image.onerror = reject + image.src = url + }) + const canvas = document.createElement('canvas') + canvas.width = useCaseDiagram.value.canvas.width + canvas.height = useCaseDiagram.value.canvas.height + const context = canvas.getContext('2d') + context.fillStyle = '#ffffff' + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(image, 0, 0) + const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + if (pngBlob) { + saveBlob(pngBlob, `${useCaseDiagram.value.title || '用例图'}.png`) + } + } finally { + URL.revokeObjectURL(url) + } +} + +async function downloadSequencePng() { + const text = sequenceSvgText() + if (!text) return + const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const image = new Image() + image.decoding = 'async' + try { + await new Promise((resolve, reject) => { + image.onload = resolve + image.onerror = reject + image.src = url + }) + const canvas = document.createElement('canvas') + canvas.width = sequenceDiagram.value.canvas.width + canvas.height = sequenceDiagram.value.canvas.height + const context = canvas.getContext('2d') + context.fillStyle = '#ffffff' + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(image, 0, 0) + const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + if (pngBlob) { + saveBlob(pngBlob, `${sequenceDiagram.value.title || '时序图'}.png`) + } + } finally { + URL.revokeObjectURL(url) + } +} + function downloadCurrentSvg() { const text = graphSvgText() if (!text) return @@ -1191,6 +1704,8 @@ onMounted(loadData) .diagram-center-page { display: grid; gap: 18px; + width: calc(100% - 40px); + max-width: none; } .diagram-layout { @@ -1658,6 +2173,435 @@ onMounted(loadData) dominant-baseline: middle; } +.use-case-editor { + display: grid; + min-height: 660px; + background: #ffffff; +} + +.use-case-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 68px; + padding: 14px 18px; + border-bottom: 1px solid #e7ecf3; + + strong, + span { + display: block; + } + + strong { + color: #172033; + font-size: 16px; + } + + span { + margin-top: 4px; + color: #667085; + font-size: 13px; + } +} + +.use-case-workbench { + display: grid; + grid-template-columns: minmax(340px, 38%) minmax(0, 1fr); + min-height: 592px; +} + +.use-case-dsl-panel { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 14px; + border-right: 1px solid #e7ecf3; + background: #fbfdff; +} + +.use-case-dsl-header { + display: grid; + gap: 10px; + + strong, + span { + display: block; + } + + strong { + color: #172033; + font-size: 15px; + } + + span { + margin-top: 4px; + color: #667085; + font-size: 12px; + line-height: 1.5; + } +} + +.use-case-dsl-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + + :deep(.el-button) { + margin-left: 0; + } +} + +.use-case-grammar { + padding: 9px 10px; + border: 1px solid #dbeafe; + border-radius: 6px; + background: #eff6ff; + color: #475569; + font-size: 12px; + line-height: 1.6; +} + +.use-case-dsl-input { + flex: 1; + + :deep(.el-textarea__inner) { + min-height: 460px !important; + border-color: #d9e0ea; + border-radius: 6px; + color: #172033; + font-family: Consolas, "Courier New", "Microsoft YaHei", monospace; + font-size: 14px; + line-height: 1.65; + } +} + +.use-case-dsl-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + span { + color: #667085; + font-size: 12px; + } +} + +.use-case-preview-panel { + display: grid; + min-width: 0; + align-content: start; + background: #f8fafc; +} + +.use-case-warnings { + display: grid; + gap: 4px; + padding: 10px 18px; + border-bottom: 1px solid #fde68a; + background: #fffbeb; + color: #92400e; + font-size: 12px; +} + +.use-case-preview-scroll { + min-height: 592px; + overflow: auto; + padding: 24px; +} + +.use-case-svg { + display: block; + background: #ffffff; + border: 1px solid #d9e0ea; + border-radius: 6px; + font-family: "Microsoft YaHei", Arial, sans-serif; +} + +.use-case-svg-title { + fill: #111111; + font-size: 18px; + font-weight: 700; + text-anchor: middle; +} + +.use-case-relation { + fill: none; + stroke: #222222; + stroke-width: 1.3; + + &.is-dependency { + stroke-dasharray: 5 4; + } +} + +.use-case-relation-label { + fill: #111111; + font-size: 11px; + paint-order: stroke; + stroke: #ffffff; + stroke-width: 4px; + text-anchor: middle; +} + +.use-case-actor circle, +.use-case-actor path, +.use-case-node ellipse { + fill: #ffffff; + stroke: #222222; + stroke-width: 1.4; +} + +.use-case-actor path { + fill: none; +} + +.use-case-actor text, +.use-case-node text { + fill: #111111; + font-size: 13px; + text-anchor: middle; + dominant-baseline: middle; +} + +.sequence-editor { + display: grid; + min-height: 680px; + background: #ffffff; +} + +.sequence-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 68px; + padding: 14px 18px; + border-bottom: 1px solid #e7ecf3; + + strong, + span { + display: block; + } + + strong { + color: #172033; + font-size: 16px; + } + + span { + margin-top: 4px; + color: #667085; + font-size: 13px; + } +} + +.sequence-workbench { + display: grid; + grid-template-columns: minmax(340px, 38%) minmax(0, 1fr); + min-height: 612px; +} + +.sequence-dsl-panel { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 14px; + border-right: 1px solid #e7ecf3; + background: #fbfdff; +} + +.sequence-dsl-header { + display: grid; + gap: 10px; + + strong, + span { + display: block; + } + + strong { + color: #172033; + font-size: 15px; + } + + span { + margin-top: 4px; + color: #667085; + font-size: 12px; + line-height: 1.5; + } +} + +.sequence-dsl-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + + :deep(.el-button) { + margin-left: 0; + } +} + +.sequence-grammar { + padding: 9px 10px; + border: 1px solid #dbeafe; + border-radius: 6px; + background: #eff6ff; + color: #475569; + font-size: 12px; + line-height: 1.6; +} + +.sequence-dsl-input { + flex: 1; + + :deep(.el-textarea__inner) { + min-height: 500px !important; + border-color: #d9e0ea; + border-radius: 6px; + color: #172033; + font-family: Consolas, "Courier New", "Microsoft YaHei", monospace; + font-size: 14px; + line-height: 1.65; + } +} + +.sequence-dsl-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + span { + color: #667085; + font-size: 12px; + } +} + +.sequence-preview-panel { + display: grid; + min-width: 0; + align-content: start; + background: #f8fafc; +} + +.sequence-errors { + display: grid; + gap: 4px; + padding: 10px 18px; + border-bottom: 1px solid #fee2e2; + background: #fff7f7; + color: #b42318; + font-size: 12px; +} + +.sequence-preview-scroll { + min-height: 612px; + overflow: auto; + padding: 24px; +} + +.sequence-svg { + display: block; + background: #ffffff; + border: 1px solid #d9e0ea; + border-radius: 6px; + font-family: "Microsoft YaHei", Arial, sans-serif; +} + +.sequence-svg-title { + fill: #0f172a; + font-size: 18px; + font-weight: 700; + text-anchor: middle; +} + +.sequence-lifeline { + fill: none; + stroke: #94a3b8; + stroke-dasharray: 7 6; + stroke-width: 1.1; +} + +.sequence-frame-box { + fill: #f8fbff; + fill-opacity: 0.72; + stroke: #64748b; + stroke-width: 1.2; +} + +.sequence-frame-tag { + fill: #e8f0fb; + stroke: #64748b; + stroke-width: 1.2; +} + +.sequence-frame-kind { + fill: #1f2937; + font-size: 12px; + font-weight: 700; +} + +.sequence-frame-note { + fill: #475569; + font-size: 11px; +} + +.sequence-frame-else-line { + fill: none; + stroke: #94a3b8; + stroke-dasharray: 6 5; +} + +.sequence-activation { + fill: #ffffff; + stroke: #1f2937; + stroke-width: 1.2; +} + +.sequence-message-path { + fill: none; + stroke: #1f2937; + stroke-width: 1.5; + + &.is-return { + stroke-dasharray: 7 5; + } +} + +.sequence-message-text { + fill: #1f2937; + font-size: 13px; + paint-order: stroke; + stroke: #ffffff; + stroke-width: 4px; + text-anchor: middle; +} + +.sequence-actor-head, +.sequence-object-box { + fill: #ffffff; + stroke: #1f2937; + stroke-width: 1.4; +} + +.sequence-actor-body { + fill: none; + stroke: #1f2937; + stroke-linecap: round; + stroke-width: 1.5; +} + +.sequence-participant-label { + fill: #1f2937; + font-size: 13px; + text-anchor: middle; +} + .custom-diagram-panel { display: grid; gap: 0; @@ -1759,20 +2703,32 @@ onMounted(loadData) } .architecture-workbench, - .flowchart-workbench { + .flowchart-workbench, + .use-case-workbench, + .sequence-workbench { grid-template-columns: 1fr; } .architecture-dsl-panel, - .flowchart-dsl-panel { + .flowchart-dsl-panel, + .use-case-dsl-panel, + .sequence-dsl-panel { border-right: 0; border-bottom: 1px solid #e7ecf3; } .architecture-toolbar, - .flowchart-toolbar { + .flowchart-toolbar, + .use-case-toolbar, + .sequence-toolbar { align-items: flex-start; flex-direction: column; } } + +@media (max-width: 720px) { + .diagram-center-page { + width: calc(100% - 24px); + } +} diff --git a/RuoYi-Vue/easycode-web/src/views/diagramCenterView.test.mjs b/RuoYi-Vue/easycode-web/src/views/diagramCenterView.test.mjs index 335e1cd..ee10462 100644 --- a/RuoYi-Vue/easycode-web/src/views/diagramCenterView.test.mjs +++ b/RuoYi-Vue/easycode-web/src/views/diagramCenterView.test.mjs @@ -60,7 +60,7 @@ test('diagram center promotes the three-line table tab and removes overview', () const menu = source.slice(menuStart, menuEnd) const labels = [...menu.matchAll(/ match[1]) - assert.deepEqual(labels, ['three_line_table', 'er', 'module', 'architecture', 'flowchart', 'sequence', 'ai']) + assert.deepEqual(labels, ['three_line_table', 'er', 'module', 'architecture', 'flowchart', 'use_case', 'sequence', 'ai']) assert.doesNotMatch(source, /overview:\s*'overview'/) assert.doesNotMatch(source, /activeDiagram\.value === 'overview'/) assert.doesNotMatch(source, /function buildOverviewGraph/)