From 35ca496b5fe637cb886897c64d947478d422ef29 Mon Sep 17 00:00:00 2001 From: oneao Date: Thu, 27 Aug 2026 23:06:36 +0800 Subject: [PATCH] 20260827230636 --- .../console-2026-08-27T12-49-10-066Z.log | 1 + .../console-2026-08-27T12-54-29-208Z.log | 262 ++++++ .../page-2026-08-27T12-49-10-091Z.yml | 4 + .../page-2026-08-27T12-49-22-094Z.yml | 5 + .../page-2026-08-27T12-54-29-278Z.yml | 19 + code/fms/.tmp-dnd-test.html | 57 ++ code/fms/.tmp-server.js | 20 + .../fms-vue/src/components/ui/tree/tree.vue | 43 +- code/fms/fms-vue/src/router/index.js | 7 + .../components/ModuleTableEditPanel.vue | 544 +++++------- .../structure-tree/ModuleStructureTree.vue | 798 ++++++++++++++++++ .../structure-tree/StructureTreeNode.vue | 464 ++++++++++ code/fms/fms-vue/src/views/tmp-dnd-panel.vue | 48 ++ .../unit/module-table-edit-panel-dnd.spec.js | 265 ++++++ .../tests/unit/tree-external-drag.spec.js | 97 +++ 15 files changed, 2313 insertions(+), 321 deletions(-) create mode 100644 code/fms/.playwright-cli/console-2026-08-27T12-49-10-066Z.log create mode 100644 code/fms/.playwright-cli/console-2026-08-27T12-54-29-208Z.log create mode 100644 code/fms/.playwright-cli/page-2026-08-27T12-49-10-091Z.yml create mode 100644 code/fms/.playwright-cli/page-2026-08-27T12-49-22-094Z.yml create mode 100644 code/fms/.playwright-cli/page-2026-08-27T12-54-29-278Z.yml create mode 100644 code/fms/.tmp-dnd-test.html create mode 100644 code/fms/.tmp-server.js create mode 100644 code/fms/fms-vue/src/views/system/module-management/components/structure-tree/ModuleStructureTree.vue create mode 100644 code/fms/fms-vue/src/views/system/module-management/components/structure-tree/StructureTreeNode.vue create mode 100644 code/fms/fms-vue/src/views/tmp-dnd-panel.vue create mode 100644 code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js create mode 100644 code/fms/fms-vue/tests/unit/tree-external-drag.spec.js diff --git a/code/fms/.playwright-cli/console-2026-08-27T12-49-10-066Z.log b/code/fms/.playwright-cli/console-2026-08-27T12-49-10-066Z.log new file mode 100644 index 00000000..9ab34151 --- /dev/null +++ b/code/fms/.playwright-cli/console-2026-08-27T12-49-10-066Z.log @@ -0,0 +1 @@ +[ 15ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:8090/favicon.ico:0 diff --git a/code/fms/.playwright-cli/console-2026-08-27T12-54-29-208Z.log b/code/fms/.playwright-cli/console-2026-08-27T12-54-29-208Z.log new file mode 100644 index 00000000..cbe0d762 --- /dev/null +++ b/code/fms/.playwright-cli/console-2026-08-27T12-54-29-208Z.log @@ -0,0 +1,262 @@ +[ 48ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://127.0.0.1:5082/src/router/index.js?t=1787835264988:0 +[ 69ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:5082/favicon.ico:0 +[ 99613ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://127.0.0.1:5082/src/router/index.js?t=1787835368774:0 +[ 163804ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://127.0.0.1:5082/src/router/index.js?t=1787835432966:0 +[ 882512ms] [LOG] [vite] server connection lost. Polling for restart... @ http://127.0.0.1:5082/@vite/client:998 +[ 3036438ms] [LOG] [DBG panel] treeExpandedKeys write: ["g:g1","g:g2"] at watch.immediate (http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787838305562:574:77) <- at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:17) <- at callWithAsyncErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2110:15) @ http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787838305562:573 +[ 3036452ms] [LOG] [DBG panel] treeExpandedKeys write: ["g:ungrouped","c:1","g:g1","c:2","c:3","g:g2","c:4"] at watch.immediate (http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787838305562:574:77) <- at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:17) <- at callWithAsyncErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2110:15) @ http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787838305562:573 +[ 4339647ms] [WARNING] [Vue warn]: Failed to resolve component: Tree +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement. + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4351358ms] [WARNING] [Vue warn]: Unhandled error during execution of setup function + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4351359ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4351362ms] ReferenceError: treeData is not defined + at setup (http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787839620509:528:3) + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:17) + at setupStatefulComponent (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:6225:23) + at setupComponent (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:6201:35) + at mountComponent (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:5044:3) + at processComponent (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:5034:9) + at patch (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:4824:28) + at patchKeyedChildren (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:5302:41) + at patchChildren (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:5202:24) + at patchElement (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:4962:26) +[ 4357190ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4357190ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4479878ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4479879ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4504430ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4504430ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4524715ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4524715ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4528113ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4528113ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4532316ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4532316ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4540062ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4540062ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4552449ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4552449ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4578715ms] [ERROR] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2304:22 + at Array.forEach () + at rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2297:24) + at Object.rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2354:11) + at http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787839821621:1941:25 + at http://127.0.0.1:5082/@vite/client:61:95 + at http://127.0.0.1:5082/@vite/client:217:52 + at http://127.0.0.1:5082/@vite/client:195:55 + at Array.forEach () + at HMRClient.queueUpdate (http://127.0.0.1:5082/@vite/client:195:33) @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2355 +[ 4578715ms] [WARNING] [HMR] Something went wrong during Vue component hot-reload. Full reload required. @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2356 +[ 4602539ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4602539ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4608728ms] [ERROR] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2304:22 + at Array.forEach () + at rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2297:24) + at Object.rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2354:11) + at http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787839871712:1863:25 + at http://127.0.0.1:5082/@vite/client:61:95 + at http://127.0.0.1:5082/@vite/client:217:52 + at http://127.0.0.1:5082/@vite/client:195:55 + at Array.forEach () + at HMRClient.queueUpdate (http://127.0.0.1:5082/@vite/client:195:33) @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2355 +[ 4608728ms] [WARNING] [HMR] Something went wrong during Vue component hot-reload. Full reload required. @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2356 +[ 4627589ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4627589ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4634028ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4634028ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4641469ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4641469ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4687116ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 4687116ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 4816617ms] [ERROR] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2304:22 + at Array.forEach () + at rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2297:24) + at Object.rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2354:11) + at http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787839956297:1860:25 + at http://127.0.0.1:5082/@vite/client:61:95 + at http://127.0.0.1:5082/@vite/client:217:52 + at http://127.0.0.1:5082/@vite/client:195:55 + at Array.forEach () + at HMRClient.queueUpdate (http://127.0.0.1:5082/@vite/client:195:33) @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2355 +[ 4816617ms] [WARNING] [HMR] Something went wrong during Vue component hot-reload. Full reload required. @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2356 +[ 5484270ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 5484270ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 5604899ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 5604899ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 5899930ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 5899931ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 5955292ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 5955293ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 6941940ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 6941943ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 6993467ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 6993467ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 7001600ms] [WARNING] [Vue warn]: Unhandled error during execution of scheduler flush + at + at ref=Ref< Proxy(Object) > > + at + at @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2028 +[ 7001600ms] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2331:23 + at callWithErrorHandling (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2103:31) + at flushJobs (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2243:5) +[ 7010025ms] [ERROR] TypeError: Cannot read properties of null (reading 'flags') + at http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2304:22 + at Array.forEach () + at rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2297:24) + at Object.rerender (http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2354:11) + at http://127.0.0.1:5082/src/views/system/module-management/components/ModuleTableEditPanel.vue?t=1787842270767:1875:25 + at http://127.0.0.1:5082/@vite/client:61:95 + at http://127.0.0.1:5082/@vite/client:217:52 + at http://127.0.0.1:5082/@vite/client:195:55 + at Array.forEach () + at HMRClient.queueUpdate (http://127.0.0.1:5082/@vite/client:195:33) @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2355 +[ 7010026ms] [WARNING] [HMR] Something went wrong during Vue component hot-reload. Full reload required. @ http://127.0.0.1:5082/node_modules/.vite/deps/runtime-dom.esm-bundler-DFFakwD_.js?v=ee68b338:2356 diff --git a/code/fms/.playwright-cli/page-2026-08-27T12-49-10-091Z.yml b/code/fms/.playwright-cli/page-2026-08-27T12-49-10-091Z.yml new file mode 100644 index 00000000..94c3b6df --- /dev/null +++ b/code/fms/.playwright-cli/page-2026-08-27T12-49-10-091Z.yml @@ -0,0 +1,4 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: source (field 42) + - generic [ref=e3]: group target + - generic [ref=e4]: group target 2 \ No newline at end of file diff --git a/code/fms/.playwright-cli/page-2026-08-27T12-49-22-094Z.yml b/code/fms/.playwright-cli/page-2026-08-27T12-49-22-094Z.yml new file mode 100644 index 00000000..6d35300b --- /dev/null +++ b/code/fms/.playwright-cli/page-2026-08-27T12-49-22-094Z.yml @@ -0,0 +1,5 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: source (field 42) + - generic [ref=e3]: group target + - generic [ref=e4]: group target 2 + - generic [ref=e5]: "[dragstart] [dragenter target] [dragover target] getData(custom)= \"\" getData(text/plain)= \"\" types= [\"text/plain\",\"application/fms-field\"] effectAllowed= \"copy\" [drop target] getData(custom)= \"42\" getData(text/plain)= \"张三\" types= [\"text/plain\",\"application/fms-field\"] effectAllowed= \"copy\" [dragend] effectAllowed= copy" \ No newline at end of file diff --git a/code/fms/.playwright-cli/page-2026-08-27T12-54-29-278Z.yml b/code/fms/.playwright-cli/page-2026-08-27T12-54-29-278Z.yml new file mode 100644 index 00000000..cc222abf --- /dev/null +++ b/code/fms/.playwright-cli/page-2026-08-27T12-54-29-278Z.yml @@ -0,0 +1,19 @@ +- generic [active] [ref=f1e1]: + - status: + - generic: + - generic: FMS + - generic: 数字化管理系统 + - generic: 正在加载,请稍候… + - generic [ref=f1e4]: + - generic [ref=f1e5]: "[plugin:vite:import-analysis] Failed to resolve import \"nprogress/nprogress.css\" from \"src/router/index.js\". Does the file exist?" + - generic [ref=f1e6]: D:/workspace/code/fms/fms-vue/src/router/index.js:3:8 + - generic [ref=f1e7]: "1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import NProgress from 'nprogress' 3 | import 'nprogress/nprogress.css' | ^ 4 | import DefaultLayout from '@/layouts/DefaultLayout.vue' 5 | import { useAuthStore } from '@/stores/auth'" + - generic [ref=f1e8]: at TransformPluginContext._formatLog (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:31066:39) at TransformPluginContext.error (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:31063:14) at normalizeUrl (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:28008:18) at async file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:28076:30 at async Promise.all (index 2) at async TransformPluginContext.transform (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:28044:4) at async EnvironmentPluginContainer.transform (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:30851:14) at async loadAndTransform (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:20619:26) at async viteTransformMiddleware (file:///D:/workspace/code/fms/fms-vue/node_modules/.pnpm/vite@8.2.1_sass@1.102.0_yaml@2.9.0/node_modules/vite/dist/node/chunks/node.js:25142:20) + - generic [ref=f1e9]: + - text: Click outside, press Esc key, or fix the code to dismiss.You can also disable this overlay by setting + - code [ref=f1e10]: server.hmr.overlay + - text: to + - code [ref=f1e11]: "false" + - text: in + - code [ref=f1e12]: vite.config.js + - text: . \ No newline at end of file diff --git a/code/fms/.tmp-dnd-test.html b/code/fms/.tmp-dnd-test.html new file mode 100644 index 00000000..d43c792f --- /dev/null +++ b/code/fms/.tmp-dnd-test.html @@ -0,0 +1,57 @@ + + + + +DnD behavior probe + + + +
source (field 42)
+
group target
+
group target 2
+

+
+
+
diff --git a/code/fms/.tmp-server.js b/code/fms/.tmp-server.js
new file mode 100644
index 00000000..0e749c24
--- /dev/null
+++ b/code/fms/.tmp-server.js
@@ -0,0 +1,20 @@
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+const root = 'd:/workspace/code/fms'
+http
+  .createServer((req, res) => {
+    const urlPath = req.url === '/' ? '/.tmp-dnd-test.html' : decodeURIComponent(req.url.split('?')[0])
+    const fp = path.join(root, urlPath)
+    fs.readFile(fp, (err, data) => {
+      if (err) {
+        res.writeHead(404)
+        res.end('nf')
+        return
+      }
+      res.writeHead(200, { 'Content-Type': fp.endsWith('.html') ? 'text/html; charset=utf-8' : 'text/plain' })
+      res.end(data)
+    })
+  })
+  .listen(8090, '127.0.0.1')
+console.log('server on 8090')
diff --git a/code/fms/fms-vue/src/components/ui/tree/tree.vue b/code/fms/fms-vue/src/components/ui/tree/tree.vue
index 5fff8a2b..7641dc4a 100644
--- a/code/fms/fms-vue/src/components/ui/tree/tree.vue
+++ b/code/fms/fms-vue/src/components/ui/tree/tree.vue
@@ -31,6 +31,10 @@ const props = defineProps({
   disabled: { type: Boolean, default: false },
   lazy: { type: Boolean, default: false },
   draggable: { type: Boolean, default: false },
+  // 外部拖拽源解析器:(dataTransfer, event) => dragKey | null。
+  // 内部无节点拖拽(dragKey 为空)时,据此把树外拖入的源(如字段库)映射为拖拽键,
+  // 使树内节点可作为外部拖拽的落点。返回 null 表示不识别该外部拖拽源。
+  externalDragKey: { type: Function, default: null },
   // 过滤关键词:非空时高亮命中节点并自动展开其祖先路径
   filter: { type: String, default: '' },
   blockNode: { type: Boolean, default: false },
@@ -302,6 +306,9 @@ function autoExpandParents(keys) {
 const dragKey = ref(null)
 const dropKey = ref(null)
 const dropPosition = ref(null)
+// 标记「树内节点发起的拖拽」:外部拖拽(如字段库)在 dragover 阶段会把占位键
+// 写入 dragKey,不能据此误判为内部拖拽 —— 用独立标记区分子孙循环校验与清理分支。
+const isInternalDrag = ref(false)
 
 function isDescendant(parentKey, childKey) {
   const stack = [...(flat.value.childrenMap.get(parentKey) || [])]
@@ -316,40 +323,63 @@ function isDescendant(parentKey, childKey) {
 function onDragStart(node, e) {
   const key = getNodeKey(node, fn.value)
   dragKey.value = key
+  isInternalDrag.value = true
   if (e.dataTransfer) {
     e.dataTransfer.effectAllowed = 'move'
     e.dataTransfer.setData('text/plain', String(key))
   }
   emit('dragstart', { node, event: e })
 }
+// 解析本次拖拽键:外部源每次重新解析(dragover 阶段 Chrome 读不到 getData(),
+// 只能返回占位键;若被上阶段的占位值挡住,drop 阶段就拿不到真实字段 id 了);
+// 无外部源时用内部节点拖拽键。
+function resolveDragKey(e) {
+  if (!isInternalDrag.value && typeof props.externalDragKey === 'function') {
+    const key = props.externalDragKey(e?.dataTransfer, e)
+    if (key != null) return key
+  }
+  return dragKey.value
+}
 function onDragOver(node, e) {
   e.preventDefault()
-  if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
+  if (e.dataTransfer) {
+    // 外部源(如字段库)effectAllowed='copy' 时保持 copy,否则内部节点移动用 move
+    e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
+  }
   const rect = e.currentTarget.getBoundingClientRect()
   dropPosition.value = calcDropPosition(e.clientY, rect)
   dropKey.value = getNodeKey(node, fn.value)
+  // 外部拖拽源:临时写入 dragKey,让 drop 落点高亮/指示线可见
+  const external = resolveDragKey(e)
+  if (external != null && dragKey.value == null) dragKey.value = external
   emit('dragover', { node, event: e })
 }
 function onDragLeave(node, e) {
   emit('dragleave', { node, event: e })
 }
-// 根容器统一兜底:鼠标真正离开整棵树时清理 drop 指示(避免拖出后残留高亮/指示线)
+// 根容器统一兜底:鼠标真正离开整棵树时清理 drop 指示(避免拖出后残留高亮/指示线);
+// 外部拖拽未落放即拖离时同步清掉占位 dragKey,防止下次 hover 误判
 function onTreeDragLeave(e) {
   const root = e.currentTarget
   if (root && !root.contains(e.relatedTarget)) {
     dropKey.value = null
     dropPosition.value = null
+    if (!isInternalDrag.value) dragKey.value = null
   }
 }
 function onDrop(node, e) {
   e.preventDefault()
-  const drag = dragKey.value
+  const drag = resolveDragKey(e)
   const drop = getNodeKey(node, fn.value)
+  const wasInternal = isInternalDrag.value
+  // 先快照落点位置:resetDragVisual 会把 dropPosition 清为 null,
+  // 若在 emit 时才读取,before/inside/after 将全部丢失(永远为 null)
+  const pos = dropPosition.value
   resetDragVisual()
   if (drag == null || drag === drop) return
-  // 禁止拖入自身子孙,避免层级循环
-  if (isDescendant(drag, drop)) return
-  emit('drop', { dragKey: drag, dropKey: drop, dropPosition: dropPosition.value, node, event: e })
+  // 禁止拖入自身子孙,避免层级循环(仅树内节点拖拽需要校验)
+  if (wasInternal && isDescendant(drag, drop)) return
+  emit('drop', { dragKey: drag, dropKey: drop, dropPosition: pos, node, event: e })
 }
 function onDragEnd(e) {
   const key = dragKey.value
@@ -360,6 +390,7 @@ function resetDragVisual() {
   dragKey.value = null
   dropKey.value = null
   dropPosition.value = null
+  isInternalDrag.value = false
 }
 
 /* ---------- 键盘导航(roving tabindex) ---------- */
diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js
index 83abb139..5f9536fb 100644
--- a/code/fms/fms-vue/src/router/index.js
+++ b/code/fms/fms-vue/src/router/index.js
@@ -16,6 +16,13 @@ const router = createRouter({
       component: () => import('@/views/login/index.vue'),
       meta: { title: '登录' },
     },
+    // 【临时】DnD 端到端验证页,验证后删除
+    {
+      path: '/tmp-dnd-test',
+      name: 'tmp-dnd-test',
+      component: () => import('@/views/tmp-dnd-panel.vue'),
+      meta: { title: '临时 DnD 测试' },
+    },
     {
       path: '/',
       component: DefaultLayout,
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleTableEditPanel.vue b/code/fms/fms-vue/src/views/system/module-management/components/ModuleTableEditPanel.vue
index e7f5f6d5..446e9168 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModuleTableEditPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/components/ModuleTableEditPanel.vue
@@ -2,13 +2,13 @@
 /**
  * 子表表格设计面板(s_field_edit,子表模式 / b_edit_mode='table')。
  * 提供「设计视图 / 数据视图」两种形态,共享同一份草稿数据:
- * - 设计视图:字段库 | 上:多级表头表格实时预览 | 下:分组树 + 属性面板。
+ * - 设计视图:字段库 | 上:多级表头表格实时预览 | 下:分组结构树 + 属性面板。
  *   预览表头按 s_field_group 分组树渲染多级分组带(band),colspan/rowspan 自动
  *   计算(支持任意深度与参差深度),mock 数据行跟随真实顺序与列宽;预览严格被动,
  *   唯一交互是列头右缘拖拽列宽。
- *   下方分组树(Tree 组件)承担全部结构编辑:分组为父节点、列为叶子子节点,
- *   支持拖拽排序 / 嵌套 / 跨组移动列、右键增删改分组、复制粘贴列属性;
- *   属性面板编辑选中列 / 分组的属性。
+ *   下方分组结构树(专用组件 structure-tree/ModuleStructureTree)承担全部结构编辑:
+ *   分组为父节点、列为叶子子节点,支持拖拽排序 / 嵌套 / 跨组移动列、右键增删改分组、
+ *   复制粘贴列属性、分组标题内联重命名;属性面板编辑选中列 / 分组的属性。
  *   结构变更后自动将 editConfig 扁平数组重排为「分组树前序」视觉顺序并重排 b_xh
  *   (维护"分组子树列连续"不变式),保证保存 / 重载所见即所得。
  * - 数据视图:FmsTable 高密度编辑表格(行拖拽排序、同步到列表/查询配置)。
@@ -18,19 +18,12 @@ import {
   Trash2,
   PanelRightClose,
   PanelRightOpen,
-  Check,
-  X,
   Plus,
   Pencil,
   FolderPlus,
-  Folder,
-  Inbox,
-  Table2,
-  Copy,
-  Clipboard,
-  Power,
 } from '@lucide/vue'
-import { Message, Button, Select, Tree } from '@/components/ui'
+import { Message, Button, Select } from '@/components/ui'
+import ModuleStructureTree from './structure-tree/ModuleStructureTree.vue'
 import FmsTable from '@/components/fms-table/FmsTable.vue'
 import { createTempId } from '@/utils/tempId'
 
@@ -57,7 +50,19 @@ const emit = defineEmits([
 ])
 
 const localEdit = ref([])
+// 分组本地副本:拖拽实时换位时立即更新,组件 :groups 绑定 localGroups 而非 props.groups,
+// 避免分组顺序视觉滞后(props.groups 需父级回流后更新)。
+const localGroups = ref([])
+watch(
+  () => props.groups,
+  (g) => {
+    localGroups.value = g ? g.map((row) => ({ ...row })) : []
+  },
+  { immediate: true },
+)
 const activeKey = ref(null)
+// 专用结构树组件引用(新增分组后触发内联命名等)
+const structureTreeRef = ref(null)
 
 function onEditModeChange(value) {
   if (props.loading) return
@@ -292,6 +297,8 @@ const columnWidthOptions = computed(() => {
 
 /* ================= 字段库 ================= */
 const libSearch = ref('')
+// 字段库拖拽:拖入下方分组树的分类节点即可加入该分组
+const draggingFieldId = ref(null)
 // 字段库:展示模块的全部字段(含未加入表格的字段),按名称 a-z 排序;
 // 搜索时按名称/编码过滤,命中才显示
 const libraryFields = computed(() => {
@@ -317,11 +324,27 @@ function isFieldSelected(field) {
   return !!row && String(row.b_field_id) === String(field.b_id)
 }
 
+function onFieldDragStart(event, field) {
+  draggingFieldId.value = String(field.b_id)
+  if (event.dataTransfer) {
+    event.dataTransfer.effectAllowed = 'copy'
+    event.dataTransfer.setData('application/fms-field', String(field.b_id))
+    event.dataTransfer.setData('text/plain', String(field.b_name || field.b_field || field.b_id))
+  }
+}
+
+function onFieldDragEnd() {
+  draggingFieldId.value = null
+}
+
 /* ================= 分组树(多级表头) ================= */
 // 分组是模块级树(s_field_group.b_parent_id)。多级表头 = 分组树:
 // 每个分组节点渲染一个表头分组带(band),列挂在分组下,band 的 colspan/rowspan 由树推导。
 function sortGroupNodesByXh(nodes) {
-  return nodes.toSorted((a, b) => (a.group.b_xh || 0) - (b.group.b_xh || 0))
+  // toSorted 返回新数组,必须回写原数组,否则 buildGroupTree 的排序不生效
+  const sorted = nodes.toSorted((a, b) => (a.group.b_xh || 0) - (b.group.b_xh || 0))
+  nodes.splice(0, nodes.length, ...sorted)
+  return nodes
 }
 
 function buildGroupTree(groups) {
@@ -347,7 +370,7 @@ function buildGroupTree(groups) {
   return roots
 }
 
-const groupTree = computed(() => buildGroupTree(props.groups))
+const groupTree = computed(() => buildGroupTree(localGroups.value))
 
 const groupNodeMap = computed(() => {
   const map = new Map()
@@ -403,10 +426,12 @@ const bandMeta = computed(() => {
   return { roots, metaMap, maxLevel }
 })
 
-// 视觉列序 = 分组树前序(分组直属列 → 子分组列);空分组给占位列;未分组列垫底
+// 视觉列序 = 未分组列在前 + 分组树前序(分组直属列 → 子分组列);空分组给占位列;分组默认在最后面
 const visualColumns = computed(() => {
   const columns = groupColumnsMap.value
   const list = []
+  // 未分组列在最前,分组带(多级表头)依次排后
+  for (const row of columns.ungrouped) list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: null })
   const walk = (nodes) => {
     for (const node of nodes) {
       const own = columns.map.get(node.gid) || []
@@ -418,7 +443,6 @@ const visualColumns = computed(() => {
     }
   }
   walk(bandMeta.value.roots)
-  for (const row of columns.ungrouped) list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: null })
   return list
 })
 
@@ -426,6 +450,9 @@ const visualColumns = computed(() => {
 // 非叶子带 rowspan = 1。colspan = 子树列数。任意参差深度都能正确对齐。
 const bandRows = computed(() => {
   const { roots, maxLevel } = bandMeta.value
+  // 无分组时不渲染 band 行(避免空分组树时出现多余的空表头行)
+  if (!roots.length) return []
+  const ungroupedCount = groupColumnsMap.value.ungrouped.length
   const byLevel = new Map()
   const collect = (nodes) => {
     for (const node of nodes) {
@@ -438,8 +465,14 @@ const bandRows = computed(() => {
   const rows = []
   for (let level = 0; level <= maxLevel; level++) {
     const nodes = byLevel.get(level) || []
-    rows.push(
-      nodes.map((node) => ({
+    const cells = []
+    // 存在未分组列时,band 行开头预留与列头对齐的空位(否则分组带会从第 1 列起铺,
+    // 把未分组列的列头盖在分组带下方,导致「预览」与「分组树」的列-分组归属不一致)
+    if (level === 0 && ungroupedCount > 0) {
+      cells.push({ spacer: true, span: ungroupedCount, rowspan: maxLevel + 1 })
+    }
+    cells.push(
+      ...nodes.map((node) => ({
         gid: node.gid,
         group: node.group,
         level: node.level,
@@ -447,199 +480,151 @@ const bandRows = computed(() => {
         rowspan: node.children.length ? 1 : maxLevel - node.level + 1,
       })),
     )
+    rows.push(cells)
   }
   return rows
 })
 
 /* ================= 关键处理区:分组树数据 =================
- * 树即结构编辑的面板:分组为父节点、列为叶子子节点,与多级表头同源(bandMeta)。
- * 节点 key 统一加前缀便于区分种类:'g:' 分组带 / 'c:' 列。 */
-const treeExpandedKeys = ref([])
+ * 结构编辑面板已迁移到专用组件 ModuleStructureTree(structure-tree/),
+ * 本面板仅保留数据语义(bandMeta / commitColDrag / commitBandDrag 等)。
+ * 树节点 key 约定:'g:' 分组带 / 'c:' 列。 */
 
 function findRowByFieldId(fid) {
   return localEdit.value.find((r) => String(r.b_field_id) === String(fid)) || null
 }
 
-function makeColNode(row) {
-  return {
-    key: `c:${row.b_field_id}`,
-    title: fieldName(row),
-    kind: 'col',
-    gid: groupIdOfRow(row),
-    row,
-    isLeaf: true,
-    icon: Table2,
-  }
-}
-
-// 分组节点:直属列在前、子分组在后(与视觉前序一致,rebuildFlat 按此重排扁平数组)
-function buildTreeNodes(metaNodes) {
-  const columns = groupColumnsMap.value
-  return metaNodes.map((meta) => ({
-    key: `g:${meta.gid}`,
-    title: meta.group.b_title || '空分组',
-    kind: 'group',
-    gid: meta.gid,
-    group: meta.group, // props.groups 行引用(与 commitEditBandTitle/deleteBand 兼容)
-    span: meta.span,
-    icon: Folder,
-    children: [
-      ...(columns.map.get(meta.gid) || []).map((row) => makeColNode(row)),
-      ...buildTreeNodes(meta.children),
-    ],
-  }))
-}
-
-const treeData = computed(() => {
-  const nodes = buildTreeNodes(bandMeta.value.roots)
-  const ungrouped = groupColumnsMap.value.ungrouped
-  if (ungrouped.length) {
-    nodes.push({
-      key: 'g:ungrouped',
-      title: '未分组',
-      kind: 'group',
-      gid: null,
-      span: ungrouped.length,
-      icon: Inbox,
-      disabled: true, // 虚拟节点:仅作收纳落点,不可拖拽 / 选中 / 右键
-      children: ungrouped.map((row) => makeColNode(row)),
-    })
-  }
-  return nodes
-})
-
-// 结构编辑下整树始终展开(新建分组即时可见)
+/* 编辑配置变化 → 同步到本地草稿并静默重排(依赖 groupColumnsMap/bandMeta 等 computed,
+   故 watch 必须在它们定义之后注册,否则 immediate 回调会触发 TDZ 报错)。 */
 watch(
-  treeData,
-  (nodes) => {
-    const all = []
-    const walk = (list) => {
-      for (const n of list) {
-        all.push(n.key)
-        if (n.children) walk(n.children)
-      }
-    }
-    walk(nodes)
-    treeExpandedKeys.value = all
+  () => props.editConfig,
+  (val) => {
+    if (val === localEdit.value) return
+    localEdit.value = val?.map((row) => ({ ...row })) || []
+    // 载入后按分组树前序静默重排数组(不 emit、不改 b_xh),保证数组顺序 = 视觉顺序
+    reorderSilently()
   },
   { immediate: true },
 )
 
 const groupCount = computed(() => (props.groups || []).length)
 
-/* 预览 mock 数据行:按字段类型生成占位值,仅用于实时预览视觉 */
-const mockRows = computed(() => [0, 1, 2])
-function mockValue(col, rowIndex) {
+/* 预览 mock 数据行:仅 1 行示例数据。按字段类型 + 字段名联想生成贴近真实业务
+   数据的占位值,用于实时预览视觉,不参与保存。 */
+const mockRows = computed(() => [0])
+function mockValue(col) {
   if (col.kind !== 'col') return ''
   const field = fieldOf(col.row)
-  const r = rowIndex % 3
+  const name = String(field?.b_name || field?.b_field || '')
   switch (field?.b_type) {
     case 'checkbox':
-      return r === 1 ? '☑' : '☐'
+      return '☑'
     case 'select':
-      return ['选项 A', '选项 B', '选项 C'][r]
+      return /状态|类型|性质|级别/.test(name) ? '启用' : '选项 A'
     case 'number':
-      return ['1,240.50', '3.14', '88'][r]
+      return /数量|个数|件数|合计/.test(name) ? '10' : '1,200'
     case 'money':
-      return ['¥1,200.00', '¥56.00', '¥9,999.00'][r]
+      return '¥1,200.00'
     case 'date':
-      return ['2026-08-27', '2026-07-15', '2026-06-01'][r]
+      return '2026-03-18'
     case 'datetime':
-      return ['2026-08-27 10:30', '2026-07-15 08:00', '2026-06-01 23:59'][r]
+      return '2026-03-18 09:30'
     case 'textarea':
-      return ['这是一段示例文本', '第二行示例内容', '第三行'][r]
-    default:
-      return ['示例文本', '示例值 2', '示例值 3'][r]
+      return '示例说明:这是一段较长的多行文本内容,用于预览换行效果'
+    default: {
+      // 输入框:按字段名联想示例值,让预览更贴近真实录入
+      if (/姓名|名字|负责人|联系人|客户|供应商/.test(name)) return '张三'
+      if (/电话|手机|号码/.test(name)) return '138****1234'
+      if (/邮箱|邮件/.test(name)) return 'zhangsan@example.com'
+      if (/地址|位置|地点/.test(name)) return '北京市朝阳区示例路 88 号'
+      if (/编号|编码|单号|批次|流水号/.test(name)) return 'FMS-2026-0001'
+      if (/数量|个数|件数/.test(name)) return '10'
+      if (/单位/.test(name)) return '件'
+      if (/日期|时间/.test(name)) return '2026-03-18'
+      if (/备注|说明|描述|摘要/.test(name)) return '示例备注内容'
+      return '示例内容'
+    }
   }
 }
 
-/* ================= 树拖拽 → 复用画布 commit 语义 =================
- * Tree 的 drop 提供 dragKey/dropKey/dropPosition(before/inside/after)。
- * key 前缀 g:/c: 区分分组与列;「未分组」虚拟节点 g:ungrouped 只作落点。 */
-function onTreeDrop({ dragKey, dropKey, dropPosition }) {
-  if (props.loading) return
-  if (!dragKey || !dropKey || dragKey === dropKey) return
-  const dragGroup = dragKey.startsWith('g:')
-  const dropGroup = dropKey.startsWith('g:')
+/* ================= 结构编辑:专用组件事件 → 复用画布 commit 语义 =================
+ * 专用组件 ModuleStructureTree 已按领域语义直接计算落点,面板仅做提交:
+ * - col-drop:列拖拽(换位/入组/出组/子树边界)→ commitColDrag
+ * - band-drop:分组拖拽(嵌套/排序)→ commitBandDrag
+ * - field-drop:字段库拖入 → onExternalFieldDrop
+ * - context-menu-click:右键动作 → 增删改分组 / 复制粘贴列属性
+ * - rename-commit:分组内联重命名 → updateGroups */
+function onColDrop({ fieldId, line }) {
+  if (props.loading || !fieldId || !line) return
+  commitColDrag(fieldId, line)
+}
 
-  if (dragGroup) {
-    if (dragKey === 'g:ungrouped') return // 虚拟节点不可拖出
-    const srcId = dragKey.slice(2)
-    // 拖到列上 → 以该列所属分组为目标带(与画布语义一致)
-    if (!dropGroup) {
-      const row = findRowByFieldId(dropKey.slice(2))
-      const gid = row ? groupIdOfRow(row) : null
-      if (gid == null) return
-      if (dropPosition === 'inside') commitBandDrag(srcId, { kind: 'band-nest', gid: String(gid) })
-      else commitBandDrag(srcId, { kind: 'band-edge', gid: String(gid), pos: dropPosition })
-      return
-    }
-    if (dropKey === 'g:ungrouped') return // 分组不入「未分组」
-    const gid = dropKey.slice(2)
-    if (dropPosition === 'inside') commitBandDrag(srcId, { kind: 'band-nest', gid })
-    else commitBandDrag(srcId, { kind: 'band-edge', gid, pos: dropPosition })
-    return
-  }
+function onBandDrop({ gid, line }) {
+  if (props.loading || !gid || !line) return
+  commitBandDrag(gid, line)
+}
 
-  // ---- 列拖拽 ----
-  const fieldId = dragKey.slice(2)
-  if (dropGroup) {
+function onFieldDrop({ fieldId, dropKey, dropPosition }) {
+  onExternalFieldDrop(fieldId, dropKey, dropPosition)
+}
+
+/* 字段库拖入分组树:落点语义归一为列插入/移动:
+   - 未加入表格的字段 → 插入目标分组(落点列则插到其前/后)
+   - 已加入表格的字段 → 复用 commitColDrag 移动(跨组移动 / 移出分组) */
+function onExternalFieldDrop(fieldId, dropKey, dropPosition) {
+  if (props.loading || !fieldId) return
+  const field = fieldMap.value.get(String(fieldId))
+  if (!field) return
+  const existing = localEdit.value.find((r) => String(r.b_field_id) === String(fieldId))
+  // 已加入:复用列拖拽移动语义
+  if (existing) {
     if (dropKey === 'g:ungrouped') {
       commitColDrag(fieldId, { kind: 'ungrouped' })
-      return
+    } else if (dropKey.startsWith('g:')) {
+      commitColDrag(fieldId, { kind: 'into-band', gid: dropKey.slice(2) })
+    } else {
+      const row = findRowByFieldId(dropKey.slice(2))
+      commitColDrag(fieldId, {
+        kind: 'col-edge',
+        gid: row ? groupIdOfRow(row) : null,
+        targetId: dropKey.slice(2),
+        pos: dropPosition === 'before' ? 'before' : 'after',
+      })
     }
-    const gid = dropKey.slice(2)
-    if (dropPosition === 'inside') commitColDrag(fieldId, { kind: 'into-band', gid })
-    // 列落在分组带前/后缘:插到该分组子树边界(新增 col-group-edge)
-    else commitColDrag(fieldId, { kind: 'col-group-edge', gid, pos: dropPosition })
     return
   }
-  const row = findRowByFieldId(dropKey.slice(2))
-  if (!row) return
-  const gid = groupIdOfRow(row)
-  if (dropPosition === 'inside') {
-    commitColDrag(fieldId, gid == null ? { kind: 'ungrouped' } : { kind: 'into-band', gid: String(gid) })
+  // 未加入:插入(落点分组带 → 该分组末尾;落点列 → 其前/后;未分组 → 追加到未分组区)
+  let groupId = null
+  let targetId = null
+  if (dropKey === 'g:ungrouped') {
+    groupId = null
+  } else if (dropKey.startsWith('g:')) {
+    groupId = dropKey.slice(2)
   } else {
-    commitColDrag(fieldId, {
-      kind: 'col-edge',
-      gid: gid == null ? null : String(gid),
-      targetId: dropKey.slice(2),
-      pos: dropPosition,
-    })
+    const row = findRowByFieldId(dropKey.slice(2))
+    groupId = row ? groupIdOfRow(row) : null
+    targetId = row ? dropKey.slice(2) : null
   }
+  const created = insertField(field, {
+    groupId,
+    targetId,
+    position: dropPosition === 'before' ? 'before' : 'after',
+  })
+  if (!created) return
+  selectRow(created)
+  nextTick(() => scrollToRowCell(String(created.b_field_id)))
 }
 
-/* ================= 树右键菜单 ================= */
-function buildTreeContextMenu(node, key) {
-  if (key === 'g:ungrouped') return []
-  if (node?.kind === 'group') {
-    return [
-      { key: 'add-sub', label: '新增子分组', icon: FolderPlus },
-      { key: 'rename', label: '重命名', icon: Pencil },
-      { divider: true },
-      { key: 'enable', label: Number(node.group.b_canuse) === 1 ? '停用' : '启用', icon: Power },
-      { key: 'delete', label: '删除分组', icon: Trash2, danger: true },
-    ]
-  }
-  if (node?.kind === 'col') {
-    return [
-      { key: 'copy', label: '复制列属性', icon: Copy },
-      { key: 'paste', label: '粘贴列属性', icon: Clipboard, disabled: !clipboardProps.value },
-      { key: 'delete', label: '删除列', icon: Trash2, danger: true },
-    ]
-  }
-  return []
-}
-
-function onTreeContextMenuClick({ item, node }) {
+/* 右键菜单动作:node 为专用组件的树节点(kind/gid/group/row) */
+function onStructureContextMenuClick({ item, node }) {
   if (node?.kind === 'group') {
     const band = bandMeta.value.metaMap.get(String(node.gid))
     if (!band) return
     if (item.key === 'add-sub') addSubGroup(band)
-    else if (item.key === 'rename') startEditBand(band)
+    else if (item.key === 'rename') structureTreeRef.value?.startRename(String(node.gid))
     else if (item.key === 'enable') {
       selectBand(band)
-      toggleBandEnable() // 读取 selectedBand
+      toggleBandEnable()
     } else if (item.key === 'delete') deleteBand(band)
   } else if (node?.kind === 'col') {
     if (item.key === 'copy') {
@@ -652,11 +637,19 @@ function onTreeContextMenuClick({ item, node }) {
   }
 }
 
+/* 分组内联重命名提交(专用组件 emit) */
+function onRenameCommit({ gid, title }) {
+  const updated = (props.groups || []).map((g) =>
+    String(g.b_id) === String(gid) ? { ...g, b_title: title } : g,
+  )
+  emit('updateGroups', updated)
+}
+
 /* ================= 结构规范化 ================= */
 // 载入后静默重排(不 emit、不改 b_xh):保证 localEdit 数组顺序 = 分组树前序视觉顺序
 function reorderSilently() {
   const columns = groupColumnsMap.value
-  const list = []
+  const list = [...columns.ungrouped]
   const walk = (nodes) => {
     for (const node of nodes) {
       for (const row of columns.map.get(node.gid) || []) list.push(row)
@@ -664,7 +657,6 @@ function reorderSilently() {
     }
   }
   walk(bandMeta.value.roots)
-  for (const row of columns.ungrouped) list.push(row)
   if (
     list.length === localEdit.value.length &&
     list.every((r, i) => r === localEdit.value[i])
@@ -674,22 +666,30 @@ function reorderSilently() {
   localEdit.value = list
 }
 
-// 结构变更后:按树前序重排数组 + 重排 b_xh + 提交
-function rebuildFlat() {
+// 结构变更后:按树前序重排数组 + 重排 b_xh + 提交。
+// roots 可显式传入(如 commitBandDrag 用本次新分组构树):props.groups 受控于父级,
+// emit 后需等下一次渲染才回流,bandMeta(基于 props.groups)会滞后一拍。
+function rebuildFlat(roots = bandMeta.value.roots) {
   const columns = groupColumnsMap.value
-  const list = []
+  const list = [...columns.ungrouped]
   const walk = (nodes) => {
     for (const node of nodes) {
       for (const row of columns.map.get(node.gid) || []) list.push(row)
       walk(node.children)
     }
   }
-  walk(bandMeta.value.roots)
-  for (const row of columns.ungrouped) list.push(row)
+  walk(roots)
   localEdit.value = list.map((row, i) => ({ ...row, b_xh: (i + 1) * 10 }))
   emitUpdate()
 }
 
+// 由给定分组数组构建可用于重排的 band 根节点(gid + children),与 bandMeta 解耦
+function bandRootsFromGroups(groups) {
+  const toRoots = (nodes) =>
+    nodes.map((node) => ({ gid: String(node.group.b_id), children: toRoots(node.children) }))
+  return toRoots(buildGroupTree(groups))
+}
+
 // 给分组树重新编号:同级按树序 (i+1)*10
 function renumberAllSiblings(groups) {
   const roots = buildGroupTree(groups)
@@ -708,19 +708,6 @@ function emitGroupsRenumbered(nextGroups) {
   emit('updateGroups', renumberAllSiblings(nextGroups))
 }
 
-/* 编辑配置变化 → 同步到本地草稿并静默重排(依赖 groupColumnsMap/bandMeta 等 computed,
-   故 watch 必须在它们定义之后注册,否则 immediate 回调会触发 TDZ 报错) */
-watch(
-  () => props.editConfig,
-  (val) => {
-    if (val === localEdit.value) return
-    localEdit.value = val?.map((row) => ({ ...row })) || []
-    // 载入后按分组树前序静默重排数组(不 emit、不改 b_xh),保证数组顺序 = 视觉顺序
-    reorderSilently()
-  },
-  { immediate: true },
-)
-
 /* ================= 选择 ================= */
 const selectedRow = computed(() => {
   const key = selectedNodeKey.value
@@ -746,56 +733,6 @@ function clearSelection() {
   selectedNodeKey.value = null
 }
 
-// 树选中同步(受控 selected-keys)
-function onTreeSelect(keys) {
-  selectedNodeKey.value = keys?.[0] || null
-}
-
-/* ================= 分组带:内联重命名 ================= */
-const editingGroupKey = ref(null) // 正在编辑的分组带 gid
-const groupTitleDraft = ref('')
-const sectionEditInput = ref(null)
-
-function flushEditingBandTitle() {
-  const key = editingGroupKey.value
-  if (!key) return
-  const band = bandMeta.value.metaMap.get(key)
-  if (band) commitEditBandTitle(band)
-  else cancelEditGroupTitle()
-}
-
-function startEditBand(band) {
-  if (!band || props.loading) return
-  flushEditingBandTitle()
-  editingGroupKey.value = String(band.gid)
-  groupTitleDraft.value = band.group.b_title || ''
-  nextTick(() => {
-    const el = sectionEditInput.value
-    if (el) {
-      el.focus()
-      el.select()
-    }
-  })
-}
-
-function cancelEditGroupTitle() {
-  editingGroupKey.value = null
-  groupTitleDraft.value = ''
-}
-
-function commitEditBandTitle(band) {
-  if (editingGroupKey.value !== String(band.gid)) return
-  const title = groupTitleDraft.value.trim()
-  editingGroupKey.value = null
-  groupTitleDraft.value = ''
-  // 允许清空标题(标题留空即不显示分组标题)
-  if (band.group.b_title === title) return
-  const updated = (props.groups || []).map((g) =>
-    String(g.b_id) === String(band.gid) ? { ...g, b_title: title } : g,
-  )
-  emit('updateGroups', updated)
-}
-
 /* ================= 分组带:增删改 ================= */
 function nextGroupXh() {
   return (props.groups || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10
@@ -804,7 +741,6 @@ function nextGroupXh() {
 /** 顶部按钮:新增顶级分组带,插入后立即进入内联命名 */
 function addRootGroup() {
   if (props.loading) return
-  flushEditingBandTitle()
   const newGroup = {
     b_id: createTempId(),
     b_module_id: props.moduleId,
@@ -820,7 +756,6 @@ function addRootGroup() {
 /** 分组带菜单:新增子分组(嵌套一层) */
 function addSubGroup(band) {
   if (!band || props.loading) return
-  flushEditingBandTitle()
   const newGroup = {
     b_id: createTempId(),
     b_module_id: props.moduleId,
@@ -836,10 +771,10 @@ function addSubGroup(band) {
 function focusNewBandTitle(gid) {
   // emit('updateGroups') 后 props.groups 需经父级重渲染才回流,故推迟到 nextTick 再定位
   nextTick(() => {
-    const band = bandMeta.value.metaMap.get(String(gid))
-    if (!band) return
+    if (!bandMeta.value.metaMap.has(String(gid))) return
     selectedNodeKey.value = `g:${gid}` // 树 / 预览 / 属性面板同步高亮新分组
-    startEditBand(band) // startEditBand 内部 nextTick 聚焦树内联命名输入
+    // 由专用结构树组件触发内联命名(内部 nextTick 聚焦输入框)
+    structureTreeRef.value?.startRename(String(gid))
   })
 }
 
@@ -869,7 +804,8 @@ function deleteBand(band) {
   for (const row of localEdit.value) {
     if (groupIdOfRow(row) === gid) row.b_group_id = null
   }
-  rebuildFlat()
+  // 用提升后的新分组重排(同 commitBandDrag:bandMeta 基于旧 props 会滞后一拍)
+  rebuildFlat(bandRootsFromGroups(groups))
   emit('updateGroups', groups)
   // 列表配置中引用该分组的行清理 b_group_id(仅被删带自身,子分组引用仍有效)
   const listUpdated = (props.listConfig || []).map((r) => {
@@ -1026,9 +962,9 @@ function pastePropsToSelected() {
   emitUpdate()
 }
 
-/* ================= 结构拖拽:改由下方分组树(Tree 组件)承担 =================
-   画布已降级为纯预览,列/分组的排序、嵌套、跨组移动全部在树中完成;
-   onTreeDrop 复用 commitColDrag / commitBandDrag 语义(见上方「树拖拽」)。 */
+/* ================= 结构拖拽:由下方专用结构树组件承担 =================
+   画布已降级为纯预览,列/分组的排序、嵌套、跨组移动全部在结构树中完成;
+   组件按领域语义直接给出落点,本面板 commitColDrag / commitBandDrag 复用。 */
 
 function isDescendantOf(ancestorGid, nodeGid) {
   const meta = bandMeta.value.metaMap.get(String(nodeGid))
@@ -1074,7 +1010,7 @@ function countColumnsBefore(gid, rows) {
 }
 
 /* 列拖拽落点:col-edge 前后插入 / into-band 入组尾 / col-group-edge 分组子树边界 /
-   ungrouped 未分组。树拖拽(onTreeDrop)把 Tree 的落点语义归一到这里。 */
+   ungrouped 未分组。专用结构树组件把落点语义归一到这里。 */
 function commitColDrag(fieldId, line) {
   const rows = localEdit.value
   const idx = rows.findIndex((r) => String(r.b_field_id) === String(fieldId))
@@ -1153,7 +1089,10 @@ function commitBandDrag(sourceGid, line) {
     return c
   })
   emit('updateGroups', groups)
-  rebuildFlat()
+  // 同步本地副本:结构树组件绑定 localGroups,拖拽实时换位立即生效
+  localGroups.value = groups.map((g) => ({ ...g }))
+  // 用本次新分组重排列序:若用 bandMeta(旧 props)重排,b_xh 会按旧树前序编号,与新结构不一致
+  rebuildFlat(bandRootsFromGroups(groups))
   selectedNodeKey.value = `g:${sourceGid}`
 }
 
@@ -1304,10 +1243,13 @@ onBeforeUnmount(() => {
                 'is-selected': isFieldSelected(field),
                 'is-added': isFieldAdded(field),
               }"
-              title="点击加入表格;已加入的点击定位"
+              title="点击加入表格;已加入的点击定位;拖拽到下方分组树可加入对应分组"
+              draggable="true"
               :data-field-id="String(field.b_id)"
               :data-added="isFieldAdded(field) ? 'true' : 'false'"
               @click="selectField(field)"
+              @dragstart="onFieldDragStart($event, field)"
+              @dragend="onFieldDragEnd"
             >
               {{ field.b_name || field.b_field }}
                {
                 
                   
-                    {{ band.group.b_title || '空' }}
+                    
                   
                 
                 
@@ -1463,7 +1411,7 @@ onBeforeUnmount(() => {
                     class="mm-tdesign__mock"
                     :class="{ 'is-muted': col.kind === 'slot' }"
                   >
-                    {{ mockValue(col, ri) }}
+                    {{ mockValue(col) }}
                   
                 
               
@@ -1480,69 +1428,25 @@ onBeforeUnmount(() => {
               {{ groupCount }} 个分组
             
             
- - - +
@@ -1745,7 +1649,7 @@ onBeforeUnmount(() => { type="outline" :disabled="loading" class="mm-fdesign__band-action" - @click="startEditBand(selectedBand)" + @click="structureTreeRef?.startRename(selectedBand.gid)" > 重命名 @@ -2389,6 +2293,16 @@ onBeforeUnmount(() => { } } +/* 未分组列占位带:不可见,仅撑起列头对齐,避免分组带覆盖未分组列 */ +.mm-tdesign__band--spacer { + background: transparent; + border: none; + + &:hover { + background: transparent; + } +} + .mm-tdesign__band-title { display: inline-flex; align-items: center; diff --git a/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/ModuleStructureTree.vue b/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/ModuleStructureTree.vue new file mode 100644 index 00000000..443875af --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/ModuleStructureTree.vue @@ -0,0 +1,798 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/StructureTreeNode.vue b/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/StructureTreeNode.vue new file mode 100644 index 00000000..f4aa24c7 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/module-management/components/structure-tree/StructureTreeNode.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/tmp-dnd-panel.vue b/code/fms/fms-vue/src/views/tmp-dnd-panel.vue new file mode 100644 index 00000000..18df1091 --- /dev/null +++ b/code/fms/fms-vue/src/views/tmp-dnd-panel.vue @@ -0,0 +1,48 @@ + + + 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 new file mode 100644 index 00000000..83b1ba42 --- /dev/null +++ b/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js @@ -0,0 +1,265 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import ModuleTableEditPanel from '@/views/system/module-management/components/ModuleTableEditPanel.vue' + +/** + * 专用结构树组件 ModuleStructureTree 的状态驱动拖拽,在结构树内完成全部落点 + * 语义计算后通过事件交给面板 commit;本文件聚焦这些语义事件的正确性。 + */ +class FakeDataTransfer { + constructor() { + this.store = new Map() + this.types = [] + this.effectAllowed = 'none' + this.dropEffect = 'none' + } + setData(type, value) { + if (!this.types.includes(type)) this.types.push(type) + this.store.set(type, String(value)) + } + getData(type) { + return this.store.has(type) ? this.store.get(type) : '' + } +} + +const fields = [ + { b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' }, + { b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' }, + { b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select' }, + { b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea' }, + { b_id: '5', b_name: '金额', b_field: 'amount', b_type: 'money' }, +] +const groups = [ + { b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 }, + { b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 }, +] +const editConfig = [ + { 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: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 100, 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: 160, b_xh: 30 }, + { b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g2', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 220, b_xh: 40 }, +] + +function mountPanel() { + return mount(ModuleTableEditPanel, { + props: { + moduleId: 'm1', + editConfig: editConfig.map((r) => ({ ...r })), + listConfig: [], + queryConfig: [], + fields, + groups: groups.map((g) => ({ ...g })), + editMode: 'table', + }, + }) +} + +// 按标题文本定位树节点行(分组树整树展开);专用组件节点行类名 .mst-node +function treeRow(wrapper, title) { + const row = wrapper.findAll('.mst-node').find((w) => w.text().includes(title)) + expect(row, `未找到树节点:${title}`).toBeTruthy() + return row.element +} + +/* VTU 的 trigger 会把 dragover/drop 构造为 DragEvent(clientY 只有 getter), + 自定义坐标会被静默丢弃;手工构造事件并用 defineProperty 注入坐标。 */ +function fireDrag(el, type, { clientY = 20, dataTransfer } = {}) { + const ev = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(ev, 'clientY', { value: clientY }) + Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer }) + el.dispatchEvent(ev) +} + +// 同理 pointerdown/pointermove 映射为 MouseEvent(clientX 只读),手工注入坐标 +function firePointer(el, type, { clientX = 0, pointerId = 1 } = {}) { + const ev = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(ev, 'clientX', { value: clientX }) + Object.defineProperty(ev, 'pointerId', { value: pointerId }) + el.dispatchEvent(ev) +} + +function lastUpdate(wrapper) { + const updates = wrapper.emitted('update') + expect(updates?.length).toBeGreaterThan(0) + return updates.at(-1)[0] +} + +describe('ModuleTableEditPanel 分组树拖拽语义', () => { + beforeEach(() => { + // 面板(数据视图)内嵌 FmsTable,其主题读取 app store + setActivePinia(createPinia()) + // 行高 40:clientY 5 → before,20 → inside,35 → after + Element.prototype.getBoundingClientRect = () => ({ + top: 0, + bottom: 40, + left: 0, + right: 200, + width: 200, + height: 40, + x: 0, + y: 0, + toJSON: () => ({}), + }) + Element.prototype.scrollIntoView = vi.fn() + }) + + 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 }) + + const rows = lastUpdate(wrapper) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4']) + expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40]) + // 年龄跟随目标列分组(未分组),而不是嵌套进任何分组 + expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull() + }) + + 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 }) + + const rows = lastUpdate(wrapper) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4']) + expect(rows.find((r) => r.b_field_id === '3').b_group_id).toBe('g2') + }) + + 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 }) + + const rows = lastUpdate(wrapper) + expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2') + // g2 列:备注 → 姓名;整体前序:年龄/状态(g1) → 备注/姓名(g2) + expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1']) + }) + + 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 }) + + const rows = lastUpdate(wrapper) + expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull() + // 未分组:姓名 → 年龄;其后 g1(状态)、g2(备注) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4']) + }) + + it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => { + const wrapper = mountPanel() + // 字段库「金额」(未加入) 拖到分组「基本信息」行中部 + const libItem = wrapper + .findAll('.mm-fdesign__lib-item') + .find((w) => w.attributes('data-field-id') === '5') + expect(libItem).toBeTruthy() + const dt = new FakeDataTransfer() + fireDrag(libItem.element, 'dragstart', { dataTransfer: dt }) + fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 }) + fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt }) + + const rows = lastUpdate(wrapper) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '5', '4']) + expect(rows.find((r) => r.b_field_id === '5').b_group_id).toBe('g1') + expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40, 50]) + }) + + it('字段库已加入字段拖到分组带:跨组移动(复用列拖拽语义)', () => { + const wrapper = mountPanel() + // 字段库「备注」(已加入 g2) 拖到分组「基本信息」行中部 + const libItem = wrapper + .findAll('.mm-fdesign__lib-item') + .find((w) => w.attributes('data-field-id') === '4') + const dt = new FakeDataTransfer() + fireDrag(libItem.element, 'dragstart', { dataTransfer: dt }) + fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 }) + fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt }) + + const rows = lastUpdate(wrapper) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4']) + expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1') + }) + + 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 }) + + // 分组重排:g2 在前(10)、g1 在后(20),g1 仍是顶级分组 + const groupUpdates = wrapper.emitted('updateGroups') + expect(groupUpdates?.length).toBeGreaterThan(0) + const nextGroups = groupUpdates.at(-1)[0] + const g1 = nextGroups.find((g) => g.b_id === 'g1') + const g2 = nextGroups.find((g) => g.b_id === 'g2') + expect(g1.b_parent_id).toBeNull() + expect(g1.b_xh).toBe(20) + expect(g2.b_xh).toBe(10) + // 列序跟随分组树前序:姓名(未分组) → 备注(g2) → 年龄/状态(g1) + const rows = lastUpdate(wrapper) + expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3']) + }) + + 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 }) + + const groupUpdates = wrapper.emitted('updateGroups') + const nextGroups = groupUpdates.at(-1)[0] + expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1') + }) + + it('预览表列头右缘拖拽仅调列宽,松手才提交一次', () => { + const wrapper = mountPanel() + const resizer = wrapper + .findAll('th.mm-tdesign__cell') + .find((th) => th.attributes('data-field-id') === '2') + .find('.mm-tdesign__resizer') + expect(resizer).toBeTruthy() + + firePointer(resizer.element, 'pointerdown', { clientX: 200 }) + firePointer(resizer.element, 'pointermove', { clientX: 230 }) + // 拖拽过程中不提交 + expect(wrapper.emitted('update')).toBeUndefined() + firePointer(resizer.element, 'pointerup') + + const rows = lastUpdate(wrapper) + // 年龄初始宽 100,右移 30px → 130 + expect(rows.find((r) => r.b_field_id === '2').b_width).toBe(130) + }) + + it('实时换位:dragover 即提交,drop 不重复', () => { + // 拖过即换:拖拽经过目标行时立即换位,drop 阶段不再重复提交 + const wrapper = mountPanel() + const dt = new FakeDataTransfer() + // 年龄拖到姓名上(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) + }) +}) diff --git a/code/fms/fms-vue/tests/unit/tree-external-drag.spec.js b/code/fms/fms-vue/tests/unit/tree-external-drag.spec.js new file mode 100644 index 00000000..27821cbc --- /dev/null +++ b/code/fms/fms-vue/tests/unit/tree-external-drag.spec.js @@ -0,0 +1,97 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import Tree from '@/components/ui/tree/tree.vue' + +/** + * 模拟 Chrome 的 DataTransfer 行为: + * - dragover / dragenter 阶段:getData() 对所有类型返回空字符串(隐私保护),但 types / effectAllowed 可用 + * - drop 阶段:getData() 正常返回 dragstart 中写入的数据 + */ +function makeDataTransfer({ types = [], get = () => '' } = {}) { + return { + effectAllowed: 'copy', + dropEffect: 'none', + types, + getData: vi.fn((t) => get(t)), + setData: vi.fn(), + } +} + +const treeData = [ + { + key: 'g:1', + title: '分组1', + isLeaf: false, + children: [{ key: 'c:1', title: '列1', isLeaf: true }], + }, +] + +describe('Tree 外部拖拽(字段库 → 分组)', () => { + beforeEach(() => { + // 让 calcDropPosition 能算出 'inside'(行高 40,鼠标落在中点) + Element.prototype.getBoundingClientRect = () => ({ + top: 0, + bottom: 40, + left: 0, + right: 200, + width: 200, + height: 40, + x: 0, + y: 0, + toJSON: () => ({}), + }) + }) + + it('dragover 阶段 getData 为空(Chrome 限制)时,基于 types 识别外部源并设置拖放指示', async () => { + const externalDragKey = vi.fn((dt) => { + const types = Array.from(dt?.types || []) + if (!types.includes('application/fms-field')) return null + const fid = dt?.getData?.('application/fms-field') + return fid ? `external-field:${fid}` : `external-field:__pending__` + }) + const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } }) + const nodeRow = wrapper.find('.tree-node') + // dragover:getData 读不到值(模拟 Chrome),仅 types 可用 + const dt = makeDataTransfer({ types: ['application/fms-field'], get: () => '' }) + await nodeRow.trigger('dragover', { dataTransfer: dt, clientY: 20 }) + expect(externalDragKey).toHaveBeenCalled() + // dragKey 已被解析 → 节点行出现放置指示(说明拖拽已被识别,可正常落放) + expect(nodeRow.classes()).toContain('drop-inside') + }) + + it('dragover 阶段仅用 getData 判断时(修复前实现),拖放指示不出现', async () => { + // 旧实现:只靠 getData 判断拖拽源 → Chrome 下 dragover 读不到 → dragKey 不设置 + const externalDragKey = vi.fn((dt) => { + const fid = dt?.getData?.('application/fms-field') + return fid ? `external-field:${String(fid)}` : null + }) + const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } }) + const nodeRow = wrapper.find('.tree-node') + const dt = makeDataTransfer({ types: ['application/fms-field'], get: () => '' }) + await nodeRow.trigger('dragover', { dataTransfer: dt, clientY: 20 }) + // 没有任何 drop-* 指示类 + expect(nodeRow.classes().some((c) => c.startsWith('drop-'))).toBe(false) + }) + + it('drop 阶段 getData 可用时,发出正确的 external-field 拖拽键', async () => { + const externalDragKey = (dt) => { + const types = Array.from(dt?.types || []) + if (!types.includes('application/fms-field')) return null + const fid = dt?.getData?.('application/fms-field') + return fid ? `external-field:${fid}` : `external-field:__pending__` + } + const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } }) + const nodeRow = wrapper.find('.tree-node') + // dragover(读不到)→ drop(能读到 '42') + const overDT = makeDataTransfer({ types: ['application/fms-field'], get: () => '' }) + await nodeRow.trigger('dragover', { dataTransfer: overDT, clientY: 20 }) + const dropDT = makeDataTransfer({ + types: ['application/fms-field'], + get: (t) => (t === 'application/fms-field' ? '42' : ''), + }) + await nodeRow.trigger('drop', { dataTransfer: dropDT }) + const drops = wrapper.emitted('drop') + expect(drops).toBeTruthy() + expect(drops[0][0]).toMatchObject({ dragKey: 'external-field:42', dropKey: 'g:1' }) + }) +})