feat(users): support department assignment and employee matching
This commit is contained in:
parent
a4daa71bfa
commit
f5f9ca6ba7
602
furniture-3d-system/package-lock.json
generated
602
furniture-3d-system/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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())
|
||||
|
||||
118
furniture-3d-system/src/app/api/auth/register/route.test.ts
Normal file
118
furniture-3d-system/src/app/api/auth/register/route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/* 动态字体大小 */
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
用户中心
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
27
furniture-3d-system/src/lib/business-account-types.ts
Normal file
27
furniture-3d-system/src/lib/business-account-types.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user