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('金额') }) })