前端整合开源组件
This commit is contained in:
764
front/package-lock.json
generated
764
front/package-lock.json
generated
@@ -9,6 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
@@ -713,6 +716,44 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.49.0.tgz",
|
||||
@@ -845,6 +886,590 @@
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -1678,6 +2303,18 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -2003,6 +2640,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||
@@ -2463,6 +3106,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
@@ -3382,6 +4034,53 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||
@@ -3433,6 +4132,28 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -3845,6 +4566,49 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function AdminAuditsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const [page, setPage] = useState<{
|
||||
items: AdminAuditLog[];
|
||||
@@ -134,8 +135,11 @@ export default function AdminAuditsPage() {
|
||||
async function copyText(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setNotice('审计详情已复制');
|
||||
setError('');
|
||||
} catch {
|
||||
window.alert('复制失败,请手动复制。');
|
||||
setError('复制失败,请手动复制。');
|
||||
setNotice('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +183,12 @@ export default function AdminAuditsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="mb-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-blue-600 dark:text-blue-300">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Copy, FileBox, RefreshCw, Search, ShieldAlert, XCircle } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AdminSelect } from '@/src/components/admin/AdminSelect';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
import {
|
||||
@@ -114,6 +115,7 @@ export default function AdminFileBlobs() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const [page, setPage] = useState<{
|
||||
items: AdminFileBlobResponse[];
|
||||
@@ -166,8 +168,11 @@ export default function AdminFileBlobs() {
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setNotice('对象键已复制');
|
||||
setError('');
|
||||
} catch {
|
||||
window.alert('复制失败,请手动复制。');
|
||||
setError('复制失败,请手动复制。');
|
||||
setNotice('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +219,12 @@ export default function AdminFileBlobs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="mb-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-blue-600 dark:text-blue-300">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<motion.section variants={container} initial="hidden" animate="show" className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<motion.div variants={itemVariants}>
|
||||
{metricCard({
|
||||
@@ -285,7 +296,7 @@ export default function AdminFileBlobs() {
|
||||
/>
|
||||
</label>
|
||||
<label className="group relative block">
|
||||
<select
|
||||
<AdminSelect
|
||||
value={filters.entityType}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -293,7 +304,7 @@ export default function AdminFileBlobs() {
|
||||
entityType: event.target.value as AdminFileBlobEntityType | '',
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
className="w-full font-black text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
<option value="">全部实体类型</option>
|
||||
{Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => (
|
||||
@@ -301,7 +312,7 @@ export default function AdminFileBlobs() {
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
|
||||
import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function AdminFilesList() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [ownerQuery, setOwnerQuery] = useState('');
|
||||
const [files, setFiles] = useState<AdminFile[]>([]);
|
||||
const [pendingDeleteFile, setPendingDeleteFile] = useState<AdminFile | null>(null);
|
||||
|
||||
async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) {
|
||||
setError('');
|
||||
@@ -43,6 +45,21 @@ export default function AdminFilesList() {
|
||||
void loadFiles();
|
||||
}, []);
|
||||
|
||||
async function handleConfirmDeleteFile() {
|
||||
if (!pendingDeleteFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = pendingDeleteFile;
|
||||
setPendingDeleteFile(null);
|
||||
try {
|
||||
await deleteAdminFile(target.id);
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '彻底删除文件失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -148,13 +165,7 @@ export default function AdminFilesList() {
|
||||
<td className="px-8 py-5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确认物理擦除 ${file.filename} 吗?此操作将触发硬件级销毁。`)) {
|
||||
return;
|
||||
}
|
||||
await deleteAdminFile(file.id);
|
||||
await loadFiles();
|
||||
}}
|
||||
onClick={() => setPendingDeleteFile(file)}
|
||||
className="p-2.5 rounded-lg glass-panel hover:bg-red-600 hover:text-white text-red-500 border border-white/10 transition-all opacity-0 group-hover:opacity-100 shadow-sm"
|
||||
title="彻底删除"
|
||||
>
|
||||
@@ -176,6 +187,22 @@ export default function AdminFilesList() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdminAlertDialog
|
||||
open={pendingDeleteFile !== null}
|
||||
title="彻底删除文件"
|
||||
description={
|
||||
pendingDeleteFile
|
||||
? `确认物理擦除 ${pendingDeleteFile.filename} 吗?此操作将触发硬件级销毁。`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="确认删除"
|
||||
cancelLabel="取消"
|
||||
confirmTone="danger"
|
||||
busy={loading && pendingDeleteFile !== null}
|
||||
onConfirm={handleConfirmDeleteFile}
|
||||
onCancel={() => setPendingDeleteFile(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,10 +69,12 @@ function sectionTitle(title: string, subtitle: string) {
|
||||
export default function AdminFilesystem() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [filesystem, setFilesystem] = useState<AdminFilesystemResponse | null>(null);
|
||||
|
||||
async function loadFilesystem() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
setFilesystem(await getAdminFilesystem());
|
||||
} catch (err) {
|
||||
@@ -92,8 +94,11 @@ export default function AdminFilesystem() {
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setNotice('标识已复制');
|
||||
setError('');
|
||||
} catch {
|
||||
window.alert('复制失败,请手动复制。');
|
||||
setError('复制失败,请手动复制。');
|
||||
setNotice('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +170,7 @@ export default function AdminFilesystem() {
|
||||
</div>
|
||||
|
||||
{error ? <div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
{notice ? <div className="mb-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-blue-600 dark:text-blue-300">{notice}</div> : null}
|
||||
|
||||
{loading && !filesystem ? (
|
||||
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">正在读取文件系统快照...</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useForm } from 'react-hook-form';
|
||||
import { Copy, Database, RefreshCw, RotateCcw, Save, Server, Settings, Shield, Clock3, Layers3 } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
|
||||
import { AdminInput } from '@/src/components/admin/AdminInput';
|
||||
import {
|
||||
getAdminSettings,
|
||||
rotateAdminRegistrationInviteCode,
|
||||
@@ -139,6 +141,7 @@ export default function AdminSettingsPage() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [savingInviteCode, setSavingInviteCode] = useState(false);
|
||||
const [rotatingInviteCode, setRotatingInviteCode] = useState(false);
|
||||
const [rotateInviteDialogOpen, setRotateInviteDialogOpen] = useState(false);
|
||||
const [savingTransferLimit, setSavingTransferLimit] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
@@ -208,10 +211,6 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
|
||||
async function handleRotateInviteCode() {
|
||||
if (!window.confirm('确定要轮换邀请码吗?旧邀请码会立即失效。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRotatingInviteCode(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
@@ -226,6 +225,11 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmRotateInviteCode() {
|
||||
setRotateInviteDialogOpen(false);
|
||||
await handleRotateInviteCode();
|
||||
}
|
||||
|
||||
async function handleSaveTransferLimit(values: OfflineTransferLimitFormValues) {
|
||||
const nextLimit = values.offlineTransferStorageLimitBytes;
|
||||
if (!Number.isInteger(nextLimit) || nextLimit <= 0) {
|
||||
@@ -384,7 +388,7 @@ export default function AdminSettingsPage() {
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||
编辑邀请码
|
||||
</span>
|
||||
<input
|
||||
<AdminInput
|
||||
{...inviteCodeForm.register('inviteCode', {
|
||||
required: '邀请码不能为空',
|
||||
maxLength: {
|
||||
@@ -396,7 +400,6 @@ export default function AdminSettingsPage() {
|
||||
maxLength={64}
|
||||
placeholder="输入新的邀请码"
|
||||
aria-invalid={inviteCodeForm.formState.errors.inviteCode ? 'true' : 'false'}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.25em] outline-none transition-all placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
{inviteCodeForm.formState.errors.inviteCode ? (
|
||||
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
|
||||
@@ -415,7 +418,7 @@ export default function AdminSettingsPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRotateInviteCode}
|
||||
onClick={() => setRotateInviteDialogOpen(true)}
|
||||
disabled={savingInviteCode || rotatingInviteCode}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/5 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] transition-all hover:bg-white/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@@ -467,7 +470,7 @@ export default function AdminSettingsPage() {
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||
输入新的字节数
|
||||
</span>
|
||||
<input
|
||||
<AdminInput
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
@@ -478,7 +481,6 @@ export default function AdminSettingsPage() {
|
||||
Number.isInteger(value) && value > 0 ? true : '离线快传存储上限必须是大于 0 的整数',
|
||||
})}
|
||||
aria-invalid={offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? 'true' : 'false'}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.2em] outline-none transition-all placeholder:opacity-20 focus:border-amber-500/50 focus:ring-4 focus:ring-amber-500/10"
|
||||
/>
|
||||
{offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? (
|
||||
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
|
||||
@@ -615,6 +617,18 @@ export default function AdminSettingsPage() {
|
||||
</section>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<AdminAlertDialog
|
||||
open={rotateInviteDialogOpen}
|
||||
title="轮换邀请码"
|
||||
description="旧邀请码会立即失效,新的邀请码将覆盖当前注册入口。"
|
||||
confirmLabel="确认轮换"
|
||||
cancelLabel="取消"
|
||||
confirmTone="warning"
|
||||
busy={rotatingInviteCode}
|
||||
onConfirm={handleConfirmRotateInviteCode}
|
||||
onCancel={() => setRotateInviteDialogOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
} from '@tanstack/react-table';
|
||||
import { AdminSelect } from '@/src/components/admin/AdminSelect';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
import { deleteAdminShare, getAdminShares, type AdminShare } from '@/src/lib/admin-shares';
|
||||
|
||||
@@ -70,9 +72,11 @@ function boolBadge(active: boolean, activeLabel: string, inactiveLabel: string,
|
||||
export default function AdminShares() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const [shares, setShares] = useState<AdminShare[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pendingDeleteShare, setPendingDeleteShare] = useState<AdminShare | null>(null);
|
||||
|
||||
async function loadShares(nextFilters = filters) {
|
||||
setError('');
|
||||
@@ -94,9 +98,28 @@ export default function AdminShares() {
|
||||
async function copyText(value: string, successMessage: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
window.alert(successMessage);
|
||||
setNotice(successMessage);
|
||||
setError('');
|
||||
} catch {
|
||||
setError('复制失败,请手动复制。');
|
||||
setNotice('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDeleteShare() {
|
||||
if (!pendingDeleteShare) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = pendingDeleteShare;
|
||||
setPendingDeleteShare(null);
|
||||
try {
|
||||
await deleteAdminShare(target.id);
|
||||
setLoading(true);
|
||||
await loadShares();
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : '删除分享失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,14 +263,7 @@ export default function AdminShares() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确认删除分享 ${share.shareName || share.fileName} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
await deleteAdminShare(share.id);
|
||||
setLoading(true);
|
||||
await loadShares();
|
||||
}}
|
||||
onClick={() => setPendingDeleteShare(share)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-red-500 transition-all hover:bg-red-500 hover:text-white"
|
||||
title="删除分享"
|
||||
>
|
||||
@@ -331,26 +347,26 @@ export default function AdminShares() {
|
||||
/>
|
||||
</label>
|
||||
<label className="relative block group">
|
||||
<select
|
||||
<AdminSelect
|
||||
value={filters.passwordProtected}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, passwordProtected: event.target.value as 'true' | 'false' | '' }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
className="w-full font-black text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
<option value="">密码保护</option>
|
||||
<option value="true">需要密码</option>
|
||||
<option value="false">无需密码</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</label>
|
||||
<label className="relative block group">
|
||||
<select
|
||||
<AdminSelect
|
||||
value={filters.expired}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, expired: event.target.value as 'true' | 'false' | '' }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
className="w-full font-black text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
<option value="">过期状态</option>
|
||||
<option value="true">已过期</option>
|
||||
<option value="false">未过期</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -394,6 +410,12 @@ export default function AdminShares() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<div className="mb-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-blue-600 dark:text-blue-300">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||
<span>共 {total} 条分享记录</span>
|
||||
<span>当前页 {shares.length} 条</span>
|
||||
@@ -458,6 +480,22 @@ export default function AdminShares() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdminAlertDialog
|
||||
open={pendingDeleteShare !== null}
|
||||
title="删除分享"
|
||||
description={
|
||||
pendingDeleteShare
|
||||
? `确认删除分享 ${pendingDeleteShare.shareName || pendingDeleteShare.fileName} 吗?删除后该分享将立即失效。`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="确认删除"
|
||||
cancelLabel="取消"
|
||||
confirmTone="danger"
|
||||
busy={loading && pendingDeleteShare !== null}
|
||||
onConfirm={handleConfirmDeleteShare}
|
||||
onCancel={() => setPendingDeleteShare(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
type StoragePolicyCapabilities,
|
||||
type StoragePolicyUpsertPayload,
|
||||
} from '@/src/lib/admin-storage-policies';
|
||||
import { AdminDialog } from '@/src/components/admin/AdminDialog';
|
||||
import { AdminInput } from '@/src/components/admin/AdminInput';
|
||||
import { AdminSelect } from '@/src/components/admin/AdminSelect';
|
||||
import { formatBytes } from '@/src/lib/format';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
@@ -421,217 +424,215 @@ export default function AdminStoragePoliciesList() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md px-4 py-8 overflow-y-auto mt-0">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-12 shadow-2xl border-white/20"
|
||||
>
|
||||
<h2 className="mb-10 text-3xl font-black tracking-tighter uppercase">{editingPolicy ? '编辑策略' : '新建策略'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">策略名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">驱动协议</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value as StoragePolicyUpsertPayload['type'] }))}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
>
|
||||
<option value="LOCAL">本地文件系统</option>
|
||||
<option value="S3_COMPATIBLE">S3 兼容接口</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">端点地址</label>
|
||||
<input
|
||||
value={form.endpoint || ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">桶名称</label>
|
||||
<input
|
||||
value={form.bucketName || ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bucketName: event.target.value }))}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">对象大小上限(字节)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.maxSizeBytes}
|
||||
onChange={(event) => setForm((current) => ({ ...current, maxSizeBytes: Number(event.target.value), capabilities: { ...current.capabilities, maxObjectSize: Number(event.target.value) } }))}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
['privateBucket', '私有桶'],
|
||||
['enabled', '启用'],
|
||||
['capabilities.directUpload', '直传'],
|
||||
['capabilities.multipartUpload', '分片上传'],
|
||||
['capabilities.signedDownloadUrl', '签名下载'],
|
||||
['capabilities.serverProxyDownload', '代理下载'],
|
||||
['capabilities.requiresCors', '需要 CORS'],
|
||||
['capabilities.supportsInternalEndpoint', '内网端点'],
|
||||
] as const
|
||||
).map(([key, label]) => {
|
||||
const checked =
|
||||
key === 'privateBucket'
|
||||
? form.privateBucket
|
||||
: key === 'enabled'
|
||||
? form.enabled
|
||||
: form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
|
||||
const checkedBoolean = Boolean(checked);
|
||||
return (
|
||||
<label key={key} className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group",
|
||||
checkedBoolean ? "bg-white/5 border-white/10" : "opacity-30"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedBoolean}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.checked;
|
||||
if (key === 'privateBucket') {
|
||||
setForm((current) => ({ ...current, privateBucket: nextValue }));
|
||||
return;
|
||||
}
|
||||
if (key === 'enabled') {
|
||||
setForm((current) => ({ ...current, enabled: nextValue }));
|
||||
return;
|
||||
}
|
||||
const capabilityKey = key.replace('capabilities.', '') as keyof StoragePolicyCapabilities;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
capabilities: {
|
||||
...current.capabilities,
|
||||
[capabilityKey]: nextValue,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 text-blue-600 focus:ring-0"
|
||||
/>
|
||||
<span className={cn("transition-colors", checked ? "text-blue-500" : "")}>
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingPolicy(null);
|
||||
}}
|
||||
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void savePolicy()}
|
||||
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence>
|
||||
{migratingPolicy ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md px-4 py-8 overflow-y-auto mt-0">
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-10 shadow-2xl border-white/20"
|
||||
<AdminDialog
|
||||
open={showForm}
|
||||
title={editingPolicy ? '编辑策略' : '新建策略'}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setShowForm(false);
|
||||
setEditingPolicy(null);
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingPolicy(null);
|
||||
}}
|
||||
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
<h2 className="text-3xl font-black tracking-tighter uppercase">发起迁移</h2>
|
||||
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">仅创建迁移任务,不会立即执行对象复制</p>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void savePolicy()}
|
||||
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">策略名称</label>
|
||||
<AdminInput
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">驱动协议</label>
|
||||
<AdminSelect
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value as StoragePolicyUpsertPayload['type'] }))}
|
||||
>
|
||||
<option value="LOCAL">本地文件系统</option>
|
||||
<option value="S3_COMPATIBLE">S3 兼容接口</option>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">端点地址</label>
|
||||
<AdminInput
|
||||
value={form.endpoint || ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">桶名称</label>
|
||||
<AdminInput
|
||||
value={form.bucketName || ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bucketName: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">对象大小上限(字节)</label>
|
||||
<AdminInput
|
||||
type="number"
|
||||
value={form.maxSizeBytes}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
maxSizeBytes: Number(event.target.value),
|
||||
capabilities: { ...current.capabilities, maxObjectSize: Number(event.target.value) },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 rounded-lg border border-white/10 bg-white/5 p-5">
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">源策略</div>
|
||||
<div className="mt-2 text-sm font-black tracking-tight">{migratingPolicy.name}</div>
|
||||
<div className="mt-1 text-[10px] font-bold opacity-40">PID::{migratingPolicy.id}</div>
|
||||
</div>
|
||||
<div className="h-px bg-white/10" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">选择已有目标策略</label>
|
||||
<select
|
||||
value={migrationTargetPolicyId}
|
||||
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
>
|
||||
<option value="">请选择目标策略</option>
|
||||
{policies
|
||||
.filter((item) => item.id !== migratingPolicy.id)
|
||||
.map((policy) => (
|
||||
<option key={policy.id} value={policy.id}>
|
||||
{policy.name} / PID::{policy.id} / {policy.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">或手动输入目标策略 ID</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={migrationTargetPolicyId}
|
||||
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||
placeholder="例如 12"
|
||||
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold leading-5 opacity-50">
|
||||
如果目标策略不在下拉框里,可以直接输入它的策略 ID。当前页面只负责创建迁移任务,不负责迁移进度展示。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
['privateBucket', '私有桶'],
|
||||
['enabled', '启用'],
|
||||
['capabilities.directUpload', '直传'],
|
||||
['capabilities.multipartUpload', '分片上传'],
|
||||
['capabilities.signedDownloadUrl', '签名下载'],
|
||||
['capabilities.serverProxyDownload', '代理下载'],
|
||||
['capabilities.requiresCors', '需要 CORS'],
|
||||
['capabilities.supportsInternalEndpoint', '内网端点'],
|
||||
] as const
|
||||
).map(([key, label]) => {
|
||||
const checked =
|
||||
key === 'privateBucket'
|
||||
? form.privateBucket
|
||||
: key === 'enabled'
|
||||
? form.enabled
|
||||
: form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
|
||||
const checkedBoolean = Boolean(checked);
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group',
|
||||
checkedBoolean ? 'bg-white/5 border-white/10' : 'opacity-30'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedBoolean}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.checked;
|
||||
if (key === 'privateBucket') {
|
||||
setForm((current) => ({ ...current, privateBucket: nextValue }));
|
||||
return;
|
||||
}
|
||||
if (key === 'enabled') {
|
||||
setForm((current) => ({ ...current, enabled: nextValue }));
|
||||
return;
|
||||
}
|
||||
const capabilityKey = key.replace('capabilities.', '') as keyof StoragePolicyCapabilities;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
capabilities: {
|
||||
...current.capabilities,
|
||||
[capabilityKey]: nextValue,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 text-blue-600 focus:ring-0"
|
||||
/>
|
||||
<span className={cn('transition-colors', checked ? 'text-blue-500' : '')}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AdminDialog>
|
||||
|
||||
<div className="mt-10 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMigrationDialog}
|
||||
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||
<AdminDialog
|
||||
open={Boolean(migratingPolicy)}
|
||||
title="发起迁移"
|
||||
description="仅创建迁移任务,不会立即执行对象复制"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
closeMigrationDialog();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMigrationDialog}
|
||||
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitMigration()}
|
||||
disabled={migrationSubmitting}
|
||||
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60 transition-all"
|
||||
>
|
||||
{migrationSubmitting ? '创建中...' : '创建迁移任务'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{migratingPolicy ? (
|
||||
<div className="grid gap-4 rounded-lg border border-white/10 bg-white/5 p-5">
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">源策略</div>
|
||||
<div className="mt-2 text-sm font-black tracking-tight">{migratingPolicy.name}</div>
|
||||
<div className="mt-1 text-[10px] font-bold opacity-40">PID::{migratingPolicy.id}</div>
|
||||
</div>
|
||||
<div className="h-px bg-white/10" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">选择已有目标策略</label>
|
||||
<AdminSelect
|
||||
value={migrationTargetPolicyId}
|
||||
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitMigration()}
|
||||
disabled={migrationSubmitting}
|
||||
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60 transition-all"
|
||||
>
|
||||
{migrationSubmitting ? '创建中...' : '创建迁移任务'}
|
||||
</button>
|
||||
<option value="">请选择目标策略</option>
|
||||
{policies
|
||||
.filter((item) => item.id !== migratingPolicy.id)
|
||||
.map((policy) => (
|
||||
<option key={policy.id} value={policy.id}>
|
||||
{policy.name} / PID::{policy.id} / {policy.type}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">或手动输入目标策略 ID</label>
|
||||
<AdminInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={migrationTargetPolicyId}
|
||||
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||
placeholder="例如 12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold leading-5 opacity-50">
|
||||
如果目标策略不在下拉框里,可以直接输入它的策略 ID。当前页面只负责创建迁移任务,不负责迁移进度展示。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</AdminDialog>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AdminSelect } from '@/src/components/admin/AdminSelect';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { formatDateTime } from '@/src/lib/format';
|
||||
import { getAdminTask, getAdminTasks, type AdminTask, type AdminTaskQuery } from '@/src/lib/admin-tasks';
|
||||
@@ -345,21 +346,21 @@ export default function AdminTasks() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="rounded-lg glass-panel px-4 py-3 text-[11px] font-black uppercase tracking-widest">
|
||||
<span className="mr-3 opacity-40">每页</span>
|
||||
<select
|
||||
<AdminSelect
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
const nextSize = Number(event.target.value);
|
||||
setPageSize(nextSize);
|
||||
void loadTasks(0, filters, nextSize);
|
||||
}}
|
||||
className="bg-transparent outline-none"
|
||||
className="w-auto min-w-[5rem] bg-transparent border-0 rounded-none p-0 pr-8 shadow-none focus:ring-0 focus:border-transparent font-black text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Check, Clipboard, KeyRound, PencilLine, RefreshCw, Search, Shield, Mail, Phone, X } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table';
|
||||
import { AdminSelect } from '@/src/components/admin/AdminSelect';
|
||||
import { AdminInput } from '@/src/components/admin/AdminInput';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import {
|
||||
getAdminUsers,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
updateUserStorageQuota,
|
||||
type AdminUser,
|
||||
} from '@/src/lib/admin-users';
|
||||
import { AdminDialog } from '@/src/components/admin/AdminDialog';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
|
||||
const container = {
|
||||
@@ -85,6 +88,7 @@ export default function AdminUsersList() {
|
||||
const [temporaryPasswords, setTemporaryPasswords] = useState<Record<number, string>>({});
|
||||
const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState<number | null>(null);
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
trigger,
|
||||
getValues,
|
||||
@@ -456,274 +460,260 @@ export default function AdminUsersList() {
|
||||
|
||||
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
|
||||
|
||||
<div className="grid flex-1 min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">正在查询用户数据...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/10 dark:bg-black/40">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] opacity-40",
|
||||
header.column.id === 'actions' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<motion.tbody
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="divide-y divide-white/10 dark:divide-white/5"
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const user = row.original;
|
||||
const isEditing = editingUser?.id === user.id;
|
||||
return (
|
||||
<motion.tr
|
||||
key={row.id}
|
||||
variants={itemVariants}
|
||||
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/10 dark:bg-black/40">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"group transition-colors",
|
||||
isEditing ? "bg-blue-500/10 dark:bg-blue-500/5" : "hover:bg-white/10 dark:hover:bg-white/5"
|
||||
'px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] opacity-40',
|
||||
header.column.id === 'actions' ? 'text-right' : 'text-left',
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"px-8 py-5 align-top",
|
||||
cell.column.id === 'actions' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
|
||||
暂无用户记录
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</motion.tbody>
|
||||
</table>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<motion.tbody
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="divide-y divide-white/10 dark:divide-white/5"
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const user = row.original;
|
||||
const isEditing = editingUser?.id === user.id;
|
||||
return (
|
||||
<motion.tr
|
||||
key={row.id}
|
||||
variants={itemVariants}
|
||||
className={cn(
|
||||
'group transition-colors',
|
||||
isEditing ? 'bg-blue-500/10 dark:bg-blue-500/5' : 'hover:bg-white/10 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'px-8 py-5 align-top',
|
||||
cell.column.id === 'actions' ? 'text-right' : 'text-left',
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
|
||||
暂无用户记录
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</motion.tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdminDialog
|
||||
open={Boolean(editingUser)}
|
||||
layout="side-panel"
|
||||
title="用户策略编辑"
|
||||
description={
|
||||
editingUser
|
||||
? '这里负责角色、存储配额、最大上传限制和手动改密。临时密码生成仍保留在表格快捷操作里,避免和手动改密混在一起。'
|
||||
: '从左侧表格点击“编辑”按钮,右侧会自动展开该用户的策略面板。'
|
||||
}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
closeEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editingUser ? (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">当前账号</div>
|
||||
<div className="mt-2 text-[12px] font-black uppercase tracking-tight">{editingUser.email}</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border',
|
||||
editingUser.banned
|
||||
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||
)}
|
||||
>
|
||||
{editingUser.banned ? '已禁用' : '正常'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] font-black uppercase tracking-tight">
|
||||
{formatBytes(editingUser.usedStorageBytes)} / <span className="opacity-30">{formatBytes(editingUser.storageQuotaBytes)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 w-full rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||
style={{ width: `${Math.min(100, (editingUser.usedStorageBytes / editingUser.storageQuotaBytes) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl xl:sticky xl:top-6 xl:self-start">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">用户策略编辑</div>
|
||||
<h2 className="mt-2 text-lg font-black tracking-tight uppercase">
|
||||
{editingUser ? editingUser.username : '请选择用户'}
|
||||
</h2>
|
||||
<p className="mt-2 text-[10px] font-bold opacity-40 leading-relaxed">
|
||||
{editingUser
|
||||
? '这里负责角色、存储配额、最大上传限制和手动改密。临时密码生成仍保留在表格快捷操作里,避免和手动改密混在一起。'
|
||||
: '从左侧表格点击“编辑”打开该用户的策略面板。'}
|
||||
</p>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">基础配置</div>
|
||||
<div className="mt-1 text-[11px] font-bold opacity-50">修改角色、存储配额和最大上传限制后,点击保存即可生效。</div>
|
||||
</div>
|
||||
{editingUser ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest opacity-50 transition-colors hover:bg-white/10 hover:opacity-100"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
<Shield className="h-3 w-3" />
|
||||
{watchedRole}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editingUser ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">当前账号</div>
|
||||
<div className="mt-2 text-[12px] font-black uppercase tracking-tight">{editingUser.email}</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
|
||||
editingUser.banned
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
)}
|
||||
>
|
||||
{editingUser.banned ? '已禁用' : '正常'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] font-black uppercase tracking-tight">
|
||||
{formatBytes(editingUser.usedStorageBytes)} / <span className="opacity-30">{formatBytes(editingUser.storageQuotaBytes)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 w-full rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||
style={{ width: `${Math.min(100, (editingUser.usedStorageBytes / editingUser.storageQuotaBytes) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">基础配置</div>
|
||||
<div className="mt-1 text-[11px] font-bold opacity-50">修改角色、存储配额和最大上传限制后,点击保存即可生效。</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
<Shield className="h-3 w-3" />
|
||||
{watchedRole}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">角色</span>
|
||||
<select
|
||||
{...register('role', {
|
||||
validate: (value) => (value === 'USER' || value === 'ADMIN' ? true : '请选择有效角色'),
|
||||
})}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
>
|
||||
<option value="USER">USER - 普通用户</option>
|
||||
<option value="ADMIN">ADMIN - 管理员</option>
|
||||
</select>
|
||||
{errors.role ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
|
||||
{errors.role.message}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">存储配额(字节)</span>
|
||||
<input
|
||||
{...register('storageQuotaBytes', {
|
||||
validate: (value) => validateNonNegativeBytes(value, '存储配额'),
|
||||
})}
|
||||
inputMode="numeric"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
{errors.storageQuotaBytes ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
|
||||
{errors.storageQuotaBytes.message}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">最大上传限制(字节)</span>
|
||||
<input
|
||||
{...register('maxUploadSizeBytes', {
|
||||
validate: (value) => validateNonNegativeBytes(value, '最大上传限制'),
|
||||
})}
|
||||
inputMode="numeric"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
{errors.maxUploadSizeBytes ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
|
||||
{errors.maxUploadSizeBytes.message}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveEditorProfile()}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-lg bg-blue-600 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-white transition-colors hover:bg-blue-500"
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">角色</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
rules={{
|
||||
validate: (value) => (value === 'USER' || value === 'ADMIN' ? true : '请选择有效角色'),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<AdminSelect
|
||||
value={field.value}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
className="w-full text-[11px] font-black uppercase tracking-widest"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
保存基础配置
|
||||
</button>
|
||||
</div>
|
||||
<option value="USER">USER - 普通用户</option>
|
||||
<option value="ADMIN">ADMIN - 管理员</option>
|
||||
</AdminSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.role ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">{errors.role.message}</p>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-amber-500">手动设置密码</div>
|
||||
<div className="mt-1 text-[10px] font-bold opacity-60 leading-relaxed">
|
||||
这里是人工指定一个新密码,会直接覆盖当前密码。它和“生成临时密码”是两条不同的管理路径。
|
||||
</div>
|
||||
</div>
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">新密码</span>
|
||||
<input
|
||||
{...register('manualPassword', {
|
||||
validate: (value) => (value.trim() ? true : '请输入要手动设置的新密码'),
|
||||
})}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-white/10 bg-black/20 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-amber-500/50 focus:ring-4 focus:ring-amber-500/10"
|
||||
placeholder="输入后点击“手动设置密码”"
|
||||
/>
|
||||
{errors.manualPassword ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-amber-500">
|
||||
{errors.manualPassword.message}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitManualPassword()}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-lg border border-amber-500/20 bg-amber-500/15 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-amber-500 transition-colors hover:bg-amber-500 hover:text-white"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
手动设置密码
|
||||
</button>
|
||||
<p className="text-[10px] font-bold opacity-50 leading-relaxed">
|
||||
适合人工恢复账号、统一初始化密码或和用户同步已知密码。若要发放一次性密码,请继续使用表格里的“生成临时密码”。
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">存储配额(字节)</span>
|
||||
<AdminInput
|
||||
{...register('storageQuotaBytes', {
|
||||
validate: (value) => validateNonNegativeBytes(value, '存储配额'),
|
||||
})}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
{errors.storageQuotaBytes ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
|
||||
{errors.storageQuotaBytes.message}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">最大上传限制(字节)</span>
|
||||
<AdminInput
|
||||
{...register('maxUploadSizeBytes', {
|
||||
validate: (value) => validateNonNegativeBytes(value, '最大上传限制'),
|
||||
})}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
{errors.maxUploadSizeBytes ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
|
||||
{errors.maxUploadSizeBytes.message}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">账号状态</div>
|
||||
<div className="mt-2 text-[10px] font-bold opacity-50 leading-relaxed">
|
||||
可直接切换禁用 / 恢复,不影响上面的基础配置或手动改密表单。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void mutate(() => updateUserStatus(editingUser.id, !editingUser.banned))}
|
||||
className={cn(
|
||||
"mt-4 inline-flex w-full items-center justify-center gap-3 rounded-lg border px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] transition-colors",
|
||||
editingUser.banned
|
||||
? "border-green-500/20 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
|
||||
: "border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{editingUser.banned ? '恢复账号' : '禁用账号'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveEditorProfile()}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-lg bg-blue-600 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
保存基础配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-amber-500">手动设置密码</div>
|
||||
<div className="mt-1 text-[10px] font-bold opacity-60 leading-relaxed">
|
||||
这里是人工指定一个新密码,会直接覆盖当前密码。它和“生成临时密码”是两条不同的管理路径。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-10 rounded-lg border border-dashed border-white/10 px-6 py-12 text-center">
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-25">编辑面板为空</div>
|
||||
<p className="mt-3 text-[11px] font-bold opacity-40 leading-relaxed">
|
||||
点击左侧任意用户行的“编辑”按钮,右侧会自动展开该用户的角色、配额和密码管理表单。
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">新密码</span>
|
||||
<AdminInput
|
||||
{...register('manualPassword', {
|
||||
validate: (value) => (value.trim() ? true : '请输入要手动设置的新密码'),
|
||||
})}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="bg-black/20 focus:border-amber-500/50 focus:ring-amber-500/10"
|
||||
placeholder="输入后点击“手动设置密码”"
|
||||
/>
|
||||
{errors.manualPassword ? (
|
||||
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-amber-500">
|
||||
{errors.manualPassword.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitManualPassword()}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-lg border border-amber-500/20 bg-amber-500/15 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-amber-500 transition-colors hover:bg-amber-500 hover:text-white"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
手动设置密码
|
||||
</button>
|
||||
<p className="text-[10px] font-bold opacity-50 leading-relaxed">
|
||||
适合人工恢复账号、统一初始化密码或和用户同步已知密码。若要发放一次性密码,请继续使用表格里的“生成临时密码”。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">账号状态</div>
|
||||
<div className="mt-2 text-[10px] font-bold opacity-50 leading-relaxed">
|
||||
可直接切换禁用 / 恢复,不影响上面的基础配置或手动改密表单。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void mutate(() => updateUserStatus(editingUser.id, !editingUser.banned))}
|
||||
className={cn(
|
||||
'mt-4 inline-flex w-full items-center justify-center gap-3 rounded-lg border px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] transition-colors',
|
||||
editingUser.banned
|
||||
? 'border-green-500/20 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{editingUser.banned ? '恢复账号' : '禁用账号'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminDialog>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
93
front/src/components/admin/AdminAlertDialog.tsx
Normal file
93
front/src/components/admin/AdminAlertDialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
type AdminAlertDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmTone?: 'danger' | 'warning';
|
||||
busy?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function AdminAlertDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = '确认',
|
||||
cancelLabel = '取消',
|
||||
confirmTone = 'danger',
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: AdminAlertDialogProps) {
|
||||
const toneClasses =
|
||||
confirmTone === 'warning'
|
||||
? 'border-amber-500/20 bg-amber-500/10 text-amber-400'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-400';
|
||||
|
||||
return (
|
||||
<AlertDialog.Root
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !busy) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out" />
|
||||
<AlertDialog.Content
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (busy) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="fixed inset-0 z-[101] flex items-center justify-center px-4 py-6"
|
||||
>
|
||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/10 bg-gray-950/95 p-6 text-gray-100 shadow-[0_30px_120px_rgba(0,0,0,0.55)]">
|
||||
<div className={cn('mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border', toneClasses)}>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<AlertDialog.Title className="text-xl font-black tracking-tight">{title}</AlertDialog.Title>
|
||||
<AlertDialog.Description className="mt-3 text-sm leading-6 text-gray-300">
|
||||
{description}
|
||||
</AlertDialog.Description>
|
||||
|
||||
<div className="mt-8 flex flex-wrap justify-end gap-3">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-[0.18em] text-gray-100 transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-xl px-5 py-3 text-[11px] font-black uppercase tracking-[0.18em] text-white transition-all hover:scale-[1.01] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
confirmTone === 'warning' ? 'bg-amber-500 hover:bg-amber-400' : 'bg-red-600 hover:bg-red-500',
|
||||
)}
|
||||
>
|
||||
{busy ? '处理中' : confirmLabel}
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
187
front/src/components/admin/AdminDialog.tsx
Normal file
187
front/src/components/admin/AdminDialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
export const AdminDialogRoot = Dialog.Root;
|
||||
export const AdminDialogTrigger = Dialog.Trigger;
|
||||
export const AdminDialogPortal = Dialog.Portal;
|
||||
export const AdminDialogOverlay = Dialog.Overlay;
|
||||
export const AdminDialogContent = Dialog.Content;
|
||||
export const AdminDialogTitle = Dialog.Title;
|
||||
export const AdminDialogDescription = Dialog.Description;
|
||||
export const AdminDialogClose = Dialog.Close;
|
||||
|
||||
type AdminDialogLayout = 'center' | 'side-panel';
|
||||
type AdminDialogAccent = 'default' | 'warning' | 'danger' | 'success';
|
||||
|
||||
type AdminDialogProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
layout?: AdminDialogLayout;
|
||||
mode?: AdminDialogLayout;
|
||||
size?: string;
|
||||
accent?: AdminDialogAccent;
|
||||
dismissible?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
footerClassName?: string;
|
||||
titleClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
closeLabel?: string;
|
||||
};
|
||||
|
||||
const accentClasses: Record<AdminDialogAccent, string> = {
|
||||
default: 'border-blue-500/20 bg-blue-500/10 text-blue-400',
|
||||
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
|
||||
danger: 'border-red-500/20 bg-red-500/10 text-red-400',
|
||||
success: 'border-green-500/20 bg-green-500/10 text-green-400',
|
||||
};
|
||||
|
||||
export function AdminDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
layout,
|
||||
mode,
|
||||
size,
|
||||
accent = 'default',
|
||||
dismissible = true,
|
||||
showCloseButton = true,
|
||||
footer,
|
||||
children,
|
||||
onOpenChange,
|
||||
className,
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
headerClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
closeLabel = '关闭对话框',
|
||||
}: AdminDialogProps) {
|
||||
const resolvedLayout = layout ?? mode ?? (size === 'side-panel' ? 'side-panel' : 'center');
|
||||
const isSidePanel = resolvedLayout === 'side-panel';
|
||||
const sizeClasses =
|
||||
size === 'sm'
|
||||
? 'max-w-[min(96vw,28rem)]'
|
||||
: size === 'md'
|
||||
? 'max-w-[min(96vw,36rem)]'
|
||||
: size === 'xl'
|
||||
? 'max-w-[min(96vw,56rem)]'
|
||||
: size === '2xl'
|
||||
? 'max-w-[min(96vw,72rem)]'
|
||||
: size === 'full'
|
||||
? 'max-w-[calc(100vw-2rem)]'
|
||||
: 'max-w-[min(96vw,48rem)]';
|
||||
const shellClasses = cn(
|
||||
'glass-panel-no-hover relative flex min-h-0 w-full flex-col overflow-hidden text-gray-900 dark:text-gray-100',
|
||||
isSidePanel
|
||||
? 'h-[100dvh] max-w-[min(100vw,40rem)] rounded-none border-l border-white/10 shadow-[0_30px_120px_rgba(0,0,0,0.55)]'
|
||||
: cn('max-h-[min(calc(100dvh-3rem),48rem)] rounded-3xl border border-white/10 shadow-[0_30px_120px_rgba(0,0,0,0.55)]', sizeClasses),
|
||||
panelClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
overlayClassName,
|
||||
)}
|
||||
/>
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
'fixed z-[101] outline-none',
|
||||
isSidePanel
|
||||
? 'inset-y-0 right-0 flex w-full justify-end p-0'
|
||||
: 'inset-0 flex items-center justify-center px-4 py-6 sm:px-6',
|
||||
className,
|
||||
)}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!dismissible) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (!dismissible) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (!dismissible) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={shellClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-4 border-b border-white/10 px-6 py-6',
|
||||
!isSidePanel && 'rounded-t-3xl',
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-4">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border',
|
||||
accentClasses[accent],
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title className={cn('text-xl font-black tracking-tight text-gray-900 dark:text-gray-100', titleClassName)}>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
{description ? (
|
||||
<Dialog.Description
|
||||
className={cn('mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300', descriptionClassName)}
|
||||
>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCloseButton ? (
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 p-2 text-gray-500 transition-colors hover:bg-white/10 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-6', bodyClassName)}>{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<div className={cn('border-t border-white/10 px-6 py-5', footerClassName)}>{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
21
front/src/components/admin/AdminInput.tsx
Normal file
21
front/src/components/admin/AdminInput.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
export type AdminInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(function AdminInput(
|
||||
{ className, type = 'text', ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors placeholder:opacity-40 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
155
front/src/components/admin/AdminSelect.tsx
Normal file
155
front/src/components/admin/AdminSelect.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as Select from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Children, isValidElement, type ReactNode } from 'react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
type AdminSelectChangeEvent = {
|
||||
target: {
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ParsedOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type WidthPreset = 'field' | 'filter' | 'compact' | 'fit';
|
||||
|
||||
type AdminSelectProps = {
|
||||
value: string | number;
|
||||
onChange: (event: AdminSelectChangeEvent) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
width?: WidthPreset;
|
||||
};
|
||||
|
||||
const EMPTY_VALUE_SENTINEL = '__ADMIN_SELECT_EMPTY__';
|
||||
|
||||
function stringifyNode(node: ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((item) => stringifyNode(item)).join('').trim();
|
||||
}
|
||||
|
||||
if (isValidElement<{ children?: ReactNode }>(node)) {
|
||||
return stringifyNode(node.props.children);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseOptions(children: ReactNode): { options: ParsedOption[]; hasEmptyOption: boolean } {
|
||||
const options: ParsedOption[] = [];
|
||||
let hasEmptyOption = false;
|
||||
|
||||
Children.forEach(children, (child) => {
|
||||
if (!isValidElement<{ value?: string | number; disabled?: boolean; children?: ReactNode }>(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawValue = child.props.value;
|
||||
const value = rawValue == null ? '' : String(rawValue);
|
||||
if (value === '') {
|
||||
hasEmptyOption = true;
|
||||
}
|
||||
|
||||
options.push({
|
||||
value,
|
||||
label: stringifyNode(child.props.children),
|
||||
disabled: Boolean(child.props.disabled),
|
||||
});
|
||||
});
|
||||
|
||||
return { options, hasEmptyOption };
|
||||
}
|
||||
|
||||
export function AdminSelect({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
width = 'field',
|
||||
}: AdminSelectProps) {
|
||||
const { options, hasEmptyOption } = parseOptions(children);
|
||||
const normalizedValue = String(value ?? '');
|
||||
const resolvedValue = normalizedValue === '' && hasEmptyOption ? EMPTY_VALUE_SENTINEL : normalizedValue;
|
||||
const selectedOption = options.find((option) => option.value === normalizedValue);
|
||||
const placeholder = options.find((option) => option.value === '')?.label ?? '请选择';
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={resolvedValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextValue) => {
|
||||
const resolvedNextValue = nextValue === EMPTY_VALUE_SENTINEL ? '' : nextValue;
|
||||
onChange({
|
||||
target: {
|
||||
value: resolvedNextValue,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/10 px-4 py-4 text-left text-sm font-bold text-gray-900 outline-none transition-all data-[placeholder]:text-gray-500 dark:text-gray-100 dark:data-[placeholder]:text-gray-400',
|
||||
'focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
width === 'filter' && 'min-h-[3.25rem] text-[11px] font-black uppercase tracking-widest',
|
||||
width === 'compact' && 'min-h-0 rounded-none border-0 bg-transparent p-0 pr-8 shadow-none focus:ring-0 focus:border-transparent text-[11px] font-black uppercase tracking-widest',
|
||||
width === 'fit' && 'w-auto',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Select.Value placeholder={placeholder}>
|
||||
{selectedOption?.label}
|
||||
</Select.Value>
|
||||
<Select.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
position="popper"
|
||||
sideOffset={8}
|
||||
className="z-[120] overflow-hidden rounded-2xl border border-white/10 bg-gray-950/95 text-gray-100 shadow-[0_24px_80px_rgba(0,0,0,0.45)] backdrop-blur-2xl"
|
||||
>
|
||||
<Select.ScrollUpButton className="flex h-8 items-center justify-center text-gray-400">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Select.ScrollUpButton>
|
||||
<Select.Viewport className="min-w-[var(--radix-select-trigger-width)] p-2">
|
||||
{options.map((option) => {
|
||||
const optionValue = option.value === '' ? EMPTY_VALUE_SENTINEL : option.value;
|
||||
|
||||
return (
|
||||
<Select.Item
|
||||
key={`${optionValue}-${option.label}`}
|
||||
value={optionValue}
|
||||
disabled={option.disabled}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-xl py-2.5 pl-3 pr-9 text-sm font-bold text-gray-100 outline-none transition-colors',
|
||||
'data-[highlighted]:bg-blue-500/15 data-[highlighted]:text-blue-300 data-[disabled]:pointer-events-none data-[disabled]:opacity-35',
|
||||
)}
|
||||
>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
<Select.ItemIndicator className="absolute right-3 inline-flex items-center justify-center text-blue-300">
|
||||
<Check className="h-4 w-4" />
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
);
|
||||
})}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton className="flex h-8 items-center justify-center text-gray-400">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user