Preview admin chart blocks in the designer

This commit is contained in:
王鹏
2026-06-19 23:49:06 +08:00
parent 36cbbbae22
commit 46140d4b6f
9 changed files with 3506 additions and 756 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"echarts": "^5.4.0",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
<template>
<main class="block-canvas">
<el-empty v-if="!blocks.length" description="拖入左侧业务块开始配置页面" :image-size="80" />
<article
v-for="(block, index) in blocks"
v-else
:key="block.id"
class="canvas-block"
:class="{ active: block.id === selectedId }"
:style="{ gridColumn: `span ${block.span || 12}` }"
role="button"
tabindex="0"
draggable="true"
@click="emit('select-block', block.id)"
@keydown.enter.prevent="emit('select-block', block.id)"
@keydown.space.prevent="emit('select-block', block.id)"
@dragstart="dragIndex = index"
@dragover.prevent
@drop="dropBlock(index)"
>
<div class="canvas-block-toolbar">
<span class="drag-handle">::</span>
<strong class="block-title">{{ block.name || block.blockCode }}</strong>
<small class="block-code">{{ block.blockCode }}</small>
<button type="button" class="remove" @click.stop="emit('remove-block', block.id)">删除</button>
</div>
<ChartBlockPreview
v-if="definitionFor(block)?.kind === 'chart'"
:definition="definitionFor(block)"
:block="block"
/>
</article>
</main>
</template>
<script setup>
import { ref } from 'vue'
import ChartBlockPreview from './ChartBlockPreview.vue'
const props = defineProps({
blocks: { type: Array, default: () => [] },
definitions: { type: Array, default: () => [] },
selectedId: { type: String, default: '' }
})
const emit = defineEmits(['select-block', 'reorder-block', 'remove-block'])
const dragIndex = ref(-1)
function dropBlock(toIndex) {
if (dragIndex.value < 0 || dragIndex.value === toIndex) return
emit('reorder-block', { fromIndex: dragIndex.value, toIndex })
dragIndex.value = -1
}
function definitionFor(block) {
return props.definitions.find((definition) => definition?.code === block?.blockCode) || null
}
</script>
<style scoped lang="scss">
.block-canvas {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
align-content: start;
gap: 12px;
min-height: 320px;
padding: 16px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #fbfdff;
}
.canvas-block {
display: block;
width: 100%;
min-width: 0;
min-height: 52px;
padding: 10px 12px;
border: 1px solid #e7ecf3;
border-radius: 8px;
background: #fff;
color: #303133;
text-align: left;
cursor: pointer;
}
.canvas-block.active {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.08);
}
.canvas-block-toolbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
}
.drag-handle,
.canvas-block small,
.remove {
color: #98a2b3;
font-size: 12px;
}
.block-title,
.block-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove {
padding: 0;
border: 0;
background: transparent;
white-space: nowrap;
cursor: pointer;
}
.remove:hover {
color: #f56c6c;
}
@media (max-width: 900px) {
.canvas-block {
grid-column: 1 / -1 !important;
}
}
</style>

View File

@@ -3,6 +3,7 @@
<BlockPalette v-if="showPalette" :definitions="blockDefinitions" @add-block="addBlock" /> <BlockPalette v-if="showPalette" :definitions="blockDefinitions" @add-block="addBlock" />
<BlockCanvas <BlockCanvas
:blocks="layout.blocks" :blocks="layout.blocks"
:definitions="blockDefinitions"
:selected-id="selectedBlockId" :selected-id="selectedBlockId"
@select-block="selectBlock" @select-block="selectBlock"
@reorder-block="reorderBlock" @reorder-block="reorderBlock"

View File

