20260827230636
This commit is contained in:
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: .
|
||||
@@ -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>
|
||||
@@ -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) ---------- */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+229
-315
@@ -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;
|
||||
|
||||
+798
@@ -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>
|
||||
+464
@@ -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' })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user