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)
- })
-})