@@ -0,0 +1,125 @@
<template>
<section class="chart-block-preview">
<header v-if="block?.display?.showTitle !== false" class="preview-header">
<strong>{{ block?.name || definition?.name || '图表' }}</strong>
<small>样例数据</small>
</header>
<div v-if="definition?.chartType === 'metric'" class="metric-preview">
<strong>{{ sample.summary.value.toLocaleString() }}</strong>
<span> {{ sample.summary.count }} </span>
</div>
<div v-else-if="errorMessage" class="preview-error">
<span>{{ errorMessage }}</span>
<el-button size="small" @click.stop="retry">重试</el-button>
</div>
<div v-else ref="chartElement" class="chart-preview-canvas"></div>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { buildChartOption, sampleChartData } from './chartBlock.js'
const props = defineProps({
definition: { type: Object, default: null },
block: { type: Object, default: null }
})
const chartElement = ref(null)
const errorMessage = ref('')
const sample = computed(() => sampleChartData(props.definition))
const option = computed(() => buildChartOption(props.definition, props.block, sample.value))
let chart = null
onMounted(() => {
renderChart()
window.addEventListener('resize', resizeChart)
})
watch(option, renderChart, { deep: true })
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
disposeChart()
})
async function renderChart() {
if (props.definition?.chartType === 'metric') return
await nextTick()
if (!chartElement.value) return
try {
errorMessage.value = ''
if (!chart) {
chart = echarts.init(chartElement.value)
}
chart.setOption(option.value, true)
chart.resize()
} catch (error) {
errorMessage.value = error?.message || '图表预览失败'
disposeChart()
}
}
function resizeChart() {
chart?.resize()
}
function disposeChart() {
if (!chart) return
chart.dispose()
chart = null
}
function retry() {
renderChart()
}
</script>
<style scoped lang="scss">
.chart-block-preview {
display: grid;
gap: 8px;
min-height: 180px;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-header small,
.metric-preview span {
color: #98a2b3;
font-size: 12px;
}
.chart-preview-canvas {
width: 100%;
min-height: 150px;
}
.metric-preview {
display: grid;
align-content: center;
gap: 6px;
min-height: 130px;
}
.metric-preview strong {
color: #172033;
font-size: 30px;
}
.preview-error {
display: grid;
place-items: center;
align-content: center;
gap: 10px;
min-height: 130px;
color: #f56c6c;
}
</style>

View File

@@ -64,3 +64,54 @@ export function validateChartInstance(definition, block, tables) {
} }
return errors return errors
} }
export function sampleChartData(definition) {
if (definition?.chartType === 'metric') {
return { dimensions: [], series: [], summary: { value: 86420, count: 328 } }
}
return {
dimensions: [
{ key: 'A', label: 'A' },
{ key: 'B', label: 'B' },
{ key: 'C', label: 'C' },
{ key: 'D', label: 'D' }
],
series: [{ key: 'metric1', name: '指标 1', values: [32, 51, 46, 78] }],
summary: { value: 207, count: 4 }
}
}
export function buildChartOption(definition, block, data = sampleChartData(definition)) {
const labels = data.dimensions.map((item) => item.label)
const sourceSeries = data.series || []
if (definition?.chartType === 'pie') {
const values = sourceSeries[0]?.values || []
return {
tooltip: { trigger: 'item' },
legend: { show: block?.display?.showLegend !== false },
series: [{
type: 'pie',
radius: block?.display?.donut ? ['45%', '70%'] : '70%',
label: { show: block?.display?.showLabel !== false },
data: labels.map((name, index) => ({ name, value: values[index] ?? 0 }))
}]
}
}
const horizontal = definition?.chartType === 'bar' && block?.display?.horizontal
const series = sourceSeries.map((item) => ({
name: item.name,
type: definition?.chartType === 'bar' ? 'bar' : 'line',
smooth: definition?.chartType === 'line' && Boolean(block?.display?.smooth),
stack: definition?.chartType === 'bar' && block?.display?.stacked ? 'total' : undefined,
areaStyle: definition?.chartType === 'line' && block?.display?.area ? {} : undefined,
data: item.values || []
}))
return {
tooltip: { trigger: 'axis' },
legend: { show: block?.display?.showLegend !== false },
xAxis: horizontal ? { type: 'value' } : { type: 'category', data: labels },
yAxis: horizontal ? { type: 'category', data: labels } : { type: 'value' },
series
}
}

View File

