前端整合开源组件

This commit is contained in:
yoyuzh
2026-04-12 12:42:52 +08:00
parent 820e055d22
commit ee08d9bf85
17 changed files with 2186 additions and 494 deletions

764
front/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
});

View 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>
);
}