chore(fms-vue): WIP 基线快照——收编工作区未提交的前端改动
不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容: - ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整, 新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新 - 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、 结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、 分组标题多语言、I18nKeyCell 等(含配套 spec) - 布局/主题/仪表盘/演示页小幅更新
This commit is contained in:
1 parent
a1079a7150
commit
05d0c7f304
58 files changed
+3698
-1343
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import Popover from '@/components/ui/popover/popover.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
|
||||
/** 查询 Teleport 到 body 的浮层(Transition + v-if 渲染) */
|
||||
function bodyPopover() {
|
||||
return document.body.querySelector('.popover')
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('Popover 触发器包裹', () => {
|
||||
it('默认(unwrapped=false)渲染 span.popover-trigger 包裹层', () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click' },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('span.popover-trigger').exists()).toBe(true)
|
||||
expect(wrapper.find('button.t-btn').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('unwrapped 单根元素:不渲染包裹层 span,仅保留用户触发器', () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', unwrapped: true },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('span.popover-trigger').exists()).toBe(false)
|
||||
expect(wrapper.find('button.t-btn').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('unwrapped 多根 fragment:自动回退到 span.popover-trigger 包裹层', () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', unwrapped: true },
|
||||
slots: {
|
||||
trigger: '<button>A</button><button>B</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('span.popover-trigger').exists()).toBe(true)
|
||||
expect(wrapper.findAll('span.popover-trigger > button').length).toBe(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('unwrapped 文本节点:回退到包裹层', () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', unwrapped: true },
|
||||
slots: {
|
||||
trigger: '纯文本触发器',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('span.popover-trigger').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popover 零包裹交互', () => {
|
||||
it('unwrapped 点击触发器可开合浮层(事件注入到用户 vnode)', async () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', unwrapped: true },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
expect(bodyPopover()).toBeNull()
|
||||
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
expect(bodyPopover()).not.toBeNull()
|
||||
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
expect(bodyPopover()).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('unwrapped 浮层有 left/top 定位(证明锚点指令真正挂载:vnode.dirs 字段名)', async () => {
|
||||
// 回归测试:曾因把指令注入到 vnode.directives 字段(应为 vnode.dirs),
|
||||
// 导致锚点指令静默失效、triggerEl 永远 null、updatePosition 提前 return、
|
||||
// 浮层无定位在 body 末尾不可见。jsdom 矩形=0,updatePosition 期望写出 0px。
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', unwrapped: true },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
const float = bodyPopover()
|
||||
expect(float).not.toBeNull()
|
||||
// 锚点指令必须已挂载并同步 triggerEl,updatePosition 才能写 left/top;
|
||||
// 若 vnode.dirs 拼成 directives(被静默忽略),style.left 保持空串。
|
||||
// jsdom 矩形=0,具体值取决于 computePosition 的 shift 防溢出,仅断言非空即可。
|
||||
expect(float.style.left).not.toBe('')
|
||||
expect(float.style.top).not.toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('事件合并:用户 @click 与内部 toggle 均触发,不互相覆盖', async () => {
|
||||
const TestHost = {
|
||||
components: { Popover },
|
||||
setup() {
|
||||
const userClicks = ref(0)
|
||||
return { userClicks }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<Popover trigger="click" unwrapped>
|
||||
<template #trigger>
|
||||
<button class="t-btn" @click="userClicks++">触发</button>
|
||||
</template>
|
||||
<template #content><div class="t-content">内容</div></template>
|
||||
</Popover>
|
||||
<span class="t-counter">{{ userClicks }}</span>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
const wrapper = mount(TestHost)
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
// 用户 handler 先执行
|
||||
expect(wrapper.find('.t-counter').text()).toBe('1')
|
||||
// 内部 toggle 后执行 → 浮层打开
|
||||
expect(bodyPopover()).not.toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('组件作触发器:事件注入到组件根元素,点击可开合', async () => {
|
||||
const TestHost = {
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover trigger="click" unwrapped>
|
||||
<template #trigger><Button>点击</Button></template>
|
||||
<template #content><div class="t-content">内容</div></template>
|
||||
</Popover>
|
||||
`,
|
||||
}
|
||||
const wrapper = mount(TestHost)
|
||||
const btn = wrapper.find('button')
|
||||
expect(btn.exists()).toBe(true)
|
||||
await btn.trigger('click')
|
||||
await flush()
|
||||
expect(bodyPopover()).not.toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 复现用:新建空数据模块后 FormEditPanel 首次挂载(全部空数组)。
|
||||
* 若存在响应式自触发循环,Vue 会抛 "Maximum recursive updates exceeded"。
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ModuleFormEditPanel from '@/views/system/module-management/components/form-edit/FormEditPanel.vue'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(async () => ({ data: [] })),
|
||||
saveObjectApi: vi.fn(),
|
||||
nextIdApi: vi.fn(),
|
||||
describeApi: vi.fn(async () => ({ data: [] })),
|
||||
}))
|
||||
|
||||
const StubDraggable = {
|
||||
name: 'draggable',
|
||||
props: {
|
||||
list: { type: Array, default: () => [] },
|
||||
itemKey: { type: String, default: undefined },
|
||||
},
|
||||
emits: ['start', 'end'],
|
||||
template: `
|
||||
<div class="stub-draggable">
|
||||
<template v-for="el in list" :key="el[itemKey || 'key']">
|
||||
<slot name="item" :element="el" />
|
||||
</template>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
function mountEmptyPanel() {
|
||||
return mount(ModuleFormEditPanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
editConfig: [],
|
||||
listConfig: [],
|
||||
fields: [],
|
||||
groups: [],
|
||||
editMode: 'form',
|
||||
loading: false,
|
||||
active: true,
|
||||
},
|
||||
global: { stubs: { draggable: StubDraggable } },
|
||||
})
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
|
||||
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' },
|
||||
]
|
||||
const groups = [
|
||||
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
|
||||
]
|
||||
const editConfig = [
|
||||
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_colspan: 24, b_height: null, b_placeholder: null, b_xh: 10 },
|
||||
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_colspan: 24, b_height: null, b_placeholder: null, b_xh: 20 },
|
||||
]
|
||||
|
||||
describe('FormEditPanel 空模块挂载', () => {
|
||||
it('从空挂载不触发递归更新', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = mountEmptyPanel()
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已有数据模块切到空草稿(新建模块序列)不触发递归更新', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = mount(ModuleFormEditPanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
editConfig: editConfig.map((r) => ({ ...r })),
|
||||
listConfig: [],
|
||||
fields,
|
||||
groups: groups.map((g) => ({ ...g })),
|
||||
editMode: 'form',
|
||||
loading: false,
|
||||
active: true,
|
||||
},
|
||||
global: { stubs: { draggable: StubDraggable } },
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
// 模拟 addModule:drafts 整体替换为空草稿,props 全部变为空数组
|
||||
await wrapper.setProps({ editConfig: [], fields: [], groups: [] })
|
||||
await wrapper.vm.$nextTick()
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ModuleListConfigPanel from '@/views/system/module-management/components/ModuleListConfigPanel.vue'
|
||||
|
||||
/**
|
||||
* 列表配置面板与子表设计共用 useStructureEditing / ModuleStructureTree / TablePreview,
|
||||
* 拖拽语义与 module-table-edit-panel-dnd.spec 同源;本文件聚焦:
|
||||
* 1) 共用语义在列表侧(s_field_view 行)同样成立;
|
||||
* 2) 列表侧 features 差异(无分组启停菜单、列节点无删除按钮);
|
||||
* 3) 分组删除经 update-groups(groups, removedGroupIds) 上抛级联清理。
|
||||
*/
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
const StubDraggable = {
|
||||
name: 'draggable',
|
||||
props: {
|
||||
list: { type: Array, default: () => [] },
|
||||
itemKey: { type: String, default: undefined },
|
||||
},
|
||||
emits: ['start', 'end'],
|
||||
template: `
|
||||
<div class="stub-draggable">
|
||||
<template v-for="el in list" :key="el[itemKey || 'key']">
|
||||
<slot name="item" :element="el" />
|
||||
</template>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
class FakeDataTransfer {
|
||||
constructor() {
|
||||
this.store = new Map()
|
||||
this.types = []
|
||||
this.effectAllowed = 'none'
|
||||
this.dropEffect = 'none'
|
||||
}
|
||||
setData(type, value) {
|
||||
if (!this.types.includes(type)) this.types.push(type)
|
||||
this.store.set(type, String(value))
|
||||
}
|
||||
getData(type) {
|
||||
return this.store.has(type) ? this.store.get(type) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function mountPanel(extraProps = {}) {
|
||||
return mount(ModuleListConfigPanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
moduleCode: 'sys_user',
|
||||
listConfig: listConfig.map((r) => ({ ...r })),
|
||||
fields,
|
||||
groups: groups.map((g) => ({ ...g })),
|
||||
...extraProps,
|
||||
},
|
||||
global: { stubs: { draggable: StubDraggable } },
|
||||
})
|
||||
}
|
||||
|
||||
function treeRow(wrapper, title) {
|
||||
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes(title))
|
||||
expect(row, `未找到树节点:${title}`).toBeTruthy()
|
||||
return row.element
|
||||
}
|
||||
|
||||
function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
|
||||
const ev = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(ev, 'clientX', { value: clientX })
|
||||
Object.defineProperty(ev, 'clientY', { value: clientY })
|
||||
Object.defineProperty(ev, 'button', { value: button })
|
||||
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 })
|
||||
Object.defineProperty(ev, 'clientY', { value: clientY })
|
||||
Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer })
|
||||
el.dispatchEvent(ev)
|
||||
}
|
||||
|
||||
let hitElement = null
|
||||
|
||||
function startMouseDrag(wrapper, srcTitle) {
|
||||
const row = treeRow(wrapper, srcTitle)
|
||||
const nodeKey = row.getAttribute('data-key')
|
||||
expect(nodeKey, `节点缺少 data-key:${srcTitle}`).toBeTruthy()
|
||||
wrapper.findComponent({ name: 'draggable' }).vm.$emit('start', {
|
||||
item: { dataset: { key: nodeKey } },
|
||||
})
|
||||
}
|
||||
|
||||
function moveMouseTo(wrapper, targetTitle, { clientX = 0, clientY = 20 } = {}) {
|
||||
const row = treeRow(wrapper, targetTitle)
|
||||
hitElement = row.querySelector('.mst-node__label') || row
|
||||
fireMouse(window, 'mousemove', { clientX, clientY })
|
||||
}
|
||||
|
||||
function endMouseDrag(wrapper) {
|
||||
wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
|
||||
}
|
||||
|
||||
function lastUpdate(wrapper) {
|
||||
const updates = wrapper.emitted('update')
|
||||
expect(updates?.length).toBeGreaterThan(0)
|
||||
return updates.at(-1)[0]
|
||||
}
|
||||
|
||||
describe('ModuleListConfigPanel 结构编辑(与子表设计共用内核)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
document.elementFromPoint = () => hitElement
|
||||
Element.prototype.getBoundingClientRect = () => ({
|
||||
top: 0,
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 40,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
it('列拖到分组节点(inside):移入该分组直属列末尾', () => {
|
||||
const wrapper = mountPanel()
|
||||
// 姓名(v1,未分组) 拖到分组「扩展信息」(g2) 行中部
|
||||
startMouseDrag(wrapper, '姓名')
|
||||
moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
|
||||
endMouseDrag(wrapper)
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
it('列拖到「未分组」收纳节点:移出分组', () => {
|
||||
const wrapper = mountPanel()
|
||||
// 年龄(v2,属 g1) 拖到「未分组」
|
||||
startMouseDrag(wrapper, '年龄')
|
||||
moveMouseTo(wrapper, '未分组', { clientY: 20 })
|
||||
endMouseDrag(wrapper)
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
it('分组拖到分组带上(inside):嵌套(b_parent_id 指向目标)', () => {
|
||||
const wrapper = mountPanel()
|
||||
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部
|
||||
startMouseDrag(wrapper, '扩展信息')
|
||||
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
|
||||
endMouseDrag(wrapper)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('删除分组:update-groups 带 removedGroupIds,组内行归未分组', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountPanel()
|
||||
const libItem = wrapper
|
||||
.findAll('.mm-fdesign__lib-item')
|
||||
.find((w) => w.attributes('data-field-id') === '4')
|
||||
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)
|
||||
})
|
||||
|
||||
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
|
||||
const wrapper = mountPanel()
|
||||
const libItem = wrapper
|
||||
.findAll('.mm-fdesign__lib-item')
|
||||
.find((w) => w.attributes('data-field-id') === '4')
|
||||
expect(libItem).toBeTruthy()
|
||||
const dt = new FakeDataTransfer()
|
||||
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
|
||||
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
|
||||
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('属性面板:选中列显示列属性(无状态开关),删除列 = 隐藏', async () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* useModuleSave 保存编排测试:守护跨表保存的关键不变量。
|
||||
* 这些顺序约束此前只存在于注释中,被改动时不会有任何报警:
|
||||
* 1. 校验失败时不产生任何保存请求,且定位到对应分节;
|
||||
* 2. s_module 有修改走 updates、新建走 inserts;
|
||||
* 3. 临时 id 先分配真实主键,再跨表重映射 b_field_id(req 行与草稿行同引用);
|
||||
* 4. s_autocode 的 updates 剥离服务端维护的 b_currentperiod / b_currentvalue;
|
||||
* 5. i18n inserts 分配真实主键并回写面板,避免二次保存重复插入。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useModuleSave } from '@/views/system/module-management/composables/useModuleSave'
|
||||
import { saveObjectApi, nextIdApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
saveObjectApi: vi.fn(),
|
||||
nextIdApi: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
let idSeq
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
idSeq = 9000
|
||||
nextIdApi.mockImplementation(async (count) => {
|
||||
const ids = []
|
||||
for (let i = 0; i < count; i++) ids.push(String(++idSeq))
|
||||
return { data: ids }
|
||||
})
|
||||
})
|
||||
|
||||
function baseDrafts(over = {}) {
|
||||
const drafts = {
|
||||
// b_i18n 与 buildModuleI18nKey('data', 'demo') 派生值一致:saveModule 每次保存都会物化该键
|
||||
module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo' },
|
||||
fields: [],
|
||||
groups: [],
|
||||
listConfig: [],
|
||||
editConfig: [],
|
||||
queryConfig: [],
|
||||
autoCode: [],
|
||||
powers: [],
|
||||
}
|
||||
return Object.assign(drafts, over)
|
||||
}
|
||||
|
||||
function setup({ drafts, originals, basicPanel = null, autoCodePanel = null, i18nPanel = null }) {
|
||||
const unsavedModuleIds = new Set()
|
||||
const deps = {
|
||||
selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }),
|
||||
drafts: ref(drafts),
|
||||
originals: ref(originals),
|
||||
loadedModuleId: ref('1'),
|
||||
moduleSelectedId: ref('1'),
|
||||
unsavedModuleIds,
|
||||
configSaving: ref(false),
|
||||
busy: ref(false),
|
||||
activeSection: ref('basic'),
|
||||
moduleTreeData: ref([]),
|
||||
findModuleNode: () => null,
|
||||
basicPanelRef: ref(basicPanel),
|
||||
autoCodePanelRef: ref(autoCodePanel),
|
||||
i18nPanelRef: ref(i18nPanel),
|
||||
getFinalI18nItems: () => [{ key: 'module.demo', managed: true, id: 'module:demo' }],
|
||||
}
|
||||
return { saveModule: useModuleSave(deps).saveModule, unsavedModuleIds, activeSection: deps.activeSection }
|
||||
}
|
||||
|
||||
function reqOf(table) {
|
||||
return saveObjectApi.mock.calls.at(-1)[0].find((req) => req.table === table)
|
||||
}
|
||||
|
||||
describe('useModuleSave 保存编排', () => {
|
||||
it('无变更时不发请求并提示', async () => {
|
||||
const drafts = baseDrafts()
|
||||
const { saveModule } = setup({ drafts, originals: JSON.parse(JSON.stringify(drafts)) })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(Message.info).toHaveBeenCalledWith('没有需要保存的变更')
|
||||
})
|
||||
|
||||
it('基础面板校验失败:不发请求,定位到 basic 分节', async () => {
|
||||
const drafts = baseDrafts()
|
||||
const { saveModule, activeSection } = setup({
|
||||
drafts,
|
||||
originals: JSON.parse(JSON.stringify(drafts)),
|
||||
basicPanel: { validate: async () => ['编码不能为空'] },
|
||||
})
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(activeSection.value).toBe('basic')
|
||||
expect(Message.warning).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('s_module 修改走 updates,新建走 inserts', async () => {
|
||||
const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '改名后' } })
|
||||
const originals = baseDrafts()
|
||||
const { saveModule } = setup({ drafts, originals })
|
||||
await saveModule()
|
||||
expect(reqOf('s_module').updates[0].b_name).toBe('改名后')
|
||||
|
||||
const { saveModule: saveNew, unsavedModuleIds } = setup({
|
||||
drafts: baseDrafts(),
|
||||
originals: baseDrafts(),
|
||||
})
|
||||
unsavedModuleIds.add('1')
|
||||
await saveNew()
|
||||
expect(reqOf('s_module').inserts[0].b_code).toBe('demo')
|
||||
})
|
||||
|
||||
it('临时 id 分配真实主键并跨表重映射 b_field_id', async () => {
|
||||
// 新字段(临时 id -1)+ 引用它的列表列:编辑行同引用,重映射必须同时落到请求与草稿
|
||||
const listRow = { b_id: '100', b_module_id: '1', b_field_id: '-1', b_visible: 1, b_xh: 10 }
|
||||
const drafts = baseDrafts({
|
||||
fields: [{ b_id: '-1', b_module_id: '1', b_field: 'name', b_name: '姓名' }],
|
||||
listConfig: [listRow],
|
||||
})
|
||||
const originals = baseDrafts({
|
||||
listConfig: [{ b_id: '100', b_module_id: '1', b_field_id: '9999', b_visible: 1, b_xh: 10 }],
|
||||
})
|
||||
const { saveModule } = setup({ drafts, originals })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(nextIdApi).toHaveBeenCalled()
|
||||
const fieldReq = reqOf('s_field')
|
||||
expect(fieldReq.inserts[0].b_id).toBe('9001')
|
||||
// 列表列由旧字段改挂新字段:update 行内的临时外键必须已换成真实 id
|
||||
const viewReq = reqOf('s_field_view')
|
||||
expect(viewReq.updates[0].b_field_id).toBe('9001')
|
||||
// 草稿行与请求行同引用:重映射后草稿也持有真实 id
|
||||
expect(drafts.listConfig[0].b_field_id).toBe('9001')
|
||||
})
|
||||
|
||||
it('s_autocode updates 剥离服务端维护的期间/当前值', async () => {
|
||||
const rule = {
|
||||
b_id: '200',
|
||||
b_module_id: '1',
|
||||
b_field_id: '100',
|
||||
b_canuse: 1,
|
||||
b_currentperiod: 2026,
|
||||
b_currentvalue: 5,
|
||||
}
|
||||
const drafts = baseDrafts({
|
||||
autoCode: [rule],
|
||||
module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo', b_savetable: 't_demo' },
|
||||
})
|
||||
const { saveModule } = setup({
|
||||
drafts,
|
||||
// 原件行与草稿行存在差异(当前值不同)才会产生 update,从而走到剥离逻辑
|
||||
originals: baseDrafts({ autoCode: [{ ...rule, b_currentvalue: 4 }] }),
|
||||
})
|
||||
|
||||
await saveModule()
|
||||
|
||||
const update = reqOf('s_autocode').updates[0]
|
||||
expect(update).not.toHaveProperty('b_currentperiod')
|
||||
expect(update).not.toHaveProperty('b_currentvalue')
|
||||
expect(update.b_field_id).toBe('100')
|
||||
})
|
||||
|
||||
it('i18n inserts 分配真实主键并回写面板,事务内提交', async () => {
|
||||
const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '新名称' } })
|
||||
const originals = baseDrafts()
|
||||
const i18nPanel = {
|
||||
reconcileManagedRows: vi.fn(),
|
||||
buildSaveRequest: vi.fn(() => ({
|
||||
table: 's_i18n',
|
||||
key_field: 'b_id',
|
||||
inserts: [{ b_id: '-9', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '新名称' }],
|
||||
})),
|
||||
applyInsertedIds: vi.fn(),
|
||||
markSaved: vi.fn(),
|
||||
}
|
||||
const { saveModule } = setup({ drafts, originals, i18nPanel })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// 受管键 reconcile 在构建请求前执行
|
||||
expect(i18nPanel.reconcileManagedRows).toHaveBeenCalledWith([
|
||||
{ key: 'module.demo', managed: true, id: 'module:demo' },
|
||||
])
|
||||
// 临时主键分配后回写面板,避免二次保存重复插入
|
||||
expect(i18nPanel.applyInsertedIds).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ b_id: '9001', b_key: 'module.demo' }),
|
||||
])
|
||||
expect(reqOf('s_i18n').inserts[0].b_id).toBe('9001')
|
||||
expect(i18nPanel.markSaved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('跨面板校验前置:启用自动编码但未配置保存表则拦截', async () => {
|
||||
const rule = { b_id: '200', b_module_id: '1', b_field_id: '100', b_canuse: 1 }
|
||||
const drafts = baseDrafts({ autoCode: [rule] })
|
||||
const { saveModule } = setup({ drafts, originals: baseDrafts() })
|
||||
|
||||
const ok = await saveModule()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(Message.warning).toHaveBeenCalledWith('启用自动编码前请先配置保存表')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user