Files
workspace/code/fms/fms-vue/tests/views/module-management.spec.js
T
2026-08-26 17:31:31 +08:00

3006 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { nextTick, ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia } from 'pinia'
import MenuDetailPanel from '@/views/system/module-management/components/MenuDetailPanel.vue'
import ModuleBasicPanel from '@/views/system/module-management/components/ModuleBasicPanel.vue'
import ModuleEditConfigPanel from '@/views/system/module-management/components/ModuleEditConfigPanel.vue'
// —— 单一接缝:mock 业务 API 层,确定性驱动页面状态 ——
// 三个模块形成父子结构:1 系统(分类,根)→ 2 订单(数据模块,子)/ 3 查询A(查询模块,子)
const moduleRows = [
{
b_id: '1',
b_code: 'sys',
b_name: '系统',
b_module_type: 'category',
b_parent_id: null,
b_canuse: 1,
b_xh: 1,
},
{
b_id: '2',
b_code: 'order',
b_name: '订单',
b_module_type: 'data',
b_parent_id: '1',
b_canuse: 1,
b_xh: 1,
},
{
b_id: '3',
b_code: 'qry',
b_name: '查询A',
b_module_type: 'query',
b_parent_id: '1',
b_canuse: 1,
b_xh: 2,
},
]
const menuRows = [
{
b_id: '10',
b_code: 'menu-sys',
b_name: '系统菜单',
b_menu_type: 'directory',
b_parent_id: null,
b_canuse: 1,
b_xh: 1,
},
{
b_id: '11',
b_code: 'menu-user',
b_name: '用户菜单',
b_menu_type: 'page',
b_parent_id: '10',
b_canuse: 1,
b_xh: 1,
},
{
b_id: '12',
b_code: 'menu-standalone',
b_name: '独立菜单',
b_menu_type: 'page',
b_parent_id: null,
b_canuse: 1,
b_xh: 2,
},
]
// 返回深拷贝以避免 tests 间 fixture 对象残留(如 b_i18n 等易变字段)
function deepCloneRows(rows) {
return rows.map((r) => JSON.parse(JSON.stringify(r)))
}
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async (table, where) => {
if (table === 's_module') {
const match = String(where || '').match(/b_id\s*=\s*(\d+)/)
if (match)
return { data: deepCloneRows(moduleRows.filter((r) => String(r.b_id) === match[1])) }
return { data: deepCloneRows(moduleRows) }
}
if (table === 's_menu') {
const match = String(where || '').match(/b_id\s*=\s*(\d+)/)
if (match) return { data: deepCloneRows(menuRows.filter((r) => String(r.b_id) === match[1])) }
return { data: deepCloneRows(menuRows) }
}
return { data: [] }
}),
saveObjectApi: vi.fn(async () => ({})),
nextIdApi: vi.fn(async () => ({ data: '999' })),
describeApi: vi.fn(async () => ({ data: [] })),
}))
vi.mock('@/components/ui/message/message-manager', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loadDataApi, saveObjectApi, nextIdApi } from '@/services/api'
import Message from '@/components/ui/message/message-manager'
import ModuleManagement from '@/views/system/module-management/index.vue'
// vuedraggable 打桩:jsdom 中 SortableJS 初始化不稳定,仅渲染 #item 列表
// move 声明为 prop(与真实 vuedraggable 一致,模板以 :move 传入回调),测试中直接调用
const stubDraggable = {
name: 'Draggable',
props: ['list', 'itemKey', 'move'],
template:
'<div class="stub-draggable"><template v-for="item in list" :key="String(item.b_id)"><slot name="item" :element="item" /></template><slot name="footer" /></div>',
}
// 子面板统一打桩(页面测试只验证页面自身的骨架/交互)
// ModuleBasicPanel 的 validate 结果可配置,用于测试「保存校验失败」分支
let basicValidateResult = []
const panelStubs = {
ModuleBasicPanel: {
name: 'ModuleBasicPanel',
template: '<div class="stub-basic" />',
methods: {
validate() {
return Promise.resolve(basicValidateResult)
},
},
},
ModuleFieldsPanel: { name: 'ModuleFieldsPanel', template: '<div class="stub-fields" />' },
ModuleListConfigPanel: { name: 'ModuleListConfigPanel', template: '<div class="stub-list" />' },
ModuleEditConfigPanel: {
name: 'ModuleEditConfigPanel',
props: ['editMode'],
template: '<div class="stub-edit" />',
},
ModuleQueryConfigPanel: {
name: 'ModuleQueryConfigPanel',
template: '<div class="stub-query" />',
},
ModuleAutoCodePanel: { name: 'ModuleAutoCodePanel', template: '<div class="stub-autocode" />' },
ModulePowersPanel: { name: 'ModulePowersPanel', template: '<div class="stub-powers" />' },
ModuleI18nPanel: { name: 'ModuleI18nPanel', template: '<div class="stub-i18n" />' },
MenuDetailPanel: { name: 'MenuDetailPanel', template: '<div class="stub-menu" />' },
// Modal 尊重 open / footer 条件:footer=false 时不渲染按钮区(与真实组件一致)
Modal: {
name: 'Modal',
props: ['open', 'title', 'okText', 'okType', 'confirmLoading', 'footer'],
template:
'<div v-if="open" class="stub-modal"><slot /><div v-if="footer !== false" class="stub-modal-footer"><slot name="footer" /></div></div>',
},
Transition: { template: '<slot />' },
}
async function mountPage() {
const wrapper = mount(ModuleManagement, {
global: { plugins: [createPinia()], stubs: panelStubs },
})
activeWrapper = wrapper
await flushPromises()
await nextTick()
return wrapper
}
// 模块树面板(v-show 下两个树面板都在 DOM,需限定第一个)
function moduleTreeOf(wrapper) {
return wrapper.findAll('.mm-tree-panel')[0]
}
// 点击树节点标题选中模块;默认收起时需先展开根节点
async function selectModule(wrapper, name) {
const tree = moduleTreeOf(wrapper)
const switcher = tree.find('.tree-node-switcher:not(.is-placeholder)')
if (switcher.exists()) {
await switcher.trigger('click')
await nextTick()
}
const node = tree.findAll('.tree-node-content').find((el) => el.text().includes(name))
expect(node).toBeTruthy()
await node.trigger('click')
await flushPromises()
await nextTick()
}
// 记录当前挂载实例,afterEach 统一卸载(Teleport 内容随组件卸载自动清理)
let activeWrapper = null
describe('模块管理页面(重构后)', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
basicValidateResult = []
vi.clearAllMocks()
})
afterEach(() => {
activeWrapper?.unmount()
activeWrapper = null
})
it('渲染树面板:标题 + 新建下拉 + 刷新 + 搜索', async () => {
const wrapper = await mountPage()
const tree = moduleTreeOf(wrapper)
expect(tree.find('.mm-tree-header__title').text()).toBe('模块')
expect(tree.find('.mm-tree-header__icon-btn').exists()).toBe(true)
expect(tree.find('input[data-slot="input"]').exists()).toBe(true)
})
it('树默认收起:只显示根节点,不展开子节点', async () => {
const wrapper = await mountPage()
const tree = moduleTreeOf(wrapper)
const contents = tree.findAll('.tree-node-content')
// 模块树默认收起,只显示根节点「系统」(子节点「订单」不可见)
expect(contents.length).toBe(1)
expect(contents[0].text()).toContain('系统')
// 子节点 订单 未展开不可见
expect(tree.text()).not.toContain('订单')
})
it('树节点显示类型 tag', async () => {
const wrapper = await mountPage()
const tags = moduleTreeOf(wrapper).findAll('.mm-tree-node__tag')
expect(tags.length).toBeGreaterThan(0)
expect(tags[0].text()).toBe('分类')
})
it('展开后子节点可见,选中数据模块显示分组导航且唯一激活', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
// 横向导航渲染全部配置项(无分组小标题)
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
expect(labels).toContain('基本信息')
expect(labels).toContain('字段定义')
expect(labels).toContain('表单设计')
expect(labels).toContain('列表配置')
expect(labels).toContain('查询配置')
expect(labels).toContain('自动编码')
expect(labels).toContain('权限')
expect(labels).toContain('多语言')
// 仅一个激活项(基本信息)
const active = wrapper.findAll('.mm-config-nav .tabs-tab-active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('基本信息')
})
it('编辑方式=table 的模块:导航显示「表格设计」并传递 edit-mode 给编辑面板', async () => {
const impl = vi.mocked(loadDataApi).getMockImplementation()
vi.mocked(loadDataApi).mockImplementation(async (table, where) => {
if (table === 's_module') {
const match = String(where || '').match(/b_id\s*=\s*(\d+)/)
if (match) {
const rows = deepCloneRows(moduleRows.filter((r) => String(r.b_id) === match[1]))
if (rows.length) rows[0].b_edit_mode = 'table'
return { data: rows }
}
return { data: deepCloneRows(moduleRows) }
}
return { data: [] }
})
try {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
expect(labels).toContain('表格设计')
expect(labels).not.toContain('表单设计')
const editPanel = wrapper.findComponent({ name: 'ModuleEditConfigPanel' })
expect(editPanel.props('editMode')).toBe('table')
} finally {
vi.mocked(loadDataApi).mockImplementation(impl)
}
})
it('点击导航项切换内容面板', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const fieldsItem = wrapper
.findAll('.mm-config-nav .tabs-tab')
.find((el) => el.text().includes('字段定义'))
expect(fieldsItem).toBeTruthy()
await fieldsItem.trigger('click')
await nextTick()
const active = wrapper.findAll('.mm-config-nav .tabs-tab-active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('字段定义')
// 对应内容面板显示
expect(wrapper.find('.stub-fields').exists()).toBe(true)
})
it('header 显示:重载 / 保存按钮 + 面包屑', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
expect(wrapper.text()).toContain('重载')
expect(wrapper.text()).toContain('保存')
// 面包屑:上级路径 系统 / 订单
expect(wrapper.text()).toContain('系统')
})
it('Ctrl+S 触发保存:调用 saveObjectApi', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
saveObjectApi.mockClear()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
})
it('点击「重载」重新加载当前模块数据', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
loadDataApi.mockClear()
const reloadBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('重载'))
await reloadBtn.trigger('click')
await flushPromises()
await nextTick()
// 重新查询当前模块及其子表
expect(loadDataApi).toHaveBeenCalledWith('s_module', expect.any(String))
})
it('顶部新建下拉:新增数据模块(调用 nextIdApi 并生成草稿)', async () => {
const wrapper = await mountPage()
const newBtn = moduleTreeOf(wrapper).find('.mm-tree-header__icon-btn')
await newBtn.trigger('click')
await nextTick()
const addItem = [...document.querySelectorAll('.dropdown-item')].find((el) =>
el.textContent.includes('新增数据模块'),
)
expect(addItem).toBeTruthy()
addItem.click()
await flushPromises()
await nextTick()
expect(nextIdApi).toHaveBeenCalled()
// 新建草稿:选中新节点(is-selected 生效)
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
})
it('右键模块节点:删除确认弹窗为 danger 按钮', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
// 右键「订单」节点(无子节点,可删除)打开上下文菜单(Teleport 到 body)
const row = moduleTreeOf(wrapper)
.findAll('.tree-node')
.find((el) => el.text().includes('订单'))
await row.trigger('contextmenu')
await nextTick()
const deleteItem = [...document.querySelectorAll('.tree-context-menu__item')].find((el) =>
el.textContent.includes('删除模块'),
)
expect(deleteItem).toBeTruthy()
deleteItem.click()
await flushPromises()
await nextTick()
const modal = wrapper.find('.stub-modal')
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('删除模块')
// okType=danger → 确认按钮为 danger 样式
expect(modal.find('.button-danger').exists()).toBe(true)
})
it('菜单区右键:四种新建类型(目录/页面/数据页面/外链)', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
const row = menuTree.find('.tree-node')
await row.trigger('contextmenu')
await nextTick()
const labels = [...document.querySelectorAll('.tree-context-menu__item')].map((el) =>
el.textContent.trim(),
)
expect(labels).toContain('新增目录')
expect(labels).toContain('新增页面')
expect(labels).toContain('新增数据页面')
expect(labels).toContain('新增外链')
})
it('有未保存修改时切换节点弹出保护(保存并切换/放弃修改/取消)', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
// 通过基本信息面板 stub 发出修改,使 hasModuleChanges 为 true
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', { ...moduleRows[1], b_name: '订单(已修改)' })
await nextTick()
// 点击模块树根节点触发切换
const rootNode = moduleTreeOf(wrapper)
.findAll('.tree-node-content')
.find((el) => el.text().includes('系统'))
await rootNode.trigger('click')
await nextTick()
// 弹出三按钮保护
const modal = wrapper.find('.stub-modal')
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('有未保存的修改')
expect(modal.text()).toContain('保存并切换')
expect(modal.text()).toContain('放弃修改')
expect(modal.text()).toContain('取消')
})
it('顶部切换到菜单配置后:菜单树渲染', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
// 激活 tab 变为菜单配置
const activeTabs = wrapper.findAll('.tabs-tab-active')
expect(activeTabs[0].text()).toContain('菜单配置')
// 菜单树面板标题与数据
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
expect(menuTree.find('.mm-tree-header__title').text()).toBe('菜单')
expect(menuTree.text()).toContain('系统菜单')
})
it('搜索过滤:输入关键词后树仅保留命中路径', async () => {
const wrapper = await mountPage()
const tree = moduleTreeOf(wrapper)
const searchInput = tree.find('input[data-slot="input"]')
await searchInput.setValue('订单')
await nextTick()
// 命中路径(系统 → 订单)可见;非命中的「查询A」被过滤
expect(tree.text()).toContain('订单')
expect(tree.text()).not.toContain('查询A')
})
it('点击刷新按钮重新加载模块树', async () => {
const wrapper = await mountPage()
loadDataApi.mockClear()
const refreshBtn = moduleTreeOf(wrapper).findAll('.mm-tree-header__icon-btn')[1]
await refreshBtn.trigger('click')
await flushPromises()
await nextTick()
expect(loadDataApi).toHaveBeenCalledWith('s_module', '1 = 1', 'b_xh ASC, b_id ASC')
})
it('模块右键:四种新建类型(分类/数据/查询)', async () => {
const wrapper = await mountPage()
const row = moduleTreeOf(wrapper).find('.tree-node')
await row.trigger('contextmenu')
await nextTick()
const labels = [...document.querySelectorAll('.tree-context-menu__item')].map((el) =>
el.textContent.trim(),
)
expect(labels).toContain('新增分类')
expect(labels).toContain('新增数据模块')
expect(labels).toContain('新增查询模块')
})
it('分类节点:分组导航仅显示基本信息(无数据结构/展示配置/业务能力/多语言)', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '系统')
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
expect(labels).toEqual(['基本信息'])
})
it('查询模块:分组导航无「表单设计」「自动编码」', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '查询A')
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
expect(labels).toContain('字段定义')
expect(labels).toContain('查询配置')
expect(labels).not.toContain('表单设计')
expect(labels).not.toContain('自动编码')
})
it('点击「保存」按钮保存模块(saveObjectApi 调用)', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
saveObjectApi.mockClear()
const saveBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('保存'))
await saveBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
})
it('保存校验失败:不调用 saveObjectApi', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
basicValidateResult = [{ name: 'b_code', errors: ['必填'] }]
saveObjectApi.mockClear()
const saveBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('保存'))
await saveBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).not.toHaveBeenCalled()
})
it('新建数据模块后保存:inserts 写入 s_module,未保存标记清除', async () => {
const wrapper = await mountPage()
// 顶部下拉新建数据模块
const newBtn = moduleTreeOf(wrapper).find('.mm-tree-header__icon-btn')
await newBtn.trigger('click')
await nextTick()
const addItem = [...document.querySelectorAll('.dropdown-item')].find((el) =>
el.textContent.includes('新增数据模块'),
)
addItem.click()
await flushPromises()
await nextTick()
// 新建草稿:选中新节点
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
// 通过基本信息 stub 补齐必填字段
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', {
b_id: '999',
b_code: 'newmod',
b_name: '新模块',
b_module_type: 'data',
b_parent_id: '1',
b_canuse: 1,
b_xh: 10,
})
await nextTick()
saveObjectApi.mockClear()
const saveBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('保存'))
await saveBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
// inserts 含 s_module
const reqs = saveObjectApi.mock.calls[0][0]
const moduleReq = reqs.find((r) => r.table === 's_module')
expect(moduleReq).toBeTruthy()
expect(moduleReq.inserts).toBeTruthy()
// 保存成功后仍选中新节点
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
})
it('重载:有未保存修改时弹出确认', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', { ...moduleRows[1], b_name: '订单(改)' })
await nextTick()
const reloadBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('重载'))
await reloadBtn.trigger('click')
await nextTick()
const modal = wrapper.find('.stub-modal')
expect(modal.exists()).toBe(true)
// Modal stub 不渲染 title,断言正文与按钮
expect(modal.text()).toContain('确定放弃修改并重新加载数据')
expect(modal.text()).toContain('重载')
})
it('切换保护:取消 → 保持当前选中', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', { ...moduleRows[1], b_name: '订单(改)' })
await nextTick()
await moduleTreeOf(wrapper)
.findAll('.tree-node-content')
.find((el) => el.text().includes('系统'))
.trigger('click')
await nextTick()
const modal = wrapper.find('.stub-modal')
const cancelBtn = modal.findAll('button').find((b) => b.text().includes('取消'))
await cancelBtn.trigger('click')
await nextTick()
// 未切换:树节点仍是「订单」
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('订单')
})
it('切换保护:放弃修改 → 切换到目标节点', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', { ...moduleRows[1], b_name: '订单(改)' })
await nextTick()
await moduleTreeOf(wrapper)
.findAll('.tree-node-content')
.find((el) => el.text().includes('系统'))
.trigger('click')
await nextTick()
const modal = wrapper.find('.stub-modal')
const discardBtn = modal.findAll('button').find((b) => b.text().includes('放弃修改'))
await discardBtn.trigger('click')
await flushPromises()
await nextTick()
// 已切换到「系统」
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('系统')
})
it('切换保护:保存并切换 → 先保存再切换', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
basicStub.vm.$emit('update', { ...moduleRows[1], b_name: '订单(改)' })
await nextTick()
saveObjectApi.mockClear()
await moduleTreeOf(wrapper)
.findAll('.tree-node-content')
.find((el) => el.text().includes('系统'))
.trigger('click')
await nextTick()
const modal = wrapper.find('.stub-modal')
const saveBtn = modal.findAll('button').find((b) => b.text().includes('保存并切换'))
await saveBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
// 已切换到「系统」
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('系统')
})
it('删除确认后执行:调用 saveObjectApi(含子表删除)', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
saveObjectApi.mockClear()
const row = moduleTreeOf(wrapper)
.findAll('.tree-node')
.find((el) => el.text().includes('订单'))
await row.trigger('contextmenu')
await nextTick()
const deleteItem = [...document.querySelectorAll('.tree-context-menu__item')].find((el) =>
el.textContent.includes('删除模块'),
)
deleteItem.click()
await flushPromises()
await nextTick()
const modal = wrapper.find('.stub-modal')
const okBtn = modal.findAll('button').find((b) => b.text().includes('删除'))
await okBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
// 删除请求含 s_module 的 deletes
const reqs = saveObjectApi.mock.calls[0][0]
expect(reqs.some((r) => r.table === 's_module' && r.deletes?.length)).toBe(true)
})
it('删除含已保存子模块的目录:拦截并提示,不弹确认', async () => {
const wrapper = await mountPage()
const row = moduleTreeOf(wrapper).find('.tree-node') // 系统(含已保存子模块)
await row.trigger('contextmenu')
await nextTick()
const deleteItem = [...document.querySelectorAll('.tree-context-menu__item')].find((el) =>
el.textContent.includes('删除模块'),
)
deleteItem.click()
await flushPromises()
await nextTick()
expect(wrapper.find('.stub-modal').exists()).toBe(false)
expect(Message.warning).toHaveBeenCalled()
})
it('删除未保存草稿:不弹确认直接移除节点', async () => {
const wrapper = await mountPage()
const tree = moduleTreeOf(wrapper)
// 先展开树(新建会自动展开祖先,计数以展开后为基准)
const switcher = tree.find('.tree-node-switcher:not(.is-placeholder)')
if (switcher.exists()) {
await switcher.trigger('click')
await nextTick()
}
const before = tree.findAll('.tree-node').length // 系统 + 订单 + 查询A
// 顶部新建数据模块(草稿)
const newBtn = tree.find('.mm-tree-header__icon-btn')
await newBtn.trigger('click')
await nextTick()
const addItem = [...document.querySelectorAll('.dropdown-item')].find((el) =>
el.textContent.includes('新增数据模块'),
)
addItem.click()
await flushPromises()
await nextTick()
const afterNew = tree.findAll('.tree-node').length
expect(afterNew).toBe(before + 1)
// 右键新草稿节点(最后一个)删除
const rows = tree.findAll('.tree-node')
await rows[rows.length - 1].trigger('contextmenu')
await nextTick()
const deleteItem = [...document.querySelectorAll('.tree-context-menu__item')].find((el) =>
el.textContent.includes('删除模块'),
)
deleteItem.click()
await flushPromises()
await nextTick()
// 无确认弹窗,节点已移除
expect(wrapper.find('.stub-modal').exists()).toBe(false)
expect(tree.findAll('.tree-node').length).toBe(before)
})
it('菜单:选中菜单后保存(saveMenu 调用 saveObjectApi)', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
const rootContent = menuTree.find('.tree-node-content')
await rootContent.trigger('click')
await flushPromises()
await nextTick()
saveObjectApi.mockClear()
const saveBtn = wrapper
.findAll('.mm-header__actions button')
.find((b) => b.text().includes('保存'))
await saveBtn.trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalled()
})
it('菜单:有未保存修改时切换节点弹出保护', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
await menuTree.find('.tree-node-content').trigger('click') // 选中系统菜单
await flushPromises()
await nextTick()
// stub MenuDetailPanel 发出修改
const menuStub = wrapper.findComponent({ name: 'MenuDetailPanel' })
menuStub.vm.$emit('update', { ...menuRows[0], b_name: '系统菜单(改)' })
await nextTick()
// 点击另一个根菜单「独立菜单」
const standalone = menuTree
.findAll('.tree-node-content')
.find((el) => el.text().includes('独立菜单'))
await standalone.trigger('click')
await nextTick()
const modal = wrapper.find('.stub-modal')
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('有未保存的修改')
})
it('菜单配置:中栏导航按类型显示且唯一激活,点击切换分区', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
// 选中页面类型根菜单「独立菜单」
const node = menuTree.findAll('.tree-node-content').find((el) => el.text().includes('独立菜单'))
expect(node).toBeTruthy()
await node.trigger('click')
await flushPromises()
await nextTick()
const nav = wrapper.findAll('.mm-config-nav').at(-1)
const labels = nav.findAll('.mm-config-nav__label').map((t) => t.text())
expect(labels).toEqual(['基本信息', '权限配置', '多语言'])
// 仅一个激活项(基本信息)
expect(nav.findAll('.tabs-tab-active')).toHaveLength(1)
expect(nav.findAll('.tabs-tab-active')[0].text()).toContain('基本信息')
// 点击「权限配置」→ 激活态切换,activeSection 同步到面板
const powersItem = nav
.findAll('.tabs-tab')
.find((el) => el.text().includes('权限配置'))
expect(powersItem).toBeTruthy()
await powersItem.trigger('click')
await nextTick()
expect(nav.findAll('.tabs-tab-active')).toHaveLength(1)
expect(nav.findAll('.tabs-tab-active')[0].text()).toContain('权限配置')
const menuStub = wrapper.findComponent({ name: 'MenuDetailPanel' })
expect(menuStub.attributes('active-section')).toBe('powers')
})
it('菜单配置:目录类型不显示「权限配置」导航项', async () => {
const wrapper = await mountPage()
const menuTab = wrapper.findAll('.tabs-tab').find((t) => t.text().includes('菜单配置'))
await menuTab.trigger('click')
await flushPromises()
await nextTick()
const menuTree = wrapper.findAll('.mm-tree-panel')[1]
await menuTree.find('.tree-node-content').trigger('click') // 系统菜单(目录)
await flushPromises()
await nextTick()
const nav = wrapper.findAll('.mm-config-nav').at(-1)
expect(nav.findAll('.mm-config-nav__label').map((t) => t.text())).toEqual([
'基本信息',
'多语言',
])
expect(nav.text()).not.toContain('权限配置')
})
})
describe('ModuleBasicPanel(基本信息面板)', () => {
const stubComponents = {
Form: {
name: 'Form',
props: ['columns', 'layout'],
template: '<div class="stub-form"><slot /></div>',
},
FormItem: {
name: 'FormItem',
props: ['label', 'name', 'span'],
template:
'<div class="stub-form-item"><span class="stub-form-label">{{ label }}</span><slot /></div>',
},
Input: {
name: 'Input',
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<input class="stub-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
Select: {
name: 'Select',
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template:
'<select class="stub-select" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><option v-for="opt in options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option></select>',
},
Switch: { name: 'Switch', props: ['modelValue'], template: '<div class="stub-switch" />' },
I18nQuickSetModal: true,
}
const dataModule = {
b_id: '1',
b_code: 'order',
b_name: '订单',
b_module_type: 'data',
b_parent_id: null,
b_canuse: 1,
b_xh: 10,
b_viewtable: 'v_order',
b_keyfield: 'b_id',
}
const mountBasic = (data) =>
mount(ModuleBasicPanel, {
props: { data, parentName: '', loading: false },
global: { stubs: stubComponents },
})
it('渲染三个区块标题:标识与归属 / 数据来源 / 行为设置', async () => {
const wrapper = mountBasic({ ...dataModule })
await nextTick()
const titles = wrapper.findAll('.mm-basic__section-title').map((t) => t.text())
expect(titles).toEqual(['标识与归属', '数据来源', '行为设置', '描述'])
})
it('分类节点不显示「数据来源」区块', async () => {
const wrapper = mountBasic({ ...dataModule, b_module_type: 'category' })
await nextTick()
const titles = wrapper.findAll('.mm-basic__section-title').map((t) => t.text())
expect(titles).toEqual(['标识与归属', '行为设置', '描述'])
})
it('表单为三列布局(Form columns=3)', async () => {
const wrapper = mountBasic({ ...dataModule })
await nextTick()
expect(wrapper.findComponent({ name: 'Form' }).props('columns')).toBe(3)
})
it('修改字段后 emit update(携带新值)', async () => {
const wrapper = mountBasic({ ...dataModule })
await nextTick()
const nameInput = wrapper.findAll('.stub-input')[1] // 第二个输入框 = 模块名称
await nameInput.setValue('新订单名')
const update = wrapper.emitted('update')
expect(update).toBeTruthy()
expect(update[0][0].b_name).toBe('新订单名')
})
it('查询模块不显示「编辑方式」标签(编辑方式已移至表单设计面板)', async () => {
const wrapper = mountBasic({ ...dataModule, b_module_type: 'query', b_savetable: null })
await nextTick()
const labels = wrapper.findAll('.stub-form-label').map((l) => l.text())
expect(labels).not.toContain('编辑方式')
})
})
const mountMenu = (menu, activeSection = 'basic') =>
mount(MenuDetailPanel, {
props: { menu, parentName: '', moduleOptions: [], powers: [], loading: false, activeSection },
global: {
stubs: {
IconSelect: true,
ModulePowersPanel: {
name: 'ModulePowersPanel',
template: '<div class="stub-menu-powers" />',
},
ModuleI18nPanel: true,
I18nQuickSetModal: true,
},
},
})
describe('MenuDetailPanel', () => {
it('validate / saveI18n / markI18nSaved 同时暴露(defineExpose 未被覆盖)', async () => {
const wrapper = mountMenu(
ref({
b_id: '1',
b_code: '',
b_name: '',
b_menu_type: 'directory',
b_canuse: 1,
}).value,
)
await nextTick()
expect(typeof wrapper.vm.validate).toBe('function')
expect(typeof wrapper.vm.saveI18n).toBe('function')
expect(typeof wrapper.vm.markI18nSaved).toBe('function')
})
it('validate 对空必填返回错误字段,且校验时 emit update:activeSection=basic', async () => {
const wrapper = mountMenu(
ref({
b_id: '1',
b_code: '',
b_name: '',
b_menu_type: 'page',
b_route: '',
b_canuse: 1,
}).value,
)
await nextTick()
const errors = await wrapper.vm.validate()
expect(errors.length).toBeGreaterThan(0)
const names = errors.map((e) => e.name)
expect(names).toContain('b_code')
expect(names).toContain('b_name')
// 校验强制切回基本信息分区
expect(wrapper.emitted('update:activeSection')?.at(-1)?.[0]).toBe('basic')
})
it('目录类型:即使 activeSection=powers 也不渲染权限面板(导航由父级控制)', async () => {
const wrapper = mountMenu(
ref({
b_id: '1',
b_code: 'm',
b_name: '目录',
b_menu_type: 'directory',
b_canuse: 1,
}).value,
'powers',
)
await nextTick()
expect(wrapper.find('.stub-menu-powers').exists()).toBe(false)
// 基本信息内容始终渲染
expect(wrapper.text()).toContain('菜单编码')
})
it('页面类型:activeSection=powers 时渲染权限面板', async () => {
const wrapper = mountMenu(
ref({
b_id: '1',
b_code: 'p',
b_name: '页面',
b_menu_type: 'page',
b_route: '/x',
b_canuse: 1,
}).value,
'powers',
)
await nextTick()
expect(wrapper.find('.stub-menu-powers').exists()).toBe(true)
})
it('菜单类型变为目录时:若停留在 powers 分区则 emit update:activeSection=basic', async () => {
const page = {
b_id: '1',
b_code: 'p',
b_name: '页面',
b_menu_type: 'page',
b_route: '/x',
b_canuse: 1,
}
const wrapper = mountMenu(ref(page).value, 'powers')
await nextTick()
await wrapper.setProps({ menu: { ...page, b_menu_type: 'directory' } })
await nextTick()
expect(wrapper.emitted('update:activeSection')?.at(-1)?.[0]).toBe('basic')
})
})
// 放置意图:在目标卡片上 stub rect 并触发 mousemove(fallback 模式下意图检测挂在 window mousemove)
async function hoverCard(wrapper, fieldId, rect, clientX, clientY) {
const card = wrapper.find(`.mm-fdesign__field[data-field-id="${fieldId}"]`)
card.element.getBoundingClientRect = () => ({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
})
card.element.dispatchEvent(
new MouseEvent('mousemove', { clientX, clientY, bubbles: true, cancelable: true }),
)
}
// 构造拖拽 start/end 事件的 item 元素(带 data-field-id)
function itemEl(fieldId) {
const el = document.createElement('div')
el.setAttribute('data-field-id', fieldId)
return el
}
describe('ModuleEditConfigPanel(表单设计视图)', () => {
const editRows = [
{
b_id: 'e1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 1,
b_readonly: 0,
b_disabled: 0,
b_xh: 10,
},
{
b_id: 'e2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_xh: 20,
},
{
b_id: 'e3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 1,
b_xh: 30,
},
]
const fields = [
{ b_id: 'f1', b_field: 'order_no', b_name: '订单编号', b_type: 'input', b_canuse: 1 },
{ b_id: 'f2', b_field: 'amount', b_name: '订单金额', b_type: 'money', b_canuse: 1 },
{ b_id: 'f3', b_field: 'status', b_name: '订单状态', b_type: 'select', b_canuse: 1 },
]
const groups = [{ b_id: 'g1', b_title: '基础信息', b_xh: 10, b_canuse: 1 }]
const mountEdit = (overrides = {}) =>
mount(ModuleEditConfigPanel, {
props: {
moduleId: '2',
moduleName: '订单',
editConfig: editRows,
listConfig: [],
queryConfig: [],
fields,
groups,
referencedGroupIds: ['g1'],
loading: false,
...overrides,
},
global: {
stubs: {
FmsTable: { name: 'FmsTable', template: '<div class="stub-fmstable" />' },
FieldGroupManager: true,
Draggable: stubDraggable,
},
},
attachTo: document.body,
})
// 快速构造画布行(拖拽用例通用)
const mkRow = (b_id, b_field_id, b_group_id, b_colspan, b_xh = 10) => ({
b_id,
b_module_id: '2',
b_field_id,
b_group_id,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan,
b_xh,
})
it('默认设计视图:字段库 / 画布卡片 / 属性面板三栏', async () => {
const wrapper = mountEdit()
await nextTick()
expect(wrapper.find('.mm-fdesign__lib').exists()).toBe(true)
expect(wrapper.find('.mm-fdesign__canvas-wrap').exists()).toBe(true)
expect(wrapper.find('.mm-fdesign__inspector').exists()).toBe(true)
// 画布按分组渲染字段卡片
const cards = wrapper.findAll('.mm-fdesign__field')
expect(cards.length).toBe(3)
expect(wrapper.text()).toContain('基础信息')
expect(wrapper.text()).toContain('未分组')
// 属性面板初始未选中字段(子标题显示"未选中")
expect(wrapper.find('.mm-fdesign__inspector .mm-fdesign__subhead').text()).toContain('未选中')
// 左栏只放字段:不显示「字段库」标题与布局组件入口
expect(wrapper.text()).not.toContain('字段库')
expect(wrapper.text()).not.toContain('布局组件')
expect(wrapper.text()).not.toContain('分组容器')
// 数据视图表格默认隐藏
expect(wrapper.find('.mm-edit__data').attributes('style')).toContain('display: none')
})
it('切换到数据视图显示 FmsTable,且可切回设计视图', async () => {
const wrapper = mountEdit()
await nextTick()
const dataBtn = wrapper.find('button[data-view="data"]')
await dataBtn.trigger('click')
await nextTick()
expect(wrapper.find('.mm-edit__data').attributes('style')).not.toContain('display: none')
expect(wrapper.find('.stub-fmstable').exists()).toBe(true)
expect(wrapper.find('.mm-fdesign').attributes('style')).toContain('display: none')
await wrapper.find('button[data-view="design"]').trigger('click')
await nextTick()
expect(wrapper.find('.mm-fdesign').attributes('style')).not.toContain('display: none')
})
it('点击字段卡片:选中并显示焦点状态,属性面板显示对应字段', async () => {
const wrapper = mountEdit()
await nextTick()
const card = wrapper.find('.mm-fdesign__field[data-field-id="f2"]')
await card.trigger('click')
await nextTick()
expect(card.classes()).toContain('is-selected')
expect(wrapper.find('.mm-fdesign__inspector').text()).toContain('订单金额')
// 切换选中:原卡片取消焦点
const card2 = wrapper.find('.mm-fdesign__field[data-field-id="f1"]')
await card2.trigger('click')
await nextTick()
expect(card.classes()).not.toContain('is-selected')
expect(card2.classes()).toContain('is-selected')
})
it('表单设计不显示设备切换和画布提示文案', async () => {
const wrapper = mountEdit()
await nextTick()
expect(wrapper.find('button[data-device]').exists()).toBe(false)
expect(wrapper.find('.mm-fdesign__sheet').classes()).not.toContain('is-mobile')
expect(wrapper.text()).not.toContain('画布 · 桌面端 24 栅格')
expect(wrapper.text()).not.toContain('从左侧字段库点击或拖拽,即可添加表单项')
expect(wrapper.text()).not.toContain('移动端预览')
})
it('点击字段库中未加入表单的字段:追加表单项并 emit update', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const wrapper = mountEdit({ fields: extraFields })
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
await wrapper.findAll('.mm-fdesign__lib-item')[3].trigger('click')
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(4)
const updates = wrapper.emitted('update')
expect(updates).toBeTruthy()
expect(updates[updates.length - 1][0].some((r) => r.b_field_id === 'f4')).toBe(true)
})
it('画布与字段库使用 vuedraggable 容器:字段渲染完整', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const wrapper = mountEdit({ fields: extraFields })
await nextTick()
// 字段库渲染全部字段项(4 个字段,无布局组件)
expect(wrapper.findAll('.mm-fdesign__lib-item').length).toBe(4)
// 画布分组渲染现有字段
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
})
it('属性面板:开关写入草稿并 emit update', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f3"]').trigger('click')
await nextTick()
// 必填开关:初始关闭(f3 required=0)→ 点击打开
const requiredToggle = wrapper.findAll('.mm-fdesign__toggle')[0]
expect(requiredToggle.classes()).not.toContain('is-on')
await requiredToggle.trigger('click')
await nextTick()
expect(requiredToggle.classes()).toContain('is-on')
const updates = wrapper.emitted('update')
expect(updates).toBeTruthy()
const last = updates[updates.length - 1][0]
const row = last.find((r) => r.b_id === 'e3')
expect(row.b_required).toBe(1)
})
// ---- 拖拽回归(stubDraggable 模拟 SortableJS 事件流)----
function draggableBy(wrapper, predicate) {
return wrapper.findAllComponents(stubDraggable).find((c) => predicate(c.props('list')))
}
// 字段库 draggable:列表元素均含 b_field(画布行是 b_field_id)
function libDraggableOf(wrapper) {
return draggableBy(
wrapper,
(l) => Array.isArray(l) && l.length > 0 && l.every((i) => i.b_field != null),
)
}
// 分组画布 draggable:按分组 id 定位('' = 未分组)
function sectionDraggableOf(wrapper, gid) {
return draggableBy(
wrapper,
(l) =>
Array.isArray(l) &&
l.length > 0 &&
l.every((i) => String(i.b_group_id ?? '') === gid && i.b_field_id != null),
)
}
// 模拟库→画布拖拽(布局冻结模式):start(库)→ dragover 意图(可选)→ end(源=库,自行提交)
async function simulateLibDrop(wrapper, field, intent) {
const lib = libDraggableOf(wrapper)
lib.vm.$emit('start', { item: itemEl(field.b_id) })
if (intent) await intent()
lib.vm.$emit('end')
await nextTick()
}
// 模拟画布内 / 跨组拖拽:start → dragover 意图(可选)→ end(按意图提交,无意图 = 取消)
async function simulateCardDrop(wrapper, target, fieldId, intent) {
target.vm.$emit('start', { item: itemEl(fieldId) })
if (intent) await intent()
target.vm.$emit('end', { item: itemEl(fieldId) })
await nextTick()
}
it('字段库拖入画布:提交草稿(end 只在源列表触发,库侧提交)', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const wrapper = mountEdit({ fields: extraFields })
await nextTick()
// 带对齐意图(悬停在 e2 中部左半区 = 同行 before):并入 e2 所在行,整行均分
await simulateLibDrop(wrapper, extraFields[3], () =>
hoverCard(wrapper, 'f2', { top: 100, height: 60, left: 0, width: 200 }, 80, 130),
)
const updates = wrapper.emitted('update')
expect(updates).toBeTruthy()
const last = updates[updates.length - 1][0]
const row = last.find((r) => r.b_field_id === 'f4')
expect(row).toBeTruthy()
expect(row.b_group_id).toBe('g1')
// 对齐线为同行:新字段并入 e2 所在行,两字段均分
expect(row.b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(12)
// 拖入后自动选中,属性面板显示新字段
expect(wrapper.find('.mm-fdesign__inspector').text()).toContain('备注')
})
it('字段库拖入重复字段:警告并拒绝放置', async () => {
const wrapper = mountEdit()
await nextTick()
await simulateLibDrop(wrapper, fields[1]) // f2 已在表单中
expect(Message.warning).toHaveBeenCalled()
const updates = wrapper.emitted('update')
const rows = updates?.length ? updates[updates.length - 1][0] : null
// 草稿未变化(未 emit)或 f2 仍只有一行
if (rows) expect(rows.filter((r) => r.b_field_id === 'f2')).toHaveLength(1)
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
})
it('拖到行尾下方(newline):独占新行且索引不错位', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 布局冻结模式:悬停意图(f3 下边缘窄带 = newline)+ 松手提交
await simulateCardDrop(wrapper, target, 'f1', () =>
hoverCard(wrapper, 'f3', { top: 100, height: 60, left: 0, width: 200 }, 100, 150),
)
const updates = wrapper.emitted('update')
expect(updates).toBeTruthy()
const last = updates[updates.length - 1][0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f3', 'f1'])
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(24) // 新行独占
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(24) // f2 自成一行,保持不变
expect(last.find((r) => r.b_field_id === 'f3').b_colspan).toBe(24) // f3 原行只有自己,均分仍为 24
})
it('无悬停意图松手:视为取消,布局完全不变(不合并行)', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 未悬停到任何卡片(无意图)松手:取消,不产生任何草稿变更
await simulateCardDrop(wrapper, target, 'f1')
expect(wrapper.emitted('update')).toBeFalsy()
// 三行保持各自独立,顺序与宽度不变
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
})
it('组内拖动并对齐到目标字段同侧:两字段合并为一行均分', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 把 f3 拖到 f1 右半区(同行 after 意图):并入 f1 所在行
await simulateCardDrop(wrapper, target, 'f3', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 180, 40),
)
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f3', 'f2'])
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f3').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(24) // 不受影响
})
it('拖到紧邻的上一行(f2 → f1 中部):意图正常记录并合并同行(回归)', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// f2 拖到紧邻上一行 f1 的中部右半区(同行 after):意图由原生 dragover 记录,
// 不受 SortableJS「目标位置=当前位置时跳过 move」的影响
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 180, 40),
)
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f3').b_colspan).toBe(24)
})
it('同行意图悬停多字段行:整行卡片都高亮,插入侧遮罩仅在被悬停卡片', async () => {
// 第一行两字段(f1+f2 各 12),第二行 f3 整行
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 拖 f3 悬停到 f2 中部左半区(同行 before)
target.vm.$emit('start', { item: itemEl('f3') })
await hoverCard(wrapper, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 60, 40)
// 整行高亮:f1、f2 都出现 is-drop-hover,而不是只有被悬停的 f2
expect(wrapper.find('.mm-fdesign__field[data-field-id="f1"]').classes()).toContain(
'is-drop-hover',
)
expect(wrapper.find('.mm-fdesign__field[data-field-id="f2"]').classes()).toContain(
'is-drop-hover',
)
// 插入侧「同行」遮罩仍只在被悬停卡片(指示精确插入位置)
expect(wrapper.find('.mm-fdesign__field[data-field-id="f2"]').classes()).toContain(
'is-drop-before',
)
expect(wrapper.find('.mm-fdesign__field[data-field-id="f1"]').classes()).not.toContain(
'is-drop-before',
)
// 松手提交:f3 插入 f2 左侧,三字段均分一行
target.vm.$emit('end', { item: itemEl('f3') })
await nextTick()
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f3', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([8, 8, 8])
})
it('组内拖动到目标卡片上边缘窄带:插入目标行上方新行,保持各自宽度', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_xh: 20,
},
{
b_id: 'a3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 30,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 把 f3 拖到 f1 上边缘窄带(line-before):插到 f1 上方独占整行,f1/f2 保持整行
await simulateCardDrop(wrapper, target, 'f3', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 15), // relY = 0.083 < 0.3
)
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
expect(last.map((r) => r.b_field_id)).toEqual(['f3', 'f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24, 24]) // f3 独占上方新行,f1/f2 保持整行
})
it('空分组可放置:拖入后归属新分组', async () => {
const extraGroups = [...groups, { b_id: 'g2', b_title: '附加信息', b_xh: 20, b_canuse: 1 }]
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const wrapper = mountEdit({ groups: extraGroups, fields: extraFields })
await nextTick()
// 空分组渲染占位提示
expect(wrapper.find('.mm-fdesign__grid-empty').exists()).toBe(true)
const empty = draggableBy(wrapper, (l) => Array.isArray(l) && l.length === 0)
expect(empty).toBeTruthy()
// 悬停在空分组上(无卡片):意图 = 放到该分组末尾
const emptySectionEl = wrapper
.find('.mm-fdesign__grid-empty')
.element.closest('.mm-fdesign__section')
await simulateLibDrop(wrapper, extraFields[3], async () => {
// fallback 模式:意图检测挂在 window mousemove 上
emptySectionEl.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
)
})
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
const row = last.find((r) => r.b_field_id === 'f4')
expect(row).toBeTruthy()
expect(row.b_group_id).toBe('g2')
expect(row.b_colspan).toBe(24)
})
it('Delete 快捷键:仅面板可见(active)时删除选中字段', async () => {
const wrapper = mountEdit({ active: false })
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
await nextTick()
// 面板不可见:不删除
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
await wrapper.setProps({ active: true })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
wrapper.unmount() // 手动卸载,避免 window keydown 监听影响后续用例
})
it('栅格宽度修改:同行总宽超 24 时联动压缩其它字段', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 20,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
const input = wrapper.find('input[aria-label="栅格宽度"]')
await input.setValue('16')
await input.trigger('change')
await nextTick()
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
expect(last.find((r) => r.b_id === 'a1').b_colspan).toBe(16)
expect(last.find((r) => r.b_id === 'a2').b_colspan).toBe(8) // 24-16 联动压缩
})
it('删除字段:回收同行栅格;撤销按快照整体恢复', async () => {
const rows = [
{
b_id: 'a1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 10,
},
{
b_id: 'a2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: 'g1',
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_colspan: 12,
b_xh: 20,
},
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
await wrapper
.find('.mm-fdesign__field-toolbar .mm-fdesign__tool-btn.is-danger')
.trigger('click')
await nextTick()
let updates = wrapper.emitted('update')
let last = updates[updates.length - 1][0]
expect(last).toHaveLength(1)
expect(last[0].b_colspan).toBe(24) // 栅格回收:剩余字段占满整行
await wrapper.find('.mm-edit__undo').trigger('click')
await nextTick()
updates = wrapper.emitted('update')
last = updates[updates.length - 1][0]
expect(last).toHaveLength(2)
expect(last.map((r) => r.b_colspan)).toEqual([12, 12]) // 快照恢复原宽度
})
it('显示开关:关闭后卡片呈隐藏样式并带「隐藏」角标', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
const toggles = wrapper.findAll('.mm-fdesign__toggle')
const visibleToggle = toggles[toggles.length - 1] // 显示开关排在最后
expect(visibleToggle.classes()).toContain('is-on')
await visibleToggle.trigger('click')
await nextTick()
const card = wrapper.find('.mm-fdesign__field[data-field-id="f2"]')
expect(card.classes()).toContain('is-invisible')
expect(card.find('.mm-fdesign__invisible-mark').exists()).toBe(true)
})
it('点击画布空白:取消选中字段(属性面板回到空态)', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
expect(wrapper.find('.mm-fdesign__inspector').text()).toContain('订单金额')
await wrapper.find('.mm-fdesign__sheet').trigger('click')
await nextTick()
expect(wrapper.find('.mm-fdesign__field.is-selected').exists()).toBe(false)
expect(wrapper.find('.mm-fdesign__inspector .mm-fdesign__subhead').text()).toContain('未选中')
})
// ================= 拖拽边界全量回归(补齐同行内调序 / 跨组 / 源行回收等场景) =================
it('同行内调序(f2 → f1 左侧 before):保持各字段原宽度,不破坏行布局', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 60, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12, 24]) // 同行成员宽度保持,f1 不被误回收为 24
})
it('同行内调序(f1 → f2 右侧 after):保持原宽度', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f1', () =>
hoverCard(wrapper, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 180, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12, 24])
})
it('同行 line-before(f2 拖到同行 f1 上边缘):抽到上方独占新行,原行成员回收整行', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 13),
)
const last = wrapper.emitted('update').at(-1)[0]
// f2 抽出独占上方新行,f1 回收整行(原行 12/12 只剩 f1 → 24),f3 不变
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24, 24])
})
it('一行三字段,拖其中一个到行首上方(同行 line-before):抽到上方独占新行,原行回收均分', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 8, 10), mkRow('a2', 'f2', 'g1', 8, 20), mkRow('a3', 'f3', 'g1', 8, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 13),
)
const last = wrapper.emitted('update').at(-1)[0]
// f2 被抽出到上方独占整行,原行 f1、f3 回收均分 12/12
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 12, 12])
})
it('同行 newline(f2 拖到同行 f1 下方):f1 独占回收 24,f2 新行独占 24', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 68),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24])
})
it('跨行合并 after(f2 拖到下一行 f3 右侧):源行 f1 回收为整行,目标行 f3+f2 均分', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f3', { top: 10, height: 60, left: 0, width: 200 }, 180, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f3', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 12, 12]) // f1 源行回收,f3+f2 均分
})
it('跨行 newline(f2 拖到下一行 f3 下方):源行 f1 回收,f3 独占,f2 新行独占', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f3', { top: 10, height: 60, left: 0, width: 200 }, 100, 68),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f3', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24, 24])
})
it('跨组拖拽(g1 的 f1 拖到 g2 的 f2 右侧):归属变更 + 目标行均分', async () => {
const extraGroups = [
{ b_id: 'g1', b_title: '基础信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_title: '附加信息', b_xh: 20, b_canuse: 1 },
]
const rows = [mkRow('a1', 'f1', 'g1', 24, 10), mkRow('a2', 'f2', 'g2', 24, 20), mkRow('a3', 'f3', 'g2', 24, 30)]
const wrapper = mountEdit({ groups: extraGroups, editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f1', () =>
hoverCard(wrapper, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 180, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12, 24])
expect(last.find((r) => r.b_field_id === 'f1').b_group_id).toBe('g2') // 跟随目标分组
})
it('拖到其它分组空隙(无卡片):移到该分组末尾并回收源行宽度', async () => {
const extraGroups = [
{ b_id: 'g1', b_title: '基础信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_title: '附加信息', b_xh: 20, b_canuse: 1 },
]
// g1 内一行两字段 12/12,f3 在 g1 独立行;把 f3 拖到 g2 空白
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ groups: extraGroups, editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
const emptySectionEl = wrapper
.findAll('.mm-fdesign__grid-empty')
.map((w) => w.element)
.find(
(el) => el.closest('[data-section-key]')?.getAttribute('data-section-key') === 'g-g2',
)
?.closest('.mm-fdesign__section')
expect(emptySectionEl).toBeTruthy()
await simulateCardDrop(wrapper, target, 'f3', async () => {
// fallback 模式:意图检测挂在 window mousemove 上
emptySectionEl.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
)
})
const last = wrapper.emitted('update').at(-1)[0]
const moved = last.find((r) => r.b_field_id === 'f3')
expect(moved.b_group_id).toBe('g2')
expect(moved.b_colspan).toBe(24)
// 源行 [f1, f2] 移走 f3 后:f1、f2 保持 12/12(f3 本就独立行,无成员回收)
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(12)
})
it('拖走同行字段到分组空隙:源行剩余成员回收整行(overKey 分支源行采集)', async () => {
const extraGroups = [
{ b_id: 'g1', b_title: '基础信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_title: '附加信息', b_xh: 20, b_canuse: 1 },
]
// g1 一行两字段 12/12,把 f2 拖到 g2 空白 → 源行只剩 f1,回收为 24
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ groups: extraGroups, editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
const emptySectionEl = wrapper
.findAll('.mm-fdesign__grid-empty')
.map((w) => w.element)
.find(
(el) => el.closest('[data-section-key]')?.getAttribute('data-section-key') === 'g-g2',
)
?.closest('.mm-fdesign__section')
expect(emptySectionEl).toBeTruthy()
await simulateCardDrop(wrapper, target, 'f2', async () => {
// fallback 模式:意图检测挂在 window mousemove 上
emptySectionEl.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
)
})
const last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_group_id).toBe('g2')
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(24)
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(24) // 源行回收整行
})
it('余数分配:五字段同行,新字段插入行首先拿余数(5/5/5/5/4)', async () => {
const rows = [
mkRow('a1', 'f1', 'g1', 6, 10),
mkRow('a2', 'f2', 'g1', 6, 20),
mkRow('a3', 'f3', 'g1', 6, 30),
mkRow('a4', 'f4', 'g1', 6, 40),
mkRow('a5', 'f5', 'g1', 24, 50),
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
await simulateCardDrop(wrapper, target, 'f5', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 60, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f5', 'f1', 'f2', 'f3', 'f4'])
// 5 字段均分 24:base=4, rem=4;行首 f5 先拿余数,依次为 5/5/5/5/4
expect(last.map((r) => r.b_colspan)).toEqual([5, 5, 5, 5, 4])
expect(last.reduce((s, r) => s + r.b_colspan, 0)).toBe(24)
})
it('字段库拖入到三字段行首(before):三字段均分 8/8/8', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ fields: extraFields, editConfig: rows })
await nextTick()
await simulateLibDrop(wrapper, extraFields[3], () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 60, 40),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f4', 'f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([8, 8, 8])
})
it('拖到目标字段自身:按上/下半判定为上方/下方新行(左右同行无意义)', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
// 悬停自身下半部 → 下方新行:f2 移出成为 [f1] 下方的新行
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
target.vm.$emit('start', { item: itemEl('f2') })
await hoverCard(wrapper, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 100, 68)
target.vm.$emit('end', { item: itemEl('f2') })
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24])
// 悬停自身上半部 → 上方新行:f2 移出成为 f1 上方的新行
const wrapper2 = mountEdit({ editConfig: rows })
await nextTick()
const target2 = sectionDraggableOf(wrapper2, 'g1')
target2.vm.$emit('start', { item: itemEl('f2') })
await hoverCard(wrapper2, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 100, 15)
target2.vm.$emit('end', { item: itemEl('f2') })
await nextTick()
last = wrapper2.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24])
// 单字段整行:悬停自身无意义,取消不产生变更
const wrapper3 = mountEdit({ editConfig: [mkRow('a1', 'f1', 'g1', 24, 10)] })
await nextTick()
const target3 = sectionDraggableOf(wrapper3, 'g1')
target3.vm.$emit('start', { item: itemEl('f1') })
await hoverCard(wrapper3, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 68)
target3.vm.$emit('end', { item: itemEl('f1') })
await nextTick()
expect(wrapper3.emitted('update')).toBeFalsy()
})
it('记录意图后拖出画布区域松手:视为取消,不把字段误移到最后悬停的分组', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
target.vm.$emit('start', { item: itemEl('f2') })
// 先悬停 f1 记录意图
await hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 180, 40)
// 拖出画布区域(fallback 模式:mousemove 到画布外的 body,意图被清除)
await document.body.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 5, clientY: 5 }),
)
// 松手
target.vm.$emit('end', { item: itemEl('f2') })
await nextTick()
expect(wrapper.emitted('update')).toBeFalsy()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
})
it('字段库拖入行尾下方(newline):新字段独占一行,目标行保持均分', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ fields: extraFields, editConfig: rows })
await nextTick()
await simulateLibDrop(wrapper, extraFields[3], () =>
hoverCard(wrapper, 'f2', { top: 10, height: 60, left: 0, width: 200 }, 100, 68),
)
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f4'])
expect(last.find((r) => r.b_field_id === 'f4').b_colspan).toBe(24)
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
expect(last.find((r) => r.b_field_id === 'f2').b_colspan).toBe(12)
})
it('字段库拖入未分组区域:归属未分组并独占整行', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const rows = [mkRow('a1', 'f1', 'g1', 24, 10), mkRow('a2', 'f2', null, 24, 20)]
const wrapper = mountEdit({ fields: extraFields, editConfig: rows })
await nextTick()
const ungroupedSection = wrapper.find('[data-section-key="ungrouped"]')
expect(ungroupedSection.exists()).toBe(true)
await simulateLibDrop(wrapper, extraFields[3], async () => {
// fallback 模式:意图检测挂在 window mousemove 上
ungroupedSection.element.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
)
})
const last = wrapper.emitted('update').at(-1)[0]
const row = last.find((r) => r.b_field_id === 'f4')
expect(row.b_group_id).toBe(null)
expect(row.b_colspan).toBe(24)
})
// ================= 操作便利性:撤销/重做 / 上下移 / 快捷开关 / 栅格步进 / 复制粘贴 / 分组方向 =================
it('撤销 / 重做:修改栅格宽度后 Ctrl+Z 恢复,Ctrl+Shift+Z 重做', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
const input = wrapper.find('input[aria-label="栅格宽度"]')
await input.setValue('12') // input 事件即时生效
await nextTick()
expect(wrapper.emitted('update').at(-1)[0].find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }))
await nextTick()
expect(wrapper.emitted('update').at(-1)[0].find((r) => r.b_field_id === 'f1').b_colspan).toBe(24)
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, shiftKey: true, bubbles: true }),
)
await nextTick()
expect(wrapper.emitted('update').at(-1)[0].find((r) => r.b_field_id === 'f1').b_colspan).toBe(12)
wrapper.unmount() // 移除 window keydown 监听
})
it('删除字段后可撤销恢复、重做再删除(Ctrl+Z / Ctrl+Shift+Z)', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="delete"]').trigger('click')
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }))
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, shiftKey: true, bubbles: true }),
)
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
wrapper.unmount() // 移除 window keydown 监听
})
it('工具栏上移 / 下移:整行与相邻行整体交换', async () => {
// 第一行 [f1, f2] 12/12,第二行 f3 整行
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20), mkRow('a3', 'f3', 'g1', 24, 30)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
// 选中第二行 f3,上移 → 整行与第一行交换:f3 到顶,[f1,f2] 落第二行
await wrapper.find('[data-row-id="a3"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-up"]').trigger('click')
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f3', 'f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 12, 12]) // 各字段宽度不变,行结构不变
// 选中第一行 f3,下移 → 还原
await wrapper.find('[data-row-id="a3"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-down"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12, 24])
// 选中第一行 f1,上移 → 已在首行,无操作
await wrapper.find('[data-row-id="a1"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-up"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
})
it('多行分组内整行上移 / 下移:跨越多行逐级交换', async () => {
// 三行:a1 整行、a2+a3 同行、a4 整行
const rows = [
mkRow('a1', 'f1', 'g1', 24, 10),
mkRow('a2', 'f2', 'g1', 12, 20),
mkRow('a3', 'f3', 'g1', 12, 30),
mkRow('a4', 'f4', 'g1', 24, 40),
]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
// 选中第二行 f2(同行 [f2,f3]),上移 → [f2,f3] 整行与 [f1] 交换
await wrapper.find('[data-row-id="a2"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-up"]').trigger('click')
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f3', 'f1', 'f4'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12, 24, 24])
// 选中第三行 f1,下移 → 与 [f4] 交换
await wrapper.find('[data-row-id="a1"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-down"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f3', 'f4', 'f1'])
// 选中首行 f2,再上移 → 已在首行,无操作
await wrapper.find('[data-row-id="a2"]').trigger('click')
await nextTick()
await wrapper.find('button[data-action="move-up"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f3', 'f4', 'f1'])
})
it('栅格宽度输入框:直接输入调整并联动压缩同行', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 12, 10), mkRow('a2', 'f2', 'g1', 12, 20)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
await wrapper.find('[data-row-id="a1"]').trigger('click')
await nextTick()
const input = wrapper.find('input[aria-label="栅格宽度"]')
await input.setValue('16')
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_id === 'a1').b_colspan).toBe(16)
expect(last.find((r) => r.b_id === 'a2').b_colspan).toBe(8) // 24-16 联动压缩
})
it('卡片标签快捷开关:点「必」「显」直接切换并 emit update', async () => {
const wrapper = mountEdit()
await nextTick()
const card = wrapper.find('.mm-fdesign__field[data-field-id="f2"]')
await card.find('button[aria-label="切换必填"]').trigger('click')
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_required).toBe(1)
await card.find('button[aria-label="切换显示"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_visible).toBe(0)
expect(card.classes()).toContain('is-invisible')
})
it('Ctrl+C / Ctrl+V:复制选中字段属性,选中其它字段粘贴', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }),
)
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }),
)
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
const f2 = last.find((r) => r.b_field_id === 'f2')
expect(f2.b_required).toBe(1) // f1 required=1 → 套用到 f2
expect(f2.b_colspan).toBe(24)
})
it('Ctrl+X 剪切:复制属性后直接删除当前卡片,可粘贴到其它字段', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true }),
)
await nextTick()
// f1 已被删除
let last = wrapper.emitted('update').at(-1)[0]
expect(last.some((r) => r.b_field_id === 'f1')).toBe(false)
// 选中 f2 后 Ctrl+V 粘贴 f1 的属性
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }),
)
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
const f2 = last.find((r) => r.b_field_id === 'f2')
expect(f2.b_required).toBe(1) // f1 required=1 → 套用到 f2
expect(f2.b_colspan).toBe(24)
})
it('字段库按控件类型分组展示,字段顺序与总数不变', async () => {
const extraFields = [
...fields,
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const wrapper = mountEdit({ fields: extraFields })
await nextTick()
const subtitles = wrapper.findAll('.mm-fdesign__lib-subtitle').map((s) => s.text())
// 首次出现顺序:输入框(input)、金额(money)、下拉选择(select)、文本域(textarea)
expect(subtitles).toEqual(['输入框', '金额', '下拉选择', '文本域'])
expect(wrapper.findAll('.mm-fdesign__lib-item').length).toBe(4) // 4 字段,无布局组件
})
it('智能偏向判定:按比例最近边缘,鼠标偏向哪条边就插到哪边', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 24, 10), mkRow('a2', 'f2', 'g1', 24, 20)]
const wrapper = mountEdit({ editConfig: rows })
await nextTick()
const target = sectionDraggableOf(wrapper, 'g1')
// 偏上((50,22):dyTop=0.2 最近)→ 移到目标行上方新行
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 50, 22),
)
let last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24])
// 偏左((40,25):dxLeft=0.2 比 dyTop=0.25 更近)→ 插到该字段前并入同行
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 40, 25),
)
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f1'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12])
// 偏右((160,25):dxRight=0.2 最近)→ 插到该字段后并入同行
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 160, 25),
)
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([12, 12])
// 偏下((100,68):dyBottom=0.033 最近)→ 移到目标行下方新行
await simulateCardDrop(wrapper, target, 'f2', () =>
hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 100, 68),
)
last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2'])
expect(last.map((r) => r.b_colspan)).toEqual([24, 24])
})
it('点击卡片任意区域(含预览控件)都能选中:指针穿透到卡片本体', async () => {
const wrapper = mountEdit()
await nextTick()
// 点击预览控件包装区(模拟 pointer-events:none 穿透效果)
const controlArea = wrapper.find('.mm-fdesign__field[data-field-id="f2"] .mm-fdesign__field-control')
expect(controlArea.exists()).toBe(true)
await controlArea.trigger('click')
await nextTick()
expect(wrapper.find('[data-row-id="e2"]').classes()).toContain('is-selected')
expect(wrapper.find('.mm-fdesign__inspector .mm-fdesign__subhead').text()).toContain('金额')
})
})
// 带一个分组(g1 金额信息)的子表挂载数据:前两列在组内,第三列未分组
const groupFixture = (rows) => ({
groups: [
{
b_id: 'g1',
b_module_id: '2',
b_parent_id: null,
b_title: '金额信息',
b_i18n: null,
b_layout_direction: 'vertical',
b_xh: 10,
b_canuse: 1,
},
],
editConfig: rows.map((r, i) => ({ ...r, b_group_id: i < 2 ? 'g1' : null })),
})
// 列分区列表谓词:列表元素均为编辑行(含 b_field_id,与字段库的 b_field 区分)
const isColumnList = (c) => {
const l = c.props('list')
return Array.isArray(l) && l.length > 0 && l.every((i) => i.b_field_id != null)
}
describe('ModuleEditConfigPanel(子表表格设计)', () => {
// 预览弹窗经 Modal Teleport 渲染到 body;测试结束后主动关闭,
// 避免残留的旧弹窗手柄被后续测试的 document.body 查询命中
afterEach(() => {
document.body
.querySelectorAll('button.fms-modal-header-btn[aria-label="关闭"]')
.forEach((btn) => btn.click())
})
const tableRows = [
{
b_id: 'e1',
b_module_id: '2',
b_field_id: 'f1',
b_group_id: null,
b_visible: 1,
b_required: 1,
b_readonly: 0,
b_disabled: 0,
b_width: 160,
b_xh: 10,
},
{
b_id: 'e2',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_width: 140,
b_xh: 20,
},
{
b_id: 'e3',
b_module_id: '2',
b_field_id: 'f3',
b_group_id: null,
b_visible: 0,
b_required: 0,
b_readonly: 0,
b_disabled: 1,
b_width: 160,
b_xh: 30,
},
]
const fields = [
{ b_id: 'f1', b_field: 'order_no', b_name: '订单编号', b_type: 'input', b_canuse: 1 },
{ b_id: 'f2', b_field: 'amount', b_name: '订单金额', b_type: 'money', b_canuse: 1 },
{ b_id: 'f3', b_field: 'status', b_name: '订单状态', b_type: 'select', b_canuse: 1 },
{ b_id: 'f4', b_field: 'remark', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
const mountTableEdit = (overrides = {}) =>
mount(ModuleEditConfigPanel, {
props: {
moduleId: '2',
editConfig: tableRows,
listConfig: [],
queryConfig: [],
fields,
groups: [],
referencedGroupIds: [],
editMode: 'table',
loading: false,
...overrides,
},
global: {
stubs: {
FmsTable: {
name: 'FmsTable',
props: ['columns'],
template: '<div class="stub-fmstable" />',
},
FieldGroupManager: true,
Draggable: stubDraggable,
},
},
attachTo: document.body,
})
// 列清单 draggable:列表元素均为编辑行(含 b_field_id,与字段库的 b_field 区分)
function columnDraggableOf(wrapper) {
return wrapper.findAllComponents(stubDraggable).find((c) => {
const l = c.props('list')
return Array.isArray(l) && l.length > 0 && l.every((i) => i.b_field_id != null)
})
}
it('表格模式:纵向列卡片列表按分区展示,隐藏表单画布 / 分组管理 / 容器/说明文字', async () => {
const wrapper = mountTableEdit()
await nextTick()
expect(wrapper.find('.mm-tdesign__sheet').exists()).toBe(true)
expect(wrapper.findAll('.mm-tdesign__col')).toHaveLength(3)
// 表单画布分区不存在(类名 .mm-fdesign__section,与子表分区 .mm-tdesign__section 区分)
expect(wrapper.find('.mm-fdesign__section').exists()).toBe(false)
expect(wrapper.text()).not.toContain('分组管理')
// 子表布局组件区只有「表头分组」,不包含「分组容器」「说明文字」
expect(wrapper.text()).not.toContain('分组容器')
expect(wrapper.text()).not.toContain('说明文字')
// 默认无分组,列在未分组区
expect(wrapper.find('.mm-tdesign__section.is-ungrouped').exists()).toBe(true)
// 工具栏:列数统计 + 隐藏列提示 + 预览按钮
const toolbar = wrapper.find('.mm-tdesign__toolbar')
expect(toolbar.text()).toContain('3')
expect(toolbar.text()).toContain('1 列隐藏')
expect(toolbar.find('.mm-tdesign__preview-btn').exists()).toBe(true)
// 卡片按 b_xh 顺序展示名称/类型/列宽/必填星号,隐藏列带角标
const cards = wrapper.findAll('.mm-tdesign__col')
expect(cards[0].text()).toContain('订单编号')
expect(cards[1].text()).toContain('订单金额')
expect(cards[2].text()).toContain('订单状态')
expect(cards[0].text()).toContain('160px')
expect(cards[0].text()).toContain('输入框')
expect(cards[0].text()).toContain('*')
expect(cards[2].classes()).toContain('is-invisible')
expect(cards[2].text()).toContain('隐藏')
})
it('点击列卡片:选中并显示属性,步进/输入修改列宽 emit update(b_width)', async () => {
const wrapper = mountTableEdit()
await nextTick()
const col = wrapper.find('.mm-tdesign__col[data-field-id="f2"]')
await col.trigger('click')
await nextTick()
expect(col.classes()).toContain('is-selected')
expect(wrapper.find('.mm-fdesign__inspector').text()).toContain('订单金额')
// 列宽下拉当前值为 140(头部编辑方式 Select 与列宽 Select 并存,按 id 定位)
expect(wrapper.findComponent('#mm-tdesign-width').props('modelValue')).toBe('140')
// 步进 +10
await wrapper.find('button[aria-label="列宽加 10"]').trigger('click')
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(150)
// 数字输入自定义列宽
await wrapper.find('input[aria-label="自定义列宽"]').setValue('200')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(200)
})
it('字段库点击添加列:默认列宽按字段类型生成并自动选中', async () => {
const wrapper = mountTableEdit()
await nextTick()
const remark = wrapper
.findAll('.mm-fdesign__lib-item')
.find((el) => el.text().includes('备注'))
await remark.trigger('click')
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
const row = last.find((r) => r.b_field_id === 'f4')
expect(row).toBeTruthy()
expect(row.b_width).toBe(220) // textarea 默认列宽 220
expect(row.b_visible).toBe(1)
expect(wrapper.find('.mm-tdesign__col[data-field-id="f4"]').classes()).toContain('is-selected')
})
it('预览子表弹窗:打开显示真实表格(冻结列 + 样例控件),可关闭', async () => {
const wrapper = mountTableEdit()
await nextTick()
await wrapper.find('.mm-tdesign__preview-btn').trigger('click')
await nextTick()
// Modal 经 Teleport 渲染到 body
const table = document.body.querySelector('.mm-tdesign__preview-wrap .mm-tdesign__table')
expect(table).toBeTruthy()
const heads = document.body.querySelectorAll('.mm-tdesign__head-cell')
expect(heads).toHaveLength(3)
expect(heads[0].textContent).toContain('订单编号')
expect(heads[0].textContent).toContain('160')
// 样例数据行:真实控件外观 + 冻结列(分组行 + 列名行 + 数据行,共 3 行 × 2 冻结列)
expect(document.body.querySelectorAll('.mm-tdesign__cell')).toHaveLength(3)
expect(document.body.querySelector('.mm-tdesign__sample.is-money').textContent).toContain('¥')
expect(document.body.querySelectorAll('.mm-tdesign__frozen')).toHaveLength(6)
// 无分组时第一级表头只有一个空标题分组格
const groupCells = document.body.querySelectorAll('.mm-tdesign__group-cell')
expect(groupCells).toHaveLength(1)
expect(groupCells[0].classList.contains('is-unnamed')).toBe(true)
// 关闭弹窗
document.body.querySelector('button.fms-modal-header-btn[aria-label="关闭"]').click()
await nextTick()
expect(document.body.querySelector('.mm-tdesign__preview-wrap')).toBeFalsy()
})
it('列清单拖拽排序:end 后重排 b_xh 并 emit update', async () => {
const wrapper = mountTableEdit()
await nextTick()
const list = columnDraggableOf(wrapper)
expect(list).toBeTruthy()
list.vm.$emit('start', { item: itemEl('f1') })
// 模拟 SortableJS 就地重排:把 e1 移到末尾
const arr = list.props('list')
const [moved] = arr.splice(0, 1)
arr.push(moved)
list.vm.$emit('end')
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f2', 'f3', 'f1'])
expect(last.map((r) => r.b_xh)).toEqual([10, 20, 30])
})
it('字段库克隆 add 进列清单:重排序号并选中新列', async () => {
const wrapper = mountTableEdit()
await nextTick()
const list = columnDraggableOf(wrapper)
const arr = list.props('list')
arr.push({
b_id: 'e9',
b_module_id: '2',
b_field_id: 'f4',
b_group_id: null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_width: 220,
b_xh: 0,
})
// 模拟 SortableJS add 事件:from = 字段库列表,to = 列分组列表(未分组)
list.vm.$emit('add', {
newIndex: arr.length - 1,
from: { classList: { contains: () => true } },
to: { dataset: { groupId: 'ungrouped' }, classList: { contains: () => false } },
})
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3', 'f4'])
expect(last.find((r) => r.b_field_id === 'f4').b_xh).toBe(40)
expect(wrapper.find('.mm-tdesign__col[data-field-id="f4"]').classes()).toContain('is-selected')
})
it('字段库克隆 add 重复字段:警告并移除新行(不 emit update)', async () => {
const wrapper = mountTableEdit()
await nextTick()
const list = columnDraggableOf(wrapper)
const arr = list.props('list')
arr.push({
b_id: 'e9',
b_module_id: '2',
b_field_id: 'f2',
b_group_id: null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_width: 140,
b_xh: 0,
})
list.vm.$emit('add', {
newIndex: arr.length - 1,
from: { classList: { contains: () => true } },
to: { dataset: { groupId: 'ungrouped' }, classList: { contains: () => false } },
})
await nextTick()
expect(Message.warning).toHaveBeenCalled()
// 重复行被移除,f2 仍只有一行;未提交变更(无 update 事件)
expect(arr.filter((r) => r.b_field_id === 'f2')).toHaveLength(1)
expect(arr).toHaveLength(3)
expect(wrapper.emitted('update')).toBeUndefined()
})
it('数据视图:表格模式隐藏分组/栅格/占位符列,显示列宽列', async () => {
const wrapper = mountTableEdit()
await nextTick()
await wrapper.find('button[data-view="data"]').trigger('click')
await nextTick()
const table = wrapper.findComponent({ name: 'FmsTable' })
const colKeys = table.props('columns').map((c) => c.dataIndex)
expect(colKeys).toContain('b_width')
expect(colKeys).not.toContain('b_group_id')
expect(colKeys).not.toContain('b_colspan')
expect(colKeys).not.toContain('b_placeholder')
expect(colKeys).not.toContain('b_height')
})
// 带一个分组(g1 金额信息)的挂载:f1/f2 在组内,f3 未分组
it('头部编辑方式分段控件:主表表单/子表表格,切换 emit update:editMode', async () => {
const wrapper = mountTableEdit()
await nextTick()
const modeBtns = wrapper.findAll('.mm-edit__mode-btn')
expect(modeBtns).toHaveLength(2)
expect(modeBtns[0].text()).toContain('主表表单')
expect(modeBtns[1].text()).toContain('子表表格')
// 当前 editMode=table → 第二个按钮激活
expect(modeBtns[1].classes()).toContain('is-active')
await modeBtns[0].trigger('click')
await nextTick()
expect(wrapper.emitted('update:editMode').at(-1)[0]).toBe('form')
})
it('拖拽中隐藏空分组提示(避免与拖拽占位重叠显示两行)', async () => {
// 预置:空分组 g1 + 未分组列 f1
const wrapper = mountTableEdit({
groups: [
{
b_id: 'g1',
b_module_id: '2',
b_parent_id: null,
b_title: '空组',
b_i18n: null,
b_layout_direction: 'vertical',
b_xh: 10,
b_canuse: 1,
},
],
editConfig: [tableRows[0]],
})
await nextTick()
// 拖拽前:空分组渲染拖入占位
expect(wrapper.find('.mm-tdesign__section-drop-hint').exists()).toBe(true)
// 模拟拖拽开始(dragPayload 置位)→ 提示隐藏
const list = columnDraggableOf(wrapper)
list.vm.$emit('start', { item: itemEl('f1') })
await nextTick()
expect(wrapper.find('.mm-tdesign__section-drop-hint').exists()).toBe(false)
list.vm.$emit('end')
await nextTick()
})
it('字段库点击添加:自动滚动到新列位置', async () => {
const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {})
try {
const wrapper = mountTableEdit()
await nextTick()
const remark = wrapper
.findAll('.mm-fdesign__lib-item')
.find((el) => el.text().includes('备注'))
await remark.trigger('click')
await nextTick()
await nextTick()
expect(scrollSpy).toHaveBeenCalled()
// 滚动的目标为新列卡片
const target = scrollSpy.mock.instances[scrollSpy.mock.instances.length - 1]
expect(target.className).toContain('mm-tdesign__col')
} finally {
scrollSpy.mockRestore()
}
})
it('添加表头分组:emit updateGroups,新空分区出现', async () => {
const wrapper = mountTableEdit()
await nextTick()
await wrapper.find('.mm-tdesign__add-group').trigger('click')
await nextTick()
const newGroups = wrapper.emitted('updateGroups').at(-1)[0]
expect(newGroups).toHaveLength(1)
expect(newGroups[0].b_title).toBe('新分组')
// 父组件响应 updateGroups 后,画布渲染新分组(空分区拖入提示)
await wrapper.setProps({ groups: newGroups })
await nextTick()
const titles = wrapper.findAll('.mm-tdesign__section-title').map((t) => t.text())
expect(titles.some((t) => t.includes('新分组'))).toBe(true)
// 新空分区渲染拖入占位
expect(wrapper.find('.mm-tdesign__section-drop-hint').exists()).toBe(true)
})
it('字段库已添加字段标记 data-added(拖拽被 filter 拦截,已添加字段不可再拖)', async () => {
const wrapper = mountTableEdit()
await nextTick()
// f2 已在列中 → data-added=true(filter 选择器命中,mousedown 不启动拖拽)
expect(wrapper.find('.mm-fdesign__lib-item[data-field-id="f2"]').attributes('data-added')).toBe(
'true',
)
// f4 未加入 → data-added=false
expect(wrapper.find('.mm-fdesign__lib-item[data-field-id="f4"]').attributes('data-added')).toBe(
'false',
)
})
it('字段库拖入已存在字段到其他分组:拒绝并移除克隆(防重查全部列,不只目标组)', async () => {
// 预置:f2 在分组 g1,g2 为空分组,f1 未分组
const wrapper = mountTableEdit({
groups: [
{
b_id: 'g1',
b_module_id: '2',
b_parent_id: null,
b_title: '金额信息',
b_i18n: null,
b_layout_direction: 'vertical',
b_xh: 10,
b_canuse: 1,
},
{
b_id: 'g2',
b_module_id: '2',
b_parent_id: null,
b_title: '空组',
b_i18n: null,
b_layout_direction: 'vertical',
b_xh: 20,
b_canuse: 1,
},
],
editConfig: [
{ ...tableRows[1], b_group_id: 'g1', b_xh: 10 }, // f2 在 g1
{ ...tableRows[0], b_group_id: null, b_xh: 20 }, // f1 未分组
],
})
await nextTick()
// 空分组 g2 的 draggable(list 为空数组)
const g2List = wrapper
.findAllComponents(stubDraggable)
.find((c) => Array.isArray(c.props('list')) && c.props('list').length === 0)
expect(g2List).toBeTruthy()
const arr = g2List.props('list')
arr.push({
b_id: 'e9',
b_module_id: '2',
b_field_id: 'f2', // 已在 g1 存在
b_group_id: null,
b_visible: 1,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_width: 140,
b_xh: 0,
})
g2List.vm.$emit('add', {
newIndex: 0,
from: { classList: { contains: () => true } },
to: { dataset: { groupId: 'g2' }, classList: { contains: () => false } },
})
await nextTick()
expect(Message.warning).toHaveBeenCalled()
// 克隆被移除,未提交变更
expect(arr).toHaveLength(0)
expect(wrapper.emitted('update')).toBeUndefined()
})
it('跨组拖拽:列拖入分组后 @end 同步 b_group_id 并按分组顺序重排', async () => {
const wrapper = mountTableEdit(groupFixture(tableRows))
await nextTick()
// 定位分区列表:g1(2 列,均含 b_field_id)与未分组(1 列)
const lists = wrapper.findAllComponents(stubDraggable).filter(isColumnList)
const g1List = lists.find((c) => c.props('list').length === 2)
const ungroupedList = lists.find((c) => c.props('list').length === 1)
expect(g1List).toBeTruthy()
expect(ungroupedList).toBeTruthy()
// 模拟 SortableJS 跨组移动:f3 从未分组移入 g1 末尾
const srcArr = ungroupedList.props('list')
const [moved] = srcArr.splice(0, 1)
const g1Arr = g1List.props('list')
g1Arr.push(moved)
g1List.vm.$emit('add', {
newIndex: g1Arr.length - 1,
from: { classList: { contains: () => false } },
to: { dataset: { groupId: 'g1' }, classList: { contains: () => false } },
})
ungroupedList.vm.$emit('end')
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
expect(last.find((r) => r.b_field_id === 'f3').b_group_id).toBe('g1')
expect(last.map((r) => r.b_xh)).toEqual([10, 20, 30])
// 画布上 f3 已进入分组分区
expect(g1List.props('list').map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
})
it('预览弹窗:有分组时渲染两级表头(分组名跨列 = 组内列宽之和)', async () => {
const wrapper = mountTableEdit(groupFixture(tableRows))
await nextTick()
await wrapper.find('.mm-tdesign__preview-btn').trigger('click')
await nextTick()
const groupCells = document.body.querySelectorAll('.mm-tdesign__group-cell')
expect(groupCells).toHaveLength(2) // 金额信息 + 未分组列
expect(groupCells[0].textContent).toContain('金额信息')
expect(groupCells[0].style.width).toBe('300px') // 160 + 140
expect(groupCells[1].classList.contains('is-unnamed')).toBe(true)
expect(groupCells[1].style.width).toBe('160px')
// 列名行仍在
expect(document.body.querySelectorAll('.mm-tdesign__head-cell')).toHaveLength(3)
document.body.querySelector('button.fms-modal-header-btn[aria-label="关闭"]').click()
await nextTick()
})
// 打开预览弹窗(表格经 Modal Teleport 渲染到 body),返回表格元素
async function openPreview(wrapper) {
await wrapper.find('.mm-tdesign__preview-btn').trigger('click')
await nextTick()
return document.body.querySelector('.mm-tdesign__preview-wrap .mm-tdesign__table')
}
function previewHandle(fieldId) {
// 限定在预览弹窗内查询列宽手柄,避免命中残留 DOM
return document.body.querySelector(
`.mm-tdesign__preview-wrap .mm-tdesign__resize-handle[data-field-id="${fieldId}"]`,
)
}
it('预览弹窗拖拽调整列宽:实时更新表头,松手后 emit update(b_width)', async () => {
const wrapper = mountTableEdit()
await nextTick()
await openPreview(wrapper)
const handle = previewHandle('f2')
expect(handle).toBeTruthy()
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, bubbles: true }))
// 拖拽中:仅本地实时改宽(表头列宽变化),不提交
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 160 }))
await nextTick()
expect(wrapper.emitted('update')).toBeUndefined()
const resizingCol = document.body.querySelector('.mm-tdesign__head-cell.is-resizing-col')
expect(resizingCol.attributes['style'].value).toContain('width: 200px')
// 松手:提交新宽度并自动选中该列
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 160 }))
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(200)
expect(wrapper.find('.mm-tdesign__col[data-field-id="f2"]').classes()).toContain('is-selected')
})
it('预览弹窗列宽拖拽限制在 40–600px', async () => {
const wrapper = mountTableEdit()
await nextTick()
await openPreview(wrapper)
const handle = previewHandle('f1')
// 向左拖到极限 → 40
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, bubbles: true }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: -99999 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: -99999 }))
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f1').b_width).toBe(40)
// 向右拖到极限 → 600
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, bubbles: true }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 99999 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 99999 }))
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f1').b_width).toBe(600)
})
it('预览弹窗列宽手柄键盘左右键微调 ±10px', async () => {
const wrapper = mountTableEdit()
await nextTick()
await openPreview(wrapper)
const handle = previewHandle('f2')
handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(150)
handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(140)
})
it('预览弹窗双击列宽手柄:恢复字段类型默认列宽', async () => {
const wrapper = mountTableEdit()
await nextTick()
await openPreview(wrapper)
const handle = previewHandle('f2')
// 先拖到 300
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, bubbles: true }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 260 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 260 }))
await nextTick()
expect(wrapper.emitted('update').at(-1)[0].find((r) => r.b_field_id === 'f2').b_width).toBe(300)
// 双击恢复 money 默认 140
previewHandle('f2').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
await nextTick()
const last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_field_id === 'f2').b_width).toBe(140)
})
})