@@ -173,3 +173,18 @@ test('business block canvas supports drag reorder and block removal', () => {
assert.equal(source.includes('reorder-block'), true) assert.equal(source.includes('reorder-block'), true)
assert.equal(source.includes('remove-block'), true) assert.equal(source.includes('remove-block'), true)
}) })
test('chart canvas renders previews in a twelve-column grid', () => {
const canvas = readSource('business-blocks/BlockCanvas.vue')
const previewPath = resolve(currentDir, 'business-blocks/ChartBlockPreview.vue')
assert.equal(existsSync(previewPath), true)
const preview = readSource('business-blocks/ChartBlockPreview.vue')
assert.equal(canvas.includes('grid-template-columns: repeat(12, minmax(0, 1fr))'), true)
assert.equal(canvas.includes(':style="{ gridColumn: `span ${block.span || 12}` }"'), true)
assert.equal(canvas.includes('<ChartBlockPreview'), true)
assert.equal(canvas.includes('role="button"'), true)
assert.equal(preview.includes("import * as echarts from 'echarts'"), true)
assert.equal(preview.includes('chart.resize()'), true)
assert.equal(preview.includes('chart.dispose()'), true)
})

View File

@@ -11,6 +11,14 @@ function readSource(relativePath) {
return readFileSync(resolve(srcDir, relativePath), 'utf8') return readFileSync(resolve(srcDir, relativePath), 'utf8')
} }
function cssBlock(source, selector, offset = 0) {
const start = source.indexOf(`${selector} {`, offset)
assert.notEqual(start, -1)
const end = source.indexOf('\n}', start)
assert.notEqual(end, -1)
return source.slice(start, end)
}
test('project api exposes frontend page design endpoints', () => { test('project api exposes frontend page design endpoints', () => {
const source = readSource('api/project.js') const source = readSource('api/project.js')
@@ -53,7 +61,7 @@ test('frontend page designer reports local draft edits as dirty before commit',
assert.equal(source.includes("emit('dirty-change', true)"), true) assert.equal(source.includes("emit('dirty-change', true)"), true)
assert.equal(source.includes('const suppressDirty = ref(false)'), true) assert.equal(source.includes('const suppressDirty = ref(false)'), true)
assert.equal(source.includes('syncJson({ silent: true })'), 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.pageName" @input="syncPageName"'), true)
assert.equal(source.includes('v-model="draft.routePath" @input="markDirty"'), true) assert.equal(source.includes('v-model="draft.routePath" @input="markDirty"'), true)
}) })
@@ -61,7 +69,8 @@ test('frontend page designer renders page navigation as a normal menu inside the
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('<nav class="page-menu"'), false) assert.equal(source.includes('<nav class="page-menu"'), false)
assert.equal(source.includes('class="canvas-page-menu"'), true) assert.equal(source.includes(':class="[\'canvas\', canvasMenuPositionClass]"'), true)
assert.equal(source.includes(':class="canvasPageMenuClass"'), true)
assert.equal(source.includes('class="canvas-page-menu-bar"'), 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('v-for="group in pageMenuGroups"'), true)
assert.equal(source.includes('@click.stop="selectMenuPage(child)"'), true) assert.equal(source.includes('@click.stop="selectMenuPage(child)"'), true)
@@ -72,13 +81,42 @@ test('frontend page designer renders page navigation as a normal menu inside the
assert.equal(source.includes('const pageMenuGroups = computed'), true) assert.equal(source.includes('const pageMenuGroups = computed'), true)
}) })
test('frontend page designer applies configured menu position to the canvas', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const scriptStart = source.indexOf('const canvasMenuPosition = computed')
const scriptEnd = source.indexOf('const scopedPageMenuDesigns = computed', scriptStart)
const script = source.slice(scriptStart, scriptEnd)
const cssStart = source.indexOf('.canvas.menu-position-side')
const cssEnd = source.indexOf('.canvas-menu-group {', cssStart)
const css = source.slice(cssStart, cssEnd)
assert.notEqual(scriptStart, -1)
assert.equal(script.includes('navigationConfig.value.position'), true)
assert.equal(script.includes('menuPositionOptions'), true)
assert.equal(script.includes('canvasMenuPositionClass'), true)
assert.equal(script.includes('canvasPageMenuClass'), true)
assert.equal(source.includes('value="side"'), true)
assert.equal(source.includes('value="mobile"'), true)
assert.notEqual(cssStart, -1)
assert.equal(css.includes('grid-template-columns: 220px minmax(0, 1fr)'), true)
assert.equal(css.includes('.canvas.menu-position-side .canvas-page-menu'), true)
assert.equal(css.includes('.canvas.menu-position-side > :not(.canvas-page-menu)'), true)
assert.equal(css.includes('.canvas-page-menu.menu-position-side .canvas-page-menu-bar'), true)
assert.equal(css.includes('flex-direction: column'), true)
})
test('frontend page designer hides associated pages from the final menu while keeping them editable', () => { test('frontend page designer hides associated pages from the final menu while keeping them editable', () => {
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('designMatchesScope'), true)
assert.equal(source.includes('const scopedPageMenuDesigns = computed'), true)
assert.equal(source.includes('const editableMenuPages = computed'), true) assert.equal(source.includes('const editableMenuPages = computed'), true)
assert.equal(source.includes('const associatedPages = computed'), true) assert.equal(source.includes('const selectedPageAssociatedPages = computed'), true)
assert.equal(source.includes('const unlinkedAssociatedPages = computed'), true)
assert.equal(source.includes('const visibleAssociatedPages = computed'), true) assert.equal(source.includes('const visibleAssociatedPages = computed'), true)
assert.equal(source.includes('selectedMenuGroup.value?.children.length'), true) assert.equal(source.includes('associatedPagesForDesign(pageMenuDesigns.value, currentDraftDesign.value)'), true)
assert.equal(source.includes('uniqueDesigns(selectedPageAssociatedPages.value.concat(unlinkedAssociatedPages.value))'), true)
assert.equal(source.includes('pageParentPageCode(design)'), true)
assert.equal(source.includes('shouldShowInMenu'), true) assert.equal(source.includes('shouldShowInMenu'), true)
assert.equal(source.includes('isAssociatedPageType'), true) assert.equal(source.includes('isAssociatedPageType'), true)
assert.equal(readSource('utils/pageDesignerMenu.js').includes('pageType === \'detail\''), true) assert.equal(readSource('utils/pageDesignerMenu.js').includes('pageType === \'detail\''), true)
@@ -86,7 +124,9 @@ test('frontend page designer hides associated pages from the final menu while ke
assert.equal(source.includes('buildPageMenuGroups(editableMenuPages.value, { mode: activeMode() })'), true) assert.equal(source.includes('buildPageMenuGroups(editableMenuPages.value, { mode: activeMode() })'), true)
assert.equal(source.includes('关联页面'), true) assert.equal(source.includes('关联页面'), true)
assert.equal(source.includes('v-for="design in visibleAssociatedPages"'), true) assert.equal(source.includes('v-for="design in visibleAssociatedPages"'), true)
assert.equal(source.includes('v-model="navigationConfig.showInMenu"'), true) assert.equal(source.includes('v-model="navigationConfig.showInMenu"'), false)
assert.equal(source.includes(':model-value="currentShowInMenu"'), true)
assert.equal(source.includes('@change="setNavigationVisibility"'), true)
assert.equal(source.includes('显示在菜单'), true) assert.equal(source.includes('显示在菜单'), true)
}) })
@@ -107,6 +147,9 @@ test('frontend page designer keeps menu display name separate from page name', (
assert.equal(source.includes('菜单显示名称'), true) assert.equal(source.includes('菜单显示名称'), true)
assert.equal(source.includes('v-model="navigationConfig.menuName"'), true) assert.equal(source.includes('v-model="navigationConfig.menuName"'), true)
assert.equal(source.includes('@input="syncMenuName"'), true)
assert.equal(source.includes('@input="syncPageName"'), true)
assert.equal(source.includes('function shouldSyncMenuNameWithPageName'), true)
assert.equal(source.includes("currentMenuLevel === 'primary'"), true) assert.equal(source.includes("currentMenuLevel === 'primary'"), true)
}) })
@@ -115,14 +158,211 @@ test('frontend page designer keeps left side focused on adding materials', () =>
assert.equal(source.includes('class="add-content-materials"'), true) assert.equal(source.includes('class="add-content-materials"'), true)
assert.equal(source.includes('@click="addPageDesign(\'primary\')"'), true) assert.equal(source.includes('@click="addPageDesign(\'primary\')"'), true)
assert.equal(source.includes('@click="addPageDesign(\'directory\')"'), true)
assert.equal(source.includes('@click="addPageDesign(\'secondary\')"'), true) assert.equal(source.includes('@click="addPageDesign(\'secondary\')"'), true)
assert.equal(source.includes('class="material-pane"'), 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 field-materials"'), true)
assert.equal(source.includes('class="material-section action-materials"'), true) assert.equal(source.includes('basic-action-materials'), true)
assert.equal(source.includes('business-action-materials'), true)
assert.equal(source.includes('v-if="isBusinessBlockLayout" class="material-section business-materials"'), true) assert.equal(source.includes('v-if="isBusinessBlockLayout" class="material-section business-materials"'), true)
assert.equal(source.includes('<aside class="page-list">'), false) assert.equal(source.includes('<aside class="page-list">'), false)
}) })
test('frontend page designer optimizes add content shortcuts as a compact action stack', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('class="quick-actions optimized-page-actions"'), true)
assert.equal(source.includes('class="primary-page-action"'), true)
assert.equal(source.includes('class="secondary-page-actions"'), true)
assert.equal(source.includes('class="quick-action-icon"'), true)
assert.equal(source.includes('class="quick-action-meta"'), true)
assert.equal(source.includes('<Plus />'), true)
assert.equal(source.includes('<FolderAdd />'), true)
assert.equal(source.includes('<Tickets />'), true)
assert.equal(source.includes('<Setting />'), true)
assert.equal(source.includes('添加页面'), true)
assert.equal(source.includes('主页面'), true)
assert.equal(source.includes('菜单目录'), true)
assert.equal(source.includes('子页面'), true)
assert.equal(source.includes('菜单设置'), true)
assert.equal(source.includes('legacy-page-actions'), false)
assert.equal(source.includes('新增一级菜单'), false)
})
test('frontend page designer keeps material pools in a tabbed left toolbar near the canvas', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('const activeMaterialPanel = ref(\'page\')'), true)
assert.equal(source.includes('const materialPanelTabs = ['), true)
assert.equal(source.includes('const visibleMaterialPanelTabs = computed'), true)
assert.equal(source.includes('class="material-pane-tabs"'), true)
assert.equal(source.includes('v-for="tab in visibleMaterialPanelTabs"'), true)
assert.equal(source.includes(':class="{ active: activeMaterialPanel === tab.value }"'), true)
assert.equal(source.includes('@click="setActiveMaterialPanel(tab.value)"'), true)
assert.equal(source.includes('class="material-panel-scroll"'), true)
assert.equal(source.includes('v-show="activeMaterialPanel === \'field\'"'), true)
assert.equal(source.includes('v-show="activeMaterialPanel === \'action\'"'), true)
assert.equal(source.includes('v-show="activeMaterialPanel === \'business\'"'), true)
assert.equal(source.includes('@click="setActiveMaterialPanel(\'business\')"'), true)
assert.equal(source.includes('@click="setActiveMaterialPanel(\'action\')"'), true)
assert.equal(source.includes('@click="setActiveMaterialPanel(\'field\')"'), true)
})
test('frontend page designer keeps page structure inside page materials', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const tabsStart = source.indexOf('const materialPanelTabs = [')
const tabsEnd = source.indexOf(']', tabsStart)
const tabs = source.slice(tabsStart, tabsEnd)
const pagePanelStart = source.indexOf('v-show="activeMaterialPanel === \'page\'"')
const fieldPanelStart = source.indexOf('v-show="activeMaterialPanel === \'field\'"', pagePanelStart)
const pagePanel = source.slice(pagePanelStart, fieldPanelStart)
assert.equal(tabs.includes("value: 'structure'"), false)
assert.equal(source.includes('v-show="activeMaterialPanel === \'structure\'"'), false)
assert.equal(pagePanel.includes('class="material-section region-materials"'), true)
assert.equal(pagePanel.includes('hiddenRegions'), true)
})
test('frontend page designer pins the material toolbar and scrolls pools internally', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const materialPane = cssBlock(source, '.material-pane')
const materialScroll = cssBlock(source, '.material-panel-scroll')
const compactMediaStart = source.indexOf('@media (max-width: 760px)')
assert.notEqual(compactMediaStart, -1)
const compactMaterialPane = cssBlock(source, '.material-pane', compactMediaStart)
const compactMaterialScroll = cssBlock(source, '.material-panel-scroll', compactMediaStart)
assert.equal(materialPane.includes('position: sticky'), true)
assert.equal(materialPane.includes('align-self: start'), true)
assert.equal(materialPane.includes('height: calc(100vh - 112px)'), true)
assert.equal(materialPane.includes('overflow: hidden'), true)
assert.equal(materialScroll.includes('flex: 1 1 auto'), true)
assert.equal(materialScroll.includes('overflow-y: auto'), true)
assert.equal(materialScroll.includes('overscroll-behavior: contain'), true)
assert.equal(compactMaterialPane.includes('position: sticky'), true)
assert.equal(compactMaterialPane.includes('height: min(58vh, 520px)'), true)
assert.equal(compactMaterialPane.includes('overflow: hidden'), true)
assert.equal(compactMaterialPane.includes('position: static'), false)
assert.equal(compactMaterialScroll.includes('overflow-y: auto'), true)
assert.equal(compactMaterialScroll.includes('overflow: visible'), false)
})
test('frontend page designer supports editable directory menu nodes and top-level drag sorting', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('菜单目录'), true)
assert.equal(source.includes('子页面'), true)
assert.equal(source.includes('selectedConfigType === \'directory\''), true)
assert.equal(source.includes('function addDirectoryChildMenu'), true)
assert.equal(source.includes('function startMenuGroupDrag'), true)
assert.equal(source.includes('function dropMenuGroup'), true)
assert.equal(source.includes('function applyMenuGroupOrder'), true)
assert.equal(source.includes('applyPageMenuGroupOrder(pageMenuDesigns.value, groups, { mode: activeMode() })'), true)
assert.equal(source.includes('draggable="true"'), true)
assert.equal(source.includes('@dragstart.stop="startMenuGroupDrag(group)"'), true)
assert.equal(source.includes('@drop.stop="dropMenuGroup(group)"'), true)
})
test('frontend page designer lets custom primary directories be deleted', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const directoryPaneStart = source.indexOf("selectedConfigType === 'directory'")
const directoryPaneEnd = source.indexOf('<div class="menu-config-list">', directoryPaneStart)
const directoryPane = source.slice(directoryPaneStart, directoryPaneEnd)
assert.equal(directoryPane.includes('删除当前目录'), true)
assert.equal(directoryPane.includes('@click="deletePageDesign(draft)"'), true)
assert.equal(source.includes("pageCode.startsWith('menu_dir_')"), true)
})
test('frontend page designer refreshes secondary menu configuration from current layout state', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const currentLevelStart = source.indexOf('const currentMenuLevel = computed')
const currentLevelEnd = source.indexOf('const currentParentMenuCode = computed', currentLevelStart)
const currentLevel = source.slice(currentLevelStart, currentLevelEnd)
const parentOptionsStart = source.indexOf('const parentMenuOptions = computed')
const parentOptionsEnd = source.indexOf('function inferCurrentParentMenuCode', parentOptionsStart)
const parentOptions = source.slice(parentOptionsStart, parentOptionsEnd)
const helperStart = source.indexOf('function inferCurrentParentMenuCode')
const helperEnd = source.indexOf('const currentMenuLevel = computed', helperStart)
const helper = source.slice(helperStart, helperEnd)
const panelStateStart = source.indexOf('function currentMenuPanelState')
const panelStateEnd = source.indexOf('const menuPanelState = computed', panelStateStart)
const panelState = source.slice(panelStateStart, panelStateEnd)
const setLevelStart = source.indexOf('function setMenuLevel')
const setLevelEnd = source.indexOf('function setParentMenuGroup', setLevelStart)
const setLevel = source.slice(setLevelStart, setLevelEnd)
const setParentStart = source.indexOf('function setParentMenuGroup')
const setParentEnd = source.indexOf('function layoutOptionsForPageType', setParentStart)
const setParent = source.slice(setParentStart, setParentEnd)
assert.equal(panelState.includes('currentNavigationConfig()'), true)
assert.equal(currentLevel.includes('pageMenuLevel(draft.value)'), false)
assert.equal(helper.includes('config.parentMenuCode'), true)
assert.equal(panelState.includes('inferCurrentParentMenuCode()'), true)
assert.equal(panelState.indexOf('inferCurrentParentMenuCode()') < panelState.indexOf('config.menuLevel'), true)
assert.equal(currentLevel.includes('menuPanelState.value.level'), true)
assert.equal(source.includes('class="menu-level-switch"'), true)
assert.equal(source.includes(":class=\"{ active: currentMenuLevel === 'secondary' }\""), true)
assert.equal(source.includes('@click="setMenuLevel(\'secondary\')"'), true)
assert.equal(source.includes('<el-segmented'), false)
assert.equal(source.includes('label="所属一级菜单/目录"'), true)
assert.equal(parentOptions.includes('group.primary'), true)
assert.equal(parentOptions.includes('isMenuDirectoryDesign(group.primary)'), true)
assert.equal(parentOptions.includes('designKey(group.primary) !== selectedKey.value'), true)
assert.equal(setLevel.includes('parentMenuOptions.value'), true)
assert.equal(source.includes('ref="parentMenuSelectRef"'), true)
assert.equal(source.includes('const parentMenuSelectRef = ref(null)'), true)
assert.equal(setParent.includes('parentMenuSelectRef.value?.blur?.()'), true)
})
test('frontend page designer uses one inferred parent source for secondary menu level and parent selector', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const helperStart = source.indexOf('function inferCurrentParentMenuCode')
const helperEnd = source.indexOf('const currentMenuLevel = computed', helperStart)
const helper = source.slice(helperStart, helperEnd)
const currentLevelStart = source.indexOf('const currentMenuLevel = computed')
const currentLevelEnd = source.indexOf('const currentParentMenuCode = computed', currentLevelStart)
const currentLevel = source.slice(currentLevelStart, currentLevelEnd)
const currentParentStart = source.indexOf('const currentParentMenuCode = computed')
const currentParentEnd = source.indexOf('const directoryMenuCode = computed', currentParentStart)
const currentParent = source.slice(currentParentStart, currentParentEnd)
const panelStateStart = source.indexOf('function currentMenuPanelState')
const panelStateEnd = source.indexOf('const menuPanelState = computed', panelStateStart)
const panelState = source.slice(panelStateStart, panelStateEnd)
assert.notEqual(helperStart, -1)
assert.equal(helper.includes('parentMenuOptions.value.find'), true)
assert.equal(helper.includes('config.menuCode'), true)
assert.equal(helper.includes('draft.value?.menuCode'), true)
assert.equal(panelState.includes('inferCurrentParentMenuCode()'), true)
assert.equal(panelState.indexOf('inferCurrentParentMenuCode()') < panelState.indexOf('config.menuLevel'), true)
assert.equal(currentLevel.includes('menuPanelState.value.level'), true)
assert.equal(currentParent.includes('menuPanelState.value.parentCode'), true)
assert.equal(currentParent.includes('pageGroupKey'), false)
})
test('frontend page designer drives menu panel controls from normalized selected state', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const selectStart = source.indexOf('function selectDesign')
const selectEnd = source.indexOf('function addPageDesign', selectStart)
const selectDesign = source.slice(selectStart, selectEnd)
const visibilityStart = source.indexOf('function setNavigationVisibility')
const visibilityEnd = source.indexOf('function shouldSyncMenuNameWithPageName', visibilityStart)
const visibility = source.slice(visibilityStart, visibilityEnd)
assert.equal(source.includes('const menuPanelState = computed(() => currentMenuPanelState())'), true)
assert.equal(source.includes('const currentShowInMenu = computed(() => menuPanelState.value.showInMenu)'), true)
assert.equal(source.includes(':model-value="currentShowInMenu"'), true)
assert.equal(source.includes('v-model="navigationConfig.showInMenu"'), false)
assert.equal(source.includes('@change="setNavigationVisibility"'), true)
assert.equal(source.includes('`menu-level-${selectedKey}-${currentMenuLevel}`'), true)
assert.equal(source.includes('function normalizeCurrentMenuConfig'), true)
assert.equal(source.includes('function applySecondaryParent'), true)
assert.equal(source.includes('function applyPrimaryMenuState'), true)
assert.equal(selectDesign.includes('normalizeCurrentMenuConfig()'), true)
assert.equal(visibility.includes('normalizeCurrentMenuConfig()'), true)
})
test('frontend page designer removes redundant structure and canvas action blocks', () => { test('frontend page designer removes redundant structure and canvas action blocks', () => {
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
@@ -155,6 +395,15 @@ test('frontend page designer keeps right pane focused on selected configuration'
assert.equal(source.includes('pendingSelectPageCode'), true) assert.equal(source.includes('pendingSelectPageCode'), true)
}) })
test('frontend page designer remounts property pane when selected menu changes', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes(':key="propertyPaneKey"'), true)
assert.equal(source.includes('const propertyPaneKey = computed'), true)
assert.equal(source.includes('selectedConfigType.value'), true)
assert.equal(source.includes('selectedKey.value'), true)
})
test('frontend page designer delegates business block pages without replacing normal layouts', () => { test('frontend page designer delegates business block pages without replacing normal layouts', () => {
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
@@ -188,6 +437,16 @@ test('frontend page designer supports embedded business blocks on normal pages',
assert.equal(source.includes('v-if="selectedBusinessBlock || isBusinessBlockLayout"'), true) assert.equal(source.includes('v-if="selectedBusinessBlock || isBusinessBlockLayout"'), true)
}) })
test('embedded admin chart blocks retain span and render previews in grid wrappers', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes("import ChartBlockPreview from '@/components/business-blocks/ChartBlockPreview.vue'"), true)
assert.equal(source.includes('<ChartBlockPreview'), true)
assert.equal(source.includes('embedded-block-grid'), true)
assert.equal(source.includes('block.span || 12'), true)
assert.equal(source.includes("definition?.placements?.includes('embedded')"), true)
})
test('frontend page designer can switch copy and defaults for admin mode', () => { test('frontend page designer can switch copy and defaults for admin mode', () => {
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
@@ -199,6 +458,21 @@ test('frontend page designer can switch copy and defaults for admin mode', () =>
assert.equal(source.includes('designerCopy'), true) assert.equal(source.includes('designerCopy'), true)
}) })
test('frontend page designer keeps admin business block home pages on business block canvas', () => {
const source = readSource('components/FrontendPageDesigner.vue')
const adminOptionsStart = source.indexOf('const adminLayoutOptionsByPageType = {')
const adminOptionsEnd = source.indexOf('const pageTypeLabels = {', adminOptionsStart)
const adminOptions = source.slice(adminOptionsStart, adminOptionsEnd)
const defaultLayoutStart = source.indexOf('function defaultLayout')
const defaultLayoutEnd = source.indexOf('function defaultNavigationConfig', defaultLayoutStart)
const defaultLayout = source.slice(defaultLayoutStart, defaultLayoutEnd)
assert.notEqual(adminOptionsStart, -1)
assert.equal(adminOptions.includes('business_block'), true)
assert.equal(adminOptions.includes('business-blocks-v1'), true)
assert.equal(defaultLayout.includes("pageType === 'business_block' ? 'business-blocks-v1'"), true)
})
test('frontend page designer exposes page-type scoped layout choices', () => { test('frontend page designer exposes page-type scoped layout choices', () => {
const source = readSource('components/FrontendPageDesigner.vue') const source = readSource('components/FrontendPageDesigner.vue')
@@ -265,3 +539,26 @@ test('frontend page designer exposes business actions in the draggable action pa
assert.equal(source.includes('props.database?.businessActions'), true) assert.equal(source.includes('props.database?.businessActions'), true)
assert.equal(source.includes('action.ownerTable === draft.value?.tableName'), false) assert.equal(source.includes('action.ownerTable === draft.value?.tableName'), false)
}) })
test('frontend page designer separates basic and business action pools', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes('基础按钮池'), true)
assert.equal(source.includes('业务按钮池'), true)
assert.equal(source.includes('basic-action-materials'), true)
assert.equal(source.includes('business-action-materials'), true)
assert.equal(source.includes('v-for="action in builtinActionPalette"'), true)
assert.equal(source.includes('v-for="action in businessActionPalette"'), true)
})
test('frontend page designer exposes configurable business button settings', () => {
const source = readSource('components/FrontendPageDesigner.vue')
assert.equal(source.includes("selectedConfigType === 'businessButton'"), true)
assert.equal(source.includes('业务按钮配置'), true)
assert.equal(source.includes('businessButtonConfig'), true)
assert.equal(source.includes('setBusinessButtonConfig'), true)
assert.equal(source.includes('successMessage'), true)
assert.equal(source.includes('confirmMessage'), true)
assert.equal(source.includes('refreshTarget'), true)
})