+
-
+
*
+
+
+
+
+
+
+
+
-
+
@@ -351,6 +428,49 @@ function onRenameCancel() {
font-style: normal;
}
+/* 常驻操作按钮:上移 / 下移 / 删除 */
+.mst-node__actions {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: 4px;
+ opacity: 0.85;
+}
+
+.mst-node__act {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--fms-text-secondary);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
+ color: var(--fms-primary);
+ }
+
+ &.is-danger:hover:not(:disabled) {
+ background: color-mix(in srgb, #d95757 12%, transparent);
+ color: #d95757;
+ }
+
+ &:disabled {
+ opacity: 0.32;
+ cursor: not-allowed;
+ }
+}
+
.mst-node__edit-input {
flex: 1 1 auto;
min-width: 80px;
diff --git a/code/fms/fms-vue/tests/helpers/app.js b/code/fms/fms-vue/tests/helpers/app.js
index a946def8..c8580907 100644
--- a/code/fms/fms-vue/tests/helpers/app.js
+++ b/code/fms/fms-vue/tests/helpers/app.js
@@ -61,9 +61,9 @@ export async function mountPage(Component, options = {}) {
global: {
plugins: [pinia, router],
stubs: options.stubs || {},
- ...(options.global || {}),
+ ...options.global,
},
- ...(options.mountOptions || {}),
+ ...options.mountOptions,
})
return { wrapper, router, pinia }
diff --git a/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js b/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js
index 83b1ba42..7928cf03 100644
--- a/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js
+++ b/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleTableEditPanel from '@/views/system/module-management/components/ModuleTableEditPanel.vue'
@@ -52,6 +53,7 @@ function mountPanel() {
groups: groups.map((g) => ({ ...g })),
editMode: 'table',
},
+ global: { stubs: { draggable: StubDraggable } },
})
}
@@ -64,8 +66,9 @@ function treeRow(wrapper, title) {
/* VTU 的 trigger 会把 dragover/drop 构造为 DragEvent(clientY 只有 getter),
自定义坐标会被静默丢弃;手工构造事件并用 defineProperty 注入坐标。 */
-function fireDrag(el, type, { clientY = 20, dataTransfer } = {}) {
+function fireDrag(el, type, { clientX = 10, clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
+ Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'clientY', { value: clientY })
Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer })
el.dispatchEvent(ev)
@@ -79,6 +82,59 @@ function firePointer(el, type, { clientX = 0, pointerId = 1 } = {}) {
el.dispatchEvent(ev)
}
+/* ---- 内部拖拽改为 vuedraggable(SortableJS force-fallback)驱动,与主表设计器一致 ----
+ * jsdom 不实现 elementFromPoint(命中检测),测试里 mock 它返回预先指定的目标行元素;
+ * onSortWindowMove 据此 closest('.mst-node') 定位落点。
+ * vuedraggable 组件用 StubDraggable 替换,测试通过 emit start/end 来模拟拖拽生命周期。 */
+const StubDraggable = {
+ name: 'draggable',
+ props: {
+ list: { type: Array, default: () => [] },
+ itemKey: { type: String, default: undefined },
+ },
+ emits: ['start', 'end'],
+ template: `
+
+
+
+
+
+
+ `,
+}
+
+let hitElement = null
+
+function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
+ const ev = new Event(type, { bubbles: true, cancelable: true })
+ Object.defineProperty(ev, 'clientX', { value: clientX })
+ Object.defineProperty(ev, 'clientY', { value: clientY })
+ Object.defineProperty(ev, 'button', { value: button })
+ el.dispatchEvent(ev)
+}
+
+// 在源节点 grip 上开始拖拽(emit start 到 draggable 组件)
+function startMouseDrag(wrapper, srcTitle) {
+ const row = treeRow(wrapper, srcTitle)
+ const nodeKey = row.getAttribute('data-key')
+ expect(nodeKey, `节点缺少 data-key:${srcTitle}`).toBeTruthy()
+ wrapper.findComponent({ name: 'draggable' }).vm.$emit('start', {
+ item: { dataset: { key: nodeKey } },
+ })
+}
+
+// 移动鼠标到目标节点行(clientY 决定落点 before/inside/after)
+function moveMouseTo(wrapper, targetTitle, { clientX = 0, clientY = 20 } = {}) {
+ const row = treeRow(wrapper, targetTitle)
+ hitElement = row.querySelector('.mst-node__label') || row
+ fireMouse(window, 'mousemove', { clientX, clientY })
+}
+
+// 松手提交(emit end 到 draggable 组件)
+function endMouseDrag(wrapper) {
+ wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
+}
+
function lastUpdate(wrapper) {
const updates = wrapper.emitted('update')
expect(updates?.length).toBeGreaterThan(0)
@@ -89,6 +145,8 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
beforeEach(() => {
// 面板(数据视图)内嵌 FmsTable,其主题读取 app store
setActivePinia(createPinia())
+ // jsdom 无命中检测,mock elementFromPoint 返回测试指定的目标行元素
+ document.elementFromPoint = () => hitElement
// 行高 40:clientY 5 → before,20 → inside,35 → after
Element.prototype.getBoundingClientRect = () => ({
top: 0,
@@ -107,10 +165,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('列拖到列上(中部 inside)直接换位:插到目标列后并沿用其分组,不做入组嵌套', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到 姓名(c:1,未分组) 行中部
- fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '姓名'), 'dragover', { dataTransfer: dt, clientY: 20 })
- fireDrag(treeRow(wrapper, '姓名'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '年龄')
+ moveMouseTo(wrapper, '姓名', { clientY: 20 })
+ endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
@@ -122,10 +179,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('列拖到列上(上缘 before)换到目标列前,跨组移动时沿用目标列分组', () => {
const wrapper = mountPanel()
// 状态(c:3,属 g1) 拖到 备注(c:4,属 g2) 行上缘
- fireDrag(treeRow(wrapper, '状态'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '备注'), 'dragover', { dataTransfer: dt, clientY: 5 })
- fireDrag(treeRow(wrapper, '备注'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '状态')
+ moveMouseTo(wrapper, '备注', { clientY: 5 })
+ endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
@@ -135,10 +191,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('列拖到分组节点(中部 inside):移入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 姓名(c:1,未分组) 拖到分组「扩展信息」(g2) 行中部 → 加入 g2 直属列末尾(备注后)
- fireDrag(treeRow(wrapper, '姓名'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '扩展信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
- fireDrag(treeRow(wrapper, '扩展信息'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '姓名')
+ moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
+ endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
@@ -149,10 +204,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('列拖到「未分组」收纳节点:移出分组并追加到未分组区', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到「未分组」节点 → b_group_id 置空,追加到未分组区末尾(姓名后)
- fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '未分组'), 'dragover', { dataTransfer: dt, clientY: 20 })
- fireDrag(treeRow(wrapper, '未分组'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '年龄')
+ moveMouseTo(wrapper, '未分组', { clientY: 20 })
+ endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
@@ -197,10 +251,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('分组拖到列上(inside)直接换位:变为该列所属分组的后置兄弟,不嵌套', () => {
const wrapper = mountPanel()
// 分组「基本信息」(g1) 拖到 备注(c:4,属 g2) 行中部 → g1 移到 g2 之后(同级换位)
- fireDrag(treeRow(wrapper, '基本信息'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '备注'), 'dragover', { dataTransfer: dt, clientY: 20 })
- fireDrag(treeRow(wrapper, '备注'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '基本信息')
+ moveMouseTo(wrapper, '备注', { clientY: 20 })
+ endMouseDrag(wrapper)
// 分组重排:g2 在前(10)、g1 在后(20),g1 仍是顶级分组
const groupUpdates = wrapper.emitted('updateGroups')
@@ -219,10 +272,9 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
it('分组拖到分组带上(inside)仍为嵌套语义', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部 → 成为 g1 子分组
- fireDrag(treeRow(wrapper, '扩展信息'), 'dragstart')
- const dt = new FakeDataTransfer()
- fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
- fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
+ startMouseDrag(wrapper, '扩展信息')
+ moveMouseTo(wrapper, '基本信息', { clientY: 20 })
+ endMouseDrag(wrapper)
const groupUpdates = wrapper.emitted('updateGroups')
const nextGroups = groupUpdates.at(-1)[0]
@@ -248,18 +300,144 @@ describe('ModuleTableEditPanel 分组树拖拽语义', () => {
expect(rows.find((r) => r.b_field_id === '2').b_width).toBe(130)
})
- it('实时换位:dragover 即提交,drop 不重复', () => {
- // 拖过即换:拖拽经过目标行时立即换位,drop 阶段不再重复提交
+ it('预览表列头拖拽:列落列换位并沿用目标列分组', () => {
const wrapper = mountPanel()
+ const cell = (fid) =>
+ wrapper
+ .findAll('th.mm-tdesign__cell')
+ .find((th) => th.attributes('data-field-id') === fid)
+ const src = cell('2') // 年龄(g1)
+ const target = cell('4') // 备注(g2)
+ expect(src).toBeTruthy()
+ expect(target).toBeTruthy()
const dt = new FakeDataTransfer()
+ // 拖到目标列左半区(before)
+ fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
+ fireDrag(target.element, 'dragover', { dataTransfer: dt, clientX: 5, clientY: 20 })
+ fireDrag(target.element, 'drop', { dataTransfer: dt, clientX: 5, clientY: 20 })
+
+ const rows = lastUpdate(wrapper)
+ // 年龄移到备注前并进入 g2;顺序:姓名(未分组) → 状态(g1) → 年龄/备注(g2)
+ expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBe('g2')
+ expect(rows.map((r) => r.b_field_id)).toEqual(['1', '3', '2', '4'])
+ })
+
+ it('预览表列头拖拽:拖到分组带移入该分组直属列末尾', () => {
+ const wrapper = mountPanel()
+ const src = wrapper
+ .findAll('th.mm-tdesign__cell')
+ .find((th) => th.attributes('data-field-id') === '1') // 姓名(未分组)
+ const band = wrapper
+ .findAll('th.mm-tdesign__band')
+ .find((th) => th.attributes('data-band-id') === 'g2')
+ expect(src).toBeTruthy()
+ expect(band).toBeTruthy()
+ const dt = new FakeDataTransfer()
+ fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
+ fireDrag(band.element, 'dragover', { dataTransfer: dt, clientX: 50, clientY: 20 })
+ fireDrag(band.element, 'drop', { dataTransfer: dt, clientX: 50, clientY: 20 })
+
+ const rows = lastUpdate(wrapper)
+ expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
+ expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1'])
+ })
+
+ it('预览表:全部分组时,拖到「未分组」落点把列拖出分组', async () => {
+ // 4 列全部在 g1(无未分组列)
+ const rows = [
+ { b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
+ { b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
+ { b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
+ { b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
+ ]
+ const wrapper = mount(ModuleTableEditPanel, {
+ props: {
+ moduleId: 'm1',
+ editConfig: rows.map((r) => ({ ...r })),
+ listConfig: [],
+ queryConfig: [],
+ fields,
+ groups: [groups[0]].map((g) => ({ ...g })),
+ editMode: 'table',
+ },
+ global: { stubs: { draggable: StubDraggable } },
+ })
+ const src = wrapper
+ .findAll('th.mm-tdesign__cell')
+ .find((th) => th.attributes('data-field-id') === '1')
+ expect(src).toBeTruthy()
+ // 无未分组列 → 拖拽开始后才出现「未分组」落点
+ expect(wrapper.find('.mm-tdesign__ungrouped-drop').exists()).toBe(false)
+ const dt = new FakeDataTransfer()
+ fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
+ await nextTick()
+ const dropZone = wrapper.find('.mm-tdesign__ungrouped-drop')
+ expect(dropZone.exists()).toBe(true)
+ fireDrag(dropZone.element, 'dragover', { dataTransfer: dt, clientX: 10, clientY: 20 })
+ fireDrag(dropZone.element, 'drop', { dataTransfer: dt, clientX: 10, clientY: 20 })
+
+ const rowsOut = lastUpdate(wrapper)
+ expect(rowsOut.find((r) => r.b_field_id === '1').b_group_id).toBeNull()
+ expect(rowsOut.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
+ })
+
+ it('悬停亮指示,松手才提交列拖拽', () => {
+ // 悬停阶段仅亮落点指示,mouseup 时提交一次变更,不再重复
+ const wrapper = mountPanel()
// 年龄拖到姓名上(after)
- fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
- fireDrag(treeRow(wrapper, '姓名'), 'dragover', { dataTransfer: dt, clientY: 20 })
- // dragover 已触发换位:update 已发出
- expect(wrapper.emitted('update')?.length).toBeGreaterThanOrEqual(1)
- const updateCount = wrapper.emitted('update').length
- // drop 不再新增 update
- fireDrag(treeRow(wrapper, '姓名'), 'drop', { dataTransfer: dt })
- expect(wrapper.emitted('update').length).toBe(updateCount)
+ startMouseDrag(wrapper, '年龄')
+ moveMouseTo(wrapper, '姓名', { clientY: 20 })
+ // mousemove 不触发换位:update 未发出
+ expect(wrapper.emitted('update')).toBeUndefined()
+ // mouseup 提交一次
+ endMouseDrag(wrapper)
+ expect(wrapper.emitted('update')?.length).toBe(1)
+ })
+
+ it('预览表头:未分组列头在 band 行贯穿整段高度,不留空白', () => {
+ // 4 列:2 列分组(g1),2 列未分组
+ const rows = [
+ { b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
+ { b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
+ { b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
+ { b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
+ ]
+ const wrapper = mount(ModuleTableEditPanel, {
+ props: {
+ moduleId: 'm1',
+ editConfig: rows.map((r) => ({ ...r })),
+ listConfig: [],
+ queryConfig: [],
+ fields,
+ // 仅一个分组 g1(基本信息),避免空分组 g2 产生额外占位列
+ groups: [groups[0]].map((g) => ({ ...g })),
+ editMode: 'table',
+ },
+ global: { stubs: { draggable: StubDraggable } },
+ })
+
+ // 未分组列头在 band 行渲染:2 个 cell,rowspan 贯穿全部表头行(band 行 + 列头行)
+ const ungroupedCells = wrapper.findAll('th.mm-tdesign__cell--ungrouped')
+ expect(ungroupedCells).toHaveLength(2)
+ ungroupedCells.forEach((th) => expect(th.attributes('rowspan')).toBe('2'))
+ expect(ungroupedCells[0].attributes('data-field-id')).toBe('1')
+ expect(ungroupedCells[1].attributes('data-field-id')).toBe('2')
+ // 未分组列头不是空带:渲染了字段名
+ expect(ungroupedCells[0].text()).toContain('姓名')
+ expect(ungroupedCells[1].text()).toContain('年龄')
+
+ // 分组带正常渲染
+ const bandCells = wrapper.findAll('th.mm-tdesign__band')
+ expect(bandCells).toHaveLength(1)
+ expect(bandCells[0].text()).toContain('基本信息')
+
+ // 列头行只渲染分组列(未分组列已在 band 行)
+ // 注意:只查预览表(.mm-tdesign__table),数据视图的 FmsTable 经 v-show 仍驻留 DOM
+ const theadRows = wrapper.findAll('.mm-tdesign__table thead tr')
+ const headerRow = theadRows.at(-1)
+ const headerCells = headerRow.findAll('th.mm-tdesign__cell')
+ expect(headerCells.map((th) => th.attributes('data-field-id'))).toEqual(['3', '4'])
+ // 总列数不变(colgroup 仍为 4 列)
+ expect(wrapper.findAll('colgroup col')).toHaveLength(4)
})
})