20260911173133

This commit is contained in:
oneao committed 2026-09-11 17:31:34 +08:00
1 parent 3838ed74b1
commit f3ad727f1c
60 files changed
+4971 -6621

No files matched your search

@@ -1,32 +1,40 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleListConfigPanel from '@/views/module/module-management/components/ModuleListConfigPanel.vue'
import ModuleListConfigPanel from '@/views/module/module-management/ModuleListConfigPanel.vue'
/**
* 列表配置面板与子表设计共用 useStructureEditing / ModuleStructureTree / TablePreview,
* 拖拽语义与 module-table-edit-panel-dnd.spec 同源;本文件聚焦:
* 1) 共用语义在列表侧(s_field_view 行)同样成立;
* 2) 列表侧 features 差异(无分组启停菜单、列节点无删除按钮);
* 3) 分组删除经 update-groups(groups, removedGroupIds) 上抛级联清理。
* 列表配置面板(V2:view JSON 节点模型)结构编辑测试。
* 面板对外数据模型为 s_module_schema.b_schema_json({ schemaVersion, columns: [...] }),
* 列节点为 { field, ... },分组节点为 { type: 'group', title, children: [...] }。
* 编辑器内部经 schemaModel 适配复用结构编辑内核;外部只观察 update:modelValue 的 JSON。
*/
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_i18n: 'field.sys.name' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number', b_i18n: 'field.sys.age' },
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select', b_i18n: 'field.sys.status' },
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea', b_i18n: 'field.sys.remark' },
]
const groups = [
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 },
]
const listConfig = [
{ b_id: 'v1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_i18n: null, b_visible: 1, b_width: 120, b_xh: 10 },
{ b_id: 'v2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 100, b_xh: 20 },
{ b_id: 'v3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 160, b_xh: 30 },
{ b_field: 'name', b_name: '姓名', b_type: 'input' },
{ b_field: 'age', b_name: '年龄', b_type: 'number' },
{ b_field: 'status', b_name: '状态', b_type: 'select' },
{ b_field: 'remark', b_name: '备注', b_type: 'textarea' },
]
function baseSchema() {
return {
schemaVersion: 1,
columns: [
{ field: 'name', width: 120 },
{
type: 'group',
title: '基本信息',
children: [
{ field: 'age', width: 100 },
{ field: 'status', width: 160 },
],
},
],
}
}
const StubDraggable = {
name: 'draggable',
props: {
@@ -64,10 +72,8 @@ function mountPanel(extraProps = {}) {
return mount(ModuleListConfigPanel, {
props: {
moduleId: 'm1',
moduleCode: 'sys_user',
listConfig: listConfig.map((r) => ({ ...r })),
fields,
groups: groups.map((g) => ({ ...g })),
modelValue: baseSchema(),
...extraProps,
},
global: { stubs: { draggable: StubDraggable } },
@@ -88,7 +94,6 @@ function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
el.dispatchEvent(ev)
}
/* VTU trigger 会把 dragover/drop 构造为 DragEvent(坐标只有 getter),手工注入 */
function fireDrag(el, type, { clientX = 10, clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
@@ -118,13 +123,21 @@ function endMouseDrag(wrapper) {
wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
}
function lastUpdate(wrapper) {
const updates = wrapper.emitted('update')
function lastSchema(wrapper) {
const updates = wrapper.emitted('update:modelValue')
expect(updates?.length).toBeGreaterThan(0)
return updates.at(-1)[0]
}
describe('ModuleListConfigPanel 结构编辑(与子表设计共用内核)', () => {
function groupNode(schema, title) {
return (schema.columns || []).find((n) => n.type === 'group' && n.title === title)
}
function topLevelFields(schema) {
return (schema.columns || []).filter((n) => n.type !== 'group').map((n) => n.field)
}
describe('ModuleListConfigPanel 结构编辑(view JSON 节点模型)', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.elementFromPoint = () => hitElement
@@ -142,150 +155,107 @@ describe('ModuleListConfigPanel 结构编辑(与子表设计共用内核)',
Element.prototype.scrollIntoView = vi.fn()
})
it('列拖到分组节点(inside):移入该分组直属列末尾', () => {
it('列拖到分组节点(inside):移入该分组子节点末尾', async () => {
const wrapper = mountPanel()
// 姓名(v1,未分组) 拖到分组「扩展信息」(g2) 行中部
startMouseDrag(wrapper, '姓名')
moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '1'])
const schema = lastSchema(wrapper)
const group = groupNode(schema, '基本信息')
expect(group.children.map((n) => n.field)).toEqual(['age', 'status', 'name'])
expect(topLevelFields(schema)).toEqual([])
})
it('列拖到「未分组」收纳节点:移出分组', () => {
it('列拖到「未分组」收纳节点:移出分组回到顶层', async () => {
const wrapper = mountPanel()
// 年龄(v2,属 g1) 拖到「未分组」
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '未分组', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息').children.map((n) => n.field)).toEqual(['status'])
expect(topLevelFields(schema).sort()).toEqual(['age', 'name'])
})
it('分组拖到分组带上(inside):嵌套(b_parent_id 指向目标)', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部
it('分组拖到分组带上(inside):嵌套为子分组', async () => {
const wrapper = mountPanel({
modelValue: {
schemaVersion: 1,
columns: [
{
type: 'group',
title: '基本信息',
children: [{ field: 'name' }],
},
{
type: 'group',
title: '扩展信息',
children: [{ field: 'remark' }],
},
],
},
})
startMouseDrag(wrapper, '扩展信息')
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
await nextTick()
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const nextGroups = groupUpdates.at(-1)[0]
expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1')
const schema = lastSchema(wrapper)
const outer = groupNode(schema, '基本信息')
const nested = outer.children.find((n) => n.type === 'group')
expect(nested.title).toBe('扩展信息')
})
it('删除分组:update-groups 带 removedGroupIds,组内行归未分组', () => {
it('删除分组:分组节点消失,组内字段回到顶层', async () => {
const wrapper = mountPanel()
// 树上「基本信息」(g1) 节点常驻删除按钮
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(row).toBeTruthy()
const delBtn = row.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
delBtn.trigger('click')
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const [nextGroups, removedIds] = groupUpdates.at(-1)
expect(nextGroups.map((g) => g.b_id)).toEqual(['g2'])
expect(removedIds).toEqual(['g1'])
// g1 下的行(年龄/状态)归未分组并重排
const rows = lastUpdate(wrapper)
expect(rows.every((r) => r.b_group_id == null)).toBe(true)
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30])
})
it('列表侧 features:分组右键无「启停」,列节点有删除按钮(同表格设计)', async () => {
const wrapper = mountPanel()
// 列节点(姓名)渲染删除按钮
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(colRow).toBeTruthy()
expect(colRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
// 分组节点也有删除按钮
const groupRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(groupRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
})
it('树中列节点删除按钮:删除 = 隐藏(b_visible=0),行保留', async () => {
const wrapper = mountPanel()
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
const delBtn = colRow.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
// 行不删(启用字段始终有配置行),仅置隐藏;预览该列置灰带「隐藏」标记
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息')).toBeUndefined()
expect(topLevelFields(schema).sort()).toEqual(['age', 'name', 'status'])
})
it('预览表渲染多级表头:band 行含分组标题,列头含字段名', () => {
const wrapper = mountPanel()
const bands = wrapper.findAll('th.mm-tdesign__band')
expect(bands.map((th) => th.text())).toContain('基本信息')
// 未分组列(姓名)表头贯穿 band 行
const ungroupedCell = wrapper
.findAll('th.mm-tdesign__cell--ungrouped')
.find((th) => th.attributes('data-field-id') === '1')
expect(ungroupedCell).toBeTruthy()
expect(ungroupedCell.text()).toContain('姓名')
})
it('字段库字段点击加入:未加入字段点击后生成配置行并选中', async () => {
it('字段库点击未加入字段:追加为顶层列节点', async () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
.find((w) => w.attributes('data-field-id') === 'remark')
expect(libItem).toBeTruthy()
await libItem.trigger('click')
const rows = lastUpdate(wrapper)
// 新列归未分组区(未分组在前),备注为 textarea:默认隐藏、列宽 160
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBeNull()
expect(rows.find((r) => r.b_field_id === '4').b_visible).toBe(0)
expect(rows.find((r) => r.b_field_id === '4').b_width).toBe(160)
const schema = lastSchema(wrapper)
expect(topLevelFields(schema)).toContain('remark')
})
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
it('字段库字段拖入分组带(inside):加入该分组子节点末尾', async () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
expect(libItem).toBeTruthy()
.find((w) => w.attributes('data-field-id') === 'remark')
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
await nextTick()
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1')
const schema = lastSchema(wrapper)
expect(groupNode(schema, '基本信息').children.map((n) => n.field)).toEqual([
'age',
'status',
'remark',
])
})
it('属性面板:选中列显示列属性(无状态开关),删除列 = 隐藏', async () => {
it('预览表渲染多级表头:band 行含分组标题', () => {
const wrapper = mountPanel()
// 树中点击列节点 → Inspector 显示列属性
const treeRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(treeRow).toBeTruthy()
await treeRow.trigger('click')
const inspector = wrapper.find('.mm-fdesign__inspector')
expect(inspector.exists()).toBe(true)
expect(inspector.text()).toContain('姓名')
// 列表侧无状态开关(显示/必填等是子表编辑语义)
expect(inspector.findAll('.mm-fdesign__status-chip')).toHaveLength(0)
// 删除列按钮:点击隐藏该列(行保留)
const delBtn = inspector.findAll('button').find((b) => b.text().includes('删除列'))
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
const bands = wrapper.findAll('th.mm-tdesign__band')
expect(bands.map((th) => th.text())).toContain('基本信息')
})
})