Merge remote-tracking branch 'origin/codex/wuyongjie-assisted-assembly-next' into codex/xuxiake-mainline

This commit is contained in:
WuYongJie 2026-04-30 10:45:35 +08:00
commit d2ae88ce23
39 changed files with 2584 additions and 611 deletions

View File

@ -33,6 +33,7 @@
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"lucide-react": "^0.563.0",
"mssql": "^12.5.0",
"nanoid": "^5.1.6",
"next": "^16.1.6",
"obj2gltf": "^3.2.0",
@ -1120,6 +1121,265 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure-rest/core-client": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@azure-rest/core-client/-/core-client-2.6.0.tgz",
"integrity": "sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0",
"@azure/core-tracing": "^1.3.0",
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-auth": {
"version": "1.10.1",
"resolved": "https://registry.npmmirror.com/@azure/core-auth/-/core-auth-1.10.1.tgz",
"integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-util": "^1.13.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-client": {
"version": "1.10.1",
"resolved": "https://registry.npmmirror.com/@azure/core-client/-/core-client-1.10.1.tgz",
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0",
"@azure/core-tracing": "^1.3.0",
"@azure/core-util": "^1.13.0",
"@azure/logger": "^1.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-http-compat": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz",
"integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@azure/core-client": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0"
}
},
"node_modules/@azure/core-lro": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/@azure/core-lro/-/core-lro-2.7.2.tgz",
"integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-util": "^1.2.0",
"@azure/logger": "^1.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-paging": {
"version": "1.6.2",
"resolved": "https://registry.npmmirror.com/@azure/core-paging/-/core-paging-1.6.2.tgz",
"integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.23.0",
"resolved": "https://registry.npmmirror.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz",
"integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
"@azure/core-tracing": "^1.3.0",
"@azure/core-util": "^1.13.0",
"@azure/logger": "^1.3.0",
"@typespec/ts-http-runtime": "^0.3.4",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-tracing": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-util": {
"version": "1.13.1",
"resolved": "https://registry.npmmirror.com/@azure/core-util/-/core-util-1.13.1.tgz",
"integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/identity": {
"version": "4.13.1",
"resolved": "https://registry.npmmirror.com/@azure/identity/-/identity-4.13.1.tgz",
"integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.9.0",
"@azure/core-client": "^1.9.2",
"@azure/core-rest-pipeline": "^1.17.0",
"@azure/core-tracing": "^1.0.0",
"@azure/core-util": "^1.11.0",
"@azure/logger": "^1.0.0",
"@azure/msal-browser": "^5.5.0",
"@azure/msal-node": "^5.1.0",
"open": "^10.1.0",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/keyvault-common": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz",
"integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==",
"license": "MIT",
"dependencies": {
"@azure-rest/core-client": "^2.3.3",
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-rest-pipeline": "^1.8.0",
"@azure/core-tracing": "^1.0.0",
"@azure/core-util": "^1.10.0",
"@azure/logger": "^1.1.4",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/keyvault-keys": {
"version": "4.10.0",
"resolved": "https://registry.npmmirror.com/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz",
"integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==",
"license": "MIT",
"dependencies": {
"@azure-rest/core-client": "^2.3.3",
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-http-compat": "^2.2.0",
"@azure/core-lro": "^2.7.2",
"@azure/core-paging": "^1.6.2",
"@azure/core-rest-pipeline": "^1.19.0",
"@azure/core-tracing": "^1.2.0",
"@azure/core-util": "^1.11.0",
"@azure/keyvault-common": "^2.0.0",
"@azure/logger": "^1.1.4",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/logger": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/@azure/logger/-/logger-1.3.0.tgz",
"integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
"license": "MIT",
"dependencies": {
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/msal-browser": {
"version": "5.9.0",
"resolved": "https://registry.npmmirror.com/@azure/msal-browser/-/msal-browser-5.9.0.tgz",
"integrity": "sha512-CzE+4PefDSJWj26zU7G1bKchlGRRHMBFreG4tAlGuzyI8hAPiYGobaJvZBgZBf6L63iphX7VH+ityL8VgEQz9Q==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.5.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.5.2",
"resolved": "https://registry.npmmirror.com/@azure/msal-common/-/msal-common-16.5.2.tgz",
"integrity": "sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "5.1.5",
"resolved": "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-5.1.5.tgz",
"integrity": "sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.5.2",
"jsonwebtoken": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -3811,6 +4071,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-joda/core": {
"version": "5.7.0",
"resolved": "https://registry.npmmirror.com/@js-joda/core/-/core-5.7.0.tgz",
"integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==",
"license": "BSD-3-Clause"
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmmirror.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
@ -5595,6 +5861,12 @@
"react": "^18 || ^19"
}
},
"node_modules/@tediousjs/connection-string": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@tediousjs/connection-string/-/connection-string-1.1.0.tgz",
"integrity": "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==",
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
@ -5996,6 +6268,15 @@
"@types/react": "*"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmmirror.com/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmmirror.com/@types/ssh2/-/ssh2-1.15.5.tgz",
@ -6391,6 +6672,20 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz",
"integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -6702,6 +6997,18 @@
"node": ">=18.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
@ -7736,6 +8043,21 @@
"node": ">=10.0.0"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@ -8561,6 +8883,34 @@
"node": ">=16.0.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
@ -8579,6 +8929,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
@ -9711,6 +10073,24 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz",
@ -11147,6 +11527,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@ -11225,6 +11620,24 @@
"node": ">=0.10.0"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz",
@ -11456,6 +11869,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz",
@ -12561,6 +12989,12 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-md4": {
"version": "0.3.2",
"resolved": "https://registry.npmmirror.com/js-md4/-/js-md4-0.3.2.tgz",
"integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@ -13702,6 +14136,34 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mssql": {
"version": "12.5.0",
"resolved": "https://registry.npmmirror.com/mssql/-/mssql-12.5.0.tgz",
"integrity": "sha512-nTbhxS1qi5SPwuKygwfRzmp2p6e/2v37ZFzvwvMf27wRSI+09J7J2pP7zaAUzqT4znMyHYBrcUyxkjSeeNyDTg==",
"license": "MIT",
"dependencies": {
"@tediousjs/connection-string": "^1.0.0",
"commander": "^11.0.0",
"debug": "^4.3.3",
"tarn": "^3.0.2",
"tedious": "^19.0.0"
},
"bin": {
"mssql": "bin/mssql"
},
"engines": {
"node": ">=18.19.0"
}
},
"node_modules/mssql/node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
@ -13794,6 +14256,12 @@
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/native-duplexpair": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
"integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==",
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
@ -14177,6 +14645,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmmirror.com/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-inside-container": "^1.0.0",
"wsl-utils": "^0.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
@ -14879,6 +15365,15 @@
}
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -15504,6 +15999,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -16580,6 +17087,86 @@
"node": ">=6"
}
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/tedious": {
"version": "19.2.1",
"resolved": "https://registry.npmmirror.com/tedious/-/tedious-19.2.1.tgz",
"integrity": "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==",
"license": "MIT",
"dependencies": {
"@azure/core-auth": "^1.7.2",
"@azure/identity": "^4.2.1",
"@azure/keyvault-keys": "^4.4.0",
"@js-joda/core": "^5.6.5",
"@types/node": ">=18",
"bl": "^6.1.4",
"iconv-lite": "^0.7.0",
"js-md4": "^0.3.2",
"native-duplexpair": "^1.0.0",
"sprintf-js": "^1.1.3"
},
"engines": {
"node": ">=18.17"
}
},
"node_modules/tedious/node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmmirror.com/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/tedious/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/tedious/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/tedious/node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz",
@ -17704,6 +18291,21 @@
}
}
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",

View File

@ -53,6 +53,7 @@
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"lucide-react": "^0.563.0",
"mssql": "^12.5.0",
"nanoid": "^5.1.6",
"next": "^16.1.6",
"obj2gltf": "^3.2.0",

View File

@ -0,0 +1,22 @@
CREATE TYPE "BusinessAccountType" AS ENUM ('PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER');
ALTER TABLE "User"
ADD COLUMN "businessRoles" "BusinessAccountType"[] NOT NULL DEFAULT ARRAY[]::"BusinessAccountType"[];
CREATE TABLE "EmployeeLookupCache" (
"id" TEXT NOT NULL,
"cacheKey" TEXT NOT NULL,
"keyType" TEXT NOT NULL,
"lookupValue" TEXT NOT NULL,
"result" JSONB,
"sourceStatus" "EmployeeSourceStatus",
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EmployeeLookupCache_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "EmployeeLookupCache_cacheKey_key" ON "EmployeeLookupCache"("cacheKey");
CREATE INDEX "EmployeeLookupCache_keyType_idx" ON "EmployeeLookupCache"("keyType");
CREATE INDEX "EmployeeLookupCache_expiresAt_idx" ON "EmployeeLookupCache"("expiresAt");

View File

@ -938,6 +938,13 @@ enum EmployeeSourceStatus {
DIMISSIONED
}
enum BusinessAccountType {
PLANNING
PURCHASING
WAREHOUSE
SUPPLIER
}
enum AssetType {
BOARD
COMPONENT
@ -998,6 +1005,7 @@ model User {
// 角色与权限
role Role @default(USER)
businessRoles BusinessAccountType[] @default([])
// 组织架构
departmentId String?
@ -1053,6 +1061,23 @@ model User {
@@index([employeeDirectoryId])
}
model EmployeeLookupCache {
id String @id @default(cuid())
cacheKey String @unique
keyType String
lookupValue String
result Json?
sourceStatus EmployeeSourceStatus?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([keyType])
@@index([expiresAt])
}
// 部门
model Department {
id String @id @default(cuid())

View File

@ -0,0 +1,118 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { AppError, ErrorCode } from '@/server/core/errors'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockCreateUser: any = jest.fn()
const mockCreateLog: any = jest.fn()
const mockMatchByJobNumber: any = jest.fn()
const mockResolveDepartmentIdByExternalId: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services/user.service', () => ({
userService: {
create: (...args: unknown[]) => mockCreateUser(...args),
},
}))
jest.mock('@/server/services/log.service', () => ({
logService: {
create: (...args: unknown[]) => mockCreateLog(...args),
},
}))
jest.mock('@/server/services/external-employee-directory.service', () => ({
externalEmployeeDirectoryService: {
matchByJobNumber: (...args: unknown[]) => mockMatchByJobNumber(...args),
resolveDepartmentIdByExternalId: (...args: unknown[]) => mockResolveDepartmentIdByExternalId(...args),
},
}))
const { POST } = require('./route')
function createRequest(body: unknown) {
return {
json: jest.fn(async () => body),
} as any
}
describe('POST /api/auth/register', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({ requestId: 'req-register' }),
)
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
data,
status,
requestId,
}))
})
it('工号匹配到在职员工后创建待激活账号', async () => {
mockMatchByJobNumber.mockResolvedValue({
id: 'emp-1',
jobNumber: '1001',
name: '张三',
mobile: '13800000000',
sourceStatus: 'EMPLOYED',
dingtalkUserId: 'dt-1',
dingtalkUnionId: 'union-1',
primaryDepartmentExternalId: 'dept-ext-1',
})
mockResolveDepartmentIdByExternalId.mockResolvedValue('dept-1')
mockCreateUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
name: 'zhangsan',
role: 'USER',
employeeDirectoryId: 'emp-1',
})
const response = await POST(createRequest({
email: 'user@example.com',
name: 'zhangsan',
jobNumber: '1001',
password: '123456',
}))
expect(mockMatchByJobNumber).toHaveBeenCalledWith('1001')
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
name: 'zhangsan',
role: 'USER',
status: 'PENDING',
departmentId: 'dept-1',
employeeDirectoryId: 'emp-1',
dingtalkUserId: 'dt-1',
dingtalkUnionId: 'union-1',
}),
)
expect(response).toEqual(expect.objectContaining({ success: true, status: 201 }))
})
it('工号未匹配时拒绝注册,不创建账号', async () => {
mockMatchByJobNumber.mockResolvedValue(null)
await expect(POST(createRequest({
email: 'user@example.com',
name: 'zhangsan',
jobNumber: 'not-found',
password: '123456',
}))).rejects.toEqual(
expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '未找到在职员工信息,请核对工号或联系管理员',
}) as AppError,
)
expect(mockCreateUser).not.toHaveBeenCalled()
})
})

View File

