Fix page designer validation and generation
This commit is contained in:
2589
RuoYi-Vue/easycode-web/src/components/FrontendPageDesigner.vue
Normal file
2589
RuoYi-Vue/easycode-web/src/components/FrontendPageDesigner.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
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('project api exposes frontend page design endpoints', () => {
|
||||
const source = readSource('api/project.js')
|
||||
|
||||
assert.equal(source.includes('export function listPageDesigns'), true)
|
||||
assert.equal(source.includes('export function initPageDesigns'), true)
|
||||
assert.equal(source.includes('export function createPageDesign'), true)
|
||||
assert.equal(source.includes('export function getPageDesign'), true)
|
||||
assert.equal(source.includes('export function savePageDesign'), true)
|
||||
assert.equal(source.includes('export function deletePageDesign'), true)
|
||||
assert.equal(source.includes('export function listBusinessBlocks'), true)
|
||||
assert.equal(source.includes('/page-designs/init'), true)
|
||||
assert.equal(source.includes('url: `/front/project/${projectId}/page-designs`'), true)
|
||||
assert.equal(source.includes('/business-blocks'), true)
|
||||
assert.equal(source.includes('scope = \'frontend\''), true)
|
||||
assert.equal(source.includes('params: { scope }'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer edits layouts and emits saves', () => {
|
||||
assert.equal(existsSync(resolve(srcDir, 'components/FrontendPageDesigner.vue')), true)
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('defineEmits'), true)
|
||||
assert.equal(source.includes('update:designs'), true)
|
||||
assert.equal(source.includes('dirty-change'), true)
|
||||
assert.equal(source.includes('save-design'), true)
|
||||
assert.equal(source.includes('create-design'), true)
|
||||
assert.equal(source.includes('delete-design'), true)
|
||||
assert.equal(source.includes('init-designs'), true)
|
||||
assert.equal(source.includes('layoutJson'), true)
|
||||
assert.equal(source.includes('actionJson'), true)
|
||||
assert.equal(source.includes('draggable="true"'), true)
|
||||
assert.equal(source.includes('function commitCurrentDraft'), true)
|
||||
assert.equal(source.includes('defineExpose'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer reports local draft edits as dirty before commit', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('function markDirty'), true)
|
||||
assert.equal(source.includes("emit('dirty-change', true)"), true)
|
||||
assert.equal(source.includes('const suppressDirty = ref(false)'), true)
|
||||
assert.equal(source.includes('syncJson({ silent: true })'), true)
|
||||
assert.equal(source.includes('v-model="draft.pageName" @input="markDirty"'), true)
|
||||
assert.equal(source.includes('v-model="draft.routePath" @input="markDirty"'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer renders page navigation as a normal menu inside the canvas', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('<nav class="page-menu"'), false)
|
||||
assert.equal(source.includes('class="canvas-page-menu"'), true)
|
||||
assert.equal(source.includes('class="canvas-page-menu-bar"'), true)
|
||||
assert.equal(source.includes('v-for="group in pageMenuGroups"'), true)
|
||||
assert.equal(source.includes('@click.stop="selectMenuPage(child)"'), true)
|
||||
assert.equal(source.includes('@click.stop="selectMenuGroup(group)"'), true)
|
||||
assert.equal(source.includes('@click.stop="toggleMenuGroup(group.key)"'), true)
|
||||
assert.equal(source.includes('class="canvas-menu-config"'), true)
|
||||
assert.equal(source.includes('function selectMenuConfig'), true)
|
||||
assert.equal(source.includes('const pageMenuGroups = computed'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer hides associated pages from the final menu while keeping them editable', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('const editableMenuPages = computed'), true)
|
||||
assert.equal(source.includes('const associatedPages = computed'), true)
|
||||
assert.equal(source.includes('const visibleAssociatedPages = computed'), true)
|
||||
assert.equal(source.includes('selectedMenuGroup.value?.children.length'), true)
|
||||
assert.equal(source.includes('shouldShowInMenu'), true)
|
||||
assert.equal(source.includes('isAssociatedPageType'), true)
|
||||
assert.equal(readSource('utils/pageDesignerMenu.js').includes('pageType === \'detail\''), true)
|
||||
assert.equal(source.includes('v-for="group in pageMenuGroups"'), true)
|
||||
assert.equal(source.includes('buildPageMenuGroups(editableMenuPages.value, { mode: activeMode() })'), true)
|
||||
assert.equal(source.includes('关联页面'), true)
|
||||
assert.equal(source.includes('v-for="design in visibleAssociatedPages"'), true)
|
||||
assert.equal(source.includes('v-model="navigationConfig.showInMenu"'), true)
|
||||
assert.equal(source.includes('显示在菜单'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer selects primary page from menu label while arrow toggles children', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
const functionStart = source.indexOf('function selectMenuGroup(group)')
|
||||
const functionEnd = source.indexOf('function selectMenuPage', functionStart)
|
||||
const functionBody = source.slice(functionStart, functionEnd)
|
||||
|
||||
assert.equal(source.includes('@click.stop="selectMenuGroup(group)"'), true)
|
||||
assert.equal(source.includes('@click.stop="toggleMenuGroup(group.key)"'), true)
|
||||
assert.match(functionBody, /if \(group\?\.primary\)[\s\S]*?selectDesign\(group\.primary\)[\s\S]*?return/)
|
||||
assert.equal(functionBody.indexOf('if (group?.primary)') < functionBody.indexOf('if (group?.children?.length)'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer keeps menu display name separate from page name', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('菜单显示名称'), true)
|
||||
assert.equal(source.includes('v-model="navigationConfig.menuName"'), true)
|
||||
assert.equal(source.includes("currentMenuLevel === 'primary'"), true)
|
||||
})
|
||||
|
||||
test('frontend page designer keeps left side focused on adding materials', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('class="add-content-materials"'), true)
|
||||
assert.equal(source.includes('@click="addPageDesign(\'primary\')"'), true)
|
||||
assert.equal(source.includes('@click="addPageDesign(\'secondary\')"'), true)
|
||||
assert.equal(source.includes('class="material-pane"'), true)
|
||||
assert.equal(source.includes('class="material-section field-materials"'), true)
|
||||
assert.equal(source.includes('class="material-section action-materials"'), true)
|
||||
assert.equal(source.includes('v-if="isBusinessBlockLayout" class="material-section business-materials"'), true)
|
||||
assert.equal(source.includes('<aside class="page-list">'), false)
|
||||
})
|
||||
|
||||
test('frontend page designer removes redundant structure and canvas action blocks', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('class="structure-materials"'), false)
|
||||
assert.equal(source.includes('当前页面结构'), false)
|
||||
assert.equal(source.includes('v-for="item in pageStructureItems"'), false)
|
||||
assert.equal(source.includes('class="canvas-menu-actions"'), false)
|
||||
assert.equal(source.includes('.canvas-menu-actions'), false)
|
||||
})
|
||||
|
||||
test('frontend page designer does not commit stale draft while reconciling deleted pages', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('function selectDesign(design, shouldCommit = true)'), true)
|
||||
assert.equal(source.includes('if (shouldCommit && draft.value && selectedKey.value && nextKey !== selectedKey.value)'), true)
|
||||
assert.equal(source.includes('selectDesign(selected, false)'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer keeps right pane focused on selected configuration', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
const propertyPaneStart = source.indexOf('<aside class="property-pane">')
|
||||
const propertyPaneEnd = source.indexOf('</aside>', propertyPaneStart)
|
||||
const propertyPane = source.slice(propertyPaneStart, propertyPaneEnd)
|
||||
|
||||
assert.equal(propertyPane.includes('field-matrix'), false)
|
||||
assert.equal(propertyPane.includes('action-editor'), false)
|
||||
assert.equal(propertyPane.includes('字段池'), false)
|
||||
assert.equal(propertyPane.includes('按钮池'), false)
|
||||
assert.equal(source.includes('function addPageDesign'), true)
|
||||
assert.equal(source.includes('pendingSelectPageCode'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer delegates business block pages without replacing normal layouts', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes("import BusinessBlockDesigner from '@/components/business-blocks/BusinessBlockDesigner.vue'"), true)
|
||||
assert.equal(source.includes("import BlockConfigForm from '@/components/business-blocks/BlockConfigForm.vue'"), true)
|
||||
assert.equal(source.includes("blockDefinitions: { type: Array, default: () => [] }"), true)
|
||||
assert.equal(source.includes('business-blocks-v1'), true)
|
||||
assert.equal(source.includes('isBusinessBlockLayout'), true)
|
||||
assert.equal(source.includes('<BusinessBlockDesigner'), true)
|
||||
assert.equal(source.includes(':show-config="false"'), true)
|
||||
assert.equal(source.includes(':show-palette="false"'), true)
|
||||
assert.equal(source.includes('@select-block="handleBusinessBlockSelect"'), true)
|
||||
assert.equal(source.includes('addBusinessBlock'), true)
|
||||
assert.equal(source.includes('business-block-config-panel'), true)
|
||||
assert.equal(source.includes('updateSelectedBusinessBlockConfig'), true)
|
||||
assert.equal(source.includes('applyBusinessBlockLayoutJson'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer supports embedded business blocks on normal pages', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('embedded-business-materials'), true)
|
||||
assert.equal(source.includes('embedded-block-band'), true)
|
||||
assert.equal(source.includes('embeddedBlocks'), true)
|
||||
assert.equal(source.includes('embeddedBlockDefinitions'), true)
|
||||
assert.equal(source.includes('function addEmbeddedBusinessBlock'), true)
|
||||
assert.equal(source.includes('function dropEmbeddedBusinessBlock'), true)
|
||||
assert.equal(source.includes('function removeEmbeddedBusinessBlock'), true)
|
||||
assert.equal(source.includes('selectedBusinessBlockZone'), true)
|
||||
assert.equal(source.includes("placement === 'embedded'"), true)
|
||||
assert.equal(source.includes('v-if="selectedBusinessBlock || isBusinessBlockLayout"'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer can switch copy and defaults for admin mode', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('mode: { type: String, default: \'frontend\' }'), true)
|
||||
assert.equal(source.includes('后台页面设计'), true)
|
||||
assert.equal(source.includes('先从后台菜单和数据表初始化后台页面设计'), true)
|
||||
assert.equal(source.includes('admin-list-v1'), true)
|
||||
assert.equal(source.includes('admin-list-dense-v1'), true)
|
||||
assert.equal(source.includes('designerCopy'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer exposes page-type scoped layout choices', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('布局类型'), true)
|
||||
assert.equal(source.includes('layoutOptionsByPageType'), true)
|
||||
assert.equal(source.includes('frontend-list-card-v1'), true)
|
||||
assert.equal(source.includes('frontend-current-user-card-v1'), true)
|
||||
assert.equal(source.includes('frontend-detail-media-v1'), true)
|
||||
assert.equal(source.includes('frontend-form-group-v1'), true)
|
||||
assert.equal(source.includes('@change="selectLayout"'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer supports visual canvas regions and palette dragging', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('字段池'), true)
|
||||
assert.equal(source.includes('按钮池'), true)
|
||||
assert.equal(source.includes('删除区域'), true)
|
||||
assert.equal(source.includes('region-materials'), true)
|
||||
assert.equal(source.includes('图片字段'), true)
|
||||
assert.equal(source.includes('dropPaletteField'), true)
|
||||
assert.equal(source.includes('dropAction'), true)
|
||||
assert.equal(source.includes('cardRegion'), true)
|
||||
assert.equal(source.includes('setRegionVisible'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer lets placed action buttons be removed', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('function removeAction'), true)
|
||||
assert.equal(source.includes("removeAction('toolbar', index)"), true)
|
||||
assert.equal(source.includes("removeAction('cardActions', actionIndex)"), true)
|
||||
assert.equal(source.includes("removeAction('table', actionIndex)"), true)
|
||||
assert.equal(source.includes('class="chip-remove"'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer lets placed fields be removed from canvas regions', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('function removeRegionField'), true)
|
||||
assert.equal(source.includes("removeRegionField('query', index)"), true)
|
||||
assert.equal(source.includes("removeRegionField('table', index)"), true)
|
||||
assert.equal(source.includes("removeRegionField('card', fieldIndex)"), true)
|
||||
assert.equal(source.includes("removeRegionField('form', index)"), true)
|
||||
assert.equal(source.includes('title="删除字段"'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer exposes row action drop zones in non-card layouts', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('class="table-actions drop-zone"'), true)
|
||||
assert.equal(source.includes("dropAction('table')"), true)
|
||||
assert.equal(source.includes("dropAction('table', actionIndex)"), true)
|
||||
assert.equal(source.includes("startRegionActionDrag('table', actionIndex)"), true)
|
||||
})
|
||||
|
||||
test('frontend page designer exposes business actions in the draggable action palette', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('businessActionPalette'), true)
|
||||
assert.equal(source.includes('sortBusinessActionPalette'), true)
|
||||
assert.equal(source.includes('businessActionCode(action)'), true)
|
||||
assert.equal(source.includes("`business:${action.code}`"), true)
|
||||
assert.equal(source.includes('props.database?.businessActions'), true)
|
||||
assert.equal(source.includes('action.ownerTable === draft.value?.tableName'), false)
|
||||
})
|
||||
464
RuoYi-Vue/easycode-web/src/views/PageDesignerView.vue
Normal file
464
RuoYi-Vue/easycode-web/src/views/PageDesignerView.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<section class="designer-shell-page" v-loading="loading">
|
||||
<header class="designer-topbar">
|
||||
<div class="designer-title">
|
||||
<span class="breadcrumb">{{ projectName || '项目' }} / 页面设计器</span>
|
||||
<h1>{{ scopeTitle }}</h1>
|
||||
<p>{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="designer-actions">
|
||||
<span v-if="hasUnsavedChanges" class="dirty-badge">有未保存修改</span>
|
||||
<el-button :disabled="!projectId" :loading="previewing" @click="handlePreview">预览页面</el-button>
|
||||
<el-button type="primary" :disabled="!projectId" :loading="saving" @click="saveAllDesigns">保存设计</el-button>
|
||||
<el-button @click="handleBack">返回工作台</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="designer-toolbar">
|
||||
<el-segmented v-model="scopeProxy" :options="scopeOptions" />
|
||||
<div class="toolbar-meta">
|
||||
<span>{{ pageDesigns.length }} 个页面</span>
|
||||
<span>{{ database.tables.length }} 张数据表</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
class="designer-alert"
|
||||
type="warning"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="designer-card">
|
||||
<FrontendPageDesigner
|
||||
ref="designerRef"
|
||||
:designs="pageDesigns"
|
||||
:database="database"
|
||||
:block-definitions="scope === 'frontend' ? businessBlockDefinitions : []"
|
||||
:mode="scope"
|
||||
:show-header="false"
|
||||
:saving="saving"
|
||||
:initializing="initializing"
|
||||
@update:designs="handleDesignsUpdate"
|
||||
@dirty-change="handleDirtyChange"
|
||||
@init-designs="handleInitPageDesigns"
|
||||
@create-design="handleCreatePageDesign"
|
||||
@delete-design="handleDeletePageDesign"
|
||||
@save-design="handleSavePageDesign"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import FrontendPageDesigner from '@/components/FrontendPageDesigner.vue'
|
||||
import {
|
||||
createPageDesign,
|
||||
deletePageDesign,
|
||||
generateProject,
|
||||
getDatabase,
|
||||
getProject,
|
||||
initPageDesigns,
|
||||
listBusinessBlocks,
|
||||
listPageDesigns,
|
||||
savePageDesign
|
||||
} from '@/api/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const designerRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const initializing = ref(false)
|
||||
const previewing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const projectName = ref('')
|
||||
const database = ref({ tables: [], sql: '', erDiagram: emptyErDiagram(), businessActions: [] })
|
||||
const pageDesigns = ref([])
|
||||
const businessBlockDefinitions = ref([])
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
const projectId = computed(() => normalizeProjectId(route.params.projectId || route.query.projectId))
|
||||
const scope = computed(() => route.query.scope === 'admin' ? 'admin' : 'frontend')
|
||||
const scopeProxy = computed({
|
||||
get: () => scope.value,
|
||||
set: (value) => {
|
||||
router.replace({
|
||||
path: `/project/${projectId.value}/page-designer`,
|
||||
query: { ...route.query, scope: value }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const scopeOptions = [
|
||||
{ label: '前台', value: 'frontend' },
|
||||
{ label: '后台', value: 'admin' }
|
||||
]
|
||||
|
||||
const scopeTitle = computed(() => scope.value === 'admin' ? '后台页面设计' : '前台页面设计')
|
||||
const scopeDescription = computed(() => scope.value === 'admin'
|
||||
? '配置后台管理页的查询区、工具栏、表格列、表单字段、批量操作和行按钮。'
|
||||
: '配置前台页面的字段、查询区、卡片列表、按钮和公告、轮播等业务块。')
|
||||
|
||||
function emptyErDiagram() {
|
||||
return {
|
||||
positions: {},
|
||||
relations: [],
|
||||
deletedEdgeIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectId(value) {
|
||||
const id = Number(value)
|
||||
return Number.isFinite(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
function normalizeErDiagram(value) {
|
||||
const source = value && typeof value === 'object' ? value : {}
|
||||
return {
|
||||
positions: source.positions && typeof source.positions === 'object' ? source.positions : {},
|
||||
relations: Array.isArray(source.relations) ? source.relations : [],
|
||||
deletedEdgeIds: Array.isArray(source.deletedEdgeIds) ? source.deletedEdgeIds : []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDatabase(payload) {
|
||||
const data = payload?.data || payload || {}
|
||||
return {
|
||||
tables: Array.isArray(data.tables) ? data.tables : [],
|
||||
sql: data.sql || '',
|
||||
erDiagram: normalizeErDiagram(data.erDiagram),
|
||||
businessActions: Array.isArray(data.businessActions) ? data.businessActions : []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDesignerData() {
|
||||
if (!projectId.value) {
|
||||
errorMessage.value = '缺少项目 ID,请从工作台重新进入页面设计器'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [projectResult, databaseResult, designResult, blockResult] = await Promise.all([
|
||||
getProject(projectId.value),
|
||||
getDatabase(projectId.value),
|
||||
listPageDesigns(projectId.value, scope.value),
|
||||
listBusinessBlocks(projectId.value)
|
||||
])
|
||||
projectName.value = projectResult?.projectName || ''
|
||||
database.value = normalizeDatabase(databaseResult)
|
||||
pageDesigns.value = Array.isArray(designResult) ? designResult : []
|
||||
businessBlockDefinitions.value = Array.isArray(blockResult) ? blockResult : []
|
||||
hasUnsavedChanges.value = false
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '加载页面设计器失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDesignsUpdate(designs) {
|
||||
pageDesigns.value = Array.isArray(designs) ? designs : []
|
||||
hasUnsavedChanges.value = true
|
||||
}
|
||||
|
||||
function handleDirtyChange(isDirty) {
|
||||
if (isDirty) {
|
||||
hasUnsavedChanges.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInitPageDesigns() {
|
||||
if (!projectId.value) return
|
||||
initializing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await initPageDesigns(projectId.value, scope.value)
|
||||
pageDesigns.value = Array.isArray(result?.designs) ? result.designs : []
|
||||
if (!pageDesigns.value.length) {
|
||||
const refreshed = await listPageDesigns(projectId.value, scope.value)
|
||||
pageDesigns.value = Array.isArray(refreshed) ? refreshed : []
|
||||
}
|
||||
hasUnsavedChanges.value = false
|
||||
ElMessage.success(`${scopeTitle.value}已初始化`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || `初始化${scopeTitle.value}失败`
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePageDesign(design) {
|
||||
if (!projectId.value) return
|
||||
const wasDirty = hasUnsavedChanges.value
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const saved = await createPageDesign(projectId.value, design, scope.value)
|
||||
pageDesigns.value = pageDesigns.value.concat(saved)
|
||||
hasUnsavedChanges.value = wasDirty
|
||||
ElMessage.success('页面已新增')
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '新增页面失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePageDesign(design) {
|
||||
if (!projectId.value || !design?.designId) return
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const saved = await savePageDesign(projectId.value, design.designId, design, scope.value)
|
||||
replacePageDesign(saved)
|
||||
hasUnsavedChanges.value = false
|
||||
ElMessage.success('页面设计已保存')
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '保存页面设计失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePageDesign(design) {
|
||||
if (!projectId.value || !design?.designId) return
|
||||
const confirmed = await confirmDeletePage(design)
|
||||
if (!confirmed) return
|
||||
const wasDirty = hasUnsavedChanges.value
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await deletePageDesign(projectId.value, design.designId, scope.value)
|
||||
removePageDesign(design)
|
||||
hasUnsavedChanges.value = wasDirty
|
||||
ElMessage.success('页面已删除')
|
||||
} catch (error) {
|
||||
if (shouldRemoveMissingPage(error)) {
|
||||
removePageDesign(design)
|
||||
ElMessage.warning('页面已不存在,已从列表移除')
|
||||
return
|
||||
}
|
||||
errorMessage.value = error.message || '删除页面失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replacePageDesign(saved) {
|
||||
pageDesigns.value = pageDesigns.value.map((item) => {
|
||||
const sameDesign = String(item.designId || item.pageCode) === String(saved.designId || saved.pageCode)
|
||||
return sameDesign ? saved : item
|
||||
})
|
||||
}
|
||||
|
||||
function removePageDesign(deleted) {
|
||||
pageDesigns.value = pageDesigns.value.filter((item) => {
|
||||
return String(item.designId || item.pageCode) !== String(deleted.designId || deleted.pageCode)
|
||||
})
|
||||
}
|
||||
|
||||
function shouldRemoveMissingPage(error) {
|
||||
const message = String(error?.message || '')
|
||||
return message.includes('不存在') || message.includes('无权限') || message.includes('404')
|
||||
}
|
||||
|
||||
async function confirmDeletePage(design) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除页面“${design.pageName || design.pageCode}”?`, '删除页面', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAllDesigns() {
|
||||
if (!projectId.value) return
|
||||
designerRef.value?.commitCurrentDraft?.()
|
||||
const pendingDesigns = pageDesigns.value.filter((design) => design?.designId)
|
||||
if (!pendingDesigns.length) {
|
||||
hasUnsavedChanges.value = false
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
for (const design of pendingDesigns) {
|
||||
const saved = await savePageDesign(projectId.value, design.designId, design, scope.value)
|
||||
replacePageDesign(saved)
|
||||
}
|
||||
hasUnsavedChanges.value = false
|
||||
ElMessage.success('页面设计已保存')
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '保存页面设计失败'
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
if (!projectId.value) return
|
||||
previewing.value = true
|
||||
try {
|
||||
await saveAllDesigns()
|
||||
await generateProject(projectId.value)
|
||||
router.push(`/project/${projectId.value}/preview`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '生成预览失败'
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLeaveDirty() {
|
||||
if (!hasUnsavedChanges.value) return true
|
||||
try {
|
||||
await ElMessageBox.confirm('当前页面设计有未保存修改,确认离开吗?', '未保存修改', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '离开',
|
||||
cancelButtonText: '继续编辑'
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBack() {
|
||||
router.push({ path: '/generate', query: { projectId: projectId.value } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [projectId.value, scope.value],
|
||||
() => {
|
||||
loadDesignerData()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(loadDesignerData)
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
return confirmLeaveDirty()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate(async () => {
|
||||
return confirmLeaveDirty()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.designer-shell-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: calc(100vh - 76px);
|
||||
padding: 20px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.designer-topbar,
|
||||
.designer-toolbar,
|
||||
.designer-card {
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.designer-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.designer-title {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.designer-title h1 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.designer-title p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.designer-actions,
|
||||
.designer-toolbar,
|
||||
.toolbar-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.designer-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.designer-toolbar {
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.toolbar-meta {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dirty-badge {
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fff8e6;
|
||||
color: #b7791f;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.designer-alert {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.designer-card {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.designer-card :deep(.page-designer-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.designer-topbar,
|
||||
.designer-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.designer-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
64
RuoYi-Vue/easycode-web/src/views/pageDesignerView.test.mjs
Normal file
64
RuoYi-Vue/easycode-web/src/views/pageDesignerView.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
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('router exposes standalone page designer route', () => {
|
||||
const routerSource = readSource('router/index.js')
|
||||
|
||||
assert.equal(routerSource.includes("import PageDesignerView from '@/views/PageDesignerView.vue'"), true)
|
||||
assert.equal(routerSource.includes("path: '/project/:projectId/page-designer'"), true)
|
||||
assert.equal(routerSource.includes("name: 'page-designer'"), true)
|
||||
assert.equal(routerSource.includes('component: PageDesignerView'), true)
|
||||
})
|
||||
|
||||
test('standalone page designer wraps the focused designer component', () => {
|
||||
assert.equal(existsSync(resolve(srcDir, 'views/PageDesignerView.vue')), true)
|
||||
const source = readSource('views/PageDesignerView.vue')
|
||||
|
||||
assert.equal(source.includes("import FrontendPageDesigner from '@/components/FrontendPageDesigner.vue'"), true)
|
||||
assert.equal(source.includes('<FrontendPageDesigner'), true)
|
||||
assert.equal(source.includes(':show-header="false"'), true)
|
||||
assert.equal(source.includes('designer-shell-page'), true)
|
||||
assert.equal(source.includes('designer-topbar'), true)
|
||||
assert.equal(source.includes('保存设计'), true)
|
||||
assert.equal(source.includes('返回工作台'), true)
|
||||
assert.equal(source.includes('预览页面'), true)
|
||||
})
|
||||
|
||||
test('standalone page designer loads scope-specific designs and business blocks', () => {
|
||||
const source = readSource('views/PageDesignerView.vue')
|
||||
|
||||
assert.equal(source.includes('const scope = computed'), true)
|
||||
assert.equal(source.includes("route.query.scope === 'admin'"), true)
|
||||
assert.equal(source.includes('listPageDesigns(projectId.value, scope.value)'), true)
|
||||
assert.equal(source.includes('listBusinessBlocks(projectId.value)'), true)
|
||||
assert.equal(source.includes('initPageDesigns(projectId.value, scope.value)'), true)
|
||||
assert.equal(source.includes('createPageDesign(projectId.value, design, scope.value)'), true)
|
||||
assert.equal(source.includes('savePageDesign(projectId.value, design.designId, design, scope.value)'), true)
|
||||
assert.equal(source.includes('deletePageDesign(projectId.value, design.designId, scope.value)'), true)
|
||||
})
|
||||
|
||||
test('standalone page designer guards dirty local edits across scope route changes', () => {
|
||||
const source = readSource('views/PageDesignerView.vue')
|
||||
|
||||
assert.equal(source.includes('@dirty-change="handleDirtyChange"'), true)
|
||||
assert.equal(source.includes('function handleDirtyChange'), true)
|
||||
assert.equal(source.includes('onBeforeRouteUpdate'), true)
|
||||
assert.equal(source.includes('return confirmLeaveDirty()'), true)
|
||||
})
|
||||
|
||||
test('frontend page designer can hide its internal header when hosted by standalone page', () => {
|
||||
const source = readSource('components/FrontendPageDesigner.vue')
|
||||
|
||||
assert.equal(source.includes('showHeader: { type: Boolean, default: true }'), true)
|
||||
assert.equal(source.includes('v-if="showHeader"'), true)
|
||||
})
|
||||
Reference in New Issue
Block a user