feat: add standalone module diagram editor

This commit is contained in:
王鹏
2026-07-07 14:24:19 +08:00
parent 23535804e1
commit 67e0335f01
5 changed files with 473 additions and 45 deletions

View File

@@ -5,8 +5,13 @@ import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import RegisterView from '@/views/RegisterView.vue'
import GenerateView from '@/views/GenerateView.vue'
import DiagramCenterView from '@/views/DiagramCenterView.vue'
import ModuleDiagramEditorView from '@/views/ModuleDiagramEditorView.vue'
import PageDesignerView from '@/views/PageDesignerView.vue'
import PaperDraftView from '@/views/PaperDraftView.vue'
import ProjectListView from '@/views/ProjectListView.vue'
import PreviewView from '@/views/PreviewView.vue'
import ProfileView from '@/views/ProfileView.vue'
import SourceDetailView from '@/views/SourceDetailView.vue'
import SourceStoreView from '@/views/SourceStoreView.vue'
@@ -16,8 +21,38 @@ const routes = [
{ path: '/register', name: 'register', component: RegisterView },
{ path: '/source-store', name: 'source-store', component: SourceStoreView },
{ path: '/source-store/:sourceId', name: 'source-detail', component: SourceDetailView },
{ path: '/profile', name: 'profile', component: ProfileView, meta: { requiresAuth: true } },
{ path: '/projects', name: 'projects', component: ProjectListView, meta: { requiresAuth: true } },
{ path: '/generate', name: 'generate', component: GenerateView, meta: { requiresAuth: true } },
{
path: '/project/:projectId/page-designer',
name: 'page-designer',
component: PageDesignerView,
meta: { requiresAuth: true }
},
{
path: '/project/:projectId/diagrams',
name: 'diagram-center',
component: DiagramCenterView,
meta: { requiresAuth: true }
},
{
path: '/module-diagram',
name: 'module-diagram-editor-local',
component: ModuleDiagramEditorView
},
{
path: '/project/:projectId/module-diagram',
name: 'module-diagram-editor',
component: ModuleDiagramEditorView,
meta: { requiresAuth: true }
},
{
path: '/project/:projectId/paper-draft',
name: 'paper-draft',
component: PaperDraftView,
meta: { requiresAuth: true }
},
{
path: '/project/:projectId/preview',
name: 'preview',

View File

@@ -0,0 +1,270 @@
<template>
<section class="page module-diagram-editor-page" v-loading="loading">
<div class="panel module-editor-shell">
<div class="panel-header">
<div>
<h1 class="panel-title">功能模块图编辑器</h1>
<p class="muted">{{ project.projectName || '输入缩进结构,生成论文风格功能模块图。' }}</p>
</div>
<div class="toolbar">
<el-button v-if="projectId" @click="router.push(`/project/${projectId}/diagrams`)">返回图表中心</el-button>
<el-button v-if="projectId" type="primary" :loading="saving" @click="saveEditorDiagram">保存到项目</el-button>
</div>
</div>
<div class="module-editor-layout">
<aside class="module-editor-side">
<div class="module-editor-side-header">
<strong>缩进结构</strong>
<div class="module-editor-side-actions">
<el-button text type="primary" @click="loadExampleOutline">示例</el-button>
<el-button v-if="projectId" text type="primary" @click="useBlueprintOutline">使用项目蓝图</el-button>
</div>
</div>
<el-input
v-model="editableOutlineText"
type="textarea"
:rows="22"
resize="none"
spellcheck="false"
placeholder="第一行写系统名称;第二层缩进 2 个空格;第三层及以下作为功能节点。"
/>
</aside>
<main class="module-editor-canvas">
<AppModuleDiagramView
v-model:source-mode="sourceMode"
v-model:outline-text="outlineText"
v-model:layout-options="layoutOptions"
:blueprint="appBlueprint"
:project-name="project.projectName"
:editable="false"
/>
</main>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'
import { getProject, listProjectDiagrams, saveProjectDiagram } from '@/api/project'
import { blueprintToModuleOutline, moduleOutlineToText, parseModuleOutlineText } from '@/utils/appModuleOutline'
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
const EXAMPLE_OUTLINE = `学生社团管理系统
管理员端
注册登录
用户管理
社团管理
成员管理
活动管理
公告管理
系统管理
普通用户端
注册登录
社团浏览
社团申请
活动浏览
活动报名
个人中心`
const route = useRoute()
const router = useRouter()
const projectId = computed(() => route.params.projectId || route.query.projectId || '')
const loading = ref(false)
const saving = ref(false)
const project = reactive({})
const savedDiagrams = ref([])
const sourceMode = ref('outline')
const outlineText = ref(EXAMPLE_OUTLINE)
const layoutOptions = ref({ density: 'standard', direction: 'horizontal', fontSize: 'standard' })
const appBlueprint = computed(() => parseJson(project.appBlueprint, { roles: [], frontendMenus: [], adminMenus: [] }))
const blueprintOutlineText = computed(() => moduleOutlineToText(blueprintToModuleOutline(appBlueprint.value, { projectName: project.projectName })))
const activeOutlineText = computed(() => sourceMode.value === 'blueprint' ? blueprintOutlineText.value : outlineText.value)
const currentGraph = computed(() => buildAppModuleGraph(parseModuleOutlineText(activeOutlineText.value).outline, layoutOptions.value))
const editableOutlineText = computed({
get: () => outlineText.value,
set: (value) => {
outlineText.value = value
sourceMode.value = 'outline'
}
})
function parseJson(value, fallback) {
if (!value) return fallback
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch (error) {
return fallback
}
}
function loadExampleOutline() {
outlineText.value = EXAMPLE_OUTLINE
sourceMode.value = 'outline'
}
function useBlueprintOutline() {
const text = blueprintOutlineText.value
outlineText.value = text || EXAMPLE_OUTLINE
sourceMode.value = 'outline'
}
function normalizeLayoutOptions(value = {}) {
return {
density: value.density || 'standard',
direction: value.direction || 'horizontal',
fontSize: value.fontSize || 'standard'
}
}
function editorDiagramPayload() {
return {
sourceMode: sourceMode.value,
outlineText: outlineText.value,
layoutOptions: layoutOptions.value,
graph: currentGraph.value,
projectName: project.projectName
}
}
function applySavedModuleDiagram(saved) {
const payload = parseJson(saved?.diagramJson, null)
if (!payload || typeof payload !== 'object') {
useBlueprintOutline()
return
}
sourceMode.value = payload.sourceMode === 'blueprint' ? 'blueprint' : 'outline'
outlineText.value = typeof payload.outlineText === 'string' ? payload.outlineText : (blueprintOutlineText.value || EXAMPLE_OUTLINE)
layoutOptions.value = normalizeLayoutOptions(payload.layoutOptions)
}
async function loadEditorData() {
if (!projectId.value) return
loading.value = true
try {
const [projectResult, diagramsResult] = await Promise.all([
getProject(projectId.value),
listProjectDiagrams(projectId.value)
])
Object.assign(project, projectResult || {})
savedDiagrams.value = Array.isArray(diagramsResult) ? diagramsResult : []
const saved = savedDiagrams.value.find((item) => item.diagramType === 'module')
if (saved) {
applySavedModuleDiagram(saved)
} else {
useBlueprintOutline()
}
} catch (error) {
ElMessage.error(error.message || '加载模块图失败')
} finally {
loading.value = false
}
}
async function saveEditorDiagram() {
if (!projectId.value) return
const existing = savedDiagrams.value.find((item) => item.diagramType === 'module')
saving.value = true
try {
const saved = await saveProjectDiagram(projectId.value, {
diagramId: existing?.diagramId,
diagramType: 'module',
title: '功能模块图',
description: '系统功能模块图草稿',
diagramJson: JSON.stringify(editorDiagramPayload())
})
if (saved?.diagramId) {
const index = savedDiagrams.value.findIndex((item) => item.diagramId === saved.diagramId)
if (index >= 0) {
savedDiagrams.value.splice(index, 1, saved)
} else {
savedDiagrams.value.unshift(saved)
}
}
ElMessage.success('功能模块图已保存')
} catch (error) {
ElMessage.error(error.message || '保存模块图失败')
} finally {
saving.value = false
}
}
onMounted(loadEditorData)
</script>
<style scoped lang="scss">
.module-diagram-editor-page {
display: grid;
gap: 18px;
}
.module-editor-shell {
overflow: hidden;
}
.module-editor-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
min-height: 640px;
}
.module-editor-side {
display: grid;
align-content: start;
gap: 12px;
padding: 18px;
border-right: 1px solid #e7ecf3;
background: #fbfdff;
}
.module-editor-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.module-editor-side-header strong {
color: #172033;
font-size: 15px;
}
.module-editor-side-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.module-editor-side :deep(.el-textarea__inner) {
min-height: 560px !important;
border-color: #d9e0ea;
color: #172033;
font-family: Consolas, "Courier New", "Microsoft YaHei", monospace;
font-size: 14px;
line-height: 1.65;
}
.module-editor-canvas {
min-width: 0;
background: #ffffff;
}
@media (max-width: 980px) {
.module-editor-layout {
grid-template-columns: 1fr;
}
.module-editor-side {
border-right: 0;
border-bottom: 1px solid #e7ecf3;
}
}
</style>

View File

@@ -8,7 +8,7 @@
</div>
<div class="toolbar">
<el-button :icon="Refresh" :loading="loading" @click="loadProjects">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="router.push('/generate')">新建项目</el-button>
<el-button type="primary" :icon="Plus" @click="router.push({ path: '/generate', query: { mode: 'one-click' } })">新建项目</el-button>
</div>
</div>
@@ -17,7 +17,7 @@
<el-table-column prop="projectName" label="项目名称" min-width="190">
<template #default="{ row }">
<div class="project-name">{{ row.projectName || '未命名项目' }}</div>
<div class="project-desc">{{ row.projectDesc || row.requirementKeyword || row.industryTemplate || '暂无描述' }}</div>
<div class="project-desc">{{ row.projectDesc || row.requirementKeyword || '暂无描述' }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="140">
@@ -32,34 +32,30 @@
{{ row.updateTime || row.createTime || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="390" fixed="right">
<el-table-column label="操作" width="328" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button class="action-button action-button-wide" :icon="EditPen" plain @click="continueProject(row)">
继续生成
</el-button>
<el-button
class="action-button"
:icon="View"
plain
:disabled="row.previewStatus !== '1'"
@click="router.push(`/project/${projectIdOf(row)}/preview`)"
>
预览
</el-button>
<el-button
class="action-button"
:icon="Download"
plain
:disabled="row.previewStatus !== '1'"
:loading="downloadingProjectId === projectIdOf(row)"
@click="downloadSource(row)"
>
下载
</el-button>
<el-button class="action-button" :icon="Delete" type="danger" plain @click="removeProject(row)">
删除
</el-button>
<el-tooltip content="专家编辑" placement="top">
<el-button class="project-icon-button" :icon="EditPen" circle plain aria-label="专家编辑" @click="continueProject(row)" />
</el-tooltip>
<el-tooltip content="图表中心" placement="top">
<el-button class="project-icon-button" :icon="DataAnalysis" circle plain aria-label="图表中心" @click="openDiagrams(row)" />
</el-tooltip>
<el-tooltip content="模块图" placement="top">
<el-button class="project-icon-button" :icon="Connection" circle plain aria-label="模块图" @click="openModuleDiagramEditor(row)" />
</el-tooltip>
<el-tooltip content="论文初稿" placement="top">
<el-button class="project-icon-button" :icon="Document" circle plain aria-label="论文初稿" @click="openPaperDraft(row)" />
</el-tooltip>
<el-tooltip content="预览" placement="top">
<el-button class="project-icon-button" :icon="View" circle plain aria-label="预览" :disabled="row.previewStatus !== '1'" @click="router.push(`/project/${projectIdOf(row)}/preview`)" />
</el-tooltip>
<el-tooltip content="下载" placement="top">
<el-button class="project-icon-button" :icon="Download" circle plain aria-label="下载" :disabled="row.previewStatus !== '1'" :loading="downloadingProjectId === projectIdOf(row)" @click="downloadSource(row)" />
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button class="project-icon-button" :icon="Delete" circle plain type="danger" aria-label="删除" @click="removeProject(row)" />
</el-tooltip>
</div>
</template>
</el-table-column>
@@ -68,9 +64,7 @@
<el-empty
v-if="!loading && !projects.length"
description="暂无项目,创建一个新的项目草稿吧"
>
<el-button type="primary" :icon="Plus" @click="router.push('/generate')">新建项目</el-button>
</el-empty>
/>
</div>
</div>
</section>
@@ -80,7 +74,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Download, EditPen, Plus, Refresh, View } from '@element-plus/icons-vue'
import { Connection, DataAnalysis, Delete, Document, Download, EditPen, Plus, Refresh, View } from '@element-plus/icons-vue'
import { deleteProject, downloadProject, listProjects } from '@/api/project'
import { saveBlob, sourceZipName } from '@/utils/download'
@@ -121,7 +115,34 @@ function continueProject(project) {
ElMessage.warning('项目 ID 不存在,无法继续')
return
}
router.push({ path: '/generate', query: { projectId } })
router.push({ path: '/generate', query: { projectId, mode: 'expert' } })
}
function openDiagrams(project) {
const projectId = projectIdOf(project)
if (!projectId) {
ElMessage.warning('项目 ID 不存在,无法打开图表中心')
return
}
router.push(`/project/${projectId}/diagrams`)
}
function openModuleDiagramEditor(project) {
const projectId = projectIdOf(project)
if (!projectId) {
ElMessage.warning('项目 ID 不存在,无法打开模块图')
return
}
router.push(`/project/${projectId}/module-diagram`)
}
function openPaperDraft(project) {
const projectId = projectIdOf(project)
if (!projectId) {
ElMessage.warning('项目 ID 不存在,无法打开论文初稿')
return
}
router.push(`/project/${projectId}/paper-draft`)
}
async function downloadSource(project) {
@@ -149,7 +170,7 @@ async function removeProject(project) {
if (!projectId) return
try {
await ElMessageBox.confirm(`确认删除项目${project.projectName || projectId}`, '删除项目', {
await ElMessageBox.confirm(`确认删除项目${project.projectName || projectId}`, '删除项目', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
@@ -187,9 +208,16 @@ onMounted(loadProjects)
}
.project-desc {
display: -webkit-box;
max-width: 540px;
margin-top: 4px;
overflow: hidden;
color: #667085;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.row-actions {
@@ -203,17 +231,10 @@ onMounted(loadProjects)
margin-left: 0;
}
.action-button {
min-width: 72px;
height: 32px;
padding: 7px 10px;
border-color: #d8e0eb;
color: #475467;
background: #ffffff;
}
.action-button-wide {
min-width: 94px;
.project-icon-button {
width: 36px;
height: 36px;
padding: 0;
}
@media (max-width: 860px) {

View File

@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const currentDir = dirname(fileURLToPath(import.meta.url))
const srcDir = resolve(currentDir, '..')
function readSource(relativePath) {
return readFileSync(resolve(srcDir, relativePath), 'utf8')
}
test('standalone module diagram editor page exists and embeds the shared component', () => {
assert.equal(existsSync(resolve(srcDir, 'views/ModuleDiagramEditorView.vue')), true)
const source = readSource('views/ModuleDiagramEditorView.vue')
assert.equal(source.includes("import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'"), true)
assert.equal(source.includes('module-editor-layout'), true)
assert.equal(source.includes('module-editor-side'), true)
assert.equal(source.includes('module-editor-canvas'), true)
assert.equal(source.includes('v-model:source-mode="sourceMode"'), true)
assert.equal(source.includes('v-model:outline-text="outlineText"'), true)
assert.equal(source.includes('v-model:layout-options="layoutOptions"'), true)
assert.equal(source.includes(':editable="false"'), true)
})
test('standalone module diagram editor supports optional project loading and saving', () => {
const source = readSource('views/ModuleDiagramEditorView.vue')
assert.equal(source.includes('const projectId = computed'), true)
assert.equal(source.includes('getProject(projectId.value)'), true)
assert.equal(source.includes('listProjectDiagrams(projectId.value)'), true)
assert.equal(source.includes('saveProjectDiagram(projectId.value'), true)
assert.equal(source.includes('function editorDiagramPayload'), true)
assert.equal(source.includes('sourceMode: sourceMode.value'), true)
assert.equal(source.includes('outlineText: outlineText.value'), true)
assert.equal(source.includes('layoutOptions: layoutOptions.value'), true)
})
test('router exposes project and local module diagram editor routes', () => {
const source = readSource('router/index.js')
assert.equal(source.includes("import ModuleDiagramEditorView from '@/views/ModuleDiagramEditorView.vue'"), true)
assert.equal(source.includes("path: '/module-diagram'"), true)
assert.equal(source.includes("path: '/project/:projectId/module-diagram'"), true)
assert.equal(source.includes("name: 'module-diagram-editor'"), true)
})

View File

@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const currentDir = dirname(fileURLToPath(import.meta.url))
function readView(name) {
return readFileSync(resolve(currentDir, name), 'utf8')
}
test('project list row actions use compact icon buttons like profile project actions', () => {
const source = readView('ProjectListView.vue')
assert.equal(source.includes('project-icon-button'), true)
assert.equal(source.includes('el-tooltip content="专家编辑"'), true)
assert.equal(source.includes('aria-label="专家编辑"'), true)
assert.equal(source.includes('aria-label="预览"'), true)
assert.equal(source.includes('aria-label="下载"'), true)
assert.equal(source.includes('aria-label="删除"'), true)
assert.match(source, /<el-table-column label="操作" width="328" fixed="right">/)
assert.doesNotMatch(source, /<el-button[^>]*>\s*继续生成\s*<\/el-button>/)
assert.doesNotMatch(source, /<el-button[^>]*>\s*预览\s*<\/el-button>/)
assert.doesNotMatch(source, /<el-button[^>]*>\s*下载\s*<\/el-button>/)
assert.doesNotMatch(source, /<el-button[^>]*>\s*删除\s*<\/el-button>/)
})
test('empty project state does not include a duplicate create project button', () => {
const source = readView('ProjectListView.vue')
assert.equal(source.includes("router.push({ path: '/generate', query: { mode: 'one-click' } })"), true)
assert.doesNotMatch(source, /<el-empty[\s\S]*?<el-button[\s\S]*?<\/el-empty>/)
})
test('project list truncates long project descriptions after two lines', () => {
const source = readView('ProjectListView.vue')
const styleMatch = source.match(/\.project-desc\s*\{([\s\S]*?)\n\}/)
assert.notEqual(styleMatch, null)
assert.match(styleMatch[1], /display:\s*-webkit-box;/)
assert.match(styleMatch[1], /-webkit-box-orient:\s*vertical;/)
assert.match(styleMatch[1], /-webkit-line-clamp:\s*2;/)
assert.match(styleMatch[1], /overflow:\s*hidden;/)
})
test('project list exposes compact module diagram action', () => {
const source = readView('ProjectListView.vue')
assert.equal(source.includes('el-tooltip content="模块图"'), true)
assert.equal(source.includes('aria-label="模块图"'), true)
assert.equal(source.includes('openModuleDiagramEditor'), true)
assert.equal(source.includes('`/project/${projectId}/module-diagram`'), true)
})