前端整合开源组件

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

View File

@@ -64,6 +64,201 @@ cd front && npm run build
Expected: both commands pass. Expected: both commands pass.
### Task 9: Replace Hand-Styled Admin Search And Filter Inputs
**Files:**
- Modify: `front/src/admin/users-list.tsx`
- Modify: `front/src/admin/files-list.tsx`
- Modify: `front/src/admin/shares.tsx`
- Modify: `front/src/admin/fileblobs.tsx`
- Modify: `front/src/admin/tasks.tsx`
- Modify: `front/src/admin/audits.tsx`
- [ ] **Step 1: Replace search and filter inputs with the shared admin input**
Target:
```text
- 用户策略: 搜索框
- 文件治理: 文件名 / 所属用户
- 分享治理: 用户 / 文件名 / Token
- 对象治理: 用户 / 策略 ID / 对象键
- 任务监控: 多个筛选输入
- 审计日志: actor / action / targetType / targetId
```
- [ ] **Step 2: Verify Batch 9**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.
### Task 8: Replace Hand-Styled Admin Text And Number Inputs
**Files:**
- Add: `front/src/components/admin/AdminInput.tsx`
- Modify: `front/src/admin/users-list.tsx`
> Current execution scope for this batch is limited to `users-list.tsx`.
- [x] **Step 1: Add reusable admin input primitives**
Expected:
```text
- expose shared admin text/number input styling
- support form registration and controlled usage
- keep current focus, disabled, and validation affordances
```
- [x] **Step 2: Replace editable inputs in configuration-heavy pages**
Target:
```text
- 用户策略: 存储配额 / 最大上传限制 / 手动密码
```
- [x] **Step 3: Verify Batch 8**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.
### Task 6: Replace Hand-Rolled Admin Select Controls
**Files:**
- Modify: `front/package.json`
- Modify: `front/package-lock.json`
- Add: `front/src/components/admin/AdminSelect.tsx`
- Modify: `front/src/admin/shares.tsx`
- Modify: `front/src/admin/fileblobs.tsx`
- Modify: `front/src/admin/tasks.tsx`
- Modify: `front/src/admin/storage-policies-list.tsx`
- [x] **Step 1: Add a reusable admin select wrapper based on Radix Select**
Expected:
```text
- install an OSS select dependency
- expose a shared admin-styled select wrapper
- support placeholder, controlled value, and option groups used by admin pages
```
- [x] **Step 2: Replace filter selects in governance pages**
Target:
```text
- 分享治理: 密码保护 / 过期状态
- 对象治理: 实体类型
- 任务监控: 每页条数
```
- [x] **Step 3: Replace modal selects in storage policies**
Target:
```text
- 新建 / 编辑策略: 驱动协议
- 发起迁移: 目标策略选择
```
- [x] **Step 4: Verify Batch 6**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.
### Task 7: Replace The Remaining Admin Form Select
**Files:**
- Modify: `front/src/admin/users-list.tsx`
- [x] **Step 1: Replace the user role field with the shared admin select**
Target:
```text
- 用户策略: role 字段
- 保持 react-hook-form 校验和提交逻辑不变
```
- [x] **Step 2: Verify Batch 7**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.
### Task 5: Replace Hand-Rolled Admin Modal Shells
**Files:**
- Modify: `front/package.json`
- Modify: `front/package-lock.json`
- Add: `front/src/components/admin/AdminDialog.tsx`
- Modify: `front/src/admin/storage-policies-list.tsx`
- Modify: `front/src/admin/users-list.tsx`
- [x] **Step 1: Add a reusable admin dialog wrapper based on Radix Dialog**
Expected:
```text
- install an OSS dialog dependency
- expose a shared admin-styled modal/sheet wrapper
- support centered modal and right-side panel layouts
```
- [x] **Step 2: Replace storage policy form and migration overlays**
Target:
```text
- 新建 / 编辑策略弹层
- 发起迁移弹层
```
- [x] **Step 3: Replace the user strategy editor shell**
Target:
```text
- 用对话框 / 侧栏承载用户策略编辑
- 保留当前表单与快捷操作逻辑
```
- [x] **Step 4: Verify Batch 5**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.
### Task 2: Replace Admin List Tables With `@tanstack/react-table` ### Task 2: Replace Admin List Tables With `@tanstack/react-table`
**Files:** **Files:**
@@ -139,3 +334,58 @@ cd front && npm run build
``` ```
Expected: both commands pass. Expected: both commands pass.
### Task 4: Replace Native Admin Alerts And Confirms
**Files:**
- Modify: `front/package.json`
- Modify: `front/package-lock.json`
- Add: `front/src/components/admin/AdminAlertDialog.tsx`
- Modify: `front/src/admin/settings.tsx`
- Modify: `front/src/admin/shares.tsx`
- Modify: `front/src/admin/files-list.tsx`
- Modify: `front/src/admin/filesystem.tsx`
- Modify: `front/src/admin/fileblobs.tsx`
- Modify: `front/src/admin/audits.tsx`
- [x] **Step 1: Add a reusable admin alert dialog wrapper**
Expected:
```text
- install an OSS dialog dependency
- expose a shared admin-styled alert dialog wrapper
- keep current Tailwind visual system intact
```
- [x] **Step 2: Replace destructive confirm actions with in-app dialogs**
Target:
```text
- 系统设置: 轮换邀请码
- 分享治理: 删除分享
- 文件治理: 彻底删除
```
- [x] **Step 3: Replace remaining admin alert feedback with page-level notices**
Target:
```text
- 文件系统: 复制失败
- 对象实体: 复制失败
- 审计日志: 复制失败
- 分享治理: 复制成功 / 失败
```
- [x] **Step 4: Verify Batch 4**
Run:
```bash
cd front && npm run lint
cd front && npm run build
```
Expected: both commands pass.

