Implement coordinated frontend and backend updates
This commit is contained in:
@@ -1161,4 +1161,3 @@
|
|||||||
- 未拆开工作空间与内容资产前,不收口分享和快传
|
- 未拆开工作空间与内容资产前,不收口分享和快传
|
||||||
- 未收口上传与存储治理前,不把内容资产域视为稳定
|
- 未收口上传与存储治理前,不把内容资产域视为稳定
|
||||||
- 未稳定后端领域边界前,不开始前端大规模域化重组
|
- 未稳定后端领域边界前,不开始前端大规模域化重组
|
||||||
|
|
||||||
|
|||||||
304
docs/frontend-component-guide.md
Normal file
304
docs/frontend-component-guide.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# 前端组件选型与替换指南
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
这份文档用于回答两个问题:
|
||||||
|
|
||||||
|
1. 当前 `front/` 已经有哪些前端基础组件与页面壳
|
||||||
|
2. 哪些“不完善但不承载业务规则”的前端组件适合用成熟开源组件替换
|
||||||
|
|
||||||
|
本文只讨论前端组件、交互容器、表现层能力与替换边界,不改变 `docs/architecture.md` 里已经确定的领域边界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 组件替换原则
|
||||||
|
|
||||||
|
### 2.1 可以放心引入开源组件的范围
|
||||||
|
|
||||||
|
- 纯展示层组件
|
||||||
|
- 纯交互壳组件
|
||||||
|
- 表格、图表、树、分栏、拖拽、上传入口、媒体播放器 UI
|
||||||
|
- 不负责权限判断、不负责业务规则决策的基础组件
|
||||||
|
|
||||||
|
### 2.2 不应交给开源组件决定的范围
|
||||||
|
|
||||||
|
- 权限与身份判断
|
||||||
|
- 上传模式选择:`PROXY / DIRECT_SINGLE / DIRECT_MULTIPART`
|
||||||
|
- 分享是否可访问、可下载、可导入
|
||||||
|
- 工作空间路径合法性、同级重名规则、回收站恢复规则
|
||||||
|
- 任务是否可重试、是否可取消、状态机如何流转
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
- 开源组件负责“怎么展示、怎么交互”
|
||||||
|
- 业务代码负责“能不能做、规则是什么、最终状态如何判定”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前项目已有的核心基础组件
|
||||||
|
|
||||||
|
### 3.1 应用外壳
|
||||||
|
|
||||||
|
- `front/src/components/layout/Layout.tsx`
|
||||||
|
- 桌面端主壳
|
||||||
|
- 包含左侧导航、主题切换、任务摘要、退出按钮、`UploadCenter`
|
||||||
|
- `front/src/mobile-components/MobileLayout.tsx`
|
||||||
|
- 移动端主壳
|
||||||
|
- 包含顶部浮层、底部导航、主题切换、任务摘要、退出按钮、`UploadCenter`
|
||||||
|
- `front/src/admin/AdminLayout.tsx`
|
||||||
|
- 管理台二级导航壳
|
||||||
|
- 负责后台子页面切换,不承载业务规则
|
||||||
|
|
||||||
|
### 3.2 全局状态与共用组件
|
||||||
|
|
||||||
|
- `front/src/components/ThemeProvider.tsx`
|
||||||
|
- 亮暗主题上下文
|
||||||
|
- `front/src/components/ThemeToggle.tsx`
|
||||||
|
- 主题切换按钮
|
||||||
|
- `front/src/components/upload/UploadCenter.tsx`
|
||||||
|
- 全局上传队列浮层
|
||||||
|
- 展示任务数量、展开/收起、清理已完成任务
|
||||||
|
- `front/src/components/tasks/TaskSummaryPanel.tsx`
|
||||||
|
- 导航栏里的活动任务摘要
|
||||||
|
- `front/src/components/media/FileThumbnail.tsx`
|
||||||
|
- 文件缩略图/类型图标组件
|
||||||
|
|
||||||
|
### 3.3 现有组件短板
|
||||||
|
|
||||||
|
- 表格几乎全是页面内手写 `<table>`
|
||||||
|
- 文件树和文件详情面板仍是页面级拼装
|
||||||
|
- 上传入口是“按钮 + 隐藏 input”模式,拖拽能力弱
|
||||||
|
- 快传页面没有二维码、批量选择、可视化发送步骤
|
||||||
|
- 媒体预览/播放器能力基本空缺
|
||||||
|
- 管理台多个页面仍是占位页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐引入的开源组件
|
||||||
|
|
||||||
|
以下推荐按“适合当前项目”优先,而不是单纯按星标排序。
|
||||||
|
|
||||||
|
### 4.1 `@tanstack/react-table`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/TanStack/table>
|
||||||
|
- 适合替换:
|
||||||
|
- `front/src/admin/users-list.tsx`
|
||||||
|
- `front/src/admin/files-list.tsx`
|
||||||
|
- `front/src/admin/fileblobs.tsx`
|
||||||
|
- `front/src/admin/shares.tsx`
|
||||||
|
- `front/src/admin/tasks.tsx`
|
||||||
|
- `front/src/pages/Shares.tsx`
|
||||||
|
- `front/src/pages/Tasks.tsx`
|
||||||
|
- 后续审计日志页
|
||||||
|
- 后续管理台所有表格页
|
||||||
|
- 适配原因:
|
||||||
|
- Headless,不会破坏当前玻璃拟态视觉
|
||||||
|
- 支持排序、筛选、分页、列配置、行操作
|
||||||
|
- 特别适合你这种“业务规则自写,视觉样式自定”的项目
|
||||||
|
- 替换边界:
|
||||||
|
- 仅替换表格渲染和交互
|
||||||
|
- 用户角色、封禁、删除、任务取消等操作仍调用现有业务 API
|
||||||
|
|
||||||
|
### 4.2 `@tanstack/react-virtual`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/TanStack/virtual>
|
||||||
|
- 适合替换:
|
||||||
|
- 网盘文件列表
|
||||||
|
- 管理台用户表
|
||||||
|
- 管理台文件审计表
|
||||||
|
- 任务列表与后续审计日志列表
|
||||||
|
- 适配原因:
|
||||||
|
- 大列表性能提升明显
|
||||||
|
- 与 `TanStack Table` 搭配自然
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责渲染性能
|
||||||
|
- 不负责分页规则和数据源决策
|
||||||
|
|
||||||
|
### 4.3 `react-dropzone`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/react-dropzone/react-dropzone>
|
||||||
|
- 适合替换:
|
||||||
|
- `front/src/pages/files/FilesPage.tsx` 的上传文件入口
|
||||||
|
- `front/src/transfer/pages/TransferPage.tsx` 的发送文件区域
|
||||||
|
- 适配原因:
|
||||||
|
- 现有上传入口过于基础
|
||||||
|
- 拖拽体验、选中文件反馈、目录导入体验都能更好
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责文件选择与拖拽
|
||||||
|
- 上传策略仍由后端 `storage-governance` 决定
|
||||||
|
|
||||||
|
### 4.4 `react-arborist`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/brimdata/react-arborist>
|
||||||
|
- 适合替换:
|
||||||
|
- `front/src/pages/files/FilesPage.tsx` 的左侧目录树
|
||||||
|
- 适配原因:
|
||||||
|
- 当前目录树是递归按钮结构,功能够用但扩展性弱
|
||||||
|
- 后续若需要键盘导航、虚拟化、拖拽移动、懒加载,`react-arborist` 很合适
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责树控件交互
|
||||||
|
- 路径合法性、移动规则、同名冲突仍由后端判定
|
||||||
|
|
||||||
|
### 4.5 `dnd-kit`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/clauderic/dnd-kit>
|
||||||
|
- 适合替换:
|
||||||
|
- 文件移动的可视化拖拽
|
||||||
|
- 上传队列排序
|
||||||
|
- 后续管理台面板编排
|
||||||
|
- 适配原因:
|
||||||
|
- 你当前项目前端自定义程度高,`dnd-kit` 比重型集成组件更灵活
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责拖拽行为
|
||||||
|
- 最终 move/copy 仍走现有 API
|
||||||
|
|
||||||
|
### 4.6 `react-resizable-panels`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/bvaughn/react-resizable-panels>
|
||||||
|
- 适合替换:
|
||||||
|
- 网盘页面三栏布局:目录树 / 文件表 / 文件详情
|
||||||
|
- 后续管理台多面板布局
|
||||||
|
- 适配原因:
|
||||||
|
- 当前 `FilesPage` 三栏是固定宽度,不利于高密度工作流
|
||||||
|
- 可调面板更符合“桌面工作台”气质
|
||||||
|
|
||||||
|
### 4.7 `recharts`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/recharts/recharts>
|
||||||
|
- 适合替换:
|
||||||
|
- `front/src/admin/dashboard.tsx` 的遥测图表区域
|
||||||
|
- 后续请求趋势、活跃用户、存储分布图
|
||||||
|
- 适配原因:
|
||||||
|
- 当前后台总览以统计卡为主,图表层很薄
|
||||||
|
- `Recharts` 接入简单,适合当前 React 栈
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责可视化
|
||||||
|
- 指标口径仍由后端 summary 接口定义
|
||||||
|
|
||||||
|
### 4.8 `vidstack`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/vidstack/player>
|
||||||
|
- 适合替换:
|
||||||
|
- 后续文件预览页里的音视频播放器
|
||||||
|
- 分享页的音视频预览层
|
||||||
|
- 适配原因:
|
||||||
|
- 当前项目几乎没有完整播放器能力
|
||||||
|
- `vidstack` 的 React 组合式设计很适合保留你的视觉系统
|
||||||
|
- 替换边界:
|
||||||
|
- 只负责播放 UI 与控制条
|
||||||
|
- 下载授权、预览授权、签名地址获取仍由业务接口负责
|
||||||
|
|
||||||
|
### 4.9 `hls.js`
|
||||||
|
|
||||||
|
- GitHub: <https://github.com/video-dev/hls.js>
|
||||||
|
- 适合替换:
|
||||||
|
- 后续若引入 `m3u8` / 分片视频播放
|
||||||
|
- 适配原因:
|
||||||
|
- 这是 HLS 底层能力,不建议自己实现
|
||||||
|
- 替换边界:
|
||||||
|
- 作为播放器底层能力
|
||||||
|
- 不承载任何业务权限逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前项目不建议优先引入的组件
|
||||||
|
|
||||||
|
### 5.1 `ag-grid`
|
||||||
|
|
||||||
|
- 功能很强
|
||||||
|
- 但对当前项目来说偏重
|
||||||
|
- 会明显抬高管理台复杂度和定制成本
|
||||||
|
- 只有在后台表格演进成“数据平台级界面”时才值得引入
|
||||||
|
|
||||||
|
### 5.2 重型文件管理器整包
|
||||||
|
|
||||||
|
- 不建议直接引入“整套 file manager UI”
|
||||||
|
- 原因是你的业务语义已经明显超出通用文件管理器:
|
||||||
|
- 分享
|
||||||
|
- 快传
|
||||||
|
- 异步任务
|
||||||
|
- 存储策略
|
||||||
|
- 管理治理
|
||||||
|
- 更适合按能力拆分引入单点组件,而不是整体换壳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 推荐替换优先级
|
||||||
|
|
||||||
|
### P1:立刻有价值
|
||||||
|
|
||||||
|
1. `@tanstack/react-table`
|
||||||
|
2. `react-dropzone`
|
||||||
|
3. `react-resizable-panels`
|
||||||
|
4. `recharts`
|
||||||
|
|
||||||
|
### P2:网盘工作区增强
|
||||||
|
|
||||||
|
1. `react-arborist`
|
||||||
|
2. `@tanstack/react-virtual`
|
||||||
|
3. `dnd-kit`
|
||||||
|
|
||||||
|
### P3:媒体预览体系建设
|
||||||
|
|
||||||
|
1. `vidstack`
|
||||||
|
2. `hls.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 页面与推荐组件映射
|
||||||
|
|
||||||
|
| 页面 | 当前问题 | 推荐组件 | 替换范围 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/files` | 文件树、文件表、上传入口都较原始 | `react-arborist` + `TanStack Table` + `react-dropzone` + `react-resizable-panels` | 目录树、文件表、上传选择、三栏布局 |
|
||||||
|
| `/tasks` | 列表为手写表格 | `TanStack Table` | 列表渲染与排序/筛选 |
|
||||||
|
| `/shares` | 列表信息密度一般 | `TanStack Table` | 分享表格与列配置 |
|
||||||
|
| `/transfer` | 文件选择、状态展示、历史记录卡片较基础 | `react-dropzone` | 文件选择与拖拽体验 |
|
||||||
|
| `/admin/dashboard` | 图表能力偏弱 | `recharts` | 指标趋势和分布图 |
|
||||||
|
| `/admin/users` | 表格操作多但结构不稳定 | `TanStack Table` | 用户管理表格 |
|
||||||
|
| `/admin/files` | 审计表格可扩展性弱 | `TanStack Table` + `TanStack Virtual` | 文件审计表格和大列表性能 |
|
||||||
|
| `/admin/file-blobs` | 风险巡检列表尚未实现 | `TanStack Table` + `TanStack Virtual` | 对象实体列表、筛选、风险标签 |
|
||||||
|
| `/admin/shares` | 分享治理页尚未实现 | `TanStack Table` | 分享治理列表和筛选栏 |
|
||||||
|
| `/admin/tasks` | 全站任务监控尚未实现 | `TanStack Table` | 任务监控表格与详情侧栏 |
|
||||||
|
| `/admin/audits` | 审计日志前端未接线 | `TanStack Table` | 审计日志表格与筛选栏 |
|
||||||
|
| 媒体预览页(待建) | 目前无播放器体系 | `vidstack` + `hls.js` | 播放器和流媒体底层 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 建议保留自研的部分
|
||||||
|
|
||||||
|
这些部分不建议被开源组件接管:
|
||||||
|
|
||||||
|
- `front/src/lib/upload-session.ts`
|
||||||
|
- `front/src/lib/upload-runtime.ts`
|
||||||
|
- `front/src/lib/background-tasks.ts`
|
||||||
|
- `front/src/lib/shares-v2.ts`
|
||||||
|
- `front/src/lib/files.ts`
|
||||||
|
- `front/src/transfer/api/transfer.ts`
|
||||||
|
- 页面内所有调用业务 API 的 action handler
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 它们直接对应项目自己的领域模型和 API 语义
|
||||||
|
- 它们才是“项目资产”,而不是 UI 可替换件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 最推荐的一组组合
|
||||||
|
|
||||||
|
如果只做一轮最稳、最值的升级,建议组合如下:
|
||||||
|
|
||||||
|
- 表格:`@tanstack/react-table`
|
||||||
|
- 大列表:`@tanstack/react-virtual`
|
||||||
|
- 上传入口:`react-dropzone`
|
||||||
|
- 目录树:`react-arborist`
|
||||||
|
- 分栏布局:`react-resizable-panels`
|
||||||
|
- 图表:`recharts`
|
||||||
|
- 播放器:`vidstack`
|
||||||
|
- HLS 底层:`hls.js`
|
||||||
|
|
||||||
|
这组组合和当前项目的契合点在于:
|
||||||
|
|
||||||
|
- 不会抢走业务规则所有权
|
||||||
|
- 能显著改善工作台体验
|
||||||
|
- 能让管理台和网盘页更专业
|
||||||
|
- 不会把前端重构成另一个不可控的大系统
|
||||||
1399
docs/frontend-page-orchestration.md
Normal file
1399
docs/frontend-page-orchestration.md
Normal file
File diff suppressed because it is too large
Load Diff
141
docs/superpowers/plans/2026-04-12-admin-oss-refactor.md
Normal file
141
docs/superpowers/plans/2026-04-12-admin-oss-refactor.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Admin OSS Refactor Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the most error-prone hand-rolled admin UI primitives with mature OSS components, starting with configuration forms.
|
||||||
|
|
||||||
|
**Architecture:** Do the replacement in two batches. Batch 1 replaces form state and validation on configuration-heavy pages with `react-hook-form`. Batch 2 replaces hand-built table state/rendering on admin resource lists with `@tanstack/react-table`. Keep backend contracts unchanged and preserve the existing visual system.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Vite 6, Tailwind CSS v4, `react-hook-form`, `@tanstack/react-table`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Replace Configuration Forms With `react-hook-form`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/package.json`
|
||||||
|
- Modify: `front/package-lock.json`
|
||||||
|
- Modify: `front/src/admin/settings.tsx`
|
||||||
|
- Modify: `front/src/admin/users-list.tsx`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add `react-hook-form` to the frontend**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd front && npm install react-hook-form
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `front/package.json` and `front/package-lock.json` include `react-hook-form`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Migrate `系统设置` editable sections to `react-hook-form`**
|
||||||
|
|
||||||
|
Target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- invite code form
|
||||||
|
- offline transfer limit form
|
||||||
|
- validation and submit lifecycle
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: remove page-local draft state for editable settings where `react-hook-form` can own the fields.
|
||||||
|
|
||||||
|
- [x] **Step 3: Migrate `用户策略` editor panel to `react-hook-form`**
|
||||||
|
|
||||||
|
Target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- role
|
||||||
|
- storageQuotaBytes
|
||||||
|
- maxUploadSizeBytes
|
||||||
|
- manualPassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: replace manual draft state and ad hoc validation with form-driven state and explicit submit handlers.
|
||||||
|
|
||||||
|
- [x] **Step 4: Verify Batch 1**
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/package.json`
|
||||||
|
- Modify: `front/package-lock.json`
|
||||||
|
- Modify: `front/src/admin/users-list.tsx`
|
||||||
|
- Modify: `front/src/admin/storage-policies-list.tsx`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add `@tanstack/react-table` to the frontend**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd front && npm install @tanstack/react-table
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `front/package.json` and `front/package-lock.json` include `@tanstack/react-table`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Migrate `用户策略` table rendering to TanStack**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- columns defined declaratively
|
||||||
|
- row rendering stays visually consistent
|
||||||
|
- action cells preserve current behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Migrate `存储策略` table rendering to TanStack**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- existing columns preserved
|
||||||
|
- no backend contract changes
|
||||||
|
- current modal / action flows preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Verify Batch 2**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd front && npm run lint
|
||||||
|
cd front && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands pass.
|
||||||
|
|
||||||
|
### Task 3: Extend TanStack Table To Share Governance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/admin/shares.tsx`
|
||||||
|
|
||||||
|
- [x] **Step 1: Migrate `分享治理` table rendering to TanStack**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- existing columns preserved
|
||||||
|
- no backend contract changes
|
||||||
|
- copy / open / delete actions preserved
|
||||||
|
- filter form and empty state preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Verify Batch 3**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd front && npm run lint
|
||||||
|
cd front && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands pass.
|
||||||
335
docs/superpowers/plans/2026-04-12-frontend-docs.md
Normal file
335
docs/superpowers/plans/2026-04-12-frontend-docs.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Frontend Documentation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Write two repo-aligned frontend docs: one component selection guide tailored to this project and one page orchestration guide that enumerates every current page, buttons, and admin settings item.
|
||||||
|
|
||||||
|
**Architecture:** Read the live frontend routes, shared shells, admin pages, and supporting API shapes first, then write the docs directly from code so the output reflects the real UI instead of intentions. Keep the component guide opinionated about which OSS components fit the project while keeping the page orchestration guide strictly descriptive about what exists today.
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown, Vite 6, React 19, TypeScript, repo docs under `docs/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Inventory Current Frontend Routes And Admin Surfaces
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/plans/2026-04-12-frontend-docs.md`
|
||||||
|
- Read: `front/src/App.tsx`
|
||||||
|
- Read: `front/src/components/layout/Layout.tsx`
|
||||||
|
- Read: `front/src/mobile-components/MobileLayout.tsx`
|
||||||
|
- Read: `front/src/admin/*.tsx`
|
||||||
|
- Read: `front/src/pages/**/*.tsx`
|
||||||
|
- Read: `front/src/transfer/pages/TransferPage.tsx`
|
||||||
|
- Read: `front/src/lib/admin*.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture the route and admin page inventory**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Public routes:
|
||||||
|
- /login
|
||||||
|
- /share/:token
|
||||||
|
|
||||||
|
App routes:
|
||||||
|
- /overview
|
||||||
|
- /files
|
||||||
|
- /tasks
|
||||||
|
- /shares
|
||||||
|
- /recycle-bin
|
||||||
|
- /transfer
|
||||||
|
|
||||||
|
Admin routes:
|
||||||
|
- /admin/dashboard
|
||||||
|
- /admin/settings
|
||||||
|
- /admin/filesystem
|
||||||
|
- /admin/storage-policies
|
||||||
|
- /admin/users
|
||||||
|
- /admin/files
|
||||||
|
- /admin/file-blobs
|
||||||
|
- /admin/shares
|
||||||
|
- /admin/tasks
|
||||||
|
- /admin/oauth-apps
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the route inventory against the code**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,220p' front/src/App.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: route declarations matching the list above.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Capture the implemented admin settings and placeholder pages**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implemented admin pages:
|
||||||
|
- dashboard
|
||||||
|
- storage-policies
|
||||||
|
- users
|
||||||
|
- files
|
||||||
|
|
||||||
|
Placeholder admin pages:
|
||||||
|
- settings
|
||||||
|
- filesystem
|
||||||
|
- file-blobs
|
||||||
|
- shares
|
||||||
|
- tasks
|
||||||
|
- oauth-apps
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the admin page status directly**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,340p' front/src/admin/settings.tsx
|
||||||
|
sed -n '1,340p' front/src/admin/filesystem.tsx
|
||||||
|
sed -n '1,340p' front/src/admin/fileblobs.tsx
|
||||||
|
sed -n '1,340p' front/src/admin/shares.tsx
|
||||||
|
sed -n '1,340p' front/src/admin/tasks.tsx
|
||||||
|
sed -n '1,340p' front/src/admin/oauthapps.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: simple placeholder components with only a title and no real controls.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the inventory checkpoint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/plans/2026-04-12-frontend-docs.md
|
||||||
|
git commit -m "docs: add frontend docs planning inventory"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Write The Frontend Component Selection Guide
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/frontend-component-guide.md`
|
||||||
|
- Read: `front/src/components/layout/Layout.tsx`
|
||||||
|
- Read: `front/src/mobile-components/MobileLayout.tsx`
|
||||||
|
- Read: `front/src/components/upload/UploadCenter.tsx`
|
||||||
|
- Read: `front/src/components/tasks/TaskSummaryPanel.tsx`
|
||||||
|
- Read: `front/src/pages/files/FilesPage.tsx`
|
||||||
|
- Read: `front/src/admin/storage-policies-list.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the document header and replacement principles**
|
||||||
|
|
||||||
|
```md
|
||||||
|
# 前端组件选型与替换指南
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
这份文档用于回答两个问题:
|
||||||
|
|
||||||
|
1. 当前 `front/` 已经有哪些前端基础组件与页面壳
|
||||||
|
2. 哪些“不完善但不承载业务规则”的前端组件适合用成熟开源组件替换
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the current in-repo component baseline**
|
||||||
|
|
||||||
|
```md
|
||||||
|
## 3. 当前项目已有的核心基础组件
|
||||||
|
|
||||||
|
### 3.1 应用外壳
|
||||||
|
|
||||||
|
- `front/src/components/layout/Layout.tsx`
|
||||||
|
- `front/src/mobile-components/MobileLayout.tsx`
|
||||||
|
- `front/src/admin/AdminLayout.tsx`
|
||||||
|
|
||||||
|
### 3.2 全局状态与共用组件
|
||||||
|
|
||||||
|
- `front/src/components/ThemeProvider.tsx`
|
||||||
|
- `front/src/components/ThemeToggle.tsx`
|
||||||
|
- `front/src/components/upload/UploadCenter.tsx`
|
||||||
|
- `front/src/components/tasks/TaskSummaryPanel.tsx`
|
||||||
|
- `front/src/components/media/FileThumbnail.tsx`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the recommended OSS component matrix**
|
||||||
|
|
||||||
|
```md
|
||||||
|
### 4.1 `@tanstack/react-table`
|
||||||
|
- 适合替换管理台和列表型页面表格
|
||||||
|
|
||||||
|
### 4.3 `react-dropzone`
|
||||||
|
- 适合替换网盘和快传的文件选择入口
|
||||||
|
|
||||||
|
### 4.4 `react-arborist`
|
||||||
|
- 适合替换网盘目录树
|
||||||
|
|
||||||
|
### 4.7 `recharts`
|
||||||
|
- 适合替换后台总览图表层
|
||||||
|
|
||||||
|
### 4.8 `vidstack`
|
||||||
|
- 适合后续文件预览里的音视频播放器
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Save the component guide**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,260p' docs/frontend-component-guide.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the document contains the current component baseline, replacement principles, recommended libraries, and adoption priorities.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/frontend-component-guide.md
|
||||||
|
git commit -m "docs: add frontend component selection guide"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Write The Frontend Page Orchestration Guide
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/frontend-page-orchestration.md`
|
||||||
|
- Read: `front/src/pages/Login.tsx`
|
||||||
|
- Read: `front/src/pages/Overview.tsx`
|
||||||
|
- Read: `front/src/pages/files/FilesPage.tsx`
|
||||||
|
- Read: `front/src/pages/Tasks.tsx`
|
||||||
|
- Read: `front/src/pages/Shares.tsx`
|
||||||
|
- Read: `front/src/pages/RecycleBin.tsx`
|
||||||
|
- Read: `front/src/transfer/pages/TransferPage.tsx`
|
||||||
|
- Read: `front/src/pages/FileShare.tsx`
|
||||||
|
- Read: `front/src/admin/*.tsx`
|
||||||
|
- Read: `front/src/lib/admin-users.ts`
|
||||||
|
- Read: `front/src/lib/admin-storage-policies.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the route inventory and shell sections**
|
||||||
|
|
||||||
|
```md
|
||||||
|
# 前端页面编排文档
|
||||||
|
|
||||||
|
## 2. 路由总表
|
||||||
|
|
||||||
|
### 2.1 公共页面
|
||||||
|
- `/login`
|
||||||
|
- `/share/:token`
|
||||||
|
|
||||||
|
### 2.2 主应用页面
|
||||||
|
- `/overview`
|
||||||
|
- `/files`
|
||||||
|
- `/tasks`
|
||||||
|
- `/shares`
|
||||||
|
- `/recycle-bin`
|
||||||
|
- `/transfer`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add every user-facing page with regions and buttons**
|
||||||
|
|
||||||
|
```md
|
||||||
|
## 6.2 网盘页 `/files`
|
||||||
|
|
||||||
|
### 顶部工具区按钮
|
||||||
|
- `上传文件`
|
||||||
|
- `新建文件夹`
|
||||||
|
- `刷新`
|
||||||
|
|
||||||
|
### 右侧文件详情栏按钮
|
||||||
|
- `下载`
|
||||||
|
- `创建分享`
|
||||||
|
- `重命名`
|
||||||
|
- `移动`
|
||||||
|
- `删除`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add every admin page and enumerate every current setting item**
|
||||||
|
|
||||||
|
```md
|
||||||
|
## 7.4 存储策略 `/admin/storage-policies`
|
||||||
|
|
||||||
|
### 弹窗表单字段
|
||||||
|
- `策略名称`
|
||||||
|
- `驱动协议`
|
||||||
|
- `端点地址`
|
||||||
|
- `桶名称`
|
||||||
|
- `对象大小上限(字节)`
|
||||||
|
|
||||||
|
### 布尔设置项
|
||||||
|
- `私有桶`
|
||||||
|
- `启用`
|
||||||
|
- `直传`
|
||||||
|
- `分片上传`
|
||||||
|
- `签名下载`
|
||||||
|
- `代理下载`
|
||||||
|
- `需要 CORS`
|
||||||
|
- `内网端点`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Explicitly call out placeholder admin pages and API-backed but unexposed settings**
|
||||||
|
|
||||||
|
```md
|
||||||
|
### 当前前端未暴露但 API 已存在的管理项
|
||||||
|
- `updateUserMaxUploadSize`
|
||||||
|
- `resetUserPassword`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Save the page orchestration guide**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,320p' docs/frontend-page-orchestration.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the document lists every current page, every visible button, and every current admin setting item, while marking placeholder admin pages clearly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/frontend-page-orchestration.md
|
||||||
|
git commit -m "docs: add frontend page orchestration guide"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Self-Review And Repo Validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/frontend-component-guide.md`
|
||||||
|
- Modify: `docs/frontend-page-orchestration.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-04-12-frontend-docs.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify the docs mention real files and only real commands**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "npm test|npm typecheck|TODO|TBD|implement later" docs/frontend-component-guide.md docs/frontend-page-orchestration.md docs/superpowers/plans/2026-04-12-frontend-docs.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify admin settings coverage against the real code**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,420p' front/src/admin/storage-policies-list.tsx
|
||||||
|
sed -n '1,360p' front/src/admin/users-list.tsx
|
||||||
|
sed -n '1,360p' front/src/admin/files-list.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every visible control in those files is represented in `docs/frontend-page-orchestration.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify git sees the new docs**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A docs/frontend-component-guide.md
|
||||||
|
A docs/frontend-page-orchestration.md
|
||||||
|
A docs/superpowers/plans/2026-04-12-frontend-docs.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the final docs set**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/frontend-component-guide.md docs/frontend-page-orchestration.md docs/superpowers/plans/2026-04-12-frontend-docs.md
|
||||||
|
git commit -m "docs: add frontend documentation set"
|
||||||
|
```
|
||||||
51
front/package-lock.json
generated
51
front/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
@@ -1431,6 +1433,39 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -3322,6 +3357,22 @@
|
|||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.72.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
|
||||||
|
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const AdminFilesList = lazy(() => import('./admin/files-list'));
|
|||||||
const AdminFileBlobs = lazy(() => import('./admin/fileblobs'));
|
const AdminFileBlobs = lazy(() => import('./admin/fileblobs'));
|
||||||
const AdminShares = lazy(() => import('./admin/shares'));
|
const AdminShares = lazy(() => import('./admin/shares'));
|
||||||
const AdminTasks = lazy(() => import('./admin/tasks'));
|
const AdminTasks = lazy(() => import('./admin/tasks'));
|
||||||
|
const AdminAudits = lazy(() => import('./admin/audits'));
|
||||||
const AdminOAuthApps = lazy(() => import('./admin/oauthapps'));
|
const AdminOAuthApps = lazy(() => import('./admin/oauthapps'));
|
||||||
|
|
||||||
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
||||||
@@ -55,6 +56,7 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
|||||||
<Route path="file-blobs" element={<AdminFileBlobs />} />
|
<Route path="file-blobs" element={<AdminFileBlobs />} />
|
||||||
<Route path="shares" element={<AdminShares />} />
|
<Route path="shares" element={<AdminShares />} />
|
||||||
<Route path="tasks" element={<AdminTasks />} />
|
<Route path="tasks" element={<AdminTasks />} />
|
||||||
|
<Route path="audits" element={<AdminAudits />} />
|
||||||
<Route path="oauth-apps" element={<AdminOAuthApps />} />
|
<Route path="oauth-apps" element={<AdminOAuthApps />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -10,54 +10,77 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
Users
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const adminNavItems = [
|
const adminNavSections = [
|
||||||
{ to: 'dashboard', icon: LayoutDashboard, label: '总览' },
|
{
|
||||||
{ to: 'settings', icon: Settings, label: '系统设置' },
|
title: '配置控制台',
|
||||||
{ to: 'filesystem', icon: HardDrive, label: '文件系统' },
|
items: [{ to: 'dashboard', icon: LayoutDashboard, label: '配置首页' }],
|
||||||
{ to: 'storage-policies', icon: Database, label: '存储策略' },
|
},
|
||||||
{ to: 'users', icon: Users, label: '用户管理' },
|
{
|
||||||
{ to: 'files', icon: Files, label: '文件审计' },
|
title: '核心配置',
|
||||||
{ to: 'file-blobs', icon: FileBox, label: '对象实体' },
|
items: [
|
||||||
{ to: 'shares', icon: Share2, label: '分享管理' },
|
{ to: 'settings', icon: Settings, label: '系统设置' },
|
||||||
{ to: 'tasks', icon: ListTodo, label: '任务监控' },
|
{ to: 'storage-policies', icon: Database, label: '存储策略' },
|
||||||
{ to: 'oauth-apps', icon: Key, label: '三方应用' },
|
{ to: 'users', icon: Users, label: '用户策略' },
|
||||||
|
{ to: 'filesystem', icon: HardDrive, label: '文件系统快照' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '治理工具',
|
||||||
|
items: [
|
||||||
|
{ to: 'files', icon: Files, label: '文件治理' },
|
||||||
|
{ to: 'file-blobs', icon: FileBox, label: '对象治理' },
|
||||||
|
{ to: 'shares', icon: Share2, label: '分享治理' },
|
||||||
|
{ to: 'tasks', icon: ListTodo, label: '任务监控' },
|
||||||
|
{ to: 'audits', icon: Activity, label: '审计日志' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '规划能力',
|
||||||
|
items: [{ to: 'oauth-apps', icon: Key, label: '三方应用' }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden">
|
||||||
{/* Admin Secondary Sidebar */}
|
|
||||||
<aside className="w-64 flex-shrink-0 border-r border-white/10 bg-white/5 dark:bg-black/20 flex flex-col z-10">
|
<aside className="w-64 flex-shrink-0 border-r border-white/10 bg-white/5 dark:bg-black/20 flex flex-col z-10">
|
||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">管理中心</h2>
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">配置控制台</h2>
|
||||||
|
<p className="mt-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-20">先改配置,再做治理</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto px-4 pb-8 space-y-1 custom-scrollbar">
|
<nav className="flex-1 overflow-y-auto px-4 pb-8 custom-scrollbar">
|
||||||
{adminNavItems.map((item) => (
|
{adminNavSections.map((section) => (
|
||||||
<NavLink
|
<div key={section.title} className="mb-7">
|
||||||
key={item.to}
|
<div className="px-4 pb-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{section.title}</div>
|
||||||
to={item.to}
|
<div className="space-y-1">
|
||||||
className={({ isActive }) =>
|
{section.items.map((item) => (
|
||||||
cn(
|
<NavLink
|
||||||
"flex items-center gap-3 px-4 py-3 rounded-lg text-xs font-black uppercase tracking-widest transition-all duration-300",
|
key={item.to}
|
||||||
isActive
|
to={item.to}
|
||||||
? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/10"
|
className={({ isActive }) =>
|
||||||
: "text-gray-700 dark:text-gray-200 hover:bg-white/10 dark:hover:bg-white/5 opacity-60 hover:opacity-100"
|
cn(
|
||||||
)
|
'flex items-center gap-3 px-4 py-3 rounded-lg text-xs font-black uppercase tracking-widest transition-all duration-300',
|
||||||
}
|
isActive
|
||||||
>
|
? 'bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/10'
|
||||||
<item.icon className="h-4 w-4" />
|
: 'text-gray-700 dark:text-gray-200 hover:bg-white/10 dark:hover:bg-white/5 opacity-60 hover:opacity-100',
|
||||||
{item.label}
|
)
|
||||||
</NavLink>
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Admin Content Area */}
|
|
||||||
<main className="flex-1 overflow-hidden relative">
|
<main className="flex-1 overflow-hidden relative">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
|||||||
455
front/src/admin/audits.tsx
Normal file
455
front/src/admin/audits.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, RefreshCw, Search } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { formatDateTime } from '@/src/lib/format';
|
||||||
|
import { getAdminAudits, type AdminAuditLog } from '@/src/lib/admin-audits';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 12, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = {
|
||||||
|
actorQuery: '',
|
||||||
|
actionType: '',
|
||||||
|
targetType: '',
|
||||||
|
targetId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function titleBlock(title: string, subtitle: string) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthorities(value: AdminAuditLog['actorAuthorities']) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the plain-text splitter below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetailsJson(detailsJson: string | null) {
|
||||||
|
if (!detailsJson?.trim()) {
|
||||||
|
return '无详细内容';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detailsJson);
|
||||||
|
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
return detailsJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionPill(value: string) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] text-blue-600 dark:text-blue-300">
|
||||||
|
{value || '-'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetPill(type: string, targetId: string | null) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col gap-1">
|
||||||
|
<span className="inline-flex w-fit items-center rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-80">
|
||||||
|
{type || '-'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] font-black uppercase tracking-[0.16em] opacity-35">{targetId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAuditsPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const [page, setPage] = useState<{
|
||||||
|
items: AdminAuditLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [expandedAuditIds, setExpandedAuditIds] = useState<Set<number>>(() => new Set());
|
||||||
|
|
||||||
|
async function loadAudits(nextPage = 0, nextFilters = filters, isRefresh = false) {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAdminAudits(nextPage, 100, nextFilters);
|
||||||
|
setPage(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载审计日志失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAudits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyText(value: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch {
|
||||||
|
window.alert('复制失败,请手动复制。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = page?.items ?? [];
|
||||||
|
const activeFilterLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
filters.actorQuery.trim() ? `操作者: ${filters.actorQuery.trim()}` : '',
|
||||||
|
filters.actionType.trim() ? `动作: ${filters.actionType.trim()}` : '',
|
||||||
|
filters.targetType.trim() ? `目标类型: ${filters.targetType.trim()}` : '',
|
||||||
|
filters.targetId.trim() ? `目标 ID: ${filters.targetId.trim()}` : '',
|
||||||
|
].filter(Boolean),
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const isInitialLoading = loading && !page;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">审计日志</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
`GET /api/admin/audits` / 操作者 / 动作 / 目标 / 详情展开
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadAudits(page?.page ?? 0, filters, true);
|
||||||
|
}}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
刷新列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void loadAudits(0, filters);
|
||||||
|
}}
|
||||||
|
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
{titleBlock('筛选器', '只使用后端支持的查询参数,避免前端侧再做任何推断')}
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_0.9fr_0.9fr_0.9fr]">
|
||||||
|
<label className="group relative block">
|
||||||
|
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
|
||||||
|
<input
|
||||||
|
value={filters.actorQuery}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, actorQuery: event.target.value }))}
|
||||||
|
placeholder="操作者关键词"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<input
|
||||||
|
value={filters.actionType}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, actionType: event.target.value }))}
|
||||||
|
placeholder="动作类型"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<input
|
||||||
|
value={filters.targetType}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, targetType: event.target.value }))}
|
||||||
|
placeholder="目标类型"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<input
|
||||||
|
value={filters.targetId}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, targetId: event.target.value }))}
|
||||||
|
placeholder="目标 ID"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeFilterLabels.length ? (
|
||||||
|
activeFilterLabels.map((label) => (
|
||||||
|
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25">当前没有启用筛选条件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilters(DEFAULT_FILTERS);
|
||||||
|
void loadAudits(0, DEFAULT_FILTERS);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
应用筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
<span>共 {page?.total ?? 0} 条审计记录</span>
|
||||||
|
<span>当前页 {items.length} 条</span>
|
||||||
|
<span>{page ? `第 ${page.page + 1} 页 / 每页 ${page.size}` : '第 - 页'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<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 border border-white/10 shadow-3xl">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-[1600px] divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">时间</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">操作者</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">动作</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">目标</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">摘要</th>
|
||||||
|
<th className="px-6 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40">详情</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody variants={container} initial="hidden" animate="show" className="divide-y divide-white/10 dark:divide-white/5">
|
||||||
|
{items.map((audit) => {
|
||||||
|
const authorities = normalizeAuthorities(audit.actorAuthorities);
|
||||||
|
const expanded = expandedAuditIds.has(audit.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={audit.id}>
|
||||||
|
<motion.tr variants={itemVariants} className="group transition-colors hover:bg-white/10 dark:hover:bg-white/5">
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black tracking-tight">{formatDateTime(audit.createdAt)}</div>
|
||||||
|
<div className="mt-1 font-mono text-[9px] font-black uppercase tracking-[0.18em] opacity-35">ID {audit.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[12px] font-black tracking-tight">{audit.actorUsername || '系统 / 未知'}</div>
|
||||||
|
<div className="mt-1 font-mono text-[9px] font-black uppercase tracking-[0.18em] opacity-35">
|
||||||
|
{audit.actorUserId != null ? `user #${audit.actorUserId}` : '无用户 ID'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{authorities.length ? (
|
||||||
|
authorities.map((authority) => (
|
||||||
|
<span
|
||||||
|
key={`${audit.id}-${authority}`}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-70"
|
||||||
|
>
|
||||||
|
{authority}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
|
||||||
|
无权限信息
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">{actionPill(audit.actionType)}</td>
|
||||||
|
<td className="px-6 py-5 align-top">{targetPill(audit.targetType, audit.targetId)}</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="max-w-[560px] text-[11px] font-bold leading-6 opacity-90">{audit.summary || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedAuditIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(audit.id)) {
|
||||||
|
next.delete(audit.id);
|
||||||
|
} else {
|
||||||
|
next.add(audit.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[9px] font-black uppercase tracking-[0.18em] transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
{expanded ? '收起详情' : '查看详情'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
{expanded ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 pb-6">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">详情内容</h3>
|
||||||
|
<p className="mt-2 text-[11px] font-bold opacity-45">`detailsJson` 原文与格式化预览</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(audit.detailsJson || '')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[9px] font-black uppercase tracking-[0.18em] transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
复制原文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-black/10 p-4">
|
||||||
|
<div className="mb-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">基础信息</div>
|
||||||
|
<div className="space-y-3 text-[11px] font-bold leading-6">
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Summary</span>
|
||||||
|
{audit.summary || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Action</span>
|
||||||
|
{audit.actionType || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Target</span>
|
||||||
|
{audit.targetType || '-'} {audit.targetId ? `#${audit.targetId}` : ''}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Created</span>
|
||||||
|
{formatDateTime(audit.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">JSON 预览</div>
|
||||||
|
<pre className="max-h-[420px] overflow-auto rounded-lg border border-white/10 bg-black/20 p-4 font-mono text-[11px] leading-6 text-gray-200 dark:text-gray-100">
|
||||||
|
{formatDetailsJson(audit.detailsJson)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-35">
|
||||||
|
当前筛选条件下没有审计记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</motion.tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
{page ? `第 ${page.page + 1} 页 / 每页 ${page.size}` : '尚未加载分页信息'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!page || page.page <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadAudits(page.page - 1, filters);
|
||||||
|
}}
|
||||||
|
disabled={!page || page.page <= 0 || loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!page || (page.page + 1) * page.size >= page.total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadAudits(page.page + 1, filters);
|
||||||
|
}}
|
||||||
|
disabled={!page || (page.page + 1) * page.size >= page.total || loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Copy, Database, HardDrive, RefreshCw, Send, Users, ChevronRight, Activity } from 'lucide-react';
|
import {
|
||||||
import { cn } from '@/src/lib/utils';
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
Copy,
|
||||||
|
Database,
|
||||||
|
HardDrive,
|
||||||
|
RefreshCw,
|
||||||
|
Send,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
import { getAdminSummary, type AdminSummary } from '@/src/lib/admin';
|
import { getAdminSummary, type AdminSummary } from '@/src/lib/admin';
|
||||||
import { formatBytes } from '@/src/lib/format';
|
import { formatBytes } from '@/src/lib/format';
|
||||||
|
|
||||||
@@ -11,27 +23,107 @@ const container = {
|
|||||||
show: {
|
show: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.05
|
staggerChildren: 0.05,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { y: 20, opacity: 0 },
|
hidden: { y: 16, opacity: 0 },
|
||||||
show: { y: 0, opacity: 1 }
|
show: { y: 0, opacity: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ConfigCard({
|
||||||
|
to,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
highlights,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
highlights: string[];
|
||||||
|
tone: 'blue' | 'emerald' | 'amber';
|
||||||
|
}) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'emerald'
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-500'
|
||||||
|
: tone === 'amber'
|
||||||
|
? 'border-amber-500/20 bg-amber-500/10 text-amber-500'
|
||||||
|
: 'border-blue-500/20 bg-blue-500/10 text-blue-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="group glass-panel-no-hover rounded-2xl border border-white/10 p-7 shadow-3xl transition-all hover:border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className={cn('flex h-12 w-12 items-center justify-center rounded-xl border', toneClass)}>{icon}</div>
|
||||||
|
<ArrowRight className="h-4 w-4 opacity-20 transition-all group-hover:translate-x-1 group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-xl font-black tracking-tight text-gray-900 dark:text-white">{title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
|
{highlights.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-75"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCard({
|
||||||
|
to,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="group rounded-xl border border-white/10 bg-white/5 p-5 transition-all hover:border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-black/10 text-gray-700 dark:text-gray-100">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-[0.18em]">{title}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold leading-5 opacity-45">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 opacity-20 transition-all group-hover:translate-x-1 group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [summary, setSummary] = useState<AdminSummary | null>(null);
|
const [summary, setSummary] = useState<AdminSummary | null>(null);
|
||||||
|
const [copiedInviteCode, setCopiedInviteCode] = useState(false);
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
setSummary(await getAdminSummary());
|
setSummary(await getAdminSummary());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '加载后台总览失败');
|
setError(err instanceof Error ? err.message : '加载配置首页失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -41,17 +133,29 @@ export default function AdminDashboard() {
|
|||||||
void loadSummary();
|
void loadSummary();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function copyInviteCode(inviteCode: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteCode);
|
||||||
|
setCopiedInviteCode(true);
|
||||||
|
window.setTimeout(() => setCopiedInviteCode(false), 1500);
|
||||||
|
} catch {
|
||||||
|
setError('复制邀请码失败,请手动复制。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<div className="mb-10 flex items-center justify-between">
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white">后台指挥中心</h1>
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">配置首页</h1>
|
||||||
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">全局基础设施 / 系统遥测</p>
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
系统配置 / 存储配置 / 用户策略 / 治理工具
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -59,145 +163,159 @@ export default function AdminDashboard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
void loadSummary();
|
void loadSummary();
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||||
刷新状态
|
刷新配置视图
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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 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}
|
||||||
|
|
||||||
{loading && !summary ? (
|
{loading && !summary ? (
|
||||||
<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>
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<motion.div
|
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
|
||||||
variants={container}
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl">
|
||||||
initial="hidden"
|
<div className="grid gap-8 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
animate="show"
|
<div>
|
||||||
className="space-y-10"
|
<div className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">配置主入口</div>
|
||||||
>
|
<h2 className="mt-4 text-3xl font-black tracking-tight text-gray-900 dark:text-white">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
后台先改配置,再做治理
|
||||||
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-blue-500/30 transition-all">
|
</h2>
|
||||||
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-blue-500/10 border border-blue-500/20 shadow-[0_0_15px_rgba(59,130,246,0.1)]">
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-gray-600 dark:text-gray-300">
|
||||||
<Users className="h-7 w-7 text-blue-500" />
|
这里不再把后台定义成“看统计的地方”,而是把已经能影响系统行为的配置入口集中起来。你现在最应该先改的是系统级开关、存储策略和用户策略,治理工具放在第二层。
|
||||||
</div>
|
</p>
|
||||||
<h3 className="text-4xl font-black tracking-tight group-hover:text-blue-500 transition-colors">{summary.totalUsers}</h3>
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">用户总数</p>
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
|
||||||
</motion.div>
|
邀请码: {summary.inviteCode}
|
||||||
|
</span>
|
||||||
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-green-500/30 transition-all">
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
|
||||||
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-green-500/10 border border-green-500/20 shadow-[0_0_15px_rgba(34,197,94,0.1)]">
|
离线快传上限: {formatBytes(summary.offlineTransferStorageLimitBytes)}
|
||||||
<HardDrive className="h-7 w-7 text-green-500" />
|
</span>
|
||||||
</div>
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
|
||||||
<h3 className="text-4xl font-black tracking-tight group-hover:text-green-500 transition-colors">{summary.totalFiles}</h3>
|
当前用户数: {summary.totalUsers}
|
||||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">文件总数</p>
|
</span>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-purple-500/30 transition-all">
|
|
||||||
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-purple-500/10 border border-purple-500/20 shadow-[0_0_15px_rgba(168,85,247,0.1)]">
|
|
||||||
<Database className="h-7 w-7 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-4xl font-black tracking-tight group-hover:text-purple-500 transition-colors">{formatBytes(summary.totalStorageBytes).split(' ')[0]}<span className="text-xl ml-1 opacity-40">{formatBytes(summary.totalStorageBytes).split(' ')[1]}</span></h3>
|
|
||||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">存储容量</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-amber-500/30 transition-all">
|
|
||||||
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-amber-500/10 border border-amber-500/20 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
|
|
||||||
<Send className="h-7 w-7 text-amber-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-4xl font-black tracking-tight group-hover:text-amber-500 transition-colors">{formatBytes(summary.offlineTransferStorageBytes).split(' ')[0]}<span className="text-xl ml-1 opacity-40">{formatBytes(summary.offlineTransferStorageBytes).split(' ')[1]}</span></h3>
|
|
||||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">快传占用</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2">
|
|
||||||
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">快捷入口</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<Link to="/admin/users" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-blue-500/30 transition-all group">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 group-hover:bg-blue-600 text-blue-500 group-hover:text-white transition-all shadow-inner">
|
|
||||||
<Users className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[11px] font-black uppercase tracking-widest block">用户管理</span>
|
|
||||||
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity">统一账号控制</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin/files" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-green-500/30 transition-all group">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 rounded-lg bg-green-500/10 group-hover:bg-green-600 text-green-500 group-hover:text-white transition-all shadow-inner">
|
|
||||||
<HardDrive className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[11px] font-black uppercase tracking-widest block">文件审计</span>
|
|
||||||
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity">全站文件巡检</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin/storage-policies" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-purple-500/30 transition-all group">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="p-3 rounded-lg bg-purple-500/10 group-hover:bg-purple-600 text-purple-500 group-hover:text-white transition-all shadow-inner">
|
|
||||||
<Database className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[11px] font-black uppercase tracking-widest block">存储策略</span>
|
|
||||||
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity">按策略分发</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</motion.section>
|
|
||||||
|
|
||||||
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">运行概览</h2>
|
|
||||||
<div className="flex items-center gap-3 text-[9px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-green-500/10 text-green-500 border border-green-500/20 shadow-inner">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]"></span>
|
|
||||||
服务健康
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="p-8 rounded-lg bg-black/40 border border-white/5">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.3em] opacity-30">邀请码</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { navigator.clipboard.writeText(summary.inviteCode); window.alert('邀请码已复制'); }}
|
|
||||||
className="p-2 rounded-lg hover:bg-white/10 transition-all opacity-40 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl font-black tracking-[0.4em] text-center p-8 bg-blue-500/5 rounded-lg border border-white/5 text-blue-500/80 drop-shadow-2xl">
|
|
||||||
{summary.inviteCode}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-6">
|
||||||
<div className="p-6 rounded-lg bg-white/5 border border-white/5 group hover:border-white/20 transition-all">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 flex items-center gap-2">
|
<div>
|
||||||
<Activity className="h-3 w-3" /> 下载流量
|
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30">当前生效值</div>
|
||||||
</div>
|
<div className="mt-2 text-xl font-black tracking-[0.25em] text-blue-500">{summary.inviteCode}</div>
|
||||||
<div className="text-2xl font-black tracking-tight">{formatBytes(summary.downloadTrafficBytes)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 rounded-lg bg-white/5 border border-white/5 group hover:border-white/20 transition-all">
|
<button
|
||||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 flex items-center gap-2">
|
type="button"
|
||||||
<Activity className="h-3 w-3" /> 请求量
|
onClick={() => void copyInviteCode(summary.inviteCode)}
|
||||||
</div>
|
className="rounded-lg border border-white/10 bg-white/5 p-2.5 transition-colors hover:bg-white/10"
|
||||||
<div className="text-2xl font-black tracking-tight group-hover:text-blue-500 transition-colors font-black">{summary.requestCount} <span className="text-xs opacity-40 ml-1">次</span></div>
|
title="复制邀请码"
|
||||||
|
>
|
||||||
|
{copiedInviteCode ? <Shield className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30">离线快传占用</div>
|
||||||
|
<div className="mt-2 text-lg font-black tracking-tight">{formatBytes(summary.offlineTransferStorageBytes)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30">下载流量</div>
|
||||||
|
<div className="mt-2 text-lg font-black tracking-tight">{formatBytes(summary.downloadTrafficBytes)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30">总文件数</div>
|
||||||
|
<div className="mt-2 text-lg font-black tracking-tight">{summary.totalFiles}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30">请求量</div>
|
||||||
|
<div className="mt-2 text-lg font-black tracking-tight">{summary.requestCount}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</div>
|
||||||
</div>
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">配置分组</h2>
|
||||||
|
<p className="mt-2 text-[10px] font-bold uppercase tracking-[0.2em] opacity-25">先处理系统行为,再处理治理问题</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<ConfigCard
|
||||||
|
to="/admin/settings"
|
||||||
|
icon={<Settings className="h-6 w-6" />}
|
||||||
|
title="系统配置"
|
||||||
|
description="集中处理邀请码、离线快传总容量,以及当前运行环境里最直接影响注册和传输行为的系统项。"
|
||||||
|
highlights={['邀请码', '离线快传上限', '运行快照']}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
<ConfigCard
|
||||||
|
to="/admin/storage-policies"
|
||||||
|
icon={<Database className="h-6 w-6" />}
|
||||||
|
title="存储配置"
|
||||||
|
description="集中处理存储策略的新增、编辑、启停与迁移任务创建,不再把这块藏在资源表格里。"
|
||||||
|
highlights={['策略编辑', '启停', '迁移任务']}
|
||||||
|
tone="emerald"
|
||||||
|
/>
|
||||||
|
<ConfigCard
|
||||||
|
to="/admin/users"
|
||||||
|
icon={<Users className="h-6 w-6" />}
|
||||||
|
title="用户策略"
|
||||||
|
description="集中处理用户角色、配额、上传上限、手动改密和临时密码重置,把用户页从“查人”改成“改规则”。"
|
||||||
|
highlights={['角色', '配额', '上传上限', '密码策略']}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">治理工具</h2>
|
||||||
|
<p className="mt-2 text-[10px] font-bold uppercase tracking-[0.2em] opacity-25">这些页面更偏治理与排查,而不是直接改配置</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ToolCard to="/admin/files" icon={<HardDrive className="h-5 w-5" />} title="文件治理" description="查全站文件、执行高风险删除。" />
|
||||||
|
<ToolCard to="/admin/file-blobs" icon={<Database className="h-5 w-5" />} title="对象治理" description="查 blob 关联、孤儿风险和对象异常。" />
|
||||||
|
<ToolCard to="/admin/shares" icon={<Share2 className="h-5 w-5" />} title="分享治理" description="排查 Token、撤销分享和过期风险。" />
|
||||||
|
<ToolCard to="/admin/tasks" icon={<Send className="h-5 w-5" />} title="任务监控" description="观察迁移和后台任务,不在这里改系统配置。" />
|
||||||
|
<ToolCard to="/admin/audits" icon={<Activity className="h-5 w-5" />} title="审计日志" description="复盘谁改了什么,而不是直接改值。" />
|
||||||
|
<ToolCard to="/admin/filesystem" icon={<HardDrive className="h-5 w-5" />} title="文件系统快照" description="查看当前文件与上传体系状态。" />
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants} className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
|
||||||
|
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30">系统配置负载</div>
|
||||||
|
<div className="mt-3 text-2xl font-black tracking-tight">{formatBytes(summary.offlineTransferStorageLimitBytes)}</div>
|
||||||
|
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45">当前系统里最直接可调的资源上限是离线快传容量,总览展示它是为了方便你先去调参。</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
|
||||||
|
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-500/20 bg-emerald-500/10 text-emerald-500">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30">存储当前占用</div>
|
||||||
|
<div className="mt-3 text-2xl font-black tracking-tight">{formatBytes(summary.totalStorageBytes)}</div>
|
||||||
|
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45">存储策略页会决定上传模式、对象大小上限和迁移方向,这里只给你一个当前量级参考。</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
|
||||||
|
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-amber-500/20 bg-amber-500/10 text-amber-500">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30">用户策略对象</div>
|
||||||
|
<div className="mt-3 text-2xl font-black tracking-tight">{summary.totalUsers}</div>
|
||||||
|
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45">用户页现在应该被理解成“用户策略面板”,你在里面改的是规则和限制,不是只读名单。</p>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1 +1,482 @@
|
|||||||
export default function AdminFileBlobs() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin FileBlobs (待开发)</h1></div>; }
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, CheckCircle2, Copy, FileBox, RefreshCw, Search, ShieldAlert, XCircle } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||||
|
import {
|
||||||
|
getAdminFileBlobs,
|
||||||
|
type AdminFileBlobEntityType,
|
||||||
|
type AdminFileBlobResponse,
|
||||||
|
} from '@/src/lib/admin-fileblobs';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 12, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = {
|
||||||
|
userQuery: '',
|
||||||
|
storagePolicyId: '',
|
||||||
|
objectKey: '',
|
||||||
|
entityType: '' as AdminFileBlobEntityType | '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS: Record<AdminFileBlobEntityType, string> = {
|
||||||
|
VERSION: '版本',
|
||||||
|
THUMBNAIL: '缩略图',
|
||||||
|
LIVE_PHOTO: '实况照片',
|
||||||
|
TRANSCODE: '转码产物',
|
||||||
|
AVATAR: '头像',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusPill(active: boolean, trueLabel: string, falseLabel: string, tone: 'red' | 'amber' | 'purple' = 'red') {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'amber'
|
||||||
|
? active
|
||||||
|
? 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
|
||||||
|
: tone === 'purple'
|
||||||
|
? active
|
||||||
|
? 'border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
|
||||||
|
: active
|
||||||
|
? 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em]',
|
||||||
|
toneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
|
||||||
|
{active ? trueLabel : falseLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleBlock(title: string, subtitle: string) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
tone: 'blue' | 'green' | 'amber' | 'red' | 'purple';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-2xl transition-all group hover:border-white/20">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mb-5 flex h-12 w-12 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]',
|
||||||
|
tone === 'blue' && 'bg-blue-500/10 border-blue-500/20 text-blue-500',
|
||||||
|
tone === 'green' && 'bg-green-500/10 border-green-500/20 text-green-500',
|
||||||
|
tone === 'purple' && 'bg-purple-500/10 border-purple-500/20 text-purple-500',
|
||||||
|
tone === 'amber' && 'bg-amber-500/10 border-amber-500/20 text-amber-500',
|
||||||
|
tone === 'red' && 'bg-red-500/10 border-red-500/20 text-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black tracking-tight">{value}</h3>
|
||||||
|
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskRow(label: string, active: boolean, tone: 'red' | 'amber' | 'purple') {
|
||||||
|
return statusPill(active, label, `无${label}`, tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminFileBlobs() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const [page, setPage] = useState<{
|
||||||
|
items: AdminFileBlobResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function loadFileBlobs(nextFilters = filters, isRefresh = false) {
|
||||||
|
const trimmedStoragePolicyId = nextFilters.storagePolicyId.trim();
|
||||||
|
if (trimmedStoragePolicyId) {
|
||||||
|
const parsedStoragePolicyId = Number(trimmedStoragePolicyId);
|
||||||
|
if (!Number.isInteger(parsedStoragePolicyId) || parsedStoragePolicyId <= 0) {
|
||||||
|
setError('存储策略 ID 必须是正整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAdminFileBlobs(0, 100, {
|
||||||
|
userQuery: nextFilters.userQuery,
|
||||||
|
storagePolicyId: trimmedStoragePolicyId ? Number(trimmedStoragePolicyId) : null,
|
||||||
|
objectKey: nextFilters.objectKey,
|
||||||
|
entityType: nextFilters.entityType,
|
||||||
|
});
|
||||||
|
setPage(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载对象实体失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFileBlobs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyText(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch {
|
||||||
|
window.alert('复制失败,请手动复制。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = page?.items ?? [];
|
||||||
|
const blobMissingCount = items.filter((item) => item.blobMissing).length;
|
||||||
|
const orphanRiskCount = items.filter((item) => item.orphanRisk).length;
|
||||||
|
const referenceMismatchCount = items.filter((item) => item.referenceMismatch).length;
|
||||||
|
const anyRiskCount = items.filter((item) => item.blobMissing || item.orphanRisk || item.referenceMismatch).length;
|
||||||
|
const activeFilterLabels = [
|
||||||
|
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
|
||||||
|
filters.storagePolicyId.trim() ? `策略: #${filters.storagePolicyId.trim()}` : '',
|
||||||
|
filters.objectKey.trim() ? `对象键: ${filters.objectKey.trim()}` : '',
|
||||||
|
filters.entityType ? `实体类型: ${ENTITY_TYPE_LABELS[filters.entityType]}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
const isInitialLoading = loading && !page;
|
||||||
|
const pageLabel = page ? `第 ${page.page + 1} 页 / 每页 ${page.size}` : '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">对象实体</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
`GET /api/admin/file-blobs` / 用户检索 / 策略过滤 / 风险巡检
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadFileBlobs(filters, true);
|
||||||
|
}}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
刷新列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.section variants={container} initial="hidden" animate="show" className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
{metricCard({
|
||||||
|
label: '当前页对象数',
|
||||||
|
value: page ? `${items.length}` : '-',
|
||||||
|
icon: <FileBox className="h-6 w-6" />,
|
||||||
|
tone: 'blue',
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
{metricCard({
|
||||||
|
label: 'blobMissing',
|
||||||
|
value: `${blobMissingCount}`,
|
||||||
|
icon: <XCircle className="h-6 w-6" />,
|
||||||
|
tone: 'red',
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
{metricCard({
|
||||||
|
label: 'orphanRisk',
|
||||||
|
value: `${orphanRiskCount}`,
|
||||||
|
icon: <AlertTriangle className="h-6 w-6" />,
|
||||||
|
tone: 'amber',
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
{metricCard({
|
||||||
|
label: 'referenceMismatch',
|
||||||
|
value: `${referenceMismatchCount}`,
|
||||||
|
icon: <ShieldAlert className="h-6 w-6" />,
|
||||||
|
tone: 'purple',
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void loadFileBlobs(filters);
|
||||||
|
}}
|
||||||
|
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
{titleBlock('筛选器', '只保留服务端支持的查询参数,避免前端做额外猜测')}
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.2fr_0.8fr_1fr_0.8fr]">
|
||||||
|
<label className="group relative block">
|
||||||
|
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
|
||||||
|
<input
|
||||||
|
value={filters.userQuery}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
|
||||||
|
placeholder="搜索用户名 / 邮箱 / 手机号"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<input
|
||||||
|
value={filters.storagePolicyId}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, storagePolicyId: event.target.value }))}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="存储策略 ID"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<input
|
||||||
|
value={filters.objectKey}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, objectKey: event.target.value }))}
|
||||||
|
placeholder="对象键"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="group relative block">
|
||||||
|
<select
|
||||||
|
value={filters.entityType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">全部实体类型</option>
|
||||||
|
{Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeFilterLabels.length ? (
|
||||||
|
activeFilterLabels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25">当前没有启用筛选条件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilters(DEFAULT_FILTERS);
|
||||||
|
void loadFileBlobs(DEFAULT_FILTERS);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
应用筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
|
||||||
|
{page ? (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
<span>
|
||||||
|
共 {page.total} 条记录 / {pageLabel}
|
||||||
|
</span>
|
||||||
|
<span>风险对象 {anyRiskCount} 条</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{isInitialLoading ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
正在加载对象实体快照...
|
||||||
|
</div>
|
||||||
|
) : page ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-[1600px] divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">对象键</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">实体 / Blob</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">存储策略</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">大小 / 类型</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">关联信息</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">风险</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody variants={container} initial="hidden" animate="show" className="divide-y divide-white/10 dark:divide-white/5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const rowClassName = cn(
|
||||||
|
'group transition-colors',
|
||||||
|
item.blobMissing && 'bg-red-500/5 hover:bg-red-500/10',
|
||||||
|
!item.blobMissing && item.orphanRisk && 'bg-amber-500/5 hover:bg-amber-500/10',
|
||||||
|
!item.blobMissing && !item.orphanRisk && item.referenceMismatch && 'bg-purple-500/5 hover:bg-purple-500/10',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.tr key={`${item.entityId}-${item.blobId}`} variants={itemVariants} className={rowClassName}>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="break-all font-mono text-[11px] font-black uppercase tracking-tight group-hover:text-blue-500">
|
||||||
|
{item.objectKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
|
||||||
|
创建者 {item.createdByUsername || `#${item.createdByUserId ?? '-'}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
|
||||||
|
样本所有者 {item.sampleOwnerUsername || '-'}
|
||||||
|
{item.sampleOwnerEmail ? ` / ${item.sampleOwnerEmail}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(item.objectKey)}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 p-2 text-blue-500 opacity-30 transition-all hover:bg-blue-600 hover:text-white group-hover:opacity-100"
|
||||||
|
title="复制对象键"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight">实体 #{item.entityId}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Blob #{item.blobId}</div>
|
||||||
|
<div className="mt-2 inline-flex rounded-full border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] text-blue-600 dark:text-blue-400">
|
||||||
|
{ENTITY_TYPE_LABELS[item.entityType]}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight">策略 #{item.storagePolicyId}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
|
||||||
|
{item.createdByUserId != null ? `创建者 ID ${item.createdByUserId}` : '创建者 ID -'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight">{formatBytes(item.size)}</div>
|
||||||
|
<div className="mt-1 break-words text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
|
||||||
|
{item.contentType || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="space-y-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
|
||||||
|
<div>引用 {item.referenceCount ?? '-'}</div>
|
||||||
|
<div>关联文件 {item.linkedStoredFileCount}</div>
|
||||||
|
<div>关联所有者 {item.linkedOwnerCount}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{riskRow('blobMissing', item.blobMissing, 'red')}
|
||||||
|
{riskRow('orphanRisk', item.orphanRisk, 'amber')}
|
||||||
|
{riskRow('referenceMismatch', item.referenceMismatch, 'purple')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-tighter opacity-30">{formatDateTime(item.createdAt)}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
|
||||||
|
Blob {formatDateTime(item.blobCreatedAt)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} 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 className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
暂无对象实体数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,327 @@
|
|||||||
export default function AdminFilesystem() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Filesystem (待开发)</h1></div>; }
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CheckCircle2, Copy, Database, Globe, HardDrive, Layers3, RefreshCw, Server, ShieldCheck, XCircle } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||||
|
import { getAdminFilesystem, type AdminFilesystemResponse } from '@/src/lib/admin-filesystem';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 14, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusClass(active: boolean) {
|
||||||
|
return active
|
||||||
|
? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
|
||||||
|
: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(active: boolean) {
|
||||||
|
return active ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanLabel(active: boolean) {
|
||||||
|
return active ? '启用' : '停用';
|
||||||
|
}
|
||||||
|
|
||||||
|
function infoRow(label: string, value: string) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30">{label}</div>
|
||||||
|
<div className="mt-2 break-words text-[11px] font-black uppercase tracking-tight opacity-80">{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityRow(label: string, active: boolean) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50">{label}</span>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(active))}>
|
||||||
|
{statusIcon(active)}
|
||||||
|
{booleanLabel(active)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionTitle(title: string, subtitle: string) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminFilesystem() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [filesystem, setFilesystem] = useState<AdminFilesystemResponse | null>(null);
|
||||||
|
|
||||||
|
async function loadFilesystem() {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
setFilesystem(await getAdminFilesystem());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载文件系统信息失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFilesystem();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyText(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch {
|
||||||
|
window.alert('复制失败,请手动复制。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overviewCards = filesystem
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: '存储提供者',
|
||||||
|
value: filesystem.overview.storageProvider,
|
||||||
|
icon: <Server className="h-7 w-7 text-blue-500" />,
|
||||||
|
tone: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '文件总数',
|
||||||
|
value: String(filesystem.overview.totalFiles),
|
||||||
|
icon: <HardDrive className="h-7 w-7 text-green-500" />,
|
||||||
|
tone: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '对象总数',
|
||||||
|
value: String(filesystem.overview.totalBlobs),
|
||||||
|
icon: <Database className="h-7 w-7 text-purple-500" />,
|
||||||
|
tone: 'purple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '实体总数',
|
||||||
|
value: String(filesystem.overview.totalEntities),
|
||||||
|
icon: <Layers3 className="h-7 w-7 text-amber-500" />,
|
||||||
|
tone: 'amber',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const capabilityEntries = filesystem
|
||||||
|
? [
|
||||||
|
['直传', filesystem.defaultPolicy.capabilities.directUpload],
|
||||||
|
['分片上传', filesystem.defaultPolicy.capabilities.multipartUpload],
|
||||||
|
['签名下载', filesystem.defaultPolicy.capabilities.signedDownloadUrl],
|
||||||
|
['服务端代理下载', filesystem.defaultPolicy.capabilities.serverProxyDownload],
|
||||||
|
['原生缩略图', filesystem.defaultPolicy.capabilities.thumbnailNative],
|
||||||
|
['友好下载名', filesystem.defaultPolicy.capabilities.friendlyDownloadName],
|
||||||
|
['需要 CORS', filesystem.defaultPolicy.capabilities.requiresCors],
|
||||||
|
['内部端点', filesystem.defaultPolicy.capabilities.supportsInternalEndpoint],
|
||||||
|
] as const
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">文件系统</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">存储概览 / 上传模式 / 媒体处理 / 缓存 / WebDAV</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
void loadFilesystem();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 font-black text-[11px] uppercase tracking-widest transition-all hover:bg-white/40"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
</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}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : filesystem ? (
|
||||||
|
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
|
||||||
|
<motion.section variants={itemVariants} className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{overviewCards.map((card) => (
|
||||||
|
<div key={card.label} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-2xl transition-all group hover:border-white/20">
|
||||||
|
<div className={cn('mb-6 flex h-14 w-14 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]', card.tone === 'green' && 'bg-green-500/10 border-green-500/20', card.tone === 'purple' && 'bg-purple-500/10 border-purple-500/20', card.tone === 'amber' && 'bg-amber-500/10 border-amber-500/20', card.tone === 'blue' && 'bg-blue-500/10 border-blue-500/20')}>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-4xl font-black tracking-tight">{card.value}</h3>
|
||||||
|
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{card.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8 xl:grid-cols-[1.35fr_0.65fr]">
|
||||||
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-10 shadow-3xl">
|
||||||
|
{sectionTitle('默认存储策略', '当前系统选择的默认分发与对象存储规则')}
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
|
<span className={cn('inline-flex items-center gap-2 rounded-sm border px-2.5 py-1 text-[9px] font-black uppercase tracking-widest', filesystem.defaultPolicy.enabled ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20')}>
|
||||||
|
{statusIcon(filesystem.defaultPolicy.enabled)}
|
||||||
|
{filesystem.defaultPolicy.enabled ? '默认策略启用' : '默认策略停用'}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-sm border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">{filesystem.defaultPolicy.defaultPolicy ? 'DEFAULT' : 'NON-DEFAULT'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(filesystem.defaultPolicy.endpoint || filesystem.defaultPolicy.bucketName || filesystem.defaultPolicy.name)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-sm border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest opacity-70 transition-all hover:bg-white/10 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
复制标识
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{infoRow('ID', String(filesystem.defaultPolicy.id))}
|
||||||
|
{infoRow('名称', filesystem.defaultPolicy.name)}
|
||||||
|
{infoRow('类型', filesystem.defaultPolicy.type)}
|
||||||
|
{infoRow('访问模式', filesystem.defaultPolicy.privateBucket ? '私有桶' : '公开桶')}
|
||||||
|
{infoRow('Bucket', filesystem.defaultPolicy.bucketName || '-')}
|
||||||
|
{infoRow('Endpoint', filesystem.defaultPolicy.endpoint || '-')}
|
||||||
|
{infoRow('Region', filesystem.defaultPolicy.region || '-')}
|
||||||
|
{infoRow('Prefix', filesystem.defaultPolicy.prefix || '-')}
|
||||||
|
{infoRow('凭证模式', filesystem.defaultPolicy.credentialMode)}
|
||||||
|
{infoRow('策略上限', formatBytes(filesystem.defaultPolicy.maxSizeBytes))}
|
||||||
|
{infoRow('创建时间', formatDateTime(filesystem.defaultPolicy.createdAt))}
|
||||||
|
{infoRow('更新时间', formatDateTime(filesystem.defaultPolicy.updatedAt))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.3em] opacity-30">能力矩阵</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{capabilityEntries.map(([label, active]) => capabilityRow(label, active))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50">单对象最大值</span>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-tight">{formatBytes(filesystem.defaultPolicy.capabilities.maxObjectSize)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
|
||||||
|
{sectionTitle('上传模式矩阵', '前端只展示服务端暴露的实际可用上传路径')}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: '代理上传', active: filesystem.upload.proxyUpload, note: '客户端经由后端转发,适合受控或兼容性场景。' },
|
||||||
|
{ label: '直传单文件', active: filesystem.upload.directSingleUpload, note: '单文件直接命中存储端,适合小文件快速上传。' },
|
||||||
|
{ label: '直传分片', active: filesystem.upload.directMultipartUpload, note: '大文件分片写入,适合稳定传输与断点续传。' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">{item.label}</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">{item.note}</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(item.active))}>
|
||||||
|
{statusIcon(item.active)}
|
||||||
|
{booleanLabel(item.active)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50">有效最大文件大小</span>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-tight">{formatBytes(filesystem.upload.effectiveMaxFileSizeBytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
|
||||||
|
{sectionTitle('媒体处理', '缩略图与元数据采集能力快照')}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">元数据提取</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">文件入库后是否自动提取媒体信息。</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.mediaProcessing.metadataExtractionEnabled))}>
|
||||||
|
{statusIcon(filesystem.mediaProcessing.metadataExtractionEnabled)}
|
||||||
|
{booleanLabel(filesystem.mediaProcessing.metadataExtractionEnabled)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">原生缩略图</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">是否直接由存储或后端生成缩略图结果。</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.mediaProcessing.nativeThumbnailSupport))}>
|
||||||
|
{statusIcon(filesystem.mediaProcessing.nativeThumbnailSupport)}
|
||||||
|
{booleanLabel(filesystem.mediaProcessing.nativeThumbnailSupport)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
|
||||||
|
{sectionTitle('缓存状态', '文件列表与目录版本的缓存后端')}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{infoRow('缓存后端', filesystem.cache.backend)}
|
||||||
|
{infoRow('文件列表 TTL', `${filesystem.cache.filesListTtlSeconds} 秒`)}
|
||||||
|
{infoRow('目录版本 TTL', `${filesystem.cache.directoryVersionTtlSeconds} 秒`)}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
|
||||||
|
{sectionTitle('WebDAV 状态', '只读挂载与外部客户端访问能力')}
|
||||||
|
<div className="flex items-start justify-between gap-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">WebDAV 服务</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">管理台仅展示后端当前是否暴露 WebDAV。</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.webdav.enabled))}>
|
||||||
|
{filesystem.webdav.enabled ? <Globe className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
|
||||||
|
{booleanLabel(filesystem.webdav.enabled)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-50">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-blue-500" />
|
||||||
|
说明
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] font-bold leading-6 opacity-60">
|
||||||
|
当前页面仅展示文件系统快照,不提供就地编辑。所有可变配置仍由系统设置与存储策略页面管理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,258 @@
|
|||||||
export default function AdminOAuthApps() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin OAuthApps (待开发)</h1></div>; }
|
import type { ReactNode } from 'react';
|
||||||
|
import { ArrowRight, Ban, CheckCircle2, KeyRound, ShieldAlert, Sparkles } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 16, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{eyebrow}</h2>
|
||||||
|
<h3 className="mt-3 text-2xl font-black tracking-tight text-gray-900 dark:text-white">{title}</h3>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
children,
|
||||||
|
tone = 'neutral',
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
tone?: 'neutral' | 'warning' | 'success' | 'info';
|
||||||
|
}) {
|
||||||
|
const toneClasses = {
|
||||||
|
neutral: 'border-white/10 bg-white/5 text-gray-600 dark:text-gray-300',
|
||||||
|
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||||
|
success: 'border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300',
|
||||||
|
info: 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-[9px] font-black uppercase tracking-[0.22em] ${toneClasses}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
tone = 'blue',
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
tone?: 'blue' | 'amber' | 'green' | 'violet';
|
||||||
|
}) {
|
||||||
|
const ringClasses = {
|
||||||
|
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-500',
|
||||||
|
amber: 'border-amber-500/20 bg-amber-500/10 text-amber-500',
|
||||||
|
green: 'border-green-500/20 bg-green-500/10 text-green-500',
|
||||||
|
violet: 'border-violet-500/20 bg-violet-500/10 text-violet-500',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
variants={itemVariants}
|
||||||
|
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border ${ringClasses}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13px] font-black uppercase tracking-[0.18em] text-gray-900 dark:text-white">{title}</h4>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminOAuthApps() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">
|
||||||
|
三方应用
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
当前仅保留规划状态 / 后端支持未就绪 / 不提供可写配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="warning">规划中</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.section
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="grid grid-cols-1 gap-6 xl:grid-cols-12"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="xl:col-span-7 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
|
||||||
|
>
|
||||||
|
<SectionTitle
|
||||||
|
eyebrow="当前状态"
|
||||||
|
title="后端尚未提供 OAuth 应用管理接口"
|
||||||
|
description="页面现在只做状态说明,不接入任何写接口,也不展示虚假的配置表单。等 `/api/admin/oauth-apps` 及相关校验、审计、回调配置能力上线后,这里再开放真正的管理操作。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
|
||||||
|
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||||
|
后端支持状态
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||||
|
目前仓库里没有面向管理员的 OAuth 应用管理 API,因此前端只能展示说明,不能创建、编辑或删除应用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
|
||||||
|
<Ban className="h-4 w-4 text-red-500" />
|
||||||
|
为什么没有可写控件
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||||
|
如果现在就提供按钮或输入框,会让人误以为配置已经生效。为了避免误导,这一页保持只读,直到后端能力真正落地。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.aside
|
||||||
|
variants={itemVariants}
|
||||||
|
className="xl:col-span-5 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500">
|
||||||
|
<KeyRound className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-black uppercase tracking-[0.18em] text-gray-900 dark:text-white">
|
||||||
|
规划入口
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.24em] opacity-30">
|
||||||
|
仅展示后续将开放的能力
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
|
||||||
|
<Sparkles className="h-4 w-4 text-blue-500" />
|
||||||
|
未来会增加
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
|
||||||
|
OAuth 应用的创建、编辑、停用与删除
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
|
||||||
|
Client ID / Client Secret 的安全展示与轮换
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
|
||||||
|
回调地址、授权范围和状态的可视化管理
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
|
||||||
|
审计记录与变更历史
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
className="flex w-full items-center justify-between rounded-xl border border-dashed border-white/10 bg-white/5 px-5 py-4 text-left opacity-60"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-[0.2em] text-gray-900 dark:text-white">
|
||||||
|
等待后端 API 到位
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
目前不会提交任何写操作
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
|
||||||
|
<motion.section
|
||||||
|
variants={itemVariants}
|
||||||
|
className="xl:col-span-12 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
|
||||||
|
>
|
||||||
|
<SectionTitle
|
||||||
|
eyebrow="后续能力"
|
||||||
|
title="后端上线后,这里会逐步开放的内容"
|
||||||
|
description="这一页会先作为路线图,等管理员接口稳定后再切回真正的操作界面。前端会直接绑定真实接口,并补齐权限、校验和反馈。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<InfoCard
|
||||||
|
title="应用登记"
|
||||||
|
description="支持录入应用名称、回调地址、主页地址、描述和启用状态,形成可追踪的应用清单。"
|
||||||
|
icon={<KeyRound className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="凭据管理"
|
||||||
|
description="支持生成、复制、轮换和失效 Client Secret,并对敏感凭据做受控展示。"
|
||||||
|
icon={<ShieldAlert className="h-5 w-5" />}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="授权范围"
|
||||||
|
description="支持配置 OAuth scope、授权类型和回调白名单,避免把权限交给不该拿到的应用。"
|
||||||
|
icon={<Sparkles className="h-5 w-5" />}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="审计追踪"
|
||||||
|
description="记录每一次增删改、密钥轮换和回调配置变更,方便排查和安全复核。"
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</motion.section>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,620 @@
|
|||||||
export default function AdminSettings() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Settings (待开发)</h1></div>; }
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Copy, Database, RefreshCw, RotateCcw, Save, Server, Settings, Shield, Clock3, Layers3 } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
rotateAdminRegistrationInviteCode,
|
||||||
|
updateAdminOfflineTransferStorageLimit,
|
||||||
|
updateAdminRegistrationInviteCode,
|
||||||
|
type AdminSettings,
|
||||||
|
} from '@/src/lib/admin-settings';
|
||||||
|
import { formatBytes } from '@/src/lib/format';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 18, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDurationSeconds(seconds: number) {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = seconds / 60;
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes % 1 === 0 ? minutes : minutes.toFixed(1)} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = minutes / 60;
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours % 1 === 0 ? hours : hours.toFixed(1)} 小时`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = hours / 24;
|
||||||
|
return `${days % 1 === 0 ? days : days.toFixed(1)} 天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationMs(milliseconds: number) {
|
||||||
|
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (milliseconds < 1000) {
|
||||||
|
return `${milliseconds} 毫秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDurationSeconds(milliseconds / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(value: boolean, trueLabel = '已启用', falseLabel = '未启用') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em]',
|
||||||
|
value
|
||||||
|
? 'border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value ? trueLabel : falseLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnapshotRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-white/10 py-3 last:border-0 last:pb-0">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">{label}</span>
|
||||||
|
<span className={cn('text-right text-[11px] font-bold leading-5', valueClassName)}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnapshotCard({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
badge: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
variants={itemVariants}
|
||||||
|
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-blue-500 shadow-inner">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]">{title}</h3>
|
||||||
|
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.3em] opacity-30">{badge}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteCodeFormValues = {
|
||||||
|
inviteCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OfflineTransferLimitFormValues = {
|
||||||
|
offlineTransferStorageLimitBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [savingInviteCode, setSavingInviteCode] = useState(false);
|
||||||
|
const [rotatingInviteCode, setRotatingInviteCode] = useState(false);
|
||||||
|
const [savingTransferLimit, setSavingTransferLimit] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
|
const inviteCodeForm = useForm<InviteCodeFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
inviteCode: '',
|
||||||
|
},
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
});
|
||||||
|
const offlineTransferLimitForm = useForm<OfflineTransferLimitFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
offlineTransferStorageLimitBytes: 1,
|
||||||
|
},
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSettings(isRefresh = false) {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const nextSettings = await getAdminSettings();
|
||||||
|
setSettings(nextSettings);
|
||||||
|
inviteCodeForm.reset({
|
||||||
|
inviteCode: nextSettings.registration.currentInviteCode,
|
||||||
|
});
|
||||||
|
offlineTransferLimitForm.reset({
|
||||||
|
offlineTransferStorageLimitBytes: nextSettings.transfer.offlineTransferStorageLimitBytes,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载系统设置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSaveInviteCode(values: InviteCodeFormValues) {
|
||||||
|
const nextInviteCode = values.inviteCode.trim();
|
||||||
|
if (!nextInviteCode) {
|
||||||
|
setError('邀请码不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingInviteCode(true);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
try {
|
||||||
|
await updateAdminRegistrationInviteCode(nextInviteCode);
|
||||||
|
await loadSettings(true);
|
||||||
|
setNotice('邀请码已更新');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '更新邀请码失败');
|
||||||
|
} finally {
|
||||||
|
setSavingInviteCode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRotateInviteCode() {
|
||||||
|
if (!window.confirm('确定要轮换邀请码吗?旧邀请码会立即失效。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRotatingInviteCode(true);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
try {
|
||||||
|
await rotateAdminRegistrationInviteCode();
|
||||||
|
await loadSettings(true);
|
||||||
|
setNotice('邀请码已轮换');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '轮换邀请码失败');
|
||||||
|
} finally {
|
||||||
|
setRotatingInviteCode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveTransferLimit(values: OfflineTransferLimitFormValues) {
|
||||||
|
const nextLimit = values.offlineTransferStorageLimitBytes;
|
||||||
|
if (!Number.isInteger(nextLimit) || nextLimit <= 0) {
|
||||||
|
setError('离线快传存储上限必须是大于 0 的整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingTransferLimit(true);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
try {
|
||||||
|
await updateAdminOfflineTransferStorageLimit(nextLimit);
|
||||||
|
await loadSettings(true);
|
||||||
|
setNotice('离线快传存储上限已更新');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '更新离线快传存储上限失败');
|
||||||
|
} finally {
|
||||||
|
setSavingTransferLimit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = loading || refreshing;
|
||||||
|
const watchedOfflineLimit = offlineTransferLimitForm.watch('offlineTransferStorageLimitBytes');
|
||||||
|
const offlineLimitPreview = Number.isFinite(watchedOfflineLimit) ? watchedOfflineLimit : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">系统设置</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
可编辑设置 / 只读快照 / 后端能力边界
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadSettings(true);
|
||||||
|
}}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
刷新设置
|
||||||
|
</button>
|
||||||
|
</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 backdrop-blur-md 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 backdrop-blur-md dark:text-blue-300">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && !settings ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
正在读取系统设置快照...
|
||||||
|
</div>
|
||||||
|
) : settings ? (
|
||||||
|
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">可编辑设置</h2>
|
||||||
|
<p className="mt-2 text-[11px] font-bold opacity-40">
|
||||||
|
这里是当前后端明确支持写入的设置项,仅包含邀请码与离线快传容量上限。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-green-500/20 bg-green-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] text-green-600 dark:text-green-400">
|
||||||
|
PATCH / POST
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||||
|
<motion.section
|
||||||
|
variants={itemVariants}
|
||||||
|
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500 shadow-inner">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]">注册邀请码</h3>
|
||||||
|
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
当前为可写设置,变更后立即生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{settings.registration.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">是否强制邀请码</div>
|
||||||
|
<div className="text-sm font-black">{settings.registration.inviteCodeRequired ? '是' : '否'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">管理角色</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{settings.registration.managementRoles.map((role) => (
|
||||||
|
<span
|
||||||
|
key={role}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em]"
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30">当前邀请码</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(settings.registration.currentInviteCode);
|
||||||
|
setNotice('邀请码已复制');
|
||||||
|
} catch {
|
||||||
|
setError('复制邀请码失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-lg p-2 text-blue-500 transition-all hover:bg-white/10 hover:text-blue-400"
|
||||||
|
title="复制邀请码"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="break-all rounded-xl border border-white/10 bg-blue-500/5 px-4 py-4 font-mono text-lg font-black tracking-[0.3em] text-blue-500">
|
||||||
|
{settings.registration.currentInviteCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_auto]"
|
||||||
|
onSubmit={inviteCodeForm.handleSubmit(handleSaveInviteCode, () => {
|
||||||
|
setError('');
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
编辑邀请码
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
{...inviteCodeForm.register('inviteCode', {
|
||||||
|
required: '邀请码不能为空',
|
||||||
|
maxLength: {
|
||||||
|
value: 64,
|
||||||
|
message: '邀请码不能超过 64 个字符',
|
||||||
|
},
|
||||||
|
validate: (value) => value.trim().length > 0 || '邀请码不能为空',
|
||||||
|
})}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="输入新的邀请码"
|
||||||
|
aria-invalid={inviteCodeForm.formState.errors.inviteCode ? 'true' : 'false'}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.25em] outline-none transition-all placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
{inviteCodeForm.formState.errors.inviteCode ? (
|
||||||
|
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
|
||||||
|
{inviteCodeForm.formState.errors.inviteCode.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingInviteCode || rotatingInviteCode}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] text-white shadow-lg transition-all hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingInviteCode ? '保存中' : '保存'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRotateInviteCode}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RotateCcw className={cn('h-4 w-4', rotatingInviteCode && 'animate-spin')} />
|
||||||
|
{rotatingInviteCode ? '轮换中' : '轮换'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<motion.section
|
||||||
|
variants={itemVariants}
|
||||||
|
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-amber-500/20 bg-amber-500/10 text-amber-500 shadow-inner">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]">离线快传存储上限</h3>
|
||||||
|
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
控制离线快传在站点内可占用的总容量
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{settings.transfer.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">当前上限</div>
|
||||||
|
<div className="text-3xl font-black tracking-tight text-amber-500">
|
||||||
|
{formatBytes(settings.transfer.offlineTransferStorageLimitBytes)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
{settings.transfer.offlineTransferStorageLimitBytes} 字节
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-5"
|
||||||
|
onSubmit={offlineTransferLimitForm.handleSubmit(handleSaveTransferLimit, () => {
|
||||||
|
setError('');
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
输入新的字节数
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
{...offlineTransferLimitForm.register('offlineTransferStorageLimitBytes', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
required: '离线快传存储上限不能为空',
|
||||||
|
validate: (value) =>
|
||||||
|
Number.isInteger(value) && value > 0 ? true : '离线快传存储上限必须是大于 0 的整数',
|
||||||
|
})}
|
||||||
|
aria-invalid={offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? 'true' : 'false'}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.2em] outline-none transition-all placeholder:opacity-20 focus:border-amber-500/50 focus:ring-4 focus:ring-amber-500/10"
|
||||||
|
/>
|
||||||
|
{offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? (
|
||||||
|
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
|
||||||
|
{offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-amber-500/10 bg-amber-500/5 px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30">容量预览</div>
|
||||||
|
<div className="mt-1 text-sm font-black">{formatBytes(offlineLimitPreview)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingTransferLimit || savingInviteCode || rotatingInviteCode}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-amber-500 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] text-white shadow-lg transition-all hover:bg-amber-400 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingTransferLimit ? '保存中' : '保存上限'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
|
||||||
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
|
仅供运营参考
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-bold leading-6 opacity-70">
|
||||||
|
该设置只影响离线快传的总存储配额,不会改变文件列表、分享或普通上传的容量规则。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">只读快照</h2>
|
||||||
|
<p className="mt-2 text-[11px] font-bold opacity-40">
|
||||||
|
下列内容全部来自 <span className="font-mono">GET /api/admin/settings</span>,当前不提供前端编辑入口。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
|
||||||
|
Snapshot
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<SnapshotCard
|
||||||
|
title="站点设置"
|
||||||
|
badge={settings.site.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Settings className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow label="是否支持" value={statusPill(settings.site.supported, '已接入', '未接入')} />
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.site.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
|
||||||
|
<SnapshotCard
|
||||||
|
title="用户会话"
|
||||||
|
badge={settings.userSession.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Shield className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow label="Access TTL" value={formatDurationSeconds(settings.userSession.accessExpirationSeconds)} />
|
||||||
|
<SnapshotRow label="Refresh TTL" value={formatDurationSeconds(settings.userSession.refreshExpirationSeconds)} />
|
||||||
|
<SnapshotRow
|
||||||
|
label="Token 黑名单"
|
||||||
|
value={statusPill(settings.userSession.tokenBlacklistEnabled, '已启用', '未启用')}
|
||||||
|
/>
|
||||||
|
<SnapshotRow
|
||||||
|
label="黑名单缓冲"
|
||||||
|
value={formatDurationSeconds(settings.userSession.tokenBlacklistTtlBufferSeconds)}
|
||||||
|
/>
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.userSession.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
|
||||||
|
<SnapshotCard
|
||||||
|
title="媒体处理"
|
||||||
|
badge={settings.mediaProcessing.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Server className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow
|
||||||
|
label="元数据提取"
|
||||||
|
value={statusPill(settings.mediaProcessing.metadataExtractionEnabled, '已启用', '未启用')}
|
||||||
|
/>
|
||||||
|
<SnapshotRow
|
||||||
|
label="缩略图生成"
|
||||||
|
value={statusPill(settings.mediaProcessing.thumbnailGenerationEnabled, '已启用', '未启用')}
|
||||||
|
/>
|
||||||
|
<SnapshotRow
|
||||||
|
label="视频封面"
|
||||||
|
value={statusPill(settings.mediaProcessing.videoPosterEnabled, '已启用', '未启用')}
|
||||||
|
/>
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.mediaProcessing.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
|
||||||
|
<SnapshotCard
|
||||||
|
title="任务队列"
|
||||||
|
badge={settings.queue.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Clock3 className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow label="后端" value={settings.queue.backend} valueClassName="font-mono uppercase tracking-[0.2em]" />
|
||||||
|
<SnapshotRow label="固定延迟" value={formatDurationMs(settings.queue.mediaMetadataFixedDelayMs)} />
|
||||||
|
<SnapshotRow label="初始延迟" value={formatDurationMs(settings.queue.mediaMetadataInitialDelayMs)} />
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.queue.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
|
||||||
|
<SnapshotCard
|
||||||
|
title="外观"
|
||||||
|
badge={settings.appearance.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Layers3 className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow label="是否支持" value={statusPill(settings.appearance.supported, '已接入', '未接入')} />
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.appearance.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
|
||||||
|
<SnapshotCard
|
||||||
|
title="服务器"
|
||||||
|
badge={settings.server.writeSupported ? '可写快照' : '只读快照'}
|
||||||
|
icon={<Server className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<SnapshotRow
|
||||||
|
label="存储提供者"
|
||||||
|
value={settings.server.storageProvider}
|
||||||
|
valueClassName="font-mono uppercase tracking-[0.2em]"
|
||||||
|
/>
|
||||||
|
<SnapshotRow label="Redis" value={statusPill(settings.server.redisEnabled, '已启用', '未启用')} />
|
||||||
|
<SnapshotRow label="写入支持" value={statusPill(settings.server.writeSupported, '可写', '只读')} />
|
||||||
|
</SnapshotCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,463 @@
|
|||||||
export default function AdminShares() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Shares (待开发)</h1></div>; }
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Copy, ExternalLink, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type RowData,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||||
|
import { deleteAdminShare, getAdminShares, type AdminShare } from '@/src/lib/admin-shares';
|
||||||
|
|
||||||
|
declare module '@tanstack/react-table' {
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
thClassName?: string;
|
||||||
|
tdClassName?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 10, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = {
|
||||||
|
userQuery: '',
|
||||||
|
fileName: '',
|
||||||
|
token: '',
|
||||||
|
passwordProtected: '' as 'true' | 'false' | '',
|
||||||
|
expired: '' as 'true' | 'false' | '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function boolBadge(active: boolean, activeLabel: string, inactiveLabel: string, tone: 'blue' | 'amber' | 'purple' | 'red' = 'blue') {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'amber'
|
||||||
|
? active
|
||||||
|
? 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
|
||||||
|
: tone === 'purple'
|
||||||
|
? active
|
||||||
|
? 'border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
|
||||||
|
: tone === 'red'
|
||||||
|
? active
|
||||||
|
? 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
|
||||||
|
: active
|
||||||
|
? 'border-blue-500/20 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[8px] font-black uppercase tracking-widest', toneClass)}>
|
||||||
|
{active ? activeLabel : inactiveLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminShares() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const [shares, setShares] = useState<AdminShare[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
async function loadShares(nextFilters = filters) {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getAdminShares(0, 100, nextFilters);
|
||||||
|
setShares(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载分享治理列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadShares();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyText(value: string, successMessage: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
window.alert(successMessage);
|
||||||
|
} catch {
|
||||||
|
setError('复制失败,请手动复制。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterLabels = [
|
||||||
|
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
|
||||||
|
filters.fileName.trim() ? `文件: ${filters.fileName.trim()}` : '',
|
||||||
|
filters.token.trim() ? `Token: ${filters.token.trim()}` : '',
|
||||||
|
filters.passwordProtected ? `密码保护: ${filters.passwordProtected === 'true' ? '是' : '否'}` : '',
|
||||||
|
filters.expired ? `已过期: ${filters.expired === 'true' ? '是' : '否'}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const columns: ColumnDef<AdminShare>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'token',
|
||||||
|
header: '分享',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-black tracking-tight uppercase">{row.original.shareName || row.original.fileName}</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-[9px] font-black tracking-[0.18em] opacity-30">{row.original.token}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{boolBadge(row.original.passwordProtected, '需密码', '无密码', 'amber')}
|
||||||
|
{boolBadge(row.original.expired, '已过期', '未过期', 'red')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'permissions',
|
||||||
|
header: '权限',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{boolBadge(row.original.allowDownload, '可下载', '仅查看', 'blue')}
|
||||||
|
{boolBadge(row.original.allowImport, '可导入', '受保护', 'purple')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
|
||||||
|
Max DL {row.original.maxDownloads ?? '∞'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'owner',
|
||||||
|
header: '所属用户',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight text-blue-500">{row.original.ownerUsername}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">{row.original.ownerEmail}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">UID #{row.original.ownerId}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fileInfo',
|
||||||
|
header: '文件信息',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight">{row.original.fileName}</div>
|
||||||
|
<div className="mt-1 truncate max-w-[260px] text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
|
||||||
|
{row.original.filePath}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
|
||||||
|
{row.original.directory ? '目录' : `${formatBytes(row.original.fileSize)} / ${row.original.fileContentType || '-'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stats',
|
||||||
|
header: '统计',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="space-y-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
|
||||||
|
<div>下载 {row.original.downloadCount}</div>
|
||||||
|
<div>查看 {row.original.viewCount}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'time',
|
||||||
|
header: '时间',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-left',
|
||||||
|
tdClassName: 'px-6 py-5 align-top',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-tighter opacity-30">{formatDateTime(row.original.createdAt)}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
|
||||||
|
过期 {row.original.expiresAt ? formatDateTime(row.original.expiresAt) : '永久有效'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '操作',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-6 py-5 text-right',
|
||||||
|
tdClassName: 'px-6 py-5 align-top text-right',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const share = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 opacity-30 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(share.token, '分享 Token 已复制')}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 transition-all hover:bg-blue-600 hover:text-white"
|
||||||
|
title="复制 Token"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(`${window.location.origin}/share/${share.token}`, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 transition-all hover:bg-blue-600 hover:text-white"
|
||||||
|
title="打开分享"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
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"
|
||||||
|
title="删除分享"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: shares,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => String(row.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">分享管理</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">分享治理 / Token 检索 / 过期与密码保护筛选</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
void loadShares();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 font-black text-[11px] uppercase tracking-widest transition-all hover:bg-white/40"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||||
|
刷新列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
void loadShares(filters);
|
||||||
|
}}
|
||||||
|
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">筛选器</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">严格对应后端 `GET /api/admin/shares` 支持的查询参数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_1fr_1.2fr_0.8fr_0.8fr]">
|
||||||
|
<label className="relative block group">
|
||||||
|
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
|
||||||
|
<input
|
||||||
|
value={filters.userQuery}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
|
||||||
|
placeholder="所有者"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.fileName}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, fileName: event.target.value }))}
|
||||||
|
placeholder="文件名"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.token}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, token: event.target.value }))}
|
||||||
|
placeholder="分享 Token"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<select
|
||||||
|
value={filters.passwordProtected}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, passwordProtected: event.target.value as 'true' | 'false' | '' }))}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
>
|
||||||
|
<option value="">密码保护</option>
|
||||||
|
<option value="true">需要密码</option>
|
||||||
|
<option value="false">无需密码</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<select
|
||||||
|
value={filters.expired}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, expired: event.target.value as 'true' | 'false' | '' }))}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
>
|
||||||
|
<option value="">过期状态</option>
|
||||||
|
<option value="true">已过期</option>
|
||||||
|
<option value="false">未过期</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeFilterLabels.length ? (
|
||||||
|
activeFilterLabels.map((label) => (
|
||||||
|
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25">当前没有启用筛选条件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilters(DEFAULT_FILTERS);
|
||||||
|
setLoading(true);
|
||||||
|
void loadShares(DEFAULT_FILTERS);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
应用筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
<span>共 {total} 条分享记录</span>
|
||||||
|
<span>当前页 {shares.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{loading && shares.length === 0 ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
正在读取分享治理列表...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-[1500px] divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const meta = header.column.columnDef.meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
className={cn(
|
||||||
|
'text-[9px] font-black uppercase tracking-[0.2em] opacity-40',
|
||||||
|
meta?.thClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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) => (
|
||||||
|
<motion.tr key={row.id} variants={itemVariants} className="group transition-colors hover:bg-white/10 dark:hover:bg-white/5">
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const meta = cell.column.columnDef.meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={cell.id} className={cn(meta?.tdClassName)}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={table.getAllColumns().length} 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>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
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, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type RowData,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
createStorageMigration,
|
createStorageMigration,
|
||||||
createStoragePolicy,
|
createStoragePolicy,
|
||||||
@@ -14,6 +21,13 @@ import {
|
|||||||
import { formatBytes } from '@/src/lib/format';
|
import { formatBytes } from '@/src/lib/format';
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
|
|
||||||
|
declare module '@tanstack/react-table' {
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
thClassName?: string;
|
||||||
|
tdClassName?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
|
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
|
||||||
return {
|
return {
|
||||||
directUpload: false,
|
directUpload: false,
|
||||||
@@ -67,6 +81,161 @@ export default function AdminStoragePoliciesList() {
|
|||||||
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
|
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
|
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
|
||||||
|
const [migratingPolicy, setMigratingPolicy] = useState<AdminStoragePolicy | null>(null);
|
||||||
|
const [migrationTargetPolicyId, setMigrationTargetPolicyId] = useState('');
|
||||||
|
const [migrationSubmitting, setMigrationSubmitting] = useState(false);
|
||||||
|
const [migrationNotice, setMigrationNotice] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const columns: ColumnDef<AdminStoragePolicy>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: '名称',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-left',
|
||||||
|
tdClassName: 'px-8 py-5',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 font-black text-[13px] tracking-tight">
|
||||||
|
{policy.name}
|
||||||
|
{policy.defaultPolicy ? (
|
||||||
|
<span className="rounded-sm bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 text-[8px] border border-blue-500/20 uppercase tracking-widest font-black">
|
||||||
|
默认
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold opacity-30 mt-1 tracking-tighter">PID::{policy.id}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'type',
|
||||||
|
header: '后端类型',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-left',
|
||||||
|
tdClassName: 'px-8 py-5',
|
||||||
|
},
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">
|
||||||
|
{String(getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'endpoint',
|
||||||
|
header: '访问端点',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-left',
|
||||||
|
tdClassName: 'px-8 py-5',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="truncate max-w-[180px] font-bold opacity-60 text-[11px] tracking-tight">
|
||||||
|
{policy.endpoint || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-black text-blue-500 uppercase tracking-tighter mt-0.5">
|
||||||
|
{policy.bucketName || '私有根路径'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: '状态',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-left',
|
||||||
|
tdClassName: 'px-8 py-5',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[9px] font-black uppercase tracking-widest border',
|
||||||
|
policy.enabled
|
||||||
|
? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
|
||||||
|
: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full', policy.enabled ? 'bg-green-500 animate-pulse' : 'bg-red-500')} />
|
||||||
|
{policy.enabled ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'maxSizeBytes',
|
||||||
|
header: '对象上限',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-left',
|
||||||
|
tdClassName: 'px-8 py-5 font-black opacity-60 text-xs tracking-tighter',
|
||||||
|
},
|
||||||
|
cell: ({ getValue }) => formatBytes(Number(getValue())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '操作',
|
||||||
|
meta: {
|
||||||
|
thClassName: 'px-8 py-5 text-right',
|
||||||
|
tdClassName: 'px-8 py-5 text-right',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2.5 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
setForm(buildInitialForm(policy));
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg glass-panel hover:bg-white/40 text-gray-500 border-white/20 transition-all"
|
||||||
|
title="编辑策略"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{!policy.defaultPolicy ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await updateStoragePolicyStatus(policy.id, !policy.enabled);
|
||||||
|
await loadPolicies();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg glass-panel border-white/20 transition-all',
|
||||||
|
policy.enabled ? 'text-amber-500 hover:bg-amber-500/10' : 'text-green-500 hover:bg-green-500/10'
|
||||||
|
)}
|
||||||
|
title={policy.enabled ? '停用' : '启用'}
|
||||||
|
>
|
||||||
|
{policy.enabled ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openMigrationDialog(policy)}
|
||||||
|
className="p-2 rounded-lg glass-panel hover:bg-blue-500/10 text-blue-500 border-white/20 transition-all"
|
||||||
|
title="发起迁移"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: policies,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
async function loadPolicies() {
|
async function loadPolicies() {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -99,6 +268,61 @@ export default function AdminStoragePoliciesList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMigrationDialog(policy: AdminStoragePolicy) {
|
||||||
|
const firstTargetPolicy = policies.find((item) => item.id !== policy.id);
|
||||||
|
setMigratingPolicy(policy);
|
||||||
|
setMigrationTargetPolicyId(firstTargetPolicy ? String(firstTargetPolicy.id) : '');
|
||||||
|
setMigrationNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMigrationDialog() {
|
||||||
|
setMigratingPolicy(null);
|
||||||
|
setMigrationTargetPolicyId('');
|
||||||
|
setMigrationSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMigration() {
|
||||||
|
if (!migratingPolicy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPolicyId = Number(migrationTargetPolicyId);
|
||||||
|
if (!Number.isInteger(targetPolicyId) || targetPolicyId <= 0) {
|
||||||
|
setMigrationNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: '请输入有效的目标策略 ID',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPolicyId === migratingPolicy.id) {
|
||||||
|
setMigrationNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: '目标策略不能与源策略相同',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMigrationSubmitting(true);
|
||||||
|
setMigrationNotice(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createStorageMigration(migratingPolicy.id, targetPolicyId);
|
||||||
|
setMigrationNotice({
|
||||||
|
type: 'success',
|
||||||
|
message: `已创建从 PID::${migratingPolicy.id} 到 PID::${targetPolicyId} 的迁移任务`,
|
||||||
|
});
|
||||||
|
closeMigrationDialog();
|
||||||
|
await loadPolicies();
|
||||||
|
} catch (err) {
|
||||||
|
setMigrationNotice({
|
||||||
|
type: 'error',
|
||||||
|
message: err instanceof Error ? err.message : '创建迁移任务失败',
|
||||||
|
});
|
||||||
|
setMigrationSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -138,6 +362,19 @@ export default function AdminStoragePoliciesList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{migrationNotice ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mb-8 rounded-lg border px-6 py-4 text-xs font-bold backdrop-blur-md',
|
||||||
|
migrationNotice.type === 'success'
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{migrationNotice.message}
|
||||||
|
</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 dark:text-red-400 font-bold backdrop-blur-md">{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 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -148,93 +385,33 @@ export default function AdminStoragePoliciesList() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-white/10 text-sm">
|
<table className="min-w-full divide-y divide-white/10 text-sm">
|
||||||
<thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
|
<thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
|
||||||
<tr>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<th className="px-8 py-5 text-left">名称</th>
|
<tr key={headerGroup.id}>
|
||||||
<th className="px-8 py-5 text-left">后端类型</th>
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="px-8 py-5 text-left">访问端点</th>
|
<th
|
||||||
<th className="px-8 py-5 text-left">状态</th>
|
key={header.id}
|
||||||
<th className="px-8 py-5 text-left">对象上限</th>
|
className={cn(header.column.columnDef.meta?.thClassName)}
|
||||||
<th className="px-8 py-5 text-right">操作</th>
|
>
|
||||||
</tr>
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/10 dark:divide-white/5">
|
<tbody className="divide-y divide-white/10 dark:divide-white/5">
|
||||||
{policies.map((policy) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={policy.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
<tr key={row.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
||||||
<td className="px-8 py-5">
|
{row.getVisibleCells().map((cell) => (
|
||||||
<div className="flex items-center gap-2 font-black text-[13px] tracking-tight">
|
<td
|
||||||
{policy.name}
|
key={cell.id}
|
||||||
{policy.defaultPolicy ? (
|
className={cn(
|
||||||
<span className="rounded-sm bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 text-[8px] border border-blue-500/20 uppercase tracking-widest font-black">默认</span>
|
cell.column.columnDef.meta?.thClassName,
|
||||||
) : null}
|
cell.column.columnDef.meta?.tdClassName
|
||||||
</div>
|
)}
|
||||||
<div className="text-[10px] font-bold opacity-30 mt-1 tracking-tighter">PID::{policy.id}</div>
|
>
|
||||||
</td>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<td className="px-8 py-5">
|
</td>
|
||||||
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">{policy.type}</span>
|
))}
|
||||||
</td>
|
|
||||||
<td className="px-8 py-5">
|
|
||||||
<div className="truncate max-w-[180px] font-bold opacity-60 text-[11px] tracking-tight">{policy.endpoint || '-'}</div>
|
|
||||||
<div className="text-[9px] font-black text-blue-500 uppercase tracking-tighter mt-0.5">{policy.bucketName || '私有根路径'}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-5">
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[9px] font-black uppercase tracking-widest border",
|
|
||||||
policy.enabled
|
|
||||||
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20"
|
|
||||||
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
|
||||||
)}>
|
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", policy.enabled ? "bg-green-500 animate-pulse" : "bg-red-500")}></span>
|
|
||||||
{policy.enabled ? '启用' : '停用'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-5 font-black opacity-60 text-xs tracking-tighter">
|
|
||||||
{formatBytes(policy.maxSizeBytes)}
|
|
||||||
</td>
|
|
||||||
<td className="px-8 py-5 text-right">
|
|
||||||
<div className="flex justify-end gap-2.5 opacity-40 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingPolicy(policy);
|
|
||||||
setForm(buildInitialForm(policy));
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
|
||||||
className="p-2 rounded-lg glass-panel hover:bg-white/40 text-gray-500 border-white/20 transition-all"
|
|
||||||
title="编辑策略"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{!policy.defaultPolicy ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
await updateStoragePolicyStatus(policy.id, !policy.enabled);
|
|
||||||
await loadPolicies();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded-lg glass-panel border-white/20 transition-all",
|
|
||||||
policy.enabled ? "text-amber-500 hover:bg-amber-500/10" : "text-green-500 hover:bg-green-500/10"
|
|
||||||
)}
|
|
||||||
title={policy.enabled ? '停用' : '启用'}
|
|
||||||
>
|
|
||||||
{policy.enabled ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const targetId = window.prompt('请输入迁移目标策略 ID:');
|
|
||||||
if (!targetId) return;
|
|
||||||
await createStorageMigration(policy.id, Number(targetId));
|
|
||||||
window.alert('已创建迁移任务');
|
|
||||||
}}
|
|
||||||
className="p-2 rounded-lg glass-panel hover:bg-blue-500/10 text-blue-500 border-white/20 transition-all"
|
|
||||||
title="发起迁移"
|
|
||||||
>
|
|
||||||
<ArrowRightLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -379,6 +556,82 @@ export default function AdminStoragePoliciesList() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 rounded-lg border border-white/10 bg-white/5 p-5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">源策略</div>
|
||||||
|
<div className="mt-2 text-sm font-black tracking-tight">{migratingPolicy.name}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold opacity-40">PID::{migratingPolicy.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-white/10" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">选择已有目标策略</label>
|
||||||
|
<select
|
||||||
|
value={migrationTargetPolicyId}
|
||||||
|
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
>
|
||||||
|
<option value="">请选择目标策略</option>
|
||||||
|
{policies
|
||||||
|
.filter((item) => item.id !== migratingPolicy.id)
|
||||||
|
.map((policy) => (
|
||||||
|
<option key={policy.id} value={policy.id}>
|
||||||
|
{policy.name} / PID::{policy.id} / {policy.type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">或手动输入目标策略 ID</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={migrationTargetPolicyId}
|
||||||
|
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
|
||||||
|
placeholder="例如 12"
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-bold leading-5 opacity-50">
|
||||||
|
如果目标策略不在下拉框里,可以直接输入它的策略 ID。当前页面只负责创建迁移任务,不负责迁移进度展示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeMigrationDialog}
|
||||||
|
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,743 @@
|
|||||||
export default function AdminTasks() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Tasks (待开发)</h1></div>; }
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
FileCode2,
|
||||||
|
ListTodo,
|
||||||
|
PanelRightOpen,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { formatDateTime } from '@/src/lib/format';
|
||||||
|
import { getAdminTask, getAdminTasks, type AdminTask, type AdminTaskQuery } from '@/src/lib/admin-tasks';
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 12, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: AdminTaskQuery = {
|
||||||
|
userQuery: '',
|
||||||
|
type: '',
|
||||||
|
status: '',
|
||||||
|
failureCategory: '',
|
||||||
|
leaseState: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
function taskTypeLabel(type: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ARCHIVE: '归档',
|
||||||
|
EXTRACT: '解压',
|
||||||
|
MEDIA_META: '媒体元数据',
|
||||||
|
STORAGE_POLICY_MIGRATION: '存储迁移',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
QUEUED: '排队中',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
FAILED: '已失败',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureCategoryLabel(category: string | null) {
|
||||||
|
if (!category) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
UNSUPPORTED_INPUT: '不支持输入',
|
||||||
|
DATA_STATE: '数据状态异常',
|
||||||
|
TRANSIENT_INFRASTRUCTURE: '临时基础设施',
|
||||||
|
RATE_LIMITED: '触发限流',
|
||||||
|
UNKNOWN: '未知',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[category] ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaseStateLabel(leaseState: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ACTIVE: '活跃',
|
||||||
|
LEASED: '已租约',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
FREE: '空闲',
|
||||||
|
NONE: '空闲',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[leaseState] ?? leaseState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return 'blue';
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'green';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'red';
|
||||||
|
case 'CANCELLED':
|
||||||
|
return 'amber';
|
||||||
|
case 'QUEUED':
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureTone(category: string | null) {
|
||||||
|
switch (category) {
|
||||||
|
case 'TRANSIENT_INFRASTRUCTURE':
|
||||||
|
return 'blue';
|
||||||
|
case 'RATE_LIMITED':
|
||||||
|
return 'amber';
|
||||||
|
case 'UNSUPPORTED_INPUT':
|
||||||
|
case 'DATA_STATE':
|
||||||
|
case 'UNKNOWN':
|
||||||
|
return 'red';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaseTone(leaseState: string) {
|
||||||
|
switch (leaseState) {
|
||||||
|
case 'ACTIVE':
|
||||||
|
case 'LEASED':
|
||||||
|
return 'blue';
|
||||||
|
case 'EXPIRED':
|
||||||
|
return 'red';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillClass(tone: 'blue' | 'green' | 'amber' | 'red' | 'gray') {
|
||||||
|
switch (tone) {
|
||||||
|
case 'green':
|
||||||
|
return 'border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400';
|
||||||
|
case 'blue':
|
||||||
|
return 'border-blue-500/20 bg-blue-500/10 text-blue-600 dark:text-blue-400';
|
||||||
|
case 'amber':
|
||||||
|
return 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||||
|
case 'red':
|
||||||
|
return 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400';
|
||||||
|
case 'gray':
|
||||||
|
default:
|
||||||
|
return 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
children,
|
||||||
|
tone = 'gray',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: 'blue' | 'green' | 'amber' | 'red' | 'gray';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em]', pillClass(tone))}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ title, subtitle }: { title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: 'blue' | 'green' | 'amber' | 'red' | 'gray';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-2xl transition-all hover:border-white/20">
|
||||||
|
<div className={cn('mb-5 flex h-12 w-12 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]', pillClass(tone))}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black tracking-tight">{value}</h3>
|
||||||
|
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-white/10 py-3 last:border-0 last:pb-0">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">{label}</span>
|
||||||
|
<span className={cn('max-w-[66%] text-right text-[11px] font-bold leading-5 break-all', valueClassName)}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonPreview(value: string | null) {
|
||||||
|
const parsed = parseJson(value);
|
||||||
|
if (parsed == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveTask(status: string) {
|
||||||
|
return status === 'QUEUED' || status === 'RUNNING';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTasks() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [listError, setListError] = useState('');
|
||||||
|
const [detailError, setDetailError] = useState('');
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [pageData, setPageData] = useState<{
|
||||||
|
items: AdminTask[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<AdminTask | null>(null);
|
||||||
|
const requestSeqRef = useRef(0);
|
||||||
|
|
||||||
|
async function loadTasks(nextPage = 0, nextFilters = filters, nextPageSize = pageSize, isRefresh = false) {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setListError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAdminTasks(nextPage, nextPageSize, nextFilters);
|
||||||
|
setPageData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setListError(err instanceof Error ? err.message : '加载任务监控列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function openTaskDetail(task: AdminTask) {
|
||||||
|
setSelectedTask(task);
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailError('');
|
||||||
|
const seq = ++requestSeqRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await getAdminTask(task.id);
|
||||||
|
if (seq !== requestSeqRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedTask(detail);
|
||||||
|
} catch (err) {
|
||||||
|
if (seq !== requestSeqRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailError(err instanceof Error ? err.message : '加载任务详情失败');
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeqRef.current) {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetFilters() {
|
||||||
|
setFilters(DEFAULT_FILTERS);
|
||||||
|
setSelectedTask(null);
|
||||||
|
void loadTasks(0, DEFAULT_FILTERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = pageData?.items ?? [];
|
||||||
|
const total = pageData?.total ?? 0;
|
||||||
|
const currentPage = pageData?.page ?? 0;
|
||||||
|
const currentSize = pageData?.size ?? pageSize;
|
||||||
|
const pageCount = pageData ? Math.max(1, Math.ceil((pageData.total || 0) / pageData.size)) : 0;
|
||||||
|
const activeCount = items.filter((item) => isActiveTask(item.status)).length;
|
||||||
|
const failedCount = items.filter((item) => item.status === 'FAILED').length;
|
||||||
|
const retryScheduledCount = items.filter((item) => item.retryScheduled).length;
|
||||||
|
const activeFilterLabels = [
|
||||||
|
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
|
||||||
|
filters.type.trim() ? `类型: ${filters.type.trim()}` : '',
|
||||||
|
filters.status.trim() ? `状态: ${filters.status.trim()}` : '',
|
||||||
|
filters.failureCategory.trim() ? `失败分类: ${filters.failureCategory.trim()}` : '',
|
||||||
|
filters.leaseState.trim() ? `租约状态: ${filters.leaseState.trim()}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">任务监控</h1>
|
||||||
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
|
`GET /api/admin/tasks` / `GET /api/admin/tasks/:id` / 租约与重试态监控
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="rounded-lg glass-panel px-4 py-3 text-[11px] font-black uppercase tracking-widest">
|
||||||
|
<span className="mr-3 opacity-40">每页</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextSize = Number(event.target.value);
|
||||||
|
setPageSize(nextSize);
|
||||||
|
void loadTasks(0, filters, nextSize);
|
||||||
|
}}
|
||||||
|
className="bg-transparent outline-none"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadTasks(currentPage, filters, currentSize, true);
|
||||||
|
}}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
刷新列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.section variants={container} initial="hidden" animate="show" className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<MetricCard icon={<ListTodo className="h-6 w-6" />} label="任务总数" value={String(total)} tone="blue" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<MetricCard icon={<Clock3 className="h-6 w-6" />} label="当前页数量" value={String(items.length)} tone="gray" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<MetricCard icon={<RefreshCw className="h-6 w-6" />} label="当前页进行中" value={String(activeCount)} tone="green" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<MetricCard icon={<AlertTriangle className="h-6 w-6" />} label="当前页失败" value={String(failedCount)} tone="red" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<MetricCard icon={<PanelRightOpen className="h-6 w-6" />} label="已安排重试" value={String(retryScheduledCount)} tone="amber" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void loadTasks(0, filters, pageSize);
|
||||||
|
}}
|
||||||
|
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
|
||||||
|
>
|
||||||
|
<SectionTitle title="筛选器" subtitle="只使用后端支持的任务查询参数,避免前端做额外猜测" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_0.9fr_0.8fr_1fr_0.9fr]">
|
||||||
|
<label className="relative block group">
|
||||||
|
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
|
||||||
|
<input
|
||||||
|
value={filters.userQuery ?? ''}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
|
||||||
|
placeholder="所有者"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.type ?? ''}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, type: event.target.value }))}
|
||||||
|
placeholder="任务类型"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.status ?? ''}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}
|
||||||
|
placeholder="状态"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.failureCategory ?? ''}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, failureCategory: event.target.value }))}
|
||||||
|
placeholder="失败分类"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="relative block group">
|
||||||
|
<input
|
||||||
|
value={filters.leaseState ?? ''}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, leaseState: event.target.value }))}
|
||||||
|
placeholder="租约状态"
|
||||||
|
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 placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeFilterLabels.length ? (
|
||||||
|
activeFilterLabels.map((label) => (
|
||||||
|
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25">当前没有启用筛选条件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
应用筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{listError ? (
|
||||||
|
<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">
|
||||||
|
{listError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
<span>
|
||||||
|
共 {total} 条任务记录
|
||||||
|
{pageData ? ` / 第 ${currentPage + 1} 页,共 ${pageCount} 页` : ''}
|
||||||
|
</span>
|
||||||
|
<span>当前页 {items.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-1 min-h-0 grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||||
|
<div className="min-h-0">
|
||||||
|
{loading && !pageData ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
正在读取任务监控列表...
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
当前没有任务
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-[1500px] divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">任务</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">所属用户</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">状态</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">租约 / 重试</th>
|
||||||
|
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">时间</th>
|
||||||
|
<th className="px-6 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody variants={container} initial="hidden" animate="show" className="divide-y divide-white/10 dark:divide-white/5">
|
||||||
|
{items.map((task) => {
|
||||||
|
const isSelected = selectedTask?.id === task.id;
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
key={task.id}
|
||||||
|
variants={itemVariants}
|
||||||
|
onClick={() => {
|
||||||
|
void openTaskDetail(task);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group cursor-pointer transition-colors hover:bg-white/10 dark:hover:bg-white/5',
|
||||||
|
isSelected && 'bg-blue-500/10 dark:bg-blue-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[12px] font-black tracking-tight uppercase">{taskTypeLabel(task.type)}</div>
|
||||||
|
<div className="mt-1 font-mono text-[9px] font-black tracking-[0.18em] opacity-30">#{task.id}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge tone="gray">{task.type}</Badge>
|
||||||
|
{task.correlationId ? <Badge tone="blue">{task.correlationId}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black tracking-tight uppercase">{task.ownerUsername || '-'}</div>
|
||||||
|
<div className="mt-1 text-[9px] font-mono font-black tracking-[0.16em] opacity-30">{task.ownerEmail || '-'}</div>
|
||||||
|
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">用户 ID #{task.userId}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge tone={statusTone(task.status)}>{taskStatusLabel(task.status)}</Badge>
|
||||||
|
{task.retryScheduled ? <Badge tone="amber">已安排重试</Badge> : <Badge tone="gray">未安排重试</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge tone={failureTone(task.failureCategory)}>{failureCategoryLabel(task.failureCategory)}</Badge>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-tight">
|
||||||
|
{task.attemptCount}/{task.maxAttempts} 次
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge tone={leaseTone(task.leaseState)}>{leaseStateLabel(task.leaseState)}</Badge>
|
||||||
|
<Badge tone="gray">{task.workerOwner || '无 worker'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">下一次运行:{formatDateTime(task.nextRunAt)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-40">创建:{formatDateTime(task.createdAt)}</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">更新:{formatDateTime(task.updatedAt)}</div>
|
||||||
|
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">结束:{formatDateTime(task.finishedAt)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-5 align-top text-right">
|
||||||
|
<div className="flex justify-end gap-2 opacity-60 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openTaskDetail(task);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 shadow-sm transition-all hover:bg-blue-600 hover:text-white"
|
||||||
|
title="查看详情"
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="xl:sticky xl:top-6">
|
||||||
|
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">任务详情</h2>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">点击左侧任意任务后查看完整监控信息</p>
|
||||||
|
</div>
|
||||||
|
{selectedTask ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTask(null);
|
||||||
|
setDetailError('');
|
||||||
|
setDetailLoading(false);
|
||||||
|
requestSeqRef.current += 1;
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailError ? (
|
||||||
|
<div className="mb-5 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-[11px] font-bold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||||
|
{detailError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedTask ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={statusTone(selectedTask.status)}>{taskStatusLabel(selectedTask.status)}</Badge>
|
||||||
|
<Badge tone="gray">{taskTypeLabel(selectedTask.type)}</Badge>
|
||||||
|
<Badge tone={selectedTask.retryScheduled ? 'amber' : 'gray'}>{selectedTask.retryScheduled ? '已安排重试' : '未安排重试'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-2xl font-black tracking-tight">任务 #{selectedTask.id}</div>
|
||||||
|
<div className="mt-2 font-mono text-[9px] font-black tracking-[0.18em] opacity-30">{selectedTask.correlationId || '无 correlationId'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-8 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
|
||||||
|
正在加载详情...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle title="基本信息" subtitle="任务归属、类型、状态与失败分类" />
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<DetailRow label="任务 ID" value={`#${selectedTask.id}`} />
|
||||||
|
<DetailRow label="类型" value={taskTypeLabel(selectedTask.type)} />
|
||||||
|
<DetailRow label="状态" value={<Badge tone={statusTone(selectedTask.status)}>{taskStatusLabel(selectedTask.status)}</Badge>} />
|
||||||
|
<DetailRow label="失败分类" value={<Badge tone={failureTone(selectedTask.failureCategory)}>{failureCategoryLabel(selectedTask.failureCategory)}</Badge>} />
|
||||||
|
<DetailRow label="重试已安排" value={<Badge tone={selectedTask.retryScheduled ? 'amber' : 'gray'}>{selectedTask.retryScheduled ? '是' : '否'}</Badge>} />
|
||||||
|
<DetailRow label="所属用户" value={`${selectedTask.ownerUsername || '-'} / ${selectedTask.ownerEmail || '-'}`} />
|
||||||
|
<DetailRow label="用户 ID" value={`#${selectedTask.userId}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle title="租约信息" subtitle="worker 与 lease 边界,便于排查多实例抢占" />
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<DetailRow label="租约状态" value={<Badge tone={leaseTone(selectedTask.leaseState)}>{leaseStateLabel(selectedTask.leaseState)}</Badge>} />
|
||||||
|
<DetailRow label="leaseOwner" value={selectedTask.leaseOwner || '-'} />
|
||||||
|
<DetailRow label="workerOwner" value={selectedTask.workerOwner || '-'} />
|
||||||
|
<DetailRow label="leaseExpiresAt" value={formatDateTime(selectedTask.leaseExpiresAt)} />
|
||||||
|
<DetailRow label="heartbeatAt" value={formatDateTime(selectedTask.heartbeatAt)} />
|
||||||
|
<DetailRow label="nextRunAt" value={formatDateTime(selectedTask.nextRunAt)} />
|
||||||
|
<DetailRow label="attemptCount" value={`${selectedTask.attemptCount}/${selectedTask.maxAttempts}`} />
|
||||||
|
<DetailRow label="correlationId" value={selectedTask.correlationId || '-'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle title="时间信息" subtitle="创建、更新与完成时间" />
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<DetailRow label="createdAt" value={formatDateTime(selectedTask.createdAt)} />
|
||||||
|
<DetailRow label="updatedAt" value={formatDateTime(selectedTask.updatedAt)} />
|
||||||
|
<DetailRow label="finishedAt" value={formatDateTime(selectedTask.finishedAt)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle title="公开状态" subtitle="publicStateJson / errorMessage / 原始调度状态" />
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">publicStateJson</div>
|
||||||
|
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-white/5 p-4 text-[11px] leading-6 text-gray-100">
|
||||||
|
{formatJsonPreview(selectedTask.publicStateJson)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">errorMessage</div>
|
||||||
|
<div className={cn('rounded-xl border p-4 text-[11px] leading-6', selectedTask.errorMessage ? 'border-red-500/20 bg-red-500/10 text-red-200' : 'border-white/10 bg-white/5 opacity-70')}>
|
||||||
|
{selectedTask.errorMessage || '无错误信息'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/15 bg-white/5 px-4 py-12 text-center">
|
||||||
|
<FileCode2 className="mx-auto h-10 w-10 opacity-25" />
|
||||||
|
<p className="mt-4 text-[11px] font-black uppercase tracking-[0.22em] opacity-40">尚未选择任务</p>
|
||||||
|
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-25">从左侧列表打开任务详情面板</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
|
||||||
|
{pageData ? `第 ${currentPage + 1} 页 / 每页 ${currentSize}` : '尚未加载分页信息'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentPage <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadTasks(currentPage - 1, filters, currentSize);
|
||||||
|
}}
|
||||||
|
disabled={!pageData || currentPage <= 0 || loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!pageData || currentPage + 1 >= pageCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadTasks(currentPage + 1, filters, currentSize);
|
||||||
|
}}
|
||||||
|
disabled={!pageData || currentPage + 1 >= pageCount || loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } 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 {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
import {
|
import {
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
@@ -29,11 +37,246 @@ const itemVariants = {
|
|||||||
show: { y: 0, opacity: 1 }
|
show: { y: 0, opacity: 1 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<AdminUser>();
|
||||||
|
|
||||||
|
type UserEditorFormValues = {
|
||||||
|
role: AdminUser['role'];
|
||||||
|
storageQuotaBytes: string;
|
||||||
|
maxUploadSizeBytes: string;
|
||||||
|
manualPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_EDITOR_FORM_VALUES: UserEditorFormValues = {
|
||||||
|
role: 'USER',
|
||||||
|
storageQuotaBytes: '',
|
||||||
|
maxUploadSizeBytes: '',
|
||||||
|
manualPassword: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateNonNegativeBytes(rawValue: string, label: string) {
|
||||||
|
const trimmedValue = rawValue.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return `${label}不能为空`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(trimmedValue);
|
||||||
|
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
||||||
|
return `${label}必须是非负整数`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeBytes(rawValue: string, label: string) {
|
||||||
|
const validation = validateNonNegativeBytes(rawValue, label);
|
||||||
|
if (validation !== true) {
|
||||||
|
throw new Error(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(rawValue.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminUsersList() {
|
export default function AdminUsersList() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||||
|
const [temporaryPasswords, setTemporaryPasswords] = useState<Record<number, string>>({});
|
||||||
|
const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState<number | null>(null);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
trigger,
|
||||||
|
getValues,
|
||||||
|
reset,
|
||||||
|
resetField,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserEditorFormValues>({
|
||||||
|
defaultValues: EMPTY_EDITOR_FORM_VALUES,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
});
|
||||||
|
const watchedRole = watch('role');
|
||||||
|
const columns = useMemo<ColumnDef<AdminUser, unknown>[]>(() => [
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'userInfo',
|
||||||
|
header: '用户信息',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center font-black text-blue-500 border border-blue-500/20 shadow-inner">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-black tracking-tight uppercase">{user.username}</div>
|
||||||
|
<div className="text-[10px] opacity-40 font-bold flex items-center gap-1.5 mt-0.5"><Mail className="h-3 w-3" /> {user.email}</div>
|
||||||
|
{user.phoneNumber ? <div className="mt-1 text-[9px] font-black opacity-20 tracking-widest flex items-center gap-1.5"><Phone className="h-3 w-3" />{user.phoneNumber}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('role', {
|
||||||
|
header: '角色',
|
||||||
|
cell: ({ row, getValue }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border shadow-inner",
|
||||||
|
getValue() === 'ADMIN'
|
||||||
|
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||||||
|
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||||
|
)}>
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('banned', {
|
||||||
|
header: '状态',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
|
||||||
|
getValue()
|
||||||
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
|
: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
)}>
|
||||||
|
{getValue() ? '已禁用' : '正常'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'resources',
|
||||||
|
header: '资源配额',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-tight">
|
||||||
|
{formatBytes(user.usedStorageBytes)} / <span className="opacity-30">{formatBytes(user.storageQuotaBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1 w-full max-w-[120px] rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.min(100, (user.usedStorageBytes / user.storageQuotaBytes) * 100)}%` }}
|
||||||
|
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
|
||||||
|
上传上限:{formatBytes(user.maxUploadSizeBytes)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('createdAt', {
|
||||||
|
header: '注册时间',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div className="text-[10px] font-bold opacity-30 tracking-tighter uppercase">
|
||||||
|
{formatDateTime(getValue())}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'actions',
|
||||||
|
header: '操作',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditor(user)}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
|
||||||
|
title="打开编辑面板"
|
||||||
|
>
|
||||||
|
<PencilLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generateTemporaryPassword(user.id)}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-violet-500 hover:text-white text-violet-500 transition-all border-white/10"
|
||||||
|
title="生成临时密码"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void mutate(() => updateUserStatus(user.id, !user.banned))}
|
||||||
|
className={cn(
|
||||||
|
"p-2.5 rounded-lg glass-panel border border-white/10 transition-all",
|
||||||
|
user.banned ? "hover:bg-green-500 hover:text-white text-green-500" : "hover:bg-red-500 hover:text-white text-red-500"
|
||||||
|
)}
|
||||||
|
title={user.banned ? '恢复账号' : '禁用账号'}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{temporaryPasswords[user.id] ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-violet-500/20 bg-violet-500/10 px-4 py-3 text-left shadow-inner">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-violet-500">
|
||||||
|
临时密码已生成
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold opacity-50">
|
||||||
|
请复制后立即告知用户,随后可关闭此提示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setTemporaryPasswords((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[user.id];
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-full border border-white/10 p-1.5 text-violet-500 transition-colors hover:bg-violet-500 hover:text-white"
|
||||||
|
title="关闭临时密码提示"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<code className="rounded-md border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-black tracking-[0.15em] text-white">
|
||||||
|
{temporaryPasswords[user.id]}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyTemporaryPassword(user.id, temporaryPasswords[user.id])}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-[10px] font-black uppercase tracking-widest text-violet-500 transition-colors hover:bg-violet-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{copiedTemporaryPasswordUserId === user.id ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
已复制
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clipboard className="h-3.5 w-3.5" />
|
||||||
|
复制
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], [copiedTemporaryPasswordUserId, copyTemporaryPassword, generateTemporaryPassword, mutate, openEditor, temporaryPasswords]);
|
||||||
|
const table = useReactTable<AdminUser>({
|
||||||
|
data: users,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => String(row.id),
|
||||||
|
});
|
||||||
|
|
||||||
async function loadUsers(nextQuery = query) {
|
async function loadUsers(nextQuery = query) {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -47,10 +290,106 @@ export default function AdminUsersList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latestUser = users.find((user) => user.id === editingUser.id);
|
||||||
|
if (!latestUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingUser(latestUser);
|
||||||
|
reset(
|
||||||
|
{
|
||||||
|
role: latestUser.role,
|
||||||
|
storageQuotaBytes: String(latestUser.storageQuotaBytes),
|
||||||
|
maxUploadSizeBytes: String(latestUser.maxUploadSizeBytes),
|
||||||
|
manualPassword: getValues('manualPassword'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [editingUser, users]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadUsers();
|
void loadUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function openEditor(user: AdminUser) {
|
||||||
|
setError('');
|
||||||
|
setEditingUser(user);
|
||||||
|
reset({
|
||||||
|
role: user.role,
|
||||||
|
storageQuotaBytes: String(user.storageQuotaBytes),
|
||||||
|
maxUploadSizeBytes: String(user.maxUploadSizeBytes),
|
||||||
|
manualPassword: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
setEditingUser(null);
|
||||||
|
reset(EMPTY_EDITOR_FORM_VALUES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditorProfile() {
|
||||||
|
if (!editingUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isValid = await trigger(['role', 'storageQuotaBytes', 'maxUploadSizeBytes']);
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValues = getValues();
|
||||||
|
const nextStorageQuotaBytes = parseNonNegativeBytes(currentValues.storageQuotaBytes, '存储配额');
|
||||||
|
const nextMaxUploadSizeBytes = parseNonNegativeBytes(currentValues.maxUploadSizeBytes, '最大上传限制');
|
||||||
|
|
||||||
|
await mutate(async () => {
|
||||||
|
if (currentValues.role !== editingUser.role) {
|
||||||
|
await updateUserRole(editingUser.id, currentValues.role);
|
||||||
|
}
|
||||||
|
if (nextStorageQuotaBytes !== editingUser.storageQuotaBytes) {
|
||||||
|
await updateUserStorageQuota(editingUser.id, nextStorageQuotaBytes);
|
||||||
|
}
|
||||||
|
if (nextMaxUploadSizeBytes !== editingUser.maxUploadSizeBytes) {
|
||||||
|
await updateUserMaxUploadSize(editingUser.id, nextMaxUploadSizeBytes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存基础配置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualPassword() {
|
||||||
|
if (!editingUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isValid = await trigger('manualPassword');
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPassword = getValues('manualPassword').trim();
|
||||||
|
await mutate(async () => {
|
||||||
|
await updateUserPassword(editingUser.id, nextPassword);
|
||||||
|
resetField('manualPassword');
|
||||||
|
setTemporaryPasswords((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[editingUser.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTemporaryPassword(userId: number) {
|
||||||
|
await mutate(async () => {
|
||||||
|
const result = await resetUserPassword(userId);
|
||||||
|
setTemporaryPasswords((current) => ({
|
||||||
|
...current,
|
||||||
|
[userId]: result.temporaryPassword,
|
||||||
|
}));
|
||||||
|
setCopiedTemporaryPasswordUserId(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function mutate(action: () => Promise<unknown>) {
|
async function mutate(action: () => Promise<unknown>) {
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
@@ -60,6 +399,18 @@ export default function AdminUsersList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTemporaryPassword(userId: number, password: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(password);
|
||||||
|
setCopiedTemporaryPasswordUserId(userId);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCopiedTemporaryPasswordUserId((current) => (current === userId ? null : current));
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '复制临时密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -69,8 +420,8 @@ export default function AdminUsersList() {
|
|||||||
>
|
>
|
||||||
<div className="mb-10 flex items-center justify-between">
|
<div className="mb-10 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white">身份管理</h1>
|
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white">用户策略</h1>
|
||||||
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">用户权限 / 身份档案</p>
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">角色 / 配额 / 上传限制 / 密码策略</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,154 +456,272 @@ 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="flex-1 min-h-0">
|
<div className="grid flex-1 min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
{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="overflow-x-auto">
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
<table className="min-w-full divide-y divide-white/10">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-white/10 dark:bg-black/40">
|
<table className="min-w-full divide-y divide-white/10">
|
||||||
<tr>
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">用户信息</th>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">角色</th>
|
<tr key={headerGroup.id}>
|
||||||
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">状态</th>
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">资源配额</th>
|
<th
|
||||||
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">注册时间</th>
|
key={header.id}
|
||||||
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40">操作</th>
|
className={cn(
|
||||||
</tr>
|
"px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] opacity-40",
|
||||||
</thead>
|
header.column.id === 'actions' ? 'text-right' : 'text-left'
|
||||||
<motion.tbody
|
)}
|
||||||
variants={container}
|
>
|
||||||
initial="hidden"
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
animate="show"
|
</th>
|
||||||
className="divide-y divide-white/10 dark:divide-white/5"
|
))}
|
||||||
>
|
</tr>
|
||||||
{users.map((user) => (
|
))}
|
||||||
<motion.tr key={user.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
</thead>
|
||||||
<td className="px-8 py-5">
|
<motion.tbody
|
||||||
<div className="flex items-center gap-4">
|
variants={container}
|
||||||
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center font-black text-blue-500 border border-blue-500/20 shadow-inner">
|
initial="hidden"
|
||||||
{user.username.charAt(0).toUpperCase()}
|
animate="show"
|
||||||
</div>
|
className="divide-y divide-white/10 dark:divide-white/5"
|
||||||
<div>
|
>
|
||||||
<div className="text-[12px] font-black tracking-tight uppercase">{user.username}</div>
|
{table.getRowModel().rows.map((row) => {
|
||||||
<div className="text-[10px] opacity-40 font-bold flex items-center gap-1.5 mt-0.5"><Mail className="h-3 w-3" /> {user.email}</div>
|
const user = row.original;
|
||||||
{user.phoneNumber ? <div className="mt-1 text-[9px] font-black opacity-20 tracking-widest flex items-center gap-1.5"><Phone className="h-3 w-3" />{user.phoneNumber}</div> : null}
|
const isEditing = editingUser?.id === user.id;
|
||||||
</div>
|
return (
|
||||||
</div>
|
<motion.tr
|
||||||
</td>
|
key={row.id}
|
||||||
<td className="px-8 py-5">
|
variants={itemVariants}
|
||||||
<span className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border shadow-inner",
|
"group transition-colors",
|
||||||
user.role === 'ADMIN'
|
isEditing ? "bg-blue-500/10 dark:bg-blue-500/5" : "hover:bg-white/10 dark:hover:bg-white/5"
|
||||||
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
)}
|
||||||
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
>
|
||||||
)}>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<Shield className="h-3 w-3" />
|
<td
|
||||||
{user.role}
|
key={cell.id}
|
||||||
</span>
|
className={cn(
|
||||||
</td>
|
"px-8 py-5 align-top",
|
||||||
<td className="px-8 py-5">
|
cell.column.id === 'actions' ? 'text-right' : 'text-left'
|
||||||
<span className={cn(
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">用户策略编辑</div>
|
||||||
|
<h2 className="mt-2 text-lg font-black tracking-tight uppercase">
|
||||||
|
{editingUser ? editingUser.username : '请选择用户'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-[10px] font-bold opacity-40 leading-relaxed">
|
||||||
|
{editingUser
|
||||||
|
? '这里负责角色、存储配额、最大上传限制和手动改密。临时密码生成仍保留在表格快捷操作里,避免和手动改密混在一起。'
|
||||||
|
: '从左侧表格点击“编辑”打开该用户的策略面板。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{editingUser ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeEditor}
|
||||||
|
className="rounded-full border border-white/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest opacity-50 transition-colors hover:bg-white/10 hover:opacity-100"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingUser ? (
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">当前账号</div>
|
||||||
|
<div className="mt-2 text-[12px] font-black uppercase tracking-tight">{editingUser.email}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
|
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
|
||||||
user.banned
|
editingUser.banned
|
||||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
: "bg-green-500/10 text-green-500 border-green-500/20"
|
: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
)}>
|
)}
|
||||||
{user.banned ? '已禁用' : '正常'}
|
>
|
||||||
</span>
|
{editingUser.banned ? '已禁用' : '正常'}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-8 py-5">
|
</div>
|
||||||
<div className="text-[10px] font-black uppercase tracking-tight">
|
<div className="mt-4 text-[10px] font-black uppercase tracking-tight">
|
||||||
{formatBytes(user.usedStorageBytes)} / <span className="opacity-30">{formatBytes(user.storageQuotaBytes)}</span>
|
{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" />
|
||||||
|
保存基础配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-amber-500">手动设置密码</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold opacity-60 leading-relaxed">
|
||||||
|
这里是人工指定一个新密码,会直接覆盖当前密码。它和“生成临时密码”是两条不同的管理路径。
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1 w-full max-w-[120px] rounded-full bg-white/10 overflow-hidden">
|
</div>
|
||||||
<motion.div
|
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||||
initial={{ width: 0 }}
|
</div>
|
||||||
animate={{ width: `${Math.min(100, (user.usedStorageBytes / user.storageQuotaBytes) * 100)}%` }}
|
<label className="block">
|
||||||
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30">新密码</span>
|
||||||
></motion.div>
|
<input
|
||||||
</div>
|
{...register('manualPassword', {
|
||||||
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
|
validate: (value) => (value.trim() ? true : '请输入要手动设置的新密码'),
|
||||||
上传上限:{formatBytes(user.maxUploadSizeBytes)}
|
})}
|
||||||
</div>
|
type="password"
|
||||||
</td>
|
autoComplete="new-password"
|
||||||
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
|
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"
|
||||||
{formatDateTime(user.createdAt)}
|
placeholder="输入后点击“手动设置密码”"
|
||||||
</td>
|
/>
|
||||||
<td className="px-8 py-5 text-right">
|
{errors.manualPassword ? (
|
||||||
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
|
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-amber-500">
|
||||||
<button
|
{errors.manualPassword.message}
|
||||||
type="button"
|
</p>
|
||||||
onClick={() =>
|
) : null}
|
||||||
void mutate(async () => {
|
</label>
|
||||||
const nextRole = window.prompt('设置角色:USER 或 ADMIN', user.role);
|
<button
|
||||||
if (!nextRole || (nextRole !== 'USER' && nextRole !== 'ADMIN')) {
|
type="button"
|
||||||
return;
|
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"
|
||||||
await updateUserRole(user.id, nextRole);
|
>
|
||||||
})
|
<KeyRound className="h-4 w-4" />
|
||||||
}
|
手动设置密码
|
||||||
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
|
</button>
|
||||||
title="修改角色"
|
<p className="text-[10px] font-bold opacity-50 leading-relaxed">
|
||||||
>
|
适合人工恢复账号、统一初始化密码或和用户同步已知密码。若要发放一次性密码,请继续使用表格里的“生成临时密码”。
|
||||||
<Shield className="h-4 w-4" />
|
</p>
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||||
onClick={() =>
|
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">账号状态</div>
|
||||||
void mutate(async () => {
|
<div className="mt-2 text-[10px] font-bold opacity-50 leading-relaxed">
|
||||||
const nextQuota = window.prompt('设置存储配额(字节)', String(user.storageQuotaBytes));
|
可直接切换禁用 / 恢复,不影响上面的基础配置或手动改密表单。
|
||||||
if (!nextQuota) return;
|
</div>
|
||||||
await updateUserStorageQuota(user.id, Number(nextQuota));
|
<button
|
||||||
})
|
type="button"
|
||||||
}
|
onClick={() => void mutate(() => updateUserStatus(editingUser.id, !editingUser.banned))}
|
||||||
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
|
className={cn(
|
||||||
title="修改配额"
|
"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
|
||||||
<Upload className="h-4 w-4" />
|
? "border-green-500/20 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
|
||||||
</button>
|
: "border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
|
||||||
<button
|
)}
|
||||||
type="button"
|
>
|
||||||
onClick={() =>
|
<Ban className="h-4 w-4" />
|
||||||
void mutate(async () => {
|
{editingUser.banned ? '恢复账号' : '禁用账号'}
|
||||||
const newPassword = window.prompt('设置新密码');
|
</button>
|
||||||
if (!newPassword) return;
|
</div>
|
||||||
await updateUserPassword(user.id, newPassword);
|
</div>
|
||||||
})
|
) : (
|
||||||
}
|
<div className="mt-10 rounded-lg border border-dashed border-white/10 px-6 py-12 text-center">
|
||||||
className="p-2.5 rounded-lg glass-panel hover:bg-amber-500 hover:text-white text-amber-500 transition-all border-white/10"
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-25">编辑面板为空</div>
|
||||||
title="重置密码"
|
<p className="mt-3 text-[11px] font-bold opacity-40 leading-relaxed">
|
||||||
>
|
点击左侧任意用户行的“编辑”按钮,右侧会自动展开该用户的角色、配额和密码管理表单。
|
||||||
<KeyRound className="h-4 w-4" />
|
</p>
|
||||||
</button>
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
</aside>
|
||||||
onClick={() => void mutate(() => updateUserStatus(user.id, !user.banned))}
|
</>
|
||||||
className={cn(
|
|
||||||
"p-2.5 rounded-lg glass-panel border border-white/10 transition-all",
|
|
||||||
user.banned ? "hover:bg-green-500 hover:text-white text-green-500" : "hover:bg-red-500 hover:text-white text-red-500"
|
|
||||||
)}
|
|
||||||
title={user.banned ? '恢复账号' : '禁用账号'}
|
|
||||||
>
|
|
||||||
<Ban className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
))}
|
|
||||||
{users.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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
47
front/src/lib/admin-audits.ts
Normal file
47
front/src/lib/admin-audits.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminAuditLog = {
|
||||||
|
id: number;
|
||||||
|
actorUserId: number | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
actorAuthorities: string[] | string | null;
|
||||||
|
actionType: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId: string | null;
|
||||||
|
summary: string;
|
||||||
|
detailsJson: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminAuditQuery = {
|
||||||
|
actorQuery?: string;
|
||||||
|
actionType?: string;
|
||||||
|
targetType?: string;
|
||||||
|
targetId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminAudits(page = 0, size = 100, query: AdminAuditQuery = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.actorQuery?.trim()) {
|
||||||
|
params.set('actorQuery', query.actorQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.actionType?.trim()) {
|
||||||
|
params.set('actionType', query.actionType.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.targetType?.trim()) {
|
||||||
|
params.set('targetType', query.targetType.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.targetId?.trim()) {
|
||||||
|
params.set('targetId', query.targetId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<PageResponse<AdminAuditLog>>(`/admin/audits?${params.toString()}`);
|
||||||
|
}
|
||||||
58
front/src/lib/admin-fileblobs.ts
Normal file
58
front/src/lib/admin-fileblobs.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminFileBlobEntityType = 'VERSION' | 'THUMBNAIL' | 'LIVE_PHOTO' | 'TRANSCODE' | 'AVATAR';
|
||||||
|
|
||||||
|
export type AdminFileBlobResponse = {
|
||||||
|
entityId: number;
|
||||||
|
blobId: number;
|
||||||
|
objectKey: string;
|
||||||
|
entityType: AdminFileBlobEntityType;
|
||||||
|
storagePolicyId: number;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
referenceCount: number | null;
|
||||||
|
linkedStoredFileCount: number;
|
||||||
|
linkedOwnerCount: number;
|
||||||
|
sampleOwnerUsername: string | null;
|
||||||
|
sampleOwnerEmail: string | null;
|
||||||
|
createdByUserId: number | null;
|
||||||
|
createdByUsername: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
blobCreatedAt: string | null;
|
||||||
|
blobMissing: boolean;
|
||||||
|
orphanRisk: boolean;
|
||||||
|
referenceMismatch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminFileBlobQuery = {
|
||||||
|
userQuery?: string;
|
||||||
|
storagePolicyId?: number | null;
|
||||||
|
objectKey?: string;
|
||||||
|
entityType?: AdminFileBlobEntityType | '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminFileBlobs(page = 0, size = 100, query: AdminFileBlobQuery = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.userQuery?.trim()) {
|
||||||
|
params.set('userQuery', query.userQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.storagePolicyId != null && Number.isInteger(query.storagePolicyId)) {
|
||||||
|
params.set('storagePolicyId', String(query.storagePolicyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.objectKey?.trim()) {
|
||||||
|
params.set('objectKey', query.objectKey.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.entityType) {
|
||||||
|
params.set('entityType', query.entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<PageResponse<AdminFileBlobResponse>>(`/admin/file-blobs?${params.toString()}`);
|
||||||
|
}
|
||||||
34
front/src/lib/admin-filesystem.ts
Normal file
34
front/src/lib/admin-filesystem.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { AdminStoragePolicy } from './admin-storage-policies';
|
||||||
|
|
||||||
|
export type AdminFilesystemResponse = {
|
||||||
|
overview: {
|
||||||
|
storageProvider: string;
|
||||||
|
totalFiles: number;
|
||||||
|
totalBlobs: number;
|
||||||
|
totalEntities: number;
|
||||||
|
};
|
||||||
|
defaultPolicy: AdminStoragePolicy;
|
||||||
|
upload: {
|
||||||
|
proxyUpload: boolean;
|
||||||
|
directSingleUpload: boolean;
|
||||||
|
directMultipartUpload: boolean;
|
||||||
|
effectiveMaxFileSizeBytes: number;
|
||||||
|
};
|
||||||
|
mediaProcessing: {
|
||||||
|
metadataExtractionEnabled: boolean;
|
||||||
|
nativeThumbnailSupport: boolean;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
backend: string;
|
||||||
|
filesListTtlSeconds: number;
|
||||||
|
directoryVersionTtlSeconds: number;
|
||||||
|
};
|
||||||
|
webdav: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminFilesystem() {
|
||||||
|
return fetchApi<AdminFilesystemResponse>('/admin/filesystem');
|
||||||
|
}
|
||||||
78
front/src/lib/admin-settings.ts
Normal file
78
front/src/lib/admin-settings.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
|
||||||
|
export type AdminSettings = {
|
||||||
|
site: {
|
||||||
|
supported: boolean;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
registration: {
|
||||||
|
inviteCodeRequired: boolean;
|
||||||
|
currentInviteCode: string;
|
||||||
|
managementRoles: string[];
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
userSession: {
|
||||||
|
accessExpirationSeconds: number;
|
||||||
|
refreshExpirationSeconds: number;
|
||||||
|
tokenBlacklistEnabled: boolean;
|
||||||
|
tokenBlacklistTtlBufferSeconds: number;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
transfer: {
|
||||||
|
offlineTransferStorageLimitBytes: number;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
mediaProcessing: {
|
||||||
|
metadataExtractionEnabled: boolean;
|
||||||
|
thumbnailGenerationEnabled: boolean;
|
||||||
|
videoPosterEnabled: boolean;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
queue: {
|
||||||
|
backend: string;
|
||||||
|
mediaMetadataFixedDelayMs: number;
|
||||||
|
mediaMetadataInitialDelayMs: number;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
appearance: {
|
||||||
|
supported: boolean;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
server: {
|
||||||
|
storageProvider: string;
|
||||||
|
redisEnabled: boolean;
|
||||||
|
writeSupported: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminRegistrationInviteCodeResponse = {
|
||||||
|
currentInviteCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOfflineTransferStorageLimitResponse = {
|
||||||
|
offlineTransferStorageLimitBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminSettings() {
|
||||||
|
return fetchApi<AdminSettings>('/admin/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminRegistrationInviteCode(inviteCode: string) {
|
||||||
|
return fetchApi<AdminRegistrationInviteCodeResponse>('/admin/settings/registration/invite-code', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ inviteCode }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateAdminRegistrationInviteCode() {
|
||||||
|
return fetchApi<AdminRegistrationInviteCodeResponse>('/admin/settings/registration/invite-code/rotate', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminOfflineTransferStorageLimit(offlineTransferStorageLimitBytes: number) {
|
||||||
|
return fetchApi<AdminOfflineTransferStorageLimitResponse>('/admin/settings/offline-transfer-storage-limit', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ offlineTransferStorageLimitBytes }),
|
||||||
|
});
|
||||||
|
}
|
||||||
69
front/src/lib/admin-shares.ts
Normal file
69
front/src/lib/admin-shares.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminShare = {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
shareName: string | null;
|
||||||
|
passwordProtected: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxDownloads: number | null;
|
||||||
|
downloadCount: number;
|
||||||
|
viewCount: number;
|
||||||
|
allowImport: boolean;
|
||||||
|
allowDownload: boolean;
|
||||||
|
ownerId: number;
|
||||||
|
ownerUsername: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
fileId: number;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
fileContentType: string;
|
||||||
|
fileSize: number;
|
||||||
|
directory: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminShareQuery = {
|
||||||
|
userQuery?: string;
|
||||||
|
fileName?: string;
|
||||||
|
token?: string;
|
||||||
|
passwordProtected?: 'true' | 'false' | '';
|
||||||
|
expired?: 'true' | 'false' | '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminShares(page = 0, size = 100, query: AdminShareQuery = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.userQuery?.trim()) {
|
||||||
|
params.set('userQuery', query.userQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.fileName?.trim()) {
|
||||||
|
params.set('fileName', query.fileName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.token?.trim()) {
|
||||||
|
params.set('token', query.token.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.passwordProtected) {
|
||||||
|
params.set('passwordProtected', query.passwordProtected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.expired) {
|
||||||
|
params.set('expired', query.expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<PageResponse<AdminShare>>(`/admin/shares?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminShare(shareId: number) {
|
||||||
|
return fetchApi<void>(`/admin/shares/${shareId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
68
front/src/lib/admin-tasks.ts
Normal file
68
front/src/lib/admin-tasks.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminTask = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
userId: number;
|
||||||
|
ownerUsername: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
publicStateJson: string | null;
|
||||||
|
correlationId: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
attemptCount: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
leaseOwner: string | null;
|
||||||
|
leaseExpiresAt: string | null;
|
||||||
|
heartbeatAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
failureCategory: string | null;
|
||||||
|
retryScheduled: boolean;
|
||||||
|
workerOwner: string | null;
|
||||||
|
leaseState: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminTaskQuery = {
|
||||||
|
userQuery?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
failureCategory?: string;
|
||||||
|
leaseState?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminTasks(page = 0, size = 20, query: AdminTaskQuery = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.userQuery?.trim()) {
|
||||||
|
params.set('userQuery', query.userQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.type?.trim()) {
|
||||||
|
params.set('type', query.type.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status?.trim()) {
|
||||||
|
params.set('status', query.status.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.failureCategory?.trim()) {
|
||||||
|
params.set('failureCategory', query.failureCategory.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.leaseState?.trim()) {
|
||||||
|
params.set('leaseState', query.leaseState.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<PageResponse<AdminTask>>(`/admin/tasks?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminTask(taskId: number) {
|
||||||
|
return fetchApi<AdminTask>(`/admin/tasks/${taskId}`);
|
||||||
|
}
|
||||||
198
memory.md
198
memory.md
@@ -845,3 +845,201 @@
|
|||||||
- `cd front && npm run lint`
|
- `cd front && npm run lint`
|
||||||
- `cd front && npm run build`
|
- `cd front && npm run build`
|
||||||
- build output now shows split chunks with main entry chunk `assets/index-CXR4rSrf.js` at **244.85 kB** (previously reported ~538.17 kB), and no Vite chunk-size warning.
|
- build output now shows split chunks with main entry chunk `assets/index-CXR4rSrf.js` at **244.85 kB** (previously reported ~538.17 kB), and no Vite chunk-size warning.
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 27
|
||||||
|
|
||||||
|
- 管理台前端继续补齐第一批治理页:
|
||||||
|
- `front/src/admin/settings.tsx` 已从占位页升级为真实页面,现可读取 `/api/admin/settings`。
|
||||||
|
- 新增 `front/src/lib/admin-settings.ts`,封装:
|
||||||
|
- `GET /api/admin/settings`
|
||||||
|
- `PATCH /api/admin/settings/registration/invite-code`
|
||||||
|
- `POST /api/admin/settings/registration/invite-code/rotate`
|
||||||
|
- `PATCH /api/admin/settings/offline-transfer-storage-limit`
|
||||||
|
- 系统设置页当前已支持:
|
||||||
|
- 编辑注册邀请码
|
||||||
|
- 轮换邀请码
|
||||||
|
- 修改离线快传总容量上限
|
||||||
|
- 展示注册、会话、媒体处理、队列、服务器等只读快照
|
||||||
|
- `front/src/admin/filesystem.tsx` 已从占位页升级为真实页面,现可读取 `/api/admin/filesystem`。
|
||||||
|
- 新增 `front/src/lib/admin-filesystem.ts`,封装 `GET /api/admin/filesystem`。
|
||||||
|
- 文件系统页当前已支持展示:
|
||||||
|
- 存储提供者 / 文件总数 / Blob 总数 / 实体总数
|
||||||
|
- 默认存储策略详情与能力矩阵
|
||||||
|
- 上传模式矩阵
|
||||||
|
- 媒体处理状态
|
||||||
|
- 缓存状态
|
||||||
|
- WebDAV 状态
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 28
|
||||||
|
|
||||||
|
- 管理台前端第二批治理页继续补齐:
|
||||||
|
- `front/src/admin/fileblobs.tsx` 已从占位页升级为真实页面,现可读取 `/api/admin/file-blobs`。
|
||||||
|
- 新增 `front/src/lib/admin-fileblobs.ts`,封装 `GET /api/admin/file-blobs`,支持:
|
||||||
|
- `userQuery`
|
||||||
|
- `storagePolicyId`
|
||||||
|
- `objectKey`
|
||||||
|
- `entityType`
|
||||||
|
- 对象实体页当前已支持:
|
||||||
|
- 多条件筛选
|
||||||
|
- 显示对象键、实体/Blob 编号、存储策略、大小/类型、关联信息、时间
|
||||||
|
- 显示 `blobMissing`、`orphanRisk`、`referenceMismatch` 风险标签与统计
|
||||||
|
- 复制对象键
|
||||||
|
- `front/src/admin/shares.tsx` 已从占位页升级为真实页面,现可读取 `/api/admin/shares` 并执行 `DELETE /api/admin/shares/{shareId}`。
|
||||||
|
- 新增 `front/src/lib/admin-shares.ts`,封装:
|
||||||
|
- `GET /api/admin/shares`
|
||||||
|
- `DELETE /api/admin/shares/{shareId}`
|
||||||
|
- 分享管理页当前已支持:
|
||||||
|
- 按用户、文件名、Token、是否密码保护、是否过期筛选
|
||||||
|
- 展示权限、所属用户、文件信息、统计、时间
|
||||||
|
- 复制 Token
|
||||||
|
- 打开公开分享链接
|
||||||
|
- 撤销分享
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 29
|
||||||
|
|
||||||
|
- 管理台前端第三批治理页继续补齐:
|
||||||
|
- `front/src/admin/tasks.tsx` 已从占位页升级为真实页面,现可读取:
|
||||||
|
- `GET /api/admin/tasks`
|
||||||
|
- `GET /api/admin/tasks/{taskId}`
|
||||||
|
- 新增 `front/src/lib/admin-tasks.ts`,封装任务列表与详情查询。
|
||||||
|
- 任务监控页当前已支持:
|
||||||
|
- 按用户、任务类型、状态、失败分类、租约状态筛选
|
||||||
|
- 显示任务总量、当前页数量、进行中数量、失败数量、已安排重试数量
|
||||||
|
- 展示任务状态、失败分类、租约状态、所属用户、重试与时间信息
|
||||||
|
- 右侧详情面板查看 `publicStateJson`、`errorMessage`、`correlationId`、租约与时间字段
|
||||||
|
- 分页切换任务结果
|
||||||
|
- 新增 `front/src/lib/admin-audits.ts` 与 `front/src/admin/audits.tsx`,管理台现已支持 `GET /api/admin/audits` 审计日志治理页。
|
||||||
|
- `front/src/App.tsx` 与 `front/src/admin/AdminLayout.tsx` 已补上 `/admin/audits` 路由与“审计日志”导航入口。
|
||||||
|
- 审计日志页当前已支持:
|
||||||
|
- 按操作者、动作类型、目标类型、目标 ID 筛选
|
||||||
|
- 展示操作者、权限、动作、目标、摘要、时间
|
||||||
|
- 行内展开 `detailsJson` 详情并复制原文
|
||||||
|
- 分页切换审计结果,并在空结果时显示明确空态
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 30
|
||||||
|
|
||||||
|
- 管理台前端第四批治理页继续补齐:
|
||||||
|
- `front/src/admin/users-list.tsx` 已补齐后端早已支持但前端未暴露的用户治理动作。
|
||||||
|
- 用户管理页当前除原有角色、存储配额、手动设密、封禁/恢复外,新增支持:
|
||||||
|
- 修改最大上传限制(调用 `PATCH /api/admin/users/{userId}/max-upload-size`)
|
||||||
|
- 生成临时密码(调用 `POST /api/admin/users/{userId}/password/reset`)
|
||||||
|
- 在用户行内展示临时密码结果块,并支持复制与关闭
|
||||||
|
- 手动设置新密码的文案已明确区分于“生成临时密码”,避免管理动作语义混淆
|
||||||
|
- `front/src/admin/oauthapps.tsx` 已从单行占位改为真实规划/状态页:
|
||||||
|
- 明确说明当前后端尚无 `/api/admin/oauth-apps` 管理接口
|
||||||
|
- 明确说明本页为什么暂不提供可写控件
|
||||||
|
- 给出后续 OAuth 应用登记、凭据管理、授权范围与审计追踪的规划能力
|
||||||
|
- 本页仍不调用任何不存在的 API,也不伪造表单/写操作
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 31
|
||||||
|
|
||||||
|
- 为便于实时联调,当前本地开发环境已同时拉起:
|
||||||
|
- 前端 Vite 开发服务:`http://127.0.0.1:3000`
|
||||||
|
- 后端 dev 服务:`http://127.0.0.1:8080`
|
||||||
|
- 本地 Redis:`127.0.0.1:6379`,并已用 `APP_REDIS_ENABLED=true` 重启后端接入
|
||||||
|
- 管理台前端第五批交互重构已落地:
|
||||||
|
- `front/src/admin/users-list.tsx` 已移除剩余 `window.prompt`,升级为“表格 + 右侧单用户编辑面板”
|
||||||
|
- 右侧面板当前可直接编辑:角色、存储配额、最大上传限制、手动设置密码
|
||||||
|
- 生成临时密码、复制临时密码、禁用/恢复账号仍保留在表格快捷操作内
|
||||||
|
- 手动设置密码与生成临时密码的语义已在 UI 中明确分开
|
||||||
|
- `front/src/admin/storage-policies-list.tsx` 已移除迁移任务入口里的 `window.prompt` / `window.alert`
|
||||||
|
- 发起迁移现在改为页面内弹层,支持:
|
||||||
|
- 从现有策略中选择目标策略
|
||||||
|
- 手动输入目标策略 ID
|
||||||
|
- 在页面内显示迁移任务创建成功/失败反馈
|
||||||
|
- 迁移能力仍然只负责创建后台任务,本页不负责展示任务执行进度
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend Refactor Batch 32
|
||||||
|
|
||||||
|
- 管理台信息架构已按“配置优先”方向完成第一轮重排,不再把后台首页定位为纯统计总览。
|
||||||
|
- `front/src/admin/dashboard.tsx` 已重写为新的“配置首页”:
|
||||||
|
- 首页现在以 `系统配置 / 存储配置 / 用户策略 / 治理工具` 为主轴
|
||||||
|
- 首屏强调当前可修改的配置入口,而不是单纯展示遥测卡片
|
||||||
|
- 保留邀请码、离线快传上限、总存储量、用户量等当前生效值摘要,作为配置上下文
|
||||||
|
- `front/src/admin/AdminLayout.tsx` 左侧导航已重排为四组:
|
||||||
|
- `配置控制台`
|
||||||
|
- `核心配置`
|
||||||
|
- `治理工具`
|
||||||
|
- `规划能力`
|
||||||
|
- 导航文案也已同步收口:
|
||||||
|
- `总览` -> `配置首页`
|
||||||
|
- `用户管理` -> `用户策略`
|
||||||
|
- `文件审计` -> `文件治理`
|
||||||
|
- `对象实体` -> `对象治理`
|
||||||
|
- `文件系统` -> `文件系统快照`
|
||||||
|
- `front/src/admin/users-list.tsx` 页面标题与面板说明已进一步对齐“用户策略”定位,不再强调只读名单视角。
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend OSS Refactor Batch 33
|
||||||
|
|
||||||
|
- 新增执行计划文件 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md`,用于按复选框推进前端 OSS 组件替换。
|
||||||
|
- 当前已完成计划的 Batch 1:使用 `react-hook-form` 替换管理台配置表单的手写状态管理。
|
||||||
|
- `front/package.json` / `front/package-lock.json` 已新增 `react-hook-form` 依赖。
|
||||||
|
- `front/src/admin/settings.tsx` 已改为 `react-hook-form` 驱动:
|
||||||
|
- 注册邀请码编辑改为表单驱动字段与校验
|
||||||
|
- 离线快传容量上限编辑改为表单驱动字段与校验
|
||||||
|
- 只读快照卡片与旋转邀请码动作仍保留原有交互
|
||||||
|
- `front/src/admin/users-list.tsx` 右侧“用户策略”编辑面板已改为 `react-hook-form` 驱动:
|
||||||
|
- `role`
|
||||||
|
- `storageQuotaBytes`
|
||||||
|
- `maxUploadSizeBytes`
|
||||||
|
- `manualPassword`
|
||||||
|
- 临时密码生成 / 复制、禁用 / 恢复、表格快捷操作均保持不变
|
||||||
|
- `front/src/admin/dashboard.tsx` 已顺手修复 `React.ReactNode` 命名空间类型问题,避免挡住前端全量类型检查
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|
||||||
|
## 2026-04-12 Frontend OSS Refactor Batch 34
|
||||||
|
|
||||||
|
- 前端 OSS 替换计划的 Batch 2 已完成第一轮表格层替换。
|
||||||
|
- `front/package.json` / `front/package-lock.json` 已新增 `@tanstack/react-table`。
|
||||||
|
- `front/src/admin/users-list.tsx` 已将用户策略列表从手写表格渲染切换为 TanStack Table:
|
||||||
|
- 列定义、header 渲染、row model 与 visible cell 渲染均改由 `@tanstack/react-table` 驱动
|
||||||
|
- 右侧用户策略编辑面板、临时密码操作、禁用/恢复动作保持不变
|
||||||
|
- `front/src/admin/storage-policies-list.tsx` 已将存储策略列表从手写表格渲染切换为 TanStack Table:
|
||||||
|
- 现有列、编辑按钮、启停按钮、迁移弹层触发均保持不变
|
||||||
|
- 新建/编辑策略弹层与迁移弹层逻辑未改
|
||||||
|
- 当前这批仅替换表格引擎,不新增排序、分页、筛选状态管理
|
||||||
|
- 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 35
|
||||||
|
|
||||||
|
- 前端 OSS 替换计划的 Batch 3 已完成,`分享治理` 页面已接入 `@tanstack/react-table`。
|
||||||
|
- `front/src/admin/shares.tsx` 已将手写 `<table>` 循环替换为:
|
||||||
|
- typed `ColumnDef`
|
||||||
|
- `useReactTable`
|
||||||
|
- `getCoreRowModel`
|
||||||
|
- `flexRender`
|
||||||
|
- 当前分享治理页的这些行为均保持不变:
|
||||||
|
- 筛选表单
|
||||||
|
- 统计计数
|
||||||
|
- 空状态
|
||||||
|
- 复制 Token
|
||||||
|
- 打开公开分享
|
||||||
|
- 删除分享
|
||||||
|
- 这批仍只替换表格引擎,不改后端接口,也不新增排序、分页或筛选状态管理
|
||||||
|
- OSS 替换执行计划 `docs/superpowers/plans/2026-04-12-admin-oss-refactor.md` 已继续打钩更新
|
||||||
|
- 本批前端验证通过:
|
||||||
|
- `cd front && npm run lint`
|
||||||
|
- `cd front && npm run build`
|
||||||
|
|||||||
Reference in New Issue
Block a user