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:
'
',
}
// 子面板统一打桩(页面测试只验证页面自身的骨架/交互)
// ModuleBasicPanel 的 validate 结果可配置,用于测试「保存校验失败」分支
let basicValidateResult = []
const panelStubs = {
ModuleBasicPanel: {
name: 'ModuleBasicPanel',
template: '',
methods: {
validate() {
return Promise.resolve(basicValidateResult)
},
},
},
ModuleFieldsPanel: { name: 'ModuleFieldsPanel', template: '' },
ModuleListConfigPanel: { name: 'ModuleListConfigPanel', template: '' },
ModuleEditConfigPanel: { name: 'ModuleEditConfigPanel', template: '' },
ModuleQueryConfigPanel: {
name: 'ModuleQueryConfigPanel',
template: '',
},
ModuleAutoCodePanel: { name: 'ModuleAutoCodePanel', template: '' },
ModulePowersPanel: { name: 'ModulePowersPanel', template: '' },
ModuleI18nPanel: { name: 'ModuleI18nPanel', template: '' },
MenuDetailPanel: { name: 'MenuDetailPanel', template: '' },
// Modal 尊重 open / footer 条件:footer=false 时不渲染按钮区(与真实组件一致)
Modal: {
name: 'Modal',
props: ['open', 'title', 'okText', 'okType', 'confirmLoading', 'footer'],
template:
'
',
},
Transition: { template: '' },
}
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 groupTitles = wrapper.findAll('.mm-config-nav__group-title').map((t) => t.text())
expect(groupTitles).toEqual(['概览', '数据结构', '界面配置', '业务能力', '高级设置'])
// 仅一个激活项(基本信息)
const active = wrapper.findAll('.mm-config-nav__item.is-active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('基本信息')
})
it('点击导航项切换内容面板', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const fieldsItem = wrapper
.findAll('.mm-config-nav__item')
.find((el) => el.text().includes('字段定义'))
expect(fieldsItem).toBeTruthy()
await fieldsItem.trigger('click')
await nextTick()
const active = wrapper.findAll('.mm-config-nav__item.is-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()
// 新建草稿标记「未保存」
expect(wrapper.text()).toContain('未保存')
})
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 titles = wrapper.findAll('.mm-config-nav__group-title').map((t) => t.text())
expect(titles).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).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(wrapper.text()).toContain('未保存')
// 通过基本信息 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(wrapper.text()).not.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()
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()
// 未切换:header 仍是「订单」
expect(wrapper.find('.mm-header__name').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(wrapper.find('.mm-header__name').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(wrapper.find('.mm-header__name').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 titles = nav.findAll('.mm-config-nav__group-title').map((t) => t.text())
expect(titles).toEqual(['概览', '业务能力', '高级设置'])
// 仅一个激活项(基本信息)
expect(nav.findAll('.mm-config-nav__item.is-active')).toHaveLength(1)
expect(nav.findAll('.mm-config-nav__item.is-active')[0].text()).toContain('基本信息')
// 点击「权限配置」→ 激活态切换,activeSection 同步到面板
const powersItem = nav
.findAll('.mm-config-nav__item')
.find((el) => el.text().includes('权限配置'))
expect(powersItem).toBeTruthy()
await powersItem.trigger('click')
await nextTick()
expect(nav.findAll('.mm-config-nav__item.is-active')).toHaveLength(1)
expect(nav.findAll('.mm-config-nav__item.is-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__group-title').map((t) => t.text())).toEqual([
'概览',
'高级设置',
])
expect(nav.text()).not.toContain('权限配置')
})
it('点击导航「分组管理」:激活分区并打开分组管理弹窗', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const groupsItem = wrapper
.findAll('.mm-config-nav__item')
.find((el) => el.text().includes('分组管理'))
expect(groupsItem).toBeTruthy()
await groupsItem.trigger('click')
await nextTick()
// 分组管理是唯一激活项,弹窗打开(FieldGroupManager 内容)
const active = wrapper.findAll('.mm-config-nav__item.is-active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('分组管理')
const modal = wrapper.find('.stub-modal')
expect(modal.exists()).toBe(true)
// 简化后的分组管理:顶部输入框即新增(不再有「新增根分组」按钮)
expect(modal.find('.mm-groups__input').exists()).toBe(true)
expect(modal.text()).toContain('将添加为顶级分组')
})
it('点击其他导航项:分组管理弹窗自动关闭', async () => {
const wrapper = await mountPage()
await selectModule(wrapper, '订单')
const groupsItem = wrapper
.findAll('.mm-config-nav__item')
.find((el) => el.text().includes('分组管理'))
await groupsItem.trigger('click')
await nextTick()
expect(wrapper.find('.stub-modal').exists()).toBe(true)
const fieldsItem = wrapper
.findAll('.mm-config-nav__item')
.find((el) => el.text().includes('字段定义'))
await fieldsItem.trigger('click')
await nextTick()
expect(wrapper.find('.stub-modal').exists()).toBe(false)
})
})
describe('ModuleBasicPanel(基本信息面板)', () => {
const stubComponents = {
Form: {
name: 'Form',
props: ['columns', 'layout'],
template: '
',
},
FormItem: {
name: 'FormItem',
props: ['label', 'name', 'span'],
template: '
',
},
Input: {
name: 'Input',
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'',
},
Select: { name: 'Select', props: ['modelValue'], template: '' },
Switch: { name: 'Switch', props: ['modelValue'], template: '' },
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('新订单名')
})
})
const mountMenu = (menu, activeSection = 'basic') =>
mount(MenuDetailPanel, {
props: { menu, parentName: '', moduleOptions: [], powers: [], loading: false, activeSection },
global: {
stubs: {
IconSelect: true,
ModulePowersPanel: {
name: 'ModulePowersPanel',
template: '',
},
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 并触发原生 dragover,由画布容器监听器记录意图
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,
})
await card.trigger('dragover', { clientX, clientY })
}
// 构造拖拽 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: '' },
FieldGroupManager: true,
Draggable: stubDraggable,
},
},
})
// 快速构造画布行(拖拽用例通用)
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.text()).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('仅桌面端设计:无设备切换按钮,画布固定桌面端 24 栅格', 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()).toContain('画布 · 桌面端 24 栅格')
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 字段 + 2 布局组件)
expect(wrapper.findAll('.mm-fdesign__lib-item').length).toBe(6)
// 画布分组渲染现有字段
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()
const xhInput = wrapper.find('#mm-fdesign-xh')
await xhInput.setValue('35')
await xhInput.trigger('change')
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_xh).toBe(35)
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 rows = [
{
b_id: 'a1',
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_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()
// 第一步:选中 f2 卡片,点属性面板「同行配对」
await wrapper.find('[data-row-id="a2"]').trigger('click')
await nextTick()
await wrapper.find('.mm-fdesign__pair-btn').trigger('click')
await nextTick()
// 配对进行中:提示条出现、卡片高亮
expect(wrapper.find('.mm-fdesign__pair-hint').exists()).toBe(true)
expect(wrapper.find('[data-row-id="a2"]').classes()).toContain('is-pairing')
// 第二步:选中 f3 卡片,再点「同行配对」
await wrapper.find('[data-row-id="a3"]').trigger('click')
await nextTick()
await wrapper.find('.mm-fdesign__pair-btn').trigger('click')
await nextTick()
const updates = wrapper.emitted('update')
const last = updates[updates.length - 1][0]
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(12)
expect(last.find((r) => r.b_field_id === 'f1').b_colspan).toBe(24)
// 两字段移到相邻(保持原有先后顺序)
expect(last.map((r) => r.b_field_id)).toEqual(['f1', 'f2', 'f3'])
})
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 () => {
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
})
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').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 () => {
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
})
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 () => {
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
})
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)
// 拖出画布区域(dragleave,relatedTarget 不在画布内)
await wrapper
.find('.mm-fdesign__canvas-wrap')
.trigger('dragleave', { relatedTarget: null })
// 松手
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 () => {
ungroupedSection.element.dispatchEvent(new Event('dragover', { bubbles: true }))
})
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('头部撤销 / 重做按钮:删除字段后可撤销恢复、重做再删除', 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)
await wrapper.find('button[aria-label="撤销"]').trigger('click')
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
await wrapper.find('button[aria-label="重做"]').trigger('click')
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
})
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()
await wrapper.find('button[aria-label="栅格宽度加一"]').trigger('click')
await nextTick()
let last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_id === 'a1').b_colspan).toBe(13)
expect(last.find((r) => r.b_id === 'a2').b_colspan).toBe(11) // 24-13 联动压缩
await wrapper.find('button[aria-label="栅格宽度减一"]').trigger('click')
await nextTick()
last = wrapper.emitted('update').at(-1)[0]
expect(last.find((r) => r.b_id === 'a1').b_colspan).toBe(12)
})
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()
expect(wrapper.find('.mm-fdesign__span-usage').text()).toBe('本行 24/24')
})
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('复制属性 / 粘贴属性:把 f1 的布局状态套用到 f2', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
await nextTick()
await wrapper.find('.mm-fdesign__clip-btn').trigger('click') // 复制
await nextTick()
expect(wrapper.find('.mm-fdesign__clip-hint').exists()).toBe(true)
await wrapper.find('.mm-fdesign__field[data-field-id="f2"]').trigger('click')
await nextTick()
await wrapper.findAll('.mm-fdesign__clip-btn')[1].trigger('click') // 粘贴
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('字段库按控件类型分组展示,字段顺序与总数不变', 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(6) // 4 字段 + 2 布局组件
})
it('分组方向按钮:切换单个分组并 emit updateGroups', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__section-dir').trigger('click')
await nextTick()
const updates = wrapper.emitted('updateGroups')
expect(updates).toBeTruthy()
const updatedGroups = updates.at(-1)[0]
expect(updatedGroups.find((g) => g.b_id === 'g1').b_layout_direction).toBe('horizontal')
})
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('金额')
})
})