764
front/package-lock.json generated
View File

@@ -9,6 +9,9 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@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", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
@@ -713,6 +716,44 @@
"node": ">=18" "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": { "node_modules/@google/genai": {
"version": "1.49.0", "version": "1.49.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.49.0.tgz", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.49.0.tgz",
@@ -845,6 +886,590 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1678,6 +2303,18 @@
"node": ">= 14" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2003,6 +2640,12 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "17.4.1", "version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
@@ -2463,6 +3106,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -3382,6 +4034,53 @@
"node": ">=0.10.0" "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": { "node_modules/react-router": {
"version": "7.14.0", "version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
@@ -3433,6 +4132,28 @@
"url": "https://opencollective.com/express" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -3845,6 +4566,49 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -12,6 +12,9 @@
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@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", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",

View File

@@ -99,6 +99,7 @@ export default function AdminAuditsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS); const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [page, setPage] = useState<{ const [page, setPage] = useState<{
items: AdminAuditLog[]; items: AdminAuditLog[];
@@ -134,8 +135,11 @@ export default function AdminAuditsPage() {
async function copyText(value: string) { async function copyText(value: string) {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setNotice('审计详情已复制');
setError('');
} catch { } catch {
window.alert('复制失败,请手动复制。'); setError('复制失败,请手动复制。');
setNotice('');
} }
} }
@@ -179,6 +183,12 @@ export default function AdminAuditsPage() {
</button> </button>
</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}
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { AlertTriangle, CheckCircle2, Copy, FileBox, RefreshCw, Search, ShieldAlert, XCircle } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Copy, FileBox, RefreshCw, Search, ShieldAlert, XCircle } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { AdminSelect } from '@/src/components/admin/AdminSelect';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { formatBytes, formatDateTime } from '@/src/lib/format'; import { formatBytes, formatDateTime } from '@/src/lib/format';
import { import {
@@ -114,6 +115,7 @@ export default function AdminFileBlobs() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS); const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [page, setPage] = useState<{ const [page, setPage] = useState<{
items: AdminFileBlobResponse[]; items: AdminFileBlobResponse[];
@@ -166,8 +168,11 @@ export default function AdminFileBlobs() {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setNotice('对象键已复制');
setError('');
} catch { } catch {
window.alert('复制失败,请手动复制。'); setError('复制失败,请手动复制。');
setNotice('');
} }
} }
@@ -214,6 +219,12 @@ export default function AdminFileBlobs() {
</div> </div>
</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.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}> <motion.div variants={itemVariants}>
{metricCard({ {metricCard({
@@ -285,7 +296,7 @@ export default function AdminFileBlobs() {
/> />
</label> </label>
<label className="group relative block"> <label className="group relative block">
<select <AdminSelect
value={filters.entityType} value={filters.entityType}
onChange={(event) => onChange={(event) =>
setFilters((current) => ({ setFilters((current) => ({
@@ -293,7 +304,7 @@ export default function AdminFileBlobs() {
entityType: event.target.value as AdminFileBlobEntityType | '', 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> <option value=""></option>
{Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ( {Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => (
@@ -301,7 +312,7 @@ export default function AdminFileBlobs() {
{label} {label}
</option> </option>
))} ))}
</select> </AdminSelect>
</label> </label>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react'; import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin'; import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin';
import { formatBytes, formatDateTime } from '@/src/lib/format'; import { formatBytes, formatDateTime } from '@/src/lib/format';
@@ -26,6 +27,7 @@ export default function AdminFilesList() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [ownerQuery, setOwnerQuery] = useState(''); const [ownerQuery, setOwnerQuery] = useState('');
const [files, setFiles] = useState<AdminFile[]>([]); const [files, setFiles] = useState<AdminFile[]>([]);
const [pendingDeleteFile, setPendingDeleteFile] = useState<AdminFile | null>(null);
async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) { async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) {
setError(''); setError('');
@@ -43,6 +45,21 @@ export default function AdminFilesList() {
void loadFiles(); 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 ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -148,13 +165,7 @@ export default function AdminFilesList() {
<td className="px-8 py-5 text-right"> <td className="px-8 py-5 text-right">
<button <button
type="button" type="button"
onClick={async () => { onClick={() => setPendingDeleteFile(file)}
if (!window.confirm(`确认物理擦除 ${file.filename} 吗?此操作将触发硬件级销毁。`)) {
return;
}
await deleteAdminFile(file.id);
await loadFiles();
}}
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" 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="彻底删除" title="彻底删除"
> >
@@ -176,6 +187,22 @@ export default function AdminFilesList() {
</div> </div>
)} )}
</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> </motion.div>
); );
} }

View File

@@ -69,10 +69,12 @@ function sectionTitle(title: string, subtitle: string) {
export default function AdminFilesystem() { export default function AdminFilesystem() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filesystem, setFilesystem] = useState<AdminFilesystemResponse | null>(null); const [filesystem, setFilesystem] = useState<AdminFilesystemResponse | null>(null);
async function loadFilesystem() { async function loadFilesystem() {
setError(''); setError('');
setNotice('');
try { try {
setFilesystem(await getAdminFilesystem()); setFilesystem(await getAdminFilesystem());
} catch (err) { } catch (err) {
@@ -92,8 +94,11 @@ export default function AdminFilesystem() {
} }
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setNotice('标识已复制');
setError('');
} catch { } catch {
window.alert('复制失败,请手动复制。'); setError('复制失败,请手动复制。');
setNotice('');
} }
} }
@@ -165,6 +170,7 @@ export default function AdminFilesystem() {
</div> </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} {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 ? ( {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> <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 { Copy, Database, RefreshCw, RotateCcw, Save, Server, Settings, Shield, Clock3, Layers3 } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
import { AdminInput } from '@/src/components/admin/AdminInput';
import { import {
getAdminSettings, getAdminSettings,
rotateAdminRegistrationInviteCode, rotateAdminRegistrationInviteCode,
@@ -139,6 +141,7 @@ export default function AdminSettingsPage() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [savingInviteCode, setSavingInviteCode] = useState(false); const [savingInviteCode, setSavingInviteCode] = useState(false);
const [rotatingInviteCode, setRotatingInviteCode] = useState(false); const [rotatingInviteCode, setRotatingInviteCode] = useState(false);
const [rotateInviteDialogOpen, setRotateInviteDialogOpen] = useState(false);
const [savingTransferLimit, setSavingTransferLimit] = useState(false); const [savingTransferLimit, setSavingTransferLimit] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
@@ -208,10 +211,6 @@ export default function AdminSettingsPage() {
} }
async function handleRotateInviteCode() { async function handleRotateInviteCode() {
if (!window.confirm('确定要轮换邀请码吗?旧邀请码会立即失效。')) {
return;
}
setRotatingInviteCode(true); setRotatingInviteCode(true);
setError(''); setError('');
setNotice(''); setNotice('');
@@ -226,6 +225,11 @@ export default function AdminSettingsPage() {
} }
} }
async function handleConfirmRotateInviteCode() {
setRotateInviteDialogOpen(false);
await handleRotateInviteCode();
}
async function handleSaveTransferLimit(values: OfflineTransferLimitFormValues) { async function handleSaveTransferLimit(values: OfflineTransferLimitFormValues) {
const nextLimit = values.offlineTransferStorageLimitBytes; const nextLimit = values.offlineTransferStorageLimitBytes;
if (!Number.isInteger(nextLimit) || nextLimit <= 0) { 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 className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
</span> </span>
<input <AdminInput
{...inviteCodeForm.register('inviteCode', { {...inviteCodeForm.register('inviteCode', {
required: '邀请码不能为空', required: '邀请码不能为空',
maxLength: { maxLength: {
@@ -396,7 +400,6 @@ export default function AdminSettingsPage() {
maxLength={64} maxLength={64}
placeholder="输入新的邀请码" placeholder="输入新的邀请码"
aria-invalid={inviteCodeForm.formState.errors.inviteCode ? 'true' : 'false'} 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 ? ( {inviteCodeForm.formState.errors.inviteCode ? (
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400"> <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>
<button <button
type="button" type="button"
onClick={handleRotateInviteCode} onClick={() => setRotateInviteDialogOpen(true)}
disabled={savingInviteCode || rotatingInviteCode} 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" 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 className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
</span> </span>
<input <AdminInput
type="number" type="number"
min={1} min={1}
step={1} step={1}
@@ -478,7 +481,6 @@ export default function AdminSettingsPage() {
Number.isInteger(value) && value > 0 ? true : '离线快传存储上限必须是大于 0 的整数', Number.isInteger(value) && value > 0 ? true : '离线快传存储上限必须是大于 0 的整数',
})} })}
aria-invalid={offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? 'true' : 'false'} 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 ? ( {offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? (
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400"> <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> </section>
</motion.div> </motion.div>
) : null} ) : null}
<AdminAlertDialog
open={rotateInviteDialogOpen}
title="轮换邀请码"
description="旧邀请码会立即失效,新的邀请码将覆盖当前注册入口。"
confirmLabel="确认轮换"
cancelLabel="取消"
confirmTone="warning"
busy={rotatingInviteCode}
onConfirm={handleConfirmRotateInviteCode}
onCancel={() => setRotateInviteDialogOpen(false)}
/>
</motion.div> </motion.div>
); );
} }

View File

@@ -8,7 +8,9 @@ import {
type ColumnDef, type ColumnDef,
type RowData, type RowData,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { AdminSelect } from '@/src/components/admin/AdminSelect';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { AdminAlertDialog } from '@/src/components/admin/AdminAlertDialog';
import { formatBytes, formatDateTime } from '@/src/lib/format'; import { formatBytes, formatDateTime } from '@/src/lib/format';
import { deleteAdminShare, getAdminShares, type AdminShare } from '@/src/lib/admin-shares'; 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() { export default function AdminShares() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS); const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [shares, setShares] = useState<AdminShare[]>([]); const [shares, setShares] = useState<AdminShare[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [pendingDeleteShare, setPendingDeleteShare] = useState<AdminShare | null>(null);
async function loadShares(nextFilters = filters) { async function loadShares(nextFilters = filters) {
setError(''); setError('');
@@ -94,9 +98,28 @@ export default function AdminShares() {
async function copyText(value: string, successMessage: string) { async function copyText(value: string, successMessage: string) {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
window.alert(successMessage); setNotice(successMessage);
setError('');
} catch { } catch {
setError('复制失败,请手动复制。'); 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>
<button <button
type="button" type="button"
onClick={async () => { onClick={() => setPendingDeleteShare(share)}
if (!window.confirm(`确认删除分享 ${share.shareName || share.fileName} 吗?`)) {
return;
}
await deleteAdminShare(share.id);
setLoading(true);
await loadShares();
}}
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" 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="删除分享" title="删除分享"
> >
@@ -331,26 +347,26 @@ export default function AdminShares() {
/> />
</label> </label>
<label className="relative block group"> <label className="relative block group">
<select <AdminSelect
value={filters.passwordProtected} value={filters.passwordProtected}
onChange={(event) => setFilters((current) => ({ ...current, passwordProtected: event.target.value as 'true' | 'false' | '' }))} 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=""></option>
<option value="true"></option> <option value="true"></option>
<option value="false"></option> <option value="false"></option>
</select> </AdminSelect>
</label> </label>
<label className="relative block group"> <label className="relative block group">
<select <AdminSelect
value={filters.expired} value={filters.expired}
onChange={(event) => setFilters((current) => ({ ...current, expired: event.target.value as 'true' | 'false' | '' }))} 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=""></option>
<option value="true"></option> <option value="true"></option>
<option value="false"></option> <option value="false"></option>
</select> </AdminSelect>
</label> </label>
</div> </div>
@@ -394,6 +410,12 @@ export default function AdminShares() {
</div> </div>
) : null} ) : 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"> <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> {total} </span>
<span> {shares.length} </span> <span> {shares.length} </span>
@@ -458,6 +480,22 @@ export default function AdminShares() {
</div> </div>
)} )}
</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> </motion.div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react'; import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion } from 'motion/react';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@@ -18,6 +18,9 @@ import {
type StoragePolicyCapabilities, type StoragePolicyCapabilities,
type StoragePolicyUpsertPayload, type StoragePolicyUpsertPayload,
} from '@/src/lib/admin-storage-policies'; } 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 { formatBytes } from '@/src/lib/format';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
@@ -421,217 +424,215 @@ export default function AdminStoragePoliciesList() {
)} )}
</div> </div>
{showForm ? ( <AdminDialog
<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"> open={showForm}
<motion.div title={editingPolicy ? '编辑策略' : '新建策略'}
initial={{ scale: 0.95, opacity: 0 }} onOpenChange={(nextOpen) => {
animate={{ scale: 1, opacity: 1 }} if (!nextOpen) {
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-12 shadow-2xl border-white/20" setShowForm(false);
> setEditingPolicy(null);
<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"> footer={
<div className="space-y-2"> <>
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label> <button
<input type="button"
value={form.name} onClick={() => {
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} setShowForm(false);
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" setEditingPolicy(null);
/> }}
</div> className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
<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"
> >
<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 className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
<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> ['privateBucket', '私有桶'],
<div className="mt-1 text-[10px] font-bold opacity-40">PID::{migratingPolicy.id}</div> ['enabled', '启用'],
</div> ['capabilities.directUpload', '直传'],
<div className="h-px bg-white/10" /> ['capabilities.multipartUpload', '分片上传'],
<div className="grid gap-4 md:grid-cols-2"> ['capabilities.signedDownloadUrl', '签名下载'],
<div className="space-y-2"> ['capabilities.serverProxyDownload', '代理下载'],
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label> ['capabilities.requiresCors', '需要 CORS'],
<select ['capabilities.supportsInternalEndpoint', '内网端点'],
value={migrationTargetPolicyId} ] as const
onChange={(event) => setMigrationTargetPolicyId(event.target.value)} ).map(([key, label]) => {
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" const checked =
> key === 'privateBucket'
<option value=""></option> ? form.privateBucket
{policies : key === 'enabled'
.filter((item) => item.id !== migratingPolicy.id) ? form.enabled
.map((policy) => ( : form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
<option key={policy.id} value={policy.id}> const checkedBoolean = Boolean(checked);
{policy.name} / PID::{policy.id} / {policy.type} return (
</option> <label
))} key={key}
</select> className={cn(
</div> 'flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group',
<div className="space-y-2"> checkedBoolean ? 'bg-white/5 border-white/10' : 'opacity-30'
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"> ID</label> )}
<input >
type="number" <input
min="1" type="checkbox"
value={migrationTargetPolicyId} checked={checkedBoolean}
onChange={(event) => setMigrationTargetPolicyId(event.target.value)} onChange={(event) => {
placeholder="例如 12" const nextValue = event.target.checked;
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" if (key === 'privateBucket') {
/> setForm((current) => ({ ...current, privateBucket: nextValue }));
</div> return;
</div> }
<p className="text-[10px] font-bold leading-5 opacity-50"> if (key === 'enabled') {
ID setForm((current) => ({ ...current, enabled: nextValue }));
</p> return;
</div> }
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"> <AdminDialog
<button open={Boolean(migratingPolicy)}
type="button" title="发起迁移"
onClick={closeMigrationDialog} description="仅创建迁移任务,不会立即执行对象复制"
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all" 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)}
> >
<option value=""></option>
</button> {policies
<button .filter((item) => item.id !== migratingPolicy.id)
type="button" .map((policy) => (
onClick={() => void submitMigration()} <option key={policy.id} value={policy.id}>
disabled={migrationSubmitting} {policy.name} / PID::{policy.id} / {policy.type}
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" </option>
> ))}
{migrationSubmitting ? '创建中...' : '创建迁移任务'} </AdminSelect>
</button>
</div> </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> </div>
) : null} ) : null}
</AnimatePresence> </AdminDialog>
</motion.div> </motion.div>
); );
} }

View File

@@ -12,6 +12,7 @@ import {
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { AdminSelect } from '@/src/components/admin/AdminSelect';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { formatDateTime } from '@/src/lib/format'; import { formatDateTime } from '@/src/lib/format';
import { getAdminTask, getAdminTasks, type AdminTask, type AdminTaskQuery } from '@/src/lib/admin-tasks'; 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"> <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"> <label className="rounded-lg glass-panel px-4 py-3 text-[11px] font-black uppercase tracking-widest">
<span className="mr-3 opacity-40"></span> <span className="mr-3 opacity-40"></span>
<select <AdminSelect
value={pageSize} value={pageSize}
onChange={(event) => { onChange={(event) => {
const nextSize = Number(event.target.value); const nextSize = Number(event.target.value);
setPageSize(nextSize); setPageSize(nextSize);
void loadTasks(0, filters, 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) => ( {PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
</option> </option>
))} ))}
</select> </AdminSelect>
</label> </label>
<button <button
type="button" type="button"

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Ban, Check, Clipboard, KeyRound, PencilLine, RefreshCw, Search, Shield, Mail, Phone, X } from 'lucide-react'; import { Ban, Check, Clipboard, KeyRound, PencilLine, RefreshCw, Search, Shield, Mail, Phone, X } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { import {
createColumnHelper, createColumnHelper,
flexRender, flexRender,
@@ -9,6 +9,8 @@ import {
useReactTable, useReactTable,
type ColumnDef, type ColumnDef,
} from '@tanstack/react-table'; } 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 { cn } from '@/src/lib/utils';
import { import {
getAdminUsers, getAdminUsers,
@@ -20,6 +22,7 @@ import {
updateUserStorageQuota, updateUserStorageQuota,
type AdminUser, type AdminUser,
} from '@/src/lib/admin-users'; } from '@/src/lib/admin-users';
import { AdminDialog } from '@/src/components/admin/AdminDialog';
import { formatBytes, formatDateTime } from '@/src/lib/format'; import { formatBytes, formatDateTime } from '@/src/lib/format';
const container = { const container = {
@@ -85,6 +88,7 @@ export default function AdminUsersList() {
const [temporaryPasswords, setTemporaryPasswords] = useState<Record<number, string>>({}); const [temporaryPasswords, setTemporaryPasswords] = useState<Record<number, string>>({});
const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState<number | null>(null); const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState<number | null>(null);
const { const {
control,
register, register,
trigger, trigger,
getValues, 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} {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 ? ( {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 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="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10"> <div className="overflow-x-auto">
<div className="overflow-x-auto"> <table className="min-w-full divide-y divide-white/10">
<table className="min-w-full divide-y divide-white/10"> <thead className="bg-white/10 dark:bg-black/40">
<thead className="bg-white/10 dark:bg-black/40"> {table.getHeaderGroups().map((headerGroup) => (
{table.getHeaderGroups().map((headerGroup) => ( <tr key={headerGroup.id}>
<tr key={headerGroup.id}> {headerGroup.headers.map((header) => (
{headerGroup.headers.map((header) => ( <th
<th key={header.id}
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}
className={cn( className={cn(
"group transition-colors", 'px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] opacity-40',
isEditing ? "bg-blue-500/10 dark:bg-blue-500/5" : "hover:bg-white/10 dark:hover:bg-white/5" header.column.id === 'actions' ? 'text-right' : 'text-left',
)} )}
> >
{row.getVisibleCells().map((cell) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
<td </th>
key={cell.id} ))}
className={cn( </tr>
"px-8 py-5 align-top", ))}
cell.column.id === 'actions' ? 'text-right' : 'text-left' </thead>
)} <motion.tbody
> variants={container}
{flexRender(cell.column.columnDef.cell, cell.getContext())} initial="hidden"
</td> animate="show"
))} className="divide-y divide-white/10 dark:divide-white/5"
</motion.tr> >
); {table.getRowModel().rows.map((row) => {
})} const user = row.original;
{table.getRowModel().rows.length === 0 ? ( const isEditing = editingUser?.id === user.id;
<tr> return (
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30"> <motion.tr
key={row.id}
</td> variants={itemVariants}
</tr> className={cn(
) : null} 'group transition-colors',
</motion.tbody> isEditing ? 'bg-blue-500/10 dark:bg-blue-500/5' : 'hover:bg-white/10 dark:hover:bg-white/5',
</table> )}
>
{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>
</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 className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></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"> <div className="mt-1 text-[11px] font-bold opacity-50"></div>
{editingUser ? editingUser.username : '请选择用户'}
</h2>
<p className="mt-2 text-[10px] font-bold opacity-40 leading-relaxed">
{editingUser
? '这里负责角色、存储配额、最大上传限制和手动改密。临时密码生成仍保留在表格快捷操作里,避免和手动改密混在一起。'
: '从左侧表格点击“编辑”打开该用户的策略面板。'}
</p>
</div> </div>
{editingUser ? ( <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">
<button <Shield className="h-3 w-3" />
type="button" {watchedRole}
onClick={closeEditor} </span>
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}
</div> </div>
{editingUser ? ( <label className="block">
<div className="mt-6 space-y-6"> <span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4"> <Controller
<div className="flex items-center justify-between gap-3"> control={control}
<div> name="role"
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div> rules={{
<div className="mt-2 text-[12px] font-black uppercase tracking-tight">{editingUser.email}</div> validate: (value) => (value === 'USER' || value === 'ADMIN' ? true : '请选择有效角色'),
</div> }}
<span render={({ field }) => (
className={cn( <AdminSelect
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border", value={field.value}
editingUser.banned onChange={(event) => field.onChange(event.target.value)}
? "bg-red-500/10 text-red-500 border-red-500/20" className="w-full text-[11px] font-black uppercase tracking-widest"
: "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"
> >
<Check className="h-4 w-4" /> <option value="USER">USER - </option>
<option value="ADMIN">ADMIN - </option>
</button> </AdminSelect>
</div> )}
/>
{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="grid gap-4 sm:grid-cols-2">
<div className="flex items-start justify-between gap-3"> <label className="block">
<div> <span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-amber-500"></div> <AdminInput
<div className="mt-1 text-[10px] font-bold opacity-60 leading-relaxed"> {...register('storageQuotaBytes', {
validate: (value) => validateNonNegativeBytes(value, '存储配额'),
</div> })}
</div> inputMode="numeric"
<KeyRound className="h-4 w-4 text-amber-500" /> />
</div> {errors.storageQuotaBytes ? (
<label className="block"> <p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span> {errors.storageQuotaBytes.message}
<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">
使
</p> </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"> <button
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div> type="button"
<div className="mt-2 text-[10px] font-bold opacity-50 leading-relaxed"> 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"
</div> >
<button <Check className="h-4 w-4" />
type="button"
onClick={() => void mutate(() => updateUserStatus(editingUser.id, !editingUser.banned))} </button>
className={cn( </div>
"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 <div className="space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
? "border-green-500/20 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white" <div className="flex items-start justify-between gap-3">
: "border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white" <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">
<Ban className="h-4 w-4" />
{editingUser.banned ? '恢复账号' : '禁用账号'}
</button>
</div> </div>
</div> </div>
) : ( <KeyRound className="h-4 w-4 text-amber-500" />
<div className="mt-10 rounded-lg border border-dashed border-white/10 px-6 py-12 text-center"> </div>
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-25"></div> <label className="block">
<p className="mt-3 text-[11px] font-bold opacity-40 leading-relaxed"> <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> </p>
</div> ) : null}
)} </label>
</aside> <button
</> type="button"
)} onClick={() => void submitManualPassword()}
</div> 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> </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>
);
}

123
memory.md
View File

@@ -6,13 +6,16 @@
- 项目主线已经从旧教务模块切换为“网盘 + 快传 + 管理台”结构 - 项目主线已经从旧教务模块切换为“网盘 + 快传 + 管理台”结构
- 快传模块已整合进主站支持取件码、分享链接、P2P 传输、部分文件接收、ZIP 下载、存入网盘 - 快传模块已整合进主站支持取件码、分享链接、P2P 传输、部分文件接收、ZIP 下载、存入网盘
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入 - 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
- 2026-04-12 已将管理台治理页里的原生 `<select>` 迁移到共享 `AdminSelect`,当前覆盖 `shares``fileblobs``tasks` 三页,筛选行为和后端调用保持不变
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制 - 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
- 同账号现已允许桌面端与移动端同时在线,但同一端类型仍只保留一个有效会话;同端再次登录会在下一次受保护请求时挤掉旧会话 - 同账号现已允许桌面端与移动端同时在线,但同一端类型仍只保留一个有效会话;同端再次登录会在下一次受保护请求时挤掉旧会话
- 后端已补生产 CORS默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz` - 后端已补生产 CORS默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz`
- 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶 - 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
- 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限 - 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
- 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧 - 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
- 游戏页已接入 `/race/``/t_race/`,带站内播放器、退出按钮和友情链接 - 2026-04-12 已将 `用户策略` 的右侧编辑壳迁移到共享 `AdminDialog` side-panel 模式,保留原有表格、表单字段、快捷操作与后端调用
- 2026-04-12 已新增共享 `AdminInput` 基础输入壳,当前只覆盖 admin 目录下的文本 / 数字输入视觉和 `forwardRef` 接口,不引入额外依赖,也不改 admin 页面
- 游戏页已接入 `/race/``/t_race/`,带站内播放器、退出按钮和友情链接
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效 - 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
- 2026-04-02 已放开未登录直达快传登录页可直接进入快传匿名用户可发在线快传2026-04-03 又放开了离线接收,因此匿名用户现在可发在线快传、接收在线快传、接收离线快传,但发离线和把离线文件存入网盘仍要求登录 - 2026-04-02 已放开未登录直达快传登录页可直接进入快传匿名用户可发在线快传2026-04-03 又放开了离线接收,因此匿名用户现在可发在线快传、接收在线快传、接收离线快传,但发离线和把离线文件存入网盘仍要求登录
- 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接 - 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接
@@ -1043,3 +1046,121 @@
- 本批前端验证通过: - 本批前端验证通过:
- `cd front && npm run lint` - `cd front && npm run lint`
- `cd front && npm run build` - `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 36
- 前端 OSS 替换计划的 Batch 4 已完成,管理台已移除原生 `window.alert` / `window.confirm`
- `front/package.json` / `front/package-lock.json` 已新增 `@radix-ui/react-alert-dialog` 依赖。
- 新增 `front/src/components/admin/AdminAlertDialog.tsx`,当前管理台确认动作统一走 Radix Alert Dialog并保持现有玻璃态视觉风格。
- `front/src/admin/settings.tsx` 已将“轮换邀请码”改为站内确认对话框,不再使用系统确认框。
- `front/src/admin/shares.tsx` 已将“删除分享”改为站内确认对话框,同时把复制 Token 成功/失败改为页内 notice / error。
- `front/src/admin/files-list.tsx` 已将“彻底删除文件”改为站内确认对话框,不再使用系统确认框。
- `front/src/admin/filesystem.tsx``front/src/admin/fileblobs.tsx``front/src/admin/audits.tsx` 的复制失败提示已改为页内反馈,不再使用系统弹窗。
- 当前 `front/src/admin` 下已不存在原生 `window.alert``window.confirm`
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 37
- 前端 OSS 替换计划的 Batch 5 已推进到 `front/src/admin/storage-policies-list.tsx`
- 该页原先手写的两个 overlay 已切到共享 `AdminDialog`
- 新建 / 编辑策略弹层
- 发起迁移弹层
- 迁移说明与页面级迁移通知 banner 保持原样。
- 后端调用、字段校验、保存逻辑和迁移任务创建逻辑均未改动。
- 共享对话框文件 `front/src/components/admin/AdminDialog.tsx` 已补齐 `mode` / `layout` 兼容,避免影响并发改动中的用户策略页面。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 38
- 前端 OSS 替换计划的 Batch 5 已完成,管理台的手写 modal / side-sheet 壳已切到 Radix Dialog。
- `front/package.json` / `front/package-lock.json` 已新增 `@radix-ui/react-dialog` 直接依赖。
- 新增 `front/src/components/admin/AdminDialog.tsx`,当前统一提供:
- 居中 modal 布局
- 右侧 side-panel 布局
- `layout` / `mode` 兼容参数
- 统一 header / body / footer / close / dismiss 行为
- `front/src/admin/storage-policies-list.tsx` 已将:
- 新建 / 编辑策略弹层
- 发起迁移弹层
- 全部迁移到共享 `AdminDialog`
- `front/src/admin/users-list.tsx` 已将用户策略编辑壳从页面内手写右侧面板切换为共享 `AdminDialog``side-panel` 布局,表格、表单字段、临时密码、禁用/恢复与后端调用均保持不变。
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新Task 5 已完成。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 39
- 前端 OSS 替换计划的 Batch 6 已推进到 `front/src/admin/storage-policies-list.tsx`
- 该页原先手写的两个 `<select>` 已切到共享 `AdminSelect`
- 新建 / 编辑策略里的驱动协议选择
- 发起迁移里的目标策略选择
- 由于当前仓库里还没有现成的 `AdminSelect` 文件,我补了一个本地共享包装组件到 `front/src/components/admin/AdminSelect.tsx`,用原生 select 保持行为不变。
- 其他页面、包依赖和后端调用均未改动。
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新Task 6 的 storage-policy 子步骤已完成。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 40
- 前端 OSS 替换计划的 Batch 6 已完成,管理台当前目标范围内的手写单选下拉已切到 Radix Select。
- `front/package.json` / `front/package-lock.json` 已新增 `@radix-ui/react-select` 依赖。
- 新增 `front/src/components/admin/AdminSelect.tsx`,当前是一个基于 Radix Select 的共享单选包装组件:
- 兼容当前页面已有的 `<option>` children 调用方式
- 兼容现有 `onChange(event)` 形状,减少页面改造面
- 对空值选项做 sentinel 映射,允许“全部 / 未设置”这类选项继续工作
- `front/src/admin/shares.tsx` 已将密码保护、过期状态筛选切到共享 `AdminSelect`
- `front/src/admin/fileblobs.tsx` 已将实体类型筛选切到共享 `AdminSelect`
- `front/src/admin/tasks.tsx` 已将“每页条数”选择器切到共享 `AdminSelect`,紧凑样式保持原样。
- `front/src/admin/storage-policies-list.tsx` 已将:
- 新建 / 编辑策略里的驱动协议选择
- 发起迁移里的目标策略选择
- 切到共享 `AdminSelect`
- 当前 `front/src/admin` 里仍保留的原生 `<select>` 仅剩 `front/src/admin/users-list.tsx` 的角色字段;它属于 `react-hook-form` 驱动表单,不在 Batch 6 范围内。
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新Task 6 已完成。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 41
- 前端 OSS 替换计划的 Batch 7 已完成,管理台剩余的原生表单单选已清空。
- `front/src/admin/users-list.tsx` 已将 `react-hook-form` 驱动的 `role` 字段切到共享 `AdminSelect`,通过 `Controller` 保持:
- 字段值同步
- 表单校验
- 提交流程
- 现有 `watchedRole` 展示
- 当前 `front/src/admin` 下已不存在原生 `<select>`
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新Task 7 已完成。
- 本批前端验证通过:
- `cd front && npm run lint`
- `cd front && npm run build`
## 2026-04-12 Frontend OSS Refactor Batch 42
- 前端 OSS 替换计划的 Batch 8 已按当前 scope 完成,`settings.tsx``storage-policies-list.tsx` 的可编辑文本/数字输入已切到共享 `AdminInput`
- 新增 `front/src/components/admin/AdminInput.tsx`,作为统一的 admin 输入壳:
- 保留原生 `<input>` 行为
- 兼容 `react-hook-form register`
- 兼容受控 `value/onChange`
- 保留现有 focus、disabled、validation affordances
- `front/src/admin/settings.tsx` 已将邀请码与离线快传上限输入切到共享 `AdminInput`,校验、提交和后端调用保持不变。
- `front/src/admin/storage-policies-list.tsx` 已将策略名称、端点、桶、对象上限与迁移目标 ID 输入切到共享 `AdminInput`,保存与迁移逻辑保持不变。
- 当前 batch 明确未改 `front/src/admin/users-list.tsx`,它已从本次 scope 中延期。
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新Task 8 的当前 scope 已完成。
## 2026-04-12 Frontend OSS Refactor Batch 43
- `front/src/admin/users-list.tsx` 的用户策略编辑面板已把可编辑的文本、数字和密码输入切到共享 `AdminInput`
- 现在这三个字段都保留了原有的 `react-hook-form` 注册、验证和提交行为:
- `storageQuotaBytes`
- `maxUploadSizeBytes`
- `manualPassword`
- 新增的 `front/src/components/admin/AdminInput.tsx` 仍然只是薄包装,没有引入 label、error、prefix 或其他额外抽象。
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已同步更新为当前 users-list scope。