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