20260827230636

This commit is contained in:
oneao committed 2026-08-27 23:06:36 +08:00
1 parent 3824af0f12
commit 35ca496b5f
15 files changed
+2313 -321

No files matched your search

@@ -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
@@ -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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 (<anonymous>)
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 (<anonymous>)
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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 (<anonymous>)
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 (<anonymous>)
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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 (<anonymous>)
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 (<anonymous>)
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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 <ModuleTableEditPanel module-id="m1" edit-config= [Object, Object, Object, Object] list-config= [] ... >
at <TmpDndPanel onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy(Object) > >
at <RouterView>
at <App> @ 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 (<anonymous>)
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 (<anonymous>)
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
@@ -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
@@ -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"
@@ -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: .
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DnD behavior probe</title>
<style>
#src, #target, #target2 { margin: 20px; padding: 16px; border: 1px solid #888; }
#src { background: #ddf; cursor: grab; }
#target { background: #dfd; }
#target2 { background: #fdd; }
#log { white-space: pre-wrap; font-family: monospace; font-size: 12px; }
</style>
</head>
<body>
<div id="src" draggable="true">source (field 42)</div>
<div id="target">group target</div>
<div id="target2">group target 2</div>
<pre id="log"></pre>
<script>
const log = document.getElementById('log')
function out(...args) { log.textContent += args.join(' ') + '\n' }
document.getElementById('src').addEventListener('dragstart', (e) => {
out('[dragstart]')
try {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/fms-field', '42')
e.dataTransfer.setData('text/plain', '张三')
} catch (err) { out('[dragstart err]', err.message) }
})
document.getElementById('src').addEventListener('dragend', (e) => {
out('[dragend] effectAllowed=', e.dataTransfer ? e.dataTransfer.effectAllowed : 'none')
})
for (const id of ['target', 'target2']) {
const el = document.getElementById(id)
el.addEventListener('dragenter', (e) => out('[dragenter ' + id + ']'))
el.addEventListener('dragover', (e) => {
e.preventDefault()
const dt = e.dataTransfer
out('[dragover ' + id + '] getData(custom)=', JSON.stringify(dt.getData('application/fms-field')),
'getData(text/plain)=', JSON.stringify(dt.getData('text/plain')),
'types=', JSON.stringify(Array.from(dt.types || [])),
'effectAllowed=', JSON.stringify(dt.effectAllowed))
})
el.addEventListener('drop', (e) => {
e.preventDefault()
const dt = e.dataTransfer
out('[drop ' + id + '] getData(custom)=', JSON.stringify(dt.getData('application/fms-field')),
'getData(text/plain)=', JSON.stringify(dt.getData('text/plain')),
'types=', JSON.stringify(Array.from(dt.types || [])),
'effectAllowed=', JSON.stringify(dt.effectAllowed))
})
}
</script>
</body>
</html>
+20
View File
@@ -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')
@@ -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) ---------- */
+7
View File
@@ -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,
@@ -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:<gid>' 分组带 / 'c:<fieldId>' 列。 */
const treeExpandedKeys = ref([])
* 结构编辑面板已迁移到专用组件 ModuleStructureTree(structure-tree/),
* 本面板仅保留数据语义(bandMeta / commitColDrag / commitBandDrag 等)。
* 树节点 key 约定:'g:<gid>' 分组带 / 'c:<fieldId>' 列。 */
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"
>
<span class="mm-fdesign__lib-name">{{ field.b_name || field.b_field }}</span>
<span v-if="isFieldAdded(field)" class="mm-fdesign__lib-added" title="已加入表格"
@@ -1414,17 +1356,23 @@ onBeforeUnmount(() => {
<tr v-for="(row, li) in bandRows" :key="`band-row-${li}`">
<th
v-for="band in row"
:key="band.gid"
:key="band.spacer ? `spacer-${li}` : band.gid"
class="mm-tdesign__band"
:data-band-id="band.gid"
:class="
band.spacer
? 'mm-tdesign__band--spacer'
: {
'is-selected': selectedNodeKey === `g:${band.gid}`,
'is-disabled': Number(band.group.b_canuse) !== 1,
}
"
:colspan="band.span"
:rowspan="band.rowspan"
:class="{
'is-selected': selectedNodeKey === `g:${band.gid}`,
'is-disabled': Number(band.group.b_canuse) !== 1,
}"
:data-band-id="band.spacer ? undefined : band.gid"
>
<span class="mm-tdesign__band-title">{{ band.group.b_title || '空' }}</span>
<template v-if="!band.spacer">
<span class="mm-tdesign__band-title">{{ band.group.b_title || '空' }}</span>
</template>
</th>
</tr>
<!-- 列头行 -->
@@ -1463,7 +1411,7 @@ onBeforeUnmount(() => {
class="mm-tdesign__mock"
:class="{ 'is-muted': col.kind === 'slot' }"
>
{{ mockValue(col, ri) }}
{{ mockValue(col) }}
</td>
</tr>
</tbody>
@@ -1480,69 +1428,25 @@ onBeforeUnmount(() => {
<span class="mm-fdesign__tree-head-meta">{{ groupCount }} 个分组</span>
</div>
<div class="mm-fdesign__tree-scroll">
<Tree
:tree-data="treeData"
:selected-keys="selectedNodeKey ? [selectedNodeKey] : []"
:expanded-keys="treeExpandedKeys"
@update:expanded-keys="(k) => (treeExpandedKeys = k)"
:context-menu="buildTreeContextMenu"
block-node
draggable
@select="onTreeSelect"
@context-menu-click="onTreeContextMenuClick"
@drop="onTreeDrop"
<ModuleStructureTree
ref="structureTreeRef"
:groups="localGroups"
:rows="localEdit"
:fields="props.fields"
:selected-key="selectedNodeKey"
:loading="props.loading"
:paste-enabled="!!clipboardProps"
@update:selected-key="(k) => (selectedNodeKey = k)"
@col-drop="onColDrop"
@band-drop="onBandDrop"
@field-drop="onFieldDrop"
@context-menu-click="onStructureContextMenuClick"
@rename-commit="onRenameCommit"
>
<template #title="{ node }">
<span
class="mm-fdesign__tree-title"
:class="{ 'is-disabled': node.kind === 'group' && node.group && Number(node.group.b_canuse) !== 1 }"
>
<template v-if="node.kind === 'group' && node.gid != null && editingGroupKey === node.gid">
<input
ref="sectionEditInput"
v-model="groupTitleDraft"
class="mm-fdesign__section-edit"
maxlength="100"
placeholder="分组名称"
@click.stop
@keydown.stop
@keydown.enter.prevent="commitEditBandTitle({ gid: node.gid, group: node.group })"
@keydown.esc.prevent="cancelEditGroupTitle"
@blur="commitEditBandTitle({ gid: node.gid, group: node.group })"
/>
<span class="mm-fdesign__section-edit-actions">
<button
type="button"
class="mm-fdesign__section-edit-btn"
title="确认"
aria-label="确认修改分组名"
@mousedown.prevent="commitEditBandTitle({ gid: node.gid, group: node.group })"
>
<Check :size="13" />
</button>
<button
type="button"
class="mm-fdesign__section-edit-btn"
title="取消"
aria-label="取消修改分组名"
@mousedown.prevent="cancelEditGroupTitle"
>
<X :size="13" />
</button>
</span>
</template>
<template v-else>
<span class="mm-fdesign__tree-label">{{ node.title }}</span>
<span v-if="node.kind === 'group'" class="mm-fdesign__tree-meta">{{ node.span }} 列</span>
<span v-else class="mm-fdesign__tree-meta">{{ controlTypeLabel(node.row) }}</span>
<i v-if="node.kind === 'col' && Number(node.row.b_required) === 1" class="mm-fdesign__required">*</i>
</template>
</span>
</template>
<template #empty>
<span class="mm-fdesign__tree-empty">暂无分组,点击工具栏「添加分组」</span>
</template>
</Tree>
</ModuleStructureTree>
</div>
</div>
@@ -1745,7 +1649,7 @@ onBeforeUnmount(() => {
type="outline"
:disabled="loading"
class="mm-fdesign__band-action"
@click="startEditBand(selectedBand)"
@click="structureTreeRef?.startRename(selectedBand.gid)"
>
<Pencil :size="14" /> 重命名
</Button>
@@ -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;
@@ -0,0 +1,798 @@
<script setup>
/**
* 专用结构编辑器:模块「子表表格」的分组结构树。
*
* 不复用通用 Tree —— 本组件直接以领域数据(groups 分组 / rows 编辑行 / fields 字段)
* 构建「分组带 + 列」树,并原生承担全部结构编辑交互:
* - 拖拽:列↔列换位、列入组、列出组、组嵌套、组排序、外部字段库拖入;
* 落点由组件按领域语义直接计算(列只有 before/after,分组才分 before/inside/after),
* 通过语义事件(col-drop / band-drop / field-drop)交给面板 commit。
* - 右键菜单:分组(新增子分组/重命名/启用停用/删除)、列(复制/粘贴/删除)。
* - 内联重命名:分组标题原地编辑,startRename(gid) 供外部(面板新增分组后)触发。
* - 选中:受控 selectedKey,双向同步面板属性面板 / 预览高亮。
* - 键盘导航:roving tabindex(↑↓←→ / Enter / Space)。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import {
Clipboard,
Copy,
Folder,
FolderPlus,
Pencil,
Power,
Trash2,
} from '@lucide/vue'
import StructureTreeNode from './StructureTreeNode.vue'
const props = defineProps({
groups: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
/** 受控选中键:'g:<gid>' | 'c:<fieldId>' | null */
selectedKey: { type: String, default: null },
loading: { type: Boolean, default: false },
/** 剪贴板是否有列属性(决定右键「粘贴」可用性) */
pasteEnabled: { type: Boolean, default: false },
})
const emit = defineEmits([
'update:selectedKey',
'col-drop',
'band-drop',
'field-drop',
'context-menu-click',
'rename-commit',
])
/* ================= 字段/名称推导 ================= */
const FIELD_TYPE_LABELS = {
input: '输入框',
textarea: '文本域',
number: '数字',
money: '金额',
date: '日期',
datetime: '日期时间',
checkbox: '复选框',
select: '下拉选择',
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) map.set(String(field.b_id), field.b_name || field.b_field)
return map
})
const fieldMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) map.set(String(field.b_id), field)
return map
})
function fieldName(row) {
if (row?.b_field_id == null) return ''
return fieldNameMap.value.get(String(row.b_field_id)) || String(row.b_field_id)
}
function controlTypeLabel(row) {
const field = row?.b_field_id != null ? fieldMap.value.get(String(row.b_field_id)) : null
return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知'
}
function groupIdOfRow(row) {
return row?.b_group_id == null ? null : String(row.b_group_id)
}
/* ================= 分组树构建(与面板 bandMeta 同源) ================= */
function sortGroupNodesByXh(nodes) {
// 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) {
const map = new Map()
const roots = []
for (const g of groups || []) map.set(String(g.b_id), { group: g, children: [] })
for (const g of groups || []) {
const node = map.get(String(g.b_id))
const parentId = g.b_parent_id == null ? null : String(g.b_parent_id)
if (parentId != null && map.has(parentId)) map.get(parentId).children.push(node)
else roots.push(node)
}
sortGroupNodesByXh(roots)
const walk = (nodes) => {
for (const node of nodes) {
sortGroupNodesByXh(node.children)
walk(node.children)
}
}
walk(roots)
return roots
}
const groupTree = computed(() => buildGroupTree(props.groups))
const groupNodeMap = computed(() => {
const map = new Map()
const walk = (nodes) => {
for (const node of nodes) {
map.set(String(node.group.b_id), node)
walk(node.children)
}
}
walk(groupTree.value)
return map
})
// groupId → 直属列(不递归收集);未分组列单独收集
const groupColumnsMap = computed(() => {
const map = new Map()
const ungrouped = []
for (const row of props.rows || []) {
const gid = groupIdOfRow(row)
if (gid != null && groupNodeMap.value.has(gid)) {
if (!map.has(gid)) map.set(gid, [])
map.get(gid).push(row)
} else {
ungrouped.push(row)
}
}
return { map, ungrouped }
})
// 分组带元信息:层级 + 子树列数(span,空分组至少 1)
const bandMeta = computed(() => {
const columns = groupColumnsMap.value
const metaMap = new Map()
const build = (nodes, level) =>
nodes.map((node) => {
const gid = String(node.group.b_id)
const meta = {
gid,
group: node.group,
level,
ownCount: (columns.map.get(gid) || []).length,
children: [],
span: 0,
}
metaMap.set(gid, meta)
meta.children = build(node.children, level + 1)
meta.span = Math.max(1, meta.ownCount + meta.children.reduce((s, c) => s + c.span, 0))
return meta
})
const roots = build(groupTree.value, 0)
return { roots, metaMap }
})
/* ================= 树数据 ================= */
function makeColNode(row) {
return {
key: `c:${row.b_field_id}`,
title: fieldName(row),
kind: 'col',
gid: groupIdOfRow(row),
row,
controlLabel: controlTypeLabel(row),
required: Number(row.b_required) === 1,
isLeaf: true,
}
}
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,
span: meta.span,
disabled: Number(meta.group.b_canuse) !== 1,
isLeaf: false,
children: [
...(columns.map.get(meta.gid) || []).map((row) => makeColNode(row)),
...buildTreeNodes(meta.children),
],
}))
}
const treeData = computed(() => {
const nodes = []
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, // 虚拟节点:仅作收纳落点
isLeaf: false,
children: ungrouped.map((row) => makeColNode(row)),
})
}
nodes.push(...buildTreeNodes(bandMeta.value.roots))
return nodes
})
/* ================= 展开:结构编辑下整树始终展开 ================= */
const expandedKeys = ref([])
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)
expandedKeys.value = all
},
{ immediate: true },
)
function toggleExpand(key) {
const set = new Set(expandedKeys.value)
if (set.has(key)) set.delete(key)
else set.add(key)
expandedKeys.value = [...set]
}
/* ================= 选中 ================= */
function handleSelect(key) {
if (props.loading) return
emit('update:selectedKey', key === props.selectedKey ? null : key)
}
/* ================= 键盘导航(roving tabindex) ================= */
const activeKey = ref(null)
const nodeEls = new Map()
function registerNode(key, el) {
if (el) nodeEls.set(key, el)
}
function unregisterNode(key) {
nodeEls.delete(key)
}
const visibleKeys = computed(() => {
const out = []
const walk = (list) => {
for (const n of list) {
out.push(n.key)
if (n.children && expandedKeys.value.includes(n.key)) walk(n.children)
}
}
walk(treeData.value)
return out
})
function onTreeKeydown(e) {
const list = visibleKeys.value
if (!list.length) return
let idx = activeKey.value != null ? list.indexOf(activeKey.value) : 0
if (idx < 0) idx = 0
const curKey = list[idx]
const node = findNodeByKey(curKey)
let handled = true
switch (e.key) {
case 'ArrowDown':
idx = Math.min(list.length - 1, idx + 1)
break
case 'ArrowUp':
idx = Math.max(0, idx - 1)
break
case 'ArrowRight':
if (!expandedKeys.value.includes(curKey) && node?.children?.length) {
toggleExpand(curKey)
} else if (node?.children?.length) {
idx = Math.min(list.length - 1, idx + 1)
} else handled = false
break
case 'ArrowLeft':
if (expandedKeys.value.includes(curKey) && node?.children?.length) {
toggleExpand(curKey)
} else handled = false
break
case 'Enter':
case ' ':
handleSelect(curKey)
break
default:
handled = false
}
if (handled) {
e.preventDefault()
activeKey.value = list[idx]
nodeEls.get(list[idx])?.focus()
}
}
function findNodeByKey(key) {
const walk = (list) => {
for (const n of list) {
if (n.key === key) return n
if (n.children) {
const hit = walk(n.children)
if (hit) return hit
}
}
return null
}
return walk(treeData.value)
}
/* ================= 拖拽:领域语义直接计算 ================= */
// 拖拽源类型:col(列)/ group(分组)/ field(外部字段库)
const dragType = ref(null)
const dragKey = ref(null)
const dropKey = ref(null)
const dropPosition = ref(null) // 'before' | 'inside' | 'after'
// 实时换位去重:上次已提交的落点 `${targetKey}:${pos}`,位置不变时不重复提交
const lastCommittedKey = ref(null)
function isDescendantGroup(ancestorGid, gid) {
const meta = bandMeta.value.metaMap.get(String(gid))
if (!meta) return false
let cur = meta.group.b_parent_id == null ? null : String(meta.group.b_parent_id)
while (cur != null) {
if (cur === String(ancestorGid)) return true
const m = bandMeta.value.metaMap.get(cur)
if (!m) break
cur = m.group.b_parent_id == null ? null : String(m.group.b_parent_id)
}
return false
}
function handleDragStart(node, e) {
if (props.loading || node.disabled) {
e.preventDefault()
return
}
dragType.value = node.kind
dragKey.value = node.key
lastCommittedKey.value = null
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.key)
}
}
// 外部字段库拖拽识别:Chrome dragover 阶段 getData() 为空(隐私保护),
// 只能靠 dataTransfer.types 识别;drop 阶段再读取真实字段 id。
function isExternalFieldDrag(e) {
return (
e?.dataTransfer &&
Array.from(e.dataTransfer.types || []).includes('application/fms-field')
)
}
function handleDragOver(node, e) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = isExternalFieldDrag(e) ? 'copy' : 'move'
}
// 外部字段拖入:临时写入占位拖拽键,让落点指示可见;drop 阶段再读取真实字段 id 提交
if (isExternalFieldDrag(e) && dragType.value !== 'field') {
dragType.value = 'field'
dragKey.value = 'external-field:__pending__'
}
// 无有效拖拽源(如拖入非节点区域)不设落点
if (!dragKey.value) {
dropKey.value = null
dropPosition.value = null
return
}
const pos = calcDropPosition(node, e)
if (!pos || dragKey.value === node.key) {
dropKey.value = null
dropPosition.value = null
return
}
dropKey.value = node.key
dropPosition.value = pos
// 外部字段:仅显示落点,等 drop 提交(dragover 阶段 getData 为空)
if (dragType.value === 'field') return
// 内部拖拽:实时换位——拖过即换,不等待 drop。
// 用 lastCommittedKey 去重,避免同一位持续 dragover 反复提交。
const commitKey = `${node.key}:${pos}`
if (lastCommittedKey.value === commitKey) return
lastCommittedKey.value = commitKey
const srcKey = dragKey.value
if (dragType.value === 'col') {
const resolved = resolveColDrop(srcKey, node.key, pos)
if (resolved) emit('col-drop', resolved)
} else if (dragType.value === 'group') {
const resolved = resolveBandDrop(srcKey, node.key, pos)
if (resolved) emit('band-drop', resolved)
}
}
// 领域化落点:列(叶子)只有 before/after(上半/下半);
// 分组分 before/inside/after(上 1/4 / 中 / 下 1/4);
// 未分组虚拟节点只接受 inside(追加);分组拖入自身子孙禁止。
function calcDropPosition(node, e) {
const rect = e.currentTarget.getBoundingClientRect()
const offset = e.clientY - rect.top
// 未分组容器:仅追加
if (node.key === 'g:ungrouped') return 'inside'
// 列:无 inside 语义
if (node.kind === 'col') {
return offset < rect.height / 2 ? 'before' : 'after'
}
// 分组拖入自身子孙:禁止
if (dragType.value === 'group' && node.kind === 'group') {
if (isDescendantGroup(dragKey.value.slice(2), node.gid)) return null
}
if (dragKey.value === node.key) return null
if (offset < rect.height * 0.25) return 'before'
if (offset > rect.height * 0.75) return 'after'
return 'inside'
}
// 节点行拖离:单行不清理(dragover 会持续覆盖落点);根容器拖出整树时统一清理
function onNodeDragLeave() {
/* no-op */
}
function onRootDragLeave(e) {
const root = e.currentTarget
if (root && !root.contains(e.relatedTarget)) {
dropKey.value = null
dropPosition.value = null
if (dragType.value === 'field') dragKey.value = null
}
}
function handleDrop(node, e) {
e.preventDefault()
const targetKey = node.key
const pos = dropPosition.value
const type = dragType.value
const srcKey = dragKey.value
// 实时换位是否已发生(resetDragVisual 会清空 lastCommittedKey,需先记录)
const alreadyLiveSwapped = lastCommittedKey.value != null
resetDragVisual()
if (!srcKey || !targetKey || srcKey === targetKey) return
// 外部字段库拖入:drop 阶段可读取真实字段 id(dragover 阶段 getData 为空)
if (isExternalFieldDrag(e)) {
const fid = e.dataTransfer.getData('application/fms-field')
if (!fid) return
emit('field-drop', { fieldId: fid, dropKey: targetKey, dropPosition: pos })
return
}
// 内部拖拽已在 dragover 阶段实时换位,drop 仅兜底提交一次(若从未实时换位过)
if (!alreadyLiveSwapped) {
if (type === 'col') {
emit('col-drop', resolveColDrop(srcKey, targetKey, pos))
} else if (type === 'group') {
const resolved = resolveBandDrop(srcKey, targetKey, pos)
if (resolved) emit('band-drop', resolved)
}
}
}
// 拖出树外松手(未触发 drop):清理实时换位状态
function handleDragEnd() {
resetDragVisual()
}
function resolveColDrop(srcKey, targetKey, pos) {
const fieldId = srcKey.slice(2)
// 落点在分组带上
if (targetKey.startsWith('g:')) {
if (targetKey === 'g:ungrouped') return { fieldId, line: { kind: 'ungrouped' } }
const gid = targetKey.slice(2)
if (pos === 'inside') return { fieldId, line: { kind: 'into-band', gid } }
return { fieldId, line: { kind: 'col-group-edge', gid, pos } }
}
// 落点在列上:插到目标列前/后并沿用其分组
const row = props.rows.find((r) => String(r.b_field_id) === targetKey.slice(2))
return {
fieldId,
line: {
kind: 'col-edge',
gid: row ? groupIdOfRow(row) : null,
targetId: targetKey.slice(2),
pos,
},
}
}
function resolveBandDrop(srcKey, targetKey, pos) {
const gid = srcKey.slice(2)
// 落点在列上:按该列所在分组作为目标带
if (targetKey.startsWith('c:')) {
const row = props.rows.find((r) => String(r.b_field_id) === targetKey.slice(2))
const targetGid = row ? groupIdOfRow(row) : null
if (targetGid == null) return null
return { gid, line: { kind: 'band-edge', gid: targetGid, pos } }
}
if (targetKey === 'g:ungrouped') return null // 分组不入未分组
const targetGid = targetKey.slice(2)
if (pos === 'inside') return { gid, line: { kind: 'band-nest', gid: targetGid } }
return { gid, line: { kind: 'band-edge', gid: targetGid, pos } }
}
function resetDragVisual() {
dragType.value = null
dragKey.value = null
dropKey.value = null
dropPosition.value = null
lastCommittedKey.value = null
}
/* ================= 右键菜单 ================= */
const contextState = ref(null) // { items, x, y, node, key }
const contextMenuEl = ref(null)
function buildContextItems(node) {
if (node.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: node.disabled ? '启用' : '停用', icon: Power },
{ key: 'delete', label: '删除分组', icon: Trash2, danger: true },
]
}
return [
{ key: 'copy', label: '复制列属性', icon: Copy },
{ key: 'paste', label: '粘贴列属性', icon: Clipboard, disabled: !props.pasteEnabled },
{ key: 'delete', label: '删除列', icon: Trash2, danger: true },
]
}
function openContextMenu(node, event) {
const items = buildContextItems(node)
if (!items || !items.length) return
contextState.value = { items, x: event.clientX, y: event.clientY, node, key: node.key }
nextTick(() => {
const el = contextMenuEl.value
if (!el || !contextState.value) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let { x, y } = contextState.value
if (x + rect.width > vw - 8) x = Math.max(8, x - rect.width)
if (y + rect.height > vh - 8) y = Math.max(8, y - rect.height)
contextState.value.x = x
contextState.value.y = y
})
}
function hideContextMenu() {
contextState.value = null
}
function onMenuItemClick(item, event) {
if (item.disabled) return
const state = contextState.value
emit('context-menu-click', {
item,
node: state?.node ?? null,
key: state?.key ?? null,
event,
})
hideContextMenu()
}
function onDocMouseDown(e) {
if (!contextState.value) return
const el = contextMenuEl.value
if (el && el.contains(e.target)) return
hideContextMenu()
}
function onDocKeydown(e) {
if (e.key === 'Escape') hideContextMenu()
}
onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown, true)
document.addEventListener('keydown', onDocKeydown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMouseDown, true)
document.removeEventListener('keydown', onDocKeydown, true)
})
/* ================= 内联重命名 ================= */
const editingGid = ref(null)
const groupTitleDraft = ref('')
const editInputRef = ref(null)
// 外部触发(面板新增分组后定位并进入命名)
function startRename(gid) {
if (props.loading || gid == null) return
commitRename() // 先提交上一个编辑中的分组
editingGid.value = String(gid)
groupTitleDraft.value =
bandMeta.value.metaMap.get(String(gid))?.group.b_title || ''
nextTick(() => {
const el = editInputRef.value
if (el) {
el.focus()
el.select()
}
})
}
function commitRename() {
const gid = editingGid.value
if (gid == null) return
const title = groupTitleDraft.value.trim()
editingGid.value = null
groupTitleDraft.value = ''
const band = bandMeta.value.metaMap.get(String(gid))
if (!band) return
// 允许清空标题(留空即不显示分组标题);无变化不提交
if (band.group.b_title === title) return
emit('rename-commit', { gid, title })
}
function cancelRename() {
editingGid.value = null
groupTitleDraft.value = ''
}
defineExpose({ startRename })
/* ================= 上下文下发 ================= */
provide('moduleStructureTree', {
expandedKeys,
selectedKey: computed(() => props.selectedKey),
loading: computed(() => props.loading),
dragKey,
dragType,
dropKey,
dropPosition,
activeKey,
editingGid,
groupTitleDraft,
editInputRef,
handleSelect,
toggleExpand,
registerNode,
unregisterNode,
handleDragStart,
handleDragOver,
onNodeDragLeave,
handleDrop,
handleDragEnd,
openContextMenu,
commitRename,
cancelRename,
})
</script>
<template>
<div
class="mst-tree"
role="tree"
@keydown="onTreeKeydown"
@dragleave="onRootDragLeave"
>
<StructureTreeNode
v-for="n in treeData"
:key="n.key"
:node="n"
:level="0"
/>
<div v-if="!treeData.length" class="mst-tree__empty">
<slot name="empty">暂无分组</slot>
</div>
</div>
<!-- 右键菜单(挂载到 body,fixed 定位) -->
<Teleport to="body">
<Transition name="mst-context-fade">
<div
v-if="contextState"
ref="contextMenuEl"
class="mst-context-menu"
:style="{ left: contextState.x + 'px', top: contextState.y + 'px' }"
role="menu"
@mousedown.stop
>
<template v-for="(item, index) in contextState.items" :key="item.key || index">
<div v-if="item.divider" class="mst-context-menu__divider" role="separator"></div>
<button
v-else
type="button"
class="mst-context-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:disabled="item.disabled"
@click="(event) => onMenuItemClick(item, event)"
>
<component :is="item.icon" v-if="item.icon" :size="14" class="mst-context-menu__icon" />
<span>{{ item.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.mst-tree {
color: var(--fms-text);
font-size: 12px;
line-height: 1.5;
font-weight: 500;
outline: none;
}
.mst-tree__empty {
padding: 12px 8px;
color: var(--fms-text-secondary);
font-size: 11px;
text-align: center;
}
/* ---------- 右键菜单 ---------- */
.mst-context-menu {
position: fixed;
z-index: 3000;
min-width: 140px;
padding: 4px;
background: var(--fms-card);
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.mst-context-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 10px;
border: none;
background: transparent;
color: var(--fms-text);
font-size: 13px;
line-height: 1.5;
text-align: left;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--fms-secondary-hover);
}
&.is-danger {
color: #f53f3f;
}
&.is-disabled {
color: var(--fms-disabled-text);
cursor: not-allowed;
}
}
.mst-context-menu__icon {
flex: none;
}
.mst-context-menu__divider {
height: 1px;
margin: 4px 8px;
background: var(--fms-border);
}
.mst-context-fade-enter-active {
transition: opacity 0.12s ease;
}
.mst-context-fade-leave-active {
transition: opacity 0.08s ease;
}
.mst-context-fade-enter-from,
.mst-context-fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,464 @@
<script setup>
/**
* 专用结构树的递归节点行(分组带 / 列 / 未分组容器)。
* 只负责单行渲染与事件透传,全部状态与交互逻辑由父级 ModuleStructureTree 统一持有。
*/
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { Check, ChevronRight, Folder, GripVertical, Inbox, Table2, X } from '@lucide/vue'
const props = defineProps({
node: { type: Object, required: true },
level: { type: Number, default: 0 },
})
const ctx = inject('moduleStructureTree')
const key = computed(() => props.node.key)
const isGroup = computed(() => props.node.kind === 'group')
// 未分组容器:虚拟节点,不可拖拽 / 选中 / 右键,仅作收纳落点
const isUngrouped = computed(() => isGroup.value && props.node.gid == null)
const isExpanded = computed(() => ctx.expandedKeys.value.includes(key.value))
const isSelected = computed(() => ctx.selectedKey.value === key.value)
const isEditing = computed(() => isGroup.value && props.node.gid != null && ctx.editingGid.value === props.node.gid)
const isDragging = computed(() => ctx.dragKey.value === key.value)
const isDropTarget = computed(() => ctx.dragKey.value != null && ctx.dropKey.value === key.value)
// 指示线仅外部字段拖入时显示:内部拖拽已实时换位(拖过即换),不需要落点线
const dropClass = computed(() => {
if (!isDropTarget.value || ctx.dragType.value !== 'field') return ''
const p = ctx.dropPosition.value
return p === 'before' ? 'drop-before' : p === 'after' ? 'drop-after' : 'drop-inside'
})
const canDrag = computed(() => !isUngrouped.value && !ctx.loading.value && !isEditing.value)
const tabindex = computed(() => (ctx.activeKey.value === key.value ? 0 : -1))
const nodeIcon = computed(() => {
if (isGroup.value) return isUngrouped.value ? Inbox : Folder
return Table2
})
// 内联重命名草稿:注入的 ref 不会在模板自动解包,用 computed 双向包装
const draft = computed({
get: () => ctx.groupTitleDraft.value,
set: (v) => {
ctx.groupTitleDraft.value = v
},
})
const rowRef = ref(null)
onMounted(() => ctx.registerNode(key.value, rowRef.value))
onBeforeUnmount(() => ctx.unregisterNode(key.value))
function onRowClick() {
if (isUngrouped.value || ctx.loading.value || isEditing.value) return
ctx.handleSelect(key.value)
ctx.activeKey.value = key.value
rowRef.value?.focus()
}
function onSwitcherClick() {
if (isUngrouped.value) return
ctx.toggleExpand(key.value)
ctx.activeKey.value = key.value
rowRef.value?.focus()
}
function onDragStart(e) {
if (!canDrag.value) {
e.preventDefault()
return
}
ctx.handleDragStart(props.node, e)
}
function onDragOver(e) {
ctx.handleDragOver(props.node, e)
}
function onDragLeave(e) {
ctx.onNodeDragLeave(props.node, e)
}
function onDrop(e) {
ctx.handleDrop(props.node, e)
}
function onDragEnd() {
ctx.handleDragEnd()
}
function onContextMenu(e) {
if (isUngrouped.value || ctx.loading.value) return
ctx.openContextMenu(props.node, e)
}
function setEditInput(el) {
ctx.editInputRef.value = el
}
function onRenameCommit() {
ctx.commitRename(props.node.gid)
}
function onRenameCancel() {
ctx.cancelRename()
}
</script>
<template>
<div>
<div
ref="rowRef"
class="mst-node"
:class="{
'is-group': isGroup && !isUngrouped,
'is-ungrouped': isUngrouped,
'is-col': node.kind === 'col',
'is-expanded': isExpanded,
'is-selected': isSelected,
'is-disabled': node.kind === 'group' && node.group && Number(node.group.b_canuse) !== 1,
'is-dragging': isDragging,
[dropClass]: !!dropClass,
}"
:style="{ '--level': level }"
role="treeitem"
:aria-selected="isSelected"
:aria-expanded="isGroup && !isUngrouped ? isExpanded : undefined"
:aria-disabled="isUngrouped ? true : undefined"
:tabindex="tabindex"
:draggable="canDrag ? 'true' : undefined"
@click="onRowClick"
@dragstart="onDragStart"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@dragend="onDragEnd"
@contextmenu.stop="onContextMenu"
>
<span v-if="canDrag" class="mst-node__grip" aria-hidden="true"><GripVertical /></span>
<span v-else class="mst-node__grip is-placeholder" aria-hidden="true"></span>
<span
v-if="isGroup && !isUngrouped"
class="mst-node__switcher"
role="button"
tabindex="-1"
:aria-label="isExpanded ? '收起' : '展开'"
@click.stop="onSwitcherClick"
>
<ChevronRight class="mst-node__switcher-icon" :class="{ 'is-open': isExpanded }" />
</span>
<span v-else class="mst-node__switcher is-placeholder" aria-hidden="true"></span>
<component :is="nodeIcon" class="mst-node__icon" aria-hidden="true" />
<div class="mst-node__content">
<!-- 分组带内联重命名 -->
<template v-if="isEditing">
<input
:ref="setEditInput"
v-model="draft"
class="mst-node__edit-input"
maxlength="100"
placeholder="分组名称"
draggable="false"
@click.stop
@keydown.stop
@keydown.enter.prevent="onRenameCommit"
@keydown.esc.prevent="onRenameCancel"
@blur="onRenameCommit"
/>
<span class="mst-node__edit-actions">
<button
type="button"
class="mst-node__edit-btn"
title="确认"
aria-label="确认修改分组名"
@mousedown.prevent="onRenameCommit"
>
<Check :size="13" />
</button>
<button
type="button"
class="mst-node__edit-btn"
title="取消"
aria-label="取消修改分组名"
@mousedown.prevent="onRenameCancel"
>
<X :size="13" />
</button>
</span>
</template>
<!-- 普通标题 -->
<template v-else>
<span class="mst-node__label">{{ node.title }}</span>
<span v-if="isGroup" class="mst-node__meta">{{ node.span }} 列</span>
<span v-else class="mst-node__meta">{{ node.controlLabel }}</span>
<i v-if="node.kind === 'col' && node.required" class="mst-node__required">*</i>
</template>
</div>
</div>
<!-- 展开/收起子级:未分组容器始终展开其下列 -->
<Transition name="mst-collapse">
<div
v-if="isGroup && isExpanded && node.children && node.children.length"
class="mst-node__children"
>
<div class="mst-node__children-inner">
<StructureTreeNode
v-for="c in node.children"
:key="c.key"
:node="c"
:level="level + 1"
/>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.mst-node {
position: relative;
display: flex;
align-items: center;
min-height: 28px;
padding-left: calc(4px + 18px * var(--level));
border-radius: 5px;
cursor: default;
user-select: none;
-webkit-user-select: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover:not(.is-disabled) {
cursor: pointer;
background: var(--fms-secondary-hover);
}
&.is-selected {
background: color-mix(in srgb, var(--fms-primary) 14%, var(--fms-card));
color: var(--fms-primary);
}
&.is-disabled {
color: var(--fms-text-disabled, #9ca3af);
}
/* 未分组容器:整体作为收纳区,轻微底色 + 虚线内边 */
&.is-ungrouped {
background: color-mix(in srgb, var(--fms-secondary) 60%, var(--fms-card));
border: 1px dashed var(--fms-border);
margin: 2px 0;
padding-right: 4px;
.mst-node__label {
font-weight: 600;
}
}
/* 拖拽源半透明 */
&.is-dragging {
opacity: 0.4;
}
}
.mst-node__grip {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
height: 16px;
margin-right: 2px;
color: #b0b7c3;
cursor: grab;
&.is-placeholder {
cursor: default;
visibility: hidden;
}
&:active {
cursor: grabbing;
}
}
.mst-node__switcher {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
height: 20px;
margin-right: 2px;
color: inherit;
border-radius: 4px;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover:not(.is-placeholder) {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
}
&.is-placeholder {
cursor: default;
visibility: hidden;
}
}
.mst-node__switcher-icon {
width: 14px;
height: 14px;
color: var(--fms-text-secondary);
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.4, 1);
&.is-open {
transform: rotate(90deg);
}
}
.mst-node__icon {
flex: none;
width: 14px;
height: 14px;
margin-right: 6px;
color: inherit;
}
.mst-node__content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
white-space: nowrap;
}
.mst-node__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
}
.mst-node__meta {
margin-left: auto;
flex: none;
color: var(--fms-text-secondary);
font-size: 10px;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.mst-node__required {
flex: none;
color: #d95757;
font-style: normal;
}
.mst-node__edit-input {
flex: 1 1 auto;
min-width: 80px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--fms-primary);
border-radius: 4px;
background: var(--fms-card);
color: var(--fms-text);
font-size: 12px;
font-family: inherit;
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--fms-primary) 12%, transparent);
}
.mst-node__edit-actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.mst-node__edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
border-color: var(--fms-primary);
color: var(--fms-primary);
}
&:last-child:hover {
border-color: #d95757;
color: #d95757;
}
}
/* ---------- 拖拽落点指示:线(before/after)+ 整行高亮(inside) ---------- */
.mst-node.drop-before::before,
.mst-node.drop-after::before {
content: '';
position: absolute;
left: calc(4px + 18px * var(--level));
right: 4px;
height: 2px;
background: var(--fms-primary);
border-radius: 2px;
z-index: 2;
}
.mst-node.drop-before::before {
top: -1px;
}
.mst-node.drop-after::before {
bottom: -1px;
}
.mst-node.drop-inside {
background: color-mix(in srgb, var(--fms-primary) 16%, var(--fms-card));
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
outline-offset: -2px;
}
/* ---------- 展开/收起动画 ---------- */
.mst-node__children {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 1fr;
overflow: hidden;
}
.mst-collapse-enter-active,
.mst-collapse-leave-active {
transition:
grid-template-rows 0.22s cubic-bezier(0.2, 0.8, 0.4, 1),
opacity 0.18s ease;
}
.mst-collapse-enter-from,
.mst-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
.mst-node__children-inner {
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.mst-node,
.mst-node__switcher-icon,
.mst-node__grip {
transition: none;
}
.mst-collapse-enter-active,
.mst-collapse-leave-active {
transition: none;
}
}
</style>
@@ -0,0 +1,48 @@
<script setup>
import { ref } from 'vue'
import ModuleTableEditPanel from '@/views/system/module-management/components/ModuleTableEditPanel.vue'
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 = ref([
{ 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 = ref([
{ 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: 1, 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 },
])
const listConfig = ref([])
const queryConfig = ref([])
function onUpdate(v) {
editConfig.value = v
}
function onUpdateGroups(v) {
groups.value = v
}
</script>
<template>
<div style="height: 100vh; display: flex; padding: 12px; box-sizing: border-box">
<ModuleTableEditPanel
module-id="m1"
:edit-config="editConfig"
:list-config="listConfig"
:query-config="queryConfig"
:fields="fields"
:groups="groups"
edit-mode="table"
:active="true"
:loading="false"
@update="onUpdate"
@update-groups="onUpdateGroups"
/>
</div>
</template>
@@ -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)
})
})
@@ -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' })
})
})