Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d943f63b | |||
| 82524c4312 | |||
| 885d17cec7 | |||
| edb2db5966 | |||
| 8019c52fbc | |||
| 214578316e | |||
| e830f5a733 | |||
| 2dace1cf8d | |||
| 718d1c7aac | |||
| 96eaa810f3 | |||
| a4a1891a7e | |||
| 3451d373bc | |||
| e160476ecf | |||
| 526052d633 | |||
| 9362625b4e | |||
| e6d93d7614 | |||
| a691db573c | |||
| 56f8c477c2 | |||
| 51e26025c6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,6 +14,8 @@ dist-ssr
|
|||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
.trae/*
|
||||||
|
.mimocode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
59
.mimocode/plans/1782986311447-swift-planet.md
Normal file
59
.mimocode/plans/1782986311447-swift-planet.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# DramaList 筛选功能 + 自定义容器组件
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
1. 为短剧管理页面添加搜索筛选功能(标题、状态、类型)
|
||||||
|
2. 将 Card 容器替换为自定义实现的容器组件
|
||||||
|
|
||||||
|
## 分析
|
||||||
|
|
||||||
|
### 现有架构
|
||||||
|
- `PageQuery` 类型仅支持 `{ page, pageSize }`,无筛选字段
|
||||||
|
- `fetchPage` 直接将 params 传给 axios,扩展字段会自动作为 query 参数发送
|
||||||
|
- `usePageData` hook 固定使用 `PageQuery`,需扩展支持筛选
|
||||||
|
|
||||||
|
### 筛选字段(基于 Drama 类型)
|
||||||
|
- `title` - 短剧标题(模糊搜索)
|
||||||
|
- `status` - 状态(draft/scheduled/published/offline)
|
||||||
|
- `type` - 类型(如复仇、爱情、动作等)
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 1. 扩展 API 查询类型
|
||||||
|
**文件**: `src/api/interface.ts`
|
||||||
|
- 新增 `DramaQuery` 类型,继承 `PageQuery`,添加 `title?`、`status?`、`type?` 字段
|
||||||
|
|
||||||
|
**文件**: `src/api/dramas.ts`
|
||||||
|
- `listDramas` 参数类型从 `PageQuery` 改为 `DramaQuery`
|
||||||
|
|
||||||
|
### 2. 扩展 usePageData hook
|
||||||
|
**文件**: `src/hooks/interface.ts`
|
||||||
|
- `PageFetcher<T>` 泛型扩展,支持自定义查询类型
|
||||||
|
|
||||||
|
**文件**: `src/hooks/usePageData.ts`
|
||||||
|
- 泛型参数增加 `Q extends PageQuery`,支持自定义查询类型
|
||||||
|
- 新增 `setQuery` 方法用于更新筛选条件
|
||||||
|
- 筛选条件变化时重置到第 1 页
|
||||||
|
|
||||||
|
### 3. 创建自定义容器组件
|
||||||
|
**文件**: `src/components/Panel/index.tsx`(新建)
|
||||||
|
- 实现一个轻量级容器组件,替代 Card
|
||||||
|
- Props: `title`、`extra`(右侧操作区)、`children`、`className`
|
||||||
|
- 样式:圆角、边框、阴影,与现有 `.section-card` 风格一致
|
||||||
|
|
||||||
|
### 4. 改造 DramaList 页面
|
||||||
|
**文件**: `src/Drama/components/DramaList.tsx`
|
||||||
|
- 使用自定义 Panel 组件替换 Card
|
||||||
|
- 添加筛选区域:标题输入框 + 状态选择器 + 类型输入框 + 查询/重置按钮
|
||||||
|
- 管理筛选状态,调用 `setQuery` 更新查询
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
1. `src/api/interface.ts` - 新增 DramaQuery 类型
|
||||||
|
2. `src/api/dramas.ts` - 更新 listDramas 参数类型
|
||||||
|
3. `src/hooks/interface.ts` - 扩展 PageFetcher 泛型
|
||||||
|
4. `src/hooks/usePageData.ts` - 支持自定义查询类型和 setQuery
|
||||||
|
5. `src/components/Panel/index.tsx` - 新建自定义容器组件
|
||||||
|
6. `src/Drama/components/DramaList.tsx` - 添加筛选 UI 和 Panel
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- `npm run lint` TypeScript 检查通过
|
||||||
|
- `npm run build` 构建成功
|
||||||
147
.mimocode/plans/1783060688305-neon-meadow.md
Normal file
147
.mimocode/plans/1783060688305-neon-meadow.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# 2K 显示器适配:px → rem 转换计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前项目所有尺寸均使用 px 硬编码,在 2K (2560×1440) 显示器上元素偏小。将 px 转为 rem 后,可通过调整根 `font-size` 一键缩放整个 UI。
|
||||||
|
|
||||||
|
## 转换规则
|
||||||
|
|
||||||
|
基础:`1rem = 16px`(浏览器默认),`html { font-size: 16px }` 显式声明。
|
||||||
|
|
||||||
|
### 需要转换的值
|
||||||
|
| px | rem | 用途 |
|
||||||
|
|----|-----|------|
|
||||||
|
| 1px | 1px | 边框宽度(保持 px,不转换) |
|
||||||
|
| 2px | 0.125rem | 小间距 |
|
||||||
|
| 4px | 0.25rem | 微间距 |
|
||||||
|
| 6px | 0.375rem | 小内距 |
|
||||||
|
| 8px | 0.5rem | 内距/间距 |
|
||||||
|
| 10px | 0.625rem | gap-2.5 等 |
|
||||||
|
| 12px | 0.75rem | 小字号/内距 |
|
||||||
|
| 13px | 0.8125rem | 辅助文字 |
|
||||||
|
| 14px | 0.875rem | 次要文字 |
|
||||||
|
| 15px | 0.9375rem | 标题文字 |
|
||||||
|
| 16px | 1rem | 正文/内距 |
|
||||||
|
| 18px | 1.125rem | 表单帮助 |
|
||||||
|
| 20px | 1.25rem | 内距 |
|
||||||
|
| 22px | 1.375rem | 登录标题 |
|
||||||
|
| 24px | 1.5rem | 大内距 |
|
||||||
|
| 30px | 1.875rem | 仪表盘数值 |
|
||||||
|
| 32px | 2rem | 头像 |
|
||||||
|
| 36px | 2.25rem | 图标容器 |
|
||||||
|
| 40px | 2.5rem | 大内距 |
|
||||||
|
| 42px | 2.625rem | 登录卡片宽 |
|
||||||
|
| 44px | 2.75rem | 按钮高度/头像 |
|
||||||
|
| 48px | 3rem | 登录内距 |
|
||||||
|
| 52px | 3.25rem | 侧边栏宽 |
|
||||||
|
| 56px | 3.5rem | 登录视觉内距 |
|
||||||
|
| 60px | 3.75rem | 头部高度 |
|
||||||
|
| 64px | 4rem | 大头像/图标容器 |
|
||||||
|
| 180px | 11.25rem | InputNumber 宽 |
|
||||||
|
| 200px | 12.5rem | InputNumber 宽 |
|
||||||
|
| 224px | 14rem | 侧边栏宽 |
|
||||||
|
| 360px | 22.5rem | 权限树高 |
|
||||||
|
| 420px | 26.25rem | 登录卡片宽 |
|
||||||
|
| 520px | 32.5rem | 登录左栏最小宽 |
|
||||||
|
| 640px | 40rem | Modal 宽 |
|
||||||
|
| 680px | 42.5rem | Modal 宽 |
|
||||||
|
| 720px | 45rem | Modal 宽 |
|
||||||
|
|
||||||
|
### 不转换的值
|
||||||
|
- **边框宽度** `1px` — 保持 px,rem 在小数值下渲染不精确
|
||||||
|
- **box-shadow 偏移/模糊** `0 2px 6px` — 保持 px,视觉效果不受缩放影响
|
||||||
|
- **Tailwind 间距类** `p-5`, `gap-4`, `h-9` 等 — 已经是 rem 单位,无需修改
|
||||||
|
|
||||||
|
## 涉及文件清单
|
||||||
|
|
||||||
|
### 1. `src/index.css` — CSS 中的 px 值
|
||||||
|
- 字号:`15px` → `0.9375rem`,`13px` → `0.8125rem`,`16px` → `1rem`
|
||||||
|
- 内距:`2px 8px` → `0.125rem 0.5rem`,`14px 20px` → `0.875rem 1.25rem` 等
|
||||||
|
- 外距:`mb-[18px]` → `mb-[1.125rem]`,`margin-top: 16px` → `margin-top: 1rem`
|
||||||
|
- 圆角:`0.5rem`(已是 rem,保持)
|
||||||
|
|
||||||
|
### 2. `src/layout/index.tsx` — 布局
|
||||||
|
- `h-[60px]` → `h-[3.75rem]`
|
||||||
|
- `width={224}` → `width={"14rem"}`
|
||||||
|
- `text-[15px]` → `text-[0.9375rem]`
|
||||||
|
- `text-[13px]` → `text-[0.8125rem]`
|
||||||
|
- `size={32}` → `size={32}`(Arco Avatar 的 size 是数字像素,保持不变)
|
||||||
|
|
||||||
|
### 3. `src/components/Panel/index.tsx`
|
||||||
|
- `text-[15px]` → `text-[0.9375rem]`
|
||||||
|
|
||||||
|
### 4. `src/components/NoAccessPage/index.tsx`
|
||||||
|
- `text-[14px]` → `text-[0.875rem]`
|
||||||
|
|
||||||
|
### 5. `src/Dashboard/index.tsx`
|
||||||
|
- `text-[13px]` → `text-[0.8125rem]`
|
||||||
|
- `text-[30px]` → `text-[1.875rem]`
|
||||||
|
|
||||||
|
### 6. `src/Login/index.tsx`
|
||||||
|
- `minmax(520px,0.95fr)` → `minmax(32.5rem,0.95fr)`
|
||||||
|
- `minmax(420px,1fr)` → `minmax(26.25rem,1fr)`
|
||||||
|
|
||||||
|
### 7. `src/Login/components/LoginPanel.tsx`
|
||||||
|
- `max-w-[420px]` → `max-w-[26.25rem]`
|
||||||
|
- `text-[22px]` → `text-[1.375rem]`
|
||||||
|
- `text-[13px]` → `text-[0.8125rem]`
|
||||||
|
- `text-[15px]` → `text-[0.9375rem]`
|
||||||
|
|
||||||
|
### 8. `src/Login/components/LoginVisual.tsx`
|
||||||
|
- `text-[15px]` → `text-[0.9375rem]`
|
||||||
|
- `text-[13px]` → `text-[0.8125rem]`
|
||||||
|
- `text-[44px]` → `text-[2.75rem]`
|
||||||
|
- `max-w-[640px]` → `max-w-[40rem]`
|
||||||
|
- `max-w-[560px]` → `max-w-[35rem]`
|
||||||
|
|
||||||
|
### 9. `src/Profile/components/ProfileSummary.tsx`
|
||||||
|
- `text-[13px]` → `text-[0.8125rem]`
|
||||||
|
|
||||||
|
### 10. `src/Profile/components/ProfileInfoList.tsx`
|
||||||
|
- `text-[12px]` → `text-[0.75rem]`
|
||||||
|
- `text-[15px]` → `text-[0.9375rem]`
|
||||||
|
|
||||||
|
### 11. `src/Role/components/PermissionTreePicker.tsx`
|
||||||
|
- `mb-[15px]` → `mb-[0.9375rem]`
|
||||||
|
- `h-[360px]` → `h-[22.5rem]`
|
||||||
|
|
||||||
|
### 12. Modal 宽度(inline style)
|
||||||
|
- `Channel/index.tsx`:`style={{ width: 640 }}` → `style={{ width: "40rem" }}`
|
||||||
|
- `Drama/components/DramaList.tsx`:同上
|
||||||
|
- `Drama/components/DramaDetail.tsx`:`style={{ width: 720 }}` → `style={{ width: "45rem" }}`
|
||||||
|
- `Drama/components/EpisodeTable.tsx`:`style={{ width: 680 }}` → `style={{ width: "42.5rem" }}`
|
||||||
|
- `Role/index.tsx`:`style={{ width: 640 }}` → `style={{ width: "40rem" }}`
|
||||||
|
- `Personnel/index.tsx`:`style={{ width: 640 }}` → `style={{ width: "40rem" }}`
|
||||||
|
|
||||||
|
### 13. `Channel/index.tsx` InputNumber/Select 宽度
|
||||||
|
- `style={{ width: 200 }}` → `style={{ width: "12.5rem" }}`
|
||||||
|
|
||||||
|
## 不修改的内容
|
||||||
|
|
||||||
|
- Tailwind 间距类(`p-5`, `gap-4`, `h-9` 等)— 已是 rem
|
||||||
|
- `border-line/60` 等边框 — 保持 1px
|
||||||
|
- `shadow-*` 类 — shadow 值保持 px
|
||||||
|
- Arco 组件的 `size` prop(如 Avatar `size={32}`)— Arco 内部用 px,保持数字
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. `src/index.css` — CSS 中的 px 值
|
||||||
|
2. `src/layout/index.tsx` — 布局尺寸
|
||||||
|
3. `src/components/Panel/index.tsx` — 面板标题字号
|
||||||
|
4. `src/components/NoAccessPage/index.tsx` — 文字字号
|
||||||
|
5. `src/Dashboard/index.tsx` — 仪表盘字号
|
||||||
|
6. `src/Login/index.tsx` — 登录页布局
|
||||||
|
7. `src/Login/components/LoginPanel.tsx` — 登录面板
|
||||||
|
8. `src/Login/components/LoginVisual.tsx` — 登录视觉
|
||||||
|
9. `src/Profile/components/ProfileSummary.tsx` — 个人信息
|
||||||
|
10. `src/Profile/components/ProfileInfoList.tsx` — 信息列表
|
||||||
|
11. `src/Role/components/PermissionTreePicker.tsx` — 权限树
|
||||||
|
12. 所有 Modal 宽度(Channel, DramaList, DramaDetail, EpisodeTable, Role, Personnel)
|
||||||
|
13. Channel InputNumber/Select 宽度
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. `pnpm lint` — TypeScript 类型检查通过
|
||||||
|
2. `pnpm build` — 构建成功
|
||||||
|
3. 浏览器检查:`html` 根字号为 16px,所有尺寸使用 rem
|
||||||
|
4. 2K 适配:修改 `html { font-size: 18px }` 后 UI 等比放大
|
||||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 390,
|
||||||
|
"eslintIntegration": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"stylelintIntegration": true
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# CTH TikTok 短剧管理后台
|
# TikTok 短剧管理后台
|
||||||
|
|
||||||
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。
|
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。
|
||||||
|
|
||||||
@@ -48,7 +48,6 @@ VITE_API_BASE_URL=http://localhost:3030
|
|||||||
- 工作台
|
- 工作台
|
||||||
- 短剧管理
|
- 短剧管理
|
||||||
- 用户管理
|
- 用户管理
|
||||||
- 权限管理
|
|
||||||
- 角色管理
|
- 角色管理
|
||||||
- 管理员管理
|
- 管理员管理
|
||||||
- 日志管理
|
- 日志管理
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -12,15 +12,18 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arco-design/web-react": "^2.66.15",
|
"@arco-design/web-react": "^2.66.15",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"react": "^19.2.7",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.18.0"
|
"react-router-dom": "^7.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"postcss": "^8.5.16",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.0"
|
"vite": "^8.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
724
pnpm-lock.yaml
generated
724
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
368
src/App.css
368
src/App.css
@@ -1,368 +0,0 @@
|
|||||||
.app-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(520px, 0.95fr) minmax(420px, 1fr);
|
|
||||||
background: #f7f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-visual {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 44px 56px 48px;
|
|
||||||
color: #ffffff;
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(15, 23, 42, 0.98) 0%, rgba(22, 93, 255, 0.94) 54%, rgba(15, 118, 110, 0.96) 100%),
|
|
||||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at 82% 72%, rgba(255, 255, 255, 0.12), transparent 32%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-visual::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-logo {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
max-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-eyebrow {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand h1 {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 42px;
|
|
||||||
line-height: 1.16;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand p {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 560px;
|
|
||||||
color: rgba(255, 255, 255, 0.78);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-metrics {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
max-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-metric {
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.09);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-metric strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-metric span {
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-panel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(247, 248, 250, 0.94)),
|
|
||||||
#f7f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
padding: 36px;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card-mark {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #ffffff;
|
|
||||||
background: #165dff;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card h2 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card .subtitle {
|
|
||||||
margin: 0;
|
|
||||||
color: #86909c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card-footer {
|
|
||||||
margin-top: 22px;
|
|
||||||
padding-top: 18px;
|
|
||||||
border-top: 1px solid #f2f3f5;
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
box-shadow: 1px 0 0 #e5e6eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-row {
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 18px;
|
|
||||||
border-bottom: 1px solid #e5e6eb;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-mark {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: #ffffff;
|
|
||||||
background: #165dff;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
height: 56px;
|
|
||||||
padding: 0 20px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 1px solid #e5e6eb;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title strong {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title span {
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: #1d2129;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-trigger:hover {
|
|
||||||
background: #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card .label {
|
|
||||||
color: #86909c;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card .value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-card {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
color: #4e5969;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-help {
|
|
||||||
margin: -12px 0 18px;
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-tree-panel {
|
|
||||||
height: 360px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fbfcfd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 420px;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-grid .section-card:first-child {
|
|
||||||
grid-row: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-summary h2 {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-summary p {
|
|
||||||
margin: 0;
|
|
||||||
color: #86909c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-list div {
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid #e5e6eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fbfcfd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-list span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #86909c;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-list strong {
|
|
||||||
color: #1d2129;
|
|
||||||
font-weight: 500;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.login-page {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-visual {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.content {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-list {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1082
src/App.tsx
1082
src/App.tsx
File diff suppressed because it is too large
Load Diff
179
src/Channel/index.tsx
Normal file
179
src/Channel/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag } from "@arco-design/web-react";
|
||||||
|
import { IconDelete, IconEdit, IconPlus } from "@arco-design/web-react/icon";
|
||||||
|
import { channelApi } from "../api";
|
||||||
|
import type { Channel, ChannelQuery, CreateChannelPayload } from "../api";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { usePageData } from "../hooks/usePageData";
|
||||||
|
|
||||||
|
const INITIAL_QUERY: ChannelQuery = { page: 1, pageSize: 20 };
|
||||||
|
|
||||||
|
const CHANNEL_STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "启用",
|
||||||
|
disabled: "停用",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelFormValues = CreateChannelPayload;
|
||||||
|
|
||||||
|
export default function ChannelPage() {
|
||||||
|
const { data, loading, reload, changePage } = usePageData<Channel, ChannelQuery>(channelApi.listChannels, INITIAL_QUERY);
|
||||||
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const createChannel = async (values: ChannelFormValues) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await channelApi.createChannel({
|
||||||
|
...values,
|
||||||
|
sortOrder: Number(values.sortOrder) || 0,
|
||||||
|
isDefault: Boolean(values.isDefault),
|
||||||
|
status: values.status || "active",
|
||||||
|
});
|
||||||
|
Message.success("频道创建成功");
|
||||||
|
setCreateVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChannel = async (values: ChannelFormValues) => {
|
||||||
|
if (!editingChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await channelApi.updateChannel(editingChannel.id, {
|
||||||
|
...values,
|
||||||
|
sortOrder: Number(values.sortOrder) || 0,
|
||||||
|
isDefault: Boolean(values.isDefault),
|
||||||
|
status: values.status || "active",
|
||||||
|
});
|
||||||
|
Message.success("频道已更新");
|
||||||
|
setEditingChannel(null);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteChannel = (channel: Channel) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "删除频道",
|
||||||
|
content: `确认删除频道「${channel.name}」?`,
|
||||||
|
okButtonProps: { status: "danger" },
|
||||||
|
onOk: async () => {
|
||||||
|
await channelApi.deleteChannel(channel.id);
|
||||||
|
Message.success("频道已删除");
|
||||||
|
await reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForm = (onSubmit: (values: ChannelFormValues) => Promise<void>, initialValues: ChannelFormValues) => (
|
||||||
|
<Form layout="vertical" initialValues={initialValues} onSubmit={onSubmit}>
|
||||||
|
<Form.Item label="频道名称" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入频道名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="频道编码" field="code" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入频道编码,如 default" disabled={Boolean(editingChannel?.isDefault)} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="描述" field="description">
|
||||||
|
<Input.TextArea placeholder="请输入频道描述" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size="large" align="start">
|
||||||
|
<Form.Item label="排序权重" field="sortOrder">
|
||||||
|
<InputNumber min={0} style={{ width: "12.5rem" }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" field="status">
|
||||||
|
<Select style={{ width: "12.5rem" }} disabled={Boolean(editingChannel?.isDefault)}>
|
||||||
|
<Select.Option value="active">启用</Select.Option>
|
||||||
|
<Select.Option value="disabled">停用</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="默认频道" field="isDefault" triggerPropName="checked">
|
||||||
|
<Switch disabled={Boolean(editingChannel?.isDefault)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => {
|
||||||
|
setCreateVisible(false);
|
||||||
|
setEditingChannel(null);
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel title="频道管理" extra={<Button type="primary" icon={<IconPlus />} onClick={() => setCreateVisible(true)}>新增频道</Button>} noPadding>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={data.list}
|
||||||
|
pagination={{
|
||||||
|
current: data.pagination.page,
|
||||||
|
pageSize: data.pagination.pageSize,
|
||||||
|
total: data.pagination.total,
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
onChange: (page, pageSize) => changePage(page, pageSize),
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 80 },
|
||||||
|
{ title: "名称", dataIndex: "name" },
|
||||||
|
{ title: "编码", dataIndex: "code", width: 180 },
|
||||||
|
{ title: "排序", dataIndex: "sortOrder", width: 100 },
|
||||||
|
{
|
||||||
|
title: "默认",
|
||||||
|
dataIndex: "isDefault",
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (value ? <Tag color="arcoblue">是</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (status) => <Tag color={status === "active" ? "green" : "gray"}>{CHANNEL_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="text" icon={<IconEdit />} onClick={() => setEditingChannel(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button type="text" status="danger" icon={<IconDelete />} disabled={record.isDefault} onClick={() => deleteChannel(record)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Modal title="新增频道" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null} style={{ width: "40rem" }}>
|
||||||
|
{renderForm(createChannel, { sortOrder: 0, status: "active", isDefault: false })}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title="编辑频道" visible={Boolean(editingChannel)} onCancel={() => setEditingChannel(null)} footer={null} style={{ width: "40rem" }}>
|
||||||
|
{editingChannel && renderForm(updateChannel, {
|
||||||
|
name: editingChannel.name,
|
||||||
|
code: editingChannel.code,
|
||||||
|
description: editingChannel.description,
|
||||||
|
sortOrder: editingChannel.sortOrder,
|
||||||
|
status: editingChannel.status,
|
||||||
|
isDefault: editingChannel.isDefault,
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/Dashboard/index.tsx
Normal file
63
src/Dashboard/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { IconPlayCircle, IconUser, IconFile, IconCheckCircle } from "@arco-design/web-react/icon";
|
||||||
|
import { dramaApi, logApi, userApi } from "../api";
|
||||||
|
import { usePageData } from "../hooks/usePageData";
|
||||||
|
import { hasPermissions } from "../utils/permission";
|
||||||
|
import { NoAccessPage } from "../components/NoAccessPage";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { RequestLogTable } from "../components/RequestLogTable";
|
||||||
|
import type { AppUser, DashboardPageProps, Drama, MetricCardProps, RequestLog } from "./interface";
|
||||||
|
|
||||||
|
const METRIC_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
dramas: <IconPlayCircle className="text-2xl text-brand" />,
|
||||||
|
users: <IconUser className="text-2xl text-success" />,
|
||||||
|
logs: <IconFile className="text-2xl text-warning" />,
|
||||||
|
status: <IconCheckCircle className="text-2xl text-success" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const METRIC_COLORS: Record<string, string> = {
|
||||||
|
dramas: "from-brand/5 to-brand/10",
|
||||||
|
users: "from-success/5 to-success/10",
|
||||||
|
logs: "from-warning/5 to-warning/10",
|
||||||
|
status: "from-success/5 to-success/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
function MetricCard({ label, value, icon }: MetricCardProps & { icon: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`stat-card animate-slide-up bg-gradient-to-br ${METRIC_COLORS[icon]}`}>
|
||||||
|
<div className="mb-3.5 flex items-center gap-2">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center">{METRIC_ICONS[icon]}</div>
|
||||||
|
<span className="text-[1.1rem] font-medium text-muted">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[1.6rem] font-bold tracking-tight text-ink">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPage({ profile }: DashboardPageProps) {
|
||||||
|
const canReadDramas = hasPermissions(profile, ["drama:read"]);
|
||||||
|
const canReadUsers = hasPermissions(profile, ["user:read"]);
|
||||||
|
const canReadLogs = hasPermissions(profile, ["log:read"]);
|
||||||
|
const dramas = usePageData<Drama>(dramaApi.listDramas, { page: 1, pageSize: 20 }, canReadDramas);
|
||||||
|
const users = usePageData<AppUser>(userApi.listUsers, { page: 1, pageSize: 20 }, canReadUsers);
|
||||||
|
const logs = usePageData<RequestLog>(logApi.listRequestLogs, { page: 1, pageSize: 20 }, canReadLogs);
|
||||||
|
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4">
|
||||||
|
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} icon="dramas" />}
|
||||||
|
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} icon="users" />}
|
||||||
|
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} icon="logs" />}
|
||||||
|
<MetricCard label="接口状态" value="运行中" icon="status" />
|
||||||
|
</div>
|
||||||
|
{canReadLogs ? (
|
||||||
|
<Panel title="最近请求" noPadding>
|
||||||
|
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
{!hasAnyDataModule ? <NoAccessPage /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
12
src/Dashboard/interface.ts
Normal file
12
src/Dashboard/interface.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { AdminProfile, AppUser, Drama, RequestLog } from "../api";
|
||||||
|
|
||||||
|
export type { AdminProfile, AppUser, Drama, RequestLog };
|
||||||
|
|
||||||
|
export type DashboardPageProps = {
|
||||||
|
profile: AdminProfile | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetricCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
};
|
||||||
96
src/Data/index.tsx
Normal file
96
src/Data/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Grid, Message, Statistic, Table } from '@arco-design/web-react';
|
||||||
|
import { IconRefresh } from '@arco-design/web-react/icon';
|
||||||
|
import { dataApi } from '../api';
|
||||||
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface';
|
||||||
|
|
||||||
|
const { Row, Col } = Grid;
|
||||||
|
|
||||||
|
function DataPage() {
|
||||||
|
const [overview, setOverview] = useState<DataOverview>({
|
||||||
|
totalDramas: 0,
|
||||||
|
totalEpisodes: 0,
|
||||||
|
totalPlays: 0,
|
||||||
|
totalLikes: 0,
|
||||||
|
totalShares: 0,
|
||||||
|
totalComments: 0,
|
||||||
|
});
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics, { page: 1, pageSize: 20 });
|
||||||
|
const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics, { page: 1, pageSize: 20 });
|
||||||
|
const jobs = usePageData<SyncJob>(dataApi.listSyncJobs, { page: 1, pageSize: 20 });
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
setOverview(await dataApi.getDataOverview());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOverview();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncData = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
await dataApi.syncDataMetrics();
|
||||||
|
Message.success('数据同步完成');
|
||||||
|
await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel title="数据管理" extra={<Button type="primary" icon={<IconRefresh />} loading={syncing} onClick={syncData}>同步 TikTok 数据</Button>}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={4}><Statistic title="短剧数" value={overview.totalDramas} /></Col>
|
||||||
|
<Col span={4}><Statistic title="剧集数" value={overview.totalEpisodes} /></Col>
|
||||||
|
<Col span={4}><Statistic title="播放量" value={overview.totalPlays} /></Col>
|
||||||
|
<Col span={4}><Statistic title="点赞" value={overview.totalLikes} /></Col>
|
||||||
|
<Col span={4}><Statistic title="分享" value={overview.totalShares} /></Col>
|
||||||
|
<Col span={4}><Statistic title="评论" value={overview.totalComments} /></Col>
|
||||||
|
</Row>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="短剧数据" noPadding>
|
||||||
|
<Table rowKey="dramaId" loading={dramas.loading} data={dramas.data.list} pagination={{ current: dramas.data.pagination.page, total: dramas.data.pagination.total, pageSize: dramas.data.pagination.pageSize, sizeCanChange: true, showTotal: true, onChange: dramas.changePage }} columns={[
|
||||||
|
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 },
|
||||||
|
{ title: '短剧名称', dataIndex: 'title' },
|
||||||
|
{ title: '播放量', dataIndex: 'plays', width: 120 },
|
||||||
|
{ title: '点赞', dataIndex: 'likes', width: 120 },
|
||||||
|
{ title: '分享', dataIndex: 'shares', width: 120 },
|
||||||
|
{ title: '评论', dataIndex: 'comments', width: 120 },
|
||||||
|
{ title: '完播率', dataIndex: 'completionRate', width: 120, render: (value) => `${Math.round(Number(value) * 100)}%` },
|
||||||
|
{ title: '同步时间', dataIndex: 'syncedAt', width: 220 },
|
||||||
|
]} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="剧集数据" noPadding>
|
||||||
|
<Table rowKey="episodeId" loading={episodes.loading} data={episodes.data.list} pagination={{ current: episodes.data.pagination.page, total: episodes.data.pagination.total, pageSize: episodes.data.pagination.pageSize, sizeCanChange: true, showTotal: true, onChange: episodes.changePage }} columns={[
|
||||||
|
{ title: '剧集ID', dataIndex: 'episodeId', width: 100 },
|
||||||
|
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 },
|
||||||
|
{ title: '集数', dataIndex: 'episodeNo', width: 90 },
|
||||||
|
{ title: '剧集名称', dataIndex: 'title' },
|
||||||
|
{ title: '播放量', dataIndex: 'plays', width: 120 },
|
||||||
|
{ title: '点赞', dataIndex: 'likes', width: 120 },
|
||||||
|
{ title: '平均观看秒数', dataIndex: 'averageWatchSeconds', width: 150 },
|
||||||
|
{ title: '同步时间', dataIndex: 'syncedAt', width: 220 },
|
||||||
|
]} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="同步记录" noPadding>
|
||||||
|
<Table rowKey="id" loading={jobs.loading} data={jobs.data.list} pagination={{ current: jobs.data.pagination.page, total: jobs.data.pagination.total, pageSize: jobs.data.pagination.pageSize, sizeCanChange: true, showTotal: true, onChange: jobs.changePage }} columns={[
|
||||||
|
{ title: '任务ID', dataIndex: 'jobId' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 120 },
|
||||||
|
{ title: '短剧数', dataIndex: 'dramaCount', width: 120 },
|
||||||
|
{ title: '剧集数', dataIndex: 'episodeCount', width: 120 },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', width: 220 },
|
||||||
|
]} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataPage;
|
||||||
3
src/Data/interface.ts
Normal file
3
src/Data/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from "../api";
|
||||||
|
|
||||||
|
export type { DataOverview, DramaMetric, EpisodeMetric, SyncJob };
|
||||||
216
src/Drama/components/DramaDetail.tsx
Normal file
216
src/Drama/components/DramaDetail.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Descriptions, Form, InputNumber, Message, Modal, Space } from "@arco-design/web-react";
|
||||||
|
import { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon";
|
||||||
|
import { channelApi, dramaApi, mediaApi } from "../../api";
|
||||||
|
import { Panel } from "../../components/Panel";
|
||||||
|
import { toDramaPayload, toEpisodePayload } from "../formPayload";
|
||||||
|
import type { Channel, Drama, DramaDetailPageProps, Episode } from "../interface";
|
||||||
|
import { EpisodeTable } from "./EpisodeTable";
|
||||||
|
import { DramaFormFields } from "./DramaFormFields";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "scheduled", label: "定时发布" },
|
||||||
|
{ value: "published", label: "已发布" },
|
||||||
|
{ value: "offline", label: "已下线" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [drama, setDrama] = useState<Drama | null>(null);
|
||||||
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [episodeCount, setEpisodeCount] = useState(1);
|
||||||
|
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
|
||||||
|
const [editVisible, setEditVisible] = useState(false);
|
||||||
|
const [updatingDrama, setUpdatingDrama] = useState(false);
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [dramaData, episodeList, channelPage] = await Promise.all([dramaApi.getDrama(dramaId), dramaApi.getDramaEpisodes(dramaId), channelApi.listChannels({ page: 1, pageSize: 100 }).catch(() => ({ list: [] }))]);
|
||||||
|
setDrama(dramaData);
|
||||||
|
setEpisodeCount(dramaData.totalEpisodes || 1);
|
||||||
|
setEpisodes(episodeList);
|
||||||
|
setChannels(channelPage.list);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDetail();
|
||||||
|
}, [dramaId]);
|
||||||
|
|
||||||
|
const configureEpisodes = async () => {
|
||||||
|
await dramaApi.configureDramaEpisodes(dramaId, episodeCount);
|
||||||
|
Message.success("剧集数量已更新");
|
||||||
|
await loadDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDramaStatus = async (status: string) => {
|
||||||
|
await dramaApi.updateDrama(dramaId, { status });
|
||||||
|
Message.success("短剧状态已更新");
|
||||||
|
await loadDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDrama = async (values: Partial<Drama>) => {
|
||||||
|
setUpdatingDrama(true);
|
||||||
|
try {
|
||||||
|
await dramaApi.updateDrama(dramaId, toDramaPayload(values));
|
||||||
|
Message.success("短剧信息已更新");
|
||||||
|
setEditVisible(false);
|
||||||
|
await loadDetail();
|
||||||
|
} finally {
|
||||||
|
setUpdatingDrama(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
|
||||||
|
setUploadingEpisodeId(episode.id);
|
||||||
|
try {
|
||||||
|
const task = await mediaApi.createUploadTask({
|
||||||
|
episodeId: episode.id,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type || "video/mp4",
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
await mediaApi.completeUploadTask(task.jobId);
|
||||||
|
await mediaApi.syncReviewStatus();
|
||||||
|
Message.success("视频上传并审核通过");
|
||||||
|
await loadDetail();
|
||||||
|
} finally {
|
||||||
|
setUploadingEpisodeId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishEpisode = async (episode: Episode) => {
|
||||||
|
await dramaApi.updateEpisode(episode.id, {
|
||||||
|
publishStatus: "published",
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
Message.success("剧集已发布");
|
||||||
|
await loadDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEpisode = async (episode: Episode, values: Partial<Episode>) => {
|
||||||
|
await dramaApi.updateDramaEpisode(dramaId, episode.id, toEpisodePayload(values));
|
||||||
|
Message.success("剧集信息已更新");
|
||||||
|
await loadDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<nav className="w-full box-border bg-[#fff] p-4">
|
||||||
|
<div className="w-full flex justify-between items-center font-bold text-[1rem]">
|
||||||
|
<Space size={12}>
|
||||||
|
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
|
||||||
|
<span>短剧详情</span>
|
||||||
|
</Space>
|
||||||
|
<Button icon={<IconRefresh />} onClick={loadDetail}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<Panel>
|
||||||
|
{loading && !drama ? (
|
||||||
|
<div className="py-12 text-center text-muted">加载中...</div>
|
||||||
|
) : drama ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Space wrap>
|
||||||
|
<Button disabled={!drama} onClick={() => setEditVisible(true)}>
|
||||||
|
编辑短剧
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => updateDramaStatus("draft")}>设为草稿</Button>
|
||||||
|
<Button type="primary" onClick={() => updateDramaStatus("published")}>
|
||||||
|
上架短剧
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<img src={drama.coverUrl} alt={drama.title} className="w-full h-[13rem] object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
data={[
|
||||||
|
{ label: "短剧ID", value: drama.id },
|
||||||
|
{ label: "名称", value: drama.title },
|
||||||
|
{ label: "频道", value: drama.channelName || "-" },
|
||||||
|
{ label: "地区", value: drama.region || "-" },
|
||||||
|
{ label: "类型", value: drama.type || "-" },
|
||||||
|
{ label: "总集数", value: drama.totalEpisodes || 0 },
|
||||||
|
{ label: "已发布集数", value: drama.publishedEpisodes || 0 },
|
||||||
|
{ label: "状态", value: PUBLISH_STATUS_LABEL[drama.status] || drama.status },
|
||||||
|
{ label: "简介", value: drama.description || "-" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="剧集配置"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<InputNumber min={1} max={500} value={episodeCount} onChange={(value) => setEpisodeCount(Number(value) || 1)} />
|
||||||
|
<Button type="primary" onClick={configureEpisodes}>
|
||||||
|
保存集数
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
noPadding
|
||||||
|
>
|
||||||
|
<EpisodeTable episodes={episodes} loading={loading} uploadingEpisodeId={uploadingEpisodeId} onUploadVideo={uploadEpisodeVideo} onPublishEpisode={publishEpisode} onUpdateEpisode={updateEpisode} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drama && (
|
||||||
|
<Modal title="编辑短剧" visible={editVisible} onCancel={() => setEditVisible(false)} footer={null} style={{ width: "50rem" }}>
|
||||||
|
<Form
|
||||||
|
key={drama.id}
|
||||||
|
layout="vertical"
|
||||||
|
onSubmit={updateDrama}
|
||||||
|
initialValues={{
|
||||||
|
title: drama.title,
|
||||||
|
type: drama.type,
|
||||||
|
status: drama.status,
|
||||||
|
coverUrl: drama.coverUrl,
|
||||||
|
description: drama.description,
|
||||||
|
tags: drama.tags,
|
||||||
|
region: drama.region,
|
||||||
|
language: drama.language,
|
||||||
|
year: drama.year,
|
||||||
|
sortOrder: drama.sortOrder,
|
||||||
|
isRecommended: drama.isRecommended,
|
||||||
|
channelId: drama.channelId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DramaFormFields channels={channels} coverUrl={drama.coverUrl} statusOptions={STATUS_OPTIONS} />
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setEditVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={updatingDrama}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/Drama/components/DramaFormFields.tsx
Normal file
114
src/Drama/components/DramaFormFields.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Form, Grid, Input, InputNumber, InputTag, Select, Switch } from "@arco-design/web-react";
|
||||||
|
import { IconPlus } from "@arco-design/web-react/icon";
|
||||||
|
import type { Channel, StatusOption } from "../interface";
|
||||||
|
|
||||||
|
const { Row, Col } = Grid;
|
||||||
|
|
||||||
|
type DramaFormFieldsProps = {
|
||||||
|
channels: Channel[];
|
||||||
|
coverUrl?: string;
|
||||||
|
statusOptions: StatusOption[];
|
||||||
|
showTotalEpisodes?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DramaFormFields({ channels, coverUrl, statusOptions, showTotalEpisodes = false }: DramaFormFieldsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="relative flex aspect-[3/4] min-h-[16rem] items-center justify-center overflow-hidden rounded-lg border border-dashed border-line bg-canvas/40 text-muted">
|
||||||
|
{coverUrl ? (
|
||||||
|
<>
|
||||||
|
<img src={coverUrl} alt="短剧封面" className="h-full w-full object-cover" />
|
||||||
|
<span className="absolute bottom-2 left-2 rounded bg-black/55 px-2 py-1 text-xs text-white">当前封面</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<IconPlus className="text-3xl text-brand" />
|
||||||
|
<span className="text-sm">上传竖封面</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea autoSize={{ minRows: 5, maxRows: 5 }} placeholder="请输入封面图片 URL" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入短剧标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="频道" field="channelId">
|
||||||
|
<Select placeholder="默认频道" allowClear>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<Select.Option key={channel.id} value={channel.id}>
|
||||||
|
{channel.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="状态" field="status">
|
||||||
|
<Select placeholder="请选择状态">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<Select.Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="类型" field="type">
|
||||||
|
<Input placeholder="请输入类型" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item label="简介" field="description">
|
||||||
|
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="标签" field="tags">
|
||||||
|
<InputTag placeholder="输入后回车添加标签" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="地区" field="region">
|
||||||
|
<Input placeholder="如 中国" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="语言" field="language">
|
||||||
|
<Input placeholder="如 zh-CN" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="年份" field="year">
|
||||||
|
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={showTotalEpisodes ? 8 : 12}>
|
||||||
|
<Form.Item label="排序权重" field="sortOrder">
|
||||||
|
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
{showTotalEpisodes && (
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={showTotalEpisodes ? 8 : 12}>
|
||||||
|
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
src/Drama/components/DramaList.tsx
Normal file
247
src/Drama/components/DramaList.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Form, Grid, Input, Message, Modal, Select, Space, Table } from "@arco-design/web-react";
|
||||||
|
import { IconDelete, IconEdit, IconPlus, IconSearch, IconRefresh, IconStarFill } from "@arco-design/web-react/icon";
|
||||||
|
import { channelApi, dramaApi } from "../../api";
|
||||||
|
import type { DramaQuery } from "../../api/interface";
|
||||||
|
import { usePageData } from "../../hooks/usePageData";
|
||||||
|
import { Panel } from "../../components/Panel";
|
||||||
|
import { toDramaPayload } from "../formPayload";
|
||||||
|
import type { Channel, Drama, StatusOption } from "../interface";
|
||||||
|
import { DramaFormFields } from "./DramaFormFields";
|
||||||
|
|
||||||
|
const { Row, Col } = Grid;
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: StatusOption[] = [
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "scheduled", label: "定时发布" },
|
||||||
|
{ value: "published", label: "已发布" },
|
||||||
|
{ value: "offline", label: "已下线" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_QUERY: DramaQuery = { page: 1, pageSize: 20 };
|
||||||
|
|
||||||
|
function EpisodeProgress({ published = 0, total = 0 }: { published?: number; total?: number }) {
|
||||||
|
const percent = total > 0 ? Math.min(100, Math.max(0, (published / total) * 100)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[8.75rem]">
|
||||||
|
<div className="mb-3 leading-none text-ink">
|
||||||
|
{published} / {total}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-[#eef1f5]">
|
||||||
|
<div className="h-full rounded-full bg-[#12aaa2]" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const colorClass =
|
||||||
|
{
|
||||||
|
published: "bg-[#e6f8ee] text-[#249455]",
|
||||||
|
draft: "bg-[#fff0e6] text-[#d8781f]",
|
||||||
|
scheduled: "bg-[#e8f3ff] text-[#2673dd]",
|
||||||
|
offline: "bg-[#f0f2f5] text-[#697586]",
|
||||||
|
}[status] ?? "bg-[#f0f2f5] text-[#697586]";
|
||||||
|
|
||||||
|
return <span className={`inline-flex min-w-[3.9rem] items-center justify-center rounded-md px-3 py-2 font-semibold ${colorClass}`}>{PUBLISH_STATUS_LABEL[status] || status}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationPill({ recommended }: { recommended: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex min-w-[3.9rem] items-center justify-center gap-2 rounded-md px-3 py-2 font-semibold ${recommended ? "bg-[#fff5df] text-[#d58a18]" : "bg-[#f3f5f8] text-[#343a46]"}`}>
|
||||||
|
<IconStarFill className={recommended ? "text-[#ffad14]" : "text-[#a4adba]"} />
|
||||||
|
{recommended ? "是" : "否"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionTextButton({ tone, icon, children, onClick }: { tone: "primary" | "danger"; icon: React.ReactNode; children: React.ReactNode; onClick: () => void }) {
|
||||||
|
const colorClass = tone === "primary" ? "text-[#1f6feb] hover:text-[#1457d9]" : "text-[#ff3b3b] hover:text-[#d92d2d]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center gap-2 whitespace-nowrap border-0 bg-transparent p-0 font-semibold leading-none transition-colors ${colorClass}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="text-[1.25rem] leading-none">{icon}</span>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DramaList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, loading, reload, changePage, setFilter } = usePageData<Drama, DramaQuery>(dramaApi.listDramas, INITIAL_QUERY);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [filterTitle, setFilterTitle] = useState("");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [filterType, setFilterType] = useState("");
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const defaultChannel = useMemo(() => channels.find((channel) => channel.isDefault) ?? channels[0], [channels]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
channelApi
|
||||||
|
.listChannels({ page: 1, pageSize: 100 })
|
||||||
|
.then((page) => setChannels(page.list))
|
||||||
|
.catch(() => setChannels([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilter({ title: filterTitle || undefined, status: filterStatus, type: filterType || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFilterTitle("");
|
||||||
|
setFilterStatus(undefined);
|
||||||
|
setFilterType("");
|
||||||
|
setFilter({ title: undefined, status: undefined, type: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDrama = async (values: Partial<Drama>) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await dramaApi.createDrama(toDramaPayload(values));
|
||||||
|
Message.success("短剧创建成功");
|
||||||
|
setVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDrama = (drama: Drama) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "删除短剧",
|
||||||
|
content: `确认删除短剧「${drama.title}」?删除后该短剧下的剧集也会同步删除。`,
|
||||||
|
okButtonProps: { status: "danger" },
|
||||||
|
onOk: async () => {
|
||||||
|
await dramaApi.deleteDrama(drama.id);
|
||||||
|
Message.success("短剧已删除");
|
||||||
|
await reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel
|
||||||
|
title="短剧管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||||
|
新增短剧
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={16} align="center">
|
||||||
|
<Col span={6}>
|
||||||
|
<div className="form-label">短剧标题</div>
|
||||||
|
<Input placeholder="搜索短剧标题" value={filterTitle} onChange={setFilterTitle} allowClear />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<div className="form-label">状态</div>
|
||||||
|
<Select placeholder="全部状态" value={filterStatus} onChange={setFilterStatus} allowClear>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<Select.Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<div className="form-label">类型</div>
|
||||||
|
<Input placeholder="搜索类型" value={filterType} onChange={setFilterType} allowClear />
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<div className="form-label invisible">操作</div>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<IconSearch />} onClick={handleSearch}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button icon={<IconRefresh />} onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel noPadding>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={data.list}
|
||||||
|
pagination={{
|
||||||
|
current: data.pagination.page,
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
total: data.pagination.total,
|
||||||
|
pageSize: data.pagination.pageSize,
|
||||||
|
onChange: (page, pageSize) => changePage(page, pageSize),
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 80 },
|
||||||
|
{ title: "封面图", dataIndex: "coverUrl", width: 120, render: (value) => (value ? <img src={value} alt="封面图" className="rounded-lg object-cover" style={{ width: 80, height: 80 }} /> : null) },
|
||||||
|
{ title: "标题", dataIndex: "title" },
|
||||||
|
{ title: "地区", dataIndex: "region", width: 120 },
|
||||||
|
{ title: "类型", dataIndex: "type", width: 140 },
|
||||||
|
{
|
||||||
|
title: "已发布 / 总集数",
|
||||||
|
dataIndex: "totalEpisodes",
|
||||||
|
width: 220,
|
||||||
|
render: (_, record) => <EpisodeProgress published={record.publishedEpisodes || 0} total={record.totalEpisodes || 0} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 150,
|
||||||
|
render: (status) => <StatusPill status={String(status)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "推荐",
|
||||||
|
dataIndex: "isRecommended",
|
||||||
|
width: 130,
|
||||||
|
render: (value) => <RecommendationPill recommended={Boolean(value)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 220,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={24}>
|
||||||
|
<ActionTextButton tone="primary" icon={<IconEdit />} onClick={() => navigate(`/dramas/${record.id}`)}>
|
||||||
|
详情
|
||||||
|
</ActionTextButton>
|
||||||
|
<ActionTextButton tone="danger" icon={<IconDelete />} onClick={() => deleteDrama(record)}>
|
||||||
|
删除
|
||||||
|
</ActionTextButton>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: "56rem" }}>
|
||||||
|
<Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1, channelId: defaultChannel?.id }}>
|
||||||
|
<DramaFormFields channels={channels} statusOptions={STATUS_OPTIONS} showTotalEpisodes />
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/Drama/components/EpisodeTable.tsx
Normal file
204
src/Drama/components/EpisodeTable.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Form, Grid, Input, InputNumber, Modal, Select, Space, Table, Tag } from "@arco-design/web-react";
|
||||||
|
import type { Episode, EpisodeTableProps } from "../interface";
|
||||||
|
|
||||||
|
const { Row, Col } = Grid;
|
||||||
|
|
||||||
|
const REVIEW_STATUS_LABEL: Record<string, string> = {
|
||||||
|
not_submitted: "未提交",
|
||||||
|
pending: "待审核",
|
||||||
|
reviewing: "审核中",
|
||||||
|
approved: "已通过",
|
||||||
|
rejected: "已拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLISH_STATUS_OPTIONS = [
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "scheduled", label: "定时发布" },
|
||||||
|
{ value: "published", label: "已发布" },
|
||||||
|
{ value: "offline", label: "已下线" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVideo, onPublishEpisode, onUpdateEpisode }: EpisodeTableProps) {
|
||||||
|
const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
const submitEpisode = async (values: Partial<Episode>) => {
|
||||||
|
if (!editingEpisode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
await onUpdateEpisode(editingEpisode, values);
|
||||||
|
setEditingEpisode(null);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={episodes}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: "集数", dataIndex: "episodeNo", width: 80 },
|
||||||
|
{ title: "标题", dataIndex: "title" },
|
||||||
|
{
|
||||||
|
title: "BytePlus VID",
|
||||||
|
dataIndex: "byteplusVid",
|
||||||
|
width: 180,
|
||||||
|
render: (value) => value || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "尺寸",
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "时长",
|
||||||
|
dataIndex: "durationSeconds",
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (value ? `${value}s` : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "审核状态",
|
||||||
|
dataIndex: "reviewStatus",
|
||||||
|
width: 120,
|
||||||
|
render: (status) => <Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "发布状态",
|
||||||
|
dataIndex: "publishStatus",
|
||||||
|
width: 120,
|
||||||
|
render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 340,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button loading={uploadingEpisodeId === record.id}>
|
||||||
|
<label>
|
||||||
|
上传视频
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void onUploadVideo(record, file);
|
||||||
|
}
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
<Button type="text" onClick={() => setEditingEpisode(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editingEpisode && (
|
||||||
|
<Modal title={`编辑第 ${editingEpisode.episodeNo} 集`} visible={Boolean(editingEpisode)} onCancel={() => setEditingEpisode(null)} footer={null} style={{ width: "42.5rem" }}>
|
||||||
|
<Form
|
||||||
|
key={editingEpisode.id}
|
||||||
|
layout="vertical"
|
||||||
|
onSubmit={submitEpisode}
|
||||||
|
initialValues={{
|
||||||
|
title: editingEpisode.title,
|
||||||
|
description: editingEpisode.description,
|
||||||
|
coverUrl: editingEpisode.coverUrl,
|
||||||
|
durationSeconds: editingEpisode.durationSeconds,
|
||||||
|
width: editingEpisode.width,
|
||||||
|
height: editingEpisode.height,
|
||||||
|
freeType: editingEpisode.freeType,
|
||||||
|
price: editingEpisode.price,
|
||||||
|
publishStatus: editingEpisode.publishStatus,
|
||||||
|
nextReleaseAt: editingEpisode.nextReleaseAt,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入剧集标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="简介" field="description">
|
||||||
|
<Input.TextArea placeholder="请输入剧集简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="封面地址" field="coverUrl">
|
||||||
|
<Input placeholder="请输入封面图片 URL" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="时长(秒)" field="durationSeconds" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="宽度" field="width">
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="高度" field="height">
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="付费类型" field="freeType">
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="free">免费</Select.Option>
|
||||||
|
<Select.Option value="paid">付费</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="价格" field="price">
|
||||||
|
<InputNumber min={0} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="发布状态" field="publishStatus">
|
||||||
|
<Select>
|
||||||
|
{PUBLISH_STATUS_OPTIONS.map((option) => (
|
||||||
|
<Select.Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item label="下次释放时间" field="nextReleaseAt">
|
||||||
|
<Input placeholder="如 2026-07-02T10:00:00.000Z" />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setEditingEpisode(null)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={updating}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/Drama/formPayload.ts
Normal file
84
src/Drama/formPayload.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Drama, Episode } from "../api";
|
||||||
|
|
||||||
|
type DramaFormValues = Partial<
|
||||||
|
Pick<
|
||||||
|
Drama,
|
||||||
|
| "title"
|
||||||
|
| "type"
|
||||||
|
| "status"
|
||||||
|
| "coverUrl"
|
||||||
|
| "channelId"
|
||||||
|
| "description"
|
||||||
|
| "tags"
|
||||||
|
| "region"
|
||||||
|
| "language"
|
||||||
|
| "year"
|
||||||
|
| "sortOrder"
|
||||||
|
| "isRecommended"
|
||||||
|
| "totalEpisodes"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type EpisodeFormValues = Partial<
|
||||||
|
Pick<
|
||||||
|
Episode,
|
||||||
|
| "title"
|
||||||
|
| "description"
|
||||||
|
| "coverUrl"
|
||||||
|
| "durationSeconds"
|
||||||
|
| "width"
|
||||||
|
| "height"
|
||||||
|
| "freeType"
|
||||||
|
| "price"
|
||||||
|
| "publishStatus"
|
||||||
|
| "nextReleaseAt"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const normalizeOptionalString = (value?: string) => {
|
||||||
|
const nextValue = value?.trim();
|
||||||
|
return nextValue ? nextValue : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePositiveInteger = (value?: number) => {
|
||||||
|
const nextValue = Number(value);
|
||||||
|
return Number.isInteger(nextValue) && nextValue > 0 ? nextValue : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNonNegativeInteger = (value?: number) => {
|
||||||
|
const nextValue = Number(value);
|
||||||
|
return Number.isInteger(nextValue) && nextValue >= 0 ? nextValue : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toDramaPayload(values: DramaFormValues): DramaFormValues {
|
||||||
|
return {
|
||||||
|
title: normalizeOptionalString(values.title),
|
||||||
|
type: normalizeOptionalString(values.type),
|
||||||
|
status: values.status,
|
||||||
|
coverUrl: normalizeOptionalString(values.coverUrl),
|
||||||
|
channelId: normalizePositiveInteger(values.channelId),
|
||||||
|
description: normalizeOptionalString(values.description),
|
||||||
|
tags: values.tags?.filter(Boolean),
|
||||||
|
region: normalizeOptionalString(values.region),
|
||||||
|
language: normalizeOptionalString(values.language),
|
||||||
|
year: normalizePositiveInteger(values.year),
|
||||||
|
sortOrder: normalizeNonNegativeInteger(values.sortOrder),
|
||||||
|
isRecommended: Boolean(values.isRecommended),
|
||||||
|
totalEpisodes: normalizePositiveInteger(values.totalEpisodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEpisodePayload(values: EpisodeFormValues): EpisodeFormValues {
|
||||||
|
return {
|
||||||
|
title: normalizeOptionalString(values.title),
|
||||||
|
description: normalizeOptionalString(values.description),
|
||||||
|
coverUrl: normalizeOptionalString(values.coverUrl),
|
||||||
|
durationSeconds: normalizePositiveInteger(values.durationSeconds),
|
||||||
|
width: normalizePositiveInteger(values.width),
|
||||||
|
height: normalizePositiveInteger(values.height),
|
||||||
|
freeType: values.freeType,
|
||||||
|
price: normalizeNonNegativeInteger(values.price),
|
||||||
|
publishStatus: values.publishStatus,
|
||||||
|
nextReleaseAt: normalizeOptionalString(values.nextReleaseAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/Drama/index.tsx
Normal file
15
src/Drama/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { DramaDetail } from "./components/DramaDetail";
|
||||||
|
import { DramaList } from "./components/DramaList";
|
||||||
|
|
||||||
|
function DramasPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
return <DramaDetail dramaId={Number(id)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DramaList />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DramasPage;
|
||||||
21
src/Drama/interface.ts
Normal file
21
src/Drama/interface.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Channel, Drama, Episode } from "../api";
|
||||||
|
|
||||||
|
export type { Channel, Drama, Episode };
|
||||||
|
|
||||||
|
export type StatusOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DramaDetailPageProps = {
|
||||||
|
dramaId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeTableProps = {
|
||||||
|
episodes: Episode[];
|
||||||
|
loading: boolean;
|
||||||
|
uploadingEpisodeId: number | null;
|
||||||
|
onUploadVideo: (episode: Episode, file: File) => void | Promise<void>;
|
||||||
|
onPublishEpisode: (episode: Episode) => void | Promise<void>;
|
||||||
|
onUpdateEpisode: (episode: Episode, values: Partial<Episode>) => void | Promise<void>;
|
||||||
|
};
|
||||||
18
src/Log/index.tsx
Normal file
18
src/Log/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { logApi } from '../api';
|
||||||
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import { RequestLogTable } from '../components/RequestLogTable';
|
||||||
|
import type { RequestLog } from './interface';
|
||||||
|
|
||||||
|
function LogsPage() {
|
||||||
|
const { data, loading } = usePageData<RequestLog>(logApi.listRequestLogs, { page: 1, pageSize: 20 });
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel title="请求日志" noPadding>
|
||||||
|
<RequestLogTable data={data.list} loading={loading} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogsPage;
|
||||||
3
src/Log/interface.ts
Normal file
3
src/Log/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RequestLog } from "../api";
|
||||||
|
|
||||||
|
export type { RequestLog };
|
||||||
27
src/Login/components/LoginPanel.tsx
Normal file
27
src/Login/components/LoginPanel.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Button, Form, Input } from "@arco-design/web-react";
|
||||||
|
import { IconLock, IconUser } from "@arco-design/web-react/icon";
|
||||||
|
import type { LoginPanelProps } from "../interface";
|
||||||
|
|
||||||
|
export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center bg-canvas bg-[image:linear-gradient(180deg,rgba(255,255,255,0.86),rgba(242,243,245,0.94))] p-12">
|
||||||
|
<div className="w-full max-w-[26.25rem] rounded-2xl border border-line/40 bg-white p-10 shadow-login">
|
||||||
|
<div className="mb-8 flex flex-col items-center text-center">
|
||||||
|
<h2 className="mb-2 w-full mt-0 text-[1.375rem] font-bold text-ink">后台登录</h2>
|
||||||
|
<p className="m-0 w-full text-[0.8125rem] text-muted">使用管理员账号进入控制台</p>
|
||||||
|
</div>
|
||||||
|
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: "admin", password: "admin123" }}>
|
||||||
|
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
|
||||||
|
<Input prefix={<IconUser className="text-muted" />} placeholder="请输入用户名" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="密码" field="password" rules={[{ required: true }]}>
|
||||||
|
<Input.Password prefix={<IconLock className="text-muted" />} placeholder="请输入密码" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} long size="large" className="!h-11 mt-6 !text-[0.9375rem] !font-semibold !shadow-brand/30">
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
src/Login/components/LoginVisual.tsx
Normal file
29
src/Login/components/LoginVisual.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function LoginVisual() {
|
||||||
|
return (
|
||||||
|
<div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]">
|
||||||
|
<div className="relative z-[1] flex items-center gap-2.5 font-bold">
|
||||||
|
<span className="grid h-9 w-9 place-items-center bg-white/20 text-sm font-bold backdrop-blur-sm">CT</span>
|
||||||
|
<span className="text-white/90 text-[0.9375rem]">cth-tk-admin</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[1] max-w-[40rem]">
|
||||||
|
<span className="mb-5 inline-flex rounded-full border border-white/20 bg-white/10 px-4 py-2 text-[0.8125rem] text-white/80 backdrop-blur-sm">TikTok 短剧运营后台</span>
|
||||||
|
<h1 className="mb-5 mt-0 text-[2.75rem] font-bold leading-[1.16] tracking-tight">CTH TikTok 短剧管理后台</h1>
|
||||||
|
<p className="m-0 max-w-[35rem] text-base leading-[1.8] text-white/70">集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[1] grid max-w-[40rem] grid-cols-3 gap-4">
|
||||||
|
<div className="border bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
|
||||||
|
<strong className="mb-2 block text-xl font-semibold">内容</strong>
|
||||||
|
<span className="text-[0.8125rem] text-white/65">短剧与剧集管理</span>
|
||||||
|
</div>
|
||||||
|
<div className="border bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
|
||||||
|
<strong className="mb-2 block text-xl font-semibold">权限</strong>
|
||||||
|
<span className="text-[0.8125rem] text-white/65">角色化后台授权</span>
|
||||||
|
</div>
|
||||||
|
<div className="border bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
|
||||||
|
<strong className="mb-2 block text-xl font-semibold">日志</strong>
|
||||||
|
<span className="text-[0.8125rem] text-white/65">请求与播放追踪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/Login/index.tsx
Normal file
33
src/Login/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Message } from '@arco-design/web-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '../api';
|
||||||
|
import { LoginPanel } from './components/LoginPanel';
|
||||||
|
import { LoginVisual } from './components/LoginVisual';
|
||||||
|
import type { LoginFormValues } from './interface';
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (values: LoginFormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await authApi.login(values);
|
||||||
|
authApi.saveAuthToken(data.accessToken);
|
||||||
|
Message.success('登录成功');
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen grid-cols-1 bg-canvas min-[901px]:grid-cols-[minmax(32.5rem,0.95fr)_minmax(26.25rem,1fr)]">
|
||||||
|
<LoginVisual />
|
||||||
|
<LoginPanel loading={loading} apiBaseUrl={authApi.getApiBaseUrl()} onSubmit={onSubmit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
9
src/Login/interface.ts
Normal file
9
src/Login/interface.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { LoginPayload } from "../api";
|
||||||
|
|
||||||
|
export type LoginFormValues = LoginPayload;
|
||||||
|
|
||||||
|
export type LoginPanelProps = {
|
||||||
|
loading: boolean;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
onSubmit: (values: LoginFormValues) => void | Promise<void>;
|
||||||
|
};
|
||||||
140
src/Personnel/index.tsx
Normal file
140
src/Personnel/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
|
||||||
|
import { IconPlus } from '@arco-design/web-react/icon';
|
||||||
|
import { personnelApi, roleApi } from '../api';
|
||||||
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
|
||||||
|
|
||||||
|
function PersonnelPage() {
|
||||||
|
const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel, { page: 1, pageSize: 20 });
|
||||||
|
const roles = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [assignVisible, setAssignVisible] = useState(false);
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
const [selectedAdmin, setSelectedAdmin] = useState<AdminUser | null>(null);
|
||||||
|
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPersonnel = async (values: CreatePersonnelFormValues) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await personnelApi.createPersonnel(values);
|
||||||
|
Message.success('人员创建成功');
|
||||||
|
setCreateVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssignRoles = (admin: AdminUser) => {
|
||||||
|
setSelectedAdmin(admin);
|
||||||
|
setSelectedRoleIds(admin.roles.map((role) => role.id));
|
||||||
|
setAssignVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAssignedRoles = async () => {
|
||||||
|
if (!selectedAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAssigning(true);
|
||||||
|
try {
|
||||||
|
await personnelApi.updateAdminUserRoles(selectedAdmin.id, selectedRoleIds);
|
||||||
|
Message.success('角色分配已更新');
|
||||||
|
setAssignVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
|
||||||
|
await personnelApi.updateAdminUserStatus(id, status);
|
||||||
|
Message.success('人员状态已更新');
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleOptions = roles.data.list.map((role) => (
|
||||||
|
<Select.Option key={role.id} value={role.id}>{role.name}</Select.Option>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}>新增人员</Button>} noPadding>
|
||||||
|
<Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, sizeCanChange: true, showTotal: true, onChange: changePage }} columns={[
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
|
{ title: '姓名', dataIndex: 'nickname' },
|
||||||
|
{ title: '邮箱', dataIndex: 'email' },
|
||||||
|
{ title: '角色', render: (_, record: AdminUser) => <Space wrap>{record.isSuperAdmin ? <Tag color="gold">超级管理员</Tag> : record.roles.map((role) => <Tag key={role.id}>{role.name}</Tag>)}</Space> },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'gray'}>{status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', width: 190 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, record: AdminUser) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openAssignRoles(record)} disabled={record.isSuperAdmin}>分配角色</Button>
|
||||||
|
<Button size="small" status={record.status === 'active' ? 'danger' : 'success'} onClick={() => updateStatus(record.id, record.status === 'active' ? 'disabled' : 'active')} disabled={record.isSuperAdmin}>
|
||||||
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
<Modal title="新增人员" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null} style={{ width: "40rem" }}>
|
||||||
|
<Form form={createForm} layout="vertical" onSubmit={createPersonnel}>
|
||||||
|
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入登录用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="初始密码" field="password" rules={[{ required: true }, { minLength: 6, message: '密码至少 6 位' }]}>
|
||||||
|
<Input.Password placeholder="请输入初始密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="姓名" field="nickname">
|
||||||
|
<Input placeholder="请输入姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="邮箱" field="email" rules={[{ type: 'email', message: '请输入正确的邮箱地址' }]}>
|
||||||
|
<Input placeholder="请输入邮箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="角色" field="roleIds">
|
||||||
|
<Select mode="multiple" placeholder="请选择角色" allowClear>
|
||||||
|
{roleOptions}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setCreateVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating}>创建</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={`分配角色${selectedAdmin ? ` - ${selectedAdmin.username}` : ''}`}
|
||||||
|
visible={assignVisible}
|
||||||
|
confirmLoading={assigning}
|
||||||
|
onOk={saveAssignedRoles}
|
||||||
|
onCancel={() => setAssignVisible(false)}
|
||||||
|
style={{ width: "40rem" }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择角色"
|
||||||
|
value={selectedRoleIds}
|
||||||
|
onChange={(value) => setSelectedRoleIds(value as number[])}
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
{roleOptions}
|
||||||
|
</Select>
|
||||||
|
</Modal>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonnelPage;
|
||||||
5
src/Personnel/interface.ts
Normal file
5
src/Personnel/interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AdminUser, CreatePersonnelPayload, Role } from "../api";
|
||||||
|
|
||||||
|
export type { AdminUser, Role };
|
||||||
|
|
||||||
|
export type CreatePersonnelFormValues = CreatePersonnelPayload;
|
||||||
21
src/Profile/components/ProfileInfoList.tsx
Normal file
21
src/Profile/components/ProfileInfoList.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ProfileInfoItemProps, ProfileInfoListProps } from "../interface";
|
||||||
|
|
||||||
|
export function ProfileInfoList({ profile }: ProfileInfoListProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ProfileInfoItem label="用户名" value={profile?.username || "-"} />
|
||||||
|
<ProfileInfoItem label="状态" value={profile?.status === "active" ? "启用" : "禁用"} />
|
||||||
|
<ProfileInfoItem label="角色" value={profile?.isSuperAdmin ? "超级管理员" : profile?.roles.map((role) => role.name).join("、") || "-"} />
|
||||||
|
<ProfileInfoItem label="创建时间" value={profile?.createdAt || "-"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileInfoItem({ label, value }: ProfileInfoItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-line/50 bg-canvas/50 p-4 transition-all duration-200 hover:border-line hover:bg-canvas">
|
||||||
|
<span className="mb-2 block text-[0.75rem] font-medium uppercase tracking-wider text-muted">{label}</span>
|
||||||
|
<strong className="break-words text-[0.9375rem] font-semibold text-ink">{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/Profile/components/ProfileSummary.tsx
Normal file
16
src/Profile/components/ProfileSummary.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Avatar } from "@arco-design/web-react";
|
||||||
|
import type { ProfileSummaryProps } from "../interface";
|
||||||
|
|
||||||
|
export function ProfileSummary({ profile }: ProfileSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-center gap-5 rounded-2xl bg-gradient-to-br from-brand/5 to-brand/10 p-6 ring-1 ring-brand/10">
|
||||||
|
<Avatar size={64} className="!bg-brand !text-white !text-xl !font-bold !shadow-lg !shadow-brand/30">
|
||||||
|
{profile?.username?.slice(0, 1).toUpperCase() || "A"}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 mt-0 text-xl font-bold text-ink">{profile?.nickname || profile?.username || "管理员"}</h2>
|
||||||
|
<p className="m-0 text-[0.8125rem] text-muted">{profile?.email || "未设置邮箱"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/Profile/index.tsx
Normal file
111
src/Profile/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Form, Input, Message } from '@arco-design/web-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '../api';
|
||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import { ProfileInfoList } from './components/ProfileInfoList';
|
||||||
|
import { ProfileSummary } from './components/ProfileSummary';
|
||||||
|
import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface';
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [passwordForm] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<AdminProfile | null>(null);
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await authApi.getProfile();
|
||||||
|
setProfile(data);
|
||||||
|
form.setFieldsValue({
|
||||||
|
nickname: data.nickname,
|
||||||
|
email: data.email,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateProfile = async (values: ProfileFormValues) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data = await authApi.updateProfile(values);
|
||||||
|
setProfile(data);
|
||||||
|
Message.success('个人信息已更新');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (values: ChangePasswordFormValues) => {
|
||||||
|
if (values.newPassword !== values.confirmPassword) {
|
||||||
|
Message.error('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChangingPassword(true);
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({
|
||||||
|
oldPassword: values.oldPassword,
|
||||||
|
newPassword: values.newPassword,
|
||||||
|
});
|
||||||
|
Message.success('密码修改成功,请重新登录');
|
||||||
|
passwordForm.resetFields();
|
||||||
|
authApi.clearAuthToken();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div className="grid items-start gap-5 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
|
<Panel title="个人中心" className="min-[901px]:row-span-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 text-center text-muted">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ProfileSummary profile={profile} />
|
||||||
|
<ProfileInfoList profile={profile} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<Panel title="修改个人信息">
|
||||||
|
<Form form={form} layout="vertical" onSubmit={updateProfile}>
|
||||||
|
<Form.Item label="姓名" field="nickname">
|
||||||
|
<Input placeholder="请输入姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="邮箱" field="email" rules={[{ type: 'email', message: '请输入正确的邮箱地址' }]}>
|
||||||
|
<Input placeholder="请输入邮箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>保存修改</Button>
|
||||||
|
</Form>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="修改密码">
|
||||||
|
<Form form={passwordForm} layout="vertical" onSubmit={changePassword}>
|
||||||
|
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="请输入旧密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="新密码" field="newPassword" rules={[{ required: true }, { minLength: 6, message: '新密码至少 6 位' }]}>
|
||||||
|
<Input.Password placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="确认新密码" field="confirmPassword" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="请再次输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={changingPassword}>确认修改</Button>
|
||||||
|
</Form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
22
src/Profile/interface.ts
Normal file
22
src/Profile/interface.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AdminProfile, ChangePasswordPayload, UpdateProfilePayload } from "../api";
|
||||||
|
|
||||||
|
export type { AdminProfile };
|
||||||
|
|
||||||
|
export type ProfileFormValues = UpdateProfilePayload;
|
||||||
|
|
||||||
|
export type ChangePasswordFormValues = ChangePasswordPayload & {
|
||||||
|
confirmPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileSummaryProps = {
|
||||||
|
profile: AdminProfile | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileInfoListProps = {
|
||||||
|
profile: AdminProfile | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileInfoItemProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
137
src/Role/components/PermissionTreePicker.tsx
Normal file
137
src/Role/components/PermissionTreePicker.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Tree } from "@arco-design/web-react";
|
||||||
|
import type { Permission, PermissionModuleConfig, PermissionTreeNode, PermissionTreePickerProps } from "../interface";
|
||||||
|
|
||||||
|
const systemPermissionModules: PermissionModuleConfig[] = [
|
||||||
|
{ prefix: "permission", title: "权限管理", subject: "权限" },
|
||||||
|
{ prefix: "role", title: "角色管理", subject: "角色" },
|
||||||
|
{ prefix: "admin-user", title: "人员管理", subject: "人员" },
|
||||||
|
{ prefix: "user", title: "用户管理", subject: "用户" },
|
||||||
|
{ prefix: "log", title: "日志管理", subject: "日志" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const businessPermissionModules: PermissionModuleConfig[] = [
|
||||||
|
{ prefix: "drama", title: "短剧管理", subject: "短剧" },
|
||||||
|
{ prefix: "channel", title: "频道管理", subject: "频道" },
|
||||||
|
{ prefix: "episode", title: "剧集管理", subject: "剧集" },
|
||||||
|
{ prefix: "media", title: "媒体管理", subject: "媒体" },
|
||||||
|
{ prefix: "payment", title: "支付管理", subject: "支付" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionNames: Record<string, string> = {
|
||||||
|
read: "查看",
|
||||||
|
create: "创建",
|
||||||
|
update: "更新",
|
||||||
|
delete: "删除",
|
||||||
|
manage: "管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
function permissionKey(id: number) {
|
||||||
|
return `permission:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionIdFromKey(key: string) {
|
||||||
|
const id = Number(key.replace("permission:", ""));
|
||||||
|
return Number.isInteger(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionPrefix(code: string) {
|
||||||
|
return code.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionAction(code: string) {
|
||||||
|
return code.split(":")[1] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionTitle(permission: Permission, module?: PermissionModuleConfig) {
|
||||||
|
return permission.name || (module ? `${actionNames[getPermissionAction(permission.code)]}${module.subject}` : permission.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPermissionModuleNode(permissions: Permission[], module: PermissionModuleConfig): PermissionTreeNode | null {
|
||||||
|
const children = permissions
|
||||||
|
.filter((permission) => getPermissionPrefix(permission.code) === module.prefix)
|
||||||
|
.map((permission) => ({
|
||||||
|
key: permissionKey(permission.id),
|
||||||
|
title: getPermissionTitle(permission, module),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!children.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `module:${module.prefix}`,
|
||||||
|
title: module.title,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPermissionTree(permissions: Permission[]): PermissionTreeNode[] {
|
||||||
|
const knownPrefixes = new Set([
|
||||||
|
...systemPermissionModules.map((module) => module.prefix),
|
||||||
|
...businessPermissionModules.map((module) => module.prefix),
|
||||||
|
]);
|
||||||
|
const systemChildren = systemPermissionModules
|
||||||
|
.map((module) => buildPermissionModuleNode(permissions, module))
|
||||||
|
.filter((node): node is PermissionTreeNode => Boolean(node));
|
||||||
|
const businessChildren = businessPermissionModules
|
||||||
|
.map((module) => buildPermissionModuleNode(permissions, module))
|
||||||
|
.filter((node): node is PermissionTreeNode => Boolean(node));
|
||||||
|
const otherPermissions = permissions
|
||||||
|
.filter((permission) => !knownPrefixes.has(getPermissionPrefix(permission.code)))
|
||||||
|
.map((permission) => ({
|
||||||
|
key: permissionKey(permission.id),
|
||||||
|
title: permission.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (otherPermissions.length) {
|
||||||
|
businessChildren.push({
|
||||||
|
key: "module:other",
|
||||||
|
title: "其他权限",
|
||||||
|
children: otherPermissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: "group:system", title: "系统权限", children: systemChildren },
|
||||||
|
{ key: "group:business", title: "业务权限", children: businessChildren },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectExpandedKeys(nodes: PermissionTreeNode[]) {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const visit = (items: PermissionTreeNode[]) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.children?.length) {
|
||||||
|
keys.push(item.key);
|
||||||
|
visit(item.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
visit(nodes);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionTreePicker({ permissions, value, onChange }: PermissionTreePickerProps) {
|
||||||
|
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
|
||||||
|
const expandedKeys = useMemo(() => collectExpandedKeys(treeData), [treeData]);
|
||||||
|
const checkedKeys = value.map((id) => permissionKey(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-[0.9375rem] h-[22.5rem] overflow-auto border border-line/60 bg-canvas/30 px-4 py-3">
|
||||||
|
<Tree
|
||||||
|
blockNode
|
||||||
|
checkable
|
||||||
|
checkedStrategy="child"
|
||||||
|
checkedKeys={checkedKeys}
|
||||||
|
defaultExpandedKeys={expandedKeys}
|
||||||
|
selectable={false}
|
||||||
|
showLine
|
||||||
|
treeData={treeData}
|
||||||
|
onCheck={(keys) => {
|
||||||
|
onChange(keys.map((key) => permissionIdFromKey(key)).filter((id): id is number => id !== null));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/Role/index.tsx
Normal file
150
src/Role/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react";
|
||||||
|
import { IconPlus } from "@arco-design/web-react/icon";
|
||||||
|
import { roleApi } from "../api";
|
||||||
|
import { usePageData } from "../hooks/usePageData";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { PermissionTreePicker } from "./components/PermissionTreePicker";
|
||||||
|
import type { CreateRoleFormValues, Permission, Role } from "./interface";
|
||||||
|
|
||||||
|
function RolesPage() {
|
||||||
|
const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
|
||||||
|
const permissions = usePageData<Permission>(roleApi.listPermissions, { page: 1, pageSize: 20 });
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
|
const [creatingRole, setCreatingRole] = useState(false);
|
||||||
|
const [createPermissionIds, setCreatePermissionIds] = useState<number[]>([]);
|
||||||
|
const [assignmentVisible, setAssignmentVisible] = useState(false);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||||
|
const [selectedPermissionIds, setSelectedPermissionIds] = useState<number[]>([]);
|
||||||
|
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||||
|
|
||||||
|
const openCreateRole = () => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreatePermissionIds([]);
|
||||||
|
setCreateVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRole = async (values: CreateRoleFormValues) => {
|
||||||
|
setCreatingRole(true);
|
||||||
|
try {
|
||||||
|
await roleApi.createRole({
|
||||||
|
...values,
|
||||||
|
permissionIds: createPermissionIds,
|
||||||
|
});
|
||||||
|
Message.success("角色创建成功");
|
||||||
|
setCreateVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setCreatingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssignPermissions = (role: Role) => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setSelectedPermissionIds(role.permissions.map((permission) => permission.id));
|
||||||
|
setAssignmentVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRolePermissions = async () => {
|
||||||
|
if (!selectedRole) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingPermissions(true);
|
||||||
|
try {
|
||||||
|
await roleApi.updateRolePermissions(selectedRole.id, selectedPermissionIds);
|
||||||
|
Message.success("角色权限已更新");
|
||||||
|
setAssignmentVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setSavingPermissions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel
|
||||||
|
title="角色管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<IconPlus />} onClick={openCreateRole}>
|
||||||
|
新增角色
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
noPadding
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={data.list}
|
||||||
|
pagination={{
|
||||||
|
current: data.pagination.page,
|
||||||
|
total: data.pagination.total,
|
||||||
|
pageSize: data.pagination.pageSize,
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
onChange: changePage,
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 80 },
|
||||||
|
{ title: "编码", dataIndex: "code" },
|
||||||
|
{ title: "名称", dataIndex: "name" },
|
||||||
|
{
|
||||||
|
title: "权限列表",
|
||||||
|
render: (_, record: Role) => (
|
||||||
|
<Space wrap>
|
||||||
|
{record.permissions.map((item) => (
|
||||||
|
<Tag key={item.id}>{item.name}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 140,
|
||||||
|
render: (_, record: Role) => (
|
||||||
|
<Button size="small" onClick={() => openAssignPermissions(record)}>
|
||||||
|
分配权限
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Modal title="新增角色" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null} style={{ width: "40rem" }}>
|
||||||
|
<Form form={createForm} layout="vertical" onSubmit={createRole}>
|
||||||
|
<Form.Item label="角色编码" field="code" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入角色编码,例如 drama_operator" />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="form-help">角色的唯一标识,创建后不可修改</div>
|
||||||
|
<Form.Item label="角色名称" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入角色名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="角色描述" field="description">
|
||||||
|
<Input.TextArea placeholder="请输入角色描述" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="form-label">权限设置</div>
|
||||||
|
<PermissionTreePicker permissions={permissions.data.list} value={createPermissionIds} onChange={setCreatePermissionIds} />
|
||||||
|
<div className="form-help">为角色分配系统权限,决定拥有此角色的用户可以进行的操作</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setCreateVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creatingRole}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={`分配权限${selectedRole ? ` - ${selectedRole.name}` : ""}`}
|
||||||
|
visible={assignmentVisible}
|
||||||
|
confirmLoading={savingPermissions}
|
||||||
|
onOk={saveRolePermissions}
|
||||||
|
onCancel={() => setAssignmentVisible(false)}
|
||||||
|
style={{ width: "40rem" }}
|
||||||
|
>
|
||||||
|
<PermissionTreePicker permissions={permissions.data.list} value={selectedPermissionIds} onChange={setSelectedPermissionIds} />
|
||||||
|
</Modal>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RolesPage;
|
||||||
27
src/Role/interface.ts
Normal file
27
src/Role/interface.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Permission, Role } from "../api";
|
||||||
|
|
||||||
|
export type { Permission, Role };
|
||||||
|
|
||||||
|
export type CreateRoleFormValues = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionTreeNode = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
children?: PermissionTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionModuleConfig = {
|
||||||
|
prefix: string;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionTreePickerProps = {
|
||||||
|
permissions: Permission[];
|
||||||
|
value: number[];
|
||||||
|
onChange: (permissionIds: number[]) => void;
|
||||||
|
};
|
||||||
30
src/User/index.tsx
Normal file
30
src/User/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button, Message, Table, Tag } from '@arco-design/web-react';
|
||||||
|
import { userApi } from '../api';
|
||||||
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import type { AppUser } from './interface';
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
const { data, loading, reload, changePage } = usePageData<AppUser>(userApi.listUsers, { page: 1, pageSize: 20 });
|
||||||
|
const update状态 = async (id: number, status: 'active' | 'disabled') => {
|
||||||
|
await userApi.updateUserStatus(id, status);
|
||||||
|
Message.success('用户状态已更新');
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel title="小程序用户管理" noPadding>
|
||||||
|
<Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, sizeCanChange: true, showTotal: true, onChange: changePage }} columns={[
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'gray'}>{status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||||
|
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> },
|
||||||
|
]} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
3
src/User/interface.ts
Normal file
3
src/User/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { AppUser } from "../api";
|
||||||
|
|
||||||
|
export type { AppUser };
|
||||||
37
src/api/auth.ts
Normal file
37
src/api/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { API_BASE_URL, TOKEN_KEY, request } from "../plugins/axios";
|
||||||
|
import type { AdminProfile, ApiResponse, ChangePasswordPayload, LoginPayload, UpdateProfilePayload } from "./interface";
|
||||||
|
|
||||||
|
export const getApiBaseUrl = () => {
|
||||||
|
return API_BASE_URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveAuthToken = (token: string) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAuthToken = () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasAuthToken = () => {
|
||||||
|
return Boolean(localStorage.getItem(TOKEN_KEY));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (payload: LoginPayload) => {
|
||||||
|
const response = await request.post<ApiResponse<{ accessToken: string }>>("/api/admin/v1/auth/login", payload);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProfile = async () => {
|
||||||
|
const response = await request.get<ApiResponse<AdminProfile>>("/api/admin/v1/auth/profile");
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProfile = async (payload: UpdateProfilePayload) => {
|
||||||
|
const response = await request.patch<ApiResponse<AdminProfile>>("/api/admin/v1/auth/profile", payload);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changePassword = async (payload: ChangePasswordPayload) => {
|
||||||
|
await request.patch("/api/admin/v1/auth/password", payload);
|
||||||
|
};
|
||||||
18
src/api/channels.ts
Normal file
18
src/api/channels.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { Channel, ChannelQuery, CreateChannelPayload, UpdateChannelPayload } from "./interface";
|
||||||
|
|
||||||
|
export function listChannels(query: ChannelQuery) {
|
||||||
|
return fetchPage<Channel>('/api/admin/v1/channels', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChannel(payload: CreateChannelPayload) {
|
||||||
|
await request.post('/api/admin/v1/channels', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChannel(channelId: number, payload: UpdateChannelPayload) {
|
||||||
|
await request.patch(`/api/admin/v1/channels/${channelId}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannel(channelId: number) {
|
||||||
|
await request.delete(`/api/admin/v1/channels/${channelId}`);
|
||||||
|
}
|
||||||
25
src/api/data.ts
Normal file
25
src/api/data.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, PageQuery, SyncJob } from "./interface";
|
||||||
|
|
||||||
|
export async function getDataOverview() {
|
||||||
|
const response = await request.get<ApiResponse<DataOverview>>(
|
||||||
|
'/api/admin/v1/data/overview',
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDataMetrics() {
|
||||||
|
await request.post('/api/admin/v1/data/sync', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDramaMetrics(query: PageQuery) {
|
||||||
|
return fetchPage<DramaMetric>('/api/admin/v1/data/dramas', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEpisodeMetrics(query: PageQuery) {
|
||||||
|
return fetchPage<EpisodeMetric>('/api/admin/v1/data/episodes', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSyncJobs(query: PageQuery) {
|
||||||
|
return fetchPage<SyncJob>('/api/admin/v1/data/sync-jobs', query);
|
||||||
|
}
|
||||||
60
src/api/dramas.ts
Normal file
60
src/api/dramas.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { ApiResponse, CreateDramaPayload, Drama, DramaQuery, Episode, UpdateDramaPayload, UpdateEpisodePayload } from "./interface";
|
||||||
|
|
||||||
|
export function listDramas(query: DramaQuery) {
|
||||||
|
return fetchPage<Drama>('/api/admin/v1/dramas', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDrama(payload: CreateDramaPayload) {
|
||||||
|
await request.post('/api/admin/v1/dramas', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDrama(dramaId: number) {
|
||||||
|
const response = await request.get<ApiResponse<Drama>>(
|
||||||
|
`/api/admin/v1/dramas/${dramaId}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDramaEpisodes(dramaId: number) {
|
||||||
|
const response = await request.get<ApiResponse<{ list: Episode[] }>>(
|
||||||
|
`/api/admin/v1/dramas/${dramaId}/episodes`,
|
||||||
|
{ params: { pageSize: 200 } },
|
||||||
|
);
|
||||||
|
return response.data.data.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureDramaEpisodes(
|
||||||
|
dramaId: number,
|
||||||
|
episodeCount: number,
|
||||||
|
) {
|
||||||
|
await request.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
|
||||||
|
episodeCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDrama(
|
||||||
|
dramaId: number,
|
||||||
|
payload: UpdateDramaPayload,
|
||||||
|
) {
|
||||||
|
await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDrama(dramaId: number) {
|
||||||
|
await request.delete(`/api/admin/v1/dramas/${dramaId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEpisode(
|
||||||
|
episodeId: number,
|
||||||
|
payload: UpdateEpisodePayload,
|
||||||
|
) {
|
||||||
|
await request.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDramaEpisode(
|
||||||
|
dramaId: number,
|
||||||
|
episodeId: number,
|
||||||
|
payload: UpdateEpisodePayload,
|
||||||
|
) {
|
||||||
|
await request.patch(`/api/admin/v1/dramas/${dramaId}/episodes/${episodeId}`, payload);
|
||||||
|
}
|
||||||
10
src/api/index.ts
Normal file
10
src/api/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export * as authApi from "./auth";
|
||||||
|
export * as dataApi from "./data";
|
||||||
|
export * as dramaApi from "./dramas";
|
||||||
|
export * as channelApi from "./channels";
|
||||||
|
export * as logApi from "./logs";
|
||||||
|
export * as mediaApi from "./media";
|
||||||
|
export * as personnelApi from "./personnel";
|
||||||
|
export * as roleApi from "./roles";
|
||||||
|
export * as userApi from "./users";
|
||||||
|
export * from "./interface";
|
||||||
255
src/api/interface.ts
Normal file
255
src/api/interface.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
export type ApiResponse<T> = {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
requestId: string;
|
||||||
|
cost: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageQuery = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DramaQuery = PageQuery & {
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageData<T> = {
|
||||||
|
list: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminProfile = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
status: string;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
roles: Role[];
|
||||||
|
permissions: Permission[] | ["*"];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Drama = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
coverUrl: string;
|
||||||
|
channelId?: number;
|
||||||
|
channelName?: string;
|
||||||
|
isRecommended: boolean;
|
||||||
|
totalEpisodes?: number;
|
||||||
|
publishedEpisodes?: number;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
region?: string;
|
||||||
|
language?: string;
|
||||||
|
year?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Episode = {
|
||||||
|
id: number;
|
||||||
|
dramaId: number;
|
||||||
|
albumId: number;
|
||||||
|
episodeNo: number;
|
||||||
|
seq: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
coverUrl: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
byteplusVid?: string;
|
||||||
|
freeType: "free" | "paid";
|
||||||
|
price: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
reviewStatus: string;
|
||||||
|
reviewMessage?: string;
|
||||||
|
publishStatus: string;
|
||||||
|
nextReleaseAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Channel = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
status: "active" | "disabled";
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelQuery = PageQuery;
|
||||||
|
|
||||||
|
export type CreateChannelPayload = Partial<Omit<Channel, "id" | "createdAt" | "updatedAt">>;
|
||||||
|
|
||||||
|
export type UpdateChannelPayload = Partial<Omit<Channel, "id" | "createdAt" | "updatedAt">>;
|
||||||
|
|
||||||
|
export type CreateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "channelName" | "createdAt">>;
|
||||||
|
|
||||||
|
export type UpdateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "channelName" | "createdAt">>;
|
||||||
|
|
||||||
|
export type UpdateEpisodePayload = Partial<Omit<Episode, "id" | "dramaId" | "albumId" | "episodeNo" | "seq" | "createdAt">> & {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadTask = {
|
||||||
|
id: number;
|
||||||
|
jobId: string;
|
||||||
|
episodeId?: number;
|
||||||
|
status: string;
|
||||||
|
uploadUrl?: string;
|
||||||
|
bucket?: string;
|
||||||
|
objectKey?: string;
|
||||||
|
byteplusVid?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
|
reviewStatus?: string;
|
||||||
|
reviewMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataOverview = {
|
||||||
|
totalDramas: number;
|
||||||
|
totalEpisodes: number;
|
||||||
|
totalPlays: number;
|
||||||
|
totalLikes: number;
|
||||||
|
totalShares: number;
|
||||||
|
totalComments: number;
|
||||||
|
latestSyncedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DramaMetric = {
|
||||||
|
dramaId: number;
|
||||||
|
title: string;
|
||||||
|
plays: number;
|
||||||
|
likes: number;
|
||||||
|
shares: number;
|
||||||
|
comments: number;
|
||||||
|
completionRate: number;
|
||||||
|
averageWatchSeconds: number;
|
||||||
|
syncedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeMetric = {
|
||||||
|
dramaId: number;
|
||||||
|
episodeId: number;
|
||||||
|
title: string;
|
||||||
|
episodeNo: number;
|
||||||
|
plays: number;
|
||||||
|
likes: number;
|
||||||
|
shares: number;
|
||||||
|
comments: number;
|
||||||
|
completionRate: number;
|
||||||
|
averageWatchSeconds: number;
|
||||||
|
syncedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncJob = {
|
||||||
|
id: number;
|
||||||
|
jobId: string;
|
||||||
|
status: string;
|
||||||
|
dramaId?: number;
|
||||||
|
dramaCount: number;
|
||||||
|
episodeCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppUser = {
|
||||||
|
id: number;
|
||||||
|
tiktokOpenId: string;
|
||||||
|
nickname?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Permission = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds?: number[];
|
||||||
|
permissions: Permission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
status: string;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
roles: Role[];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RequestLog = {
|
||||||
|
id: number;
|
||||||
|
requestId: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
statusCode: number;
|
||||||
|
responseCode: number;
|
||||||
|
message: string;
|
||||||
|
costMs: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginPayload = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProfilePayload = {
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordPayload = {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUploadTaskPayload = {
|
||||||
|
episodeId: number;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
fileSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePersonnelPayload = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
roleIds?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateRolePayload = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds: number[];
|
||||||
|
};
|
||||||
6
src/api/logs.ts
Normal file
6
src/api/logs.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { fetchPage } from "../plugins/axios";
|
||||||
|
import type { PageQuery, RequestLog } from "./interface";
|
||||||
|
|
||||||
|
export function listRequestLogs(query: PageQuery) {
|
||||||
|
return fetchPage<RequestLog>('/api/admin/v1/logs/requests', query);
|
||||||
|
}
|
||||||
18
src/api/media.ts
Normal file
18
src/api/media.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { request } from "../plugins/axios";
|
||||||
|
import type { ApiResponse, CreateUploadTaskPayload, UploadTask } from "./interface";
|
||||||
|
|
||||||
|
export async function createUploadTask(payload: CreateUploadTaskPayload) {
|
||||||
|
const response = await request.post<ApiResponse<UploadTask>>(
|
||||||
|
'/api/admin/v1/media/upload-tasks',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeUploadTask(jobId: string) {
|
||||||
|
await request.patch(`/api/admin/v1/media/upload-tasks/${jobId}/complete`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncReviewStatus() {
|
||||||
|
await request.post('/api/admin/v1/media/review-status/sync', {});
|
||||||
|
}
|
||||||
24
src/api/personnel.ts
Normal file
24
src/api/personnel.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { AdminUser, CreatePersonnelPayload, PageQuery } from "./interface";
|
||||||
|
|
||||||
|
export function listPersonnel(query: PageQuery) {
|
||||||
|
return fetchPage<AdminUser>('/api/admin/v1/admin-users', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPersonnel(payload: CreatePersonnelPayload) {
|
||||||
|
await request.post('/api/admin/v1/admin-users', {
|
||||||
|
...payload,
|
||||||
|
roleIds: payload.roleIds ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUserRoles(id: number, roleIds: number[]) {
|
||||||
|
await request.patch(`/api/admin/v1/admin-users/${id}/roles`, { roleIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUserStatus(
|
||||||
|
id: number,
|
||||||
|
status: 'active' | 'disabled',
|
||||||
|
) {
|
||||||
|
await request.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
|
||||||
|
}
|
||||||
23
src/api/roles.ts
Normal file
23
src/api/roles.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { CreateRolePayload, PageQuery, Permission, Role } from "./interface";
|
||||||
|
|
||||||
|
export const listRoles = (query: PageQuery) => {
|
||||||
|
return fetchPage<Role>('/api/admin/v1/roles', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listPermissions = (query: PageQuery) => {
|
||||||
|
return fetchPage<Permission>('/api/admin/v1/permissions', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRole = async (payload: CreateRolePayload) => {
|
||||||
|
await request.post('/api/admin/v1/roles', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateRolePermissions = async (
|
||||||
|
roleId: number,
|
||||||
|
permissionIds: number[],
|
||||||
|
) => {
|
||||||
|
await request.patch(`/api/admin/v1/roles/${roleId}/permissions`, {
|
||||||
|
permissionIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
13
src/api/users.ts
Normal file
13
src/api/users.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { AppUser, PageQuery } from "./interface";
|
||||||
|
|
||||||
|
export function listUsers(query: PageQuery) {
|
||||||
|
return fetchPage<AppUser>('/api/admin/v1/users', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserStatus(
|
||||||
|
id: number,
|
||||||
|
status: 'active' | 'disabled',
|
||||||
|
) {
|
||||||
|
await request.patch(`/api/admin/v1/users/${id}/status`, { status });
|
||||||
|
}
|
||||||
18
src/components/NoAccessPage/index.tsx
Normal file
18
src/components/NoAccessPage/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Typography } from "@arco-design/web-react";
|
||||||
|
import { IconLock } from "@arco-design/web-react/icon";
|
||||||
|
import { Panel } from "../Panel";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function NoAccessPage() {
|
||||||
|
return (
|
||||||
|
<Panel title="暂无权限">
|
||||||
|
<div className="flex flex-col items-center py-10">
|
||||||
|
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-warning-light">
|
||||||
|
<IconLock className="text-3xl text-warning" />
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" className="text-center text-[0.875rem]">当前账号没有访问该功能的权限,请联系系统管理员分配角色。</Text>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/Panel/index.tsx
Normal file
23
src/components/Panel/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type PanelProps = {
|
||||||
|
title?: ReactNode;
|
||||||
|
extra?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
noPadding?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Panel({ title, extra, children, className = "", noPadding = false }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={`section-card ${className}`}>
|
||||||
|
{(title || extra) && (
|
||||||
|
<div className="flex items-center justify-between border-b border-line/50 px-6 py-4">
|
||||||
|
{title && <h3 className="m-0 text-[0.9375rem] font-semibold text-ink">{title}</h3>}
|
||||||
|
{extra && <div>{extra}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={noPadding ? "" : "p-6"}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/RequestLogTable/index.tsx
Normal file
21
src/components/RequestLogTable/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Table, Tag } from "@arco-design/web-react";
|
||||||
|
import type { RequestLogTableProps } from "./interface";
|
||||||
|
|
||||||
|
export function RequestLogTable({ data, loading }: RequestLogTableProps) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="requestId"
|
||||||
|
loading={loading}
|
||||||
|
data={data}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: "请求 ID", dataIndex: "requestId", ellipsis: true },
|
||||||
|
{ title: "方法", dataIndex: "method", width: 100, render: (value) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : 'orange'}>{value}</Tag> },
|
||||||
|
{ title: "路径", dataIndex: "path", ellipsis: true },
|
||||||
|
{ title: "状态", dataIndex: "statusCode", width: 100, render: (value) => <Tag color={value < 400 ? "green" : value < 500 ? "orange" : "red"}>{value}</Tag> },
|
||||||
|
{ title: "耗时", dataIndex: "costMs", width: 100, render: (value) => <span className={Number(value) > 1000 ? 'text-danger font-medium' : ''}>{value}ms</span> },
|
||||||
|
{ title: "消息", dataIndex: "message", width: 180 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/RequestLogTable/interface.ts
Normal file
6
src/components/RequestLogTable/interface.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { RequestLog } from "../../api";
|
||||||
|
|
||||||
|
export type RequestLogTableProps = {
|
||||||
|
data: RequestLog[];
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
5
src/hooks/interface.ts
Normal file
5
src/hooks/interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PageData, PageQuery } from "../api";
|
||||||
|
|
||||||
|
export type { PageData, PageQuery };
|
||||||
|
|
||||||
|
export type PageFetcher<T, Q extends PageQuery = PageQuery> = (query: Q) => Promise<PageData<T>>;
|
||||||
15
src/hooks/pageData.ts
Normal file
15
src/hooks/pageData.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { PageData, PageQuery } from "./interface";
|
||||||
|
|
||||||
|
export function resolvePageData<T>(
|
||||||
|
response: PageData<T>,
|
||||||
|
query: PageQuery,
|
||||||
|
): PageData<T> {
|
||||||
|
return {
|
||||||
|
list: response.list,
|
||||||
|
pagination: {
|
||||||
|
...response.pagination,
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/hooks/usePageData.ts
Normal file
53
src/hooks/usePageData.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PageData, PageQuery } from "../api";
|
||||||
|
import type { PageFetcher } from "./interface";
|
||||||
|
import { resolvePageData } from "./pageData";
|
||||||
|
|
||||||
|
export function usePageData<T, Q extends PageQuery = PageQuery>(
|
||||||
|
fetcher: PageFetcher<T, Q>,
|
||||||
|
initialQuery: Q,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState<Q>(initialQuery);
|
||||||
|
const [data, setData] = useState<PageData<T>>({
|
||||||
|
list: [],
|
||||||
|
pagination: { page: 1, pageSize: 20, total: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = async (q = query) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetcher(q);
|
||||||
|
setData(resolvePageData(response, q));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (page: number, pageSize: number) => {
|
||||||
|
setQuery((prev) => ({ ...prev, page, pageSize } as Q));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFilter = (patch: Partial<Q>) => {
|
||||||
|
setQuery((prev) => ({ ...prev, ...patch, page: 1 } as Q));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
void load(query);
|
||||||
|
}
|
||||||
|
}, [fetcher, enabled, query]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
query,
|
||||||
|
reload: () => load(query),
|
||||||
|
changePage,
|
||||||
|
setFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
300
src/index.css
300
src/index.css
@@ -1,25 +1,289 @@
|
|||||||
* {
|
@tailwind base;
|
||||||
box-sizing: border-box;
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-1.5 h-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded-full bg-line hover:bg-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
@apply bg-brand/15 text-brand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
@layer components {
|
||||||
body,
|
.section-card {
|
||||||
#root {
|
@apply border border-line/60 bg-white shadow-sm transition-shadow duration-300;
|
||||||
width: 100%;
|
}
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100%;
|
.section-card:hover {
|
||||||
margin: 0;
|
box-shadow: theme('boxShadow.card-hover');
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
@apply flex flex-col gap-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
@apply mt-6 flex justify-end gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply mb-2 text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
@apply -mt-3 mb-[1.125rem] text-[0.8125rem] leading-[1.6] text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
@apply border border-line/60 bg-white p-5 shadow-sm transition-shadow duration-300 hover:shadow-card-hover cursor-default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* ── Layout Sider ───────────────────────────────────── */
|
||||||
color: #1d2129;
|
.arco-layout-sider {
|
||||||
background: #f2f3f5;
|
transition: width 0.2s cubic-bezier(0.2, 0, 0, 1) !important;
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
.arco-layout-sider .arco-menu {
|
||||||
input,
|
border-right: none !important;
|
||||||
textarea,
|
}
|
||||||
select {
|
|
||||||
font: inherit;
|
/* ── Menu ───────────────────────────────────────────── */
|
||||||
|
.arco-menu-item {
|
||||||
|
margin: 0.125rem 0.5rem !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-menu-item:hover {
|
||||||
|
background-color: theme('colors.canvas') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-menu-item.arco-menu-selected {
|
||||||
|
background-color: theme('colors.brand-light') !important;
|
||||||
|
color: theme('colors.brand') !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-menu-inline-header {
|
||||||
|
margin: 0.125rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-menu-inline-header:hover {
|
||||||
|
background-color: theme('colors.canvas') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ───────────────────────────────────────────── */
|
||||||
|
.arco-card {
|
||||||
|
border: 1px solid theme('colors.line / 60%') !important;
|
||||||
|
box-shadow: theme('boxShadow.card') !important;
|
||||||
|
transition: box-shadow 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-card:hover {
|
||||||
|
box-shadow: theme('boxShadow.card-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-card-header {
|
||||||
|
border-bottom: 1px solid theme('colors.line / 50%') !important;
|
||||||
|
padding: 0.875rem 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-card-header-title {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 0.9375rem !important;
|
||||||
|
color: theme('colors.ink') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table ──────────────────────────────────────────── */
|
||||||
|
.arco-table-container {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 0.5rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-table-th {
|
||||||
|
background-color: theme('colors.header-bg') !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: theme('colors.secondary') !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border-bottom: 1px solid theme('colors.line / 80%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-table-td {
|
||||||
|
transition: background-color 0.15s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-table-tr:hover .arco-table-td {
|
||||||
|
background-color: theme('colors.row-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-table-sticky-header .arco-table-container {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ─────────────────────────────────────── */
|
||||||
|
.arco-table-pagination{
|
||||||
|
margin: 1rem !important;
|
||||||
|
}
|
||||||
|
.arco-pagination {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-pagination-item {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-pagination-item:hover {
|
||||||
|
background-color: theme('colors.brand-light') !important;
|
||||||
|
color: theme('colors.brand') !important;
|
||||||
|
border-color: theme('colors.brand / 40%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-pagination-item-active {
|
||||||
|
background-color: theme('colors.brand') !important;
|
||||||
|
border-color: theme('colors.brand') !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 2px 6px theme('colors.brand / 30%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Button ─────────────────────────────────────────── */
|
||||||
|
.arco-btn {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-btn-primary {
|
||||||
|
box-shadow: 0 2px 6px theme('colors.brand / 30%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-btn-primary:hover {
|
||||||
|
box-shadow: 0 4px 12px theme('colors.brand / 40%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-btn-secondary:not(.arco-btn-disabled):hover {
|
||||||
|
background-color: theme('colors.canvas') !important;
|
||||||
|
border-color: theme('colors.line-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-btn-text:not(.arco-btn-disabled):hover {
|
||||||
|
background-color: theme('colors.canvas') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-btn-text-danger:not(.arco-btn-disabled):hover {
|
||||||
|
background-color: theme('colors.danger-light') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ──────────────────────────────────────────── */
|
||||||
|
.arco-modal {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-modal-header {
|
||||||
|
border-bottom: 1px solid theme('colors.line / 60%') !important;
|
||||||
|
padding: 1rem 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-modal-title {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-modal-body {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-modal-footer {
|
||||||
|
border-top: 1px solid theme('colors.line / 60%') !important;
|
||||||
|
padding: 0.75rem 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input & Select ─────────────────────────────────── */
|
||||||
|
.arco-input-wrapper {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-input-wrapper:hover {
|
||||||
|
border-color: theme('colors.line-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-input-wrapper.arco-input-focus {
|
||||||
|
border-color: theme('colors.brand') !important;
|
||||||
|
box-shadow: 0 0 0 2px theme('colors.brand / 10%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-select-view {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-select-view:hover {
|
||||||
|
border-color: theme('colors.line-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-select-view.arco-select-view-focus {
|
||||||
|
border-color: theme('colors.brand') !important;
|
||||||
|
box-shadow: 0 0 0 2px theme('colors.brand / 10%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-textarea-wrapper {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-textarea-wrapper:hover {
|
||||||
|
border-color: theme('colors.line-hover') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-textarea-focus {
|
||||||
|
border-color: theme('colors.brand') !important;
|
||||||
|
box-shadow: 0 0 0 2px theme('colors.brand / 10%') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ───────────────────────────────────────────── */
|
||||||
|
.arco-form-item-label {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: theme('colors.secondary') !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tag ────────────────────────────────────────────── */
|
||||||
|
.arco-tag {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dropdown ───────────────────────────────────────── */
|
||||||
|
.arco-dropdown-menu {
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-dropdown-menu-item {
|
||||||
|
transition: all 0.15s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-dropdown-menu-item:hover {
|
||||||
|
background-color: theme('colors.canvas') !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Switch ─────────────────────────────────────────── */
|
||||||
|
.arco-switch {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-switch-checked {
|
||||||
|
box-shadow: 0 2px 4px theme('colors.brand / 30%') !important;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/layout/PermissionRoute.tsx
Normal file
24
src/layout/PermissionRoute.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Panel } from '../components/Panel';
|
||||||
|
import { hasPermissions } from '../utils/permission';
|
||||||
|
import { NoAccessPage } from '../components/NoAccessPage';
|
||||||
|
import type { PermissionRouteProps } from './interface';
|
||||||
|
|
||||||
|
export function PermissionRoute({
|
||||||
|
profile,
|
||||||
|
requiredPermissions,
|
||||||
|
children,
|
||||||
|
}: PermissionRouteProps) {
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Panel>
|
||||||
|
<div className="py-16 text-center text-muted">加载中...</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasPermissions(profile, requiredPermissions)) {
|
||||||
|
return <NoAccessPage />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
10
src/layout/ProtectedRoute.tsx
Normal file
10
src/layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '../api';
|
||||||
|
import type { ProtectedRouteProps } from './interface';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
if (!authApi.hasAuthToken()) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
189
src/layout/index.tsx
Normal file
189
src/layout/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography } from "@arco-design/web-react";
|
||||||
|
import { IconMenuFold, IconMenuUnfold } from "@arco-design/web-react/icon";
|
||||||
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { authApi } from "../api";
|
||||||
|
import type { AdminProfile } from "./interface";
|
||||||
|
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu";
|
||||||
|
import { PermissionRoute } from "./PermissionRoute";
|
||||||
|
import DashboardPage from "../Dashboard";
|
||||||
|
import DramasPage from "../Drama";
|
||||||
|
import ChannelPage from "../Channel";
|
||||||
|
import DataPage from "../Data";
|
||||||
|
import UsersPage from "../User";
|
||||||
|
import RolesPage from "../Role";
|
||||||
|
import PersonnelPage from "../Personnel";
|
||||||
|
import ProfilePage from "../Profile";
|
||||||
|
import LogsPage from "../Log";
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<AdminProfile | null>(null);
|
||||||
|
const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]);
|
||||||
|
const visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]);
|
||||||
|
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi.getProfile()
|
||||||
|
.then((data) => setProfile(data))
|
||||||
|
.catch(() => {
|
||||||
|
authApi.clearAuthToken();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (current?.parent && !openMenuKeys.includes(current.parent.key)) {
|
||||||
|
setOpenMenuKeys((keys) => [...keys, current.parent!.key]);
|
||||||
|
}
|
||||||
|
}, [current, openMenuKeys]);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
authApi.clearAuthToken();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="h-screen bg-canvas">
|
||||||
|
<Sider
|
||||||
|
className="border-r border-line/40 bg-gradient-to-b from-white to-panel"
|
||||||
|
width={"14rem"}
|
||||||
|
collapsed={collapsed}
|
||||||
|
collapsible
|
||||||
|
trigger={null}
|
||||||
|
>
|
||||||
|
<div className="flex h-[3.75rem] items-center gap-2.5 px-5">
|
||||||
|
<span className="grid h-9 w-9 place-items-center bg-brand text-sm font-bold text-white shadow-md shadow-brand/25">TK</span>
|
||||||
|
{!collapsed && <span className="text-[0.9375rem] font-bold tracking-tight text-ink">TK短剧管理后台</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mx-3 mb-2 h-px bg-line/50" />
|
||||||
|
<Menu
|
||||||
|
selectedKeys={[current?.item.key || "/dashboard"]}
|
||||||
|
openKeys={openMenuKeys}
|
||||||
|
onClickMenuItem={(key) => navigate(key)}
|
||||||
|
onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)}
|
||||||
|
className="!border-none"
|
||||||
|
>
|
||||||
|
{visibleMenuItems.map((item) => renderMenuItem(item))}
|
||||||
|
</Menu>
|
||||||
|
</Sider>
|
||||||
|
<Layout className="flex flex-col overflow-hidden">
|
||||||
|
<Header className="flex h-[3.75rem] shrink-0 items-center justify-between border-b border-line/40 bg-white/90 px-6 backdrop-blur-lg">
|
||||||
|
<Space size={12}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="!text-muted hover:!text-ink !rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<strong className="text-[0.9375rem] font-semibold text-ink">{current?.item.label || "工作台"}</strong>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Dropdown
|
||||||
|
position="br"
|
||||||
|
trigger="click"
|
||||||
|
droplist={
|
||||||
|
<Menu className="!p-1.5">
|
||||||
|
<Menu.Item key="profile" onClick={() => navigate("/profile")} className="!rounded-lg !h-9">
|
||||||
|
个人中心
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="logout" onClick={logout} className="!rounded-lg !h-9">
|
||||||
|
退出登录
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button className="inline-flex border-0 cursor-pointer items-center gap-2.5 bg-transparent text-ink transition-all duration-200 hover:bg-canvas/80" type="button">
|
||||||
|
<Avatar size={32} className="!bg-brand !text-white !text-[13px] !font-semibold !shadow-sm !shadow-brand/20">{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar>
|
||||||
|
<Text className="!text-[0.8125rem] !font-medium">{profile?.nickname || profile?.username || "管理员"}</Text>
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</Header>
|
||||||
|
<Content className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
|
||||||
|
<Route
|
||||||
|
path="/dramas"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["drama:read"]}>
|
||||||
|
<DramasPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dramas/:id"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["drama:read"]}>
|
||||||
|
<DramasPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/channels"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["channel:read"]}>
|
||||||
|
<ChannelPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/data"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["data:read"]}>
|
||||||
|
<DataPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["user:read"]}>
|
||||||
|
<UsersPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/roles"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["role:read"]}>
|
||||||
|
<RolesPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/personnel"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["admin-user:read"]}>
|
||||||
|
<PersonnelPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile}>
|
||||||
|
<ProfilePage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/logs"
|
||||||
|
element={
|
||||||
|
<PermissionRoute profile={profile} requiredPermissions={["log:read"]}>
|
||||||
|
<LogsPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/layout/interface.ts
Normal file
22
src/layout/interface.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
import type { AdminProfile } from "../api";
|
||||||
|
|
||||||
|
export type { AdminProfile };
|
||||||
|
|
||||||
|
export type MenuConfig = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
requiredPermissions?: string[];
|
||||||
|
children?: MenuConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionRouteProps = {
|
||||||
|
profile: AdminProfile | null;
|
||||||
|
requiredPermissions?: string[];
|
||||||
|
children: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtectedRouteProps = {
|
||||||
|
children: ReactElement;
|
||||||
|
};
|
||||||
71
src/layout/menu.tsx
Normal file
71
src/layout/menu.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Menu } from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconApps,
|
||||||
|
IconDashboard,
|
||||||
|
IconFile,
|
||||||
|
IconSettings,
|
||||||
|
IconUser,
|
||||||
|
IconVideoCamera,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import type { AdminProfile, MenuConfig } from './interface';
|
||||||
|
import { hasPermissions } from '../utils/permission';
|
||||||
|
|
||||||
|
export const SYSTEM_MENU_KEY = '/system';
|
||||||
|
|
||||||
|
export const menuItems: MenuConfig[] = [
|
||||||
|
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
|
||||||
|
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] },
|
||||||
|
{ key: '/channels', label: '频道管理', icon: <IconApps />, requiredPermissions: ['channel:read'] },
|
||||||
|
{ key: '/data', label: '数据管理', icon: <IconApps />, requiredPermissions: ['data:read'] },
|
||||||
|
{ key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
|
||||||
|
{
|
||||||
|
key: SYSTEM_MENU_KEY,
|
||||||
|
label: '系统管理',
|
||||||
|
icon: <IconSettings />,
|
||||||
|
children: [
|
||||||
|
{ key: '/roles', label: '角色管理', icon: <IconApps />, requiredPermissions: ['role:read'] },
|
||||||
|
{ key: '/personnel', label: '人员管理', icon: <IconUser />, requiredPermissions: ['admin-user:read'] },
|
||||||
|
{ key: '/profile', label: '个人中心', icon: <IconUser /> },
|
||||||
|
{ key: '/logs', label: '日志管理', icon: <IconFile />, requiredPermissions: ['log:read'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function filterMenuItems(items: MenuConfig[], profile: AdminProfile | null): MenuConfig[] {
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (item.children) {
|
||||||
|
const children = filterMenuItems(item.children, profile);
|
||||||
|
return children.length ? { ...item, children } : null;
|
||||||
|
}
|
||||||
|
return hasPermissions(profile, item.requiredPermissions) ? item : null;
|
||||||
|
})
|
||||||
|
.filter((item): item is MenuConfig => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCurrentMenu(pathname: string) {
|
||||||
|
for (const item of menuItems) {
|
||||||
|
if (item.children) {
|
||||||
|
const child = item.children.find((childItem) => pathname.startsWith(childItem.key));
|
||||||
|
if (child) {
|
||||||
|
return { item: child, parent: item };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pathname.startsWith(item.key)) {
|
||||||
|
return { item };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMenuItem(item: MenuConfig) {
|
||||||
|
if (item.children) {
|
||||||
|
return (
|
||||||
|
<Menu.SubMenu key={item.key} title={<span>{item.icon}{item.label}</span>}>
|
||||||
|
{item.children.map((child) => renderMenuItem(child))}
|
||||||
|
</Menu.SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Menu.Item key={item.key}>{item.icon}{item.label}</Menu.Item>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 让 Arco 的命令式组件(Message/Notification/Modal.confirm 等)在 React 18/19 下
|
||||||
|
// 使用 react-dom/client 的 createRoot,而非已被移除的 ReactDOM.render。必须在使用 Arco 前引入。
|
||||||
|
import '@arco-design/web-react/es/_util/react-19-adapter';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import '@arco-design/web-react/dist/css/arco.css';
|
import '@arco-design/web-react/dist/css/arco.css';
|
||||||
|
|||||||
30
src/plugins/axios.ts
Normal file
30
src/plugins/axios.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { Message } from "@arco-design/web-react";
|
||||||
|
import type { ApiResponse, PageData, PageQuery } from "../api/interface";
|
||||||
|
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
|
||||||
|
export const TOKEN_KEY = "cth_tk_admin_token";
|
||||||
|
|
||||||
|
export const request = axios.create({ baseURL: API_BASE_URL });
|
||||||
|
|
||||||
|
request.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
const message = error.response?.data?.message || error.message || "请求失败";
|
||||||
|
Message.error(message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function fetchPage<T>(url: string, params?: PageQuery): Promise<PageData<T>> {
|
||||||
|
const response = await request.get<ApiResponse<PageData<T>>>(url, { params });
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
22
src/utils/permission.ts
Normal file
22
src/utils/permission.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AdminProfile, Permission } from '../api';
|
||||||
|
|
||||||
|
export function getPermissionCodes(profile: AdminProfile | null) {
|
||||||
|
if (!profile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (profile.isSuperAdmin || profile.permissions[0] === '*') {
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
return (profile.permissions as Permission[]).map((permission) => permission.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermissions(profile: AdminProfile | null, requiredPermissions?: string[]) {
|
||||||
|
if (!requiredPermissions?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const codes = getPermissionCodes(profile);
|
||||||
|
if (codes.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return requiredPermissions.every((permission) => codes.includes(permission));
|
||||||
|
}
|
||||||
66
tailwind.config.js
Normal file
66
tailwind.config.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
important: true,
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: '#1d2129',
|
||||||
|
secondary: '#4e5969',
|
||||||
|
muted: '#86909c',
|
||||||
|
line: '#e5e6eb',
|
||||||
|
'line-hover': '#c9cdd4',
|
||||||
|
canvas: '#f2f3f5',
|
||||||
|
'row-hover': '#f7f8fa',
|
||||||
|
'header-bg': '#fafbfc',
|
||||||
|
panel: '#fbfcfd',
|
||||||
|
brand: '#165dff',
|
||||||
|
'brand-light': '#e8f3ff',
|
||||||
|
success: '#00b42a',
|
||||||
|
'success-light': '#e8ffea',
|
||||||
|
warning: '#ff7d00',
|
||||||
|
'warning-light': '#fff7e8',
|
||||||
|
danger: '#f53f3f',
|
||||||
|
'danger-light': '#fff1f0',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'Inter',
|
||||||
|
'ui-sans-serif',
|
||||||
|
'system-ui',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
login: '0 18px 48px rgba(15, 23, 42, 0.08)',
|
||||||
|
card: '0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)',
|
||||||
|
'card-hover': '0 4px 12px rgba(0,0,0,0.08)',
|
||||||
|
'card-lg': '0 4px 16px rgba(0,0,0,0.06), 0 1px 4px rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: '0.75rem',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
14
tests/pageData.test.ts
Normal file
14
tests/pageData.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolvePageData } from '../src/hooks/pageData.ts';
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
list: [{ id: 21 }],
|
||||||
|
pagination: { page: 1, pageSize: 20, total: 30 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolvePageData(response, { page: 2, pageSize: 20 });
|
||||||
|
|
||||||
|
assert.deepEqual(resolved, {
|
||||||
|
list: [{ id: 21 }],
|
||||||
|
pagination: { page: 2, pageSize: 20, total: 30 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user