Compare commits

19 Commits
master ... dev

Author SHA1 Message Date
27d943f63b refactor(Drama): 抽离表单字段组件统一复用
将新增/编辑短剧的表单字段抽离为DramaFormFields组件,统一维护表单UI逻辑,修复编辑弹窗宽度,优化列表页展示样式与交互
2026-07-03 17:08:24 +08:00
82524c4312 style: 移除多余的rounded-xl样式并整理代码格式
1. 移除多个组件冗余的rounded-xl类名,统一圆角样式配置
2. 调整Dashboard页面代码缩进与引号风格为统一的双引号格式
3. 简化DramaDetail页面布局结构,重构详情展示区域
4. 清理index.css中多余的descriptions自定义样式
5. 更新gitignore忽略.trae和.mimocode目录
2026-07-03 16:12:00 +08:00
885d17cec7 style: 统一项目内字体单位为rem并调整布局尺寸
将项目中所有硬编码的px字体尺寸、容器宽度高度统一替换为rem单位,统一整体UI样式的尺寸规范,同时调整部分布局容器的最大宽度适配新的单位体系
2026-07-03 15:42:57 +08:00
edb2db5966 refactor(Dashboard): 调整仪表盘标签文字大小为18px
本次调整仅修改了Dashboard组件中MetricCard的标签文字字号,将原本13px的标签文字调整为18px,统一仪表盘区域的文字视觉层级,让页面信息展示更清晰。
2026-07-03 15:36:04 +08:00
8019c52fbc style: 完成后台样式统一优化
本次提交完成全后台页面的样式统一整改:
1. 新增Tailwind语义化颜色token,替换所有硬编码颜色值
2. 统一全站悬浮交互效果与阴影样式
3. 修正头像、菜单的品牌渐变配色
4. 统一登录页背景色为语义化token
5. 补全缺失的页面容器包裹
6. 统一弹窗宽度与表格分页配置
7. 标准化状态标签颜色与新增按钮样式
8. 对齐表单控件宽度规范
2026-07-03 15:16:32 +08:00
214578316e style: 移除全局组件默认圆角样式配置
统一移除了菜单、卡片、表格、分页、模态框、输入组件、标签、下拉菜单等Arco组件的强制圆角样式,让组件样式可以更灵活适配业务场景
2026-07-03 15:03:13 +08:00
e830f5a733 feat: 新增频道管理功能,优化整体UI与交互体验
本次提交完成了以下核心变更:
1.  新增完整的频道管理模块,包括频道增删改查API、页面组件与权限配置
2.  为短剧功能添加频道关联字段,支持将短剧绑定到指定频道
3.  全局UI优化:统一使用Panel组件重构页面布局,统一样式规范与交互细节
4.  表格与日志组件优化:新增请求方法颜色标记、耗时高亮、状态码分级展示
5.  登录页、个人中心、布局等页面的视觉升级与交互优化
6.  完善全局TailwindCSS样式与组件样式自定义
2026-07-03 15:01:27 +08:00
2dace1cf8d feat: 添加短剧筛选功能和自定义Panel容器组件
1. 新增DramaQuery类型扩展分页查询参数,支持标题、状态、类型筛选
2. 重构usePageData hook,支持自定义查询类型和筛选状态管理
3. 新建Panel通用容器组件替代原生Card
4. 为短剧管理页面添加搜索筛选UI和逻辑
5. 统一所有页面的分页查询默认参数
6. 调整布局样式优化后台管理界面展示
2026-07-02 18:48:51 +08:00
718d1c7aac style: 优化后台整体UI风格与组件样式
1.  新增并配置全局颜色、阴影、圆角等主题变量
2.  统一各页面卡片、表单、按钮等组件的样式规范
3.  优化登录页、个人中心、仪表盘等页面的布局与视觉效果
4.  添加全局滚动条样式与元素过渡动画
2026-07-02 18:05:46 +08:00
96eaa810f3 feat: 完善短剧管理后台功能,新增编辑、详情展示优化
1.  重构接口类型定义,新增创建/更新短剧和剧集的载荷类型
2.  新增表单数据转换工具,规范化表单提交参数
3.  优化短剧列表展示,新增封面图和更清晰的集数统计
4.  新增短剧和剧集的编辑弹窗功能
5.  修复测试文件导入路径和README标题
6.  优化角色管理页面的表单提示样式
2026-07-02 17:59:31 +08:00
a4a1891a7e refactor(api): 统一将函数声明改为箭头函数形式
将所有api模块中的普通函数声明替换为const箭头函数形式,保持代码风格统一
2026-07-02 15:18:39 +08:00
3451d373bc refactor: 重构项目接口与API调用体系,统一类型管理
1.  合并拆分的API类型定义到统一的api/interface.ts文件
2.  删除旧的api/client.ts与api/types.ts文件
3.  重构所有页面与组件的API调用方式,统一使用api/index.ts导出的接口
4.  调整组件文件结构,将分散的组件按模块整理
5.  统一类型导入路径,移除冗余的类型重复定义
2026-07-02 14:57:06 +08:00
e160476ecf build: 引入tailwindcss并迁移原有样式到原子类
1. 新增postcss、autoprefixer、tailwindcss依赖并初始化配置
2. 移除全局及页面级CSS文件,将样式替换为tailwind原子类
3. 重构API调用逻辑,拆分接口请求函数
4. 统一项目样式规范,优化布局与组件样式
2026-07-02 13:38:03 +08:00
526052d633 refactor: 重构分页逻辑并统一代码风格
1. 新增分页数据处理hook和工具函数,实现分页状态统一管理
2. 替换所有页面的表格分页配置为标准current/page模式
3. 统一项目内字符串引号风格为双引号
4. 调整权限树面板样式和布局代码格式
2026-07-02 11:50:07 +08:00
9362625b4e style: 统一项目代码风格与中文文本规范
本次提交完成以下调整:
1. 在main.tsx中添加Arco Design适配React19的适配器导入,修复命令式组件兼容问题
2. 全项目替换单引号为双引号,统一代码格式化风格
3. 重构所有JSX组件的多行写法,拆分复杂属性提升可读性
4. 修正部分组件的代码缩进与换行规范
2026-07-01 23:28:54 +08:00
e6d93d7614 chore: 降级react及相关依赖到18.x版本
将项目的react、react-dom版本从19.x降级到18.3.1,同步更新对应的类型定义包,修正依赖匹配问题
2026-07-01 21:50:05 +08:00
a691db573c feat: 新增数据管理页面与短剧详情页,完善接口类型与菜单配置
1.  新增数据管理页面,支持查看数据概览、短剧/剧集指标和同步记录
2.  新增短剧详情页,支持查看短剧信息、管理剧集和上传视频
3.  新增菜单配置项,添加数据管理入口
4.  扩展API类型定义,新增剧集、上传任务、数据统计等类型
5.  修复timestamp类型为数字类型,新增短剧额外字段
6.  为页面布局添加统一的page-stack样式类
7.  新增短剧详情路由,优化短剧列表页跳转逻辑
2026-07-01 16:35:57 +08:00
56f8c477c2 feat: 完善短剧管理页面的表单与数据类型
扩展了Drama类型定义,新增简介、标签、地区等字段,重构创建短剧弹窗表单,使用更友好的选择器与布局优化用户填写体验,移除冗余的默认status参数拼接
2026-07-01 16:04:41 +08:00
51e26025c6 feat: 完成TikTok短剧管理后台基础功能开发
本次提交搭建了完整的后台管理系统,包含:
1.  登录认证与权限校验体系
2.  工作台、短剧、用户、角色、人员、日志等全业务模块
3.  响应式布局与通用样式组件
4.  菜单动态过滤与路由权限控制
5.  移除了README中冗余的权限管理菜单项
2026-07-01 15:24:13 +08:00
68 changed files with 4294 additions and 1533 deletions

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ dist-ssr
# Editor directories and files
.vscode/*
.trae/*
.mimocode/*
!.vscode/extensions.json
.idea
.DS_Store

View 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` 构建成功

View 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` — 保持 pxrem 在小数值下渲染不精确
- **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
View File

@@ -0,0 +1,9 @@
{
"tabWidth": 4,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 390,
"eslintIntegration": true,
"bracketSpacing": true,
"stylelintIntegration": true
}

View File

@@ -1,4 +1,4 @@
# CTH TikTok 短剧管理后台
# TikTok 短剧管理后台
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。
@@ -48,7 +48,6 @@ VITE_API_BASE_URL=http://localhost:3030
- 工作台
- 短剧管理
- 用户管理
- 权限管理
- 角色管理
- 管理员管理
- 日志管理

View File

@@ -12,15 +12,18 @@
"dependencies": {
"@arco-design/web-react": "^2.66.15",
"axios": "^1.18.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}

724
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

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

File diff suppressed because it is too large Load Diff

179
src/Channel/index.tsx Normal file
View 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
View 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;

View 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
View 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
View File

@@ -0,0 +1,3 @@
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from "../api";
export type { DataOverview, DramaMetric, EpisodeMetric, SyncJob };

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

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

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

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import type { RequestLog } from "../api";
export type { RequestLog };

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

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

View File

@@ -0,0 +1,5 @@
import type { AdminUser, CreatePersonnelPayload, Role } from "../api";
export type { AdminUser, Role };
export type CreatePersonnelFormValues = CreatePersonnelPayload;

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

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

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import type { AppUser } from "../api";
export type { AppUser };

37
src/api/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 });
}

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

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

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

View File

@@ -0,0 +1,6 @@
import type { RequestLog } from "../../api";
export type RequestLogTableProps = {
data: RequestLog[];
loading: boolean;
};

5
src/hooks/interface.ts Normal file
View 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
View 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
View 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,
};
}

View File

@@ -1,25 +1,289 @@
* {
box-sizing: border-box;
@tailwind base;
@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,
body,
#root {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
@layer components {
.section-card {
@apply border border-line/60 bg-white shadow-sm transition-shadow duration-300;
}
.section-card:hover {
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 {
color: #1d2129;
background: #f2f3f5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
/* ── Layout Sider ───────────────────────────────────── */
.arco-layout-sider {
transition: width 0.2s cubic-bezier(0.2, 0, 0, 1) !important;
}
button,
input,
textarea,
select {
font: inherit;
.arco-layout-sider .arco-menu {
border-right: none !important;
}
/* ── 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;
}

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

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

View File

@@ -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 { createRoot } from 'react-dom/client';
import '@arco-design/web-react/dist/css/arco.css';

30
src/plugins/axios.ts Normal file
View 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
View 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
View 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
View 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 },
});