@ -7,8 +7,10 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { AppError, ErrorCode } from '@/server/core/errors'
import { userService } from '@/server/services/user.service'
import { logService } from '@/server/services/log.service'
import { externalEmployeeDirectoryService } from '@/server/services/external-employee-directory.service'
/**
* 注册请求体
@ -17,6 +19,7 @@ interface RegisterRequest {
email: string
name: string
password: string
jobNumber: string
// role 参数被忽略,注册只允许创建 USER 角色
departmentId?: string
teamId?: string
@ -30,7 +33,19 @@ export async function POST(request: NextRequest) {
request,
async (ctx) => {
const body: RegisterRequest = await request.json()
const { email, name, password, departmentId, teamId } = body
const { email, name, password, jobNumber } = body
const employee = await externalEmployeeDirectoryService.matchByJobNumber(jobNumber)
if (!employee || employee.sourceStatus !== 'EMPLOYED') {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'未找到在职员工信息,请核对工号或联系管理员'
)
}
const departmentId = await externalEmployeeDirectoryService.resolveDepartmentIdByExternalId(
employee.primaryDepartmentExternalId
)
// 调用服务层创建用户
// 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
@ -40,9 +55,13 @@ export async function POST(request: NextRequest) {
password,
role: REGISTER_DEFAULT_ROLE,
status: 'PENDING',
departmentId,
teamId,
departmentId: departmentId ?? undefined,
isFirstLogin: false,
phone: employee.mobile ?? undefined,
employeeDirectoryId: employee.id,
dingtalkUserId: employee.dingtalkUserId,
dingtalkUnionId: employee.dingtalkUnionId,
dingtalkBoundAt: employee.dingtalkUserId || employee.dingtalkUnionId ? new Date() : null,
})
// 记录操作日志
@ -54,6 +73,7 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
role: user.role,
employeeDirectoryId: user.employeeDirectoryId,
},
})

View File

@ -3,6 +3,7 @@ import type { Role } from '@prisma/client'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
import { normalizeBusinessRoles } from '@/lib/business-account-types'
export async function POST(
request: NextRequest,
@ -12,13 +13,16 @@ export async function POST(
const { id } = await params
const body = await request.json().catch(() => ({} as Record<string, unknown>))
const role = typeof body.role === 'string' ? (body.role as Role) : undefined
const businessRoles = Array.isArray(body.businessRoles)
? normalizeBusinessRoles(body.businessRoles)
: undefined
const departmentId = typeof body.departmentId === 'string' ? body.departmentId : body.departmentId === null ? null : undefined
const teamId = typeof body.teamId === 'string' ? body.teamId : body.teamId === null ? null : undefined
try {
const user = await userManagementService.activateUser(
id,
{ role, departmentId, teamId },
{ role, businessRoles, departmentId, teamId },
ctx.user
)
return jsonSuccess({ message: '用户激活成功', user }, 200, ctx.requestId)

View File

@ -0,0 +1,94 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { ErrorCode } from '@/server/core/errors'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockJsonError: any = jest.fn()
const mockRefreshEmployeeMatch: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
jest.mock('@/server/services/user-management.service', () => ({
userManagementService: {
refreshEmployeeMatch: (...args: unknown[]) => mockRefreshEmployeeMatch(...args),
},
}))
const { POST } = require('./route')
function createRequest(body?: unknown) {
return {
json: jest.fn(async () => body),
} as any
}
describe('POST /api/users/[id]/employee-match', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({
requestId: 'req-test',
user: {
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN',
status: 'ACTIVE',
},
}),
)
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
data,
status,
requestId,
}))
mockJsonError.mockImplementation((code, message, status, requestId) => ({
success: false,
code,
message,
status,
requestId,
}))
})
it('按工号触发管理员手动员工库匹配', async () => {
mockRefreshEmployeeMatch.mockResolvedValue({
id: 'user-1',
employeeDirectoryId: 'emp-1',
})
const response = await POST(createRequest({ jobNumber: '1001' }), {
params: Promise.resolve({ id: 'user-1' }),
})
expect(mockRefreshEmployeeMatch).toHaveBeenCalledWith(
'user-1',
{ jobNumber: '1001' },
expect.objectContaining({ id: 'admin-1' }),
)
expect(response).toEqual(expect.objectContaining({ success: true, status: 200 }))
})
it('缺少工号时直接返回验证错误', async () => {
const response = await POST(createRequest({}), {
params: Promise.resolve({ id: 'user-1' }),
})
expect(mockRefreshEmployeeMatch).not.toHaveBeenCalled()
expect(response).toEqual(
expect.objectContaining({
success: false,
code: ErrorCode.VALIDATION_REQUIRED_FIELD,
message: '请输入工号',
status: 400,
}),
)
})
})

View File

@ -0,0 +1,34 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return handleRequest(request, async (ctx) => {
const { id } = await params
const body = await request.json().catch(() => ({} as Record<string, unknown>))
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber.trim() : ''
if (!jobNumber) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'请输入工号',
400,
ctx.requestId
)
}
try {
const user = await userManagementService.refreshEmployeeMatch(id, { jobNumber }, ctx.user)
return jsonSuccess({ message: '员工库匹配已更新', user }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
})
}

View File

@ -8,9 +8,10 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { AppError, ErrorCode } from '@/server/core/errors'
import { AppError } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
import { normalizeBusinessRoles } from '@/lib/business-account-types'
export async function GET(
request: NextRequest,
@ -42,16 +43,18 @@ export async function PUT(
name?: string
password?: string
role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string | null
teamId?: string | null
phone?: string | null
status?: UserStatus
}
const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
try {
const updatedUser = await userManagementService.updateUser(
id,
{ email, name, password, role, departmentId, teamId, phone, status },
{ email, name, password, role, businessRoles, departmentId, teamId, phone, status },
ctx.user
)

View File

@ -7,7 +7,7 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
import type { AllowedRole } from '@/lib/role-scope'
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
@ -19,10 +19,11 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const search = searchParams.get('search') || undefined
const role = (searchParams.get('role') as Role | null) || undefined
const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
const status = (searchParams.get('status') as UserStatus | null) || undefined
const tree = await userManagementService.getOrganizationTree(
{ search, role, status },
{ search, role, businessRole, status },
ctx.user
)

View File

@ -9,8 +9,9 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess, jsonSuccessWithPagination, jsonError } from '@/server/core/response'
import { AppError, ErrorCode } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service'
import type { Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
import type { AllowedRole } from '@/lib/role-scope'
import { normalizeBusinessRoles } from '@/lib/business-account-types'
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
@ -24,10 +25,11 @@ export async function GET(request: NextRequest) {
const search = searchParams.get('search') || undefined
const role = searchParams.get('role') as Role | undefined
const status = searchParams.get('status') as UserStatus | undefined
const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
const departmentId = searchParams.get('departmentId') || undefined
const result = await userManagementService.listUsers(
{ page, limit, search, role, status, departmentId },
{ page, limit, search, role, businessRole, status, departmentId },
ctx.user
)
@ -51,6 +53,8 @@ export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json()
const { email, name, password, role, departmentId, teamId, phone } = body
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber : undefined
const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
if (!email || !name || !password) {
return jsonError(
@ -62,10 +66,13 @@ export async function POST(request: NextRequest) {
}
try {
const newUser = await userManagementService.createUser(
{ email, name, password, role, departmentId, teamId, phone },
ctx.user
)
const createInput = { email, name, password, role, departmentId, teamId, phone }
const normalizedCreateInput = {
...createInput,
...(businessRoles ? { businessRoles } : {}),
...(jobNumber ? { jobNumber } : {}),
}
const newUser = await userManagementService.createUser(normalizedCreateInput, ctx.user)
return jsonSuccess({ message: '用户创建成功', user: newUser }, 201, ctx.requestId)
} catch (error) {

View File

@ -317,8 +317,8 @@
--color-red: #9f403d;
--color-orange: #c47a2a;
--color-yellow: #b8860b;
--color-green: #405054;
--color-mint: #647174;
--color-green: #286c34;
--color-mint: #4b9e7a;
--color-teal: #4b626e;
--color-cyan: #3d8fa3;
--color-blue: #546067;
@ -362,9 +362,9 @@
--liquid-glass-blur-enhanced: blur(30px);
/* 反馈色 */
--feedback-success-bg: rgba(64, 80, 84, 0.08);
--feedback-success-border: rgba(64, 80, 84, 0.18);
--feedback-success-text: #405054;
--feedback-success-bg: rgba(40, 108, 52, 0.08);
--feedback-success-border: rgba(40, 108, 52, 0.2);
--feedback-success-text: #286c34;
--feedback-error-bg: rgba(159, 64, 61, 0.08);
--feedback-error-border: rgba(159, 64, 61, 0.2);
--feedback-error-text: #9f403d;
@ -448,8 +448,8 @@
--color-red: #fe8983;
--color-orange: #ffb366;
--color-yellow: #ffd700;
--color-green: #405054;
--color-mint: #647174;
--color-green: #a6eea7;
--color-mint: #7ecbaa;
--color-teal: #bfd8e5;
--color-cyan: #7ec8d9;
--color-blue: #c9d6de;
@ -493,9 +493,9 @@
--liquid-glass-blur-enhanced: blur(30px);
/* 反馈色 */
--feedback-success-bg: rgba(64, 80, 84, 0.08);
--feedback-success-border: rgba(64, 80, 84, 0.18);
--feedback-success-text: #405054;
--feedback-success-bg: rgba(166, 238, 167, 0.12);
--feedback-success-border: rgba(166, 238, 167, 0.25);
--feedback-success-text: #a6eea7;
--feedback-error-bg: rgba(254, 137, 131, 0.12);
--feedback-error-border: rgba(254, 137, 131, 0.25);
--feedback-error-text: #fe8983;
@ -632,14 +632,14 @@
:root,
[data-theme='light'] {
--obs-shell-bg: #f4f5f5;
--obs-shell-bg: #eef2f3;
--obs-shell-text: #263033;
--obs-shell-panel-bg: #ffffff;
--obs-shell-content-bg: #f7f7f6;
--obs-shell-header-bg: #fbfbfa;
--obs-shell-content-bg: #f4f7f7;
--obs-shell-header-bg: #f8faf9;
--obs-shell-surface-bg: #ffffff;
--obs-shell-muted-bg: #f1f2f2;
--obs-shell-rail-bg: #f0f1f1;
--obs-shell-muted-bg: #edf2f2;
--obs-shell-rail-bg: #e5ebeb;
--obs-shell-border: rgba(17, 23, 25, 0.12);
--obs-shell-title: #101719;
--obs-shell-muted: #5f6d70;
@ -653,66 +653,67 @@
--obs-shell-icon-text: #2d393c;
--obs-shell-rail-title: #687477;
--obs-shell-rail-text: #3f4a4d;
--obs-shell-rail-hover-bg: rgba(17, 23, 25, 0.055);
--obs-shell-rail-hover-bg: rgba(17, 23, 25, 0.06);
--obs-shell-rail-hover-text: #101719;
--obs-shell-rail-active-bg: #ffffff;
--obs-shell-rail-active-text: #101719;
--obs-shell-rail-active-bg: #101719;
--obs-shell-rail-active-text: #f7f9f9;
--obs-shell-status-text: #566265;
--obs-shell-status-muted: #6d787b;
--obs-shell-status-strong: #101719;
--obs-shell-status-pill-bg: rgba(17, 23, 25, 0.055);
--obs-shell-action-bg: #ffffff;
--obs-shell-action-text: #101719;
--obs-shell-action-border: rgba(17, 23, 25, 0.34);
--obs-shell-action-hover-bg: #f2f3f3;
--obs-shell-rail-card-bg: rgba(17, 23, 25, 0.035);
--obs-shell-action-bg: #101719;
--obs-shell-action-text: #f7f9f9;
--obs-shell-action-border: rgba(17, 23, 25, 0.16);
--obs-shell-action-hover-bg: #263033;
--obs-shell-rail-card-bg: rgba(17, 23, 25, 0.045);
--obs-shell-rail-card-text: #101719;
--obs-shell-rail-card-muted: #687477;
--obs-shell-success: #405054;
--obs-shell-success: #1f7a56;
--obs-shell-warning: #9a6a16;
--obs-shell-danger: #b3453d;
}
[data-theme='dark'] {
--obs-shell-bg: #f4f5f5;
--obs-shell-text: #263033;
--obs-shell-panel-bg: #ffffff;
--obs-shell-content-bg: #f7f7f6;
--obs-shell-header-bg: #fbfbfa;
--obs-shell-surface-bg: #ffffff;
--obs-shell-muted-bg: #f1f2f2;
--obs-shell-rail-bg: #f0f1f1;
--obs-shell-border: rgba(17, 23, 25, 0.12);
--obs-shell-title: #101719;
--obs-shell-muted: #5f6d70;
--obs-shell-subtle: #7a878a;
--obs-shell-hover-bg: rgba(17, 23, 25, 0.055);
--obs-shell-control-bg: rgba(17, 23, 25, 0.045);
--obs-shell-control-hover-bg: rgba(17, 23, 25, 0.08);
--obs-shell-control-text: #4f5c5f;
--obs-shell-control-hover-text: #101719;
--obs-shell-icon-bg: rgba(17, 23, 25, 0.06);
--obs-shell-icon-text: #2d393c;
--obs-shell-rail-title: #687477;
--obs-shell-rail-text: #3f4a4d;
--obs-shell-rail-hover-bg: rgba(17, 23, 25, 0.055);
--obs-shell-rail-hover-text: #101719;
--obs-shell-bg: #111719;
--obs-shell-text: #e8edef;
--obs-shell-panel-bg: #111719;
--obs-shell-content-bg: #0f1416;
--obs-shell-header-bg: #101719;
--obs-shell-surface-bg: #151d1f;
--obs-shell-muted-bg: #202a2d;
--obs-shell-rail-bg: #111719;
--obs-shell-border: rgba(255, 255, 255, 0.10);
--obs-shell-title: #f4f7f6;
--obs-shell-muted: #aeb8bb;
--obs-shell-subtle: #8f9a9d;
--obs-shell-hover-bg: rgba(255, 255, 255, 0.055);
--obs-shell-control-bg: rgba(255, 255, 255, 0.04);
--obs-shell-control-hover-bg: rgba(255, 255, 255, 0.08);
--obs-shell-control-text: #aeb8bb;
--obs-shell-control-hover-text: #ffffff;
--obs-shell-icon-bg: rgba(255, 255, 255, 0.06);
--obs-shell-icon-text: #dfe6e8;
--obs-shell-rail-bg: #171d1f;
--obs-shell-rail-title: #748083;
--obs-shell-rail-text: #c4cccf;
--obs-shell-rail-hover-bg: rgba(255, 255, 255, 0.06);
--obs-shell-rail-hover-text: #ffffff;
--obs-shell-rail-active-bg: #ffffff;
--obs-shell-rail-active-text: #101719;
--obs-shell-status-text: #566265;
--obs-shell-status-muted: #6d787b;
--obs-shell-status-strong: #101719;
--obs-shell-status-pill-bg: rgba(17, 23, 25, 0.055);
--obs-shell-rail-active-text: #111719;
--obs-shell-status-text: #aeb8bb;
--obs-shell-status-muted: #748083;
--obs-shell-status-strong: #ffffff;
--obs-shell-status-pill-bg: rgba(255, 255, 255, 0.04);
--obs-shell-action-bg: #ffffff;
--obs-shell-action-text: #101719;
--obs-shell-action-border: rgba(17, 23, 25, 0.34);
--obs-shell-action-hover-bg: #f2f3f3;
--obs-shell-rail-card-bg: rgba(17, 23, 25, 0.035);
--obs-shell-rail-card-text: #101719;
--obs-shell-rail-card-muted: #687477;
--obs-shell-success: #405054;
--obs-shell-warning: #9a6a16;
--obs-shell-danger: #b3453d;
--obs-shell-action-text: #111719;
--obs-shell-action-border: rgba(255, 255, 255, 0.10);
--obs-shell-action-hover-bg: #e8edef;
--obs-shell-rail-card-bg: rgba(255, 255, 255, 0.04);
--obs-shell-rail-card-text: #ffffff;
--obs-shell-rail-card-muted: #748083;
--obs-shell-success: #9bdcc5;
--obs-shell-warning: #e5bd72;
--obs-shell-danger: #f3a29c;
}
.obsidian-control-grid {
@ -735,12 +736,6 @@
background: var(--obs-shell-action-hover-bg) !important;
}
.obsidian-rail-active {
background: var(--obs-shell-rail-active-bg) !important;
box-shadow: inset 3px 0 0 var(--obs-shell-title);
color: var(--obs-shell-rail-active-text) !important;
}
.obsidian-control-rail-card {
border-color: var(--obs-shell-border);
background: var(--obs-shell-rail-card-bg);
@ -772,7 +767,8 @@
}
.obsidian-control-shell > div > section > header,
.obsidian-control-shell > div > aside:first-of-type > div:first-child {
.obsidian-control-shell > div > aside > div:first-child,
.obsidian-control-shell [class*="bg-[#101719]"] {
background: var(--obs-shell-header-bg) !important;
border-color: var(--obs-shell-border) !important;
}
@ -832,17 +828,22 @@
.obsidian-control-shell [class*="text-[#101517]"],
.obsidian-control-shell [class*="text-[#101719]"] {
color: var(--obs-shell-title) !important;
color: var(--obs-shell-rail-active-text) !important;
}
.obsidian-control-shell [class*="text-white"] {
color: var(--obs-shell-action-text) !important;
color: var(--obs-shell-title) !important;
}
.obsidian-control-shell [class*="hover:text-white"]:hover {
color: var(--obs-shell-rail-hover-text) !important;
}
.obsidian-control-shell [class*="text-emerald-200"],
.obsidian-control-shell [class*="text-[#9bdcc5]"] {
color: var(--obs-shell-success) !important;
}
.obsidian-control-shell [class*="text-amber-200"],
.obsidian-control-shell [class*="text-[#e5bd72]"] {
color: var(--obs-shell-warning) !important;
@ -1350,84 +1351,9 @@
}
[data-theme='dark'] .reduced-transparency {
--liquid-glass-standard: rgba(255, 255, 255, 0.95);
--liquid-glass-enhanced: rgba(255, 255, 255, 1);
--liquid-glass-subtle: rgba(255, 255, 255, 0.9);
}
/* 白曜石已固定为浅色工作台;旧 dark 标记只按浅色 token 解释,避免旧偏好污染新平台。 */
html[data-theme='dark'],
[data-theme='dark'] {
--color-red: #9f403d;
--color-orange: #c47a2a;
--color-yellow: #b8860b;
--color-green: #405054;
--color-mint: #5f6d70;
--color-teal: #4b626e;
--color-cyan: #3d8fa3;
--color-blue: #546067;
--color-background-primary: #f9f9fb;
--color-background-secondary: #f2f4f6;
--color-background-tertiary: #ffffff;
--color-label-primary: #2d3338;
--color-label-secondary: #596065;
--color-label-tertiary: rgba(45, 51, 56, 0.6);
--color-label-quaternary: rgba(45, 51, 56, 0.25);
--color-separator: rgba(172, 179, 184, 0.3);
--color-opaque-separator: #acb3b8;
--color-link: #4b626e;
--color-button-primary-start: #ffffff;
--color-button-primary-end: #ffffff;
--color-button-primary-text: #101719;
--feedback-success-bg: rgba(64, 80, 84, 0.08);
--feedback-success-border: rgba(64, 80, 84, 0.18);
--feedback-success-text: #405054;
--color-surface: #f9f9fb;
--color-surface-container: #ebeef2;
--color-surface-container-high: #e4e9ee;
--color-surface-container-highest: #dde3e9;
--color-surface-container-low: #f2f4f6;
--color-surface-container-lowest: #ffffff;
--color-outline: #757c81;
--color-outline-variant: #acb3b8;
--liquid-glass-standard: rgba(255, 255, 255, 0.70);
--liquid-glass-enhanced: rgba(255, 255, 255, 0.85);
--liquid-glass-subtle: rgba(255, 255, 255, 0.60);
--ml-bg: #f6f8f9;
--ml-rail: #fbfcfc;
--ml-canvas: #eef2f3;
--ml-workbench: #f7f9f9;
--ml-panel: #ffffff;
--ml-text: #263033;
--ml-muted: #708287;
--ml-accent: #48686b;
--ml-accent-bg: rgba(72, 104, 107, 0.12);
--ml-border: rgba(38, 48, 51, 0.10);
--ml-glass: rgba(255, 255, 255, 0.74);
--ml-row: rgba(255, 255, 255, 0.76);
--ml-row-hover: rgba(255, 255, 255, 0.94);
--obs-shell-bg: #f4f5f5;
--obs-shell-text: #263033;
--obs-shell-panel-bg: #ffffff;
--obs-shell-content-bg: #f7f7f6;
--obs-shell-header-bg: #fbfbfa;
--obs-shell-surface-bg: #ffffff;
--obs-shell-muted-bg: #f1f2f2;
--obs-shell-rail-bg: #f0f1f1;
--obs-shell-border: rgba(17, 23, 25, 0.12);
--obs-shell-title: #101719;
--obs-shell-muted: #5f6d70;
--obs-shell-subtle: #7a878a;
--obs-shell-hover-bg: rgba(17, 23, 25, 0.055);
--obs-shell-rail-active-bg: #ffffff;
--obs-shell-rail-active-text: #101719;
--obs-shell-action-bg: #ffffff;
--obs-shell-action-text: #101719;
--obs-shell-action-border: rgba(17, 23, 25, 0.34);
--obs-shell-action-hover-bg: #f2f3f3;
--obs-shell-success: #405054;
--obs-shell-warning: #9a6a16;
--obs-shell-danger: #b3453d;
--liquid-glass-standard: rgba(22, 22, 23, 0.95);
--liquid-glass-enhanced: rgba(22, 22, 23, 1);
--liquid-glass-subtle: rgba(22, 22, 23, 0.9);
}
/* 动态字体大小 */

View File

@ -27,6 +27,7 @@ export default function RegisterPage() {
const abortControllerRef = useRef<AbortController | null>(null);
const [name, setName] = useState('');
const [jobNumber, setJobNumber] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@ -60,6 +61,7 @@ export default function RegisterPage() {
body: JSON.stringify({
email,
name,
jobNumber,
password,
}),
signal: abortController.signal,
@ -134,6 +136,20 @@ export default function RegisterPage() {
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-5">
<Input
label="工号"
id="jobNumber"
type="text"
placeholder="请输入员工工号"
value={jobNumber}
onChange={(event) => setJobNumber(event.target.value)}
required
helperText="系统会用工号匹配员工库,匹配成功后仍需管理员激活"
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
/>
<Input
label="姓名"
id="name"

View File

@ -1,18 +1,16 @@
'use client'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useCallback, useMemo, useState } from 'react'
import type { Role } from '@prisma/client'
import type { LucideIcon } from 'lucide-react'
import {
Activity,
Building2,
Briefcase,
Edit,
Fingerprint,
KeyRound,
Mail,
Phone,
RefreshCw,
Search,
ShieldCheck,
UserPlus,
@ -27,13 +25,17 @@ import { ActivateUserModal } from '@/components/users/ActivateUserModal'
import { CreateUserModal } from '@/components/users/CreateUserModal'
import { EditUserModal } from '@/components/users/EditUserModal'
import { HandoverModal } from '@/components/users/HandoverModal'
import { OrganizationManageModal } from '@/components/users/OrganizationManageModal'
import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
import { canAssignRoleByHierarchy, getAssignableRolesForRole } from '@/lib/role-scope'
import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
import { useDebounce } from '@/hooks/usePerformance'
const ROLE_OPTIONS = [
@ -86,9 +88,11 @@ function UsersPageContent() {
const { user: currentUser } = useAuth()
const [searchQuery, setSearchQuery] = useState('')
const [roleFilter, setRoleFilter] = useState('')
const [businessRoleFilter, setBusinessRoleFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [refreshToken, setRefreshToken] = useState(0)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showOrganizationModal, setShowOrganizationModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserTreeItem | null>(null)
const [editingUser, setEditingUser] = useState<UserTreeItem | null>(null)
const [activationUser, setActivationUser] = useState<UserTreeItem | null>(null)
@ -128,6 +132,7 @@ function UsersPageContent() {
}, [])
const closeCreateModal = useCallback(() => setShowCreateModal(false), [])
const closeOrganizationModal = useCallback(() => setShowOrganizationModal(false), [])
const closeEditModal = useCallback(() => setEditingUser(null), [])
const closeActivateModal = useCallback(() => setActivationUser(null), [])
const closeHandoverModal = useCallback(() => setHandoverUser(null), [])
@ -137,6 +142,10 @@ function UsersPageContent() {
triggerRefresh()
}, [closeCreateModal, triggerRefresh])
const handleOrganizationSuccess = useCallback(() => {
triggerRefresh()
}, [triggerRefresh])
const handleEditSuccess = useCallback(() => {
closeEditModal()
triggerRefresh()
@ -199,6 +208,31 @@ function UsersPageContent() {
[canActivatePendingUser],
)
const handleEmployeeMatch = useCallback(
async (user: UserTreeItem) => {
const jobNumber = window.prompt(`请输入 ${user.name} 的工号`)
if (!jobNumber?.trim()) return
try {
setActionError(null)
const response = await fetch(`/api/users/${user.id}/employee-match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ jobNumber: jobNumber.trim() }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload.error?.message || '员工库匹配失败')
}
triggerRefresh()
} catch (error) {
setActionError(error instanceof Error ? error.message : '员工库匹配失败')
}
},
[triggerRefresh],
)
const selectedUserAllowedRoles = useMemo<Role[]>(() => {
if (!editingUser || !currentUser) return []
if (editingUser.id === currentUser.id) {
@ -207,15 +241,6 @@ function UsersPageContent() {
return assignableRoles
}, [assignableRoles, currentUser, editingUser])
const activeRoleLabel = useMemo(
() => ROLE_OPTIONS.find((option) => option.value === roleFilter)?.label ?? '所有权限角色',
[roleFilter],
)
const activeStatusLabel = useMemo(
() => STATUS_OPTIONS.find((option) => option.value === statusFilter)?.label ?? '全状态',
[statusFilter],
)
return (
<ObsidianControlShell
eyebrow="权限"
@ -224,15 +249,26 @@ function UsersPageContent() {
backHref="/admin"
backLabel="后台设置"
actions={
<Button
variant="plain"
size="small"
onClick={() => setShowCreateModal(true)}
className="obsidian-control-action rounded-md border"
>
<UserPlus className="h-4 w-4" />
开设账号
</Button>
<div className="flex items-center gap-2">
<Button
variant="plain"
size="small"
onClick={() => setShowOrganizationModal(true)}
className="obsidian-control-action rounded-md border"
>
<Building2 className="h-4 w-4" />
组织
</Button>
<Button
variant="plain"
size="small"
onClick={() => setShowCreateModal(true)}
className="obsidian-control-action rounded-md border"
>
<UserPlus className="h-4 w-4" />
开设账号
</Button>
</div>
}
rail={
<div className="flex h-full flex-col gap-6">
@ -258,112 +294,100 @@ function UsersPageContent() {
/>
))}
</ObsidianRailGroup>
<ObsidianRailGroup title="业务类型">
<ObsidianRailButton
active={businessRoleFilter === ''}
icon={Briefcase}
label="全部类型"
onClick={() => setBusinessRoleFilter('')}
/>
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
<ObsidianRailButton
key={option.value}
active={businessRoleFilter === option.value}
icon={Briefcase}
label={option.label}
onClick={() => setBusinessRoleFilter(option.value)}
/>
))}
</ObsidianRailGroup>
</div>
}
inspector={
<div className="flex h-full flex-col bg-white text-[#152024]">
{selectedUser ? (
<>
<div className="border-b border-[#dce3e4] bg-[#fbfbfa] px-4 py-4">
<p className="text-[10px] font-black text-[#687477]">身份元件</p>
<div className="mt-3 flex items-start gap-3">
<IdentityPlate name={selectedUser.name} email={selectedUser.email} />
<div className="min-w-0 flex-1">
<h2 className="truncate text-base font-black text-[#101719]">{selectedUser.name}</h2>
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs font-bold text-[#647174]">
<StatusDot status={selectedUser.status} />
<span className="truncate">{roleLabels[selectedUser.role] ?? selectedUser.role}</span>
<span className="text-[#6b777a]">/</span>
<span>{statusLabels[selectedUser.status] ?? selectedUser.status}</span>
</div>
</div>
<div className="border-b border-[#dce3e4] bg-[#f8faf9] px-5 py-5">
<div className="flex h-12 w-12 items-center justify-center rounded-[6px] border border-[#d8e0e1] bg-white text-[#405054]">
<Fingerprint className="h-6 w-6" />
</div>
<h2 className="mt-4 truncate text-lg font-black text-[#152024]">{selectedUser.name}</h2>
<p className="mt-1 text-[11px] font-bold text-[#607073]">
{roleLabels[selectedUser.role] ?? selectedUser.role}
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<InspectorBlock title="基础通讯">
<ObsidianInspectorSection title="基础通讯">
<InspectorItem icon={Mail} label="工作邮箱" value={selectedUser.email} />
<InspectorItem icon={Phone} label="联系电话" value={selectedUser.phone || '未配置'} />
</InspectorBlock>
<InspectorBlock title="组织架构">
</ObsidianInspectorSection>
<ObsidianInspectorSection title="组织架构">
<InspectorItem icon={Briefcase} label="所属部门" value={selectedUser.department?.name || '未归属'} />
<InspectorItem icon={Users} label="所属小组" value={selectedUser.team?.name || '未归属'} />
</InspectorBlock>
<InspectorBlock title="安全审计">
<InspectorMetric label="账号状态" value={statusLabels[selectedUser.status] ?? selectedUser.status} tone={selectedUser.status === 'ACTIVE' ? 'default' : 'warning'} />
<InspectorMetric label="登录来源" value={selectedUser.loginSource || '系统账号'} />
<InspectorMetric label="创建时间" value={formatDate(selectedUser.createdAt)} />
<InspectorMetric label="最后登录" value={selectedUser.lastLoginAt ? formatDate(selectedUser.lastLoginAt) : '暂无记录'} />
</InspectorBlock>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-[#dce3e4] bg-[#fbfcfc] p-3">
{canEditManagedUser(selectedUser) ? (
<InspectorAction icon={Edit} label="编辑" onClick={() => handleEditUser(selectedUser)} />
) : null}
{selectedUser.status === 'PENDING' && canActivatePendingUser(selectedUser) ? (
<InspectorAction icon={ShieldCheck} label="激活" onClick={() => handleActivateUser(selectedUser)} />
) : null}
{selectedUser.id !== currentUser?.id && selectedUser.status === 'ACTIVE' ? (
<InspectorAction icon={RefreshCw} label="交接" onClick={() => setHandoverUser(selectedUser)} />
) : null}
</div>
<InspectorItem
icon={Briefcase}
label="业务类型"
value={(selectedUser.businessRoles || []).length > 0
? selectedUser.businessRoles!.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role as keyof typeof BUSINESS_ACCOUNT_TYPE_LABELS] ?? role).join(' / ')
: '未设置'}
/>
</ObsidianInspectorSection>
<ObsidianInspectorSection title="员工库">
<ObsidianDataRow label="匹配状态" value={selectedUser.employeeDirectoryId ? '已匹配员工库' : '未匹配员工库'} tone={selectedUser.employeeDirectoryId ? 'success' : 'warning'} />
<ObsidianDataRow label="工号" value={selectedUser.employeeDirectory?.jobNumber || '未记录'} />
<ObsidianDataRow label="员工姓名" value={selectedUser.employeeDirectory?.name || '未记录'} />
<ObsidianDataRow label="钉钉" value={selectedUser.loginSource === 'DINGTALK' ? '已绑定' : '未绑定'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="安全审计">
<ObsidianDataRow label="账号状态" value={statusLabels[selectedUser.status] ?? selectedUser.status} tone={selectedUser.status === 'ACTIVE' ? 'success' : 'warning'} />
<ObsidianDataRow label="登录来源" value={selectedUser.loginSource || '系统账号'} />
<ObsidianDataRow label="创建时间" value={formatDate(selectedUser.createdAt)} />
<ObsidianDataRow label="最后登录" value={selectedUser.lastLoginAt ? formatDate(selectedUser.lastLoginAt) : '暂无记录'} />
</ObsidianInspectorSection>
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[6px] border border-[#d8e0e1] bg-[#f7f9f9] text-[#647174]">
<Fingerprint className="h-6 w-6" />
</div>
<Fingerprint className="h-7 w-7 text-[#6b777a]" />
<h3 className="mt-4 text-sm font-black text-[#152024]">未选择账号</h3>
<p className="mt-2 text-xs font-medium text-[#607073]">选择一个身份元件后查看权限、组织与审计状态</p>
<p className="mt-2 text-xs font-medium text-[#607073]">点击中间列表中的人员后查看权限和组织信息</p>
</div>
)}
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#f5f5f4]">
<header className="border-b border-[#dce3e4] bg-white/72 px-3 py-2 backdrop-blur-xl">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[260px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#7a878a]" />
<input
type="text"
placeholder="搜索姓名、邮箱或 UID"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="h-9 w-full rounded-[6px] border border-[#d6dee0] bg-white pl-9 pr-3 text-sm font-semibold text-[#101719] outline-none transition placeholder:text-[#8b9699] focus:border-[#101719]"
/>
</div>
<FilterToken label="角色" value={activeRoleLabel} active={Boolean(roleFilter)} />
<FilterToken label="状态" value={activeStatusLabel} active={Boolean(statusFilter)} />
<button
type="button"
onClick={triggerRefresh}
className="flex h-9 w-9 items-center justify-center rounded-[6px] border border-[#d6dee0] bg-white text-[#526164] transition hover:border-[#101719] hover:bg-[#f2f3f3] hover:text-[#101719]"
aria-label="刷新权限数据"
title="刷新权限数据"
>
<RefreshCw className="h-4 w-4" />
</button>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#f4f7f7]">
<header className="border-b border-[#dce3e4] bg-[#f8faf9] px-4 py-3">
<div className="relative w-full max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6b777a]" />
<input
type="text"
placeholder="搜索姓名、邮箱或 UID"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="h-10 w-full rounded-[6px] border border-[#d6dee0] bg-white pl-10 pr-4 text-sm font-semibold text-[#152024] outline-none transition placeholder:text-[#7a878a] focus:border-[#152024] focus:bg-white"
/>
</div>
</header>
<div className="min-h-0 overflow-hidden p-2">
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[6px] border border-[#d9ddde] bg-white">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-[#e4e6e7] bg-[#fbfbfa] px-3">
<div className="flex min-w-0 items-center gap-2">
<Building2 className="h-4 w-4 text-[#647174]" />
<span className="text-xs font-black text-[#101719]">身份元件工作台</span>
</div>
<span className="text-[11px] font-bold text-[#687477]">组织树 / 权限矩阵</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{actionError ? (
<Alert type="error" variant="subtle" title="权限操作被拦截" description={actionError} className="mb-3 rounded-[6px]" />
) : null}
<div className="min-h-0 overflow-y-auto p-3">
{actionError ? (
<Alert type="error" variant="subtle" title="权限操作被拦截" description={actionError} className="mb-4 rounded-md" />
) : null}
<div>
<DepartmentAccessTree
searchQuery={debouncedSearchQuery}
roleFilter={roleFilter}
businessRoleFilter={businessRoleFilter}
statusFilter={statusFilter}
refreshToken={refreshToken}
currentUserId={currentUser?.id}
@ -372,18 +396,25 @@ function UsersPageContent() {
onSelect={handleSelectUser}
onActivate={handleActivateUser}
onHandover={setHandoverUser}
onEmployeeMatch={handleEmployeeMatch}
canEditUser={canEditManagedUser}
canActivateUser={canActivatePendingUser}
selectedUserId={selectedUser?.id}
/>
</div>
</section>
</div>
</div>
</div>
{showCreateModal ? (
<CreateUserModal isOpen={showCreateModal} allowedRoles={assignableRoles} onClose={closeCreateModal} onSuccess={handleCreateSuccess} />
) : null}
{showOrganizationModal ? (
<OrganizationManageModal
isOpen={showOrganizationModal}
onClose={closeOrganizationModal}
onSuccess={handleOrganizationSuccess}
/>
) : null}
{activationUser ? (
<ActivateUserModal
isOpen={!!activationUser}
@ -412,7 +443,7 @@ function UsersPageContent() {
function InspectorItem({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-[6px] border border-[#e1e7e8] bg-[#fbfcfc] px-3 py-3">
<div className="flex items-center gap-3 rounded-[6px] border border-[#dce3e4] bg-white px-3 py-3">
<Icon className="h-4 w-4 shrink-0 text-[#6b777a]" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-[#607073]">{label}</p>
@ -422,106 +453,6 @@ function InspectorItem({ icon: Icon, label, value }: { icon: LucideIcon; label:
)
}
function FilterToken({ label, value, active }: { label: string; value: string; active?: boolean }) {
return (
<div
className={`flex h-9 items-center gap-1.5 rounded-[6px] border px-2.5 text-[11px] font-bold ${
active
? 'border-[#101719] bg-[#f3f4f4] text-[#101719]'
: 'border-[#d6dbdc] bg-white text-[#5f6d70]'
}`}
>
<span className={active ? 'text-[#4f5b5e]' : 'text-[#5f6d70]'}>{label}</span>
<span>{value}</span>
</div>
)
}
function IdentityPlate({ name, email }: { name: string; email: string }) {
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[6px] border border-[#d0d9db] bg-white text-[11px] font-black text-[#263033]">
{getInitials(name, email)}
</div>
)
}
function StatusDot({ status }: { status: string }) {
const tone = getStatusTone(status)
return <span className={`h-2 w-2 shrink-0 rounded-full ${tone.dot}`} />
}
function InspectorBlock({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="border-b border-[#e1e7e8] px-4 py-4 last:border-b-0">
<h3 className="mb-3 text-[10px] font-black text-[#687477]">{title}</h3>
<div className="space-y-2">{children}</div>
</section>
)
}
function InspectorMetric({
label,
value,
tone = 'default',
}: {
label: string
value: string
tone?: 'default' | 'success' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] border border-[#e1e7e8] bg-[#fbfcfc] px-3 py-2.5">
<span className="text-[11px] font-bold text-[#647174]">{label}</span>
<span
className={`min-w-0 truncate text-right text-xs font-black ${
tone === 'success'
? 'text-[#405054]'
: tone === 'warning'
? 'text-[#8a641b]'
: 'text-[#101719]'
}`}
>
{value}
</span>
</div>
)
}
function InspectorAction({
icon: Icon,
label,
onClick,
}: {
icon: LucideIcon
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-[6px] border border-[#d6dee0] bg-white px-2 text-xs font-black text-[#263033] transition hover:border-[#b8c5c8] hover:bg-[#f5f8f8]"
>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</button>
)
}
function getInitials(name: string, email: string) {
const source = (name || email || '?').trim().replace(/\s+/g, '')
const chars = Array.from(source)
if (chars.length === 0) return '?'
if (/^[a-zA-Z]/.test(chars[0])) return chars.slice(0, 2).join('').toUpperCase()
return chars.slice(0, 2).join('')
}
function getStatusTone(status: string) {
if (status === 'ACTIVE') return { dot: 'bg-[#405054]' }
if (status === 'PENDING') return { dot: 'bg-[#c18a22]' }
if (status === 'SUSPENDED') return { dot: 'bg-[#b3453d]' }
return { dot: 'bg-[#9aa7aa]' }
}
function formatDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value

View File

@ -110,29 +110,30 @@ export function SidebarUserCenter() {
}
return (
<div ref={containerRef} className="relative border-t border-[#d8dcde] px-2 py-2">
<div ref={containerRef} className="relative px-2 py-2 border-t border-apple-separator/10">
{isOpen ? (
<div
className="absolute bottom-full left-2 right-2 mb-3 overflow-hidden rounded-[8px] shadow-[0_16px_32px_rgba(16,24,28,0.14)] animate-in fade-in slide-in-from-bottom-2 duration-300"
className="absolute bottom-full left-2 right-2 mb-3 rounded-apple-xl shadow-apple-2xl animate-in fade-in slide-in-from-bottom-2 duration-300 overflow-hidden"
style={{
backgroundColor: '#fbfbfa',
border: '1px solid #d8dcde',
backgroundColor: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(24px) saturate(160%)',
border: '1px solid rgba(255,255,255,0.4)',
}}
>
{/* SECTION 1: Account Info */}
<div className="border-b border-[#d8dcde] px-5 py-4">
<div className="px-5 py-4 border-b border-apple-separator/5">
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="text-apple-sm font-apple-bold text-apple-label-primary tracking-tight">
{currentUser?.name ?? '加载中...'}
</span>
{currentUser && (
<span className="rounded-[4px] border border-[#d8dcde] bg-[#f2f3f3] px-1.5 py-0.5 text-[9px] font-apple-black uppercase tracking-widest text-[#405054]">
<span className="px-1.5 py-0.5 rounded-apple-sm bg-apple-blue/10 text-apple-blue text-[9px] font-apple-black uppercase tracking-widest">
{roleLabelMap[currentUser.role] ?? '用户'}
</span>
)}
</div>
<span className="truncate text-[10px] font-apple-medium text-[#647174]">
<span className="text-[10px] text-apple-label-tertiary font-apple-medium truncate">
{currentUser?.email ?? '正在获取账号信息...'}
</span>
</div>
@ -140,13 +141,13 @@ export function SidebarUserCenter() {
{/* SECTION 2: Workspace Shortcuts */}
<div className="p-1.5 space-y-0.5">
<p className="px-3.5 py-2 text-[9px] font-apple-black uppercase tracking-[0.18em] text-[#6b777a]">
<p className="px-3.5 py-2 text-[9px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em]">
工作区
</p>
<Link
href="/?tab=products"
onClick={() => setIsOpen(false)}
className="group flex items-center gap-3 rounded-[6px] px-3.5 py-2 text-apple-xs font-apple-bold text-[#405054] transition-all hover:bg-[#f2f3f3] hover:text-[#101719]"
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
>
<Box size={14} className="opacity-40 group-hover:opacity-100" />
正式产品
@ -154,7 +155,7 @@ export function SidebarUserCenter() {
<Link
href="/?tab=drafts"
onClick={() => setIsOpen(false)}
className="group flex items-center gap-3 rounded-[6px] px-3.5 py-2 text-apple-xs font-apple-bold text-[#405054] transition-all hover:bg-[#f2f3f3] hover:text-[#101719]"
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
>
<FileEdit size={14} className="opacity-40 group-hover:opacity-100" />
我的草稿
@ -162,13 +163,13 @@ export function SidebarUserCenter() {
</div>
{/* SECTION 3: Admin & Actions */}
<div className="border-t border-[#d8dcde] bg-[#f5f5f4] p-1.5">
<div className="p-1.5 border-t border-apple-separator/5 bg-black/[0.02]">
{isAdmin && (
<>
<Link
href="/admin"
onClick={() => setIsOpen(false)}
className="group flex items-center gap-3 rounded-[6px] px-3.5 py-2 text-apple-xs font-apple-bold text-[#405054] transition-all hover:bg-[#fbfbfa] hover:text-[#101719]"
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
>
<LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" />
管理总控制台
@ -179,7 +180,7 @@ export function SidebarUserCenter() {
<Link
href="/users"
onClick={() => setIsOpen(false)}
className="group flex items-center gap-3 rounded-[6px] px-3.5 py-2 text-apple-xs font-apple-bold text-[#405054] transition-all hover:bg-[#fbfbfa] hover:text-[#101719]"
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
>
<Users size={14} className="opacity-40 group-hover:opacity-100" />
用户权限管理
@ -189,7 +190,7 @@ export function SidebarUserCenter() {
type="button"
onClick={handleLogout}
disabled={isLoggingOut}
className="group flex w-full items-center gap-3 rounded-[6px] px-3.5 py-2 text-apple-xs font-apple-bold text-[#8a3f3f] transition-all hover:bg-[#f4eded]"
className="w-full flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-red hover:bg-apple-red/5 transition-all group"
>
<LogOut size={14} className="opacity-60 group-hover:opacity-100" />
<span>{isLoggingOut ? '退出中...' : '退出登录'}</span>
@ -204,15 +205,15 @@ export function SidebarUserCenter() {
aria-expanded={isOpen}
onClick={() => setIsOpen((current) => !current)}
className={cn(
"flex w-full items-center gap-3 rounded-[8px] px-3 py-2.5 transition-all duration-300",
"flex w-full items-center gap-3 rounded-apple-lg px-3 py-2.5 transition-all duration-300",
isOpen
? "bg-white shadow-[inset_3px_0_0_#101719,0_8px_18px_rgba(16,24,28,0.08)] text-[#101719]"
: "text-[#405054] hover:bg-white hover:text-[#101719]"
? "bg-white shadow-apple-sm text-apple-blue"
: "text-apple-label-secondary hover:bg-white/50"
)}
>
<div className="relative">
<UserCircle2 size={18} strokeWidth={1.5} />
{currentUser && <div className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full border-2 border-[#f2f4f6] bg-[#263033]" />}
{currentUser && <div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 border-2 border-[#f2f4f6]" />}
</div>
<span className="min-w-0 flex-1 truncate text-left text-apple-xs font-apple-bold tracking-tight">
用户中心

View File

@ -37,47 +37,47 @@ export function ObsidianControlShell({
inspectorClassName,
}: ObsidianControlShellProps) {
return (
<div className={cn('obsidian-control-shell white-obsidian-shell h-full min-h-0 bg-[#f4f5f5] p-2 text-[#263033]', className)}>
<div className={cn('obsidian-control-shell h-full min-h-0 bg-[#0d1113] p-3 text-[#e7eeec]', className)}>
<div
className={cn(
'grid h-full min-h-0 gap-2',
rail && inspector && 'lg:grid-cols-[208px_minmax(0,1fr)_320px]',
rail && !inspector && 'lg:grid-cols-[208px_minmax(0,1fr)]',
!rail && inspector && 'lg:grid-cols-[minmax(0,1fr)_320px]',
'grid h-full min-h-0 gap-3',
rail && inspector && 'lg:grid-cols-[224px_minmax(0,1fr)_336px]',
rail && !inspector && 'lg:grid-cols-[224px_minmax(0,1fr)]',
!rail && inspector && 'lg:grid-cols-[minmax(0,1fr)_336px]',
)}
>
{rail ? (
<aside className="flex min-h-0 flex-col rounded-[6px] border border-[#d8dcde] bg-[#f0f1f1]">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[#d8dcde] bg-[#fbfbfa] px-3">
<aside className="flex min-h-0 flex-col rounded-md border border-white/10 bg-[#111719]">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 px-3">
{backHref ? (
<Link
href={backHref}
aria-label={backLabel}
className="flex h-8 w-8 items-center justify-center rounded-[6px] border border-[#d0d9db] bg-white text-[#526164] transition hover:border-[#b9c5c8] hover:bg-[#f6f8f8] hover:text-[#101719]"
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.03] text-[#9ba8a8] transition hover:bg-white/[0.07] hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
</Link>
) : null}
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#687477]">{eyebrow}</p>
<p className="truncate text-xs font-black text-[#172024]">{backLabel}</p>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#788586]">{eyebrow}</p>
<p className="truncate text-xs font-black text-[#dce7e4]">{backLabel}</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{rail}</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">{rail}</div>
</aside>
) : null}
<section className={cn('flex min-h-0 flex-col overflow-hidden rounded-[6px] border border-[#d6dddf] bg-white', contentClassName)}>
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-[#d6dddf] bg-white/72 px-4 backdrop-blur-xl">
<section className={cn('flex min-h-0 flex-col overflow-hidden rounded-md border border-white/10 bg-[#101517]', contentClassName)}>
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-white/10 bg-[#111719] px-4">
<div className="flex min-w-0 items-center gap-3">
{Icon ? (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] border border-[#d3d8da] bg-[#f6f6f5] text-[#405054]">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-white/10 bg-white/[0.04] text-[#cbd8d5]">
<Icon className="h-4 w-4" />
</div>
) : null}
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#687477]">{eyebrow}</p>
<h1 className="truncate text-base font-black text-[#101719]">{title}</h1>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#788586]">{eyebrow}</p>
<h1 className="truncate text-base font-black text-[#f4f7f6]">{title}</h1>
</div>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
@ -86,7 +86,7 @@ export function ObsidianControlShell({
</section>
{inspector ? (
<aside className={cn('min-h-0 overflow-hidden rounded-[6px] border border-[#d8dcde] bg-white', inspectorClassName)}>
<aside className={cn('min-h-0 overflow-hidden rounded-md border border-white/10 bg-[#111719]', inspectorClassName)}>
{inspector}
</aside>
) : null}
@ -98,7 +98,7 @@ export function ObsidianControlShell({
export function ObsidianRailGroup({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="px-2 text-[10px] font-black text-[#4f5b5e]">{title}</div>
<div className="px-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#748083]">{title}</div>
<div className="space-y-1">{children}</div>
</div>
)
@ -120,10 +120,10 @@ export function ObsidianRailButton({
onClick?: () => void
}) {
const className = cn(
'flex w-full items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition',
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition',
active
? 'obsidian-rail-active'
: 'text-[#465356] hover:bg-[#fbfbfa] hover:text-[#101719] hover:shadow-[inset_3px_0_0_rgba(17,23,25,0.14)]',
? 'bg-[#e7eeec] text-[#101517]'
: 'text-[#c0ccca] hover:bg-white/[0.06] hover:text-white',
)
const content = (
<>
@ -160,13 +160,13 @@ export function ObsidianPanel({
className?: string
}) {
return (
<section className={cn('rounded-[6px] border border-[#d6dddf] bg-white', className)}>
<section className={cn('rounded-md border border-white/10 bg-[#111719]', className)}>
{title || eyebrow ? (
<header className="border-b border-[#e1e7e8] px-4 py-3">
<header className="border-b border-white/10 px-4 py-3">
{eyebrow ? (
<p className="text-[10px] font-black text-[#687477]">{eyebrow}</p>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#748083]">{eyebrow}</p>
) : null}
{title ? <h2 className="mt-1 text-sm font-black text-[#101719]">{title}</h2> : null}
{title ? <h2 className="mt-1 text-sm font-black text-[#edf4f2]">{title}</h2> : null}
</header>
) : null}
{children}
@ -182,8 +182,8 @@ export function ObsidianInspectorSection({
children: ReactNode
}) {
return (
<section className="border-b border-[#e1e7e8] px-4 py-4 last:border-b-0">
<h3 className="mb-3 text-[10px] font-black text-[#687477]">
<section className="border-b border-white/10 px-4 py-4 last:border-b-0">
<h3 className="mb-3 text-[10px] font-black uppercase tracking-[0.18em] text-[#7f8d8e]">
{title}
</h3>
<div className="space-y-2">{children}</div>
@ -201,14 +201,14 @@ export function ObsidianDataRow({
tone?: 'default' | 'success' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] border border-[#e1e4e5] bg-[#fbfbfa] px-3 py-2.5">
<span className="text-[11px] font-bold text-[#647174]">{label}</span>
<div className="flex items-center justify-between gap-4 rounded-md border border-white/10 bg-[#151d1f] px-3 py-2.5">
<span className="text-[11px] font-bold text-[#8f9c9d]">{label}</span>
<span
className={cn(
'min-w-0 truncate text-right text-xs font-black',
tone === 'success' && 'text-[#405054]',
tone === 'warning' && 'text-[#8a641b]',
tone === 'default' && 'text-[#101719]',
tone === 'success' && 'text-[#9bdcc5]',
tone === 'warning' && 'text-[#e5bd72]',
tone === 'default' && 'text-[#edf4f2]',
)}
>
{value}
@ -223,18 +223,18 @@ export function ObsidianStatusBar({
items: Array<{ label: string; value: string; tone?: 'default' | 'success' | 'warning' }>
}) {
return (
<div className="flex flex-wrap items-center gap-2 text-[11px] text-[#647174]">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-[#aeb8bb]">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2 rounded-[6px] border border-[#d6dddf] bg-white px-3 py-2">
<div key={item.label} className="flex items-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 py-2">
<span
className={cn(
'h-1.5 w-1.5 rounded-full bg-[#748083]',
item.tone === 'success' && 'bg-[#405054]',
item.tone === 'success' && 'bg-[#27c083]',
item.tone === 'warning' && 'bg-[#f2b84b]',
)}
/>
<span className="font-bold text-[#647174]">{item.label}</span>
<span className="font-black text-[#101719]">{item.value}</span>
<span className="font-bold text-[#748083]">{item.label}</span>
<span className="font-black text-white">{item.value}</span>
</div>
))}
</div>

View File

@ -118,7 +118,7 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
'shadow-apple-md',
'border border-white/10',
'hover:opacity-90 hover:shadow-apple-lg hover:scale-[1.02]',
'focus:border-2 focus:border-[#101719]',
'focus:border-2 focus:border-apple-blue/30',
'disabled:bg-apple-bg-secondary disabled:text-apple-label-tertiary disabled:shadow-none disabled:border-apple-separator/50 disabled:opacity-100',
],
tinted: [

View File

@ -93,7 +93,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
'hover:bg-apple-bg-tertiary hover:border-apple-label-tertiary',
'focus:bg-white', // 聚焦变白
// 聚焦:仅 2px 品牌色边框,无光晕,极简有力
'focus:border-2 focus:border-[#101719] focus:outline-hidden',
'focus:border-2 focus:border-apple-blue focus:outline-hidden',
// 微调 Padding 防止 border-2 导致跳动 (常规 px-4=16px -> 15px)
'focus:px-[15px] focus:py-[11px]',
],
@ -101,7 +101,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
'bg-transparent',
'border border-apple-separator',
'hover:border-apple-label-tertiary',
'focus:border-2 focus:border-[#101719] focus:outline-hidden',
'focus:border-2 focus:border-apple-blue focus:outline-hidden',
'focus:px-[15px] focus:py-[11px]',
],
}
@ -115,7 +115,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
className: 'border-apple-red focus:border-apple-red',
},
success: {
className: 'border-[#405054] focus:border-[#405054]',
className: 'border-apple-green focus:border-apple-green',
},
warning: {
className: 'border-apple-orange focus:border-apple-orange',
@ -247,14 +247,14 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
'border border-apple-separator', // 默认 1px 可见灰色边框
'hover:bg-apple-bg-tertiary hover:border-apple-label-tertiary',
'focus:bg-white', // 聚焦变白
'focus:border-2 focus:border-[#101719] focus:outline-hidden',
'focus:border-2 focus:border-apple-blue focus:outline-hidden',
'focus:px-[15px] focus:py-[11px]',
],
outlined: [
'bg-transparent',
'border border-apple-separator',
'hover:border-apple-label-tertiary',
'focus:border-2 focus:border-[#101719] focus:outline-hidden',
'focus:border-2 focus:border-apple-blue focus:outline-hidden',
'focus:px-[15px] focus:py-[11px]',
],
}
@ -267,7 +267,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
className: 'border-apple-red focus:border-apple-red',
},
success: {
className: 'border-[#405054] focus:border-[#405054]',
className: 'border-apple-green focus:border-apple-green',
},
warning: {
className: 'border-apple-orange focus:border-apple-orange',

View File

@ -166,11 +166,11 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
'bg-apple-bg-secondary',
'border border-apple-separator',
'hover:bg-apple-bg-tertiary hover:border-apple-label-tertiary',
open ? 'bg-white border-2 border-[#101719]' : 'border border-apple-separator',
open ? 'bg-white border-2 border-apple-blue' : 'border border-apple-separator',
),
outlined: cn(
'bg-transparent',
open ? 'border-2 border-[#101719]' : 'border border-apple-separator',
open ? 'border-2 border-apple-blue' : 'border border-apple-separator',
!open && 'hover:border-apple-label-tertiary',
),
}
@ -262,12 +262,12 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition-colors',
!hasValue
? 'bg-[#f2f3f3] text-[#101719]'
? 'text-apple-blue bg-apple-blue/5'
: 'text-apple-label-tertiary hover:bg-apple-bg-secondary',
)}
>
<span className="flex-1 truncate">{placeholder}</span>
{!hasValue && <Check className="w-3.5 h-3.5 shrink-0 text-[#101719]" />}
{!hasValue && <Check className="w-3.5 h-3.5 shrink-0 text-apple-blue" />}
</li>
)}
{options.map((option) => {
@ -289,7 +289,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
? 'opacity-40 cursor-not-allowed'
: 'cursor-pointer',
!option.disabled && isSelected
? 'bg-[#f2f3f3] text-[#101719]'
? 'text-apple-blue bg-apple-blue/5'
: !option.disabled && isHighlighted
? 'bg-apple-bg-secondary text-apple-label-primary'
: 'text-apple-label-primary hover:bg-apple-bg-secondary',
@ -297,7 +297,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
>
<span className="flex-1 truncate">{option.label}</span>
{isSelected && (
<Check className="w-3.5 h-3.5 shrink-0 text-[#101719]" />
<Check className="w-3.5 h-3.5 shrink-0 text-apple-blue" />
)}
</li>
)

View File

@ -1,12 +1,13 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { Role } from '@prisma/client';
import type { BusinessAccountType, Role } from '@prisma/client';
import { AlertCircle, ShieldCheck } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { getApiErrorMessage } from '@/lib/utils';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
interface DepartmentItem {
id: string;
@ -24,6 +25,7 @@ interface ActivationUser {
id: string;
name: string;
role: string;
businessRoles?: BusinessAccountType[];
departmentId?: string | null;
teamId?: string | null;
}
@ -53,6 +55,7 @@ export function ActivateUserModal({
}: ActivateUserModalProps) {
const [formData, setFormData] = useState({
role: (user.role as Role) || allowedRoles[0] || 'USER',
businessRoles: user.businessRoles || [],
departmentId: user.departmentId || '',
teamId: user.teamId || '',
});
@ -76,12 +79,22 @@ export function ActivateUserModal({
: allowedRoles[0] || 'USER';
setFormData({
role: nextRole,
businessRoles: user.businessRoles || [],
departmentId: user.departmentId || '',
teamId: user.teamId || '',
});
setErrors({});
}, [allowedRoles, user]);
const toggleBusinessRole = (role: BusinessAccountType) => {
setFormData((current) => ({
...current,
businessRoles: current.businessRoles.includes(role)
? current.businessRoles.filter((item) => item !== role)
: [...current.businessRoles, role],
}));
};
useEffect(() => {
const loadDepartments = async () => {
try {
@ -156,6 +169,7 @@ export function ActivateUserModal({
credentials: 'include',
body: JSON.stringify({
role: formData.role,
businessRoles: formData.businessRoles,
departmentId: formData.departmentId || null,
teamId: formData.teamId || null,
}),
@ -187,7 +201,7 @@ export function ActivateUserModal({
<div className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-[#405054]" />
<ShieldCheck className="h-4 w-4 text-apple-blue" />
<h4 className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-secondary">
激活确认
</h4>
@ -237,6 +251,23 @@ export function ActivateUserModal({
]}
className="bg-white"
/>
<div className="md:col-span-2">
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">
业务类型
</p>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
<input
type="checkbox"
checked={formData.businessRoles.includes(option.value)}
onChange={() => toggleBusinessRole(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-apple-separator/5 pt-4">

View File

@ -5,12 +5,13 @@
'use client';
import { useState, useEffect } from 'react';
import type { Role } from '@prisma/client';
import type { BusinessAccountType, Role } from '@prisma/client';
import Modal from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import Button from '@/components/ui/Button';
import { AlertCircle } from 'lucide-react';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
import { getApiErrorMessage } from '@/lib/utils';
interface CreateUserModalProps {
@ -27,6 +28,11 @@ interface DepartmentItem {
children?: DepartmentItem[];
}
interface TeamItem {
id: string;
name: string;
}
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
@ -39,9 +45,11 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
const [formData, setFormData] = useState({
email: '',
name: '',
jobNumber: '',
password: '',
confirmPassword: '',
role: (allowedRoles[0] || 'USER') as Role,
businessRoles: [] as BusinessAccountType[],
phone: '',
departmentId: '',
teamId: '',
@ -50,6 +58,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
const [teams, setTeams] = useState<TeamItem[]>([]);
useEffect(() => {
setFormData((current) => ({
@ -87,6 +96,43 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
}
}, [isOpen]);
useEffect(() => {
const loadTeams = async () => {
if (!formData.departmentId) {
setTeams([]);
return;
}
try {
const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
credentials: 'include',
});
if (!response.ok) {
setTeams([]);
return;
}
const data = await response.json();
setTeams(data.data?.teams || data.teams || []);
} catch (error) {
console.error('加载小组列表失败:', error);
setTeams([]);
}
};
if (isOpen) {
void loadTeams();
}
}, [formData.departmentId, isOpen]);
const toggleBusinessRole = (role: BusinessAccountType) => {
setFormData((current) => ({
...current,
businessRoles: current.businessRoles.includes(role)
? current.businessRoles.filter((item) => item !== role)
: [...current.businessRoles, role],
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@ -95,6 +141,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
if (!formData.email) newErrors.email = '请输入邮箱';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) newErrors.email = '邮箱格式不正确';
if (!formData.name) newErrors.name = '请输入姓名';
if (!formData.jobNumber) newErrors.jobNumber = '请输入工号';
if (!formData.password) newErrors.password = '请输入密码';
else if (formData.password.length < 6) newErrors.password = '密码至少6位';
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = '两次密码输入不一致';
@ -113,8 +160,10 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
body: JSON.stringify({
email: formData.email,
name: formData.name,
jobNumber: formData.jobNumber,
password: formData.password,
role: formData.role,
businessRoles: formData.businessRoles,
phone: formData.phone || null,
departmentId: formData.departmentId || null,
teamId: formData.teamId || null,
@ -152,6 +201,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-6">
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">基础信息</h4>
<Input label="工号" value={formData.jobNumber} onChange={(e) => setFormData({ ...formData, jobNumber: e.target.value })} placeholder="员工工号" required errorText={errors.jobNumber} className="bg-white" />
<Input label="用户姓名" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="张三" required errorText={errors.name} className="bg-white" />
<Input label="电子邮箱" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required errorText={errors.email} className="bg-white" />
<Input label="联系电话" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} placeholder="13800138000" className="bg-white" />
@ -169,12 +219,31 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
{ value: '', label: '未分配部门' },
...departments.map((dept) => ({ value: dept.id, label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}` })),
]} className="bg-white" />
<Select label="所属小组" value={formData.teamId} onChange={(e) => setFormData({ ...formData, teamId: e.target.value })} options={[
{ value: '', label: '未分配小组' },
...teams.map((team) => ({ value: team.id, label: team.name })),
]} className="bg-white" />
<div>
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">业务类型</p>
<div className="grid grid-cols-2 gap-2">
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
<input
type="checkbox"
checked={formData.businessRoles.includes(option.value)}
onChange={() => toggleBusinessRole(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
</div>
</div>
<div className="space-y-5 rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="flex items-center gap-2 mb-1">
<div className="h-1.5 w-1.5 rounded-full bg-[#405054]" />
<div className="w-1.5 h-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">安全信息</h4>
</div>
<p className="text-[11px] text-apple-label-secondary">

View File

@ -2,6 +2,7 @@
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { BusinessAccountType } from '@prisma/client';
import {
Building2,
ChevronDown,
@ -24,6 +25,17 @@ export interface UserTreeItem {
loginSource?: string;
activationRequired?: boolean;
employeeDirectoryId?: string | null;
employeeDirectory?: {
id: string;
jobNumber: string | null;
name: string;
mobile: string | null;
email: string | null;
title: string | null;
sourceStatus: string;
syncedAt: string;
} | null;
businessRoles?: BusinessAccountType[];
avatar?: string;
phone?: string;
departmentId?: string | null;
@ -84,6 +96,7 @@ interface DepartmentNode {
interface DepartmentAccessTreeProps {
searchQuery: string;
roleFilter: string;
businessRoleFilter: string;
statusFilter: string;
refreshToken: number;
currentUserId?: string;
@ -93,6 +106,7 @@ interface DepartmentAccessTreeProps {
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
onHandover: (user: UserTreeItem) => void;
onEmployeeMatch: (user: UserTreeItem) => Promise<void> | void;
canEditUser?: (user: UserTreeItem) => boolean;
canActivateUser?: (user: UserTreeItem) => boolean;
}
@ -107,7 +121,7 @@ interface SectionHeaderProps {
function SummaryBadge({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex h-5 items-center gap-1.5 rounded-[5px] bg-[#f2f3f2] px-2 text-[10px] font-bold text-[#4f5b5e]">
<span className="inline-flex h-6 items-center gap-1.5 rounded-[5px] border border-[#dce3e4] bg-[#f7f9f9] px-2 text-[10px] font-bold text-[#607073]">
<span>{label}</span>
<span className="font-black text-[#152024]">{value}</span>
</span>
@ -119,7 +133,7 @@ function SectionHeader({ icon, title, count, isOpen, onToggle }: SectionHeaderPr
<button
type="button"
onClick={onToggle}
className="flex h-8 w-full items-center gap-2 rounded-[5px] px-2 text-left transition-colors hover:bg-[#f3f4f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
className="flex h-8 w-full items-center gap-2 rounded-[5px] px-2 text-left transition-colors hover:bg-[#f0f4f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<ChevronDown
size={14}
@ -129,7 +143,7 @@ function SectionHeader({ icon, title, count, isOpen, onToggle }: SectionHeaderPr
<span className="text-[11px] font-black text-[#384548]">
{title}
</span>
<span className="ml-auto rounded-[4px] bg-[#eceeed] px-1.5 py-0.5 text-[10px] font-black text-[#4f5b5e]">
<span className="ml-auto rounded-[4px] bg-[#e9eeee] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{count} 人
</span>
</button>
@ -138,7 +152,7 @@ function SectionHeader({ icon, title, count, isOpen, onToggle }: SectionHeaderPr
function UserLedgerHeader() {
return (
<div className="hidden border-b border-[#e6e7e7] bg-[#fbfbfa] px-3 py-1.5 text-[10px] font-black text-[#6a7679] xl:grid xl:grid-cols-[minmax(230px,1.25fr)_minmax(180px,0.95fr)_minmax(150px,0.8fr)_minmax(112px,0.58fr)_90px] xl:items-center">
<div className="hidden border-b border-[#e6ecec] bg-[#f7f9f9] px-3 py-2 text-[10px] font-black text-[#6a7679] xl:grid xl:grid-cols-[minmax(220px,1.25fr)_minmax(170px,0.95fr)_minmax(140px,0.8fr)_minmax(112px,0.65fr)_auto] xl:items-center">
<span>账号</span>
<span>通讯</span>
<span>组织</span>
@ -157,6 +171,7 @@ function TeamNodeView({
onDelete,
onActivate,
onHandover,
onEmployeeMatch,
canEditUser,
canActivateUser,
}: {
@ -168,17 +183,18 @@ function TeamNodeView({
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
onHandover: (user: UserTreeItem) => void;
onEmployeeMatch: (user: UserTreeItem) => Promise<void> | void;
canEditUser?: (user: UserTreeItem) => boolean;
canActivateUser?: (user: UserTreeItem) => boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="overflow-hidden rounded-[6px] border border-[#e1e7e8] bg-white">
<div className="rounded-[6px] border border-[#dce3e4] bg-white">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className="flex min-h-[40px] w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-[#f7f7f6] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
className="flex min-h-[42px] w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
@ -188,7 +204,7 @@ function TeamNodeView({
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-[#607073]">
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[#f2f3f2] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[#eef3f3] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<ShieldCheck className="h-3 w-3" />
主管
</span>
@ -206,28 +222,31 @@ function TeamNodeView({
</button>
{isOpen && (
<div className="border-t border-[#e6e7e7] bg-[#fafafa] p-2">
<div className="border-t border-[#e6ecec] bg-white">
{team.users.length > 0 ? (
<div className="overflow-hidden rounded-[6px] border border-[#e1e7e8] bg-white">
<div>
<UserLedgerHeader />
{team.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
<div className="divide-y divide-[#edf1f1]">
{team.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-[6px] border border-dashed border-[#d6dee0] bg-white px-4 py-3 text-sm text-[#607073]">
<div className="m-2 flex items-center gap-2 rounded-[6px] border border-dashed border-[#d6dee0] bg-white px-4 py-3 text-sm text-[#607073]">
<Users className="h-4 w-4" />
<span>该小组当前没有匹配的成员</span>
</div>
@ -248,6 +267,7 @@ function DepartmentNodeView({
onDelete,
onActivate,
onHandover,
onEmployeeMatch,
canEditUser,
canActivateUser,
}: {
@ -260,6 +280,7 @@ function DepartmentNodeView({
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
onHandover: (user: UserTreeItem) => void;
onEmployeeMatch: (user: UserTreeItem) => Promise<void> | void;
canEditUser?: (user: UserTreeItem) => boolean;
canActivateUser?: (user: UserTreeItem) => boolean;
}) {
@ -273,17 +294,17 @@ function DepartmentNodeView({
return (
<div
className={cn(
'overflow-hidden rounded-[6px] border border-[#d8e0e1] bg-white',
'rounded-[6px] border border-[#d8e0e1] bg-white',
depth > 0 && 'ml-4 mt-2'
)}
>
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f7f7f6] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] border border-[#dce0e1] bg-[#f3f4f4] text-[#526164]">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] border border-[#dce3e4] bg-[#f2f6f6] text-[#526164]">
<Building2 className="h-4 w-4" />
</div>
<div className="min-w-0">
@ -291,13 +312,13 @@ function DepartmentNodeView({
<span className="truncate text-[14px] font-black text-[#152024]">
{department.name}
</span>
<span className="rounded-[4px] border border-[#dce0e1] bg-[#f7f7f6] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
<span className="rounded-[4px] border border-[#dce3e4] bg-[#f7f9f9] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{department.summary.totalUserCount} 人
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px] text-[#607073]">
<span className="truncate text-[#6b777a]">{department.fullPath}</span>
<div className="inline-flex items-center gap-1 rounded-[4px] bg-[#f2f3f2] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<div className="inline-flex items-center gap-1 rounded-[4px] bg-[#eef3f3] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<ShieldCheck className="h-3 w-3" />
<span>主管</span>
</div>
@ -323,7 +344,7 @@ function DepartmentNodeView({
</button>
{isOpen && (
<div className="border-t border-[#e6e7e7] bg-[#fafafa] px-2 py-2">
<div className="border-t border-[#e6ecec] bg-white px-2 py-2">
{hasDirectUsers && (
<div className="mb-3">
<SectionHeader
@ -334,23 +355,26 @@ function DepartmentNodeView({
onToggle={() => setDirectUsersOpen((current) => !current)}
/>
{directUsersOpen && (
<div className="mt-2 overflow-hidden rounded-[6px] border border-[#e1e7e8] bg-white">
<div className="mt-2 overflow-hidden rounded-[5px] border border-[#e8eeee] bg-white">
<UserLedgerHeader />
{department.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
<div className="divide-y divide-[#edf1f1]">
{department.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
</div>
</div>
)}
</div>
@ -378,6 +402,7 @@ function DepartmentNodeView({
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
canEditUser={canEditUser}
canActivateUser={canActivateUser}
/>
@ -405,6 +430,7 @@ function DepartmentNodeView({
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
canEditUser={canEditUser}
canActivateUser={canActivateUser}
/>
@ -438,6 +464,7 @@ interface OrganizationTreePayload {
export function DepartmentAccessTree({
searchQuery,
roleFilter,
businessRoleFilter,
statusFilter,
refreshToken,
currentUserId,
@ -447,6 +474,7 @@ export function DepartmentAccessTree({
onDelete,
onActivate,
onHandover,
onEmployeeMatch,
canEditUser,
canActivateUser,
}: DepartmentAccessTreeProps) {
@ -468,6 +496,7 @@ export function DepartmentAccessTree({
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (roleFilter) params.set('role', roleFilter);
if (businessRoleFilter) params.set('businessRole', businessRoleFilter);
if (statusFilter) params.set('status', statusFilter);
const response = await fetch(`/api/users/organization-tree?${params.toString()}`, {
@ -503,7 +532,7 @@ export function DepartmentAccessTree({
return () => {
active = false;
};
}, [departmentReloadToken, refreshToken, roleFilter, searchQuery, statusFilter]);
}, [businessRoleFilter, departmentReloadToken, refreshToken, roleFilter, searchQuery, statusFilter]);
const hasDepartments = useMemo(() => departments.length > 0, [departments]);
@ -555,22 +584,18 @@ export function DepartmentAccessTree({
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
canEditUser={canEditUser}
canActivateUser={canActivateUser}
/>
))}
{unassignedUsers.length > 0 && (
<div className="space-y-2.5 pt-2">
<div className="overflow-hidden rounded-[6px] border border-[#d8e0e1] bg-white">
<button
type="button"
onClick={() => setUnassignedOpen((current) => !current)}
className={cn(
'flex min-h-[42px] w-full items-center gap-3 rounded-[6px] px-3 py-2 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]',
unassignedOpen
? 'border border-[#d8e0e1] bg-white'
: 'border border-transparent hover:border-[#dce3e4] hover:bg-[#f7f7f6]'
)}
className="flex min-h-[42px] w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-inset"
>
<ChevronDown
size={14}
@ -580,28 +605,31 @@ export function DepartmentAccessTree({
<span className="text-[13px] font-black text-[#152024]">
未分配 / 外部人员
</span>
<span className="ml-auto rounded-[4px] bg-[#eceeed] px-1.5 py-0.5 text-[10px] font-black text-[#4f5b5e]">
<span className="ml-auto rounded-[4px] bg-[#e9eeee] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{unassignedUsers.length} 人
</span>
</button>
{unassignedOpen && (
<div className="overflow-hidden rounded-[6px] border border-[#d8e0e1] bg-white">
<div className="border-t border-[#e6ecec] bg-white">
<UserLedgerHeader />
{unassignedUsers.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
<div className="divide-y divide-[#edf1f1]">
{unassignedUsers.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
onHandover={onHandover}
onEmployeeMatch={onEmployeeMatch}
currentUserId={currentUserId}
canEdit={canEditUser ? canEditUser(user) : true}
canActivate={canActivateUser ? canActivateUser(user) : true}
/>
))}
</div>
</div>
)}
</div>

View File

@ -6,12 +6,13 @@
'use client';
import { useState, useEffect } from 'react';
import type { Role } from '@prisma/client';
import type { BusinessAccountType, Role } from '@prisma/client';
import Modal from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import Button from '@/components/ui/Button';
import { AlertCircle } from 'lucide-react';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
import { getApiErrorMessage } from '@/lib/utils';
interface UserData {
@ -19,6 +20,7 @@ interface UserData {
email: string;
name: string;
role: string;
businessRoles?: BusinessAccountType[];
status: string;
phone?: string | null;
departmentId?: string | null;
@ -32,6 +34,11 @@ interface DepartmentItem {
children?: DepartmentItem[];
}
interface TeamItem {
id: string;
name: string;
}
interface EditUserModalProps {
isOpen: boolean;
user: UserData;
@ -63,6 +70,7 @@ export function EditUserModal({
password: '',
confirmPassword: '',
role: user.role,
businessRoles: user.businessRoles || [],
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
@ -72,6 +80,7 @@ export function EditUserModal({
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
const [teams, setTeams] = useState<TeamItem[]>([]);
// 加载部门列表
useEffect(() => {
@ -105,6 +114,34 @@ export function EditUserModal({
}
}, [isOpen]);
useEffect(() => {
const loadTeams = async () => {
if (!formData.departmentId) {
setTeams([]);
return;
}
try {
const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
credentials: 'include',
});
if (!response.ok) {
setTeams([]);
return;
}
const data = await response.json();
setTeams(data.data?.teams || data.teams || []);
} catch (error) {
console.error('加载小组列表失败:', error);
setTeams([]);
}
};
if (isOpen) {
void loadTeams();
}
}, [formData.departmentId, isOpen]);
// 当用户变化时更新表单
useEffect(() => {
setFormData({
@ -113,6 +150,7 @@ export function EditUserModal({
password: '',
confirmPassword: '',
role: user.role,
businessRoles: user.businessRoles || [],
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
@ -120,6 +158,15 @@ export function EditUserModal({
});
}, [user]);
const toggleBusinessRole = (role: BusinessAccountType) => {
setFormData((current) => ({
...current,
businessRoles: current.businessRoles.includes(role)
? current.businessRoles.filter((item) => item !== role)
: [...current.businessRoles, role],
}));
};
const availableRoleOptions = [
...new Set<Role>([
...(allowedRoles as Role[]),
@ -171,6 +218,7 @@ export function EditUserModal({
email: string;
name: string;
role: string;
businessRoles: BusinessAccountType[];
status: string;
phone: string | null;
departmentId: string | null;
@ -180,6 +228,7 @@ export function EditUserModal({
email: formData.email,
name: formData.name,
role: formData.role,
businessRoles: formData.businessRoles,
status: formData.status,
phone: formData.phone || null,
departmentId: formData.departmentId || null,
@ -306,15 +355,41 @@ export function EditUserModal({
label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}`,
})),
]}
disabled={disablePrivilegeFields}
className="bg-white"
/>
<Select
label="所属小组"
value={formData.teamId}
onChange={(e) => setFormData({ ...formData, teamId: e.target.value })}
options={[
{ value: '', label: '未分配小组' },
...teams.map((team) => ({ value: team.id, label: team.name })),
]}
className="bg-white"
/>
<div>
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">
业务类型
</p>
<div className="grid grid-cols-2 gap-2">
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
<input
type="checkbox"
checked={formData.businessRoles.includes(option.value)}
onChange={() => toggleBusinessRole(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
</div>
</div>
<div className="space-y-5 rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="flex items-center gap-2 mb-1">
<div className="h-1.5 w-1.5 rounded-full bg-[#405054]" />
<div className="w-1 h-1 rounded-full bg-apple-blue" />
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">
安全设置
</h4>

View File

@ -337,7 +337,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
<>
<div className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="mb-4 flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-[#405054]" />
<div className="h-1.5 w-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
<h4 className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-secondary">
交接对象
</h4>
@ -444,7 +444,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
<label className="flex items-start gap-3 rounded-md border border-[#dde3e5] bg-white px-4 py-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-apple-separator/30 accent-[#405054]"
className="mt-1 h-4 w-4 rounded border-apple-separator/30 accent-apple-blue"
checked={removeCollaborations}
onChange={(event) => setRemoveCollaborations(event.target.checked)}
/>
@ -493,13 +493,13 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
className={cn(
'flex cursor-pointer items-start gap-3 rounded-md border px-4 py-3 transition-all',
checked
? 'border-[#cbd2d4] bg-[#f2f3f3]'
? 'border-apple-blue/20 bg-apple-blue/5'
: 'border-apple-separator/10 bg-white hover:border-apple-separator/20',
)}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-apple-separator/30 accent-[#405054]"
className="mt-1 h-4 w-4 rounded border-apple-separator/30 accent-apple-blue"
checked={checked}
onChange={() => toggleSku(sku.id)}
/>

View File

@ -0,0 +1,210 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, Building2, Users2 } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { getApiErrorMessage } from '@/lib/utils'
interface DepartmentItem {
id: string
name: string
level?: number
children?: DepartmentItem[]
}
interface OrganizationManageModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
}
function flattenDepartments(items: DepartmentItem[], level = 0): DepartmentItem[] {
return items.reduce<DepartmentItem[]>((acc, item) => {
acc.push({ ...item, level })
if (item.children?.length) {
acc.push(...flattenDepartments(item.children, level + 1))
}
return acc
}, [])
}
export function OrganizationManageModal({
isOpen,
onClose,
onSuccess,
}: OrganizationManageModalProps) {
const [departments, setDepartments] = useState<DepartmentItem[]>([])
const [departmentName, setDepartmentName] = useState('')
const [parentId, setParentId] = useState('')
const [teamName, setTeamName] = useState('')
const [teamDepartmentId, setTeamDepartmentId] = useState('')
const [teamDescription, setTeamDescription] = useState('')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [reloadToken, setReloadToken] = useState(0)
useEffect(() => {
const loadDepartments = async () => {
if (!isOpen) return
const response = await fetch('/api/departments', { credentials: 'include' })
if (!response.ok) return
const payload = await response.json()
setDepartments(flattenDepartments(payload.data?.departments || payload.departments || []))
}
void loadDepartments()
}, [isOpen, reloadToken])
const departmentOptions = useMemo(
() => [
{ value: '', label: '作为一级部门' },
...departments.map((department) => ({
value: department.id,
label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
})),
],
[departments]
)
const teamDepartmentOptions = useMemo(
() => [
{ value: '', label: '选择所属部门' },
...departments.map((department) => ({
value: department.id,
label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
})),
],
[departments]
)
const createDepartment = async () => {
if (!departmentName.trim()) {
setError('请输入部门名称')
return
}
setLoading(true)
setError(null)
setMessage(null)
try {
const response = await fetch('/api/departments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: departmentName.trim(),
parentId: parentId || null,
}),
})
const payload = await response.json()
if (!response.ok) {
setError(getApiErrorMessage(payload.error, '创建部门失败'))
return
}
setDepartmentName('')
setParentId('')
setMessage('部门已创建')
setReloadToken((current) => current + 1)
onSuccess()
} finally {
setLoading(false)
}
}
const createTeam = async () => {
if (!teamName.trim()) {
setError('请输入小组名称')
return
}
if (!teamDepartmentId) {
setError('请选择小组所属部门')
return
}
setLoading(true)
setError(null)
setMessage(null)
try {
const response = await fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: teamName.trim(),
departmentId: teamDepartmentId,
description: teamDescription.trim() || null,
}),
})
const payload = await response.json()
if (!response.ok) {
setError(getApiErrorMessage(payload.error, '创建小组失败'))
return
}
setTeamName('')
setTeamDescription('')
setMessage('小组已创建')
onSuccess()
} finally {
setLoading(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="组织管理" size="medium">
<div className="space-y-5 p-2">
{error ? (
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4">
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
<p className="text-xs font-bold text-red-700">{error}</p>
</div>
) : null}
{message ? (
<div className="rounded-md border border-[#c9ded8] bg-[#f0f8f5] px-4 py-3 text-xs font-bold text-[#2c6654]">
{message}
</div>
) : null}
<section className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
<div className="mb-4 flex items-center gap-2">
<Building2 className="h-4 w-4 text-[#405054]" />
<h3 className="text-[11px] font-black text-[#152024]">部门</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input label="部门名称" value={departmentName} onChange={(event) => setDepartmentName(event.target.value)} />
<Select label="上级部门" value={parentId} onChange={(event) => setParentId(event.target.value)} options={departmentOptions} />
</div>
<div className="mt-4 flex justify-end">
<Button type="button" variant="plain" size="small" loading={loading} onClick={createDepartment}>
创建部门
</Button>
</div>
</section>
<section className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
<div className="mb-4 flex items-center gap-2">
<Users2 className="h-4 w-4 text-[#405054]" />
<h3 className="text-[11px] font-black text-[#152024]">小组</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input label="小组名称" value={teamName} onChange={(event) => setTeamName(event.target.value)} />
<Select label="所属部门" value={teamDepartmentId} onChange={(event) => setTeamDepartmentId(event.target.value)} options={teamDepartmentOptions} />
<div className="md:col-span-2">
<Input label="说明" value={teamDescription} onChange={(event) => setTeamDescription(event.target.value)} placeholder="可选" />
</div>
</div>
<div className="mt-4 flex justify-end">
<Button type="button" variant="plain" size="small" loading={loading} onClick={createTeam}>
创建小组
</Button>
</div>
</section>
</div>
</Modal>
)
}

View File

@ -22,6 +22,7 @@ import {
CheckCircle2,
Clock,
Edit,
Link2,
Mail,
Phone,
RefreshCw,
@ -29,7 +30,9 @@ import {
Users,
type LucideIcon,
} from 'lucide-react';
import type { BusinessAccountType } from '@prisma/client';
import Image from '@/components/ui/Image';
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types';
import { cn } from '@/lib/utils';
interface UserListItemProps {
@ -42,6 +45,17 @@ interface UserListItemProps {
loginSource?: string;
activationRequired?: boolean;
employeeDirectoryId?: string | null;
employeeDirectory?: {
id: string;
jobNumber: string | null;
name: string;
mobile: string | null;
email: string | null;
title: string | null;
sourceStatus: string;
syncedAt: string;
} | null;
businessRoles?: BusinessAccountType[];
avatar?: string;
phone?: string;
department?: {
@ -59,6 +73,7 @@ interface UserListItemProps {
onDelete: (userId: string) => void;
onActivate?: (user: UserListItemProps['user']) => void;
onHandover?: (user: UserListItemProps['user']) => void;
onEmployeeMatch?: (user: UserListItemProps['user']) => void;
onSelect?: (user: UserListItemProps['user']) => void;
currentUserId?: string;
canEdit?: boolean;
@ -69,57 +84,57 @@ interface UserListItemProps {
const statusConfig = {
ACTIVE: {
label: '在职',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
color: 'border-[#bfe4d4] bg-[#edf8f3] text-[#1f7a56]',
},
PENDING: {
label: '待激活',
color: 'border-transparent bg-[#fbf4e3] text-[#8a641b]',
color: 'border-[#ead9aa] bg-[#fff8e8] text-[#8a641b]',
},
INACTIVE: {
label: '离职',
color: 'border-transparent bg-[#f2f2f1] text-[#687477]',
color: 'border-[#d8dee0] bg-[#f2f5f5] text-[#687477]',
},
SUSPENDED: {
label: '锁定',
color: 'border-transparent bg-[#fbefed] text-[#a9483f]',
color: 'border-[#efc8c4] bg-[#fff1ef] text-[#a9483f]',
},
};
const roleConfig: Record<string, { label: string; color: string; avatar: string }> = {
SUPER_ADMIN: {
label: '超级管理员',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#e3d2ad] bg-[#fbf5e7] text-[#7d5a18]',
avatar: 'border-[#e3d2ad] bg-[#fbf5e7] text-[#7d5a18]',
},
ADMIN: {
label: '管理员',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#d7c7b0] bg-[#fff4e7] text-[#985f22]',
avatar: 'border-[#d7c7b0] bg-[#fff4e7] text-[#985f22]',
},
SUPERVISOR: {
label: '主管',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
SUPERVISOR_DEPARTMENT: {
label: '部门主管',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
SUPERVISOR_TEAM: {
label: '小组主管',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
DESIGNER: {
label: '设计师',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]',
avatar: 'border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]',
},
USER: {
label: '普通用户',
color: 'border-transparent bg-[#f2f3f2] text-[#405054]',
avatar: 'border-[#d2d7d9] bg-[#f7f7f6] text-[#263033]',
color: 'border-[#d8dee0] bg-[#f5f7f7] text-[#4f5b5e]',
avatar: 'border-[#d8dee0] bg-[#f5f7f7] text-[#4f5b5e]',
},
};
@ -137,7 +152,7 @@ function IdentityChip({ children, className }: { children: string; className?: s
return (
<span
className={cn(
'inline-flex h-5 items-center rounded-[5px] border px-1.5 text-[10px] font-black leading-none',
'inline-flex h-[22px] items-center rounded-[5px] border px-2 text-[10px] font-black leading-none',
className
)}
>
@ -155,7 +170,7 @@ function RowActionButton({
}: {
icon: LucideIcon;
label?: string;
tone?: 'neutral' | 'warning' | 'danger';
tone?: 'neutral' | 'warning' | 'danger' | 'success';
title: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}) {
@ -166,15 +181,16 @@ function RowActionButton({
aria-label={title}
onClick={onClick}
className={cn(
'inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-[5px] border text-[12px] font-bold transition-colors',
label ? 'px-2.5' : 'w-7 px-0',
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[5px] border px-2.5 text-[12px] font-bold transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-offset-1',
tone === 'neutral' &&
'border-transparent bg-transparent text-[#526164] hover:border-[#d6dee0] hover:bg-white hover:text-[#101719]',
'border-[#d6dee0] bg-[#f7f9f9] text-[#324044] hover:border-[#b8c4c7] hover:bg-[#eef3f3]',
tone === 'warning' &&
'border-transparent bg-transparent text-[#8a641b] hover:border-[#ead9aa] hover:bg-[#fff9eb]',
'border-[#ead9aa] bg-[#fff9eb] text-[#8a641b] hover:border-[#d8bd72] hover:bg-[#fff3d3]',
tone === 'danger' &&
'border-transparent bg-transparent text-[#9d5750] hover:border-[#efc8c4] hover:bg-[#fff1ef]'
'border-transparent bg-transparent px-2 text-[#9d5750] hover:border-[#efc8c4] hover:bg-[#fff1ef]',
tone === 'success' &&
'border-[#bfe4d4] bg-[#eff9f4] text-[#1f7a56] hover:border-[#95d5b9] hover:bg-[#e5f5ee]'
)}
>
<Icon className="h-3.5 w-3.5" />
@ -189,6 +205,7 @@ export const UserListItem = memo(function UserListItem({
onDelete,
onActivate,
onHandover,
onEmployeeMatch,
onSelect,
currentUserId,
canEdit = true,
@ -260,6 +277,14 @@ export const UserListItem = memo(function UserListItem({
[onHandover, user]
);
const handleEmployeeMatch = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onEmployeeMatch?.(user);
},
[onEmployeeMatch, user]
);
return (
<div
role="button"
@ -272,14 +297,14 @@ export const UserListItem = memo(function UserListItem({
onKeyDown={handleRowKeyDown}
style={
selected
? { boxShadow: 'inset 4px 0 0 #152024, 0 0 0 1px rgba(21, 32, 36, 0.12)' }
? { boxShadow: 'inset 3px 0 0 #152024' }
: undefined
}
className={cn(
'group relative grid cursor-pointer grid-cols-1 gap-2 border-b border-[#edf1f1] bg-white px-3 py-2 text-left transition-colors duration-150 last:border-b-0',
'hover:bg-[#f8f8f7] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-inset',
'xl:grid-cols-[minmax(230px,1.25fr)_minmax(180px,0.95fr)_minmax(150px,0.8fr)_minmax(112px,0.58fr)_90px] xl:items-center',
selected && 'bg-[#eceeed]',
'group relative grid cursor-pointer grid-cols-1 gap-2 bg-white px-3 py-2.5 text-left transition-colors duration-150',
'hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-inset',
'xl:grid-cols-[minmax(220px,1.25fr)_minmax(170px,0.95fr)_minmax(140px,0.8fr)_minmax(112px,0.65fr)_auto] xl:items-center',
selected && 'bg-[#eef5f4]',
isPending && !selected && 'bg-[#fffaf0]'
)}
>
@ -291,7 +316,7 @@ export const UserListItem = memo(function UserListItem({
<div className="relative shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center overflow-hidden rounded-[5px] border text-[10px] font-black',
'flex h-9 w-9 items-center justify-center overflow-hidden rounded-[6px] border text-[11px] font-black',
roleStyle.avatar
)}
>
@ -314,23 +339,31 @@ export const UserListItem = memo(function UserListItem({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="min-w-0 truncate text-[13px] font-black leading-5 text-[#101719]">
<h3 className="min-w-0 truncate text-[13px] font-black leading-5 text-[#152024]">
{user.name}
</h3>
<IdentityChip className={roleStyle.color}>{roleStyle.label}</IdentityChip>
<IdentityChip className={statusStyle.color}>{statusStyle.label}</IdentityChip>
{selected && (
<IdentityChip className="border-[#cbd2d4] bg-[#f7f7f6] text-[#405054]">已选中</IdentityChip>
<IdentityChip className="border-[#152024] bg-[#152024] text-[#fdfdfc]">当前选择</IdentityChip>
)}
{isCurrentUser && (
<IdentityChip className="border-[#cbd2d4] bg-[#f7f7f6] text-[#405054]">当前账号</IdentityChip>
)}
{user.employeeDirectoryId && (
<IdentityChip className="border-[#d8dcde] bg-[#f7f7f6] text-[#5d696c]">内部人员</IdentityChip>
<IdentityChip className="border-[#c9ded8] bg-[#f0f8f5] text-[#2c6654]">已匹配员工库</IdentityChip>
)}
{!user.employeeDirectoryId && (
<IdentityChip className="border-[#d8dee0] bg-[#f7f9f9] text-[#5d696c]">未匹配员工库</IdentityChip>
)}
{user.loginSource === 'DINGTALK' && (
<IdentityChip className="border-[#d8dcde] bg-[#f7f7f6] text-[#5d696c]">钉钉</IdentityChip>
<IdentityChip className="border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]">钉钉已绑定</IdentityChip>
)}
{(user.businessRoles || []).map((role) => (
<IdentityChip key={role} className="border-[#d8d2c4] bg-[#fbf7ee] text-[#6b5736]">
{BUSINESS_ACCOUNT_TYPE_LABELS[role as keyof typeof BUSINESS_ACCOUNT_TYPE_LABELS] ?? role}
</IdentityChip>
))}
</div>
</div>
</div>
@ -364,28 +397,31 @@ export const UserListItem = memo(function UserListItem({
<span className="truncate">{formattedLastLogin}</span>
</div>
<div
className={cn(
'flex flex-wrap items-center gap-1 xl:justify-end xl:opacity-0 xl:transition-opacity',
selected && 'xl:opacity-100',
'group-hover:opacity-100 group-focus-within:opacity-100'
)}
>
<div className="flex flex-wrap items-center gap-1.5 xl:justify-end">
{isPending && onActivate && canActivate && (
<RowActionButton
icon={CheckCircle2}
label="激活"
title={`激活 ${user.name}`}
tone="warning"
tone="success"
onClick={handleActivate}
/>
)}
{onEmployeeMatch && (
<RowActionButton
icon={Link2}
label={user.employeeDirectoryId ? '重匹配' : '匹配'}
title={`匹配 ${user.name} 的员工库信息`}
onClick={handleEmployeeMatch}
/>
)}
{canEdit && (
<RowActionButton icon={Edit} title={`编辑 ${user.name}`} onClick={handleEdit} />
<RowActionButton icon={Edit} label="编辑" title={`编辑 ${user.name}`} onClick={handleEdit} />
)}
{!isCurrentUser && user.status === 'ACTIVE' && onHandover && (
<RowActionButton
icon={RefreshCw}
label="交接"
title={`交接 ${user.name}`}
tone="warning"
onClick={handleHandover}

View File

@ -33,6 +33,7 @@ interface UserListParams extends Record<string, unknown> {
page?: number
limit?: number
role?: string
businessRole?: string
status?: string
departmentId?: string
search?: string
@ -56,6 +57,7 @@ async function fetchUsers(params: UserListParams = {}): Promise<UserListResponse
if (params.page) searchParams.set('page', String(params.page))
if (params.limit) searchParams.set('limit', String(params.limit))
if (params.role) searchParams.set('role', params.role)
if (params.businessRole) searchParams.set('businessRole', params.businessRole)
if (params.status) searchParams.set('status', params.status)
if (params.departmentId) searchParams.set('departmentId', params.departmentId)
if (params.search) searchParams.set('search', params.search)
@ -88,6 +90,8 @@ async function createUser(input: {
name: string
password: string
role?: string
businessRoles?: string[]
jobNumber?: string
departmentId?: string
teamId?: string
}): Promise<UserDetailDTO> {
@ -113,6 +117,7 @@ async function updateUser(
avatar: string
phone: string
role: string
businessRoles: string[]
departmentId: string
teamId: string
status: string

View File

@ -11,7 +11,7 @@
'use client';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Role, UserStatus } from '@prisma/client';
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client';
import type { ApiResponse } from '@/lib/api';
import {
canCreateContent,
@ -30,6 +30,7 @@ export interface User {
email: string;
name: string;
role: Role;
businessRoles?: BusinessAccountType[];
status: UserStatus;
loginSource?: string;
activationRequired?: boolean;

View File

@ -0,0 +1,27 @@
import type { BusinessAccountType } from '@prisma/client'
export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
value: BusinessAccountType
label: string
}> = [
{ value: 'PLANNING', label: '企划' },
{ value: 'PURCHASING', label: '采购' },
{ value: 'WAREHOUSE', label: '仓库' },
{ value: 'SUPPLIER', label: '供应商' },
]
export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<BusinessAccountType, string> = {
PLANNING: '企划',
PURCHASING: '采购',
WAREHOUSE: '仓库',
SUPPLIER: '供应商',
}
export function normalizeBusinessRoles(value: unknown): BusinessAccountType[] {
if (!Array.isArray(value)) return []
const allowed = new Set(BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value))
return value.filter((item): item is BusinessAccountType => {
return typeof item === 'string' && allowed.has(item as BusinessAccountType)
})
}

View File

@ -4,7 +4,7 @@
* @created 2026-01-30
*/
import type { User, Department, Team } from '@prisma/client'
import type { BusinessAccountType, EmployeeSourceStatus, User, Department, Team } from '@prisma/client'
// ============ DTO 定义 ============
@ -17,7 +17,13 @@ export interface UserListItemDTO {
name: string
avatar?: string | null
role: string
businessRoles: BusinessAccountType[]
status: string
loginSource?: string
employeeDirectoryId?: string | null
employeeDirectory?: UserEmployeeDirectoryDTO | null
departmentId?: string | null
teamId?: string | null
department?: {
id: string
name: string
@ -40,7 +46,11 @@ export interface UserDetailDTO {
avatar?: string | null
phone?: string | null
role: string
businessRoles: BusinessAccountType[]
status: string
loginSource?: string
employeeDirectoryId?: string | null
employeeDirectory?: UserEmployeeDirectoryDTO | null
isFirstLogin: boolean
department?: {
id: string
@ -67,6 +77,7 @@ export interface CurrentUserDTO {
name: string
avatar?: string | null
role: string
businessRoles: BusinessAccountType[]
permissions: string[]
department?: {
id: string
@ -87,6 +98,7 @@ export interface CreateUserInput {
name: string
password: string
role?: string
businessRoles?: BusinessAccountType[]
departmentId?: string
teamId?: string
phone?: string
@ -100,11 +112,49 @@ export interface UpdateUserInput {
avatar?: string
phone?: string
role?: string
businessRoles?: BusinessAccountType[]
departmentId?: string
teamId?: string
status?: string
}
export interface UserEmployeeDirectoryDTO {
id: string
jobNumber: string | null
name: string
mobile: string | null
email: string | null
title: string | null
sourceStatus: EmployeeSourceStatus
syncedAt: string
}
function toEmployeeDirectoryDTO(
employeeDirectory?: {
id: string
jobNumber: string | null
name: string
mobile: string | null
email: string | null
title: string | null
sourceStatus: EmployeeSourceStatus
syncedAt: Date
} | null
): UserEmployeeDirectoryDTO | null {
if (!employeeDirectory) return null
return {
id: employeeDirectory.id,
jobNumber: employeeDirectory.jobNumber,
name: employeeDirectory.name,
mobile: employeeDirectory.mobile,
email: employeeDirectory.email,
title: employeeDirectory.title,
sourceStatus: employeeDirectory.sourceStatus,
syncedAt: employeeDirectory.syncedAt.toISOString(),
}
}
// ============ 转换函数 ============
/**
@ -112,8 +162,11 @@ export interface UpdateUserInput {
*/
export function toUserListItemDTO(
user: User & {
departmentId?: string | null
teamId?: string | null
department?: Pick<Department, 'id' | 'name'> | null
team?: Pick<Team, 'id' | 'name'> | null
employeeDirectory?: Parameters<typeof toEmployeeDirectoryDTO>[0]
}
): UserListItemDTO {
return {
@ -122,7 +175,13 @@ export function toUserListItemDTO(
name: user.name,
avatar: user.avatar,
role: user.role,
businessRoles: user.businessRoles,
status: user.status,
loginSource: user.loginSource,
employeeDirectoryId: user.employeeDirectoryId,
employeeDirectory: toEmployeeDirectoryDTO(user.employeeDirectory),
departmentId: user.departmentId,
teamId: user.teamId,
department: user.department
? { id: user.department.id, name: user.department.name }
: null,
@ -141,6 +200,7 @@ export function toUserDetailDTO(
user: User & {
department?: Pick<Department, 'id' | 'name' | 'fullPath'> | null
team?: Pick<Team, 'id' | 'name'> | null
employeeDirectory?: Parameters<typeof toEmployeeDirectoryDTO>[0]
_count?: { createdProducts: number; ownedSKUs: number }
}
): UserDetailDTO {
@ -151,7 +211,11 @@ export function toUserDetailDTO(
avatar: user.avatar,
phone: user.phone,
role: user.role,
businessRoles: user.businessRoles,
status: user.status,
loginSource: user.loginSource,
employeeDirectoryId: user.employeeDirectoryId,
employeeDirectory: toEmployeeDirectoryDTO(user.employeeDirectory),
isFirstLogin: user.isFirstLogin,
department: user.department
? {
@ -187,6 +251,7 @@ export function toCurrentUserDTO(
name: user.name,
avatar: user.avatar,
role: user.role,
businessRoles: user.businessRoles,
permissions,
department: user.department
? { id: user.department.id, name: user.department.name }

View File

@ -4,7 +4,7 @@
* 认证链路数据访问层
*/
import { Prisma, Role, UserStatus } from '@prisma/client'
import { Prisma, Role, UserStatus, type BusinessAccountType } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export type AuthUserWithRelations = Prisma.UserGetPayload<{
@ -47,6 +47,7 @@ export interface FirstLoginUpdateResultRow {
email: string
name: string
role: Role
businessRoles: BusinessAccountType[]
status: UserStatus
departmentId: string | null
teamId: string | null
@ -125,6 +126,7 @@ class AuthRepository {
email: true,
name: true,
role: true,
businessRoles: true,
status: true,
departmentId: true,
teamId: true,

View File

@ -4,9 +4,24 @@ import { prisma } from '@/lib/prisma'
export interface EmployeeDirectoryMatchInput {
dingtalkUserId?: string | null
dingtalkUnionId?: string | null
jobNumber?: string | null
mobile?: string | null
}
export interface EmployeeDirectoryUpsertInput {
dingtalkUserId?: string | null
dingtalkUnionId?: string | null
jobNumber?: string | null
name: string
mobile?: string | null
email?: string | null
title?: string | null
sourceStatus?: EmployeeSourceStatus
primaryDepartmentExternalId?: string | null
departmentExternalIds?: Prisma.InputJsonValue
rawSnapshot?: Prisma.InputJsonValue
}
export type EmployeeDirectoryWithUser = Prisma.EmployeeDirectoryGetPayload<{
include: {
user: {
@ -106,6 +121,72 @@ class EmployeeDirectoryRepository {
})
}
async findByJobNumber(jobNumber: string): Promise<EmployeeDirectoryWithUser | null> {
const normalizedJobNumber = jobNumber.trim()
if (!normalizedJobNumber) {
return null
}
return prisma.employeeDirectory.findFirst({
where: {
jobNumber: normalizedJobNumber,
sourceStatus: 'EMPLOYED',
},
include: {
user: {
select: {
id: true,
status: true,
role: true,
departmentId: true,
teamId: true,
},
},
},
})
}
async upsertOne(input: EmployeeDirectoryUpsertInput): Promise<EmployeeDirectory> {
const now = new Date()
const matchConditions = [
input.jobNumber ? { jobNumber: input.jobNumber } : undefined,
input.dingtalkUserId ? { dingtalkUserId: input.dingtalkUserId } : undefined,
input.dingtalkUnionId ? { dingtalkUnionId: input.dingtalkUnionId } : undefined,
input.mobile ? { mobile: input.mobile } : undefined,
].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[]
const existingEmployee = matchConditions.length
? await prisma.employeeDirectory.findFirst({
where: { OR: matchConditions },
select: { id: true },
})
: null
const data = {
dingtalkUserId: input.dingtalkUserId ?? null,
dingtalkUnionId: input.dingtalkUnionId ?? null,
jobNumber: input.jobNumber ?? null,
name: input.name,
mobile: input.mobile ?? null,
email: input.email ?? null,
title: input.title ?? null,
sourceStatus: input.sourceStatus ?? 'EMPLOYED',
primaryDepartmentExternalId: input.primaryDepartmentExternalId ?? null,
departmentExternalIds: input.departmentExternalIds,
rawSnapshot: input.rawSnapshot,
syncedAt: now,
} satisfies Prisma.EmployeeDirectoryUncheckedCreateInput
if (existingEmployee) {
return prisma.employeeDirectory.update({
where: { id: existingEmployee.id },
data,
})
}
return prisma.employeeDirectory.create({ data })
}
async upsertMany(input: {
departments: Array<{
externalId: string
@ -291,39 +372,6 @@ class EmployeeDirectoryRepository {
syncedAt: now,
},
})
if (matchedEmployee.user?.id) {
await prisma.user.update({
where: { id: matchedEmployee.user.id },
data: { status: 'INACTIVE' },
})
}
}
const activeEmployees = await prisma.employeeDirectory.findMany({
where: { sourceStatus: 'EMPLOYED' },
select: {
id: true,
user: {
select: { id: true },
},
primaryDepartmentExternalId: true,
},
})
for (const employee of activeEmployees) {
if (!employee.user?.id) {
continue
}
const mappedDepartment = employee.primaryDepartmentExternalId
? departmentMap.get(employee.primaryDepartmentExternalId)
: null
await prisma.user.update({
where: { id: employee.user.id },
data: {
departmentId: mappedDepartment?.id ?? null,
},
})
}
return {

View File

@ -6,7 +6,7 @@
* @created 2026-01-30
*/
import { User, Prisma } from '@prisma/client'
import { User, Prisma, type BusinessAccountType } from '@prisma/client'
import { BaseRepository, FindManyOptions } from './base.repository'
// ============ 类型定义 ============
@ -14,6 +14,7 @@ import { BaseRepository, FindManyOptions } from './base.repository'
export interface UserFilter {
role?: string
status?: string
businessRole?: string
departmentId?: string
departmentIds?: string[]
teamId?: string
@ -148,6 +149,18 @@ class UserRepositoryClass extends BaseRepository<
team: {
select: { id: true, name: true },
},
employeeDirectory: {
select: {
id: true,
jobNumber: true,
name: true,
mobile: true,
email: true,
title: true,
sourceStatus: true,
syncedAt: true,
},
},
},
}),
this.prisma.user.count({ where }),
@ -169,6 +182,18 @@ class UserRepositoryClass extends BaseRepository<
team: {
select: { id: true, name: true },
},
employeeDirectory: {
select: {
id: true,
jobNumber: true,
name: true,
mobile: true,
email: true,
title: true,
sourceStatus: true,
syncedAt: true,
},
},
_count: {
select: {
createdProducts: true,
@ -317,6 +342,10 @@ class UserRepositoryClass extends BaseRepository<
where.status = filter.status as Prisma.EnumUserStatusFilter
}
if (filter.businessRole) {
where.businessRoles = { has: filter.businessRole as BusinessAccountType }
}
if (filter.departmentIds && filter.departmentIds.length > 0) {
where.departmentId = { in: filter.departmentIds }
} else if (filter.departmentId) {

View File

@ -12,7 +12,7 @@ import { hashPassword, validatePasswordStrength, verifyPassword } from '@/lib/pa
import { generateToken } from '@/lib/jwt'
import { validateToken } from '@/lib/token-validator'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { Prisma, Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, Prisma, Role, UserStatus } from '@prisma/client'
import { authRepository, type AuthUserWithRelations } from '../repositories/auth.repository'
// ============ 类型定义 ============
@ -41,6 +41,7 @@ export interface FirstLoginUpdateResult {
email: string
name: string
role: Role
businessRoles: BusinessAccountType[]
status: UserStatus
departmentId: string | null
teamId: string | null
@ -65,6 +66,7 @@ export interface AuthUserDTO {
email: string
name: string
role: Role
businessRoles: BusinessAccountType[]
status: UserStatus
loginSource: string
activationRequired: boolean
@ -242,6 +244,7 @@ class AuthService {
email: updatedUser.email,
name: updatedUser.name,
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
status: updatedUser.status,
departmentId: updatedUser.departmentId,
teamId: updatedUser.teamId,
@ -302,6 +305,7 @@ class AuthService {
email: user.email,
name: user.name,
role: user.role,
businessRoles: user.businessRoles,
status: user.status,
loginSource: user.loginSource,
activationRequired: false,
@ -409,6 +413,7 @@ class AuthService {
email: user.email,
name: user.name,
role: user.role,
businessRoles: user.businessRoles,
status: user.status,
loginSource: user.loginSource,
activationRequired: user.status === 'PENDING',

View File

@ -6,7 +6,7 @@ import { generateRandomPassword, hashPassword } from '@/lib/password'
import { AppError, ErrorCode } from '@/server/core/errors'
import { authService, type LoginResult } from './auth.service'
import { resolvePostLoginRedirect } from './auth-redirect.service'
import { employeeDirectoryRepository } from '../repositories/employee-directory.repository'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
const DINGTALK_AUTH_URL = 'https://login.dingtalk.com/oauth2/auth'
const DINGTALK_USER_ACCESS_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
@ -259,7 +259,7 @@ class DingtalkAuthService {
}
private async findOrProvisionUser(profile: DingtalkProfile) {
const employeeDirectory = await employeeDirectoryRepository.findByDingtalkIdentity({
const employeeDirectory = await externalEmployeeDirectoryService.matchByDingtalkIdentity({
dingtalkUserId: profile.userId,
dingtalkUnionId: profile.unionId,
mobile: profile.mobile,

View File

@ -0,0 +1,429 @@
import 'server-only'
import { Prisma, type EmployeeDirectory, type EmployeeSourceStatus } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
employeeDirectoryRepository,
type EmployeeDirectoryWithUser,
} from '@/server/repositories/employee-directory.repository'
type LookupKeyType = 'JOB_NUMBER' | 'DINGTALK_USER_ID' | 'DINGTALK_UNION_ID' | 'MOBILE'
interface NormalizedExternalEmployee {
dingtalkUserId?: string | null
dingtalkUnionId?: string | null
jobNumber?: string | null
name: string
mobile?: string | null
email?: string | null
title?: string | null
sourceStatus: EmployeeSourceStatus
primaryDepartmentExternalId?: string | null
departmentExternalIds?: string[] | null
rawSnapshot?: Prisma.InputJsonValue
}
interface SqlLookupRequest {
keyType: LookupKeyType
value: string
queryEnvName: string
fallbackQueryEnvName?: string
parameterName: string
}
interface MssqlLikeModule {
connect(config: Record<string, unknown>): Promise<{
request(): {
input(name: string, type: unknown, value: string): void
query<T = Record<string, unknown>>(queryText: string): Promise<{ recordset?: T[] }>
}
close(): Promise<void>
}>
NVarChar: unknown
}
const POSITIVE_CACHE_TTL_SECONDS = Number(process.env.HR_EMPLOYEE_LOOKUP_POSITIVE_TTL_SECONDS || 24 * 60 * 60)
const NEGATIVE_CACHE_TTL_SECONDS = Number(process.env.HR_EMPLOYEE_LOOKUP_NEGATIVE_TTL_SECONDS || 30 * 60)
function normalizeLookupValue(value: string | null | undefined): string {
return String(value || '').trim()
}
function buildCacheKey(keyType: LookupKeyType, value: string): string {
return `${keyType}:${normalizeLookupValue(value).toLowerCase()}`
}
function ttlToExpiresAt(seconds: number): Date {
return new Date(Date.now() + Math.max(60, seconds) * 1000)
}
function asString(value: unknown): string | null {
if (value === null || value === undefined) return null
const text = String(value).trim()
return text || null
}
function asSourceStatus(value: unknown): EmployeeSourceStatus {
const text = String(value || '').trim().toUpperCase()
if (['DIMISSIONED', 'DIMISSION', 'INACTIVE', 'LEAVED', 'LEFT', 'RESIGNED', '0'].includes(text)) {
return 'DIMISSIONED'
}
return 'EMPLOYED'
}
function asDepartmentExternalIds(value: unknown): string[] | null {
if (Array.isArray(value)) {
const items = value.map(asString).filter((item): item is string => Boolean(item))
return items.length > 0 ? items : null
}
const text = asString(value)
if (!text) return null
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text) as unknown
if (Array.isArray(parsed)) {
const items = parsed.map(asString).filter((item): item is string => Boolean(item))
return items.length > 0 ? items : null
}
} catch {
// Fall through to delimiter parsing.
}
}
const items = text
.split(/[;,|]/)
.map((item) => item.trim())
.filter(Boolean)
return items.length > 0 ? items : null
}
function toJsonSnapshot(row: Record<string, unknown>): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(row)) as Prisma.InputJsonValue
}
function normalizeExternalEmployee(row: Record<string, unknown>): NormalizedExternalEmployee | null {
const name = asString(row.name)
if (!name) return null
const departmentExternalIds = asDepartmentExternalIds(row.departmentExternalIds)
return {
dingtalkUserId: asString(row.dingtalkUserId),
dingtalkUnionId: asString(row.dingtalkUnionId),
jobNumber: asString(row.jobNumber),
name,
mobile: asString(row.mobile),
email: asString(row.email)?.toLowerCase() ?? null,
title: asString(row.title),
sourceStatus: asSourceStatus(row.sourceStatus),
primaryDepartmentExternalId: asString(row.primaryDepartmentExternalId) ?? departmentExternalIds?.[0] ?? null,
departmentExternalIds,
rawSnapshot: toJsonSnapshot(row),
}
}
function normalizeCachedEmployee(value: unknown): NormalizedExternalEmployee | null {
if (!value || typeof value !== 'object') return null
const candidate = value as Record<string, unknown>
const name = asString(candidate.name)
if (!name) return null
return {
dingtalkUserId: asString(candidate.dingtalkUserId),
dingtalkUnionId: asString(candidate.dingtalkUnionId),
jobNumber: asString(candidate.jobNumber),
name,
mobile: asString(candidate.mobile),
email: asString(candidate.email),
title: asString(candidate.title),
sourceStatus: asSourceStatus(candidate.sourceStatus),
primaryDepartmentExternalId: asString(candidate.primaryDepartmentExternalId),
departmentExternalIds: asDepartmentExternalIds(candidate.departmentExternalIds),
rawSnapshot: candidate.rawSnapshot as Prisma.InputJsonValue | undefined,
}
}
class ExternalEmployeeDirectoryService {
async matchByJobNumber(jobNumber: string): Promise<EmployeeDirectory | null> {
const value = normalizeLookupValue(jobNumber)
if (!value) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请输入工号')
}
const cached = await this.readCache('JOB_NUMBER', value)
if (cached !== undefined) {
return cached ? this.persistEmployee(cached) : null
}
const externalEmployee = await this.fetchExternalEmployee({
keyType: 'JOB_NUMBER',
value,
queryEnvName: 'HR_SQLSERVER_EMPLOYEE_BY_JOB_NUMBER_QUERY',
fallbackQueryEnvName: 'HR_SQLSERVER_EMPLOYEE_QUERY',
parameterName: 'jobNumber',
})
if (!externalEmployee) {
const localEmployee = await employeeDirectoryRepository.findByJobNumber(value)
if (localEmployee) {
await this.writeCache('JOB_NUMBER', value, this.toNormalizedFromDirectory(localEmployee))
return localEmployee
}
await this.writeCache('JOB_NUMBER', value, null)
return null
}
await this.writeCache('JOB_NUMBER', value, externalEmployee)
if (externalEmployee.sourceStatus !== 'EMPLOYED') {
return null
}
return this.persistEmployee(externalEmployee)
}
async matchByDingtalkIdentity(input: {
dingtalkUserId?: string | null
dingtalkUnionId?: string | null
mobile?: string | null
}): Promise<EmployeeDirectoryWithUser | EmployeeDirectory | null> {
const localEmployee = await employeeDirectoryRepository.findByDingtalkIdentity(input)
if (localEmployee) {
return localEmployee
}
const lookups: Array<SqlLookupRequest | null> = [
input.dingtalkUserId
? {
keyType: 'DINGTALK_USER_ID',
value: input.dingtalkUserId,
queryEnvName: 'HR_SQLSERVER_EMPLOYEE_BY_DINGTALK_USER_ID_QUERY',
parameterName: 'dingtalkUserId',
}
: null,
input.dingtalkUnionId
? {
keyType: 'DINGTALK_UNION_ID',
value: input.dingtalkUnionId,
queryEnvName: 'HR_SQLSERVER_EMPLOYEE_BY_DINGTALK_UNION_ID_QUERY',
parameterName: 'dingtalkUnionId',
}
: null,
input.mobile
? {
keyType: 'MOBILE',
value: input.mobile,
queryEnvName: 'HR_SQLSERVER_EMPLOYEE_BY_MOBILE_QUERY',
parameterName: 'mobile',
}
: null,
]
for (const lookup of lookups) {
if (!lookup) continue
const cached = await this.readCache(lookup.keyType, lookup.value)
if (cached !== undefined) {
if (cached?.sourceStatus === 'EMPLOYED') {
return this.persistEmployee(cached)
}
continue
}
const externalEmployee = await this.fetchExternalEmployee(lookup)
await this.writeCache(lookup.keyType, lookup.value, externalEmployee)
if (externalEmployee?.sourceStatus === 'EMPLOYED') {
return this.persistEmployee(externalEmployee)
}
}
return null
}
async refreshUserEmployeeMatch(input: {
userId: string
jobNumber: string
}): Promise<EmployeeDirectory | null> {
const employee = await this.matchByJobNumber(input.jobNumber)
if (!employee) {
return null
}
await prisma.user.update({
where: { id: input.userId },
data: {
employeeDirectoryId: employee.id,
dingtalkUserId: employee.dingtalkUserId ?? undefined,
dingtalkUnionId: employee.dingtalkUnionId ?? undefined,
phone: employee.mobile ?? undefined,
},
})
return employee
}
async resolveDepartmentIdByExternalId(externalId: string | null | undefined): Promise<string | null> {
const normalizedExternalId = normalizeLookupValue(externalId)
if (!normalizedExternalId) {
return null
}
const department = await prisma.department.findUnique({
where: { externalId: normalizedExternalId },
select: { id: true },
})
return department?.id ?? null
}
private async readCache(
keyType: LookupKeyType,
value: string
): Promise<NormalizedExternalEmployee | null | undefined> {
const cache = await prisma.employeeLookupCache.findUnique({
where: { cacheKey: buildCacheKey(keyType, value) },
})
if (!cache || cache.expiresAt.getTime() <= Date.now()) {
return undefined
}
return cache.result ? normalizeCachedEmployee(cache.result) : null
}
private async writeCache(
keyType: LookupKeyType,
value: string,
employee: NormalizedExternalEmployee | null
): Promise<void> {
await prisma.employeeLookupCache.upsert({
where: { cacheKey: buildCacheKey(keyType, value) },
update: {
lookupValue: value,
result: employee ? (employee as unknown as Prisma.InputJsonValue) : Prisma.DbNull,
sourceStatus: employee?.sourceStatus ?? null,
expiresAt: ttlToExpiresAt(employee ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS),
},
create: {
cacheKey: buildCacheKey(keyType, value),
keyType,
lookupValue: value,
result: employee ? (employee as unknown as Prisma.InputJsonValue) : Prisma.DbNull,
sourceStatus: employee?.sourceStatus ?? null,
expiresAt: ttlToExpiresAt(employee ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS),
},
})
}
private async fetchExternalEmployee(request: SqlLookupRequest): Promise<NormalizedExternalEmployee | null> {
const queryText = process.env[request.queryEnvName]?.trim() || (
request.fallbackQueryEnvName ? process.env[request.fallbackQueryEnvName]?.trim() : ''
)
if (!queryText) {
if (request.keyType === 'JOB_NUMBER') {
return this.getLocalFallbackByJobNumber(request.value)
}
return null
}
const sql = await this.loadMssql()
const pool = await sql.connect(this.getSqlServerConfig())
try {
const sqlRequest = pool.request()
sqlRequest.input(request.parameterName, sql.NVarChar, request.value)
const result = await sqlRequest.query<Record<string, unknown>>(queryText)
const row = result.recordset?.[0]
return row ? normalizeExternalEmployee(row) : null
} finally {
await pool.close()
}
}
private async getLocalFallbackByJobNumber(jobNumber: string): Promise<NormalizedExternalEmployee | null> {
const localEmployee = await employeeDirectoryRepository.findByJobNumber(jobNumber)
return localEmployee ? this.toNormalizedFromDirectory(localEmployee) : null
}
private async loadMssql(): Promise<MssqlLikeModule> {
try {
const moduleName = 'mssql'
const imported = (await import(moduleName)) as MssqlLikeModule & { default?: MssqlLikeModule }
return imported.default ?? imported
} catch {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '员工库 SQLServer 驱动未安装')
}
}
private getSqlServerConfig(): Record<string, unknown> {
const host = process.env.HR_SQLSERVER_HOST?.trim()
const database = process.env.HR_SQLSERVER_DATABASE?.trim()
const user = process.env.HR_SQLSERVER_USERNAME?.trim()
const password = process.env.HR_SQLSERVER_PASSWORD
if (!host || !database || !user || !password) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '员工库 SQLServer 连接信息未配置完整')
}
return {
server: host,
port: Number(process.env.HR_SQLSERVER_PORT || 1433),
database,
user,
password,
options: {
encrypt: process.env.HR_SQLSERVER_ENCRYPT === 'true',
trustServerCertificate: process.env.HR_SQLSERVER_TRUST_SERVER_CERTIFICATE !== 'false',
},
pool: {
max: Number(process.env.HR_SQLSERVER_POOL_MAX || 3),
min: 0,
idleTimeoutMillis: Number(process.env.HR_SQLSERVER_IDLE_TIMEOUT_MS || 30000),
},
requestTimeout: Number(process.env.HR_SQLSERVER_REQUEST_TIMEOUT_MS || 8000),
connectionTimeout: Number(process.env.HR_SQLSERVER_CONNECTION_TIMEOUT_MS || 8000),
}
}
private async persistEmployee(employee: NormalizedExternalEmployee): Promise<EmployeeDirectory> {
return employeeDirectoryRepository.upsertOne({
...employee,
departmentExternalIds: employee.departmentExternalIds ?? undefined,
rawSnapshot: employee.rawSnapshot,
})
}
private toNormalizedFromDirectory(employee: {
dingtalkUserId: string | null
dingtalkUnionId: string | null
jobNumber: string | null
name: string
mobile: string | null
email: string | null
title: string | null
sourceStatus: EmployeeSourceStatus
primaryDepartmentExternalId: string | null
departmentExternalIds: Prisma.JsonValue | null
rawSnapshot: Prisma.JsonValue | null
}): NormalizedExternalEmployee {
return {
dingtalkUserId: employee.dingtalkUserId,
dingtalkUnionId: employee.dingtalkUnionId,
jobNumber: employee.jobNumber,
name: employee.name,
mobile: employee.mobile,
email: employee.email,
title: employee.title,
sourceStatus: employee.sourceStatus,
primaryDepartmentExternalId: employee.primaryDepartmentExternalId,
departmentExternalIds: asDepartmentExternalIds(employee.departmentExternalIds),
rawSnapshot: employee.rawSnapshot as Prisma.InputJsonValue | undefined,
}
}
}
export const externalEmployeeDirectoryService = new ExternalEmployeeDirectoryService()

View File

@ -14,9 +14,10 @@ import {
} from '@/lib/role-scope'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import type { Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
import { logRepository } from '../repositories/log.repository'
import { departmentService, type DepartmentDTO } from './department.service'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
import { teamService, type TeamDTO } from './team.service'
import { userService } from './user.service'
@ -28,6 +29,7 @@ interface ListUsersInput {
limit?: number
search?: string
role?: Role
businessRole?: BusinessAccountType
status?: UserStatus
departmentId?: string
}
@ -44,6 +46,7 @@ interface UserListOutput {
interface OrganizationTreeInput {
search?: string
role?: Role
businessRole?: BusinessAccountType
status?: UserStatus
}
@ -228,12 +231,10 @@ class UserManagementService {
const privilegeChanged =
(input.role !== undefined && input.role !== targetUser.role) ||
(input.status !== undefined && input.status !== targetUser.status) ||
(input.departmentId !== undefined && input.departmentId !== (targetUser.departmentId ?? null)) ||
(input.teamId !== undefined && input.teamId !== (targetUser.teamId ?? null))
(input.status !== undefined && input.status !== targetUser.status)
if (privilegeChanged) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '不能修改自己的权限等级、状态或组织归属')
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '不能修改自己的权限等级或状态')
}
}
@ -325,6 +326,7 @@ class UserManagementService {
limit,
search: input.search,
role: input.role,
businessRole: input.businessRole,
status: input.status,
teamId: managedTeamId,
})
@ -369,6 +371,7 @@ class UserManagementService {
limit,
search: input.search,
role: input.role,
businessRole: input.businessRole,
status: input.status,
departmentIds,
})
@ -388,6 +391,7 @@ class UserManagementService {
limit,
search: input.search,
role: input.role,
businessRole: input.businessRole,
status: input.status,
departmentId: input.departmentId,
})
@ -411,7 +415,7 @@ class UserManagementService {
}
const canViewFull = this.canViewFullUserInfo(currentUser)
const hasFilters = Boolean(input.search || input.role || input.status)
const hasFilters = Boolean(input.search || input.role || input.businessRole || input.status)
const allDepartments = await departmentService.findAllFlat()
const allTeams = await teamService.findAll()
@ -450,6 +454,7 @@ class UserManagementService {
allUsers = await userService.listAll({
search: input.search,
role: input.role,
businessRole: input.businessRole,
status: input.status,
teamId: managedTeamId,
})
@ -463,6 +468,7 @@ class UserManagementService {
allUsers = await userService.listAll({
search: input.search,
role: input.role,
businessRole: input.businessRole,
status: input.status,
departmentIds: scopedDepartmentIds,
})
@ -580,6 +586,8 @@ class UserManagementService {
name: string
password: string
role?: Role
businessRoles?: BusinessAccountType[]
jobNumber?: string | null
departmentId?: string
teamId?: string
phone?: string
@ -608,16 +616,33 @@ class UserManagementService {
}
}
const employee = input.jobNumber
? await externalEmployeeDirectoryService.matchByJobNumber(input.jobNumber)
: null
if (input.jobNumber && !employee) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '未找到在职员工信息,请核对工号')
}
const mappedDepartmentId = employee?.primaryDepartmentExternalId
? await externalEmployeeDirectoryService.resolveDepartmentIdByExternalId(employee.primaryDepartmentExternalId)
: null
const createdUser = await userService.create({
email: input.email,
name: input.name,
password: input.password,
role: input.role || 'USER',
departmentId: input.departmentId,
businessRoles: input.businessRoles ?? [],
departmentId: input.departmentId ?? mappedDepartmentId ?? undefined,
teamId: input.teamId,
phone: input.phone,
phone: input.phone ?? employee?.mobile ?? undefined,
isFirstLogin: false,
status: 'PENDING',
employeeDirectoryId: employee?.id ?? undefined,
dingtalkUserId: employee?.dingtalkUserId ?? undefined,
dingtalkUnionId: employee?.dingtalkUnionId ?? undefined,
dingtalkBoundAt: employee?.dingtalkUserId || employee?.dingtalkUnionId ? new Date() : undefined,
})
await logRepository.createLog({
@ -629,6 +654,8 @@ class UserManagementService {
metadata: {
targetUserId: createdUser.id,
role: createdUser.role,
businessRoles: createdUser.businessRoles,
employeeDirectoryId: createdUser.employeeDirectoryId,
},
})
@ -642,6 +669,7 @@ class UserManagementService {
name?: string
password?: string
role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string | null
teamId?: string | null
phone?: string | null
@ -724,6 +752,7 @@ class UserManagementService {
userId: string,
input: {
role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string | null
teamId?: string | null
},
@ -758,6 +787,7 @@ class UserManagementService {
const updatedUser = await userService.update(userId, {
role: targetRole,
businessRoles: input.businessRoles ?? targetUser.businessRoles,
departmentId: input.departmentId ?? targetUser.departmentId,
teamId: input.teamId ?? targetUser.teamId,
status: 'ACTIVE',
@ -772,6 +802,7 @@ class UserManagementService {
metadata: {
targetUserId: userId,
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
departmentId: updatedUser.departmentId,
teamId: updatedUser.teamId,
},
@ -779,6 +810,51 @@ class UserManagementService {
return updatedUser
}
async refreshEmployeeMatch(
userId: string,
input: { jobNumber: string },
currentUser: AuthUser
) {
const targetUser = await userService.getById(userId)
const targetUserInfo: UserInfo = {
id: targetUser.id,
email: targetUser.email,
name: targetUser.name,
role: targetUser.role,
status: targetUser.status,
departmentId: targetUser.departmentId ?? undefined,
teamId: targetUser.teamId ?? undefined,
}
if (!canManageUser(currentUser, targetUserInfo)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '无权限匹配该用户')
}
const employee = await externalEmployeeDirectoryService.refreshUserEmployeeMatch({
userId,
jobNumber: input.jobNumber,
})
if (!employee) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '未找到在职员工信息,请核对工号')
}
await logRepository.createLog({
userId: currentUser.id,
type: 'USER_UPDATE',
targetType: 'User',
targetId: userId,
action: `重新匹配员工库: ${targetUser.name}`,
metadata: {
targetUserId: userId,
employeeDirectoryId: employee.id,
jobNumber: employee.jobNumber,
},
})
return userService.getById(userId)
}
}
export const userManagementService = new UserManagementService()

View File

@ -1,6 +1,6 @@
import { hashPassword, validatePasswordStrength, verifyPassword } from '@/lib/password'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, EmployeeSourceStatus, Role, UserStatus } from '@prisma/client'
import { departmentRepository } from '../repositories/department.repository'
import { teamRepository } from '../repositories/team.repository'
import { userRepository } from '../repositories/user.repository'
@ -10,6 +10,7 @@ export interface CreateUserInput {
name: string
password: string
role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string
teamId?: string
phone?: string
@ -28,6 +29,7 @@ export interface UpdateUserInput {
name?: string
password?: string
role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string | null
teamId?: string | null
phone?: string | null
@ -41,6 +43,7 @@ export interface UserListOptions {
search?: string
role?: Role
status?: UserStatus
businessRole?: BusinessAccountType
departmentId?: string
departmentIds?: string[]
teamId?: string
@ -59,10 +62,21 @@ export interface UserDTO {
email: string
name: string
role: Role
businessRoles: BusinessAccountType[]
status: UserStatus
loginSource: string
activationRequired: boolean
employeeDirectoryId: string | null
employeeDirectory: {
id: string
jobNumber: string | null
name: string
mobile: string | null
email: string | null
title: string | null
sourceStatus: EmployeeSourceStatus
syncedAt: Date
} | null
avatar: string | null
phone: string | null
departmentId: string | null
@ -104,9 +118,20 @@ class UserService {
email: string
name: string
role: Role
businessRoles?: BusinessAccountType[]
status: UserStatus
loginSource?: string
employeeDirectoryId?: string | null
employeeDirectory?: {
id: string
jobNumber: string | null
name: string
mobile: string | null
email: string | null
title: string | null
sourceStatus: EmployeeSourceStatus
syncedAt: Date
} | null
avatar: string | null
phone: string | null
departmentId: string | null
@ -121,10 +146,12 @@ class UserService {
email: user.email,
name: user.name,
role: user.role,
businessRoles: user.businessRoles ?? [],
status: user.status,
loginSource: user.loginSource ?? 'LOCAL',
activationRequired: user.status === 'PENDING',
employeeDirectoryId: user.employeeDirectoryId ?? null,
employeeDirectory: user.employeeDirectory ?? null,
avatar: user.avatar,
phone: user.phone,
departmentId: user.departmentId,
@ -143,6 +170,7 @@ class UserService {
search,
role,
status,
businessRole,
departmentId,
departmentIds,
teamId,
@ -155,6 +183,7 @@ class UserService {
search,
role,
status,
businessRole,
departmentId,
departmentIds,
teamId,
@ -173,13 +202,14 @@ class UserService {
}
async listAll(options: Omit<UserListOptions, 'page' | 'limit'> = {}): Promise<UserDTO[]> {
const { search, role, status, departmentId, departmentIds, teamId } = options
const { search, role, status, businessRole, departmentId, departmentIds, teamId } = options
const result = await userRepository.findManyWithRelations({
filter: {
search,
role,
status,
businessRole,
departmentId,
departmentIds,
teamId,
@ -244,6 +274,7 @@ class UserService {
name: input.name,
password: hashedPassword,
role: input.role ?? 'USER',
businessRoles: input.businessRoles ?? [],
departmentId: input.departmentId,
teamId: input.teamId,
phone: input.phone,
@ -302,6 +333,7 @@ class UserService {
name: input.name,
password: hashedPassword,
role: input.role,
businessRoles: input.businessRoles,
departmentId: input.departmentId,
teamId: input.teamId,
phone: input.phone,