Commit a288b353 authored by drigle's avatar drigle

feat: add regulation record graph

parent 5519d074
......@@ -45,7 +45,8 @@ export default defineConfig({
{ path: '/import', component: './LegacyFeaturePage' },
{ path: '/query', component: './LegacyFeaturePage' },
{ path: '/settings', component: './LegacyFeaturePage' },
{ path: '/graph', component: './LegacyFeaturePage' },
{ path: '/graph', component: './RecordGraphPage' },
{ path: '/graph/:subject', component: './RecordGraphPage' },
{ path: '/files', component: './LegacyFeaturePage' },
{ path: '/spider', component: './LegacyFeaturePage' },
{ path: '/spider/:spiderName', component: './LegacyFeaturePage' },
......
......@@ -27,20 +27,24 @@
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lodash": "^4.18.1",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^13.1.0",
"parse-mind": "^1.0.2",
"react": "^19.2.8",
"react-aria": "^3.51.0",
"react-aria-components": "1.19.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"reactflow": "^11.11.4",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
......@@ -51,7 +55,8 @@
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/react": "^19.2.18",
......@@ -4312,6 +4317,108 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
......@@ -4338,16 +4445,6 @@
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@shikijs/core": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
......@@ -6712,6 +6809,24 @@
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
"license": "MIT"
},
"node_modules/@umijs/bundler-utoopack/node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/@umijs/bundler-utoopack/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
......@@ -8326,6 +8441,16 @@
"value-equal": "^1.0.1"
}
},
"node_modules/@umijs/plugins/node_modules/immer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz",
"integrity": "sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@umijs/plugins/node_modules/react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
......@@ -10682,6 +10807,12 @@
"node": ">= 0.10"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
......@@ -11882,6 +12013,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
......@@ -14676,6 +14817,15 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
......@@ -15582,10 +15732,16 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz",
"integrity": "sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==",
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
......@@ -16831,6 +16987,54 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
......@@ -16990,6 +17194,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
......@@ -18917,9 +19130,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
......@@ -18928,10 +19141,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
......@@ -19702,6 +19915,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-mind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-mind/-/parse-mind-1.0.2.tgz",
"integrity": "sha512-7h+NjeBJwMEcZktG/2MBH41N3lRXtyEeeKufQooYZzc5T6k8IENk9tH/gASSy2ZRvoDA+YhZPNCZ/s4PlmqaMQ==",
"license": "MIT",
"dependencies": {
"jszip": "^3.10.1"
}
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
......@@ -20766,6 +20988,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
......@@ -22364,6 +22604,24 @@
"lodash": "^4.0.1"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
......@@ -22579,16 +22837,6 @@
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/recharts/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
......@@ -26746,6 +26994,43 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/zustand/node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
......
......@@ -29,20 +29,24 @@
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lodash": "^4.18.1",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^13.1.0",
"parse-mind": "^1.0.2",
"react": "^19.2.8",
"react-aria": "^3.51.0",
"react-aria-components": "1.19.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"reactflow": "^11.11.4",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
......@@ -53,7 +57,8 @@
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/react": "^19.2.18",
......
......@@ -71,6 +71,9 @@ importers:
crypto-js:
specifier: ^4.2.0
version: 4.2.0
dagre:
specifier: ^0.8.5
version: 0.8.5
dayjs:
specifier: ^1.11.20
version: 1.11.23
......@@ -86,6 +89,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.8)
lodash:
specifier: ^4.18.1
version: 4.18.1
maplibre-gl:
specifier: ^6.4.1
version: 6.6.0
......@@ -95,6 +101,9 @@ importers:
motion:
specifier: ^13.1.0
version: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
parse-mind:
specifier: ^1.0.2
version: 1.0.2
react:
specifier: ^19.2.8
version: 19.2.8
......@@ -113,6 +122,9 @@ importers:
react-resizable-panels:
specifier: ^4.12.3
version: 4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
recharts:
specifier: ^3.10.1
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
......@@ -146,6 +158,9 @@ importers:
xlsx:
specifier: ^0.18.5
version: 0.18.5
zustand:
specifier: ^4.5.7
version: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
devDependencies:
'@types/react':
specifier: ^19.2.18
......@@ -1650,6 +1665,42 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@reactflow/background@11.3.14':
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/controls@11.2.14':
resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/core@11.11.4':
resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/minimap@11.7.14':
resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-resizer@2.2.14':
resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reactflow/node-toolbar@1.3.14':
resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
......@@ -2251,30 +2302,96 @@ packages:
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-axis@3.0.6':
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
'@types/d3-brush@3.0.6':
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
'@types/d3-chord@3.0.6':
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-contour@3.0.6':
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
'@types/d3-delaunay@6.0.4':
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
'@types/d3-dispatch@3.0.7':
resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-fetch@3.0.7':
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-format@3.0.4':
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
'@types/d3-geo@3.1.1':
resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
'@types/d3-hierarchy@3.1.7':
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-polygon@3.0.2':
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
'@types/d3-quadtree@3.0.6':
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
'@types/d3-random@3.0.4':
resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
'@types/d3-scale-chromatic@3.1.0':
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.12':
resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==}
'@types/d3-shape@3.2.0':
resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
'@types/d3-time-format@4.0.3':
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
......@@ -3173,6 +3290,9 @@ packages:
resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==}
engines: {node: '>= 0.10'}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
......@@ -3427,6 +3547,14 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
......@@ -3447,6 +3575,10 @@ packages:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
......@@ -3463,10 +3595,23 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
dagre@0.8.5:
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
......@@ -4391,6 +4536,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
handle-thing@2.0.1:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
......@@ -4589,6 +4737,9 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@11.1.18:
resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==}
......@@ -5009,6 +5160,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.2:
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
......@@ -5070,6 +5224,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
......@@ -5878,6 +6035,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-mind@1.0.2:
resolution: {integrity: sha512-7h+NjeBJwMEcZktG/2MBH41N3lRXtyEeeKufQooYZzc5T6k8IENk9tH/gASSy2ZRvoDA+YhZPNCZ/s4PlmqaMQ==}
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
......@@ -6846,6 +7006,12 @@ packages:
peerDependencies:
react: '*'
reactflow@11.11.4:
resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
read-pkg-up@7.0.1:
resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
engines: {node: '>=8'}
......@@ -8106,6 +8272,21 @@ packages:
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
......@@ -9664,6 +9845,84 @@ snapshots:
dependencies:
react: 19.2.8
'@reactflow/background@11.3.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
classcat: 5.0.5
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/controls@11.2.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
classcat: 5.0.5
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/core@11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@types/d3': 7.4.3
'@types/d3-drag': 3.0.7
'@types/d3-selection': 3.0.12
'@types/d3-zoom': 3.0.8
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/minimap@11.7.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@types/d3-selection': 3.0.12
'@types/d3-zoom': 3.0.8
classcat: 5.0.5
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-resizer@2.2.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
classcat: 5.0.5
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reactflow/node-toolbar@1.3.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
classcat: 5.0.5
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
dependencies:
'@standard-schema/spec': 1.1.0
......@@ -10217,28 +10476,121 @@ snapshots:
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-brush@3.0.6':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-chord@3.0.6': {}
'@types/d3-color@3.1.3': {}
'@types/d3-contour@3.0.6':
dependencies:
'@types/d3-array': 3.2.2
'@types/geojson': 7946.0.16
'@types/d3-delaunay@6.0.4': {}
'@types/d3-dispatch@3.0.7': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-dsv@3.0.7': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-fetch@3.0.7':
dependencies:
'@types/d3-dsv': 3.0.7
'@types/d3-force@3.0.10': {}
'@types/d3-format@3.0.4': {}
'@types/d3-geo@3.1.1':
dependencies:
'@types/geojson': 7946.0.16
'@types/d3-hierarchy@3.1.7': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-polygon@3.0.2': {}
'@types/d3-quadtree@3.0.6': {}
'@types/d3-random@3.0.4': {}
'@types/d3-scale-chromatic@3.1.0': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.12': {}
'@types/d3-shape@3.2.0':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time-format@4.0.3': {}
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.12
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.12
'@types/d3@7.4.3':
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-axis': 3.0.6
'@types/d3-brush': 3.0.6
'@types/d3-chord': 3.0.6
'@types/d3-color': 3.1.3
'@types/d3-contour': 3.0.6
'@types/d3-delaunay': 6.0.4
'@types/d3-dispatch': 3.0.7
'@types/d3-drag': 3.0.7
'@types/d3-dsv': 3.0.7
'@types/d3-ease': 3.0.2
'@types/d3-fetch': 3.0.7
'@types/d3-force': 3.0.10
'@types/d3-format': 3.0.4
'@types/d3-geo': 3.1.1
'@types/d3-hierarchy': 3.1.7
'@types/d3-interpolate': 3.0.4
'@types/d3-path': 3.1.1
'@types/d3-polygon': 3.0.2
'@types/d3-quadtree': 3.0.6
'@types/d3-random': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-scale-chromatic': 3.1.0
'@types/d3-selection': 3.0.12
'@types/d3-shape': 3.2.0
'@types/d3-time': 3.0.4
'@types/d3-time-format': 4.0.3
'@types/d3-timer': 3.0.2
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
......@@ -10800,7 +11152,7 @@ snapshots:
dayjs: 1.11.23
dva-core: 2.0.4(redux@4.2.1)
dva-immer: 1.0.2(dva@2.5.0-beta.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
dva-loading: 3.0.25(dva-core@2.0.4(redux@5.0.1))
dva-loading: 3.0.25(dva-core@2.0.4(redux@4.2.1))
event-emitter: 0.3.5
fast-deep-equal: 3.1.3
intl: 1.2.5
......@@ -11705,6 +12057,8 @@ snapshots:
safe-buffer: 5.2.1
to-buffer: 1.2.2
classcat@5.0.5: {}
classnames@2.5.1: {}
clean-css@5.3.3:
......@@ -11963,6 +12317,13 @@ snapshots:
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
......@@ -11981,6 +12342,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
......@@ -11995,11 +12358,33 @@ snapshots:
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
dagre@0.8.5:
dependencies:
graphlib: 2.1.8
lodash: 4.18.1
data-uri-to-buffer@4.0.1: {}
data-view-buffer@1.0.2:
......@@ -12259,7 +12644,7 @@ snapshots:
dva: 2.5.0-beta.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
immer: 8.0.4
dva-loading@3.0.25(dva-core@2.0.4(redux@5.0.1)):
dva-loading@3.0.25(dva-core@2.0.4(redux@4.2.1)):
dependencies:
'@babel/runtime': 7.29.7
dva-core: 2.0.4(redux@4.2.1)
......@@ -13157,6 +13542,10 @@ snapshots:
graphemer@1.4.0: {}
graphlib@2.1.8:
dependencies:
lodash: 4.18.1
handle-thing@2.0.1: {}
hard-rejection@2.1.0: {}
......@@ -13437,6 +13826,8 @@ snapshots:
image-size@0.5.5:
optional: true
immediate@3.0.6: {}
immer@11.1.18: {}
immer@8.0.4: {}
......@@ -13868,6 +14259,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jszip@3.10.2:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
jwt-decode@4.0.0: {}
kdbush@4.1.0: {}
......@@ -13934,6 +14332,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.32.0:
optional: true
......@@ -14948,6 +15350,10 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-mind@1.0.2:
dependencies:
jszip: 3.10.2
parse-node-version@1.0.1: {}
parse5@7.3.0:
......@@ -16128,6 +16534,20 @@ snapshots:
lodash: 4.18.1
react: 19.2.8
reactflow@11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@reactflow/background': 11.3.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@reactflow/controls': 11.2.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@reactflow/core': 11.11.4(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@reactflow/minimap': 11.7.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@reactflow/node-resizer': 2.2.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@reactflow/node-toolbar': 1.3.14(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- '@types/react'
- immer
read-pkg-up@7.0.1:
dependencies:
find-up: 4.1.0
......@@ -17647,4 +18067,12 @@ snapshots:
dependencies:
tslib: 2.3.0
zustand@4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
immer: 11.1.18
react: 19.2.8
zwitch@2.0.4: {}
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const esbuild = require('esbuild');
const root = require('node:path').resolve(__dirname, '..');
function load(file, mocks = {}) {
const output = esbuild.buildSync({ entryPoints: [root + '/' + file], bundle: true, write: false, platform: 'node', format: 'cjs', packages: 'external', external: ['./DataService'] }).outputFiles[0].text;
const module = { exports: {} };
vm.runInNewContext(output, { module, exports: module.exports, require: name => mocks[name] || require(name), console });
return module.exports;
}
(async () => {
const { createGraphData } = load('src/components/RecordFlowGraph/graphData.js');
const records = [
{ _id: 'r1', name: 'first', title: '一级制度', order: 2, metadata: { level: '1级', related_regulations: [{ _id: 'r2' }] } },
{ _id: 'r2', name: 'second', title: '二级制度', order: 1, metadata: { level: '2级', related_regulations: [] } },
{ _id: 'r3', name: 'third', title: '先排制度', order: 1, metadata: { level: '1级', related_regulations: ['r2'] } },
];
const graph = createGraphData(records, ['1级', '2级'], false);
assert.equal(graph.nodes.length, 5);
assert.equal(graph.edges.length, 2);
assert.equal(graph.nodes.find(n => n.id === 'r3').position.y, 60);
assert.equal(graph.nodes.find(n => n.id === 'r1').position.y, 170);
assert.equal(graph.nodes.find(n => n.id === 'r2').position.x, 480);
assert.equal(graph.nodes.find(n => n.id === 'r1').metadata.related_regulations[0], 'r2');
const centered = createGraphData(records, ['1级', '2级'], true);
assert.equal(centered.nodes.find(n => n.id === 'r2').position.y, 115);
assert.equal(createGraphData(records.filter(r => r._id !== 'r2'), ['1级', '2级'], false).edges.length, 0);
const writes = [];
const service = load('src/services/RecordGraphService.js', { './DataService': {
updateRecord: async (...args) => writes.push(args), reorderRecords: async data => writes.push(data),
} });
await service.saveRecordRelations({ name: 'internal_regulation' }, graph.nodes.find(n => n.id === 'r1'), ['r2', 'r3']);
assert.equal(writes[0][0], 'r1');
assert.equal(writes[0][1].subject, 'internal_regulation');
assert.equal(writes[0][1].title, '一级制度');
assert.equal(writes[0][1].metadata.related_regulations.join(','), 'r2,r3');
await service.saveRecordGraphOrder({ first: 2 });
assert.equal(writes[1].first, 2);
const entry = fs.readFileSync(root + '/src/pages/LawSubjectPage.jsx', 'utf8');
assert(entry.includes('/graph/${encodeURIComponent(subject.name)}?node='));
console.log('PASS: card entry, schema levels/order, relation IDs, filtered edges, centered layout, relation and reorder payloads');
})().catch(error => { console.error(error); process.exitCode = 1; });
import { BezierEdge } from 'reactflow';
export default function GraphEdge(props) {
return <>
<BezierEdge {...props} style={{ stroke: props.selected ? 'var(--accent-soft)' : 'none', strokeWidth: 8 }} />
<BezierEdge {...props} style={{ ...props.style, stroke: props.selected || props.data?.active ? 'var(--accent)' : 'var(--border)', strokeWidth: 2 }} />
</>;
}
import { memo } from 'react';
import { Handle, Position } from 'reactflow';
export default memo(function GraphNode({ data, selected }) {
return <div className={`record-graph-node ${selected ? 'is-selected' : ''} ${data.active ? 'is-active' : ''}`}>
<Handle type="target" position={Position.Left} />
<div>{data.label}</div>
<Handle type="source" position={Position.Right} />
</div>;
});
import { useContext, useEffect, useRef, useState } from 'react';
import ReactFlow, { useNodesState, useEdgesState, addEdge, updateEdge } from 'reactflow';
import 'reactflow/dist/style.css';
import { UserContext } from '@/wrapper/Auth';
import { APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import { saveRecordRelations, saveRecordGraphOrder } from '@/services/RecordGraphService';
import { createGraphData, GRAPH_CONFIG } from './graphData';
import GraphNode from './GraphNode';
import TitleNode from './TitleNode';
import GraphEdge from './GraphEdge';
import './recordGraph.css';
const NODE_TYPES = { title: TitleNode, graph: GraphNode };
const EDGE_TYPES = { graph: GraphEdge };
export default function RecordFlowGraph({ list, levelArr, subject, centered, filterConfig, onError, onSuccess, onReload }) {
const user = useContext(UserContext);
const canEdit = user?.type === 'admin' || user?.type === 'system';
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const containerRef = useRef(null);
const [width, setWidth] = useState(0);
const edgeUpdateSuccessful = useRef(true);
const writing = useRef(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
const data = createGraphData(list, levelArr, centered, filterConfig);
setNodes(data.nodes); setEdges(data.edges);
}, [list, levelArr, centered]);
const write = async action => {
if (!canEdit || writing.current) return;
writing.current = true; setBusy(true); onError('');
try { await action(); onSuccess('保存成功'); }
catch (error) { onError(error.message || '保存失败,请刷新后重试'); onReload(); }
finally { writing.current = false; setBusy(false); }
};
const applyRelations = (id, related) => setNodes(current => current.map(node => node.id === id ? {
...node, metadata: { ...node.metadata, related_regulations: related },
} : node));
const onConnect = connection => {
const source = nodes.find(node => node.id === connection.source);
if (!source || source.metadata.related_regulations.includes(connection.target)) return;
write(async () => {
const related = [...source.metadata.related_regulations, connection.target];
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => addEdge({ ...connection, type: 'graph', selected: false, data: { active: false } }, current));
});
};
const onEdgeUpdate = (oldEdge, connection) => {
edgeUpdateSuccessful.current = true;
if (edges.some(edge => edge.source === connection.source && edge.target === connection.target)) return;
const oldSource = nodes.find(node => node.id === oldEdge.source);
const source = nodes.find(node => node.id === connection.source);
if (!oldSource || !source) return;
write(async () => {
const previous = oldSource.metadata.related_regulations.filter(id => id !== oldEdge.target);
if (oldSource.id !== source.id) {
await saveRecordRelations(subject, oldSource, previous);
applyRelations(oldSource.id, previous);
}
const related = [...(source.id === oldSource.id ? previous : source.metadata.related_regulations), connection.target];
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => updateEdge(oldEdge, connection, current));
});
};
const onEdgeUpdateEnd = (_, edge) => {
if (!edgeUpdateSuccessful.current) {
const source = nodes.find(node => node.id === edge.source);
if (source) write(async () => {
const related = source.metadata.related_regulations.filter(id => id !== edge.target);
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => current.filter(item => item.id !== edge.id));
});
}
edgeUpdateSuccessful.current = true;
};
const onNodeClick = (_, node) => {
setEdges(current => current.map(edge => ({ ...edge, selected: false, data: { ...edge.data, active: edge.source === node.id || edge.target === node.id } })));
setNodes(current => current.map(item => ({ ...item, selected: item.id === node.id, data: { ...item.data,
active: Boolean(node.metadata?.related_regulations?.includes(item.id) || item.metadata?.related_regulations?.includes(node.id)),
} })));
};
const onEdgeClick = (_, edge) => {
setEdges(current => current.map(item => ({ ...item, selected: item.id === edge.id, data: { ...item.data, active: item.id === edge.id } })));
setNodes(current => current.map(node => ({ ...node, selected: false, data: { ...node.data, active: node.id === edge.source || node.id === edge.target } })));
};
const onPaneClick = () => {
setNodes(current => current.map(node => ({ ...node, selected: false, data: { ...node.data, active: false } })));
setEdges(current => current.map(edge => ({ ...edge, selected: false, data: { ...edge.data, active: false } })));
};
const onNodeDragStop = (_, node) => {
if (!canEdit || node.type !== 'graph') return;
const sorted = nodes.filter(item => item.type === 'graph' && item.level === node.level).sort((a, b) => a.position.y - b.position.y);
const oldIndex = node.index;
const newIndex = sorted.findIndex(item => item.id === node.id);
if (oldIndex === newIndex) {
setNodes(current => current.map(item => item.id === node.id ? { ...item, position: {
x: node.level * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: (createGraphData(list, levelArr, centered).nodes.find(n => n.id === node.id)?.position.y || 0),
} } : item));
return;
}
write(async () => {
const related = sorted.slice(0, Math.max(oldIndex, newIndex) + 1);
const max = Math.max(...related.map(item => item.order || 0));
const orderMap = Object.fromEntries([...related].reverse().map((item, index) => [item.name, max - index]));
await saveRecordGraphOrder(orderMap);
const maxLength = Math.max(...levelArr.map(level => list.filter(record => record.metadata?.level === level).length));
const startY = GRAPH_CONFIG.paddingTop + (centered ? (maxLength - sorted.length) * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) / 2 : 0);
setNodes(current => current.map(item => {
const index = sorted.findIndex(candidate => candidate.id === item.id);
return index < 0 ? item : { ...item, index, order: orderMap[item.name] ?? item.order,
position: { x: item.level * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX), y: startY + index * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) } };
}));
});
};
return <div className="h-full w-full" ref={containerRef} aria-busy={busy}>
{width > 0 && <ReactFlow nodes={nodes} edges={edges} nodeTypes={NODE_TYPES} edgeTypes={EDGE_TYPES}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onPaneClick={onPaneClick}
onNodeDoubleClick={(_, node) => { if (node.type === 'graph') window.open(`/${APP_ROUTE_NAME}/${subject.name}/${encodeURIComponent(node.name || node.id)}`, '_blank', 'noopener,noreferrer'); }}
onNodeDragStop={onNodeDragStop}
{...(canEdit ? { onEdgeUpdate, onEdgeUpdateStart: () => { edgeUpdateSuccessful.current = false; }, onEdgeUpdateEnd } : {})}
defaultViewport={{ x: width / 16, y: 0, zoom: width / (GRAPH_CONFIG.nodeWidth * 8) }}
panOnScroll={false} zoomOnPinch zoomOnDoubleClick={false} panOnDrag
nodesDraggable={canEdit && !busy} nodesConnectable={canEdit && !busy} edgesUpdatable={canEdit && !busy}
nodesFocusable={false} deleteKeyCode={null} maxZoom={5} minZoom={0.1}
connectionLineStyle={{ animation: 'dashdraw 0.5s linear infinite', stroke: 'var(--accent)', strokeDasharray: 5, strokeWidth: 2 }}
/>}
</div>;
}
import { memo } from 'react';
export default memo(function TitleNode({ data }) {
return <div className="record-graph-title"><strong>{data.label}</strong></div>;
});
import _ from 'lodash';
export const referenceId = value => typeof value === 'string' ? value : value?._id;
export const GRAPH_CONFIG = { paddingTop: 60, nodeWidth: 240, nodeHeight: 50, paddingX: 240, paddingY: 60 };
const nodeConfig = { selectable: false, sourcePosition: 'right', targetPosition: 'left' };
const edgeConfig = { style: { strokeWidth: 2 } };
export function createGraphData(list, levelArr, centered, filterConfig = {}) {
let nodeArr = []
let edgeArr = []
let levelMap = _.groupBy(list, (v) => v?.metadata?.level)
levelArr.map((v) => {
levelMap[v] = _.sortBy(levelMap[v], (v) => v?.order)
})
let max = _.maxBy(levelArr.map((v) => levelMap[v]), (v) => v?.length)
let maxLength = max?.length
// 计算节点坐标
levelArr.map((v, xIndex) => {
let startY = GRAPH_CONFIG.paddingTop
let current = levelMap?.[v]
let currentLength = current?.length
nodeArr.push({
type: 'title',
id: v,
selectable: false,
draggable: false,
connectable: false,
data: {
label: v
},
position: {
x: xIndex * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: 0
},
style: {
width: GRAPH_CONFIG.nodeWidth,
height: GRAPH_CONFIG.paddingTop
}
})
levelMap?.[v]?.map((item, yIndex) => {
// let already = nodes.find((n) => n?.id == item?._id)
let filterNode = list.find((l) => l?._id == filterConfig?._id)
let chosen = item?._id == filterNode?._id
let related = item?.metadata?.related_regulations?.map((v) => v?._id).includes(filterNode?._id)
|| filterNode?.metadata?.related_regulations?.map((v) => v?._id).includes(item?._id)
// 插入点
if (centered) {
let maxHeight = maxLength * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) - GRAPH_CONFIG.paddingY
let currentHeight = currentLength * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) - GRAPH_CONFIG.paddingY
startY = GRAPH_CONFIG.paddingTop + (maxHeight - currentHeight) / 2
}
nodeArr.push({
type: 'graph',
id: item?._id,
name: item?.name,
title: item?.title,
data: {
active: false,
label: item?.title
},
level: xIndex,
index: yIndex,
order: item?.order,
metadata: {
...item?.metadata,
related_regulations: (item?.metadata?.related_regulations || []).map(referenceId)
},
// type: xIndex == 0 ? 'input' : xIndex == (levelArr.length - 1) ? 'output' : 'default',
position: {
x: xIndex * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: startY + yIndex * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY)
},
...nodeConfig
})
// 插入边
item?.metadata?.related_regulations?.map((reg) => {
let chosenEdge = item?._id == filterNode?._id || reg?._id == filterNode?._id
edgeArr.push({
id: `${item?._id}-${referenceId(reg)}`,
source: item?._id,
target: referenceId(reg),
sourceName: item?.title,
targetName: reg?.title,
type: 'graph',
data: {
active: false
},
...edgeConfig
})
})
})
})
const ids = new Set(nodeArr.map(node => node.id));
return { nodes: nodeArr, edges: edgeArr.filter(edge => ids.has(edge.source) && ids.has(edge.target)) }
}
.record-graph-page { height: calc(100vh - 8rem); min-height: 420px; display: flex; flex-direction: column; overflow: hidden; }
.record-graph-filter { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 20px; min-height: 64px; padding: 12px 24px; border-bottom: 1px solid var(--border); }
.record-graph-node { box-sizing: border-box; transition: all .5s; display: flex; align-items: center; justify-content: center; padding: 8px; border-radius: 5px; width: 240px; height: 60px; border: 2px solid var(--border); background: var(--surface); outline: 4px solid transparent; color: var(--foreground); text-align: center; font-size: 16px; cursor: pointer; }
.record-graph-node.is-active, .record-graph-node.is-selected { border-color: var(--accent); background: var(--accent-soft); font-weight: bold; }
.record-graph-node.is-selected { outline-color: var(--accent-soft); }
.record-graph-node:hover { border-color: var(--accent); }
.record-graph-node .react-flow__handle { transition: all .5s; border: 2px solid var(--border); background: var(--surface); width: 10px; height: 10px; border-radius: 50%; }
.record-graph-node.is-active .react-flow__handle, .record-graph-node.is-selected .react-flow__handle, .record-graph-node:hover .react-flow__handle { border-color: var(--accent); }
.record-graph-title { display: flex; align-items: center; justify-content: center; height: 60px; font-size: 18px; font-weight: 500; }
import { Tooltip } from '@heroui/react';
import { useRef, useState } from 'react';
import { useStoreApi } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from './store';
const STATUS_COLOR = {
修订: 'warning',
废止: 'danger',
新增: 'primary',
};
export default function DragItem(props) {
const {
nodes,
updateNode,
setDraggingItem,
flowRef,
findAncestors,
onNodeClick,
searchCount,
setSearchCount,
searchTitle,
setSearchTitle,
} = useStore((state) => ({
...state,
}));
const store = useStoreApi();
const { data } = props;
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
const handleDragStart = (e) => {
setDraggingItem(data);
if (dragRef.current && e.dataTransfer) {
e.dataTransfer.setDragImage(dragRef.current, 0, 0);
}
e.dataTransfer.effectAllowed = 'move';
setDragging(true);
};
const handleDragEnd = () => {
setDragging(false);
};
const renderRegulationStatus = (data) => {
if (data.metadata?.related_revision) {
return `待${data.metadata?.related_revision?.type}`;
}
if (data.metadata?.type) {
return `待${data.metadata.type}`;
}
return '现行有效';
};
const matchChipColor = (data) => {
const status = data.metadata?.related_revision?.type || data.metadata?.type;
return STATUS_COLOR[status] || 'default';
};
const fitViewToSelectedRegulation = () => {
const regulationNodes = nodes.filter((node) => node.data.title === data.title);
let count =
searchTitle == data.title
? searchCount + 1 > regulationNodes.length
? 0
: searchCount + 1
: 0;
const regulationNode = regulationNodes[count] || null;
setSearchTitle(data.title);
setSearchCount(count);
if (regulationNode) {
const { ancestors } = findAncestors(regulationNode, nodes);
ancestors.forEach((node) => {
node.data.expanded = true;
updateNode(node);
});
onNodeClick(null, regulationNode);
// todo 待优化
setTimeout(() => {
const currentNode = flowRef?.getNode(regulationNode.id);
const position = currentNode?.position || null;
if (!position) return;
flowRef.setCenter(position.x + 120, position.y + 18, {
duration: 200,
});
}, 500);
}
};
return (
<Tooltip delay={0}>
<div
ref={dragRef}
className="graph-list-item"
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={() => fitViewToSelectedRegulation(data)}
style={{ opacity: dragging ? 0.5 : 1 }}
>
<span className="graph-list-item-title">{props.children}</span>
<span className={`graph-status-chip graph-status-chip-${matchChipColor(data)}`}>
{renderRegulationStatus(data)}
</span>
</div>
<Tooltip.Content>{data.title}</Tooltip.Content>
</Tooltip>
);
}
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import '../graph.css';
export default function CustomNode({ data }) {
const { type, selected, related_regulation_type, opacity } = data;
const className = `${type} ${type === 'regulation' ? related_regulation_type : ''} ${
selected ? 'selected' : ''
}`;
return (
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
width: 172,
height: 36,
opacity: opacity,
}}
>
<Handle type="target" position="left" style={{ background: '#555' }} />
<div className="graph-node-title" style={{ width: '220px' }}>
{data.title}
</div>
<Handle type="source" position="right" style={{ background: '#555' }} />
</div>
);
}
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { useCallback, useRef } from 'react';
import ReactFlow, { Controls, MiniMap, useReactFlow, useStoreApi } from 'reactflow';
import 'reactflow/dist/style.css';
import '../graph.css';
import useStore from '../store';
import useCopyPaste from '../useCopyPaste';
import CustomNode from './CustomNode';
import GroupNode from './GroupNode';
import PlaceholderNode from './PlaceholderNode';
import RegulationNode from './RegulationNode';
import RootNode from './RootNode';
import useAnimatedNodes from './useAnimatedNodes';
import useExpandCollapse from './useExpandCollapse';
const nodeTypes = {
groupNode: GroupNode,
custom: CustomNode,
regulationNode: RegulationNode,
rootNode: RootNode,
placeholder: PlaceholderNode,
};
const EDGE_COLOR = '#1677ff';
export default function DndFlow() {
const {
nodes,
aggregateNodes,
edges,
aggregateNodeEdges,
onNodeClick,
onEdgesChange,
onNodesDelete,
onNodeDoubleClick,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
addNode,
addEdge,
updateNode,
handleClick,
draggingItem,
removeNode,
setEdges,
setNodes,
setAggregateNodes,
setAggregateNodeEdges,
setLayoutedElements,
setFlowRef,
} = useStore((state) => ({
...state,
}));
const { copy, paste, bufferedNode } = useCopyPaste();
const store = useStoreApi();
const reactFlowWrapper = useRef(null);
const { screenToFlowPosition } = useReactFlow();
const draggingInfo = useRef(null);
const { nodes: visibleNodes, edges: visibleEdges } = useExpandCollapse(
aggregateNodes || nodes,
aggregateNodeEdges || edges,
{ treeWidth: 320, treeHeight: 30 },
);
const { nodes: animatedNodes } = useAnimatedNodes(visibleNodes, {
animationDuration: 0,
});
const virtualNode = useRef(null);
const onDragOver = useCallback(
(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (
draggingInfo.current?.x === position.x &&
draggingInfo.current?.y === position.y
) {
return;
}
draggingInfo.current = {
x: position.x,
y: position.y,
};
const { nodeInternals } = store.getState();
const currentNodes = Array.from(nodeInternals.values());
const targetNode = currentNodes.find(
(node) =>
position.x < node.position.x + 550 &&
position.x > node.position.x + 250 &&
position.y < node.position.y + 50 &&
position.y > node.position.y - 50,
);
if (targetNode) {
if (virtualNode.current?.data?.parent === targetNode.id) return;
if (!nodes.some((node) => node.data.parent == targetNode.id)) {
targetNode.data.expanded = true;
updateNode(targetNode.id, {
expanded: true,
});
}
if (!targetNode.data.expanded) return;
if (virtualNode.current) {
removeNode(virtualNode.current.id);
setLayoutedElements();
virtualNode.current = null;
}
const newNodeId = nanoid(6);
const newNode = {
id: newNodeId,
type: 'placeholder',
draggable: true,
connectable: true,
position: { x: position.x, y: position.y },
data: {
id: newNodeId,
parent: targetNode.id,
type: 'regulationNode',
title: draggingItem.title,
related_regulation: draggingItem.metadata?.related_revise
? null
: draggingItem._id,
related_revise_regulation:
draggingItem.metadata?.related_revision?._id ||
(draggingItem.metadata.type == '新增' ? draggingItem._id : null),
related_regulation_type: draggingItem.metadata.related_regulation_type || null,
content: draggingItem.content,
},
};
virtualNode.current = newNode;
addNode(virtualNode.current);
const edge = {
id: nanoid(6),
source: targetNode.id,
target: newNodeId,
animated: true,
style: {
stroke: EDGE_COLOR,
},
};
addEdge(edge);
} else {
if (virtualNode.current) {
removeNode(virtualNode.current.id);
virtualNode.current = null;
}
}
},
[draggingItem],
);
const onDrop = (event) => {
const currentEdges = _.cloneDeep(edges);
const currentAggregateNodeEdges = _.cloneDeep(aggregateNodeEdges);
setNodes(
nodes.map((node) =>
node.type === 'placeholder' ? { ...node, type: 'regulationNode' } : node,
),
);
setAggregateNodes(
aggregateNodes
? aggregateNodes.map((node) =>
node.type === 'placeholder' ? { ...node, type: 'regulationNode' } : node,
)
: null,
);
// todo
if (virtualNode.current) {
const virtualNodeEdge =
currentEdges?.find((edge) => edge.target === virtualNode.current.id) || null;
if (virtualNodeEdge) {
virtualNodeEdge.animated = false;
virtualNodeEdge.style = {
stroke: '#666',
};
setEdges(currentEdges);
}
const virtualAggregateNodeEdge =
currentAggregateNodeEdges?.find((edge) => edge.target === virtualNode.current.id) ||
null;
if (virtualAggregateNodeEdge) {
virtualAggregateNodeEdge.animated = false;
virtualAggregateNodeEdge.style = {
stroke: '#666',
};
setAggregateNodeEdges(currentAggregateNodeEdges);
}
virtualNode.current = null;
}
};
return (
<div style={{ width: '100%', height: '100%' }}>
<div ref={reactFlowWrapper} style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={animatedNodes}
edges={visibleEdges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
onNodesDelete={onNodesDelete}
onEdgesChange={onEdgesChange}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeDoubleClick={onNodeDoubleClick}
onClick={handleClick}
fitView
proOptions={{
hideAttribution: true,
}}
onInit={(instance) => setFlowRef(instance)}
>
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</div>
</div>
);
}
import { Back, ChevronRight, Close } from '@/components/AppIcons';
import { Button, Popover } from '@heroui/react';
import HeroCheckbox from '@/components/HeroCheckbox';
import _ from 'lodash';
import { useMemo, useState } from 'react';
/**
* 多级联动的多选筛选器,替代 streams-admin 中的 antd Cascader(multiple)。
* 选中值 value 为路径数组(每条路径是从根到所选节点的 id 数组),与 store 的 cascaderValue 结构一致。
*/
export default function GraphCascadeFilter({ options, value, onChange, placeholder = '筛选制度' }) {
const [open, setOpen] = useState(false);
const [activePath, setActivePath] = useState([]);
const flatMap = useMemo(() => {
const map = {};
const walk = (nodes, path) => {
nodes.forEach((node) => {
const nodePath = [...path, node.value];
map[node.value] = { node, path: nodePath };
if (node.children?.length) walk(node.children, nodePath);
});
};
walk(options || [], []);
return map;
}, [options]);
const columns = useMemo(() => {
const cols = [options || []];
let current = options || [];
for (const id of activePath) {
const next = current.find((n) => n.value === id);
if (!next?.children?.length) break;
cols.push(next.children);
current = next.children;
}
return cols;
}, [options, activePath]);
const isChecked = (nodeValue) =>
(value || []).some((path) => _.isEqual(path, flatMap[nodeValue]?.path));
const toggle = (nodeValue) => {
const path = flatMap[nodeValue]?.path;
if (!path) return;
const next = isChecked(nodeValue)
? (value || []).filter((p) => !_.isEqual(p, path))
: [...(value || []), path];
onChange(next);
};
const selectedCount = (value || []).length;
return (
<Popover isOpen={open} onOpenChange={(next) => { setOpen(next); if (!next) setActivePath([]); }}>
<Popover.Trigger>
<Button variant="secondary" className="graph-cascade-trigger">
<span className="graph-cascade-label">{selectedCount ? `已选 ${selectedCount} 项` : placeholder}</span>
<Close
size={12}
className="graph-cascade-clear"
onClick={(e) => {
e.stopPropagation();
onChange([]);
}}
/>
</Button>
</Popover.Trigger>
<Popover.Content placement="bottom start">
<div className="graph-cascade-panel">
<div className="graph-cascade-head">
<span className="graph-cascade-title">{placeholder}</span>
<Button
size="sm"
variant="tertiary"
onPress={() => {
onChange([]);
setOpen(false);
}}
>
清空
</Button>
</div>
<div
className="graph-cascade-columns"
style={{ width: `${Math.min(columns.length, 3) * 15}rem` }}
>
{columns.map((column, depth) => (
<div className="graph-cascade-column" key={depth}>
{depth > 0 ? (
<div className="graph-cascade-column-head">
<Button
isIconOnly
size="sm"
variant="tertiary"
aria-label="返回上一级"
onPress={() => setActivePath((current) => current.slice(0, depth - 1))}
>
<Back size={14} />
</Button>
<span className="graph-cascade-column-title" title={flatMap[activePath[depth - 1]]?.node?.label}>
{flatMap[activePath[depth - 1]]?.node?.label}
</span>
</div>
) : null}
<div className="graph-cascade-list">
{column.length ? (
column.map((node) => {
const active = activePath[depth] === node.value;
const hasChildren = node.children?.length > 0;
return (
<div
key={node.value}
className={`graph-cascade-option ${active ? 'active' : ''}`}
>
<HeroCheckbox
isSelected={isChecked(node.value)}
aria-label={node.label}
onChange={() => toggle(node.value)}
>
<span className="graph-cascade-option-label" title={node.label}>
{node.label}
</span>
</HeroCheckbox>
{hasChildren ? (
<Button
isIconOnly
size="sm"
variant="tertiary"
aria-label="展开下一级"
onPress={() => {
setActivePath((current) => [...current.slice(0, depth), node.value]);
}}
>
<ChevronRight size={14} />
</Button>
) : null}
</div>
);
})
) : (
<div className="graph-cascade-empty">暂无子节点</div>
)}
</div>
</div>
))}
</div>
</div>
</Popover.Content>
</Popover>
);
}
import { Menu, Popover } from '@heroui/react';
/**
* 右键上下文菜单:使用 HeroUI Popover + Menu 实现,锚定在触发节点上。
* 仅通过 onContextMenu 打开,左键点击不会触发打开。
*/
export default function GraphContextMenu({ isOpen, onOpenChange, items, onAction, children }) {
return (
<Popover isOpen={isOpen} onOpenChange={(open) => { if (!open) onOpenChange(false); }}>
<Popover.Trigger>{children}</Popover.Trigger>
<Popover.Content placement="bottom start">
<Menu aria-label="节点操作" onAction={onAction}>
{items.map((item) => (
<Menu.Item key={item.key} id={item.key} textValue={item.label}>
{item.label}
</Menu.Item>
))}
</Menu>
</Popover.Content>
</Popover>
);
}
import { Back, Close } from '@/components/AppIcons';
import { Button, Tooltip } from '@heroui/react';
import { QuestionCircleFilled } from '@ant-design/icons';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function GroupNode({ data }) {
const { nodes, onNodeExpand, addNode, addEdge, aggregateNodes, setLayoutedElements } = useStore(
(state) => ({
...state,
}),
);
const { selected, opacity, expanded, removed = false } = data;
const className = `groupNode ${selected ? 'selected' : ''} ${removed ? 'removed' : ''}`;
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [nodes, aggregateNodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
expanded: false,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
return (
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[{ key: 'addGroupNode', label: '添加节点' }]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
setMenuOpen(false);
}}
>
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
width: 240,
height: 36,
position: 'relative',
opacity: removed ? 0.5 : opacity,
}}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<Tooltip delay={0} isDisabled={!data.content}>
<div className="graph-node-title">
{removed ? <Close size={12} style={{ color: 'red', marginRight: 4 }} /> : null}
{data.title}
</div>
<Tooltip.Content>
<div>
<p style={{ fontWeight: 'bold' }}>{data.title}</p>
{data.content ? <p>{data.content}</p> : null}
</div>
</Tooltip.Content>
</Tooltip>
<div>
{data.content ? (
<span className="graph-node-hint">
<QuestionCircleFilled />
</span>
) : null}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
);
}
import { Handle } from 'reactflow';
export default function PlaceholderNode() {
return (
<div
style={{
borderRadius: 5,
width: 80,
height: 20,
background: '#e6f4ff',
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
</div>
);
}
import { Back, Close } from '@/components/AppIcons';
import { QuestionCircleFilled } from '@ant-design/icons';
import { Button, Textarea, Tooltip } from '@heroui/react';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import { APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import XinyuanModal from '@/components/XinyuanModal';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function RegulationNode({ data }) {
const {
nodes,
onNodeExpand,
addEdge,
addNode,
showCurrentRegulationNode,
aggregateNodes,
updateNode,
setLayoutedElements,
} = useStore((state) => ({
...state,
}));
const {
selected,
related_regulation_type,
opacity,
expanded,
highlighted,
removed = false,
} = data;
const [showModal, setShowModal] = useState(false);
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const className = useMemo(() => {
return `regulation ${
showCurrentRegulationNode ? related_regulation_type : 'active'
} ${selected ? 'selected' : ''} ${removed ? 'removed' : ''}`;
}, [showCurrentRegulationNode, related_regulation_type, selected, removed]);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [nodes, aggregateNodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
const handleViewDetails = () => {
const { related_regulation } = data;
if (related_regulation) {
window.open(`/${APP_ROUTE_NAME}/internal_regulation/${related_regulation}`, '_blank');
}
};
return (
<>
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[
{ key: 'addGroupNode', label: '添加节点' },
{ key: 'addDescription', label: '添加描述' },
{ key: 'viewDetails', label: '查看详情' },
]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
if (key === 'addDescription') setShowModal(true);
if (key === 'viewDetails') handleViewDetails();
setMenuOpen(false);
}}
>
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
opacity: opacity,
position: 'relative',
backgroundColor: highlighted ? 'yellow' : 'white',
}}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<Tooltip delay={0} isDisabled={!data.content}>
<div className="graph-node-title">
{removed ? <Close size={12} style={{ color: 'red', marginRight: 4 }} /> : null}
{data.title}
</div>
<Tooltip.Content>
<div>
<p style={{ fontWeight: 'bold' }}>{data.title}</p>
{data.content ? <p>{data.content}</p> : null}
</div>
</Tooltip.Content>
</Tooltip>
<div>
{data.content ? (
<span className="graph-node-hint">
<QuestionCircleFilled />
</span>
) : null}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
<DescriptionModal
isOpen={showModal}
onOpenChange={setShowModal}
initialValue={data.content || ''}
onSubmit={(value) => {
updateNode(data.id, { content: value });
setShowModal(false);
}}
/>
</>
);
}
function DescriptionModal({ initialValue, isOpen, onOpenChange, onSubmit }) {
const [value, setValue] = useState(initialValue || '');
useEffect(() => {
if (isOpen) setValue(initialValue || '');
}, [isOpen, initialValue]);
return (
<XinyuanModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title="节点描述"
footer={
<>
<Button variant="secondary" onPress={() => onOpenChange(false)}>
取消
</Button>
<Button
onPress={() => {
onSubmit(value);
}}
>
保存
</Button>
</>
}
>
<Textarea
aria-label="节点描述"
label="节点描述"
placeholder="请输入节点描述信息"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</XinyuanModal>
);
}
import { Back } from '@/components/AppIcons';
import { Button } from '@heroui/react';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function RootNode({ data }) {
const { selected, opacity, expanded } = data;
const className = `rootNode ${selected ? 'selected' : ''}`;
const { nodes, onNodeExpand, addNode, addEdge, aggregateNodes, setLayoutedElements } = useStore(
(state) => ({
...state,
}),
);
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [aggregateNodes, nodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
return (
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[{ key: 'addGroupNode', label: '添加节点' }]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
setMenuOpen(false);
}}
>
<div
className={className}
style={{ opacity }}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<div className="graph-node-title" style={{ width: '220px' }}>
{data.title}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
);
}
import { timer } from 'd3-timer';
import { useEffect, useState } from 'react';
import { useReactFlow } from 'reactflow';
import 'reactflow/dist/style.css';
const useAnimatedNodes = (nodes, { animationDuration = 300 } = {}) => {
const [tmpNodes, setTmpNodes] = useState(nodes);
const { getNode } = useReactFlow();
useEffect(() => {
const transitions = nodes.map((node) => ({
id: node.id,
from: getNode(node.id)?.position ?? node.position,
to: node.position,
node,
}));
const t = timer((elapsed) => {
const s = elapsed / animationDuration;
const currNodes = transitions.map(({ node, from, to }) => {
return {
...node,
position: {
x: from.x + (to.x - from.x) * s,
y: from.y + (to.y - from.y) * s,
},
};
});
setTmpNodes(currNodes);
if (elapsed > animationDuration) {
setTmpNodes(nodes);
t.stop();
}
});
return () => t.stop();
}, [nodes, getNode, animationDuration]);
return { nodes: tmpNodes };
};
export default useAnimatedNodes;
import { useMemo } from 'react';
import dagre from 'dagre';
import 'reactflow/dist/style.css';
const filterCollapsedChildren = (dagreGraph, node) => {
const children = dagreGraph.successors(node.id);
if (!node.data.expanded) {
while (children?.length) {
const child = children.pop();
children.push(...dagreGraph.successors(child));
dagreGraph.removeNode(child);
}
}
};
const useExpandCollapse = (
nodes,
edges,
{ layoutNodes = true, treeWidth = 200, treeHeight = 30 } = {},
) => {
return useMemo(() => {
if (!layoutNodes) return { nodes, edges };
const dagreGraph = new dagre.graphlib.Graph()
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: 'LR' });
for (const node of nodes) {
dagreGraph.setNode(node.id, {
width: treeWidth,
height: treeHeight,
data: node.data,
});
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
for (const node of nodes) {
filterCollapsedChildren(dagreGraph, node);
}
dagre.layout(dagreGraph);
return {
nodes: nodes.flatMap((node) => {
if (!dagreGraph.hasNode(node.id)) return [];
const { x, y } = dagreGraph.node(node.id);
return [
{
...node,
position: { x, y },
data: { ...node.data },
},
];
}),
edges,
};
}, [nodes, edges, layoutNodes, treeWidth, treeHeight]);
};
export default useExpandCollapse;
.graph-page {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: calc(100vh - 4rem - var(--rc-page-padding-block-start, 0px) - var(--rc-page-padding-block-end, 0px));
margin-block-start: calc(-1 * var(--rc-page-padding-block-start, 0px));
margin-block-end: calc(-1 * var(--rc-page-padding-block-end, 0px));
overflow: hidden;
}
.graph-layout {
position: relative;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
gap: 0;
}
.graph-side {
position: relative;
height: 100%;
flex: none;
border-right: 1px solid var(--rc-color-border, #e8e8e8);
background-color: var(--rc-color-surface, #fff);
}
.graph-side-left {
width: 320px;
}
.graph-side-right {
width: 280px;
border-right: none;
border-left: 1px solid var(--rc-color-border, #e8e8e8);
}
.graph-canvas {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
}
.graph-toggle-btn {
position: absolute;
top: 48px;
z-index: 10;
}
.graph-list {
height: calc(100% - 60px);
overflow: hidden auto;
}
.graph-toolbar {
position: absolute;
inset-block-start: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 60px;
padding: 0 12px;
background-color: var(--rc-color-surface, #fff);
}
.graph-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 12px;
cursor: grab;
border-block-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-list-item:hover {
background-color: var(--rc-color-surface-hover, #f5f5f5);
}
.graph-list-item-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 240px;
}
.groupNode {
transition: 0.25s all;
display: flex;
justify-content: center;
align-items: center;
border: none;
background-color: #ececec;
border: 2px solid transparent;
}
.groupNode.selected {
border-color: #ccc;
}
.regulation {
display: flex;
justify-content: center;
align-items: center;
border: 2px solid grey;
background-color: #fff;
width: 240px;
height: 36px;
}
.regulation.selected {
outline: 4px solid rgba(128, 128, 128, 0.2);
}
.regulation.removed {
opacity: 0.5;
}
.addition {
border: 2px solid #065de7;
}
.addition.selected {
outline: 4px solid rgba(6, 93, 231, 0.2);
}
.revise {
border: 2px solid #dcac04;
}
.revise.selected {
outline: 4px solid rgba(220, 172, 4, 0.2);
}
.forbidden {
border: 2px solid #da241a;
}
.forbidden.selected {
outline: 4px solid rgba(218, 36, 26, 0.2);
}
.rootNode {
transition: 0.25s all;
display: flex;
justify-content: center;
align-items: center;
border: none;
font-size: large;
padding: 5px;
font-weight: bold;
border-radius: 5px;
background-color: #ececec;
border: 2px solid transparent;
}
.rootNode.selected {
border-color: #ccc;
}
.graph-node-wrapper {
position: relative;
}
.graph-node-count {
position: absolute;
top: 6px;
right: -10px;
z-index: 1;
}
.graph-node-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 210px;
}
.graph-node-hint {
position: absolute;
right: 0;
padding-right: 7px;
opacity: 0.3;
}
.collapse-icon {
display: none;
position: absolute;
right: 0px;
top: 6px;
cursor: pointer;
}
.graph-status-chip {
flex: none;
padding: 1px 8px;
border-radius: 9999px;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
border: 1px solid transparent;
}
.graph-status-chip-default {
color: #595959;
background-color: #fafafa;
border-color: #d9d9d9;
}
.graph-status-chip-primary {
color: #1668dc;
background-color: #e6f4ff;
border-color: #91caff;
}
.graph-status-chip-warning {
color: #d48806;
background-color: #fffbe6;
border-color: #ffe58f;
}
.graph-status-chip-danger {
color: #d9363e;
background-color: #fff1f0;
border-color: #ffa39e;
}
.graph-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.graph-tabs-panel {
flex: 1;
min-height: 0;
overflow: hidden auto;
}
.graph-detail-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.graph-timeline-item {
position: relative;
padding-block-end: 12px;
padding-inline-start: 18px;
}
.graph-timeline-item::before {
content: '';
position: absolute;
inset-inline-start: 4px;
top: 6px;
bottom: 0;
width: 2px;
background-color: var(--rc-color-border, #d9d9d9);
}
.graph-timeline-item::after {
content: '';
position: absolute;
inset-inline-start: 0;
top: 5px;
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: var(--rc-color-primary, #1677ff);
}
.graph-timeline-item:last-child::before {
display: none;
}
.graph-cascade-trigger {
position: relative;
min-width: 10rem;
}
.graph-cascade-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.graph-cascade-clear {
position: absolute;
inset-inline-end: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.graph-cascade-trigger:hover .graph-cascade-clear {
opacity: 1;
pointer-events: auto;
}
.graph-cascade-panel {
display: flex;
flex-direction: column;
min-width: 15rem;
}
.graph-cascade-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-block-end: 1px solid var(--rc-color-border, #e8e8e8);
}
.graph-cascade-title {
font-weight: 600;
}
.graph-cascade-columns {
display: flex;
min-height: 240px;
max-height: 320px;
overflow: hidden auto;
}
.graph-cascade-column {
display: flex;
flex-direction: column;
min-width: 15rem;
max-height: 320px;
border-inline-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-cascade-column-head {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-block-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-cascade-column-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
color: var(--rc-color-muted, #8c8c8c);
}
.graph-cascade-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.graph-cascade-option {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
}
.graph-cascade-option.active {
background-color: var(--rc-color-surface-hover, #f5f5f5);
}
.graph-cascade-option-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 9rem;
}
.graph-cascade-empty {
padding: 12px;
font-size: 12px;
color: var(--rc-color-muted, #8c8c8c);
}
.graph-archive-trigger {
min-width: 11rem;
justify-content: space-between;
}
import { utils, writeFile } from 'xlsx';
function replaceNullWithEmptyString(data) {
return data.map((row) => {
const newRow = {};
Object.keys(row).forEach((key) => {
newRow[key] = row[key] === null || row[key] === undefined ? '' : row[key];
});
return newRow;
});
}
export function saveRegulationSheet(data, filename = '制度列表.xlsx') {
const rows = replaceNullWithEmptyString(data || []);
const worksheet = utils.json_to_sheet(rows);
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, '制度列表');
writeFile(workbook, filename);
}
import dagre from 'dagre';
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { applyEdgeChanges, applyNodeChanges } from 'reactflow';
import 'reactflow/dist/style.css';
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { importRecords, queryRecords } from '@/services/DataService';
import { saveRegulationSheet } from './sheetExport';
const useStore = create(
subscribeWithSelector((set, get) => ({
flowRef: null,
app: null,
nodes: [],
aggregateNodes: null,
removedNodes: [],
edges: [],
aggregateNodeEdges: null,
context: [],
selectedRelateTypes: [],
showCurrentRegulationNode: true,
containerSize: {
width: 0,
height: 0,
},
cascaderValue: [],
viewport: {
x: 0,
y: 0,
zoom: 0.9,
},
selectedNode: null,
draggingItem: null,
hideLeftMenu: false,
hideRightMenu: false,
searchCount: 0,
searchTitle: '',
showRemovedNodes: true,
setShowRemovedNodes: (showRemovedNodes) => set({ showRemovedNodes }),
setFlowRef: (flowRef) => set({ flowRef }),
setSearchTitle: (searchTitle) => set({ searchTitle }),
setSearchCount: (searchCount) => set({ searchCount }),
setApp: (app) => {
set({ app });
},
setHideLeftMenu: (value) => {
set({ hideLeftMenu: value });
},
setHideRightMenu: (value) => {
set({ hideRightMenu: value });
},
setDraggingItem: (item) => {
set({ draggingItem: item });
},
setViewport: (viewport) => {
set({ viewport });
},
setNodes: (nodes) => {
set({ nodes: [...nodes] });
},
setAggregateNodes: (nodes) => {
set({ aggregateNodes: nodes });
},
setEdges: (edges) => {
set({ edges: [...edges] });
},
setAggregateNodeEdges: (edges) => {
set({ aggregateNodeEdges: edges });
},
setContext: (context) => {
set({ context });
},
setContainerSize: (size) => {
set({ containerSize: size });
},
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
});
},
onEdgesChange: (changes) => {
set({
edges: applyEdgeChanges(changes, get().edges),
});
},
onNodeClick: async (e, node) => {
const selectedNode = node;
if (node.type == 'regulationNode') {
const res = await queryRecords({
subject: 'internal_regulation',
page: 0,
page_size: 100,
filter: {
'metadata.origin_regulation': {
$in: [node.data.related_regulation],
},
},
});
selectedNode.data.history_regulation = res.records || [];
}
const { ancestors } = get().findAncestors(node, get().nodes);
selectedNode.data.ancestors = ancestors;
const nodes = get().nodes.map((n) => ({
...n,
data: {
...n.data,
selected: n.id === node.id,
},
selected: n.id === node.id,
}));
const aggregateNodes = get().aggregateNodes
? get().aggregateNodes.map((n) => ({
...n,
data: {
...n.data,
selected: n.id === node.id,
},
selected: n.id === node.id,
}))
: null;
set({
selectedNode,
nodes,
aggregateNodes,
});
},
onNodeExpand: (nodeId) => {
const aggregateNodes = get().aggregateNodes;
if (aggregateNodes) {
const nodes = get().aggregateNodes.map((n) => ({
...n,
data: {
...n.data,
selected: n.id === nodeId,
expanded: n.id === nodeId ? !n.data.expanded : n.data.expanded,
},
}));
set({
aggregateNodes: nodes,
});
} else {
const nodes = get().nodes.map((n) => ({
...n,
data: {
...n.data,
selected: n.id === nodeId,
expanded: n.id === nodeId ? !n.data.expanded : n.data.expanded,
},
}));
set({
nodes,
});
}
},
onNodeDrag: (e, node, nodes) => {},
onNodeDragStart: (e, node, nodes) => {},
onNodeDragStop: (e, node, nodes) => {},
onMoveEnd: (e, viewport) => {
set({ viewport });
},
onNodesDelete: (removeNodes) => {
let nodes = get().nodes;
const edges = [];
const removeNode = removeNodes[0];
const descendants = get().findDescendants(removeNode, get().nodes);
descendants.forEach((node) => {
nodes = nodes.filter((n) => n.id !== node.id);
});
nodes.forEach((node) => {
edges.push({
source: node.data.parent,
target: node.id,
id: `${node.data.parent}-$${node.id}`,
});
});
let aggregateNodes = get().aggregateNodes;
if (aggregateNodes) {
descendants.forEach((node) => {
aggregateNodes = aggregateNodes.filter((n) => n.id !== node.id);
});
const aggregateNodeEdges = [];
aggregateNodes.forEach((node) => {
aggregateNodeEdges.push({
source: node.data.parent,
target: node.id,
id: `${node.data.parent}-$${node.id}`,
});
});
set({
aggregateNodes,
aggregateNodeEdges,
});
}
set({
edges,
nodes,
selectedNode: null,
});
get().setLayoutedElements();
},
setSelectedNode: (id) => {
let node = get().getNode(id);
if (node) {
set({
selectedNode: node,
selectedNodeData: node.data,
nodes: get().nodes.map((n) => ({ ...n, selected: n.id === id })),
});
}
},
addNode: (node) => {
set({
nodes: [...get().nodes.map((n) => ({ ...n, selected: false })), node],
aggregateNodes: get().aggregateNodes
? [...get().aggregateNodes.map((n) => ({ ...n, selected: false })), node]
: null,
});
},
addEdge: (edge) => {
set({
edges: [
...get().edges,
{
id: nanoid(6),
...edge,
},
],
aggregateNodeEdges: get().aggregateNodeEdges
? [...get().aggregateNodeEdges, { id: nanoid(6), ...edge }]
: null,
});
},
removeNode: (id) => {
set({
nodes: get().nodes.filter((n) => n.id !== id),
edges: get().edges?.filter((e) => e.target !== id && e.source !== id),
aggregateNodes: get().aggregateNodes
? get().aggregateNodes.filter((n) => n.id !== id)
: null,
aggregateNodeEdges: get().aggregateNodeEdges
? get().aggregateNodeEdges.filter((e) => e.target !== id && e.source !== id)
: null,
});
},
copyNode: (originId) => {
let { id, position, ...rest } = get().getNode(originId);
get().addNode(rest);
},
getNode: (id) => {
return get().nodes.find((n) => n.id === id);
},
getSourceNode: (id, handleId = null) => {
let edge = null;
if (handleId) {
edge = get().edges.find((edge) => edge.target === id && edge.targetHandle == handleId);
} else {
edge = get().edges.find((edge) => edge.target === id);
}
if (!edge) {
return null;
}
return get().getNode(edge.source);
},
getTargetNode: (id) => {
const edge = get().edges.find((edge) => edge.source === id);
if (!edge) {
return null;
}
return get().getNode(edge.target);
},
getSourceNodes: (id) => {
const sourceNodes = [];
let sourceNode = get().getSourceNode(id);
while (sourceNode) {
sourceNodes.unshift(sourceNode);
sourceNode = get().getSourceNode(sourceNode.id);
}
return sourceNodes;
},
getTargetNodes: (id) => {
const targetNodes = [];
let targetNode = get().getTargetNode(id);
while (targetNode) {
targetNodes.unshift(targetNode);
targetNode = get().getTargetNode(targetNode.id);
}
return targetNodes;
},
getSelectedNode: () => {
return get().nodes.find((n) => n.selected);
},
getCenterPosition: () => {
return {
x: get().containerSize?.width / 2 - get().viewport?.x,
y: get().containerSize?.height / 2 - get().viewport?.y,
};
},
getContextValue: (value) => {
let res = value;
const contextRegex = /^\{\{.*\}\}$/;
const matchRegex = /\{\{(.*?)\}\}/;
if (contextRegex.test(value)) {
const field = value?.match(matchRegex)?.[1];
let match = get().context.find((v) => v.field === field);
res = match?.value ?? res;
}
return res;
},
clear: () => {
set({
nodes: [],
edges: [],
context: [],
containerSize: {
width: 0,
height: 0,
},
viewport: {
x: 0,
y: 0,
zoom: 0.9,
},
});
},
handleClick: (e) => {
if (e.target.className === 'react-flow__pane') {
set({
selectedNode: null,
selectedNodeData: null,
nodes: get().nodes.map((n) => {
return {
...n,
selected: false,
data: {
...n.data,
selected: false,
},
};
}),
aggregateNodes: get().aggregateNodes
? get().aggregateNodes.map((n) => {
return {
...n,
selected: false,
data: {
...n.data,
selected: false,
},
};
})
: null,
});
}
},
updateNode: (id, data) => {
set({
nodes: get().nodes.map((n) => {
if (n.id === id) {
return {
...n,
type: data?.type || n.type,
data: {
...n.data,
...data,
},
};
} else {
return n;
}
}),
aggregateNodes: get().aggregateNodes
? get().aggregateNodes.map((n) => {
if (n.id === id) {
return {
...n,
type: data?.type || n.type,
data: {
...n.data,
...data,
},
};
} else {
return n;
}
})
: null,
});
},
updateSelectedNode: (data) => {
set({
selectedNode: {
...get().selectedNode,
data: {
...get().selectedNode.data,
...data,
},
},
});
},
setSelectedRelateType: (type) => {
let relatedTypes = _.cloneDeep(get().selectedRelateTypes);
if (relatedTypes.some((item) => item == type)) {
relatedTypes = relatedTypes.filter((t) => t != type);
} else {
relatedTypes.push(type);
}
set({
selectedRelateTypes: relatedTypes,
});
get().generateAggregateNodes();
},
setSearchNodes: (id) => {
const nodes = get().nodes;
const searchNodes = nodes.map((n) => {
if (n.data.related_regulation && id.includes(n.data.related_regulation)) {
return {
...n,
data: {
...n.data,
highlighted: true,
},
};
} else {
return {
...n,
data: {
...n.data,
highlighted: false,
},
};
}
});
set({
nodes: searchNodes,
});
},
generateAggregateNodes: () => {
const totalNodes = get().nodes;
const cascaderValue = get().cascaderValue;
const relatedTypes = get().selectedRelateTypes;
const lastNodeIds = [];
const showRemovedNodes = get().showRemovedNodes;
const removedNodes = get().removedNodes;
let selectedNodeIds = null;
if (cascaderValue.length == 0 && relatedTypes.length == 0 && showRemovedNodes) {
totalNodes.forEach((node) => {
if (node.type == 'rootNode') {
node.data.expanded = true;
} else {
node.data.expanded = false;
}
});
set({
aggregateNodes: null,
aggregateNodeEdges: null,
nodes: totalNodes,
});
return;
}
let aggregateNodes = cascaderValue.length == 0 ? totalNodes : [];
let aggregateNodeEdges = [];
// cascade 筛选
if (cascaderValue.length != 0) {
cascaderValue.forEach((item) => {
lastNodeIds.push(item.slice(-1)[0]);
});
selectedNodeIds = cascaderValue.flat();
lastNodeIds.forEach((id) => {
const node = totalNodes.find((item) => item.id == id);
const descendants = get().findDescendants(node, totalNodes);
aggregateNodes.push(...descendants);
});
selectedNodeIds.forEach((id) => {
const targetNode = totalNodes.find((item) => item.id == id);
aggregateNodes.push(targetNode);
});
aggregateNodes = _.uniqBy(aggregateNodes, 'id');
const rootNode = totalNodes.find((node) => node.type == 'rootNode');
aggregateNodes.push(rootNode);
}
// 筛选制度状态
if (relatedTypes.length != 0) {
const regulationNodes = aggregateNodes.filter((item) => item.type == 'regulationNode');
let result = [];
relatedTypes.forEach((type) => {
const currentTypeRegulationNodes = regulationNodes.filter(
(item) => item.data.related_regulation_type == type,
);
currentTypeRegulationNodes.forEach((n) => {
const { ancestors } = get().findAncestors(n, aggregateNodes);
result.push(...ancestors);
});
});
result = _.uniqBy(result, 'id');
aggregateNodes = result;
}
// 筛选已移除节点
if (showRemovedNodes == false) {
removedNodes.forEach((node) => {
aggregateNodes = aggregateNodes.filter((item) => item.id != node.id);
});
}
// 生成链接线
aggregateNodes.forEach((node) => {
if (get().cascaderValue.flat().some((id) => id == node.id)) {
node.data.expanded = true;
}
if (node.data.parent) {
aggregateNodeEdges.push({
source: node.data.parent,
target: node.id,
id: `${node.data.parent}-$${node.id}`,
});
}
});
set({
aggregateNodes,
aggregateNodeEdges,
});
},
setShowCurrentRegulationNode: (checked) => {
const nodes = get().nodes;
let aggregateNodes = get().aggregateNodes || get().nodes;
const aggregateNodeEdges = [];
let additionRegulationNodes = nodes.filter(
(item) => item.type == 'regulationNode' && item.data.related_regulation_type == 'addition',
);
if (checked) {
set({
showCurrentRegulationNode: checked,
aggregateNodes: null,
aggregateNodeEdges: null,
});
} else {
let removedNods = [];
additionRegulationNodes.forEach((node) => {
const { ancestors } = get().findAncestors(node, aggregateNodes);
removedNods.push(...ancestors);
});
aggregateNodes = aggregateNodes.filter(
(item) => !removedNods.includes(item),
);
aggregateNodes.forEach((node) => {
if (node.data.parent) {
aggregateNodeEdges.push({
source: node.data.parent,
target: node.id,
id: `${node.data.parent}-$${node.id}`,
});
}
});
set({
showCurrentRegulationNode: checked,
aggregateNodes,
aggregateNodeEdges,
});
}
},
setLayoutedElements: () => {
const nodes = get().nodes;
const edges = get().edges;
const direction = 'LR';
const nodeWidth = 240;
const nodeHeight = 36;
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: direction,
});
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: nodeWidth,
height: nodeHeight,
});
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
node.position = {
x: nodeWithPosition.x,
y: nodeWithPosition.y,
};
});
set({
nodes,
edges,
});
},
syncNodesRelatedRegulation: (regulations) => {
let nodes = get().nodes;
const regulationNodes = nodes.filter((node) => node.type == 'regulationNode');
const forbiddenNode = [];
let deletedNodes = [];
for (const node of regulationNodes) {
if (node.data.related_regulation_type == 'active') {
const matchRegulation = regulations.find(
(regulation) => regulation._id == node.data.related_regulation,
);
if (matchRegulation) {
if (matchRegulation.metadata.related_revision) {
if (matchRegulation.metadata.related_revision.type === '修订') {
node.data.related_regulation_type = 'revise';
} else {
node.data.related_regulation_type = 'forbidden';
}
}
} else {
// 只有匹配上制度的才删除
if (node.data.related_regulation) {
deletedNodes.push(node);
}
}
continue;
}
//新增节点
if (node.data.related_regulation_type == 'addition') {
const matchRegulation = regulations.find(
(regulation) => regulation._id == node.data.related_revise_regulation,
);
if (matchRegulation) {
// 不再更改节点标题
} else {
const additionRegulation = regulations.find((r) => r.title == node.data.title);
if (additionRegulation) {
node.data.related_regulation_type = additionRegulation.metadata.related_regulation_type;
node.data.related_regulation = additionRegulation._id;
} else {
forbiddenNode.push(node);
}
}
continue;
}
//修订节点
if (node.data.related_regulation_type == 'revise') {
const matchRegulation = regulations.find(
(regulation) => regulation._id == node.data.related_regulation,
);
if (matchRegulation) {
if (!matchRegulation.metadata.related_revision) {
node.data.related_regulation_type = 'active';
}
} else {
forbiddenNode.push(node);
}
continue;
}
//废止节点
if (node.data.related_regulation_type == 'forbidden') {
if (
regulations.some((regulation) => regulation._id == node.data.related_regulation)
) {
const matchRegulation = regulations.find(
(regulation) => regulation._id == node.data.related_regulation,
);
node.data.related_regulation_type = matchRegulation.metadata.related_regulation_type;
} else {
forbiddenNode.push(node);
}
}
}
forbiddenNode.forEach((node) => {
const descendants = get().findDescendants(node, nodes);
deletedNodes.push(...descendants);
});
deletedNodes.forEach((node) => {
const descendants = get().findDescendants(node, nodes);
deletedNodes.push(...descendants);
});
//展示删除节点以外的
deletedNodes = _.uniqBy(deletedNodes, 'id');
deletedNodes.forEach((node) => {
node.data.removed = true;
set({
removedNodes: [...get().removedNodes, node],
});
});
set({
nodes,
});
},
loadGraphData: async () => {
const res = await queryRecords({
subject: 'internal_regulation_graph',
filter: {
name: 'graph_data',
},
});
return (
res.records?.[0]?.metadata?.graph_info || {
nodes: [],
edges: [],
}
);
},
saveGraphData: async () => {
await importRecords('internal_regulation_graph', [
{
title: '节点数据',
name: 'graph_data',
metadata: {
archive: '当前版本',
type: 'active',
graph_info: {
nodes: get().nodes,
edges: get().edges,
},
},
},
]);
},
findAncestors: (node, nodes) => {
let ancestors = [];
let edges = [];
let parent = nodes.find((n) => n.id === node.data.parent);
let target = node;
let source = null;
while (parent) {
source = parent;
ancestors.unshift(parent);
edges.push({
id: `${source.id}-$${target.id}`,
source: source.id,
target: target.id,
});
target = parent;
source = null;
parent = nodes.find((n) => n.id === parent.data.parent);
}
ancestors.push(node);
return {
edges,
ancestors,
};
},
findDescendants: (node, nodes) => {
if (!node) return [];
let descendants = [];
nodes.forEach((n) => {
if (n.data.parent === node.id) {
descendants.push(n);
descendants = descendants.concat(get().findDescendants(n, nodes));
}
});
descendants.push(node);
return descendants;
},
generateCascaderTreeDataByNodes: () => {
const nodes = get().nodes;
const idToNode = {};
nodes.forEach((node) => {
idToNode[node.id] = node;
});
// 找到所有根节点
const rootNodes = nodes.filter((node) => node.data.parent === null);
// 递归地构建树状结构
function buildTree(node) {
const children = nodes
.filter((child) => child.data.parent === node.id)
.map((child) => buildTree(child));
const nodeStructure = {
value: node.id,
label: node.data.title,
};
if (children.length > 0) {
nodeStructure.children = children;
}
return nodeStructure;
}
// 构建整个树状结构
const res = rootNodes.map((rootNode) => buildTree(rootNode));
return res[0]?.children || [];
},
generateNodesByCascader: (value) => {
set({
cascaderValue: value,
});
get().generateAggregateNodes();
},
setCascaderValue: (value) => {
set({
cascaderValue: value,
});
},
clearSelectedRelatedType: () => {
set({
selectedRelateTypes: [],
});
},
exportGraphData: async () => {
const nodes = get().nodes;
const regulationNodes = nodes.filter((n) => n.type == 'regulationNode');
const sheetData = regulationNodes.map((node) => {
return {
制度名称: node.data.title,
描述: node.data.content || null,
};
});
saveRegulationSheet(sheetData, '制度列表.xlsx');
},
fitViewToSelectedNode: async (node) => {
const { ancestors } = get().findAncestors(node, get().nodes);
ancestors.forEach((node) => {
node.data.expanded = true;
get().updateNode(node);
});
get().onNodeClick(null, node);
// todo 待优化
setTimeout(() => {
const currentNode = get().flowRef?.getNode(node.id);
const position = currentNode?.position || null;
if (!position) return;
get().flowRef.setCenter(position.x + 120, position.y + 18, {
duration: 200,
});
}, 500);
},
})),
);
export default useStore;
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useKeyPress, useStore as useReactFlowStore } from 'reactflow';
import graphStore from './store';
export function useCopyPaste() {
const { nodes, aggregateNodes, addNode, addEdge, selectedNode, findDescendants } = graphStore(
(state) => ({
...state,
}),
);
const mousePosRef = useRef({ x: 0, y: 0 });
const rfDomNode = useReactFlowStore((state) => state.domNode);
const [bufferedNode, setBufferedNode] = useState(null);
// Set up the paste buffers to store the copied nodes and edges.
// initialize the copy/paste hook
// 1. remove native copy/paste/cut handlers
// 2. add mouse move handler to keep track of the current mouse position
useEffect(() => {
const events = ['copy', 'paste'];
if (rfDomNode) {
const preventDefault = (e) => e.preventDefault();
const onMouseMove = (event) => {
mousePosRef.current = {
x: event.clientX,
y: event.clientY,
};
};
for (const event of events) {
rfDomNode.addEventListener(event, preventDefault);
}
rfDomNode.addEventListener('mousemove', onMouseMove);
return () => {
for (const event of events) {
rfDomNode.removeEventListener(event, preventDefault);
}
rfDomNode.removeEventListener('mousemove', onMouseMove);
};
}
}, [rfDomNode]);
const copy = useCallback(() => {
setBufferedNode(selectedNode);
}, [selectedNode]);
const paste = useCallback(() => {
if (!bufferedNode || !selectedNode) return;
const nodeId = nanoid(6);
const childNodes = findDescendants(bufferedNode, nodes);
let newNodes = childNodes
.map((node) => {
return {
id: `${node.id}_${nodeId}`,
type: node.type,
draggable: true,
connectable: true,
position: {
x: 0,
y: 0,
},
data: {
id: `${node.id}_${nodeId}`,
parent: `${node.data.parent}_${nodeId}`,
type: node.type,
title: node.data.title,
related_regulation: node.data.related_regulation,
related_revise_regulation: node.data.related_revise_regulation,
related_regulation_type: node.data.related_regulation_type,
content: node.data.content,
},
};
})
.reverse();
newNodes = _.uniqBy(newNodes, 'id');
const parentNode = newNodes.find((node) => node.data.id.includes(bufferedNode.data.id));
parentNode.data.parent = selectedNode.id;
for (const node of newNodes) {
addNode(node);
addEdge({
source: node.data.parent,
target: node.id,
style: {
stroke: '#666',
},
});
}
}, [bufferedNode, selectedNode]);
useShortcut(['Meta+c', 'Control+c'], copy);
useShortcut(['Meta+v', 'Control+v'], paste);
return { copy, paste, bufferedNode, nodes, aggregateNodes };
}
function useShortcut(keyCode, callback) {
const [didRun, setDidRun] = useState(false);
const shouldRun = useKeyPress(keyCode);
useEffect(() => {
if (shouldRun && !didRun) {
callback();
setDidRun(true);
} else {
setDidRun(shouldRun);
}
}, [shouldRun, didRun, callback]);
}
export default useCopyPaste;
......@@ -370,7 +370,7 @@ function RecordActions({ className, record, subjectName, onEdit, onRemove, inclu
<TableActionButton icon={Eye} label="查看" onPress={() => openRecordDetail(subjectName, record)} />
<TableActionButton icon={ArrowDownTrayIcon} isDisabled={!firstFile} label="下载" onPress={() => openFile(firstFile)} />
<TableActionButton icon={Edit} label="编辑" onPress={() => onEdit(record)} />
{includeGraph ? <TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph?subject=${subjectName}&node=${record?._id || record?.name}`)} /> : null}
{includeGraph ? <TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph/${encodeURIComponent(subjectName)}?node=${encodeURIComponent(record?._id || record?.name)}`)} /> : null}
{includeDelete ? <TableActionButton icon={Delete} label="删除" tone="delete" onPress={() => onRemove(record)} /> : null}
</TableActionGroup>
);
......@@ -960,7 +960,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
<TableActionGroup className="justify-center">
<TableActionButton icon={ArrowDownTrayIcon} isDisabled={!firstFile} label="下载" onPress={() => openFile(firstFile)} />
<TableActionButton icon={Edit} label="编辑" onPress={() => onEdit(record)} />
<TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph?subject=${subject.name}&node=${record?._id || record?.name}`)} />
<TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph/${encodeURIComponent(subject.name)}?node=${encodeURIComponent(record?._id || record?.name)}`)} />
</TableActionGroup>
</div>
) : null}
......
import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useLocation, useParams } from '@umijs/max';
import { Button, Spinner } from '@heroui/react';
import { ArrowPathIcon } from '@/components/AppIcons';
import HeroCheckbox from '@/components/HeroCheckbox';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import RecordFlowGraph from '@/components/RecordFlowGraph/RecordFlowGraph';
import { loadRecordGraph } from '@/services/RecordGraphService';
import { ApplicationContext } from './LawWorkbenchLayout';
export default function RecordGraphPage() {
const params = useParams();
const location = useLocation();
const query = new URLSearchParams(location.search);
const subjectName = params.subject || query.get('subject');
const application = useContext(ApplicationContext);
const subjectConfig = application?.layouts?.default?.subjects?.[subjectName];
const groupsKey = JSON.stringify(subjectConfig?.tag?.group?.record || []);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [operationError, setOperationError] = useState('');
const [success, setSuccess] = useState('');
const [centered, setCentered] = useState(false);
const [refresh, setRefresh] = useState(0);
const filterConfig = { _id: query.get('node'), department: query.get('dep'), tag: query.get('tag') };
useEffect(() => {
let active = true;
if (!subjectName) { setLoading(false); setError('未指定关系图的数据对象,请从制度卡片进入'); return; }
setLoading(true); setError('');
loadRecordGraph(subjectName, JSON.parse(groupsKey)).then(result => { if (active) setData(result); })
.catch(err => { if (active) setError(err.message || '关系图加载失败'); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [subjectName, groupsKey, refresh, filterConfig.department, filterConfig.tag, centered]);
const levels = useMemo(() => data?.subject?.fields?.find(field => field.name === 'level')?.settings?.options?.map(option => option.value) || [], [data]);
const list = useMemo(() => (data?.records || []).filter(record =>
(!filterConfig.department || record.metadata?.departments?.includes(filterConfig.department)) &&
(!filterConfig.tag || record.tags?.some(tag => (tag?._id || tag) === filterConfig.tag)),
), [data, filterConfig.department, filterConfig.tag]);
const changeFilter = (field, value) => {
const next = new URLSearchParams();
const values = { tag: filterConfig.tag, dep: filterConfig.department, [field]: value };
Object.entries(values).forEach(([key, item]) => { if (item) next.set(key, item); });
setSuccess(''); setOperationError('');
history.push(`/graph/${encodeURIComponent(subjectName)}${next.size ? `?${next}` : ''}`);
};
return <section className="record-graph-page" aria-label="制度关系图">
<div className="record-graph-filter">
<XinyuanAutocomplete className="w-[220px]" aria-label="标签筛选" placeholder="请选择标签" showSearch={false}
selectedKey={filterConfig.tag} options={(data?.tags || []).map(tag => ({ value: tag._id, label: tag.label }))}
onSelectionChange={value => changeFilter('tag', value)} onClear={() => changeFilter('tag', null)} />
<XinyuanAutocomplete className="w-[220px]" aria-label="部门筛选" placeholder="请选择部门"
selectedKey={filterConfig.department} options={(data?.departments || []).map(value => ({ value, label: value }))}
onSelectionChange={value => changeFilter('dep', value)} onClear={() => changeFilter('dep', null)} />
<HeroCheckbox isSelected={centered} onChange={setCentered}>居中</HeroCheckbox>
<Button isIconOnly variant="secondary" aria-label="刷新关系图" isDisabled={loading} onPress={() => { setRefresh(value => value + 1); setOperationError(''); setSuccess(''); }}><ArrowPathIcon size={16} /></Button>
</div>
{(error || operationError) && <div role="alert" className="flex items-center justify-center gap-3 p-3 text-danger">{error || operationError}<Button size="sm" variant="secondary" onPress={() => setRefresh(value => value + 1)}>重试</Button></div>}
{success && !operationError && <p role="status" className="px-4 py-2 text-sm text-success">{success}</p>}
<div className="relative min-h-0 flex-1">
{loading ? <div className="absolute inset-0 flex items-center justify-center gap-2" role="status"><Spinner />正在加载关系图…</div> : !error && data ? <>
<RecordFlowGraph key={`${subjectName}/${filterConfig._id}/${filterConfig.department}/${filterConfig.tag}/${centered}/${refresh}`}
list={list} levelArr={levels} subject={data.subject} centered={centered} filterConfig={filterConfig}
onError={setOperationError} onSuccess={setSuccess} onReload={() => setRefresh(value => value + 1)} />
{(!list.length || !levels.length) && <p className="pointer-events-none absolute inset-0 flex items-center justify-center text-muted">{!levels.length ? '当前对象未配置层级选项' : '没有符合筛选条件的制度'}</p>}
</> : null}
</div>
</section>;
}
......@@ -65,3 +65,16 @@
点击结果数量进入 `/:subject/:record`;`audit_indicator_list` 的记录详情或 `detail.view: audit_indicator_detail` 显示执行结果页面,提供标题搜索和分页。管理者可通过 Modal 查看执行数据、导出 Excel 和确认删除单条结果;结构化数组使用 DataGrid,非表格数据以格式化 JSON 展示。删除当前页最后一条时自动返回上一页。
验证命令:`node scripts/verify-view-parity.cjs` 校验配置范围、关闭开关、特殊字符编码、安全高亮、引用写入、表单校验和计算视图执行协议;`npm run build` 校验打包。浏览器使用隔离模拟数据验证六种视图及搜索、权限、指标表单,不向真实业务对象写入测试数据。
## 制度卡片关系图(2026-09-29)
入口是内部制度看板展开卡片后,下载、编辑右侧的“关系图”按钮。与 streams-admin 的 `RecordColumn` 一致进入 `/graph/:subject?node=<记录ID>`,页面对应 `RecordGraphView` 的迁移实现 `RecordGraphPage`;兼容已有 `/graph?subject=...&node=...` 地址。
- 读取当前 Subject 的 `level.settings.options`,按配置顺序排列层级标题和制度节点。同层按 `order` 升序;节点宽 240、高 60,布局横向步长 480、纵向步长 110,初始视口按容器宽度缩放。全部记录通过 `loadRecords(subject,0,99999,{},{})` 获取,不只查现行有效制度。
- 连线来源为 `metadata.related_regulations`,方向从来源制度指向被关联制度。筛选后缺失端点的线不渲染。保留 node 参数,但按 streams-admin 当前实际实现不以它裁剪记录或自动聚焦;单击节点高亮直接相邻制度及关联线,单击线高亮两端,单击画布清除高亮;双击制度新窗口打开详情。
- 顶部只有标签、部门、居中和刷新。标签读取配置 `tag.group.record` 对应组,部门聚合 `metadata.departments`。选择或清空筛选更新 URL 的 tag/dep 并重新读取数据,和来源一样清除 node 参数;条件在前端应用。居中使各列按最长列垂直居中,并重新读取记录。刷新重建图;无数据、缺少层级配置和加载失败均明确提示。
- 权限沿用来源 `useAuthorization().admin`:只有 type 为 admin/system 的用户可以拖动节点、创建或改接连线。普通用户可浏览、筛选、高亮和打开详情。新增连线更新来源制度的 `metadata.related_regulations` ID 数组;改接先移除旧关系再建立新关系;拖动线端后在空白处松开删除该关系。重复关系不重复创建。
- 同层拖动改变顺序时按来源算法生成 record name → order 映射,调用 `/record/reorder` 并恢复列内对齐;节点不跨层存储位置。连线/排序成功提示保存成功,失败显示错误并重新读取服务器状态。跨来源改接涉及两次更新,后一步失败时重新加载展示已成功的部分。
- 基础筛选控件使用 HeroUI Autocomplete/Checkbox/Button,节点和贝塞尔线保留 ReactFlow 的实现与交互,不引入其它 UI 框架。此入口没有 XMind 上传、归档版本或“保存画布”功能。
验证:`node scripts/verify-record-graph.cjs` 校验卡片入口、层级和排序、引用 ID、过滤端点、居中坐标及写入协议;浏览器使用隔离模拟数据验证页面和交互,不写入真实制度记录。
import { aggregateRecords, loadRecords, loadSubject, queryTags, updateRecord, reorderRecords } from './DataService';
export async function loadRecordGraph(subjectName, tagGroups) {
const [subject, result, departments, tags] = await Promise.all([
loadSubject(subjectName),
loadRecords(subjectName, 0, 99999, {}, {}),
aggregateRecords({ subject: subjectName, stages: [
{ $unwind: '$metadata.departments' }, { $group: { _id: '$metadata.departments' } },
] }),
queryTags({ page: 0, page_size: 999, groups: Array.isArray(tagGroups) && tagGroups.length ? tagGroups : undefined }),
]);
return { subject, records: result.records || [], departments: departments.map(item => item._id).filter(Boolean), tags: tags.tags || [] };
}
export function saveRecordRelations(subject, node, relatedRegulations) {
return updateRecord(node.id, {
name: node.name, subject: subject.name, title: node.title,
metadata: { ...node.metadata, related_regulations: relatedRegulations },
});
}
export function saveRecordGraphOrder(orderMap) {
return reorderRecords(orderMap);
}
......@@ -231,3 +231,14 @@
| `/tag/query` | POST | `{page:0,page_size:20,keyword}`,返回 `tags`。 |
指标责任人复用 `/record/query` 查询 `department`,分组/指标/结果创建、更新、软删除继续使用本文第 3 节的 Record 接口。指标结果 `execution_time` 保持原应用毫秒时间戳 Number;`related_indicator` 保存指标 `_id` 字符串。所有搜索 URL 参数均编码,避免特殊字符被当作查询参数分隔符。
## 7. 制度卡片关系图
来源为 streams-admin 的 `RecordColumn` → `RecordGraphView` → `RecordFlowGraph`。
- `GET /subject/:name`:读取 `level.settings.options`,按配置顺序生成列。
- `POST /record/query`:沿用 `loadRecords(subject, 0, 99999, {}, {})`,即 `{subject,page:0,page_size:99999,filter:{},sort:{},keyword:null,full:false,select:null}`。不附加“现行有效”限制。
- `POST /record/aggregate`:`{subject,stages:[{$unwind:'$metadata.departments'},{$group:{_id:'$metadata.departments'}}]}`,取得部门选项。
- `POST /tag/query`:`{page:0,page_size:999,groups}`,groups 取应用 Subject 配置 `tag.group.record`,无配置时省略。
- `PUT /record/:id`:连线增删/改接更新来源制度,发送 `{name,subject,title,metadata:{...metadata,related_regulations:ID[]}}`;引用值只发送 ID。
- `POST /record/reorder`:请求体直接为 `{[recordName]:order}`,同层拖动后按来源算法更新相关排序。
......@@ -206,3 +206,17 @@ type Record = {
| 廉洁谈话提纲上传 | `talk_outline_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话提纲 |
| 廉洁提醒支持材料上传 | `reminder_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传提醒材料 |
| 其他活动举措 | `other_measures` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 其他举措说明 |
## 3.2 制度卡片关系图使用字段
本节补充 streams-admin 的 `RecordGraphView/RecordFlowGraph` 已实现协议,不创建或修改远程 Subject;层级选项以运行时 `GET /subject/:name` 为准。
| 字段 | 类型/含义 |
| --- | --- |
| `metadata.level` | 层级,列顺序与值来自 Subject 的 `level.settings.options` |
| `metadata.related_regulations` | 制度引用数组;读取可为含 `_id` 的引用对象,保存仅为 ID 字符串数组 |
| `metadata.departments` | 部门字符串数组,用于聚合选项及前端筛选 |
| `tags` | Record 标签引用数组,按 `_id` 筛选 |
| `_id`、`name`、`title`、`order` | 节点 ID、更新/排序键、显示标题、同层排序值 |
画布由制度记录实时生成,不使用 `internal_regulation_graph`、修订计划、XMind 或独立画布保存记录。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment