From 820e055d22c1b266b58c41251a13bd10ef835061 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Sun, 12 Apr 2026 11:36:13 +0800 Subject: [PATCH] Implement coordinated frontend and backend updates --- docs/architecture.md | 1 - docs/frontend-component-guide.md | 304 ++++ docs/frontend-page-orchestration.md | 1399 +++++++++++++++++ .../plans/2026-04-12-admin-oss-refactor.md | 141 ++ .../plans/2026-04-12-frontend-docs.md | 335 ++++ front/package-lock.json | 51 + front/package.json | 2 + front/src/App.tsx | 2 + front/src/admin/AdminLayout.tsx | 111 +- front/src/admin/audits.tsx | 455 ++++++ front/src/admin/dashboard.tsx | 396 +++-- front/src/admin/fileblobs.tsx | 483 +++++- front/src/admin/filesystem.tsx | 328 +++- front/src/admin/oauthapps.tsx | 259 ++- front/src/admin/settings.tsx | 621 +++++++- front/src/admin/shares.tsx | 464 +++++- front/src/admin/storage-policies-list.tsx | 423 ++++- front/src/admin/tasks.tsx | 744 ++++++++- front/src/admin/users-list.tsx | 759 +++++++-- front/src/lib/admin-audits.ts | 47 + front/src/lib/admin-fileblobs.ts | 58 + front/src/lib/admin-filesystem.ts | 34 + front/src/lib/admin-settings.ts | 78 + front/src/lib/admin-shares.ts | 69 + front/src/lib/admin-tasks.ts | 68 + memory.md | 198 +++ 26 files changed, 7410 insertions(+), 420 deletions(-) create mode 100644 docs/frontend-component-guide.md create mode 100644 docs/frontend-page-orchestration.md create mode 100644 docs/superpowers/plans/2026-04-12-admin-oss-refactor.md create mode 100644 docs/superpowers/plans/2026-04-12-frontend-docs.md create mode 100644 front/src/admin/audits.tsx create mode 100644 front/src/lib/admin-audits.ts create mode 100644 front/src/lib/admin-fileblobs.ts create mode 100644 front/src/lib/admin-filesystem.ts create mode 100644 front/src/lib/admin-settings.ts create mode 100644 front/src/lib/admin-shares.ts create mode 100644 front/src/lib/admin-tasks.ts diff --git a/docs/architecture.md b/docs/architecture.md index 9e94484..b7991d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1161,4 +1161,3 @@ - 未拆开工作空间与内容资产前,不收口分享和快传 - 未收口上传与存储治理前,不把内容资产域视为稳定 - 未稳定后端领域边界前,不开始前端大规模域化重组 - diff --git a/docs/frontend-component-guide.md b/docs/frontend-component-guide.md new file mode 100644 index 0000000..57871bb --- /dev/null +++ b/docs/frontend-component-guide.md @@ -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 现有组件短板 + +- 表格几乎全是页面内手写 `` +- 文件树和文件详情面板仍是页面级拼装 +- 上传入口是“按钮 + 隐藏 input”模式,拖拽能力弱 +- 快传页面没有二维码、批量选择、可视化发送步骤 +- 媒体预览/播放器能力基本空缺 +- 管理台多个页面仍是占位页 + +--- + +## 4. 推荐引入的开源组件 + +以下推荐按“适合当前项目”优先,而不是单纯按星标排序。 + +### 4.1 `@tanstack/react-table` + +- GitHub: +- 适合替换: +- `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: +- 适合替换: + - 网盘文件列表 + - 管理台用户表 + - 管理台文件审计表 + - 任务列表与后续审计日志列表 +- 适配原因: + - 大列表性能提升明显 + - 与 `TanStack Table` 搭配自然 +- 替换边界: + - 只负责渲染性能 + - 不负责分页规则和数据源决策 + +### 4.3 `react-dropzone` + +- GitHub: +- 适合替换: + - `front/src/pages/files/FilesPage.tsx` 的上传文件入口 + - `front/src/transfer/pages/TransferPage.tsx` 的发送文件区域 +- 适配原因: + - 现有上传入口过于基础 + - 拖拽体验、选中文件反馈、目录导入体验都能更好 +- 替换边界: + - 只负责文件选择与拖拽 + - 上传策略仍由后端 `storage-governance` 决定 + +### 4.4 `react-arborist` + +- GitHub: +- 适合替换: + - `front/src/pages/files/FilesPage.tsx` 的左侧目录树 +- 适配原因: + - 当前目录树是递归按钮结构,功能够用但扩展性弱 + - 后续若需要键盘导航、虚拟化、拖拽移动、懒加载,`react-arborist` 很合适 +- 替换边界: + - 只负责树控件交互 + - 路径合法性、移动规则、同名冲突仍由后端判定 + +### 4.5 `dnd-kit` + +- GitHub: +- 适合替换: + - 文件移动的可视化拖拽 + - 上传队列排序 + - 后续管理台面板编排 +- 适配原因: + - 你当前项目前端自定义程度高,`dnd-kit` 比重型集成组件更灵活 +- 替换边界: + - 只负责拖拽行为 + - 最终 move/copy 仍走现有 API + +### 4.6 `react-resizable-panels` + +- GitHub: +- 适合替换: + - 网盘页面三栏布局:目录树 / 文件表 / 文件详情 + - 后续管理台多面板布局 +- 适配原因: + - 当前 `FilesPage` 三栏是固定宽度,不利于高密度工作流 + - 可调面板更符合“桌面工作台”气质 + +### 4.7 `recharts` + +- GitHub: +- 适合替换: + - `front/src/admin/dashboard.tsx` 的遥测图表区域 + - 后续请求趋势、活跃用户、存储分布图 +- 适配原因: + - 当前后台总览以统计卡为主,图表层很薄 + - `Recharts` 接入简单,适合当前 React 栈 +- 替换边界: + - 只负责可视化 + - 指标口径仍由后端 summary 接口定义 + +### 4.8 `vidstack` + +- GitHub: +- 适合替换: + - 后续文件预览页里的音视频播放器 + - 分享页的音视频预览层 +- 适配原因: + - 当前项目几乎没有完整播放器能力 + - `vidstack` 的 React 组合式设计很适合保留你的视觉系统 +- 替换边界: + - 只负责播放 UI 与控制条 + - 下载授权、预览授权、签名地址获取仍由业务接口负责 + +### 4.9 `hls.js` + +- GitHub: +- 适合替换: + - 后续若引入 `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` + +这组组合和当前项目的契合点在于: + +- 不会抢走业务规则所有权 +- 能显著改善工作台体验 +- 能让管理台和网盘页更专业 +- 不会把前端重构成另一个不可控的大系统 diff --git a/docs/frontend-page-orchestration.md b/docs/frontend-page-orchestration.md new file mode 100644 index 0000000..05ac99e --- /dev/null +++ b/docs/frontend-page-orchestration.md @@ -0,0 +1,1399 @@ +# 前端页面编排文档 + +## 1. 文档目标 + +这份文档按当前代码基线整理前端页面结构,回答四个问题: + +1. 现有哪些路由页面 +2. 每个页面由哪些组件/区域组成 +3. 每个页面有哪些按钮与主要交互 +4. 管理界面当前有哪些可操作设置项,哪些页面仍是占位页 + +本文对齐当前 `front/src` 实现,不虚构未落地页面。 + +--- + +## 2. 路由总表 + +### 2.1 公共页面 + +| 路由 | 页面组件 | 说明 | +| --- | --- | --- | +| `/login` | `front/src/pages/Login.tsx` | 登录/注册入口 | +| `/share/:token` | `front/src/pages/FileShare.tsx` | 公开分享访问页 | + +### 2.2 主应用页面 + +| 路由 | 页面组件 | 说明 | +| --- | --- | --- | +| `/overview` | `front/src/pages/Overview.tsx` | 用户概览页 | +| `/files` | `front/src/pages/files/FilesPage.tsx` | 网盘工作区 | +| `/tasks` | `front/src/pages/Tasks.tsx` | 异步任务页 | +| `/shares` | `front/src/pages/Shares.tsx` | 我的分享列表 | +| `/recycle-bin` | `front/src/pages/RecycleBin.tsx` | 回收站 | +| `/transfer` | `front/src/transfer/pages/TransferPage.tsx` | 快传 | + +### 2.3 管理台页面 + +| 路由 | 页面组件 | 当前状态 | +| --- | --- | --- | +| `/admin/dashboard` | `front/src/admin/dashboard.tsx` | 已实现 | +| `/admin/settings` | `front/src/admin/settings.tsx` | 占位页 | +| `/admin/filesystem` | `front/src/admin/filesystem.tsx` | 占位页 | +| `/admin/storage-policies` | `front/src/admin/storage-policies-list.tsx` | 已实现 | +| `/admin/users` | `front/src/admin/users-list.tsx` | 已实现 | +| `/admin/files` | `front/src/admin/files-list.tsx` | 已实现 | +| `/admin/file-blobs` | `front/src/admin/fileblobs.tsx` | 占位页 | +| `/admin/shares` | `front/src/admin/shares.tsx` | 占位页 | +| `/admin/tasks` | `front/src/admin/tasks.tsx` | 占位页 | +| `/admin/oauth-apps` | `front/src/admin/oauthapps.tsx` | 占位页 | + +补充说明: + +- 以上管理台路由仅桌面端可进入 +- 移动端访问 `/admin/*` 会被重定向到 `/overview` +- `front/src/mobile-components/MobileLayout.tsx` 也没有后台导航入口 + +--- + +## 3. 全局页面壳 + +### 3.1 桌面主壳 `Layout` + +文件:`front/src/components/layout/Layout.tsx` + +#### 区域 + +- 左侧主导航 +- 顶部品牌区 +- 当前账号信息区 +- 主内容区 `` +- 全局上传浮层 `UploadCenter` + +#### 左侧导航项 + +- `概览` +- `网盘` +- `任务` +- `分享` +- `回收站` +- `快传` +- `后台`:仅 `session.user.role === 'ADMIN'` 时显示 + +#### 全局按钮 + +- 主题切换按钮 +- 退出登录按钮 + +#### 全局辅助组件 + +- `TaskSummaryPanel` +- `UploadCenter` + +### 3.2 移动主壳 `MobileLayout` + +文件:`front/src/mobile-components/MobileLayout.tsx` + +#### 区域 + +- 顶部浮层 +- 中间页面区 `` +- 底部导航 +- 全局上传浮层 `UploadCenter` + +#### 顶部按钮 + +- 主题切换 +- 退出登录 + +#### 底部导航项 + +- `概览` +- `网盘` +- `任务` +- `分享` +- `回收站` +- `快传` + +### 3.3 管理台壳 `AdminLayout` + +文件:`front/src/admin/AdminLayout.tsx` + +#### 左侧二级导航项 + +- `总览` +- `系统设置` +- `文件系统` +- `存储策略` +- `用户管理` +- `文件审计` +- `对象实体` +- `分享管理` +- `任务监控` +- `三方应用` + +--- + +## 4. 全局共用浮层与摘要组件 + +### 4.1 `UploadCenter` + +文件:`front/src/components/upload/UploadCenter.tsx` + +#### 展示内容 + +- 上传任务总数 +- 每个任务的文件名、大小、状态、进度、错误信息 + +补充说明: + +- 组件内部虽然会计算上传中、成功、失败数量 +- 但当前 UI 并没有把这三个数字单独渲染成统计块 + +#### 按钮 + +- 展开/收起面板 +- 清理已完成任务 + +### 4.2 `TaskSummaryPanel` + +文件:`front/src/components/tasks/TaskSummaryPanel.tsx` + +#### 展示内容 + +- 当前活跃后台任务数 +- 首个活跃任务类型 + +#### 按钮 + +- 无独立操作按钮 +- 仅作为导航中的状态摘要组件 + +--- + +## 5. 公共页面编排 + +## 5.1 登录页 `/login` + +文件:`front/src/pages/Login.tsx` + +### 页面区域 + +- 右上角主题切换 +- 标题区:`云盘门户` +- 登录/注册卡片 +- 底部辅助操作区 + +### 表单模式 + +- 登录模式 +- 注册模式 + +### 登录模式组件 + +- 用户名输入框 +- 密码输入框 +- 登录按钮 + +### 注册模式组件 + +- 用户名输入框 +- 邮箱输入框 +- 手机号输入框 +- 邀请码输入框 +- 密码输入框 +- 确认密码输入框 +- 注册按钮 + +### 页面按钮 + +- `登录` +- `注册账号` +- `还没有账号?去注册` / `已有账号?去登录` +- `直接进入快传` +- `开发账号` +- `管理员账号` +- 主题切换按钮 + +### 主要跳转 + +- 登录成功后: + - 普通用户到 `/overview` + - 管理员到 `/admin/dashboard` +- 可匿名进入 `/transfer` + +## 5.2 分享访问页 `/share/:token` + +文件:`front/src/pages/FileShare.tsx` + +### 页面区域 + +- 分享标题区 +- 分享者与文件基础信息区 +- 错误提示区 +- 密码验证区或分享操作区 +- 底部版本戳 + +### 条件区域 + +- 若分享需要密码且未验证: + - 显示密码输入框 + - 显示验证密码按钮 +- 若已可访问: + - 显示创建时间 + - 显示有效期 + - 显示下载统计 + - 显示下载与导入操作 + +### 页面按钮 + +- `验证密码` +- `下载文件` +- `导入网盘` + +### 细节说明 + +- 未登录点击 `导入网盘` 会跳到 `/login` +- 下载走 `buildShareDownloadUrl` +- 导入时使用 `window.prompt` 让用户输入目标目录 + +--- + +## 6. 主应用页面编排 + +## 6.1 概览页 `/overview` + +文件:`front/src/pages/Overview.tsx` + +### 页面区域 + +- 页面标题区 +- 三张状态卡 +- 快捷入口卡片区 +- 最近文件面板 +- 最近任务面板 + +### 状态卡 + +- `账号权限` +- `存储配额` +- `上传上限` + +### 快捷入口卡片 + +- `网盘` +- `任务` +- `分享` +- `快传` + +### 最近文件面板内容 + +- 文件名 +- 文件路径 +- 文件大小 +- 创建时间 + +### 最近任务面板内容 + +- 任务类型 +- 任务状态 +- 更新时间 + +### 页面按钮 + +- 快捷入口卡片本身可点击跳转 + +## 6.2 网盘页 `/files` + +文件:`front/src/pages/files/FilesPage.tsx` + +### 页面整体结构 + +- 左侧目录树栏 +- 中间文件列表区 +- 右侧文件详情栏 + +### 左侧目录树栏 + +#### 组件 + +- 根目录按钮 +- 递归目录节点按钮 +- 回收站入口按钮 +- 目录树刷新状态点 + +#### 按钮 + +- `根目录` +- 每个目录节点按钮 +- `回收站` + +### 顶部工具区 + +#### 组件 + +- 面包屑路径导航 +- 文件搜索框 +- 工具按钮组 + +#### 按钮 + +- 面包屑各层级按钮 +- `上传文件` +- `新建文件夹` +- `刷新` + +### 文件列表表格 + +#### 列 + +- 名称 +- 路径 +- 大小 +- 创建时间 + +#### 行交互 + +- 单击:选中文件 +- 双击目录:进入目录 + +### 右侧文件详情栏 + +仅当存在 `selectedFile` 时显示。 + +#### 信息区 + +- 文件名 +- 路径 +- 类型 +- 大小 + +#### 操作按钮 + +- `下载` +- `创建分享` +- `重命名` +- `移动` +- `删除` + +#### 实际未暴露按钮 + +- 文件页虽然导入了 `copyFile` +- 当前 UI 没有“复制”按钮 + +### 用户输入方式 + +- 上传:隐藏 `input[type=file]` +- 新建文件夹:`window.prompt` +- 重命名:`window.prompt` +- 移动:`window.prompt` +- 删除:`window.confirm` + +## 6.3 任务页 `/tasks` + +文件:`front/src/pages/Tasks.tsx` + +### 页面区域 + +- 标题区 +- 顶部控制区 +- 任务表格 + +### 顶部控制 + +- 自动刷新复选框 +- 刷新按钮 + +### 任务表格列 + +- 类型 +- 对象 +- 状态 +- 进度 +- 更新时间 +- 操作 + +### 操作按钮 + +- `刷新` +- `重试任务`:仅 `FAILED` +- `取消任务`:仅 `QUEUED` 或 `RUNNING` +- `自动刷新` 开关 + +### 状态展示内容 + +- 业务状态标签 +- `phase` +- `errorMessage` +- 进度条 + +## 6.4 分享页 `/shares` + +文件:`front/src/pages/Shares.tsx` + +### 页面区域 + +- 标题区 +- 分享表格 + +### 表格列 + +- 分享名称 +- 权限 +- 过期时间 +- 统计 +- 操作 + +### 权限标签 + +- `需密码` +- `可下载` / `仅查看` +- `可导入` / `受保护` + +### 统计内容 + +- 下载次数 `DL` +- 查看次数 `VW` + +### 操作按钮 + +- `打开链接` +- `删除分享` + +## 6.5 回收站页 `/recycle-bin` + +文件:`front/src/pages/RecycleBin.tsx` + +### 页面区域 + +- 标题区 +- 回收站表格 + +### 表格列 + +- 文件 +- 原路径 +- 大小 +- 删除时间 +- 过期时间 +- 操作 + +### 操作按钮 + +- `恢复` + +## 6.6 快传页 `/transfer` + +文件:`front/src/transfer/pages/TransferPage.tsx` + +### 页面顶部 Tab + +- `发送` +- `接收` +- `记录` + +### 路由级深链行为 + +- 当 URL 中带 `?code=XXXXXX` 时 +- 页面会自动切到 `接收` Tab +- 并自动执行一次取件码查询 + +### A. 发送 Tab + +#### 左侧:传输配置区 + +- 模式切换按钮 + - `离线快传` + - `在线快传` +- 文件选择拖拽区 +- 已选文件列表 +- 错误提示 +- 成功提示 +- `创建会话` 按钮 + +#### 右侧:会话信息区 + +创建会话后显示: + +- 取件码 +- 模式 +- 过期时间 +- 上传进度 + +#### 右侧按钮 + +- `复制取件码` +- `复制链接` + +### B. 接收 Tab + +#### 左侧:接收会话区 + +- 取件码输入框 +- `查询取件码` +- 会话查询结果卡 +- `加入会话` + +#### 右侧:文件列表区 + +离线快传加入成功后: + +- 文件名 +- 相对路径 +- 文件大小 + +#### 文件级按钮 + +- `下载` +- `导入网盘`:仅登录用户显示 + +### C. 记录 Tab + +#### 页面区域 + +- 标题 +- 刷新按钮 +- 历史记录卡片列表 + +#### 每条记录内容 + +- 取件码 +- 过期时间 +- 文件数 +- 文件卡片网格 + +#### 会话级按钮 + +- `复制取件码` +- `复制链接` + +#### 文件级按钮 + +- `下载` +- `导入网盘` + +#### 特殊状态 + +- 未登录时仅显示“登录后可查看记录” + +--- + +## 7. 管理台页面编排 + +本节分成两层: + +- 当前前端已经落地的界面与按钮 +- 依据现有后端 `/api/admin/**` 能力,管理台应补齐的完整设置项与筛选项 + +这样文档既能反映真实代码,也能指导后续把占位页补完整。 + +## 7.1 后台总览 `/admin/dashboard` + +文件:`front/src/admin/dashboard.tsx` + +### 页面区域 + +- 标题区 +- 核心指标卡区 +- 快捷入口区 +- 运行概览区 + +### 核心指标卡 + +- `用户总数` +- `文件总数` +- `存储容量` +- `快传占用` + +### 快捷入口 + +- `用户管理` +- `文件审计` +- `存储策略` + +### 运行概览内容 + +- 服务健康状态 +- 邀请码展示 +- 下载流量 +- 请求量 + +### 页面按钮 + +- `刷新状态` +- `复制邀请码` +- 三个快捷入口卡片按钮 + +### 当前可操作设置项 + +- 无真正表单设置项 +- 只有“复制邀请码”和跳转入口 + +## 7.2 系统设置 `/admin/settings` + +文件:`front/src/admin/settings.tsx` + +### 当前状态 + +- 占位页 +- 仅显示标题 `Admin Settings` + +### 当前组件 + +- 页面容器 +- 标题 + +### 当前按钮 + +- 无 + +### 后端已支持、前端未实现的设置分组 + +根据 `GET /api/admin/settings`,本页应至少拆成以下分组: + +#### A. 站点能力分组 `site` + +- `supported` +- `writeSupported` + +说明: + +- 当前后端返回只读快照 +- 可作为“站点设置能力状态卡”,不一定需要编辑表单 + +#### B. 注册设置分组 `registration` + +- `inviteCodeRequired` +- `currentInviteCode` +- `managementRoles` +- `writeSupported` + +#### C. 用户会话分组 `userSession` + +- `accessExpirationSeconds` +- `refreshExpirationSeconds` +- `tokenBlacklistEnabled` +- `tokenBlacklistTtlBufferSeconds` +- `writeSupported` + +#### D. 快传设置分组 `transfer` + +- `offlineTransferStorageLimitBytes` +- `writeSupported` + +#### E. 媒体处理分组 `mediaProcessing` + +- `metadataExtractionEnabled` +- `thumbnailGenerationEnabled` +- `videoPosterEnabled` +- `writeSupported` + +#### F. 队列分组 `queue` + +- `backend` +- `mediaMetadataFixedDelayMs` +- `mediaMetadataInitialDelayMs` +- `writeSupported` + +#### G. 外观分组 `appearance` + +- `supported` +- `writeSupported` + +#### H. 服务器分组 `server` + +- `storageProvider` +- `redisEnabled` +- `writeSupported` + +### 后端已支持、前端未实现的可编辑设置项 + +根据当前后端可写接口,本页至少应提供: + +- `当前邀请码` 编辑 +- `邀请码轮换` +- `离线快传总容量上限` 编辑 + +### 未来应有按钮(当前前端未实现) + +- `保存邀请码` +- `轮换邀请码` +- `保存离线快传容量上限` +- `刷新设置` + +### 未来应有只读状态卡(当前前端未实现) + +- Access Token 过期时间 +- Refresh Token 过期时间 +- Token 黑名单状态 +- 媒体元数据提取开关状态 +- 缩略图生成状态 +- 视频封面状态 +- 队列后端类型 +- Redis 启用状态 +- 当前存储提供者 + +## 7.3 文件系统 `/admin/filesystem` + +文件:`front/src/admin/filesystem.tsx` + +### 当前状态 + +- 占位页 + +### 当前按钮 + +- 无 + +### 后端已支持、前端未实现的页面分组 + +根据 `GET /api/admin/filesystem`,本页应至少包含: + +#### A. 总览分组 `overview` + +- `storageProvider` +- `totalFiles` +- `totalBlobs` +- `totalEntities` + +#### B. 默认策略分组 `defaultPolicy` + +- 默认策略名称 +- 类型 +- 桶名称 +- 端点 +- 地域 +- 私有桶状态 +- 前缀 +- 凭证模式 +- 最大对象大小 +- 能力集合 +- 启用状态 +- 是否默认策略 + +#### C. 上传能力分组 `upload` + +- `proxyUpload` +- `directSingleUpload` +- `directMultipartUpload` +- `effectiveMaxFileSizeBytes` + +#### D. 媒体处理分组 `mediaProcessing` + +- `metadataExtractionEnabled` +- `nativeThumbnailSupport` + +#### E. 缓存分组 `cache` + +- `backend` +- `filesListTtlSeconds` +- `directoryVersionTtlSeconds` + +#### F. WebDAV 分组 `webdav` + +- `enabled` + +### 未来应有按钮(当前前端未实现) + +- `刷新文件系统状态` +- `跳转默认存储策略` +- `查看对象实体` + +### 建议交互(当前前端未实现) + +- 文件总数 / Blob 总数 / 实体总数指标卡 +- 当前上传模式矩阵卡 +- 默认策略详情抽屉 +- 缓存与 WebDAV 状态卡 + +## 7.4 存储策略 `/admin/storage-policies` + +文件:`front/src/admin/storage-policies-list.tsx` + +这是当前管理台里设置项最完整的页面之一。 + +### 页面区域 + +- 标题区 +- 顶部操作区 +- 策略表格 +- 新建/编辑策略弹窗 + +### 顶部按钮 + +- `刷新` +- `新建策略` + +### 策略表格列 + +- 名称 +- 后端类型 +- 访问端点 +- 状态 +- 对象上限 +- 操作 + +### 每行操作按钮 + +- `编辑策略` +- `停用` / `启用` + - 默认策略不显示此按钮 +- `发起迁移` + +### 弹窗表单字段 + +#### 基础字段 + +- `策略名称` +- `驱动协议` + - `LOCAL` + - `S3_COMPATIBLE` +- `端点地址` +- `桶名称` +- `地域` +- `前缀` +- `凭证模式` +- `对象大小上限(字节)` + +#### 布尔设置项 + +- `私有桶` `privateBucket` +- `启用` `enabled` +- `直传` `capabilities.directUpload` +- `分片上传` `capabilities.multipartUpload` +- `签名下载` `capabilities.signedDownloadUrl` +- `代理下载` `capabilities.serverProxyDownload` +- `原生缩略图` `capabilities.thumbnailNative` +- `友好下载名` `capabilities.friendlyDownloadName` +- `需要 CORS` `capabilities.requiresCors` +- `内网端点` `capabilities.supportsInternalEndpoint` + +### 弹窗按钮 + +- `取消` +- `保存` + +### 当前前端未完全暴露的问题 + +当前弹窗只暴露了基础字段和部分布尔能力,尚未把以下字段做成独立输入控件: + +- `region` +- `prefix` +- `credentialMode` +- `capabilities.thumbnailNative` +- `capabilities.friendlyDownloadName` + +因此本页需要补成“完整策略表单”,否则管理台与后端能力不一致。 + +## 7.5 用户管理 `/admin/users` + +文件:`front/src/admin/users-list.tsx` + +### 页面区域 + +- 标题区 +- 刷新按钮 +- 搜索框 +- 用户表格 + +### 搜索能力 + +- 搜索用户名 +- 搜索邮箱 +- 搜索手机号 + +### 页面按钮 + +- `刷新列表` + +### 表格列 + +- 用户信息 +- 角色 +- 状态 +- 资源配额 +- 注册时间 +- 操作 + +### 每个用户的可调设置项 + +- `角色` + - 通过 prompt 输入 `USER` 或 `ADMIN` +- `存储配额` + - 通过 prompt 输入字节数 +- `最大上传大小` + - 后端已支持,前端未暴露 +- `密码` + - 通过 prompt 直接设置新密码 +- `重置密码` + - 后端支持返回临时密码,前端未暴露 +- `账号状态` + - 禁用 / 恢复 + +### 每行按钮 + +- `修改角色` +- `修改配额` +- `重置密码` + - 实际行为是“直接设置新密码” +- `禁用账号` / `恢复账号` + +### 当前前端未暴露但 API 已存在的管理项 + +根据 `front/src/lib/admin-users.ts`,存在但本页没有按钮: + +- `updateUserMaxUploadSize` +- `resetUserPassword` + +也就是说,当前用户管理页尚未把“上传上限”和“自动生成临时密码”暴露出来。 + +### 本页应补齐的按钮 + +- `修改上传上限` +- `生成临时密码` +- `复制临时密码` + +## 7.6 文件审计 `/admin/files` + +文件:`front/src/admin/files-list.tsx` + +### 页面区域 + +- 标题区 +- 刷新按钮 +- 两个搜索框 +- 文件表格 + +### 搜索项 + +- 文件名 / 路径搜索 +- 所属用户搜索 + +### 页面按钮 + +- `刷新索引` + +### 表格列 + +- 文件 +- 所属用户 +- 大小 +- 创建时间 +- 操作 + +### 每行按钮 + +- `彻底删除` + +### 注意 + +- 当前删除确认文案写的是“物理擦除 / 硬件级销毁” +- 但前端文案夸张,建议后续改成真实业务语义 + +## 7.7 对象实体 `/admin/file-blobs` + +文件:`front/src/admin/fileblobs.tsx` + +### 当前状态 + +- 占位页 + +### 当前按钮 + +- 无 + +### 后端已支持、前端未实现的筛选项 + +根据 `GET /api/admin/file-blobs`,本页应支持: + +- `userQuery` +- `storagePolicyId` +- `objectKey` +- `entityType` + +### `entityType` 选项 + +- `VERSION` +- `THUMBNAIL` +- `LIVE_PHOTO` +- `TRANSCODE` +- `AVATAR` + +### 后端已支持、前端未实现的表格列 + +- 实体 ID `entityId` +- Blob ID `blobId` +- 对象键 `objectKey` +- 实体类型 `entityType` +- 存储策略 ID `storagePolicyId` +- 大小 `size` +- 内容类型 `contentType` +- 引用计数 `referenceCount` +- 关联逻辑文件数 `linkedStoredFileCount` +- 关联所有者数 `linkedOwnerCount` +- 示例所有者用户名 `sampleOwnerUsername` +- 示例所有者邮箱 `sampleOwnerEmail` +- 创建人 ID `createdByUserId` +- 创建人用户名 `createdByUsername` +- 实体创建时间 `createdAt` +- Blob 创建时间 `blobCreatedAt` +- `blobMissing` +- `orphanRisk` +- `referenceMismatch` + +### 未来应有按钮(当前前端未实现) + +- `刷新对象实体` +- `清空筛选` +- `按对象键复制` +- `跳转存储策略` + +### 未来应有风险标签(当前前端未实现) + +- `Blob 缺失` +- `孤儿风险` +- `引用不一致` + +## 7.8 分享管理 `/admin/shares` + +文件:`front/src/admin/shares.tsx` + +### 当前状态 + +- 占位页 + +### 当前按钮 + +- 无 + +### 后端已支持、前端未实现的筛选项 + +根据 `GET /api/admin/shares`,本页应支持: + +- `userQuery` +- `fileName` +- `token` +- `passwordProtected` +- `expired` + +### 后端已支持、前端未实现的表格列 + +- 分享 ID +- Token +- 分享名称 +- 是否密码保护 +- 是否已过期 +- 创建时间 +- 过期时间 +- 最大下载次数 +- 已下载次数 +- 已查看次数 +- 是否允许导入 +- 是否允许下载 +- 所有者 ID +- 所有者用户名 +- 所有者邮箱 +- 文件 ID +- 文件名 +- 文件路径 +- 文件内容类型 +- 文件大小 +- 是否目录 + +### 未来应有按钮(当前前端未实现) + +- `刷新分享列表` +- `删除分享` +- `复制分享 Token` +- `打开分享链接` +- `按所有者筛选` +- `按文件名筛选` + +### 核心治理动作(当前前端未实现) + +- 撤销分享 +- 快速定位滥用链接 +- 筛选密码保护分享 +- 筛选过期分享 + +## 7.9 任务监控 `/admin/tasks` + +文件:`front/src/admin/tasks.tsx` + +### 当前状态 + +- 占位页 + +### 当前按钮 + +- 无 + +### 后端已支持、前端未实现的筛选项 + +根据 `GET /api/admin/tasks`,本页应支持: + +- `userQuery` +- `type` +- `status` +- `failureCategory` +- `leaseState` + +### 后端已支持、前端未实现的表格列 + +- 任务 ID +- 任务类型 +- 任务状态 +- 用户 ID +- 所有者用户名 +- 所有者邮箱 +- `publicStateJson` +- `correlationId` +- `errorMessage` +- `attemptCount` +- `maxAttempts` +- `nextRunAt` +- `leaseOwner` +- `leaseExpiresAt` +- `heartbeatAt` +- `createdAt` +- `updatedAt` +- `finishedAt` +- `failureCategory` +- `retryScheduled` +- `workerOwner` +- `leaseState` + +### 未来应有按钮(当前前端未实现) + +- `刷新任务监控` +- `查看详情` +- `复制 correlationId` +- `按失败类别筛选` +- `按租约状态筛选` + +### 未来应有详情面板(当前前端未实现) + +- 任务公共状态 JSON 展开区 +- 执行元信息区 +- 重试预算区 +- 租约与心跳状态区 + +### 注意 + +- 管理台任务监控是“全站视角” +- 不等同于普通用户 `/tasks` 页面 + +## 7.10 三方应用 `/admin/oauth-apps` + +文件:`front/src/admin/oauthapps.tsx` + +### 当前状态 + +- 占位页 + +### 当前按钮 + +- 无 + +### 当前设置项 + +- 当前后端管理接口里未看到对应 `/api/admin/oauth-apps` 能力 +- 因此前端现在不应凭空设计可写设置项 + +### 本页建议定位 + +- 先保留为规划页 +- 等后端 OAuth 管理接口落地后再补充 + +## 7.11 审计日志(后端已支持,前端未挂路由) + +当前 `App.tsx` 没有 `/admin/audits` 路由,但后端已经提供 `GET /api/admin/audits`。 + +### 后端已支持、前端未实现的筛选项 + +- `actorQuery` +- `actionType` +- `targetType` +- `targetId` + +### 后端已支持、前端未实现的表格列 + +- 审计日志 ID +- 操作者用户 ID +- 操作者用户名 +- 操作者权限快照 +- 动作类型 +- 目标类型 +- 目标 ID +- 摘要 +- `detailsJson` +- 创建时间 + +### 未来应有按钮(当前前端未实现) + +- `刷新审计日志` +- `复制详情 JSON` +- `按动作类型筛选` +- `按目标类型筛选` + +--- + +## 8. 管理台设置项总清单 + +为了便于单独查看,这里把“当前前端实际可操作的管理设置项”重新汇总一次。 + +### 已落地设置项 + +#### 后台总览 + +- 复制邀请码 + +#### 存储策略 + +- 新建策略 +- 编辑策略 +- 启用策略 +- 停用策略 +- 发起策略迁移 +- 设置策略名称 +- 设置驱动协议 +- 设置端点地址 +- 设置桶名称 +- 设置对象大小上限 +- 设置私有桶 +- 设置启用状态 +- 设置是否支持直传 +- 设置是否支持分片上传 +- 设置是否支持签名下载 +- 设置是否支持代理下载 +- 设置是否需要 CORS +- 设置是否支持内网端点 + +#### 用户管理 + +- 修改用户角色 +- 修改用户存储配额 +- 修改用户密码 +- 禁用用户 +- 恢复用户 + +#### 文件审计 + +- 按文件名/路径筛选 +- 按所属用户筛选 +- 彻底删除文件 + +### 应补齐但前端未落地的设置项 + +#### 系统设置 + +- 当前邀请码编辑 +- 邀请码轮换 +- 离线快传总容量上限编辑 +- 会话时长与黑名单状态查看 +- 媒体处理状态查看 +- 队列后端与延迟参数查看 +- Redis 与存储提供者状态查看 + +#### 文件系统 + +- 文件总量 / Blob 总量 / 实体总量查看 +- 默认策略详情查看 +- 上传能力矩阵查看 +- 缓存 TTL 查看 +- WebDAV 状态查看 + +#### 对象实体 + +- 按用户筛选 +- 按存储策略筛选 +- 按对象键筛选 +- 按实体类型筛选 +- 风险标签巡检 + +#### 分享管理 + +- 按所有者筛选 +- 按文件名筛选 +- 按 Token 筛选 +- 按是否密码保护筛选 +- 按是否过期筛选 +- 撤销分享 + +#### 任务监控 + +- 按用户筛选 +- 按任务类型筛选 +- 按任务状态筛选 +- 按失败类别筛选 +- 按租约状态筛选 +- 查看详情 JSON + +#### 审计日志 + +- 按操作者筛选 +- 按动作类型筛选 +- 按目标类型筛选 +- 按目标 ID 筛选 + +### 暂无后端能力支撑的规划页 + +- 三方应用 + +--- + +## 9. 当前页面编排上的主要缺口 + +### 9.1 管理台 + +- 只有一半左右页面真正实现 +- `settings/filesystem/file-blobs/shares/tasks/oauth-apps` 仍是占位页 +- 用户管理缺少“上传上限”与“自动生成临时密码” +- 存储策略页未暴露全部策略字段 + +### 9.2 用户工作台 + +- 网盘页缺少“复制”操作按钮 +- 网盘页缺少图片/视频/音频预览页 +- 快传页缺少二维码、发送进度分文件视图、在线直连流程可视化 + +### 9.3 移动端 + +- 当前移动端复用主页面路由 +- 但管理台被直接重定向走开,没有单独移动后台方案 +- 移动端底部导航也没有后台入口 + +--- + +## 10. 结论 + +当前前端已经具备完整的: + +- 登录 +- 概览 +- 网盘 +- 分享 +- 回收站 +- 快传 +- 基础任务页 +- 部分管理能力 + +但管理台目前仍是“半成品治理台”: + +- `dashboard` +- `storage-policies` +- `users` +- `files` + +这四页是当前真正可用的后台主线; + +同时,后端管理能力已经明显超过当前前端管理台: + +- `settings` +- `filesystem` +- `file-blobs` +- `shares` +- `tasks` +- `audits` + +这些都已经有明确的查询或写入接口,因此后续管理台补齐时,不应该从零猜需求,而应直接以这里列出的字段、筛选项和按钮为基线实现。 diff --git a/docs/superpowers/plans/2026-04-12-admin-oss-refactor.md b/docs/superpowers/plans/2026-04-12-admin-oss-refactor.md new file mode 100644 index 0000000..bb87bac --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-admin-oss-refactor.md @@ -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. diff --git a/docs/superpowers/plans/2026-04-12-frontend-docs.md b/docs/superpowers/plans/2026-04-12-frontend-docs.md new file mode 100644 index 0000000..1561d7b --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-frontend-docs.md @@ -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" +``` diff --git a/front/package-lock.json b/front/package-lock.json index db11056..1cb9466 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@google/genai": "^1.29.0", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^5.0.4", "clsx": "^2.1.1", "dotenv": "^17.2.3", @@ -18,6 +19,7 @@ "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.72.1", "react-router-dom": "^7.14.0", "tailwind-merge": "^3.5.0", "vite": "^6.2.0" @@ -1431,6 +1433,39 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3322,6 +3357,22 @@ "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": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/front/package.json b/front/package.json index ca2b595..7d8c6a9 100644 --- a/front/package.json +++ b/front/package.json @@ -13,6 +13,7 @@ "dependencies": { "@google/genai": "^1.29.0", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^5.0.4", "clsx": "^2.1.1", "dotenv": "^17.2.3", @@ -21,6 +22,7 @@ "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.72.1", "react-router-dom": "^7.14.0", "tailwind-merge": "^3.5.0", "vite": "^6.2.0" diff --git a/front/src/App.tsx b/front/src/App.tsx index b80d3bc..c39e328 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -23,6 +23,7 @@ const AdminFilesList = lazy(() => import('./admin/files-list')); const AdminFileBlobs = lazy(() => import('./admin/fileblobs')); const AdminShares = lazy(() => import('./admin/shares')); const AdminTasks = lazy(() => import('./admin/tasks')); +const AdminAudits = lazy(() => import('./admin/audits')); const AdminOAuthApps = lazy(() => import('./admin/oauthapps')); function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { @@ -55,6 +56,7 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { } /> } /> } /> + } /> } /> diff --git a/front/src/admin/AdminLayout.tsx b/front/src/admin/AdminLayout.tsx index 7a74433..52c5d1b 100644 --- a/front/src/admin/AdminLayout.tsx +++ b/front/src/admin/AdminLayout.tsx @@ -1,65 +1,88 @@ import { NavLink, Outlet } from 'react-router-dom'; -import { - Activity, - Database, - FileBox, - Files, - HardDrive, - Key, - LayoutDashboard, - ListTodo, - Settings, - Share2, - Users +import { + Activity, + Database, + FileBox, + Files, + HardDrive, + Key, + LayoutDashboard, + ListTodo, + Settings, + Share2, + Users, } from 'lucide-react'; import { cn } from '@/src/lib/utils'; import { motion } from 'motion/react'; export default function AdminLayout() { - const adminNavItems = [ - { to: 'dashboard', icon: LayoutDashboard, label: '总览' }, - { to: 'settings', icon: Settings, label: '系统设置' }, - { to: 'filesystem', icon: HardDrive, label: '文件系统' }, - { to: 'storage-policies', icon: Database, label: '存储策略' }, - { to: 'users', icon: Users, label: '用户管理' }, - { to: 'files', icon: Files, label: '文件审计' }, - { to: 'file-blobs', icon: FileBox, label: '对象实体' }, - { to: 'shares', icon: Share2, label: '分享管理' }, - { to: 'tasks', icon: ListTodo, label: '任务监控' }, - { to: 'oauth-apps', icon: Key, label: '三方应用' }, + const adminNavSections = [ + { + title: '配置控制台', + items: [{ to: 'dashboard', icon: LayoutDashboard, label: '配置首页' }], + }, + { + title: '核心配置', + items: [ + { to: 'settings', icon: Settings, label: '系统设置' }, + { to: 'storage-policies', icon: Database, 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 (
- {/* Admin Secondary Sidebar */} - {/* Admin Content Area */}
- +

{title}

+

{subtitle}

+
+ ); +} + +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 ( + + {value || '-'} + + ); +} + +function targetPill(type: string, targetId: string | null) { + return ( +
+ + {type || '-'} + + {targetId || '-'} +
+ ); +} + +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>(() => 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 ( + +
+
+

审计日志

+

+ `GET /api/admin/audits` / 操作者 / 动作 / 目标 / 详情展开 +

+
+ +
+ +
{ + event.preventDefault(); + void loadAudits(0, filters); + }} + className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl" + > + {titleBlock('筛选器', '只使用后端支持的查询参数,避免前端侧再做任何推断')} +
+ + + + +
+ +
+
+ {activeFilterLabels.length ? ( + activeFilterLabels.map((label) => ( + + {label} + + )) + ) : ( + 当前没有启用筛选条件 + )} +
+
+ + +
+
+ + + {error ? ( +
+ {error} +
+ ) : null} + +
+ 共 {page?.total ?? 0} 条审计记录 + 当前页 {items.length} 条 + {page ? `第 ${page.page + 1} 页 / 每页 ${page.size}` : '第 - 页'} +
+ +
+ {isInitialLoading ? ( +
+ 正在读取审计日志... +
+ ) : ( +
+
+
+ + + + + + + + + + + + {items.map((audit) => { + const authorities = normalizeAuthorities(audit.actorAuthorities); + const expanded = expandedAuditIds.has(audit.id); + + return ( + + + + + + + + + + {expanded ? ( + + + + ) : null} + + ); + })} + {items.length === 0 ? ( + + + + ) : null} + +
时间操作者动作目标摘要详情
+
{formatDateTime(audit.createdAt)}
+
ID {audit.id}
+
+
{audit.actorUsername || '系统 / 未知'}
+
+ {audit.actorUserId != null ? `user #${audit.actorUserId}` : '无用户 ID'} +
+
+ {authorities.length ? ( + authorities.map((authority) => ( + + {authority} + + )) + ) : ( + + 无权限信息 + + )} +
+
{actionPill(audit.actionType)}{targetPill(audit.targetType, audit.targetId)} +
{audit.summary || '-'}
+
+ +
+
+
+
+

详情内容

+

`detailsJson` 原文与格式化预览

+
+ +
+
+
+
基础信息
+
+
+ Summary + {audit.summary || '-'} +
+
+ Action + {audit.actionType || '-'} +
+
+ Target + {audit.targetType || '-'} {audit.targetId ? `#${audit.targetId}` : ''} +
+
+ Created + {formatDateTime(audit.createdAt)} +
+
+
+
+
JSON 预览
+
+                                      {formatDetailsJson(audit.detailsJson)}
+                                    
+
+
+
+
+ 当前筛选条件下没有审计记录 +
+ + + )} + + +
+
+ {page ? `第 ${page.page + 1} 页 / 每页 ${page.size}` : '尚未加载分页信息'} +
+
+ + +
+
+ + ); +} diff --git a/front/src/admin/dashboard.tsx b/front/src/admin/dashboard.tsx index cf4f68f..f49d35b 100644 --- a/front/src/admin/dashboard.tsx +++ b/front/src/admin/dashboard.tsx @@ -1,8 +1,20 @@ -import { useEffect, useState } from 'react'; -import { Copy, Database, HardDrive, RefreshCw, Send, Users, ChevronRight, Activity } from 'lucide-react'; -import { cn } from '@/src/lib/utils'; +import { useEffect, useState, type ReactNode } from 'react'; +import { + Activity, + ArrowRight, + Copy, + Database, + HardDrive, + RefreshCw, + Send, + Settings, + Share2, + Shield, + Users, +} from 'lucide-react'; import { Link } from 'react-router-dom'; import { motion } from 'motion/react'; +import { cn } from '@/src/lib/utils'; import { getAdminSummary, type AdminSummary } from '@/src/lib/admin'; import { formatBytes } from '@/src/lib/format'; @@ -11,27 +23,107 @@ const container = { show: { opacity: 1, transition: { - staggerChildren: 0.05 - } - } + staggerChildren: 0.05, + }, + }, }; const itemVariants = { - hidden: { y: 20, opacity: 0 }, - show: { y: 0, opacity: 1 } + hidden: { y: 16, opacity: 0 }, + 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 ( + +
+
{icon}
+ +
+

{title}

+

{description}

+
+ {highlights.map((item) => ( + + {item} + + ))} +
+ + ); +} + +function ToolCard({ + to, + icon, + title, + description, +}: { + to: string; + icon: ReactNode; + title: string; + description: string; +}) { + return ( + +
+
+
+ {icon} +
+
+
{title}
+
{description}
+
+
+ +
+ + ); +} + export default function AdminDashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [summary, setSummary] = useState(null); + const [copiedInviteCode, setCopiedInviteCode] = useState(false); async function loadSummary() { setError(''); try { setSummary(await getAdminSummary()); } catch (err) { - setError(err instanceof Error ? err.message : '加载后台总览失败'); + setError(err instanceof Error ? err.message : '加载配置首页失败'); } finally { setLoading(false); } @@ -41,17 +133,29 @@ export default function AdminDashboard() { void loadSummary(); }, []); + async function copyInviteCode(inviteCode: string) { + try { + await navigator.clipboard.writeText(inviteCode); + setCopiedInviteCode(true); + window.setTimeout(() => setCopiedInviteCode(false), 1500); + } catch { + setError('复制邀请码失败,请手动复制。'); + } + } + return ( - -
+
-

后台指挥中心

-

全局基础设施 / 系统遥测

+

配置首页

+

+ 系统配置 / 存储配置 / 用户策略 / 治理工具 +

- {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null} {loading && !summary ? ( -
正在查询核心服务...
+
+ 正在读取配置首页... +
) : summary ? ( - -
- -
- -
-

{summary.totalUsers}

-

用户总数

-
- - -
- -
-

{summary.totalFiles}

-

文件总数

-
- - -
- -
-

{formatBytes(summary.totalStorageBytes).split(' ')[0]}{formatBytes(summary.totalStorageBytes).split(' ')[1]}

-

存储容量

-
- - -
- -
-

{formatBytes(summary.offlineTransferStorageBytes).split(' ')[0]}{formatBytes(summary.offlineTransferStorageBytes).split(' ')[1]}

-

快传占用

-
-
- -
- -
-

快捷入口

-
-
- -
-
- -
-
- 用户管理 - 统一账号控制 -
-
- - - -
-
- -
-
- 文件审计 - 全站文件巡检 -
-
- - - -
-
- -
-
- 存储策略 - 按策略分发 -
-
- - -
-
- - -
-

运行概览

-
- - 服务健康 + + +
+
+
配置主入口
+

+ 后台先改配置,再做治理 +

+

+ 这里不再把后台定义成“看统计的地方”,而是把已经能影响系统行为的配置入口集中起来。你现在最应该先改的是系统级开关、存储策略和用户策略,治理工具放在第二层。 +

+
+ + 邀请码: {summary.inviteCode} + + + 离线快传上限: {formatBytes(summary.offlineTransferStorageLimitBytes)} + + + 当前用户数: {summary.totalUsers} +
-
-
-
- 邀请码 - -
-
- {summary.inviteCode} -
-
-
-
-
- 下载流量 -
-
{formatBytes(summary.downloadTrafficBytes)}
+
+
+
+
当前生效值
+
{summary.inviteCode}
-
-
- 请求量 -
-
{summary.requestCount}
+ +
+
+
+
离线快传占用
+
{formatBytes(summary.offlineTransferStorageBytes)}
+
+
+
下载流量
+
{formatBytes(summary.downloadTrafficBytes)}
+
+
+
总文件数
+
{summary.totalFiles}
+
+
+
请求量
+
{summary.requestCount}
- -
+
+ + + +
+

配置分组

+

先处理系统行为,再处理治理问题

+
+
+ } + title="系统配置" + description="集中处理邀请码、离线快传总容量,以及当前运行环境里最直接影响注册和传输行为的系统项。" + highlights={['邀请码', '离线快传上限', '运行快照']} + tone="blue" + /> + } + title="存储配置" + description="集中处理存储策略的新增、编辑、启停与迁移任务创建,不再把这块藏在资源表格里。" + highlights={['策略编辑', '启停', '迁移任务']} + tone="emerald" + /> + } + title="用户策略" + description="集中处理用户角色、配额、上传上限、手动改密和临时密码重置,把用户页从“查人”改成“改规则”。" + highlights={['角色', '配额', '上传上限', '密码策略']} + tone="amber" + /> +
+
+ + +
+

治理工具

+

这些页面更偏治理与排查,而不是直接改配置

+
+
+ } title="文件治理" description="查全站文件、执行高风险删除。" /> + } title="对象治理" description="查 blob 关联、孤儿风险和对象异常。" /> + } title="分享治理" description="排查 Token、撤销分享和过期风险。" /> + } title="任务监控" description="观察迁移和后台任务,不在这里改系统配置。" /> + } title="审计日志" description="复盘谁改了什么,而不是直接改值。" /> + } title="文件系统快照" description="查看当前文件与上传体系状态。" /> +
+
+ + +
+
+ +
+
系统配置负载
+
{formatBytes(summary.offlineTransferStorageLimitBytes)}
+

当前系统里最直接可调的资源上限是离线快传容量,总览展示它是为了方便你先去调参。

+
+
+
+ +
+
存储当前占用
+
{formatBytes(summary.totalStorageBytes)}
+

存储策略页会决定上传模式、对象大小上限和迁移方向,这里只给你一个当前量级参考。

+
+
+
+ +
+
用户策略对象
+
{summary.totalUsers}
+

用户页现在应该被理解成“用户策略面板”,你在里面改的是规则和限制,不是只读名单。

+
+
) : null} diff --git a/front/src/admin/fileblobs.tsx b/front/src/admin/fileblobs.tsx index 6d13864..277b076 100644 --- a/front/src/admin/fileblobs.tsx +++ b/front/src/admin/fileblobs.tsx @@ -1 +1,482 @@ -export default function AdminFileBlobs() { return

Admin FileBlobs ()

; } +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 = { + 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 ( + + {active ? : } + {active ? trueLabel : falseLabel} + + ); +} + +function titleBlock(title: string, subtitle: string) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} + +function metricCard({ + label, + value, + icon, + tone, +}: { + label: string; + value: string; + icon: ReactNode; + tone: 'blue' | 'green' | 'amber' | 'red' | 'purple'; +}) { + return ( +
+
+ {icon} +
+

{value}

+

{label}

+
+ ); +} + +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 ( + +
+
+

对象实体

+

+ `GET /api/admin/file-blobs` / 用户检索 / 策略过滤 / 风险巡检 +

+
+
+ +
+
+ + + + {metricCard({ + label: '当前页对象数', + value: page ? `${items.length}` : '-', + icon: , + tone: 'blue', + })} + + + {metricCard({ + label: 'blobMissing', + value: `${blobMissingCount}`, + icon: , + tone: 'red', + })} + + + {metricCard({ + label: 'orphanRisk', + value: `${orphanRiskCount}`, + icon: , + tone: 'amber', + })} + + + {metricCard({ + label: 'referenceMismatch', + value: `${referenceMismatchCount}`, + icon: , + tone: 'purple', + })} + + + +
{ + event.preventDefault(); + void loadFileBlobs(filters); + }} + className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl" + > + {titleBlock('筛选器', '只保留服务端支持的查询参数,避免前端做额外猜测')} +
+ + + + +
+ +
+
+ {activeFilterLabels.length ? ( + activeFilterLabels.map((label) => ( + + {label} + + )) + ) : ( + 当前没有启用筛选条件 + )} +
+
+ + +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {page ? ( +
+ + 共 {page.total} 条记录 / {pageLabel} + + 风险对象 {anyRiskCount} 条 +
+ ) : null} + +
+ {isInitialLoading ? ( +
+ 正在加载对象实体快照... +
+ ) : page ? ( +
+
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + + + + + ); + })} + + {items.length === 0 ? ( + + + + ) : null} + +
对象键实体 / Blob存储策略大小 / 类型关联信息风险时间
+
+
+
+ {item.objectKey} +
+
+ 创建者 {item.createdByUsername || `#${item.createdByUserId ?? '-'}`} +
+
+ 样本所有者 {item.sampleOwnerUsername || '-'} + {item.sampleOwnerEmail ? ` / ${item.sampleOwnerEmail}` : ''} +
+
+ +
+
+
实体 #{item.entityId}
+
Blob #{item.blobId}
+
+ {ENTITY_TYPE_LABELS[item.entityType]} +
+
+
策略 #{item.storagePolicyId}
+
+ {item.createdByUserId != null ? `创建者 ID ${item.createdByUserId}` : '创建者 ID -'} +
+
+
{formatBytes(item.size)}
+
+ {item.contentType || '-'} +
+
+
+
引用 {item.referenceCount ?? '-'}
+
关联文件 {item.linkedStoredFileCount}
+
关联所有者 {item.linkedOwnerCount}
+
+
+
+ {riskRow('blobMissing', item.blobMissing, 'red')} + {riskRow('orphanRisk', item.orphanRisk, 'amber')} + {riskRow('referenceMismatch', item.referenceMismatch, 'purple')} +
+
+
{formatDateTime(item.createdAt)}
+
+ Blob {formatDateTime(item.blobCreatedAt)} +
+
+ 没有匹配的对象实体 +
+
+
+ ) : ( +
+ 暂无对象实体数据 +
+ )} +
+
+ ); +} diff --git a/front/src/admin/filesystem.tsx b/front/src/admin/filesystem.tsx index 192df82..c2a66bd 100644 --- a/front/src/admin/filesystem.tsx +++ b/front/src/admin/filesystem.tsx @@ -1 +1,327 @@ -export default function AdminFilesystem() { return

Admin Filesystem ()

; } +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 ? : ; +} + +function booleanLabel(active: boolean) { + return active ? '启用' : '停用'; +} + +function infoRow(label: string, value: string) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +function capabilityRow(label: string, active: boolean) { + return ( +
+ {label} + + {statusIcon(active)} + {booleanLabel(active)} + +
+ ); +} + +function sectionTitle(title: string, subtitle: string) { + return ( +
+
+

{title}

+

{subtitle}

+
+
+ ); +} + +export default function AdminFilesystem() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [filesystem, setFilesystem] = useState(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: , + tone: 'blue', + }, + { + label: '文件总数', + value: String(filesystem.overview.totalFiles), + icon: , + tone: 'green', + }, + { + label: '对象总数', + value: String(filesystem.overview.totalBlobs), + icon: , + tone: 'purple', + }, + { + label: '实体总数', + value: String(filesystem.overview.totalEntities), + icon: , + 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 ( + +
+
+

文件系统

+

存储概览 / 上传模式 / 媒体处理 / 缓存 / WebDAV

+
+ +
+ + {error ?
{error}
: null} + + {loading && !filesystem ? ( +
正在读取文件系统快照...
+ ) : filesystem ? ( + + + {overviewCards.map((card) => ( +
+
+ {card.icon} +
+

{card.value}

+

{card.label}

+
+ ))} +
+ +
+ + {sectionTitle('默认存储策略', '当前系统选择的默认分发与对象存储规则')} +
+ + {statusIcon(filesystem.defaultPolicy.enabled)} + {filesystem.defaultPolicy.enabled ? '默认策略启用' : '默认策略停用'} + + {filesystem.defaultPolicy.defaultPolicy ? 'DEFAULT' : 'NON-DEFAULT'} + +
+ +
+ {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))} +
+ +
+
能力矩阵
+
+ {capabilityEntries.map(([label, active]) => capabilityRow(label, active))} +
+
+
+ 单对象最大值 + {formatBytes(filesystem.defaultPolicy.capabilities.maxObjectSize)} +
+
+
+
+ +
+ + {sectionTitle('上传模式矩阵', '前端只展示服务端暴露的实际可用上传路径')} +
+ {[ + { label: '代理上传', active: filesystem.upload.proxyUpload, note: '客户端经由后端转发,适合受控或兼容性场景。' }, + { label: '直传单文件', active: filesystem.upload.directSingleUpload, note: '单文件直接命中存储端,适合小文件快速上传。' }, + { label: '直传分片', active: filesystem.upload.directMultipartUpload, note: '大文件分片写入,适合稳定传输与断点续传。' }, + ].map((item) => ( +
+
+
+
{item.label}
+
{item.note}
+
+ + {statusIcon(item.active)} + {booleanLabel(item.active)} + +
+
+ ))} +
+
+
+ 有效最大文件大小 + {formatBytes(filesystem.upload.effectiveMaxFileSizeBytes)} +
+
+
+ + + {sectionTitle('媒体处理', '缩略图与元数据采集能力快照')} +
+
+
+
元数据提取
+
文件入库后是否自动提取媒体信息。
+
+ + {statusIcon(filesystem.mediaProcessing.metadataExtractionEnabled)} + {booleanLabel(filesystem.mediaProcessing.metadataExtractionEnabled)} + +
+
+
+
原生缩略图
+
是否直接由存储或后端生成缩略图结果。
+
+ + {statusIcon(filesystem.mediaProcessing.nativeThumbnailSupport)} + {booleanLabel(filesystem.mediaProcessing.nativeThumbnailSupport)} + +
+
+
+ + + {sectionTitle('缓存状态', '文件列表与目录版本的缓存后端')} +
+ {infoRow('缓存后端', filesystem.cache.backend)} + {infoRow('文件列表 TTL', `${filesystem.cache.filesListTtlSeconds} 秒`)} + {infoRow('目录版本 TTL', `${filesystem.cache.directoryVersionTtlSeconds} 秒`)} +
+
+ + + {sectionTitle('WebDAV 状态', '只读挂载与外部客户端访问能力')} +
+
+
WebDAV 服务
+
管理台仅展示后端当前是否暴露 WebDAV。
+
+ + {filesystem.webdav.enabled ? : } + {booleanLabel(filesystem.webdav.enabled)} + +
+
+
+ + 说明 +
+

+ 当前页面仅展示文件系统快照,不提供就地编辑。所有可变配置仍由系统设置与存储策略页面管理。 +

+
+
+
+
+
+ ) : null} +
+ ); +} diff --git a/front/src/admin/oauthapps.tsx b/front/src/admin/oauthapps.tsx index 3ac9a99..4ab05ce 100644 --- a/front/src/admin/oauthapps.tsx +++ b/front/src/admin/oauthapps.tsx @@ -1 +1,258 @@ -export default function AdminOAuthApps() { return

Admin OAuthApps ()

; } +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 ( +
+

{eyebrow}

+

{title}

+

{description}

+
+ ); +} + +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 ( + + {children} + + ); +} + +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 ( + +
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+
+ ); +} + +export default function AdminOAuthApps() { + return ( + +
+
+

+ 三方应用 +

+

+ 当前仅保留规划状态 / 后端支持未就绪 / 不提供可写配置 +

+
+ 规划中 +
+ + + + + +
+
+
+ + 后端支持状态 +
+

+ 目前仓库里没有面向管理员的 OAuth 应用管理 API,因此前端只能展示说明,不能创建、编辑或删除应用。 +

+
+ +
+
+ + 为什么没有可写控件 +
+

+ 如果现在就提供按钮或输入框,会让人误以为配置已经生效。为了避免误导,这一页保持只读,直到后端能力真正落地。 +

+
+
+
+ + +
+
+ +
+
+

+ 规划入口 +

+

+ 仅展示后续将开放的能力 +

+
+
+ +
+
+
+ + 未来会增加 +
+
    +
  • + + OAuth 应用的创建、编辑、停用与删除 +
  • +
  • + + Client ID / Client Secret 的安全展示与轮换 +
  • +
  • + + 回调地址、授权范围和状态的可视化管理 +
  • +
  • + + 审计记录与变更历史 +
  • +
+
+ + +
+
+ + + + +
+ } + /> + } + tone="amber" + /> + } + tone="violet" + /> + } + tone="green" + /> +
+
+
+
+ ); +} diff --git a/front/src/admin/settings.tsx b/front/src/admin/settings.tsx index 1c02214..4bede59 100644 --- a/front/src/admin/settings.tsx +++ b/front/src/admin/settings.tsx @@ -1 +1,620 @@ -export default function AdminSettings() { return

Admin Settings ()

; } +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 ( + + {value ? trueLabel : falseLabel} + + ); +} + +function SnapshotRow({ + label, + value, + valueClassName, +}: { + label: string; + value: ReactNode; + valueClassName?: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function SnapshotCard({ + title, + badge, + icon, + children, +}: { + title: string; + badge: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + +
+
+
+ {icon} +
+
+

{title}

+

{badge}

+
+
+
+
{children}
+
+ ); +} + +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(null); + const inviteCodeForm = useForm({ + defaultValues: { + inviteCode: '', + }, + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + const offlineTransferLimitForm = useForm({ + 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 ( + +
+
+

系统设置

+

+ 可编辑设置 / 只读快照 / 后端能力边界 +

+
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {notice ? ( +
+ {notice} +
+ ) : null} + + {loading && !settings ? ( +
+ 正在读取系统设置快照... +
+ ) : settings ? ( + +
+
+
+

可编辑设置

+

+ 这里是当前后端明确支持写入的设置项,仅包含邀请码与离线快传容量上限。 +

+
+ + PATCH / POST + +
+ +
+ +
+
+
+ +
+
+

注册邀请码

+

+ 当前为可写设置,变更后立即生效 +

+
+
+ {settings.registration.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')} +
+ +
+
+
+
是否强制邀请码
+
{settings.registration.inviteCodeRequired ? '是' : '否'}
+
+
+
管理角色
+
+ {settings.registration.managementRoles.map((role) => ( + + {role} + + ))} +
+
+
+ +
+
+ 当前邀请码 + +
+
+ {settings.registration.currentInviteCode} +
+
+ +
{ + setError(''); + })} + > + +
+ + +
+
+
+
+ + +
+
+
+ +
+
+

离线快传存储上限

+

+ 控制离线快传在站点内可占用的总容量 +

+
+
+ {settings.transfer.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')} +
+ +
+
+
当前上限
+
+ {formatBytes(settings.transfer.offlineTransferStorageLimitBytes)} +
+
+ {settings.transfer.offlineTransferStorageLimitBytes} 字节 +
+
+ +
{ + setError(''); + })} + > + + +
+
+
容量预览
+
{formatBytes(offlineLimitPreview)}
+
+ +
+
+ +
+
+ + 仅供运营参考 +
+
+ 该设置只影响离线快传的总存储配额,不会改变文件列表、分享或普通上传的容量规则。 +
+
+
+
+
+
+ +
+
+
+

只读快照

+

+ 下列内容全部来自 GET /api/admin/settings,当前不提供前端编辑入口。 +

+
+ + Snapshot + +
+ +
+ } + > + + + + + } + > + + + + + + + + } + > + + + + + + + } + > + + + + + + + } + > + + + + + } + > + + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/front/src/admin/shares.tsx b/front/src/admin/shares.tsx index f2623e2..db7fbb7 100644 --- a/front/src/admin/shares.tsx +++ b/front/src/admin/shares.tsx @@ -1 +1,463 @@ -export default function AdminShares() { return

Admin Shares ()

; } +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 { + 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 ( + + {active ? activeLabel : inactiveLabel} + + ); +} + +export default function AdminShares() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [shares, setShares] = useState([]); + 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[] = [ + { + accessorKey: 'token', + header: '分享', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
{row.original.shareName || row.original.fileName}
+
{row.original.token}
+
+ {boolBadge(row.original.passwordProtected, '需密码', '无密码', 'amber')} + {boolBadge(row.original.expired, '已过期', '未过期', 'red')} +
+
+ ), + }, + { + id: 'permissions', + header: '权限', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
+ {boolBadge(row.original.allowDownload, '可下载', '仅查看', 'blue')} + {boolBadge(row.original.allowImport, '可导入', '受保护', 'purple')} +
+
+ Max DL {row.original.maxDownloads ?? '∞'} +
+
+ ), + }, + { + id: 'owner', + header: '所属用户', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
{row.original.ownerUsername}
+
{row.original.ownerEmail}
+
UID #{row.original.ownerId}
+
+ ), + }, + { + id: 'fileInfo', + header: '文件信息', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
{row.original.fileName}
+
+ {row.original.filePath} +
+
+ {row.original.directory ? '目录' : `${formatBytes(row.original.fileSize)} / ${row.original.fileContentType || '-'}`} +
+
+ ), + }, + { + id: 'stats', + header: '统计', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
下载 {row.original.downloadCount}
+
查看 {row.original.viewCount}
+
+ ), + }, + { + id: 'time', + header: '时间', + meta: { + thClassName: 'px-6 py-5 text-left', + tdClassName: 'px-6 py-5 align-top', + }, + cell: ({ row }) => ( +
+
{formatDateTime(row.original.createdAt)}
+
+ 过期 {row.original.expiresAt ? formatDateTime(row.original.expiresAt) : '永久有效'} +
+
+ ), + }, + { + 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 ( +
+ + + +
+ ); + }, + }, + ]; + + const table = useReactTable({ + data: shares, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => String(row.id), + }); + + return ( + +
+
+

分享管理

+

分享治理 / Token 检索 / 过期与密码保护筛选

+
+ +
+ +
{ + event.preventDefault(); + setLoading(true); + void loadShares(filters); + }} + className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl" + > +
+

筛选器

+

严格对应后端 `GET /api/admin/shares` 支持的查询参数

+
+ +
+ + + + + +
+ +
+
+ {activeFilterLabels.length ? ( + activeFilterLabels.map((label) => ( + + {label} + + )) + ) : ( + 当前没有启用筛选条件 + )} +
+
+ + +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ 共 {total} 条分享记录 + 当前页 {shares.length} 条 +
+ +
+ {loading && shares.length === 0 ? ( +
+ 正在读取分享治理列表... +
+ ) : ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta; + + return ( + + ); + })} + + ))} + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : null} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ 没有匹配的分享记录 +
+
+
+ )} +
+
+ ); +} diff --git a/front/src/admin/storage-policies-list.tsx b/front/src/admin/storage-policies-list.tsx index b5813e5..995b0b1 100644 --- a/front/src/admin/storage-policies-list.tsx +++ b/front/src/admin/storage-policies-list.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from 'react'; import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, + type RowData, +} from '@tanstack/react-table'; import { createStorageMigration, createStoragePolicy, @@ -14,6 +21,13 @@ import { import { formatBytes } from '@/src/lib/format'; import { cn } from '@/src/lib/utils'; +declare module '@tanstack/react-table' { + interface ColumnMeta { + thClassName?: string; + tdClassName?: string; + } +} + function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities { return { directUpload: false, @@ -67,6 +81,161 @@ export default function AdminStoragePoliciesList() { const [editingPolicy, setEditingPolicy] = useState(null); const [showForm, setShowForm] = useState(false); const [form, setForm] = useState(buildInitialForm()); + const [migratingPolicy, setMigratingPolicy] = useState(null); + const [migrationTargetPolicyId, setMigrationTargetPolicyId] = useState(''); + const [migrationSubmitting, setMigrationSubmitting] = useState(false); + const [migrationNotice, setMigrationNotice] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: '名称', + meta: { + thClassName: 'px-8 py-5 text-left', + tdClassName: 'px-8 py-5', + }, + cell: ({ row }) => { + const policy = row.original; + return ( +
+
+ {policy.name} + {policy.defaultPolicy ? ( + + 默认 + + ) : null} +
+
PID::{policy.id}
+
+ ); + }, + }, + { + accessorKey: 'type', + header: '后端类型', + meta: { + thClassName: 'px-8 py-5 text-left', + tdClassName: 'px-8 py-5', + }, + cell: ({ getValue }) => ( + + {String(getValue())} + + ), + }, + { + id: 'endpoint', + header: '访问端点', + meta: { + thClassName: 'px-8 py-5 text-left', + tdClassName: 'px-8 py-5', + }, + cell: ({ row }) => { + const policy = row.original; + return ( +
+
+ {policy.endpoint || '-'} +
+
+ {policy.bucketName || '私有根路径'} +
+
+ ); + }, + }, + { + id: 'status', + header: '状态', + meta: { + thClassName: 'px-8 py-5 text-left', + tdClassName: 'px-8 py-5', + }, + cell: ({ row }) => { + const policy = row.original; + return ( + + + {policy.enabled ? '启用' : '停用'} + + ); + }, + }, + { + 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 ( +
+ + {!policy.defaultPolicy ? ( + + ) : null} + +
+ ); + }, + }, + ]; + + const table = useReactTable({ + data: policies, + columns, + getCoreRowModel: getCoreRowModel(), + }); async function loadPolicies() { 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 (
+ {migrationNotice ? ( +
+ {migrationNotice.message} +
+ ) : null} + {error ?
{error}
: null}
@@ -148,93 +385,33 @@ export default function AdminStoragePoliciesList() {
- - - - - - - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {policies.map((policy) => ( - - - - - - - + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} ))} @@ -379,6 +556,82 @@ export default function AdminStoragePoliciesList() { ) : null} + + + {migratingPolicy ? ( +
+ +

发起迁移

+

仅创建迁移任务,不会立即执行对象复制

+ +
+
+
源策略
+
{migratingPolicy.name}
+
PID::{migratingPolicy.id}
+
+
+
+
+ + +
+
+ + 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" + /> +
+
+

+ 如果目标策略不在下拉框里,可以直接输入它的策略 ID。当前页面只负责创建迁移任务,不负责迁移进度展示。 +

+
+ +
+ + +
+ +
+ ) : null} +
); } diff --git a/front/src/admin/tasks.tsx b/front/src/admin/tasks.tsx index 14778af..ecb37d2 100644 --- a/front/src/admin/tasks.tsx +++ b/front/src/admin/tasks.tsx @@ -1 +1,743 @@ -export default function AdminTasks() { return

Admin Tasks ()

; } +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 = { + ARCHIVE: '归档', + EXTRACT: '解压', + MEDIA_META: '媒体元数据', + STORAGE_POLICY_MIGRATION: '存储迁移', + }; + + return labels[type] ?? type; +} + +function taskStatusLabel(status: string) { + const labels: Record = { + QUEUED: '排队中', + RUNNING: '执行中', + COMPLETED: '已完成', + FAILED: '已失败', + CANCELLED: '已取消', + }; + + return labels[status] ?? status; +} + +function failureCategoryLabel(category: string | null) { + if (!category) { + return '-'; + } + + const labels: Record = { + UNSUPPORTED_INPUT: '不支持输入', + DATA_STATE: '数据状态异常', + TRANSIENT_INFRASTRUCTURE: '临时基础设施', + RATE_LIMITED: '触发限流', + UNKNOWN: '未知', + }; + + return labels[category] ?? category; +} + +function leaseStateLabel(leaseState: string) { + const labels: Record = { + 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 ( + + {children} + + ); +} + +function SectionTitle({ title, subtitle }: { title: string; subtitle: string }) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} + +function MetricCard({ + icon, + label, + value, + tone, +}: { + icon: ReactNode; + label: string; + value: string; + tone: 'blue' | 'green' | 'amber' | 'red' | 'gray'; +}) { + return ( +
+
+ {icon} +
+

{value}

+

{label}

+
+ ); +} + +function DetailRow({ + label, + value, + valueClassName, +}: { + label: string; + value: ReactNode; + valueClassName?: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +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(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 ( + +
+
+

任务监控

+

+ `GET /api/admin/tasks` / `GET /api/admin/tasks/:id` / 租约与重试态监控 +

+
+
+ + +
+
+ + + + } label="任务总数" value={String(total)} tone="blue" /> + + + } label="当前页数量" value={String(items.length)} tone="gray" /> + + + } label="当前页进行中" value={String(activeCount)} tone="green" /> + + + } label="当前页失败" value={String(failedCount)} tone="red" /> + + + } label="已安排重试" value={String(retryScheduledCount)} tone="amber" /> + + + +
{ + event.preventDefault(); + void loadTasks(0, filters, pageSize); + }} + className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl" + > + + +
+ + + + + +
+ +
+
+ {activeFilterLabels.length ? ( + activeFilterLabels.map((label) => ( + + {label} + + )) + ) : ( + 当前没有启用筛选条件 + )} +
+
+ + +
+
+ + + {listError ? ( +
+ {listError} +
+ ) : null} + +
+ + 共 {total} 条任务记录 + {pageData ? ` / 第 ${currentPage + 1} 页,共 ${pageCount} 页` : ''} + + 当前页 {items.length} 条 +
+ +
+
+ {loading && !pageData ? ( +
+ 正在读取任务监控列表... +
+ ) : items.length === 0 ? ( +
+ 当前没有任务 +
+ ) : ( +
+
+
名称后端类型访问端点状态对象上限操作
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
-
- {policy.name} - {policy.defaultPolicy ? ( - 默认 - ) : null} -
-
PID::{policy.id}
-
- {policy.type} - -
{policy.endpoint || '-'}
-
{policy.bucketName || '私有根路径'}
-
- - - {policy.enabled ? '启用' : '停用'} - - - {formatBytes(policy.maxSizeBytes)} - -
- - {!policy.defaultPolicy ? ( - - ) : null} - -
-
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ + + + + + + + + + + + {items.map((task) => { + const isSelected = selectedTask?.id === task.id; + return ( + { + 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', + )} + > + + + + + + + + ); + })} + +
任务所属用户状态租约 / 重试时间操作
+
{taskTypeLabel(task.type)}
+
#{task.id}
+
+ {task.type} + {task.correlationId ? {task.correlationId} : null} +
+
+
{task.ownerUsername || '-'}
+
{task.ownerEmail || '-'}
+
用户 ID #{task.userId}
+
+
+ {taskStatusLabel(task.status)} + {task.retryScheduled ? 已安排重试 : 未安排重试} +
+
+ {failureCategoryLabel(task.failureCategory)} +
+
+
+ {task.attemptCount}/{task.maxAttempts} 次 +
+
+ {leaseStateLabel(task.leaseState)} + {task.workerOwner || '无 worker'} +
+
下一次运行:{formatDateTime(task.nextRunAt)}
+
+
创建:{formatDateTime(task.createdAt)}
+
更新:{formatDateTime(task.updatedAt)}
+
结束:{formatDateTime(task.finishedAt)}
+
+
+ +
+
+
+
+ )} +
+ + +
+ +
+
+ {pageData ? `第 ${currentPage + 1} 页 / 每页 ${currentSize}` : '尚未加载分页信息'} +
+
+ + +
+
+
+ ); +} diff --git a/front/src/admin/users-list.tsx b/front/src/admin/users-list.tsx index 640e55d..7d7dd8a 100644 --- a/front/src/admin/users-list.tsx +++ b/front/src/admin/users-list.tsx @@ -1,6 +1,14 @@ -import { useEffect, useState } from 'react'; -import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Ban, Check, Clipboard, KeyRound, PencilLine, RefreshCw, Search, Shield, Mail, Phone, X } from 'lucide-react'; import { motion } from 'motion/react'; +import { useForm } from 'react-hook-form'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, +} from '@tanstack/react-table'; import { cn } from '@/src/lib/utils'; import { getAdminUsers, @@ -29,11 +37,246 @@ const itemVariants = { show: { y: 0, opacity: 1 } }; +const columnHelper = createColumnHelper(); + +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() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [query, setQuery] = useState(''); const [users, setUsers] = useState([]); + const [editingUser, setEditingUser] = useState(null); + const [temporaryPasswords, setTemporaryPasswords] = useState>({}); + const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState(null); + const { + register, + trigger, + getValues, + reset, + resetField, + watch, + formState: { errors }, + } = useForm({ + defaultValues: EMPTY_EDITOR_FORM_VALUES, + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + const watchedRole = watch('role'); + const columns = useMemo[]>(() => [ + columnHelper.display({ + id: 'userInfo', + header: '用户信息', + cell: ({ row }) => { + const user = row.original; + return ( +
+
+ {user.username.charAt(0).toUpperCase()} +
+
+
{user.username}
+
{user.email}
+ {user.phoneNumber ?
{user.phoneNumber}
: null} +
+
+ ); + }, + }), + columnHelper.accessor('role', { + header: '角色', + cell: ({ row, getValue }) => { + const user = row.original; + return ( + + + {user.role} + + ); + }, + }), + columnHelper.accessor('banned', { + header: '状态', + cell: ({ getValue }) => ( + + {getValue() ? '已禁用' : '正常'} + + ), + }), + columnHelper.display({ + id: 'resources', + header: '资源配额', + cell: ({ row }) => { + const user = row.original; + return ( + <> +
+ {formatBytes(user.usedStorageBytes)} / {formatBytes(user.storageQuotaBytes)} +
+
+ +
+
+ 上传上限:{formatBytes(user.maxUploadSizeBytes)} +
+ + ); + }, + }), + columnHelper.accessor('createdAt', { + header: '注册时间', + cell: ({ getValue }) => ( +
+ {formatDateTime(getValue())} +
+ ), + }), + columnHelper.display({ + id: 'actions', + header: '操作', + cell: ({ row }) => { + const user = row.original; + return ( + <> +
+ + + +
+ {temporaryPasswords[user.id] ? ( +
+
+
+
+ 临时密码已生成 +
+
+ 请复制后立即告知用户,随后可关闭此提示 +
+
+ +
+
+ + {temporaryPasswords[user.id]} + + +
+
+ ) : null} + + ); + }, + }), + ], [copiedTemporaryPasswordUserId, copyTemporaryPassword, generateTemporaryPassword, mutate, openEditor, temporaryPasswords]); + const table = useReactTable({ + data: users, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => String(row.id), + }); async function loadUsers(nextQuery = query) { 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(() => { 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) { try { 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 (
-

身份管理

-

用户权限 / 身份档案

+

用户策略

+

角色 / 配额 / 上传限制 / 密码策略

: null} -
+
{loading && users.length === 0 ? (
正在查询用户数据...
) : ( -
-
- - - - - - - - - - - - - {users.map((user) => ( - - - - - - - + ))} + + + {table.getRowModel().rows.map((row) => { + const user = row.original; + const isEditing = editingUser?.id === user.id; + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + {table.getRowModel().rows.length === 0 ? ( + + + + ) : null} + +
用户信息角色状态资源配额注册时间操作
-
-
- {user.username.charAt(0).toUpperCase()} -
-
-
{user.username}
-
{user.email}
- {user.phoneNumber ?
{user.phoneNumber}
: null} -
-
-
- - - {user.role} - - - - {user.banned ? '已禁用' : '正常'} - - -
- {formatBytes(user.usedStorageBytes)} / {formatBytes(user.storageQuotaBytes)} -
-
- -
-
- 上传上限:{formatBytes(user.maxUploadSizeBytes)} -
-
- {formatDateTime(user.createdAt)} - -
- - - -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ 暂无用户记录 +
+
-
+ + + )}
diff --git a/front/src/lib/admin-audits.ts b/front/src/lib/admin-audits.ts new file mode 100644 index 0000000..56202c1 --- /dev/null +++ b/front/src/lib/admin-audits.ts @@ -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>(`/admin/audits?${params.toString()}`); +} diff --git a/front/src/lib/admin-fileblobs.ts b/front/src/lib/admin-fileblobs.ts new file mode 100644 index 0000000..4c2c2d3 --- /dev/null +++ b/front/src/lib/admin-fileblobs.ts @@ -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>(`/admin/file-blobs?${params.toString()}`); +} diff --git a/front/src/lib/admin-filesystem.ts b/front/src/lib/admin-filesystem.ts new file mode 100644 index 0000000..450dc66 --- /dev/null +++ b/front/src/lib/admin-filesystem.ts @@ -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('/admin/filesystem'); +} diff --git a/front/src/lib/admin-settings.ts b/front/src/lib/admin-settings.ts new file mode 100644 index 0000000..5bbe2e7 --- /dev/null +++ b/front/src/lib/admin-settings.ts @@ -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('/admin/settings'); +} + +export async function updateAdminRegistrationInviteCode(inviteCode: string) { + return fetchApi('/admin/settings/registration/invite-code', { + method: 'PATCH', + body: JSON.stringify({ inviteCode }), + }); +} + +export async function rotateAdminRegistrationInviteCode() { + return fetchApi('/admin/settings/registration/invite-code/rotate', { + method: 'POST', + }); +} + +export async function updateAdminOfflineTransferStorageLimit(offlineTransferStorageLimitBytes: number) { + return fetchApi('/admin/settings/offline-transfer-storage-limit', { + method: 'PATCH', + body: JSON.stringify({ offlineTransferStorageLimitBytes }), + }); +} diff --git a/front/src/lib/admin-shares.ts b/front/src/lib/admin-shares.ts new file mode 100644 index 0000000..0bb69b4 --- /dev/null +++ b/front/src/lib/admin-shares.ts @@ -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>(`/admin/shares?${params.toString()}`); +} + +export async function deleteAdminShare(shareId: number) { + return fetchApi(`/admin/shares/${shareId}`, { + method: 'DELETE', + }); +} diff --git a/front/src/lib/admin-tasks.ts b/front/src/lib/admin-tasks.ts new file mode 100644 index 0000000..7f0374c --- /dev/null +++ b/front/src/lib/admin-tasks.ts @@ -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>(`/admin/tasks?${params.toString()}`); +} + +export async function getAdminTask(taskId: number) { + return fetchApi(`/admin/tasks/${taskId}`); +} diff --git a/memory.md b/memory.md index 3ef4f2f..31b2fec 100644 --- a/memory.md +++ b/memory.md @@ -845,3 +845,201 @@ - `cd front && npm run lint` - `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. + +## 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` 已将手写 `` 循环替换为: +- 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`