Add executable business blueprint workflow

This commit is contained in:
王鹏
2026-05-24 13:29:42 +08:00
parent ecdc015c2f
commit 6682a44a43
40 changed files with 4756 additions and 113 deletions

View File

@@ -27,6 +27,14 @@ export function getProject(projectId) {
}).then(unwrap)
}
export function updateProject(projectId, data) {
return request({
url: `/front/project/${projectId}`,
method: 'put',
data
}).then(unwrap)
}
export function deleteProject(projectId) {
return request({
url: `/front/project/${projectId}`,
@@ -34,6 +42,13 @@ export function deleteProject(projectId) {
}).then(unwrap)
}
export function listAvailableModules() {
return request({
url: '/front/project/options/modules',
method: 'get'
}).then(unwrap)
}
export function generateDatabase(projectId, data) {
return request({
url: `/front/project/${projectId}/generate-database`,
@@ -52,6 +67,15 @@ export function generateAppBlueprint(projectId, data) {
}).then(unwrap)
}
export function generateBusinessBlueprint(projectId, data) {
return request({
url: `/front/project/${projectId}/generate-business-blueprint`,
method: 'post',
timeout: GENERATE_REQUEST_TIMEOUT,
data
}).then(unwrap)
}
export function getDatabase(projectId) {
return request({
url: `/front/project/${projectId}/database`,

View File

@@ -0,0 +1,327 @@
<template>
<div class="app-module-diagram">
<el-empty v-if="!graph.nodes.length" description="生成应用蓝图后可查看系统功能模块图" />
<template v-else>
<div class="module-toolbar">
<div class="module-summary">
<span>节点{{ graph.nodes.length }}</span>
<span>连线{{ graph.edges.length }}</span>
</div>
<el-button :icon="Picture" @click="exportPng">导出图片</el-button>
</div>
<div class="module-scroll">
<svg ref="svgRef" class="module-canvas" :viewBox="`0 0 ${canvas.width} ${canvas.height}`" :style="canvasStyle">
<defs>
<marker
id="app-module-arrow"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L8,3 z" fill="#8aa0bc" />
</marker>
</defs>
<g class="lane-layer">
<g v-for="lane in graph.lanes" :key="lane.key">
<rect class="lane-bg" :x="lane.x - 16" y="22" :width="lane.width + 32" :height="canvas.height - 44" rx="10" />
<text class="lane-title" :x="lane.x" y="46">{{ lane.title }}</text>
</g>
</g>
<g class="edge-layer">
<g v-for="edge in edgeViews" :key="edge.id">
<path class="module-edge" :d="edge.path" marker-end="url(#app-module-arrow)" />
<text class="edge-label" :x="edge.labelX" :y="edge.labelY">{{ edge.label }}</text>
</g>
</g>
<g class="node-layer">
<g
v-for="node in graph.nodes"
:key="node.id"
class="module-node"
:class="[node.kind, node.lane]"
:transform="`translate(${node.x}, ${node.y})`"
>
<rect class="module-node-box" :width="node.width" :height="node.height" rx="8" />
<rect class="module-node-head" :width="node.width" height="52" rx="8" />
<rect class="module-node-head-fill" y="44" :width="node.width" height="8" />
<text class="node-title" x="14" y="27">{{ shortText(node.title, 18) }}</text>
<text class="node-subtitle" x="14" y="46">{{ shortText(node.subtitle, 28) }}</text>
<g v-for="(badge, badgeIndex) in node.badges" :key="badge" :transform="`translate(${badgeX(node, badgeIndex)}, 72)`">
<rect class="node-badge-bg" :width="badgeWidth(badge)" height="22" rx="5" />
<text class="node-badge-text" :x="badgeWidth(badge) / 2" y="15">{{ shortText(badge, 14) }}</text>
</g>
</g>
</g>
</svg>
</div>
</template>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import { buildAppModuleGraph } from '@/utils/appModuleGraph'
import { saveBlob } from '@/utils/download'
const props = defineProps({
blueprint: {
type: Object,
default: () => ({ roles: [], frontendMenus: [], adminMenus: [] })
}
})
const svgRef = ref(null)
const graph = computed(() => buildAppModuleGraph(props.blueprint))
const nodeMap = computed(() => {
const map = new Map()
graph.value.nodes.forEach((node) => map.set(node.id, node))
return map
})
const canvas = computed(() => {
const nodes = graph.value.nodes
const lanes = graph.value.lanes || []
const width = Math.max(980, ...lanes.map((lane) => lane.x + lane.width + 48), ...nodes.map((node) => node.x + node.width + 48))
const height = Math.max(430, ...nodes.map((node) => node.y + node.height + 48))
return { width, height }
})
const canvasStyle = computed(() => ({
width: `${canvas.value.width}px`,
height: `${canvas.value.height}px`
}))
const edgeViews = computed(() => graph.value.edges
.map((edge) => {
const source = nodeMap.value.get(edge.source)
const target = nodeMap.value.get(edge.target)
if (!source || !target) return null
const sourceX = source.x + source.width
const sourceY = source.y + source.height / 2
const targetX = target.x
const targetY = target.y + target.height / 2
const curve = Math.max(64, Math.abs(targetX - sourceX) / 2)
return {
...edge,
path: `M ${sourceX} ${sourceY} C ${sourceX + curve} ${sourceY}, ${targetX - curve} ${targetY}, ${targetX} ${targetY}`,
labelX: (sourceX + targetX) / 2,
labelY: (sourceY + targetY) / 2 - 9
}
})
.filter(Boolean))
function shortText(value, maxLength) {
const text = String(value ?? '')
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
}
function badgeWidth(value) {
const text = String(value ?? '')
return Math.min(112, Math.max(48, text.length * 8 + 20))
}
function badgeX(node, index) {
return node.badges.slice(0, index).reduce((x, badge) => x + badgeWidth(badge) + 6, 14)
}
function exportStyle() {
return `
.lane-bg{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1}
.lane-title{fill:#475569;font-family:Arial,sans-serif;font-size:13px;font-weight:700}
.module-edge{fill:none;stroke:#8aa0bc;stroke-width:1.7}
.edge-label{fill:#64748b;font-family:Arial,sans-serif;font-size:12px;paint-order:stroke;stroke:#f8fafc;stroke-width:4px;text-anchor:middle}
.module-node-box{fill:#fff;stroke:#d6e0ee;stroke-width:1}
.module-node-head{fill:#eef5ff}
.module-node-head-fill{fill:#eef5ff}
.module-node.access .module-node-head,.module-node.access .module-node-head-fill{fill:#ecfdf5}
.module-node.role .module-node-head,.module-node.role .module-node-head-fill{fill:#eff6ff}
.module-node.frontend .module-node-head,.module-node.frontend .module-node-head-fill{fill:#f0fdf4}
.module-node.admin .module-node-head,.module-node.admin .module-node-head-fill{fill:#fff7ed}
.node-title{fill:#0f172a;font-family:Arial,sans-serif;font-size:15px;font-weight:700}
.node-subtitle{fill:#64748b;font-family:Arial,sans-serif;font-size:12px}
.node-badge-bg{fill:#f8fafc;stroke:#e2e8f0;stroke-width:1}
.node-badge-text{fill:#475569;font-family:Arial,sans-serif;font-size:11px;font-weight:700;text-anchor:middle}`
}
async function exportPng() {
if (!svgRef.value) return
const svg = svgRef.value.cloneNode(true)
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
style.textContent = exportStyle()
svg.insertBefore(style, svg.firstChild)
const svgText = `<?xml version="1.0" encoding="UTF-8"?>${svg.outerHTML}`
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
const image = new Image()
image.decoding = 'async'
try {
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = reject
image.src = url
})
const canvasElement = document.createElement('canvas')
canvasElement.width = canvas.value.width
canvasElement.height = canvas.value.height
const context = canvasElement.getContext('2d')
context.fillStyle = '#f8fafc'
context.fillRect(0, 0, canvasElement.width, canvasElement.height)
context.drawImage(image, 0, 0)
const blob = await new Promise((resolve) => canvasElement.toBlob(resolve, 'image/png'))
if (blob) {
saveBlob(blob, 'app-module-diagram.png')
}
} catch (error) {
ElMessage.warning('导出图片失败,请稍后重试')
} finally {
URL.revokeObjectURL(url)
}
}
</script>
<style scoped lang="scss">
.app-module-diagram {
background: #f8fafc;
min-height: 430px;
}
.module-toolbar {
align-items: center;
background: #ffffff;
border-bottom: 1px solid #e3e8f0;
display: flex;
gap: 16px;
justify-content: space-between;
padding: 14px 18px;
}
.module-summary {
color: #64748b;
display: flex;
flex-wrap: wrap;
font-size: 13px;
gap: 12px;
}
.module-scroll {
min-height: 430px;
overflow: auto;
padding: 24px;
}
.module-canvas {
display: block;
}
.lane-bg {
fill: #f8fafc;
stroke: #e2e8f0;
stroke-width: 1;
}
.lane-title {
fill: #475569;
font-size: 13px;
font-weight: 700;
}
.module-edge {
fill: none;
stroke: #8aa0bc;
stroke-width: 1.7;
}
.edge-label {
fill: #64748b;
font-size: 12px;
paint-order: stroke;
stroke: #f8fafc;
stroke-width: 4px;
text-anchor: middle;
}
.module-node-box {
fill: #ffffff;
filter: drop-shadow(0 8px 18px rgba(15, 23, 42, 0.08));
stroke: #d6e0ee;
stroke-width: 1;
}
.module-node-head,
.module-node-head-fill {
fill: #eef5ff;
}
.module-node.access {
.module-node-head,
.module-node-head-fill {
fill: #ecfdf5;
}
}
.module-node.role {
.module-node-head,
.module-node-head-fill {
fill: #eff6ff;
}
}
.module-node.frontend {
.module-node-head,
.module-node-head-fill {
fill: #f0fdf4;
}
}
.module-node.admin {
.module-node-head,
.module-node-head-fill {
fill: #fff7ed;
}
}
.node-title {
fill: #0f172a;
font-size: 15px;
font-weight: 700;
}
.node-subtitle {
fill: #64748b;
font-size: 12px;
}
.node-badge-bg {
fill: #f8fafc;
stroke: #e2e8f0;
stroke-width: 1;
}
.node-badge-text {
fill: #475569;
font-size: 11px;
font-weight: 700;
text-anchor: middle;
}
@media (max-width: 860px) {
.module-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,200 @@
const SOURCE_X = 32
const FRONTEND_X = 360
const ADMIN_X = 688
const START_Y = 72
const NODE_WIDTH = 252
const NODE_HEIGHT = 108
const NODE_GAP_Y = 24
function cleanText(value, fallback = '') {
const text = String(value ?? '').trim()
return text || fallback
}
function flagOn(value) {
return value === true || value === 1 || value === '1' || value === 'true'
}
function normalizeKey(value, fallback) {
const text = cleanText(value, fallback)
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
return text || fallback
}
function uniqueKey(value, fallbackPrefix, index, used) {
const base = normalizeKey(value, `${fallbackPrefix}_${index + 1}`)
let candidate = base
let suffix = 2
while (used.has(candidate)) {
candidate = `${base}_${suffix}`
suffix += 1
}
used.add(candidate)
return candidate
}
function normalizeRoles(roles) {
const used = new Set()
return (Array.isArray(roles) ? roles : []).filter(Boolean).map((role, index) => {
const code = uniqueKey(role.code, 'role', index, used)
return {
code,
id: `role:${code}`,
title: cleanText(role.name, code),
subtitle: cleanText(role.description, '角色'),
kind: 'role',
lane: 'role',
badges: ['角色']
}
})
}
function normalizeMenus(menus, lane, fallbackPrefix) {
const used = new Set()
return (Array.isArray(menus) ? menus : []).filter(Boolean).map((menu, index) => {
const code = uniqueKey(menu.code, fallbackPrefix, index, used)
const requiresLogin = flagOn(menu.requiresLogin)
const dataScope = cleanText(menu.dataScope, requiresLogin ? 'CURRENT_USER' : 'PUBLIC').toUpperCase()
const visibleRoles = Array.isArray(menu.visibleRoles)
? menu.visibleRoles.map((role) => normalizeKey(role, '')).filter(Boolean)
: []
return {
code,
id: `${lane}:${code}`,
title: cleanText(menu.name, code),
subtitle: cleanText(menu.path, '/'),
kind: 'menu',
lane,
requiresLogin,
visibleRoles,
dataScope,
badges: [
lane === 'frontend' ? '前台' : '后台',
requiresLogin ? '需登录' : '公开',
dataScope
]
}
})
}
function shouldUsePublic(menu) {
return !menu.requiresLogin || menu.dataScope === 'PUBLIC'
}
function needsPublicAccess(menus) {
return menus.some(shouldUsePublic)
}
function needsLoggedInAccess(menus, roleIds) {
return menus.some((menu) => {
if (!menu.requiresLogin) return false
return !menu.visibleRoles.some((roleCode) => roleIds.has(`role:${roleCode}`))
})
}
function buildAccessNodes(menus, roleIds) {
const nodes = []
if (needsPublicAccess(menus)) {
nodes.push({
code: 'public',
id: 'access:public',
title: '公开访问',
subtitle: '无需登录',
kind: 'access',
lane: 'access',
badges: ['PUBLIC']
})
}
if (needsLoggedInAccess(menus, roleIds)) {
nodes.push({
code: 'logged_in',
id: 'access:logged_in',
title: '登录用户',
subtitle: '未限定角色',
kind: 'access',
lane: 'access',
badges: ['LOGIN']
})
}
return nodes
}
function applyLayout(nodes) {
const laneIndexes = new Map()
return nodes.map((node) => {
const layoutLane = node.lane === 'admin' ? 'admin' : node.lane === 'frontend' ? 'frontend' : 'source'
const index = laneIndexes.get(layoutLane) || 0
laneIndexes.set(layoutLane, index + 1)
const x = layoutLane === 'admin' ? ADMIN_X : layoutLane === 'frontend' ? FRONTEND_X : SOURCE_X
return {
...node,
x,
y: START_Y + index * (NODE_HEIGHT + NODE_GAP_Y),
width: NODE_WIDTH,
height: NODE_HEIGHT
}
})
}
function edgeLabel(menu) {
return menu.dataScope || (menu.requiresLogin ? 'LOGIN' : 'PUBLIC')
}
function edgeId(source, target) {
return `${source}->${target}`
}
function buildEdges(menus, roleIds) {
const edges = []
menus.forEach((menu) => {
const validRoleIds = menu.visibleRoles
.map((roleCode) => `role:${roleCode}`)
.filter((roleId) => roleIds.has(roleId))
if (validRoleIds.length) {
validRoleIds.forEach((roleId) => {
edges.push({
id: edgeId(roleId, menu.id),
source: roleId,
target: menu.id,
label: edgeLabel(menu)
})
})
return
}
const source = shouldUsePublic(menu) ? 'access:public' : 'access:logged_in'
edges.push({
id: edgeId(source, menu.id),
source,
target: menu.id,
label: edgeLabel(menu)
})
})
return edges
}
export function buildAppModuleGraph(blueprint = {}) {
const roles = normalizeRoles(blueprint.roles)
const frontendMenus = normalizeMenus(blueprint.frontendMenus, 'frontend', 'frontend_menu')
const adminMenus = normalizeMenus(blueprint.adminMenus, 'admin', 'admin_menu')
const menus = [...frontendMenus, ...adminMenus]
const roleIds = new Set(roles.map((role) => role.id))
const accessNodes = buildAccessNodes(menus, roleIds)
const nodes = applyLayout([...accessNodes, ...roles, ...frontendMenus, ...adminMenus])
return {
nodes,
edges: buildEdges(menus, roleIds),
lanes: [
{ key: 'source', title: '访问角色', x: SOURCE_X, width: NODE_WIDTH },
{ key: 'frontend', title: '前台功能模块', x: FRONTEND_X, width: NODE_WIDTH },
{ key: 'admin', title: '后台功能模块', x: ADMIN_X, width: NODE_WIDTH }
]
}
}

View File

@@ -0,0 +1,82 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildAppModuleGraph } from './appModuleGraph.js'
test('buildAppModuleGraph creates role and menu module nodes with access edges', () => {
const graph = buildAppModuleGraph({
roles: [
{ code: 'reader', name: '读者', description: '前台用户' },
{ code: 'admin', name: '管理员', description: '后台运营' }
],
frontendMenus: [
{
code: 'catalog',
name: '图书目录',
path: '/catalog',
requiresLogin: false,
visibleRoles: [],
dataScope: 'PUBLIC'
},
{
code: 'my_borrows',
name: '我的借阅',
path: '/my-borrows',
requiresLogin: true,
visibleRoles: ['reader'],
dataScope: 'CURRENT_USER'
}
],
adminMenus: [
{
code: 'book_admin',
name: '图书管理',
path: '/admin/books',
requiresLogin: true,
visibleRoles: ['admin'],
dataScope: 'ROLE'
}
]
})
assert.deepEqual(
graph.nodes.map((node) => ({ id: node.id, title: node.title, kind: node.kind, lane: node.lane })),
[
{ id: 'access:public', title: '公开访问', kind: 'access', lane: 'access' },
{ id: 'role:reader', title: '读者', kind: 'role', lane: 'role' },
{ id: 'role:admin', title: '管理员', kind: 'role', lane: 'role' },
{ id: 'frontend:catalog', title: '图书目录', kind: 'menu', lane: 'frontend' },
{ id: 'frontend:my_borrows', title: '我的借阅', kind: 'menu', lane: 'frontend' },
{ id: 'admin:book_admin', title: '图书管理', kind: 'menu', lane: 'admin' }
]
)
assert.deepEqual(
graph.edges.map((edge) => ({ source: edge.source, target: edge.target, label: edge.label })),
[
{ source: 'access:public', target: 'frontend:catalog', label: 'PUBLIC' },
{ source: 'role:reader', target: 'frontend:my_borrows', label: 'CURRENT_USER' },
{ source: 'role:admin', target: 'admin:book_admin', label: 'ROLE' }
]
)
})
test('buildAppModuleGraph creates a logged-in access node for menus without explicit visible roles', () => {
const graph = buildAppModuleGraph({
roles: [],
frontendMenus: [
{
code: 'profile',
name: '个人中心',
path: '/profile',
requiresLogin: true,
visibleRoles: [],
dataScope: 'CURRENT_USER'
}
],
adminMenus: []
})
assert.equal(graph.nodes[0].id, 'access:logged_in')
assert.equal(graph.edges[0].source, 'access:logged_in')
assert.equal(graph.edges[0].target, 'frontend:profile')
})

View File

@@ -4,7 +4,7 @@
<div class="panel-header">
<div>
<h1 class="panel-title">项目生成工作台</h1>
<p class="muted">先生成数据库结构确认后再进入项目预览</p>
<p class="muted">先生成应用蓝图再生成数据库结构保存修改后生成业务蓝图</p>
</div>
</div>
@@ -33,6 +33,39 @@
placeholder="例如字段偏好、模块边界、业务规则"
/>
</el-form-item>
<el-row :gutter="16">
<el-col :xs="24" :md="8">
<el-form-item label="项目形态">
<el-switch
v-model="projectForm.frontendEnabled"
active-text="生成用户前台"
inactive-text="仅后台管理"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="16">
<el-form-item label="后台功能模块">
<el-select
v-model="projectForm.moduleIds"
multiple
clearable
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择后台维护的功能模块"
class="module-select"
:loading="loadingModules"
>
<el-option
v-for="module in availableModules"
:key="module.moduleId"
:label="moduleLabel(module)"
:value="module.moduleId"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-alert
@@ -54,6 +87,14 @@
<el-button :disabled="!projectId || !database.tables.length" :loading="saving" @click="handleSaveDatabase">
保存表结构
</el-button>
<el-button
type="primary"
:disabled="!projectId || !database.tables.length"
:loading="generatingBusinessBlueprint"
@click="handleGenerateBusinessBlueprint"
>
生成业务蓝图
</el-button>
<el-button
type="success"
:icon="View"
@@ -67,17 +108,22 @@
</div>
</div>
<div class="panel app-blueprint-panel">
<div v-if="hasAppBlueprint" class="panel app-blueprint-panel">
<div class="panel-header database-header">
<h2 class="panel-title">应用蓝图</h2>
<div class="database-header-actions">
<el-radio-group v-model="appBlueprintView" size="large">
<el-radio-button label="summary">摘要</el-radio-button>
<el-radio-button label="diagram">模块图</el-radio-button>
<el-radio-button label="json">JSON</el-radio-button>
</el-radio-group>
<span class="muted">角色{{ appBlueprint.roles.length }}</span>
<span class="muted">前台菜单{{ appBlueprint.frontendMenus.length }}</span>
<span class="muted">后台菜单{{ appBlueprint.adminMenus.length }}</span>
</div>
</div>
<div v-if="hasAppBlueprint" class="app-blueprint-content">
<div class="app-blueprint-grid">
<div class="app-blueprint-content">
<div v-show="appBlueprintView === 'summary'" class="app-blueprint-grid">
<section>
<h3>角色</h3>
<el-tag v-for="role in appBlueprint.roles" :key="role.code" effect="plain">
@@ -97,12 +143,14 @@
</el-tag>
</section>
</div>
<el-input v-model="appBlueprintJson" type="textarea" :rows="12" spellcheck="false" />
<div class="toolbar compact-toolbar">
<el-button @click="applyAppBlueprintJson">应用调整</el-button>
</div>
<AppModuleDiagramView v-show="appBlueprintView === 'diagram'" :blueprint="appBlueprint" />
<template v-if="appBlueprintView === 'json'">
<el-input v-model="appBlueprintJson" type="textarea" :rows="12" spellcheck="false" />
<div class="toolbar compact-toolbar">
<el-button @click="applyAppBlueprintJson">应用调整</el-button>
</div>
</template>
</div>
<el-empty v-else description="生成应用蓝图后,这里会展示角色和前后台菜单" />
</div>
<div class="panel designer-panel">
@@ -148,25 +196,25 @@
入参{{ action.requestFields.join('') }}
</span>
</div>
<div v-if="Array.isArray(action.rules) && action.rules.length" class="blueprint-section">
<div v-if="actionRuleItems(action).length" class="blueprint-section">
<strong>规则</strong>
<ul>
<li v-for="rule in action.rules" :key="rule">{{ rule }}</li>
<li v-for="(rule, ruleIndex) in actionRuleItems(action)" :key="`rule-${ruleIndex}-${rule}`">
{{ rule }}
</li>
</ul>
</div>
<div v-if="Array.isArray(action.effects) && action.effects.length" class="blueprint-section">
<strong>影响</strong>
<ul>
<li v-for="effect in action.effects" :key="`${effect.type}-${effect.targetTable}-${effect.targetField}`">
{{ effect.description || effect.type }}
<span v-if="effect.targetTable"> / {{ effect.targetTable }}</span>
<span v-if="effect.targetField">.{{ effect.targetField }}</span>
<li v-for="(effect, effectIndex) in action.effects" :key="blueprintEffectKey(effect, effectIndex)">
{{ formatEffect(effect) }}
</li>
</ul>
</div>
</article>
</div>
<el-empty v-else description="生成数据库后,这里会展示 AI 识别出的业务动作草稿" />
<el-empty v-else description="保存表结构后点击生成业务蓝图,这里会展示非 CRUD 业务动作" />
</div>
</section>
</template>
@@ -176,29 +224,36 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis, View } from '@element-plus/icons-vue'
import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'
import DatabaseDesigner from '@/components/DatabaseDesigner.vue'
import ErDiagramView from '@/components/ErDiagramView.vue'
import { createProject, generateAppBlueprint, generateDatabase, generateProject, getDatabase, getProject, saveDatabase } from '@/api/project'
import { createProject, generateAppBlueprint, generateBusinessBlueprint, generateDatabase, generateProject, getDatabase, getProject, listAvailableModules, saveDatabase, updateProject } from '@/api/project'
const route = useRoute()
const router = useRouter()
const generating = ref(false)
const generatingAppBlueprint = ref(false)
const generatingBusinessBlueprint = ref(false)
const saving = ref(false)
const previewing = ref(false)
const loadingProject = ref(false)
const loadingModules = ref(false)
const databaseView = ref('editor')
const appBlueprintView = ref('summary')
const projectId = ref(normalizeProjectId(route.query.projectId))
const errorMessage = ref('')
const appBlueprint = ref(emptyAppBlueprint())
const appBlueprintJson = ref(formatJson(emptyAppBlueprint()))
const database = ref({ tables: [], sql: '', erDiagram: emptyErDiagram(), businessActions: [] })
const availableModules = ref([])
const projectForm = reactive({
projectName: '',
keyword: String(route.query.keyword || ''),
projectDesc: '',
industryTemplate: ''
industryTemplate: '',
frontendEnabled: true,
moduleIds: []
})
const LAST_PROJECT_KEY = 'easycode_last_project_id'
@@ -272,12 +327,127 @@ function normalizeDatabase(payload) {
}
}
function normalizeBusinessActions(payload) {
const data = unwrap(payload)
return Array.isArray(data) ? data : []
}
function normalizeProjectId(value) {
const raw = Array.isArray(value) ? value[0] : value
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? String(id) : ''
}
function normalizeModuleIds(value) {
return (Array.isArray(value) ? value : [])
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0)
}
function moduleLabel(module) {
const alias = module.moduleAlias ? ` / ${module.moduleAlias}` : ''
return `${module.moduleName || module.moduleId}${alias}`
}
const ruleTypeLabels = {
EXISTS: '必须存在',
NOT_EXISTS: '必须不存在',
FIELD_EQUALS: '字段等于',
FIELD_NOT_EQUALS: '字段不等于',
FIELD_IN: '字段属于',
NUMBER_GTE: '数值不小于'
}
const effectTypeLabels = {
UPDATE_FIELD: '更新字段',
INSERT_ROW: '新增记录',
UPDATE_FIELDS: '更新多字段',
SET_STATUS: '设置状态',
INCREASE_NUMBER: '数值增加',
DECREASE_NUMBER: '数值减少'
}
function actionRuleItems(action) {
const rules = []
if (Array.isArray(action?.rules)) {
rules.push(...action.rules.map(formatRule).filter(Boolean))
}
if (Array.isArray(action?.ruleChecks)) {
rules.push(...action.ruleChecks.map(formatRule).filter(Boolean))
}
return rules
}
function formatRule(rule) {
if (!rule) return ''
if (typeof rule === 'string') return rule
const parts = [ruleTypeLabels[rule.type] || rule.type || '规则']
const target = formatTarget(rule.targetTable, rule.targetField)
if (target) parts.push(target)
if (Array.isArray(rule.conditionFields) && rule.conditionFields.length) {
parts.push(`条件 ${formatFieldList(rule.conditionFields)}`)
}
if (rule.expectedValue) parts.push(`期望 ${rule.expectedValue}`)
if (Array.isArray(rule.expectedValues) && rule.expectedValues.length) {
parts.push(`可选 ${formatFieldList(rule.expectedValues)}`)
}
if (rule.compareValue) parts.push(`比较 ${rule.compareValue}`)
if (rule.message) parts.push(`提示 ${rule.message}`)
return parts.join(' / ')
}
function formatEffect(effect) {
if (!effect) return ''
if (typeof effect === 'string') return effect
const parts = [effect.description || effectTypeLabels[effect.type] || effect.type || '影响']
const target = formatTarget(effect.targetTable, effect.targetField)
const values = formatValueMap(effect.values)
if (target) parts.push(target)
if (values) parts.push(values)
if (effect.value) parts.push(`${effect.value}`)
if (effect.amount) parts.push(`数量 ${effect.amount}`)
if (effect.minValue) parts.push(`下限 ${effect.minValue}`)
if (Array.isArray(effect.conditionFields) && effect.conditionFields.length) {
parts.push(`条件 ${formatFieldList(effect.conditionFields)}`)
}
if (effect.message) parts.push(`提示 ${effect.message}`)
return parts.join(' / ')
}
function blueprintEffectKey(effect, index) {
if (!effect || typeof effect === 'string') return `effect-${index}-${effect || ''}`
return `effect-${index}-${effect.type || ''}-${effect.targetTable || ''}-${effect.targetField || ''}`
}
function formatTarget(table, field) {
if (table && field) return `${table}.${field}`
return table || field || ''
}
function formatValueMap(values) {
if (!values || typeof values !== 'object' || Array.isArray(values)) return ''
return Object.entries(values)
.map(([key, value]) => `${key}=${value}`)
.join('')
}
function formatFieldList(values) {
return values.filter(Boolean).join('、')
}
function projectPayload() {
return {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
requirementKeyword: projectForm.keyword,
industryTemplate: projectForm.industryTemplate,
frontendEnabled: projectForm.frontendEnabled,
moduleIds: normalizeModuleIds(projectForm.moduleIds)
}
}
function rememberProject(id) {
if (id) {
localStorage.setItem(LAST_PROJECT_KEY, String(id))
@@ -298,6 +468,8 @@ async function loadProjectDraft(value) {
projectForm.projectDesc = project.projectDesc || ''
projectForm.keyword = project.requirementKeyword || projectForm.keyword || ''
projectForm.industryTemplate = project.industryTemplate || ''
projectForm.frontendEnabled = project.frontendEnabled !== '0'
projectForm.moduleIds = normalizeModuleIds(project.moduleIds)
appBlueprint.value = normalizeAppBlueprint(project.appBlueprint)
syncAppBlueprintJson()
database.value = normalizeDatabase(databaseResult)
@@ -310,14 +482,12 @@ async function loadProjectDraft(value) {
}
async function ensureProject() {
if (projectId.value) return projectId.value
if (projectId.value) {
await updateProject(projectId.value, projectPayload())
return projectId.value
}
const result = await createProject({
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
requirementKeyword: projectForm.keyword,
industryTemplate: projectForm.industryTemplate
})
const result = await createProject(projectPayload())
const data = unwrap(result)
projectId.value = data.projectId || data.id
@@ -337,6 +507,18 @@ async function ensureProject() {
return projectId.value
}
async function loadModuleOptions() {
loadingModules.value = true
try {
const result = await listAvailableModules()
availableModules.value = Array.isArray(result) ? result : []
} catch (error) {
ElMessage.warning(error.message || '后台功能模块加载失败')
} finally {
loadingModules.value = false
}
}
function validateRequirementInput() {
if (!projectForm.projectName.trim()) {
ElMessage.warning('请输入项目名称')
@@ -421,6 +603,39 @@ async function handleGenerateDatabase() {
}
}
async function handleGenerateBusinessBlueprint() {
if (!validateRequirementInput()) {
return
}
if (!database.value.tables.length) {
ElMessage.warning('请先生成并确认数据库设计')
return
}
generatingBusinessBlueprint.value = true
errorMessage.value = ''
try {
const id = await ensureProject()
await saveDatabase(id, database.value)
const result = await generateBusinessBlueprint(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword
})
database.value = {
...database.value,
businessActions: normalizeBusinessActions(result)
}
ElMessage.success('业务蓝图已生成')
} catch (error) {
errorMessage.value = error.message || '生成业务蓝图失败,请确认表结构已保存后重试。'
} finally {
generatingBusinessBlueprint.value = false
}
}
async function handleSaveDatabase() {
if (!projectId.value) return
@@ -459,6 +674,7 @@ watch(
)
onMounted(() => {
loadModuleOptions()
if (projectId.value) {
loadProjectDraft(projectId.value)
}
@@ -481,6 +697,10 @@ onMounted(() => {
max-width: 980px;
}
.module-select {
width: 100%;
}
.error-alert {
max-width: 980px;
}

View File

@@ -10,9 +10,12 @@
</div>
</div>
<el-tabs v-model="activeType" class="type-tabs">
<el-tab-pane label="后端" name="backend" />
<el-tab-pane label="前台" name="frontend" />
<el-tab-pane label="后台前端" name="admin_frontend" />
<el-tab-pane
v-for="type in templateTypes"
:key="type.value"
:label="type.label"
:name="type.value"
/>
</el-tabs>
<div class="tree-wrap">
<el-empty v-if="!currentTree.length && !loading" description="暂无项目结构" />
@@ -32,7 +35,7 @@ import { ElMessage } from 'element-plus'
import { Download, Refresh } from '@element-plus/icons-vue'
import CodePreview from '@/components/CodePreview.vue'
import ProjectStructureTree from '@/components/ProjectStructureTree.vue'
import { downloadProject, getFileContent, getProjectStructure } from '@/api/project'
import { downloadProject, getFileContent, getProject, getProjectStructure } from '@/api/project'
import { saveBlob, sourceZipName } from '@/utils/download'
const route = useRoute()
@@ -43,27 +46,37 @@ const codeLoading = ref(false)
const downloading = ref(false)
const code = ref('')
const selectedFileName = ref('')
const projectConfig = ref({})
const structures = reactive({
backend: [],
frontend: [],
admin_frontend: []
})
const templateTypes = computed(() => {
const types = [{ label: '后端', value: 'backend' }]
if (projectConfig.value.frontendEnabled !== '0') {
types.push({ label: '前台', value: 'frontend' })
}
types.push({ label: '后台前端', value: 'admin_frontend' })
return types
})
const currentTree = computed(() => structures[activeType.value] || [])
function unwrap(result) {
return result?.data || result || []
}
function normalizeNodes(nodes, prefix = '') {
function normalizeNodes(nodes, type, prefix = '') {
return (Array.isArray(nodes) ? nodes : []).map((node, index) => {
const label = node.label || node.name || node.path || `文件 ${index + 1}`
const path = node.path || `${prefix}${label}`
const children = normalizeNodes(node.children, `${path}/`)
const children = normalizeNodes(node.children, type, `${path}/`)
return {
...node,
id: node.id || `${activeType.value}:${path}`,
id: node.id || `${type}:${path}`,
label,
path,
type: node.type || (children.length ? 'folder' : 'file'),
@@ -83,13 +96,25 @@ function pickCodeContent(data) {
async function loadType(type) {
const result = await getProjectStructure(projectId.value, type)
const data = unwrap(result)
structures[type] = normalizeNodes(data)
structures[type] = normalizeNodes(data, type)
}
async function loadProjectConfig() {
const result = await getProject(projectId.value)
projectConfig.value = unwrap(result) || {}
if (!templateTypes.value.some((type) => type.value === activeType.value)) {
activeType.value = 'backend'
}
}
async function loadStructures() {
loading.value = true
try {
await Promise.all(['backend', 'frontend', 'admin_frontend'].map(loadType))
await loadProjectConfig()
structures.backend = []
structures.frontend = []
structures.admin_frontend = []
await Promise.all(templateTypes.value.map((type) => loadType(type.value)))
} catch (error) {
ElMessage.error(error.message || '加载项目结构失败')
} finally {