98 Commits
Author SHA1 Message Date
Zhou Weidong 430ba59613 chore(merge): 将 develop 同步到 release/v1.0.0 2026-10-23 13:08:09 +08:00
Zhou Weidong d7d43fed44 chore(merge): 合并转码设置菜单去重修复 2026-10-23 12:55:57 +08:00
Zhou Weidong aa892fe7a8 fix(router): 修正转码设置菜单重复注入 2026-10-23 12:55:56 +08:00
Zhou Weidong 7c10e057a5 chore(merge): 合并协议设置与用户系统展示 2026-10-23 12:35:26 +08:00
Zhou Weidong 8bd6aa35cb style(admin): 修正页面格式以通过检查 2026-10-23 12:35:09 +08:00
Zhou Weidong 0578052d6e feat(admin): 用户与观看记录展示系统 2026-10-23 12:34:52 +08:00
Zhou Weidong c1fb6cc21c feat(admin): 新增三语协议设置页 2026-10-23 12:34:33 +08:00
Zhou Weidong 91f1f7d793 fix(admin): 短剧简介长度上限调整为 1000 2026-10-22 22:18:06 +08:00
Zhou Weidong a47700a980 feat(admin): 新增分类管理与剧单管理页面 2026-10-22 22:18:05 +08:00
Zhou Weidong 1d18b7b52b feat(admin): 清晰度产物支持 HLS 预览播放 2026-10-22 18:12:30 +08:00
Zhou Weidong 208b9f29d8 chore(merge): 合并转码实时进度展示 2026-10-22 15:13:57 +08:00
Zhou Weidong 9b2ab1a425 fix(admin): 转码面板展示实时进度与更新时间 2026-10-22 15:12:12 +08:00
Zhou Weidong 0d5d0d841f chore(merge): 合并转码媒资 id 精度修复 2026-10-22 14:59:54 +08:00
Zhou Weidong fecc3b4817 fix(admin): 转码媒资 id 按字符串传递并处理空态 2026-10-22 14:59:22 +08:00
Zhou Weidong a4893c31a2 chore(merge): 合并转码面板组件拆分 2026-10-22 14:48:43 +08:00
Zhou Weidong e2ca3bfa81 refactor(admin): 拆分剧集转码产物面板组件 2026-10-22 14:47:01 +08:00
Zhou Weidong 52b10d3040 chore(merge): 合并剧集转码产物面板 2026-10-22 14:26:05 +08:00
Zhou Weidong 41151a7f17 feat(admin): 剧集编辑页展示转码产物与帧图 2026-10-22 14:23:47 +08:00
Zhou Weidong 75da3ea0ba chore(merge): 合并后台转码设置页面 2026-10-22 11:30:05 +08:00
Zhou Weidong 218196ee9f feat(admin): 新增转码设置页面 2026-10-22 11:29:48 +08:00
Zhou Weidong 723d056fc7 chore(merge): 批量上传改为手动点击开始 2026-10-22 09:31:43 +08:00
Zhou Weidong f174ba46ae feat(admin): 批量上传改为手动点击开始 2026-10-22 09:31:35 +08:00
Zhou Weidong 2e89f8460c chore(merge): 剧集列表分页条始终显示 2026-10-22 09:23:10 +08:00
Zhou Weidong 632bf13990 fix(admin): 剧集列表分页条始终显示 2026-10-22 09:23:04 +08:00
Zhou Weidong becbe18d76 chore(merge): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:19:33 +08:00
Zhou Weidong 6bcabea261 fix(admin): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:18:59 +08:00
Vicky 99bb666e47 chore(merge): 剧集列表改为服务端分页 2026-10-21 23:20:56 +08:00
Zhou Weidong 092fe18ae7 feat(admin): 剧集列表改为服务端分页 2026-10-21 23:20:38 +08:00
Zhou Weidong c2a76a593a chore(merge): 剧集列表分页默认每页100条 2026-10-21 23:00:13 +08:00
Zhou Weidong a28eaa4514 feat(admin): 剧集列表分页默认每页100条 2026-10-21 23:00:08 +08:00
Zhou Weidong 189a7f689a chore(merge): 批量上传创建的分集默认已上线 2026-10-21 22:53:22 +08:00
Zhou Weidong 93e1c1b8c9 feat(admin): 批量上传创建的分集默认已上线 2026-10-21 22:53:07 +08:00
Zhou Weidong 05b977cc33 fix(admin): 批量建分集是否免费改由短剧免费集数决定 2026-10-21 22:25:50 +08:00
Zhou Weidong 5741ebbf17 fix(admin): 简化批量建分集表单并将移除操作置右 2026-10-21 22:20:10 +08:00
Zhou Weidong 836600b811 chore(build): commit-msg 拦截 AI 署名 2026-10-21 22:10:35 +08:00
Zhou Weidong 4841b0bc24 fix(admin): 移除上传提示中的并发表述
上传为异步队列,文件数量不设上限,提示只保留格式与单文件大小限制
2026-10-21 22:08:47 +08:00
Vicky e1e0055915 chore(admin): 合并剧集上传文案统一与组件拆分 2026-10-21 22:07:07 +08:00
Zhou WeidongandCursor cff864d582 refactor(admin): 拆分剧集管理页超长组件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:07:04 +08:00
Zhou WeidongandCursor c71a12f6f4 fix(admin): 统一剧集上传格式与提示文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:04:36 +08:00
Zhou WeidongandCursor 7d39e3c684 chore(admin): 合并剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:40 +08:00
Zhou WeidongandCursor 6b3620c3c4 feat(admin): 剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:28 +08:00
Zhou Weidong 8e1a02ace0 chore(web): 忽略本机启动脚本与运行日志 2026-10-21 18:55:24 +08:00
Zhou Weidong 982346045d Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:52:34 +08:00
Zhou Weidong 48b09ee8cf feat(admin): 短剧编辑支持排序权重 2026-10-21 18:52:15 +08:00
Zhou Weidong ceb7905697 Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:10:21 +08:00
Zhou Weidong 979a502373 merge: 合并 K93.1 多选完整展示 2026-10-21 17:16:54 +08:00
Zhou Weidong 3e779e4efd fix(admin): 多选类型与内容类型完整展示 2026-10-21 17:16:40 +08:00
Zhou Weidong 481c231dd9 merge: 合并 K93 短剧多类型管理 2026-10-21 17:01:37 +08:00
Zhou Weidong 9e427d485f feat(admin): 短剧类型与内容类型多选 2026-10-21 17:01:22 +08:00
Zhou Weidong 58380170b0 merge: 合并 K92 举报列表短剧与分集展示 2026-10-21 16:45:08 +08:00
Zhou Weidong e583bfed5f feat(admin): 举报列表展示短剧与分集 2026-10-21 16:44:55 +08:00
Zhou Weidong 923a350cde merge: 合并 K91 观看记录页面与治理筛选 2026-10-21 16:36:09 +08:00
Zhou Weidong 0710ea55f3 feat(admin): 观看记录独立菜单与治理状态全部筛选 2026-10-21 16:35:41 +08:00
Zhou Weidong 50f2b177b1 merge: 合并 K89 分集互动数据管理 2026-10-21 15:59:13 +08:00
Zhou Weidong 0e814840b1 feat(admin): 剧集列表展示分集互动数据 2026-10-21 15:59:08 +08:00
Zhou Weidong c1e03f94be merge: 合并 K88 短剧内容类型与互动数据管理 2026-10-21 15:44:12 +08:00
Zhou Weidong c8fe571be1 feat(admin): 短剧列表展示并编辑互动数据 2026-10-21 15:43:51 +08:00
Zhou Weidong 45507dee8d perf(admin): 降低首屏体积并修复 Windows 访问白屏
1) Editor 改 defineAsyncComponent,IEditorConfig 改 import type
2) dev server 绑定 :: 双栈,避免 Windows 下 localhost 走 ::1 超时
3) dev 脚本去掉 POSIX 环境变量前缀,Windows 可直接 pnpm dev
2026-10-21 13:35:55 +08:00
Zhou Weidong 1737f37d82 fix(login): 忘记密码链接加提示,避免点击无响应 2026-10-20 09:15:19 +08:00
Zhou Weidong d283d223c0 chore: 忽略构建产物 apk 2026-10-20 09:12:11 +08:00
Zhou Weidong e9a956f16c chore(web): 忽略运行时产物(日志/启动脚本) 2026-10-19 22:43:29 +08:00
Zhou Weidong f78960fcce chore(web): 合并 release 分片上传修复 2026-10-19 18:46:27 +08:00
Zhou Weidong 8e9435ee7d fix(web): 修复分片上传 chunkSize 字符串拼接导致大文件超限 2026-10-19 18:38:06 +08:00
Zhou Weidong 83e535d12a chore(web): 合并剧集编辑弹窗 2026-10-19 13:55:29 +08:00
Zhou Weidong 2b5dda7c71 feat(web): 重做剧集编辑弹窗 2026-10-19 13:53:57 +08:00
Zhou Weidong 685d4fd1bd chore(web): 合并轮播图编辑弹窗 2026-10-19 13:49:09 +08:00
Zhou Weidong f281dcf1b3 feat(web): 重做轮播图编辑弹窗 2026-10-19 13:47:42 +08:00
Zhou Weidong 5db565b15d chore(web): 合并列表图片预览 2026-10-19 13:42:57 +08:00
Zhou Weidong 8a84f24baf feat(web): 支持后台列表图片大图预览 2026-10-19 13:42:28 +08:00
Zhou Weidong 046400ae4a chore(web): 合并剧集管理布局修复 2026-10-19 13:32:02 +08:00
Zhou Weidong 122aa21323 fix(web): 重做剧集管理顶部布局 2026-10-19 13:30:29 +08:00
Zhou Weidong 9f92d113f2 chore(web): 合并二级菜单背景修复 2026-10-19 13:28:07 +08:00
Zhou Weidong f6f4d264ee fix(web): 调整二级菜单背景样式 2026-10-19 13:27:44 +08:00
Zhou Weidong f023c7d416 chore(web): 合并工作台菜单图标修复 2026-10-19 13:22:44 +08:00
Zhou Weidong bb03a5ad84 fix(web): 修复工作台菜单图标继承 2026-10-19 13:21:09 +08:00
Zhou Weidong 73da0bbd85 chore(web): 合并 K23.1 布局修复 2026-10-19 12:58:02 +08:00
Zhou Weidong c8bf4a8fd4 fix(web): 修复禁用按钮与弹窗布局 2026-10-19 12:56:07 +08:00
Zhou Weidong 6f522303a0 chore(web): 合并 K23.1 后台修正 2026-10-19 12:34:21 +08:00
Zhou Weidong f5b399a52f fix(web): 统一危险操作按钮样式 2026-10-19 12:32:23 +08:00
Zhou Weidong e77db70278 fix(web): 修正浅色品牌文字颜色 2026-10-19 12:32:01 +08:00
Zhou Weidong fe1ae954e3 chore(web): 合并菜单对齐修复 2026-10-19 12:04:22 +08:00
Zhou Weidong 40fbd6ec7b fix(web): 对齐父子菜单文字基线 2026-10-19 12:04:18 +08:00
Zhou Weidong ee3bb1c286 chore(web): 合并 K23 后台视觉深化 2026-10-19 12:00:41 +08:00
Zhou Weidong 898e439b84 fix(web): 优化锁屏弹窗布局与交互 2026-10-19 12:00:30 +08:00
Zhou Weidong 805d7ddc52 feat(web): 重做短剧编辑弹窗 2026-10-19 12:00:26 +08:00
Zhou Weidong ba90bf9bfb style(web): 统一辣椒红主色与菜单层级 2026-10-19 12:00:21 +08:00
Zhou Weidong 3e8312ea58 chore(web): 合并首屏 loading 文字修复 2026-10-19 11:34:19 +08:00
Zhou Weidong a97b8cc026 fix(web): 修正首屏 loading 文字颜色 2026-10-19 11:34:15 +08:00
Zhou Weidong e5c5101450 chore(web): 合并 K22.1 后台收尾 2026-10-19 11:32:09 +08:00
Zhou Weidong 87970487af chore(web): 清理 mock 与旧品牌资源 2026-10-19 11:32:00 +08:00
Zhou Weidong ada741f53d style(web): 精简登录品牌与注册入口 2026-10-19 11:31:55 +08:00
Zhou Weidong 5494190330 chore(web): 合并 K22 路由图标收尾 2026-10-19 11:18:15 +08:00
Zhou Weidong 29465ab4ed fix(web): 统一静态路由图标 2026-10-19 11:18:11 +08:00
Zhou Weidong 8f3447e528 chore(web): 合并 K22 后台视觉统一 2026-10-19 11:12:52 +08:00
Zhou Weidong 97fbeda508 style(web): 统一后台图标与全局视觉 2026-10-19 11:12:38 +08:00
Zhou Weidong 5c087f4db9 chore(admin): 合并注册限流配置项 2026-10-17 23:29:04 +08:00
Zhou Weidong 54db082ffb feat(admin): 增加注册限流配置项 2026-10-17 23:28:40 +08:00
Vicky 3ec9ea6717 fix(web): 适配后端 ID 字符串化(雪花 id 不能过 Number)
后端 J1 起所有 Long id 以字符串下发,审计出 4 处把 id 当数字用的地方:

- utils/creatorImpersonation.ts:代看创作者 id 曾 Number(raw) 存 localStorage,
  19 位 id 会被四舍五入导致 X-Creator-Id 头带错 id;改为全程字符串
- contentManagement/EpisodeManagementView.vue:Number(route.query.drama) 与 item.id 比较,
  从短剧管理带 ?drama= 跳过来会选不中;改为字符串比较
- creator-center/composables/useDramaEdit.js:Number(route.params.id) 同上,作品编辑打不开
- permission/composables/useMenuManagement.js:顶级菜单哨兵 0 与接口的 "0" 类型不一致,
  下拉回显会变裸 0;统一成字符串 '0'

核过不需要改:分页 total 后端保持数字;role.id === selectedRoleId 等两侧都来自接口;
formatCount 内部 Number() 吃字符串;全局无 id 算术/下标/a.id-b.id。
2026-10-16 16:44:19 +08:00
150 changed files with 9326 additions and 1870 deletions

No files matched your search

+1 -1
View File
@@ -17,4 +17,4 @@ VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
VITE_USE_MOCK=false VITE_USE_MOCK=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=true
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=false VITE_USE_CSS_SPLIT=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+6
View File
@@ -7,3 +7,9 @@ dist-ssr
*-lock.* *-lock.*
pnpm-debug pnpm-debug
stats.html stats.html
*.apk
# 本机运行时产物(start-dev.bat / vite 日志)
start-dev.bat
dev.out.log
dev.err.log
+5
View File
@@ -1,4 +1,9 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then
echo "commit message contains AI attribution, remove it before committing" >&2
exit 1
fi
pnpm commitlint --edit "$1" pnpm commitlint --edit "$1"
@@ -0,0 +1,18 @@
# 转码媒资 ID 前端精度修复
## 根因
聚合接口把 Snowflake 媒资 ID 以字符串返回,但面板使用
`Number(detail.mediaAssetId)`,超过 `2^53` 后发生精度丢失,手动转码请求了错误 ID。
## 修复
- 聚合响应中的 episodeId/mediaAssetId/taskId 类型改为字符串。
- 父面板不再 Number 归一化,媒体 ID 校验改为字符串 `/\d+/`。
- 手动转码、帧图分页 URL 原样传递字符串。
- mediaAssetId 为空时禁用重新转码并展示明确空态。
## 验证
- 超 2^53 ID 字符串保持逐位一致。
- `pnpm lint:eslint`、`pnpm build:dev`。
@@ -0,0 +1,26 @@
# 剧集转码面板组件拆分
## 目标
- 将 `EpisodeTranscodePanel.vue` 从 816 行降到 500 行以内。
- 状态轮询、分页请求与切集重置继续由父面板统一管理。
- UI 按职责拆为状态徽标、状态操作区、清晰度表格、帧图预览。
## 组件
1. `EpisodeTranscodeStatusBadge.vue`:折叠标题中的状态与进度。
2. `EpisodeTranscodeStatusPanel.vue`:状态、进度、重试次数、错误与重转按钮。
3. `EpisodeTranscodeRenditions.vue`:清晰度表格、复制地址、新窗口打开。
4. `EpisodeTranscodeFrames.vue`:逐帧网格、雪碧图、分页与放大预览。
公共状态映射与格式化放入 `episodeTranscodeUi.ts`。
## 风险
- 父组件仍负责全部接口、轮询、请求序号和分页状态,子组件只通过 props/emits 交互。
- 不新增依赖、不改接口和 locales 文案。
## 验证
- `wc -l` 确认所有相关文件不超过 500 行。
- `pnpm lint:eslint`、`pnpm build:dev`。
@@ -0,0 +1,39 @@
# 后台转码设置页面
## 目标
1. 在内容管理下新增「转码设置」页面,权限沿用 `episode:upload`。
2. 页面字段与 FruitServer `MediaTranscodeProfile` 完全一致。
3. 对接 `GET/PUT /admin/media/transcode-profile`,保存后回填服务端响应。
4. 前端校验规则与后端一致,并在失败时定位到首个错误字段。
## 方案
- API:在 `src/api/admin/media.ts` 增加读取、保存函数,类型定义放
`src/api/admin/types.ts`;响应继续遵循现有 `Result {code,message,data}`。
- 路由:静态路由表增加 `/content/media-transcode`;默认服务端动态路由模式下,
根据 `episode:upload` 权限把同一条本地路由补充进服务端 `/content` 菜单,
已存在同名菜单时不重复添加,仅统一 i18n 标题。
- 页面:`views/systemManagement/TranscodeSettingsView.vue`,使用 Element Plus 表单、
表格、数字输入和开关;保存按钮受 `episode:upload` 控制。
- 校验:
- quality 非空且忽略首尾空格后唯一;
- width/height 为正偶数;
- videoBitrateKbps/audioBitrateKbps 为正整数;
- segmentSeconds 2–10;
- sprite.intervalSeconds 1–10、columns 5–20、thumbWidth 120–240;
- ladder 至少一档 enabled。
- 校验失败由 Element Plus 定位到首个错误 prop,动态列表错误会定位到对应行。
- 所有可见文案、提示和校验信息进入 `zh-CN.ts` / `en.ts`。
## 非目标
- 不修改 FruitServer、ReelShort 或接口契约。
- 不新增权限码、不新增依赖、不启动服务。
## 测试与门禁
- 当前工程没有单元/组件测试框架,不引入新依赖;使用 TS 类型检查和构建门禁。
- `pnpm lint:eslint`
- `pnpm ts:check`
- `pnpm build:dev`
@@ -0,0 +1,12 @@
# 转码面板实时进度与更新时间
## 目标
- 展示后端 task progress、状态文案与 updatedAt。
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
- FAILED 保持错误信息与重新转码入口。
- 保持现有轮询停止、关闭清理与字符串 ID 逻辑。
## 验证
- `pnpm lint:eslint`、`pnpm build:dev`。
+189
View File
@@ -0,0 +1,189 @@
## 2026-10-22 后台转码设置页面
### 开工确认与计划
- 仓库:ReelShortWeb
- 基线:`develop` / `origin/develop` = `723d056`
- 工作区:开工前干净
- 分支:`feature/transcode-profile-settings`
- 计划:`docs/plans/2026-10-22-transcode-profile-settings.md`
```text
$ git status --short --branch && git rev-parse --short HEAD
## develop...origin/develop
723d056
```
解读:基于指定 develop 基线创建隔离工作树。后端契约已确认字段为
`enabled / segmentSeconds / ladder / sprite`,权限为 `episode:upload`。
默认服务端动态菜单只读取 `sys_menu`,本单按“只改 Web”约束在不改后端的前提下,
对具备 `episode:upload` 的账号补充本地受控菜单,后端接口仍执行最终鉴权。
### 实现与门禁
- 新增 `/content/media-transcode` 静态路由和服务端动态菜单补充;
菜单标题走 `router.transcodeSettings`,只在账号具备 `episode:upload` 时加入。
- 新增转码设置页面,字段严格为 `enabled / segmentSeconds / ladder / sprite`;
支持档位增删、动态行校验、首个错误字段定位及服务端返回值回填。
- API 使用现有 axios Result 包装,GET 初始化、PUT 保存;成功/失败/校验提示全部走中英 i18n。
- 当前工程没有单元/组件测试框架,未新增依赖;使用 ESLint、TypeScript 与构建门禁。
```text
$ pnpm lint:eslint
> fruitshorts-admin@1.0.0 lint:eslint /private/tmp/reelshortweb-transcode-settings
> eslint --fix --ext .js,.ts,.vue ./src
$ pnpm ts:check
> fruitshorts-admin@1.0.0 ts:check /private/tmp/reelshortweb-transcode-settings
> pnpm vue-tsc --noEmit --skipLibCheck
```
解读:ESLint 零告警,TypeScript 类型检查通过。
```text
$ pnpm build:dev
> fruitshorts-admin@1.0.0 build:dev /private/tmp/reelshortweb-transcode-settings
> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus. Please adjust the manual chunk logic for these chunks.
Build successful. Please see dist-dev directory
```
解读:开发构建成功;circular chunk 为仓库既有 Element Plus 分包警告,本次未改构建配置。
## 2026-10-22 剧集转码面板组件拆分
- 分支:`feature/transcode-panel-split`
- 基线:`develop` / `origin/develop` = `52b10d3`
- 计划:`docs/plans/2026-10-22-transcode-panel-split.md`
已读取 `AGENTS.md` 与 `CODING_STANDARDS.md`。当前
`EpisodeTranscodePanel.vue` 为 816 行,超过单文件 500 行红线。
拆分将保持父组件的数据获取、轮询清理、分页请求与切集重置逻辑不变。
### 拆分结果
```text
$ wc -l src/views/contentManagement/upload/EpisodeTranscodePanel.vue \
src/views/contentManagement/upload/EpisodeTranscodeStatusBadge.vue \
src/views/contentManagement/upload/EpisodeTranscodeStatusPanel.vue \
src/views/contentManagement/upload/EpisodeTranscodeRenditions.vue \
src/views/contentManagement/upload/EpisodeTranscodeFrames.vue \
src/views/contentManagement/upload/episodeTranscodeUi.ts
266 EpisodeTranscodePanel.vue
45 EpisodeTranscodeStatusBadge.vue
155 EpisodeTranscodeStatusPanel.vue
119 EpisodeTranscodeRenditions.vue
315 EpisodeTranscodeFrames.vue
93 episodeTranscodeUi.ts
```
解读:父面板只保留接口调用、轮询、请求序号、分页状态与组件组合;
状态、清晰度、帧图分别独立,全部文件均低于 500 行。
### 门禁
```text
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:组件 props/emits、类型检查、ESLint 和开发构建全部通过;
循环 chunk 为项目既有警告。轮询启停、接口路径、分页参数和 locales 文案未改变。
## 2026-10-22 转码媒资 ID 前端精度修复
- 分支:`feature/transcode-media-id-fix-ui`
- 基线:`develop` / `origin/develop` = `a4893c3`
已复现 `Number('2113150398670966786')` 变为 `2113150398670966800`。
本次将媒资、任务 id 全链路按字符串处理,禁止 Number 转换;没有媒资时
禁用重新转码并显示空态。
```text
$ node -e "const id='2113150398670966786'; console.log(String(Number(id)))"
2113150398670966800
```
解读:Snowflake id 超过 JS 安全整数,字符串化后的 Number 已失真。
### 实现
- `EpisodeTranscodeItem` 的 episodeId/mediaAssetId/taskId,以及 rendition/thumbnail
相关 id 类型改为字符串。
- 父面板使用 `detail.mediaAssetId` 原始字符串和正则校验,不再 Number 转换。
- 手动转码与帧图分页接口原样传递字符串 id。
- `mediaAssetId` 为空时按钮保持禁用,空态优先展示后端 errorMsg 原因。
静态检查确认:
```text
$ rg -n "Number\([^\n]*mediaAssetId|mediaAssetId[^\n]*Number\(" \
src/views/contentManagement/upload src/api/admin
(无匹配)
```
### 门禁
```text
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:字符串 id 链路、类型检查、ESLint 与开发构建均通过;
循环 chunk 为项目既有警告。
## 2026-10-22 转码面板实时进度与更新时间
- 分支:`feature/transcode-progress-ui`
- 基线:`develop` / `origin/develop` = `0d5d0d8`
- 计划:`docs/plans/2026-10-22-transcode-progress-ui.md`
后端将补充 task.updatedAt;前端在现有轮询面板中展示进度、状态、
最近更新时间,并在 RUNNING/QUEUED progress=0 时显示等待进度上报提示。
### 实现
- `EpisodeTranscodeItem` 增加 `updatedAt`。
- 状态区展示“最近更新”,格式沿用 `formatDateTime`。
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
- SUCCESS/FAILED 继续停止轮询;关闭面板继续清理定时器。
- 所有 id 保持字符串传递,不引入 Number 转换。
### 门禁
```text
$ pnpm lint:eslint
> eslint --fix --ext .js,.ts,.vue ./src
(0 error / 0 warning)
$ pnpm ts:check
> vue-tsc --noEmit --skipLibCheck
(通过)
$ pnpm build:dev
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
Build successful. Please see dist-dev directory
```
解读:进度与更新时间类型、ESLint、TypeScript 和开发构建全部通过;
循环 chunk 为项目既有警告。
+8 -25
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/brand-mark.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title> <title><%= title %></title>
</head> </head>
@@ -16,7 +16,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
background: #f0f2f5; color: #f8f1ea;
background: #100c0a;
} }
.app-loading .app-loading-wrap { .app-loading .app-loading-wrap {
@@ -38,8 +39,9 @@
} }
.app-loading .app-loading-logo { .app-loading .app-loading-logo {
width: 100px; width: 72px;
margin: 0 auto 15px; margin: 0 auto 15px;
border-radius: 12px;
} }
.app-loading .app-loading-item { .app-loading .app-loading-item {
@@ -55,7 +57,7 @@
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 4px solid #2d8cf0; border: 4px solid #c4322a;
border-bottom: 0; border-bottom: 0;
border-left-color: transparent; border-left-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -68,7 +70,7 @@
left: calc(50% - 20px); left: calc(50% - 20px);
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid #87bdff; border: 4px solid #ff8a72;
border-right: 0; border-right: 0;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -85,16 +87,6 @@
} }
} }
@keyframes loader-outter {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes loader-inner { @keyframes loader-inner {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@@ -105,20 +97,11 @@
} }
} }
@keyframes loader-inner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
</style> </style>
<div class="app-loading"> <div class="app-loading">
<div class="app-loading-wrap"> <div class="app-loading-wrap">
<div class="app-loading-title"> <div class="app-loading-title">
<img src="/logo.png" class="app-loading-logo" alt="Logo" /> <img src="/brand-mark.png" class="app-loading-logo" alt="Logo" />
<div class="app-loading-title"><%= title %></div> <div class="app-loading-title"><%= title %></div>
</div> </div>
<div class="app-loading-item"> <div class="app-loading-item">
-20
View File
@@ -1,20 +0,0 @@
// Author: fruit.com.cn
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const modules = import.meta.glob('./**/*.mock.ts', {
import: 'default',
eager: true
})
const mockModules: any[] = []
Object.keys(modules).forEach(async (key) => {
if (key.includes('_')) {
return
}
mockModules.push(...(modules[key] as any))
})
export function setupProdMockServer() {
createProdMockServer(mockModules)
}
-131
View File
@@ -1,131 +0,0 @@
// Author: fruit.com.cn
import { MockMethod } from 'vite-plugin-mock'
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const adminList = [
{
path: '/dashboard',
component: '#',
redirect: '/dashboard/index',
name: 'Dashboard',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled'
},
children: [
{
path: 'index',
name: 'dashboard',
component: 'views/common/DashboardView',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled',
affix: true
}
}
]
},
{
path: '/content',
component: '#',
redirect: '/content/dramas',
name: 'Content',
meta: {
title: '内容管理',
icon: 'ep:video-camera',
alwaysShow: true
},
children: [
{
path: 'dramas',
name: 'dramas',
component: 'views/contentManagement/DramaManagementView',
meta: {
title: '短剧管理'
}
},
{
path: 'episodes',
name: 'episodes',
component: 'views/contentManagement/EpisodeManagementView',
meta: {
title: '剧集管理'
}
}
]
},
{
path: '/system',
component: '#',
redirect: '/system/access',
name: 'System',
meta: {
title: '后台管理',
icon: 'ep:setting',
alwaysShow: true
},
children: [
{
path: 'access',
name: 'access',
component: 'views/permission/RoleManagementView',
meta: {
title: '角色权限配置'
}
},
{
path: 'admin-users',
name: 'admin-users',
component: 'views/userManagement/AdminUsersView',
meta: {
title: '管理员用户列表'
}
},
{
path: 'menus',
name: 'menus',
component: 'views/permission/MenuManagementView',
meta: {
title: '菜单管理'
}
},
{
path: 'role-categories',
name: 'role-categories',
component: 'views/permission/RoleCategoriesView',
meta: {
title: '角色分类'
}
},
{
path: 'settings',
name: 'settings',
component: 'views/systemManagement/SettingsView',
meta: {
title: '系统设置'
}
}
]
}
]
const testList = adminList.filter((route) => route.path !== '/system')
export default [
// 列表接口
{
url: '/mock/role/list',
method: 'get',
timeout,
response: ({ query }) => {
const { roleName } = query
return {
code: SUCCESS_CODE,
data: roleName === 'admin' ? adminList : testList
}
}
}
] as MockMethod[]
-92
View File
@@ -1,92 +0,0 @@
// Author: fruit.com.cn
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const List: {
username: string
password: string
role: string
roleId: string
permissions: string | string[]
}[] = [
{
username: 'admin',
password: 'admin123',
role: 'admin',
roleId: '1',
permissions: ['*.*.*']
},
{
username: 'test',
password: 'test',
role: 'test',
roleId: '2',
permissions: ['example:dialog:create', 'example:dialog:delete']
}
]
export default [
// 列表接口
{
url: '/mock/user/list',
method: 'get',
response: ({ query }) => {
const { username, pageIndex, pageSize } = query
const mockList = List.filter((item) => {
if (username && item.username.indexOf(username) < 0) return false
return true
})
const pageList = mockList.filter(
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
)
return {
code: SUCCESS_CODE,
data: {
total: mockList.length,
list: pageList
}
}
}
},
// 登录接口
{
url: '/mock/user/login',
method: 'post',
timeout,
response: ({ body }) => {
const data = body
let hasUser = false
for (const user of List) {
if (user.username === data.username && user.password === data.password) {
hasUser = true
return {
code: SUCCESS_CODE,
data: user
}
}
}
if (!hasUser) {
return {
code: 500,
message: '账号或密码错误'
}
}
}
},
// 退出接口
{
url: '/mock/user/loginOut',
method: 'get',
timeout,
response: () => {
return {
code: SUCCESS_CODE,
data: null
}
}
}
]
+2 -4
View File
@@ -6,7 +6,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"i": "pnpm install", "i": "pnpm install",
"dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base", "dev": "vite --mode base",
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck", "ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
"build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro", "build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro",
"build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev", "build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev",
@@ -24,8 +24,6 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@faker-js/faker": "^8.4.0",
"@iconify/iconify": "^3.1.1", "@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2", "@vueuse/core": "^10.7.2",
@@ -40,6 +38,7 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "2.5.5", "element-plus": "2.5.5",
"hls.js": "^1.7.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -103,7 +102,6 @@
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-mock": "2.9.6",
"vite-plugin-progress": "^0.0.7", "vite-plugin-progress": "^0.0.7",
"vite-plugin-purge-icons": "^0.10.0", "vite-plugin-purge-icons": "^0.10.0",
"vite-plugin-style-import": "2.0.0", "vite-plugin-style-import": "2.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+21
View File
@@ -13,6 +13,20 @@ export interface AppUserPageParams {
registerSource?: string registerSource?: string
} }
export interface WatchHistoryPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: number
dramaTitle?: string
dramaId?: number
episodeId?: number
source?: string
finished?: boolean
startTime?: string
endTime?: string
}
export const pageAppUsersApi = (params: AppUserPageParams) => { export const pageAppUsersApi = (params: AppUserPageParams) => {
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params }) return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
} }
@@ -32,6 +46,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
}) })
} }
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
return request.get<PageResult<WatchHistoryItem>>({
url: '/admin/watch-history/page',
params
})
}
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */ /** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => { export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
return request.put<void>({ return request.put<void>({
+2
View File
@@ -15,7 +15,9 @@ export interface DramaPageParams {
keyword?: string keyword?: string
status?: string status?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
isHot?: boolean isHot?: boolean
dramaTypes?: Array<'ANIME' | 'LIVE'>
} }
export const pageDramasApi = (params: DramaPageParams) => { export const pageDramasApi = (params: DramaPageParams) => {
+85
View File
@@ -0,0 +1,85 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface DramaClassificationItem {
id: number
name: string
sort: number
status: number
dramaCount: number
updatedAt?: string
}
export interface DramaClassificationSaveData {
name: string
sort?: number
status?: number
}
export interface ClassificationDramaItem {
id: number
title: string
cover: string
status: string
totalEpisodes: number
sort: number
updatedAt: string
}
export interface ClassificationDramaPageParams {
keyword?: string
pageIndex?: number
pageSize?: number
}
export const listClassificationsApi = () => {
return request.get<DramaClassificationItem[]>({ url: '/admin/drama-classifications' })
}
export const createClassificationApi = (data: DramaClassificationSaveData) => {
return request.post<number>({ url: '/admin/drama-classifications', data })
}
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
}
export const deleteClassificationApi = (id: number) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
}
/** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`,
params
})
}
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: number,
params: ClassificationDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas/candidates`,
params
})
}
/** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds }
})
}
/** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
})
}
+80
View File
@@ -0,0 +1,80 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
import type { ClassificationDramaItem } from './dramaClassification'
export interface DramaPlaylistCategoryItem {
id: number
name: string
description: string
sort: number
status: number
dramaCount: number
}
export interface DramaPlaylistCategorySaveData {
name: string
description?: string
sort?: number
status?: number
}
export interface PlaylistCategoryDramaPageParams {
keyword?: string
pageIndex?: number
pageSize?: number
}
export const listPlaylistCategoriesApi = () => {
return request.get<DramaPlaylistCategoryItem[]>({ url: '/admin/drama-playlist-categories' })
}
export const createPlaylistCategoryApi = (data: DramaPlaylistCategorySaveData) => {
return request.post<number>({ url: '/admin/drama-playlist-categories', data })
}
export const updatePlaylistCategoryApi = (id: number, data: DramaPlaylistCategorySaveData) => {
return request.put<void>({ url: `/admin/drama-playlist-categories/${id}`, data })
}
export const deletePlaylistCategoryApi = (id: number) => {
return request.delete<void>({ url: `/admin/drama-playlist-categories/${id}` })
}
/** 分类下已关联的短剧 */
export const listPlaylistCategoryDramasApi = (
id: number,
params: PlaylistCategoryDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-playlist-categories/${id}/dramas`,
params
})
}
/** 尚未关联到该分类的短剧(选择弹窗用) */
export const listPlaylistCategoryDramaCandidatesApi = (
id: number,
params: PlaylistCategoryDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-playlist-categories/${id}/dramas/candidates`,
params
})
}
/** 批量关联短剧,返回实际新增数量 */
export const bindPlaylistCategoryDramasApi = (id: number, dramaIds: number[]) => {
return request.post<number>({
url: `/admin/drama-playlist-categories/${id}/dramas`,
data: { dramaIds }
})
}
/** 从分类移除短剧 */
export const unbindPlaylistCategoryDramaApi = (id: number, dramaId: number) => {
return request.delete<void>({
url: `/admin/drama-playlist-categories/${id}/dramas/${dramaId}`
})
}
+28 -3
View File
@@ -1,10 +1,35 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import request from '@/axios' import request from '@/axios'
import type { EpisodeItem, EpisodeSaveData } from './types' import type { EpisodeItem, EpisodeSaveData, PageResult } from './types'
export const listEpisodesApi = (dramaId: number, status?: string) => { export interface EpisodePageParams {
return request.get<EpisodeItem[]>({ url: '/admin/episodes', params: { dramaId, status } }) dramaId: number | string
status?: string
pageIndex?: number
pageSize?: number
}
/**
* 解包剧集列表分页。
* 契约(OpenAPI PageResultEpisodeRespVO):data = { total, list }。
* 兼容误把 data 当数组的旧调用,避免列表空白。
*/
export function parseEpisodePage(payload: unknown): PageResult<EpisodeItem> {
if (Array.isArray(payload)) {
return { total: payload.length, list: payload as EpisodeItem[] }
}
if (payload && typeof payload === 'object') {
const page = payload as { total?: unknown; list?: unknown }
const list = Array.isArray(page.list) ? (page.list as EpisodeItem[]) : []
const total = Number(page.total)
return { total: Number.isFinite(total) ? total : list.length, list }
}
return { total: 0, list: [] }
}
export const listEpisodesApi = (params: EpisodePageParams) => {
return request.get<PageResult<EpisodeItem>>({ url: '/admin/episodes', params })
} }
export const createEpisodeApi = (data: EpisodeSaveData) => { export const createEpisodeApi = (data: EpisodeSaveData) => {
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface LegalDocument {
docCode: 'terms' | 'privacy' | string
lang: 'zh' | 'en' | 'id' | string
title: string
content: string
}
export const listLegalDocumentsApi = () => {
return request.get<LegalDocument[]>({ url: '/admin/legal-documents' })
}
export const saveLegalDocumentsApi = (items: LegalDocument[]) => {
return request.put<void>({ url: '/admin/legal-documents', data: items })
}
+18 -1
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import request from '@/axios' import request from '@/axios'
import type { MediaAsset } from './types' import type { MediaAsset, MediaTranscodeProfile } from './types'
export interface UploadInitData { export interface UploadInitData {
fileName: string fileName: string
@@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => {
export const getMediaApi = (id: number) => { export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` }) return request.get<MediaAsset>({ url: `/admin/media/${id}` })
} }
/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */
export const getMediaTranscodeProfileApi = () => {
return request.get<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
silent: true
})
}
/** 保存转码档位配置,并返回服务端最终生效值。 */
export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => {
return request.put<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
data,
silent: true
})
}
+30
View File
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { EpisodeTranscodeItem, MediaThumbnailResult, TranscodeTriggerResult } from './types'
/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */
export const getEpisodeTranscodeApi = (episodeId: number | string) => {
return request.get<EpisodeTranscodeItem>({
url: `/admin/episodes/${episodeId}/transcode`
})
}
/** 按媒体分页读取帧图;页面切换时复用该接口。 */
export const getMediaThumbnailsApi = (
mediaAssetId: string,
pageIndex: number,
pageSize: number
) => {
return request.get<MediaThumbnailResult>({
url: `/admin/media/${mediaAssetId}/thumbnails`,
params: { pageIndex, pageSize }
})
}
/** 手动触发或重转当前分集绑定的媒体。 */
export const triggerMediaTranscodeApi = (mediaAssetId: string) => {
return request.post<TranscodeTriggerResult>({
url: `/admin/media/${mediaAssetId}/transcode`
})
}
+141
View File
@@ -20,11 +20,15 @@ export interface DramaItem {
title: string title: string
subtitle?: string subtitle?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
categoryNames?: string[]
category?: string category?: string
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
episodes?: number episodes?: number
freeEpisodes?: number freeEpisodes?: number
unlockCoin?: number unlockCoin?: number
@@ -33,6 +37,9 @@ export interface DramaItem {
status: string status: string
statusText: string statusText: string
views?: number views?: number
favoriteCount?: number
likeCount?: number
commentCount?: number
sort?: number sort?: number
publishAt?: string publishAt?: string
createdAt?: string createdAt?: string
@@ -43,12 +50,18 @@ export interface DramaSaveData {
title: string title: string
subtitle?: string subtitle?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
freeEpisodes?: number freeEpisodes?: number
unlockCoin?: number unlockCoin?: number
favoriteCount?: number
likeCount?: number
heat?: number
isHot?: boolean isHot?: boolean
sort?: number sort?: number
} }
@@ -69,6 +82,10 @@ export interface EpisodeItem {
status: string status: string
statusText: string statusText: string
views?: number views?: number
likeCount?: number
commentCount?: number
favoriteCount?: number
heat?: number
sort?: number sort?: number
} }
@@ -81,6 +98,7 @@ export interface EpisodeSaveData {
videoUrl?: string videoUrl?: string
isFree?: boolean isFree?: boolean
unlockCoin?: number unlockCoin?: number
likeCount?: number
status?: string status?: string
sort?: number sort?: number
} }
@@ -204,6 +222,105 @@ export interface MediaAsset {
createdAt?: string createdAt?: string
} }
/** 单档清晰度配置,字段与 FruitServer MediaTranscodeProfile.Rung 一致。 */
export interface MediaTranscodeLadderRung {
quality: string
width: number
height: number
videoBitrateKbps: number
audioBitrateKbps: number
enabled: boolean
}
/** 雪碧图采样配置,字段与 FruitServer MediaTranscodeProfile.Sprite 一致。 */
export interface MediaTranscodeSprite {
enabled: boolean
intervalSeconds: number
columns: number
thumbWidth: number
}
/** 转码档位配置,字段与 FruitServer MediaTranscodeProfile 一致。 */
export interface MediaTranscodeProfile {
enabled: boolean
segmentSeconds: number
ladder: MediaTranscodeLadderRung[]
sprite: MediaTranscodeSprite
}
export interface MediaRenditionItem {
id: string
quality: string
qualityName: string
width: number
height: number
resolution: string
videoBitrateKbps: number
audioBitrateKbps: number
videoCodec: string
audioCodec?: string
container: string
hlsUrl: string
sizeBytes: number
durationSec: number
segmentCount: number
status: string
}
export interface MediaThumbnailFrameItem {
frameIndex: number
timeMs: number
endTimeMs?: number
imageUrl: string
spriteUrl: string
x: number
y: number
width: number
height: number
}
export interface MediaThumbnailSpriteInfo {
spriteUrl?: string
spriteUrls: string[]
vttUrl?: string
columns: number
rows: number
totalFrames: number
thumbWidth: number
thumbHeight: number
intervalSeconds: number
}
export interface MediaThumbnailResult {
mediaAssetId?: string | null
episodeId?: string | null
/** READY / FAILED / EMPTY / INVALID / NO_EPISODE */
status: string
errorMsg?: string
pageIndex: number
pageSize: number
frames: PageResult<MediaThumbnailFrameItem>
sprite?: MediaThumbnailSpriteInfo | null
}
export interface EpisodeTranscodeItem {
episodeId: string
mediaAssetId?: string | null
taskId?: string | null
status: string
progress?: number
retryCount?: number
errorMsg?: string
updatedAt?: string
renditions: MediaRenditionItem[]
thumbnails?: MediaThumbnailResult | null
}
export interface TranscodeTriggerResult {
taskId: string
status: string
}
export interface BannerItem { export interface BannerItem {
id: number id: number
imageUrl: string imageUrl: string
@@ -269,6 +386,10 @@ export interface AppUserItem {
maskedTarget: string maskedTarget: string
createdAt?: string createdAt?: string
lastLoginAt?: string lastLoginAt?: string
/** ANDROID / IOS / WEB */
platform?: string
/** 系统版本,例如 Android 16 */
osVersion?: string
/** 预留:会员等级与到期(第二阶段) */ /** 预留:会员等级与到期(第二阶段) */
vipLevel?: number vipLevel?: number
vipExpireAt?: string vipExpireAt?: string
@@ -294,16 +415,22 @@ export interface AppUserBanRecord {
export interface WatchHistoryItem { export interface WatchHistoryItem {
id: number id: number
userId?: number
uid?: string
nickname?: string
dramaId: number dramaId: number
dramaTitle?: string dramaTitle?: string
dramaPublished?: boolean dramaPublished?: boolean
episodeId: number episodeId: number
episodeNo?: number episodeNo?: number
episodeTitle?: string
positionMs: number positionMs: number
durationMs: number durationMs: number
finished?: boolean finished?: boolean
source: string source: string
deviceId: string deviceId: string
platform?: string
osVersion?: string
watchedAt?: string watchedAt?: string
} }
@@ -311,7 +438,10 @@ export interface WatchHistoryItem {
export interface CommentItem { export interface CommentItem {
id: number id: number
dramaId: number dramaId: number
dramaTitle?: string
episodeId: number episodeId: number
episodeNo?: number
episodeTitle?: string
parentId: number parentId: number
rootId: number rootId: number
userId: number userId: number
@@ -330,9 +460,20 @@ export interface CommentItem {
export interface ReportItem { export interface ReportItem {
id: number id: number
reporterId: number reporterId: number
reporterNickname?: string
reporterUid?: string
/** COMMENT / USER / DRAMA */ /** COMMENT / USER / DRAMA */
targetType: string targetType: string
targetId: number targetId: number
targetSummary?: string
dramaId?: number
dramaTitle?: string
episodeId?: number
episodeNo?: number
episodeTitle?: string
commentContent?: string
targetNickname?: string
targetUid?: string
reasonCode: string reasonCode: string
description: string description: string
/** pending / handled / rejected */ /** pending / handled / rejected */
-4
View File
@@ -26,10 +26,6 @@ export const getAdminRoleApi = (
return request.get({ url: '/admin/auth/menus', params }) return request.get({ url: '/admin/auth/menus', params })
} }
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
return request.get({ url: '/mock/role/list2', params })
}
/** 自助改密(首登强制改密也走这个接口) */ /** 自助改密(首登强制改密也走这个接口) */
export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => { export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => {
return request.post<void>({ url: '/admin/auth/password', data }) return request.post<void>({ url: '/admin/auth/password', data })
-1
View File
@@ -2,7 +2,6 @@
export type UserLoginType = { export type UserLoginType = {
username: string username: string
password: string
} }
export type UserType = { export type UserType = {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
import AppIcon from './src/AppIcon.vue'
export { AppIcon }
+40
View File
@@ -0,0 +1,40 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import { Icon } from '@/components/Icon'
import { normalizeAppIcon } from '@/constants/icons'
import { propTypes } from '@/utils/propTypes'
/**
* 后台统一图标入口:默认使用 Material Symbols Rounded。
*/
const props = defineProps({
icon: propTypes.string,
color: propTypes.string,
size: propTypes.number.def(18),
hoverColor: propTypes.string
})
const resolvedIcon = computed(() => normalizeAppIcon(props.icon))
</script>
<template>
<Icon
:icon="resolvedIcon"
:color="color"
:size="size"
:hover-color="hoverColor"
class="app-icon"
/>
</template>
<style scoped>
.app-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
import AppImage from './src/AppImage.vue'
export { AppImage }
+94
View File
@@ -0,0 +1,94 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { ElImage } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
defineOptions({ inheritAttrs: false })
const props = defineProps({
src: {
type: String,
required: true
},
previewList: {
type: Array as () => string[],
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
fit: {
type: String as PropType<'fill' | 'contain' | 'cover' | 'none' | 'scale-down'>,
default: 'cover'
},
class: {
type: [String, Array, Object],
default: ''
},
style: {
type: [String, Array, Object],
default: undefined
},
alt: {
type: String,
default: ''
}
})
const resolvedPreviewList = computed(() => {
const images = (props.previewList || []).filter(Boolean)
return images.length ? images : [props.src]
})
const resolvedInitialIndex = computed(() =>
Math.max(0, Math.min(props.initialIndex, resolvedPreviewList.value.length - 1))
)
</script>
<template>
<ElImage
v-bind="$attrs"
:src="src"
:alt="alt"
:fit="fit"
:preview-src-list="resolvedPreviewList"
:initial-index="resolvedInitialIndex"
:preview-teleported="true"
:hide-on-click-modal="true"
:class="['app-image', props.class]"
:style="style"
>
<template #error>
<div class="app-image-error">
<AppIcon :icon="AppIcons.error" :size="20" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.app-image {
cursor: zoom-in;
}
.app-image :deep(.el-image__inner) {
display: block;
width: 100%;
height: 100%;
}
.app-image-error {
display: flex;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-lighter);
align-items: center;
justify-content: center;
}
</style>
+4 -4
View File
@@ -9,7 +9,7 @@ import { filterBreadcrumb } from './helper'
import { filter, treeToList } from '@/utils/tree' import { filter, treeToList } from '@/utils/tree'
import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router' import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
@@ -54,7 +54,7 @@ export default defineComponent({
<ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}> <ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
{meta?.icon && breadcrumbIcon.value ? ( {meta?.icon && breadcrumbIcon.value ? (
<> <>
<Icon icon={meta.icon} class="mr-[5px]"></Icon> {t(v?.meta?.title)} <AppIcon icon={meta.icon} class="mr-[5px]"></AppIcon> {t(v?.meta?.title)}
</> </>
) : ( ) : (
t(v?.meta?.title) t(v?.meta?.title)
@@ -117,10 +117,10 @@ export default defineComponent({
:deep(&__item):last-child { :deep(&__item):last-child {
.@{prefix-cls}__inner { .@{prefix-cls}__inner {
color: var(--el-text-color-placeholder); color: var(--el-text-color-regular);
&:hover { &:hover {
color: var(--el-text-color-placeholder); color: var(--el-color-primary);
} }
} }
} }
+3 -2
View File
@@ -5,6 +5,7 @@ import { computed, unref } from 'vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -26,9 +27,9 @@ const toggleCollapse = () => {
<template> <template>
<div :class="prefixCls" @click="toggleCollapse"> <div :class="prefixCls" @click="toggleCollapse">
<Icon <AppIcon
:size="18" :size="18"
:icon="collapse ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'" :icon="collapse ? AppIcons.menuOpen : AppIcons.menu"
:color="color" :color="color"
class="cursor-pointer" class="cursor-pointer"
/> />
@@ -4,6 +4,7 @@
import { ElCard, ElTooltip } from 'element-plus' import { ElCard, ElTooltip } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -24,7 +25,7 @@ defineProps({
<template #content> <template #content>
<div class="max-w-200px">{{ message }}</div> <div class="max-w-200px">{{ message }}</div>
</template> </template>
<Icon class="ml-5px" icon="bi:question-circle-fill" :size="14" /> <AppIcon class="ml-5px" :icon="AppIcons.help" :size="14" />
</ElTooltip> </ElTooltip>
</div> </div>
</template> </template>
@@ -66,7 +66,7 @@ defineExpose({
:disabled="item.disabled" :disabled="item.disabled"
:command="item" :command="item"
> >
<Icon :icon="item.icon" /> {{ t(item.label) }} <AppIcon :icon="item.icon" /> {{ t(item.label) }}
</ElDropdownItem> </ElDropdownItem>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
@@ -7,8 +7,9 @@ import { propTypes } from '@/utils/propTypes'
import { ref, unref, PropType, computed, defineComponent } from 'vue' import { ref, unref, PropType, computed, defineComponent } from 'vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { DescriptionsSchema } from './types' import { DescriptionsSchema } from './types'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import { AppIcons } from '@/constants/icons'
const appStore = useAppStore() const appStore = useAppStore()
@@ -94,12 +95,14 @@ export default defineComponent({
{props.title} {props.title}
{props.message ? ( {props.message ? (
<ElTooltip content={props.message} placement="right"> <ElTooltip content={props.message} placement="right">
<Icon icon="bi:question-circle-fill" class="ml-5px" size={14} /> <AppIcon icon={AppIcons.help} class="ml-5px" size={14} />
</ElTooltip> </ElTooltip>
) : null} ) : null}
</div> </div>
</div> </div>
{props.collapse ? <Icon icon={show.value ? 'ep:arrow-down' : 'ep:arrow-up'} /> : null} {props.collapse ? (
<AppIcon icon={show.value ? AppIcons.chevronDown : AppIcons.chevronUp} />
) : null}
</div> </div>
) : null} ) : null}
+5 -4
View File
@@ -5,6 +5,7 @@ import { ElDialog, ElScrollbar } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue' import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue'
import { isNumber } from '@/utils/is' import { isNumber } from '@/utils/is'
import { AppIcons } from '@/constants/icons'
const slots = useSlots() const slots = useSlots()
@@ -77,17 +78,17 @@ const dialogStyle = computed(() => {
<div <div
class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]" class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]"
> >
<Icon <AppIcon
v-if="fullscreen" v-if="fullscreen"
class="cursor-pointer is-hover !h-54px mr-10px" class="cursor-pointer is-hover !h-54px mr-10px"
:icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'" :icon="isFullscreen ? AppIcons.fullscreenExit : AppIcons.fullscreen"
color="var(--el-color-info)" color="var(--el-color-info)"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
@click="toggleFull" @click="toggleFull"
/> />
<Icon <AppIcon
class="cursor-pointer is-hover !h-54px" class="cursor-pointer is-hover !h-54px"
icon="ep:close" :icon="AppIcons.close"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
color="var(--el-color-info)" color="var(--el-color-info)"
@click="close" @click="close"
@@ -1,5 +1,6 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ElCascader, ElCascader,
@@ -23,9 +24,12 @@ import {
ElUpload ElUpload
} from 'element-plus' } from 'element-plus'
import { InputPassword } from '@/components/InputPassword' import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { ComponentName } from '../types' import { ComponentName } from '../types'
// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载,
// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。
const Editor = defineAsyncComponent(() => import('@/components/Editor'))
const componentMap: Recordable<Component, ComponentName> = { const componentMap: Recordable<Component, ComponentName> = {
RadioGroup: ElRadioGroup, RadioGroup: ElRadioGroup,
RadioButton: ElRadioGroup, RadioButton: ElRadioGroup,
+2 -1
View File
@@ -22,7 +22,8 @@ import {
ISelectProps, ISelectProps,
UploadProps UploadProps
} from 'element-plus' } from 'element-plus'
import { IEditorConfig } from '@wangeditor/editor' // 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链
import type { IEditorConfig } from '@wangeditor/editor'
import { CSSProperties } from 'vue' import { CSSProperties } from 'vue'
export interface PlaceholderModel { export interface PlaceholderModel {
+5 -3
View File
@@ -6,6 +6,7 @@ import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -22,9 +23,10 @@ const props = defineProps({
}) })
const isLocal = computed(() => props.icon.startsWith('svg-icon:')) const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
const normalizedIcon = computed(() => normalizeAppIcon(props.icon))
const symbolId = computed(() => { const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
}) })
// 是否使用在线图标 // 是否使用在线图标
@@ -48,8 +50,8 @@ const getIconifyStyle = computed(() => {
</svg> </svg>
<template v-else> <template v-else>
<Icon v-if="isUseOnline" :icon="icon" :style="getIconifyStyle" /> <Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${icon} iconify`" :style="getIconifyStyle"></div> <div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template> </template>
</ElIcon> </ElIcon>
</template> </template>
@@ -7,6 +7,7 @@ import { useLocaleStore } from '@/store/modules/locale'
import { useLocale } from '@/hooks/web/useLocale' import { useLocale } from '@/hooks/web/useLocale'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -36,9 +37,9 @@ const setLang = (lang: LocaleType) => {
<template> <template>
<ElDropdown :class="prefixCls" trigger="click" @command="setLang"> <ElDropdown :class="prefixCls" trigger="click" @command="setLang">
<Icon <AppIcon
:size="18" :size="18"
icon="ion:language-sharp" :icon="AppIcons.language"
class="cursor-pointer !p-0" class="cursor-pointer !p-0"
:class="$attrs.class" :class="$attrs.class"
:color="color" :color="color"
+7 -2
View File
@@ -67,8 +67,13 @@ watch(
to="/" to="/"
> >
<img <img
src="@/assets/imgs/logo.png" src="@/assets/imgs/brand-mark.png"
class="w-[calc(var(--logo-height)-10px)] h-[calc(var(--logo-height)-10px)]" class="object-cover"
style="
width: var(--brand-mark-size);
height: var(--brand-mark-size);
border-radius: var(--brand-mark-radius);
"
/> />
<div <div
v-if="show" v-if="show"
@@ -9,7 +9,7 @@ const { renderMenuTitle } = useRenderMenuTitle()
export const useRenderMenuItem = () => export const useRenderMenuItem = () =>
// allRouters: AppRouteRecordRaw[] = [], // allRouters: AppRouteRecordRaw[] = [],
{ {
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => { const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/', nested = false) => {
return routers return routers
.filter((v) => !v.meta?.hidden) .filter((v) => !v.meta?.hidden)
.map((v) => { .map((v) => {
@@ -22,21 +22,25 @@ export const useRenderMenuItem = () =>
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) && (!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!meta?.alwaysShow !meta?.alwaysShow
) { ) {
const leafMeta = onlyOneChild?.meta
? {
...onlyOneChild.meta,
icon: onlyOneChild.meta.icon || meta.icon
}
: meta
return ( return (
<ElMenuItem <ElMenuItem
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath} index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
> >
{{ {{ default: () => renderMenuTitle(leafMeta, !nested) }}
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
}}
</ElMenuItem> </ElMenuItem>
) )
} else { } else {
return ( return (
<ElSubMenu index={fullPath}> <ElSubMenu index={fullPath}>
{{ {{
title: () => renderMenuTitle(meta), title: () => renderMenuTitle(meta, !nested),
default: () => renderMenuItem(v.children!, fullPath) default: () => renderMenuItem(v.children!, fullPath, true)
}} }}
</ElSubMenu> </ElSubMenu>
) )
@@ -1,15 +1,17 @@
// Author: fruit.com.cn
import type { RouteMeta } from 'vue-router' import type { RouteMeta } from 'vue-router'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
export const useRenderMenuTitle = () => { export const useRenderMenuTitle = () => {
const renderMenuTitle = (meta: RouteMeta) => { const renderMenuTitle = (meta: RouteMeta, showIcon = true) => {
const { t } = useI18n() const { t } = useI18n()
const { title = 'Please set title', icon } = meta const { title = 'Please set title', icon } = meta
return icon ? ( return icon && showIcon ? (
<> <>
<Icon icon={meta.icon}></Icon> <AppIcon icon={meta.icon}></AppIcon>
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap"> <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
{t(title as string)} {t(title as string)}
</span> </span>
+4 -3
View File
@@ -1,10 +1,11 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { useFullscreen } from '@vueuse/core' import { useFullscreen } from '@vueuse/core'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -23,9 +24,9 @@ const toggleFullscreen = () => {
<template> <template>
<div :class="prefixCls" @click="toggleFullscreen"> <div :class="prefixCls" @click="toggleFullscreen">
<Icon <AppIcon
:size="18" :size="18"
:icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" :icon="isFullscreen ? AppIcons.fullscreenExit : AppIcons.fullscreen"
:color="color" :color="color"
/> />
</div> </div>
@@ -5,6 +5,7 @@ import { ElButton } from 'element-plus'
import { useIcon } from '@/hooks/web/useIcon' import { useIcon } from '@/hooks/web/useIcon'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { AppIcons } from '@/constants/icons'
const emit = defineEmits(['search', 'reset', 'expand']) const emit = defineEmits(['search', 'reset', 'expand'])
@@ -37,7 +38,7 @@ const onExpand = () => {
v-if="showSearch" v-if="showSearch"
type="primary" type="primary"
:loading="searchLoading" :loading="searchLoading"
:icon="useIcon({ icon: 'ep:search' })" :icon="useIcon({ icon: AppIcons.search })"
@click="onSearch" @click="onSearch"
> >
{{ t('common.query') }} {{ t('common.query') }}
@@ -45,14 +46,14 @@ const onExpand = () => {
<ElButton <ElButton
v-if="showReset" v-if="showReset"
:loading="resetLoading" :loading="resetLoading"
:icon="useIcon({ icon: 'ep:refresh-right' })" :icon="useIcon({ icon: AppIcons.refresh })"
@click="onReset" @click="onReset"
> >
{{ t('common.reset') }} {{ t('common.reset') }}
</ElButton> </ElButton>
<ElButton <ElButton
v-if="showExpand" v-if="showExpand"
:icon="useIcon({ icon: visible ? 'ep:arrow-down' : 'ep:arrow-up' })" :icon="useIcon({ icon: visible ? AppIcons.chevronDown : AppIcons.chevronUp })"
text text
@click="onExpand" @click="onExpand"
> >
+17 -7
View File
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { ElDrawer, ElDivider, ElMessage } from 'element-plus' import { ElDrawer, ElDivider, ElMessage, ElMessageBox } from 'element-plus'
import { ref, unref } from 'vue' import { ref, unref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ThemeSwitch } from '@/components/ThemeSwitch' import { ThemeSwitch } from '@/components/ThemeSwitch'
@@ -14,6 +14,7 @@ import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
import { useStorage } from '@/hooks/web/useStorage' import { useStorage } from '@/hooks/web/useStorage'
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { clear: storageClear } = useStorage('localStorage') const { clear: storageClear } = useStorage('localStorage')
@@ -145,9 +146,18 @@ const copyConfig = async () => {
} }
// 清空缓存 // 清空缓存
const clear = () => { const clear = async () => {
storageClear() try {
window.location.reload() await ElMessageBox.confirm(t('setting.clearCacheMessage'), t('setting.clearAndReset'), {
confirmButtonText: t('common.ok'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
storageClear()
window.location.reload()
} catch {
// 取消清除时保持当前状态
}
} }
const themeChange = () => { const themeChange = () => {
@@ -163,7 +173,7 @@ const themeChange = () => {
class="fixed top-[45%] right-0 w-40px h-40px flex items-center justify-center bg-[var(--el-color-primary)] cursor-pointer z-10" class="fixed top-[45%] right-0 w-40px h-40px flex items-center justify-center bg-[var(--el-color-primary)] cursor-pointer z-10"
@click="drawer = true" @click="drawer = true"
> >
<Icon icon="ant-design:setting-outlined" color="#fff" /> <AppIcon :icon="AppIcons.settings" color="#fff" />
</div> </div>
<ElDrawer v-model="drawer" direction="rtl" size="350px" :z-index="4000"> <ElDrawer v-model="drawer" direction="rtl" size="350px" :z-index="4000">
@@ -185,7 +195,7 @@ const themeChange = () => {
<ColorRadioPicker <ColorRadioPicker
v-model="systemTheme" v-model="systemTheme"
:schema="[ :schema="[
'#409eff', '#c4322a',
'#009688', '#009688',
'#536dfe', '#536dfe',
'#ff5c93', '#ff5c93',
@@ -254,6 +264,6 @@ const themeChange = () => {
@prefix-cls: ~'@{namespace}-setting'; @prefix-cls: ~'@{namespace}-setting';
.@{prefix-cls} { .@{prefix-cls} {
border-radius: 6px 0 0 6px; border-radius: var(--app-radius-md) 0 0 6px;
} }
</style> </style>
@@ -4,6 +4,7 @@
import { PropType, watch, unref, ref } from 'vue' import { PropType, watch, unref, ref } from 'vue'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -51,7 +52,7 @@ watch(
}" }"
@click="colorVal = item" @click="colorVal = item"
> >
<Icon v-if="colorVal === item" color="#fff" icon="ep:check" :size="16" /> <AppIcon v-if="colorVal === item" color="#fff" :icon="AppIcons.check" :size="16" />
</span> </span>
</div> </div>
</template> </template>
@@ -67,7 +67,7 @@ const layout = computed(() => appStore.getLayout)
.@{prefix-cls} { .@{prefix-cls} {
&__classic { &__classic {
border: 2px solid #e5e7eb; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: var(--app-radius-sm);
&::before { &::before {
position: absolute; position: absolute;
@@ -77,7 +77,7 @@ const layout = computed(() => appStore.getLayout)
width: 33%; width: 33%;
height: 100%; height: 100%;
background-color: #273352; background-color: #273352;
border-radius: 4px 0 0 4px; border-radius: var(--app-radius-sm) 0 0 4px;
content: ''; content: '';
} }
@@ -88,14 +88,14 @@ const layout = computed(() => appStore.getLayout)
width: 100%; width: 100%;
height: 25%; height: 25%;
background-color: #fff; background-color: #fff;
border-radius: 4px 4px 0; border-radius: var(--app-radius-sm) 4px 0;
content: ''; content: '';
} }
} }
&__top-left { &__top-left {
border: 2px solid #e5e7eb; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: var(--app-radius-sm);
&::before { &::before {
position: absolute; position: absolute;
@@ -105,7 +105,7 @@ const layout = computed(() => appStore.getLayout)
width: 100%; width: 100%;
height: 33%; height: 33%;
background-color: #273352; background-color: #273352;
border-radius: 4px 4px 0 0; border-radius: var(--app-radius-sm) 4px 0 0;
content: ''; content: '';
} }
@@ -116,14 +116,14 @@ const layout = computed(() => appStore.getLayout)
width: 33%; width: 33%;
height: 100%; height: 100%;
background-color: #fff; background-color: #fff;
border-radius: 4px 0 0 4px; border-radius: var(--app-radius-sm) 0 0 4px;
content: ''; content: '';
} }
} }
&__top { &__top {
border: 2px solid #e5e7eb; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: var(--app-radius-sm);
&::before { &::before {
position: absolute; position: absolute;
@@ -133,14 +133,14 @@ const layout = computed(() => appStore.getLayout)
width: 100%; width: 100%;
height: 33%; height: 33%;
background-color: #273352; background-color: #273352;
border-radius: 4px 4px 0 0; border-radius: var(--app-radius-sm) 4px 0 0;
content: ''; content: '';
} }
} }
&__cut-menu { &__cut-menu {
border: 2px solid #e5e7eb; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: var(--app-radius-sm);
&::before { &::before {
position: absolute; position: absolute;
@@ -150,7 +150,7 @@ const layout = computed(() => appStore.getLayout)
width: 100%; width: 100%;
height: 33%; height: 33%;
background-color: #273352; background-color: #273352;
border-radius: 4px 4px 0 0; border-radius: var(--app-radius-sm) 4px 0 0;
content: ''; content: '';
} }
@@ -161,7 +161,7 @@ const layout = computed(() => appStore.getLayout)
width: 10%; width: 10%;
height: 100%; height: 100%;
background-color: #fff; background-color: #fff;
border-radius: 4px 0 0 4px; border-radius: var(--app-radius-sm) 0 0 4px;
content: ''; content: '';
} }
} }
@@ -7,6 +7,7 @@ import { useAppStore } from '@/store/modules/app'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -29,7 +30,7 @@ const setCurrentSize = (size: ComponentSize) => {
<template> <template>
<ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize"> <ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize">
<Icon :size="18" icon="mdi:format-size" :color="color" class="cursor-pointer" /> <AppIcon :size="18" :icon="AppIcons.formatSize" :color="color" class="cursor-pointer" />
<template #dropdown> <template #dropdown>
<ElDropdownMenu> <ElDropdownMenu>
<ElDropdownItem v-for="item in sizeMap" :key="item" :command="item"> <ElDropdownItem v-for="item in sizeMap" :key="item" :command="item">
+4 -3
View File
@@ -6,7 +6,7 @@ import { useAppStore } from '@/store/modules/app'
import { computed, unref, defineComponent, watch, ref, onMounted } from 'vue' import { computed, unref, defineComponent, watch, ref, onMounted } from 'vue'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ElScrollbar } from 'element-plus' import { ElScrollbar } from 'element-plus'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { Menu } from '@/components/Menu' import { Menu } from '@/components/Menu'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { pathResolve } from '@/utils/routerHelper' import { pathResolve } from '@/utils/routerHelper'
@@ -14,6 +14,7 @@ import { cloneDeep } from 'lodash-es'
import { filterMenusPath, initTabMap, tabPathMap } from './helper' import { filterMenusPath, initTabMap, tabPathMap } from './helper'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { isUrl } from '@/utils/is' import { isUrl } from '@/utils/is'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls, variables } = useDesign() const { getPrefixCls, variables } = useDesign()
@@ -180,7 +181,7 @@ export default defineComponent({
}} }}
> >
<div> <div>
<Icon icon={item?.meta?.icon}></Icon> <AppIcon icon={item?.meta?.icon}></AppIcon>
</div> </div>
{!unref(showTitle) ? undefined : ( {!unref(showTitle) ? undefined : (
<p class="break-words mt-5px px-2px">{t(item.meta?.title)}</p> <p class="break-words mt-5px px-2px">{t(item.meta?.title)}</p>
@@ -198,7 +199,7 @@ export default defineComponent({
]} ]}
onClick={setCollapse} onClick={setCollapse}
> >
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon> <AppIcon icon={unref(collapse) ? AppIcons.chevronRight : AppIcons.chevronLeft}></AppIcon>
</div> </div>
<Menu <Menu
class={[ class={[
+3 -2
View File
@@ -20,8 +20,9 @@ import { CSSProperties } from 'vue'
import { getSlot } from '@/utils/tsxHelper' import { getSlot } from '@/utils/tsxHelper'
import TableActions from './components/TableActions.vue' import TableActions from './components/TableActions.vue'
import { createVideoViewer } from '@/components/VideoPlayer' import { createVideoViewer } from '@/components/VideoPlayer'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { BaseButton } from '@/components/Button' import { BaseButton } from '@/components/Button'
import { AppIcons } from '@/constants/icons'
export default defineComponent({ export default defineComponent({
name: 'Table', name: 'Table',
@@ -408,7 +409,7 @@ export default defineComponent({
) : videoPreview.includes(field) ? ( ) : videoPreview.includes(field) ? (
<BaseButton <BaseButton
type="primary" type="primary"
icon={<Icon icon="ep:video-play" />} icon={<AppIcon icon={AppIcons.videoPlay} />}
onClick={() => { onClick={() => {
createVideoViewer({ createVideoViewer({
url url
@@ -13,6 +13,7 @@ import { TableColumn } from '../types'
import { PropType, ref, watch, unref } from 'vue' import { PropType, ref, watch, unref } from 'vue'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
import { DEFAULT_FILTER_COLUMN } from '@/constants' import { DEFAULT_FILTER_COLUMN } from '@/constants'
import { AppIcons } from '@/constants/icons'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
const modelValue = defineModel<boolean>() const modelValue = defineModel<boolean>()
@@ -141,17 +142,19 @@ watch(
<div class="flex items-center"> <div class="flex items-center">
<ElRadioGroup size="small" v-model="item.fixed"> <ElRadioGroup size="small" v-model="item.fixed">
<ElRadioButton label="left"> <ElRadioButton label="left">
<Icon icon="ep:arrow-left" /> <AppIcon :icon="AppIcons.chevronLeft" />
</ElRadioButton> </ElRadioButton>
<ElRadioButton :label="undefined"> <ElRadioButton :label="undefined">
<Icon icon="ep:close" /> <AppIcon :icon="AppIcons.close" />
</ElRadioButton> </ElRadioButton>
<ElRadioButton label="right"> <ElRadioButton label="right">
<Icon icon="ep:arrow-right" /> <AppIcon :icon="AppIcons.chevronRight" />
</ElRadioButton> </ElRadioButton>
</ElRadioGroup> </ElRadioGroup>
<div class="ml-12px cursor-move handle"><Icon icon="ep:rank" /></div> <div class="ml-12px cursor-move handle">
<AppIcon :icon="AppIcons.drag" />
</div>
</div> </div>
</div> </div>
</ElCheckboxGroup> </ElCheckboxGroup>
@@ -3,11 +3,12 @@
<script lang="tsx"> <script lang="tsx">
import { defineComponent, unref, computed, PropType, ref } from 'vue' import { defineComponent, unref, computed, PropType, ref } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ComponentSize } from 'element-plus' import { ElDropdown, ElDropdownMenu, ElDropdownItem, ComponentSize } from 'element-plus'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { TableColumn } from '../types' import { TableColumn } from '../types'
import ColumnSetting from './ColumnSetting.vue' import ColumnSetting from './ColumnSetting.vue'
import { AppIcons } from '@/constants/icons'
export default defineComponent({ export default defineComponent({
name: 'TableActions', name: 'TableActions',
@@ -47,8 +48,8 @@ export default defineComponent({
<> <>
<div class="text-right h-28px flex items-center justify-end"> <div class="text-right h-28px flex items-center justify-end">
<div title="刷新" class="w-30px h-20px flex items-center justify-end" onClick={refresh}> <div title="刷新" class="w-30px h-20px flex items-center justify-end" onClick={refresh}>
<Icon <AppIcon
icon="ant-design:sync-outlined" icon={AppIcons.refresh}
class="cursor-pointer" class="cursor-pointer"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
/> />
@@ -59,8 +60,8 @@ export default defineComponent({
default: () => { default: () => {
return ( return (
<div title="尺寸" class="w-30px h-20px flex items-center justify-end"> <div title="尺寸" class="w-30px h-20px flex items-center justify-end">
<Icon <AppIcon
icon="ant-design:column-height-outlined" icon={AppIcons.viewColumn}
class="cursor-pointer" class="cursor-pointer"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
/> />
@@ -92,8 +93,8 @@ export default defineComponent({
class="w-30px h-20px flex items-center justify-end" class="w-30px h-20px flex items-center justify-end"
onClick={showColumnSetting} onClick={showColumnSetting}
> >
<Icon <AppIcon
icon="ant-design:setting-outlined" icon={AppIcons.settings}
class="cursor-pointer" class="cursor-pointer"
hover-color="var(--el-color-primary)" hover-color="var(--el-color-primary)"
/> />
+28 -31
View File
@@ -16,6 +16,7 @@ import { ElScrollbar } from 'element-plus'
import { useScrollTo } from '@/hooks/event/useScrollTo' import { useScrollTo } from '@/hooks/event/useScrollTo'
import { useTagsView } from '@/hooks/web/useTagsView' import { useTagsView } from '@/hooks/web/useTagsView'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -262,18 +263,14 @@ watch(
</script> </script>
<template> <template>
<div <div :id="prefixCls" :class="prefixCls" class="flex w-full relative bg-[var(--el-bg-color)]">
:id="prefixCls"
:class="prefixCls"
class="flex w-full relative bg-[#fff] dark:bg-[var(--el-bg-color)]"
>
<span <span
:class="`${prefixCls}__tool ${prefixCls}__tool--first`" :class="`${prefixCls}__tool ${prefixCls}__tool--first`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer" class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="move(-200)" @click="move(-200)"
> >
<Icon <AppIcon
icon="ep:d-arrow-left" :icon="AppIcons.chevronLeft"
color="var(--el-text-color-placeholder)" color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'" :hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/> />
@@ -285,7 +282,7 @@ watch(
:ref="itemRefs.set" :ref="itemRefs.set"
:schema="[ :schema="[
{ {
icon: 'ant-design:sync-outlined', icon: AppIcons.refresh,
label: t('common.reload'), label: t('common.reload'),
disabled: selectedTag?.fullPath !== item.fullPath, disabled: selectedTag?.fullPath !== item.fullPath,
command: () => { command: () => {
@@ -293,7 +290,7 @@ watch(
} }
}, },
{ {
icon: 'ant-design:close-outlined', icon: AppIcons.close,
label: t('common.closeTab'), label: t('common.closeTab'),
disabled: !!visitedViews?.length && selectedTag?.meta.affix, disabled: !!visitedViews?.length && selectedTag?.meta.affix,
command: () => { command: () => {
@@ -302,7 +299,7 @@ watch(
}, },
{ {
divided: true, divided: true,
icon: 'ant-design:vertical-right-outlined', icon: AppIcons.splitRight,
label: t('common.closeTheLeftTab'), label: t('common.closeTheLeftTab'),
disabled: disabled:
!!visitedViews?.length && !!visitedViews?.length &&
@@ -313,7 +310,7 @@ watch(
} }
}, },
{ {
icon: 'ant-design:vertical-left-outlined', icon: AppIcons.splitLeft,
label: t('common.closeTheRightTab'), label: t('common.closeTheRightTab'),
disabled: disabled:
!!visitedViews?.length && !!visitedViews?.length &&
@@ -325,7 +322,7 @@ watch(
}, },
{ {
divided: true, divided: true,
icon: 'ant-design:tag-outlined', icon: AppIcons.tag,
label: t('common.closeOther'), label: t('common.closeOther'),
disabled: selectedTag?.fullPath !== item.fullPath, disabled: selectedTag?.fullPath !== item.fullPath,
command: () => { command: () => {
@@ -333,7 +330,7 @@ watch(
} }
}, },
{ {
icon: 'ant-design:line-outlined', icon: AppIcons.line,
label: t('common.closeAll'), label: t('common.closeAll'),
command: () => { command: () => {
closeAllTags() closeAllTags()
@@ -358,7 +355,7 @@ watch(
@click="navigate" @click="navigate"
class="h-full flex justify-center items-center whitespace-nowrap pl-15px" class="h-full flex justify-center items-center whitespace-nowrap pl-15px"
> >
<Icon <AppIcon
v-if=" v-if="
item?.matched && item?.matched &&
item?.matched[1] && item?.matched[1] &&
@@ -370,10 +367,10 @@ watch(
class="mr-5px" class="mr-5px"
/> />
{{ t(item?.meta?.title as string) }} {{ t(item?.meta?.title as string) }}
<Icon <AppIcon
:class="`${prefixCls}__item--close`" :class="`${prefixCls}__item--close`"
color="#333" color="var(--el-text-color-secondary)"
icon="ant-design:close-outlined" :icon="AppIcons.close"
:size="12" :size="12"
@click.prevent.stop="closeSelectedTag(item)" @click.prevent.stop="closeSelectedTag(item)"
/> />
@@ -389,8 +386,8 @@ watch(
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer" class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="move(200)" @click="move(200)"
> >
<Icon <AppIcon
icon="ep:d-arrow-right" :icon="AppIcons.chevronRight"
color="var(--el-text-color-placeholder)" color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'" :hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/> />
@@ -400,8 +397,8 @@ watch(
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer" class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
@click="refreshSelectedTag(selectedTag)" @click="refreshSelectedTag(selectedTag)"
> >
<Icon <AppIcon
icon="ant-design:reload-outlined" :icon="AppIcons.refresh"
color="var(--el-text-color-placeholder)" color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'" :hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/> />
@@ -410,14 +407,14 @@ watch(
trigger="click" trigger="click"
:schema="[ :schema="[
{ {
icon: 'ant-design:sync-outlined', icon: AppIcons.refresh,
label: t('common.reload'), label: t('common.reload'),
command: () => { command: () => {
refreshSelectedTag(selectedTag) refreshSelectedTag(selectedTag)
} }
}, },
{ {
icon: 'ant-design:close-outlined', icon: AppIcons.close,
label: t('common.closeTab'), label: t('common.closeTab'),
disabled: !!visitedViews?.length && selectedTag?.meta.affix, disabled: !!visitedViews?.length && selectedTag?.meta.affix,
command: () => { command: () => {
@@ -426,7 +423,7 @@ watch(
}, },
{ {
divided: true, divided: true,
icon: 'ant-design:vertical-right-outlined', icon: AppIcons.splitRight,
label: t('common.closeTheLeftTab'), label: t('common.closeTheLeftTab'),
disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath, disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath,
command: () => { command: () => {
@@ -434,7 +431,7 @@ watch(
} }
}, },
{ {
icon: 'ant-design:vertical-left-outlined', icon: AppIcons.splitLeft,
label: t('common.closeTheRightTab'), label: t('common.closeTheRightTab'),
disabled: disabled:
!!visitedViews?.length && !!visitedViews?.length &&
@@ -445,14 +442,14 @@ watch(
}, },
{ {
divided: true, divided: true,
icon: 'ant-design:tag-outlined', icon: AppIcons.tag,
label: t('common.closeOther'), label: t('common.closeOther'),
command: () => { command: () => {
closeOthersTags() closeOthersTags()
} }
}, },
{ {
icon: 'ant-design:line-outlined', icon: AppIcons.line,
label: t('common.closeAll'), label: t('common.closeAll'),
command: () => { command: () => {
closeAllTags() closeAllTags()
@@ -464,8 +461,8 @@ watch(
:class="`${prefixCls}__tool`" :class="`${prefixCls}__tool`"
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer block" class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer block"
> >
<Icon <AppIcon
icon="ant-design:setting-outlined" :icon="AppIcons.settings"
color="var(--el-text-color-placeholder)" color="var(--el-text-color-placeholder)"
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'" :hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
/> />
@@ -542,8 +539,8 @@ watch(
&__item.is-active { &__item.is-active {
color: var(--el-color-white); color: var(--el-color-white);
background-color: var(--el-color-primary); background-color: var(--app-primary-strong);
border: 1px solid var(--el-color-primary); border: 1px solid var(--app-primary-strong);
.@{prefix-cls}__item--close { .@{prefix-cls}__item--close {
:deep(svg) { :deep(svg) {
color: var(--el-color-white) !important; color: var(--el-color-white) !important;
+18 -10
View File
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store/modules/app'
import { ElSwitch } from 'element-plus' import { ElSwitch } from 'element-plus'
import { useIcon } from '@/hooks/web/useIcon' import { useIcon } from '@/hooks/web/useIcon'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -13,18 +14,15 @@ const emit = defineEmits(['change'])
const prefixCls = getPrefixCls('theme-switch') const prefixCls = getPrefixCls('theme-switch')
const Sun = useIcon({ icon: 'emojione-monotone:sun', color: '#fde047' }) const Sun = useIcon({ icon: AppIcons.lightMode, color: '#fde047' })
const CrescentMoon = useIcon({ icon: 'emojione-monotone:crescent-moon', color: '#fde047' }) const CrescentMoon = useIcon({ icon: AppIcons.darkMode, color: '#fde047' })
const appStore = useAppStore() const appStore = useAppStore()
// 初始化获取是否是暗黑主题 // 初始化获取是否是暗黑主题
const isDark = ref(appStore.getIsDark) const isDark = ref(appStore.getIsDark)
// 设置switch的背景颜色
const blackColor = 'var(--el-color-black)'
const themeChange = (val: boolean) => { const themeChange = (val: boolean) => {
appStore.setIsDark(val) appStore.setIsDark(val)
emit('change', val) emit('change', val)
@@ -36,9 +34,6 @@ const themeChange = (val: boolean) => {
:class="prefixCls" :class="prefixCls"
v-model="isDark" v-model="isDark"
inline-prompt inline-prompt
:border-color="blackColor"
:inactive-color="blackColor"
:active-color="blackColor"
:active-icon="Sun" :active-icon="Sun"
:inactive-icon="CrescentMoon" :inactive-icon="CrescentMoon"
@change="themeChange" @change="themeChange"
@@ -46,7 +41,20 @@ const themeChange = (val: boolean) => {
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
:deep(.el-switch__core .el-switch__inner .is-icon) { .v-theme-switch {
overflow: visible; :deep(.el-switch__core .el-switch__inner .is-icon) {
overflow: visible;
}
:deep(.el-switch__core) {
min-width: 44px;
background: var(--el-fill-color-darker);
border-color: var(--el-text-color-secondary);
}
}
.v-theme-switch.is-checked :deep(.el-switch__core) {
background: var(--app-primary-strong) !important;
border-color: var(--app-primary-strong) !important;
} }
</style> </style>
+18 -3
View File
@@ -9,6 +9,8 @@ import { ref, computed } from 'vue'
import LockPage from './components/LockPage.vue' import LockPage from './components/LockPage.vue'
import { useLockStore } from '@/store/modules/lock' import { useLockStore } from '@/store/modules/lock'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
const userStore = useUserStore() const userStore = useUserStore()
@@ -22,6 +24,12 @@ const prefixCls = getPrefixCls('user-info')
const { t } = useI18n() const { t } = useI18n()
const displayName = computed(
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
)
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
const loginOut = () => { const loginOut = () => {
userStore.logoutConfirm() userStore.logoutConfirm()
} }
@@ -38,12 +46,19 @@ const lockScreen = () => {
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click"> <ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
<div class="flex items-center"> <div class="flex items-center">
<img <img
src="@/assets/imgs/avatar.jpg" v-if="avatarUrl"
:src="avatarUrl"
alt="" alt=""
class="w-[calc(var(--logo-height)-25px)] rounded-[50%]" class="w-[calc(var(--logo-height)-25px)] h-[calc(var(--logo-height)-25px)] rounded-[50%] object-cover"
/> />
<span
v-else
class="w-[calc(var(--logo-height)-25px)] h-[calc(var(--logo-height)-25px)] rounded-[50%] flex items-center justify-center bg-[var(--el-fill-color)]"
>
<AppIcon :icon="AppIcons.person" />
</span>
<span class="<lg:hidden text-14px pl-[5px] text-[var(--top-header-text-color)]">{{ <span class="<lg:hidden text-14px pl-[5px] text-[var(--top-header-text-color)]">{{
userStore.getUserInfo?.username displayName
}}</span> }}</span>
</div> </div>
<template #dropdown> <template #dropdown>
@@ -2,16 +2,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ref } from 'vue' import { computed, reactive } from 'vue'
import { Dialog } from '@/components/Dialog' import { Dialog } from '@/components/Dialog'
import { Form } from '@/components/Form' import { Form } from '@/components/Form'
import { useForm } from '@/hooks/web/useForm' import { useForm } from '@/hooks/web/useForm'
import { reactive, computed } from 'vue'
import { useValidator } from '@/hooks/web/useValidator' import { useValidator } from '@/hooks/web/useValidator'
import { FormSchema } from '@/components/Form' import { FormSchema } from '@/components/Form'
import { ElButton } from 'element-plus' import { ElButton } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { useLockStore } from '@/store/modules/lock' import { useLockStore } from '@/store/modules/lock'
import { useUserStore } from '@/store/modules/user'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('lock-dialog') const prefixCls = getPrefixCls('lock-dialog')
@@ -21,6 +23,12 @@ const { required } = useValidator()
const { t } = useI18n() const { t } = useI18n()
const lockStore = useLockStore() const lockStore = useLockStore()
const userStore = useUserStore()
const displayName = computed(
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
)
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -32,13 +40,10 @@ const emit = defineEmits(['update:modelValue'])
const dialogVisible = computed({ const dialogVisible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => { set: (val) => emit('update:modelValue', val)
console.log('set: ', val)
emit('update:modelValue', val)
}
}) })
const dialogTitle = ref(t('lock.lockScreen')) const dialogTitle = computed(() => t('lock.lockScreen'))
const rules = reactive({ const rules = reactive({
password: [required()] password: [required()]
@@ -51,7 +56,10 @@ const schema: FormSchema[] = reactive([
component: 'Input', component: 'Input',
componentProps: { componentProps: {
type: 'password', type: 'password',
showPassword: true showPassword: true,
style: {
width: '100%'
}
} }
} }
]) ])
@@ -73,31 +81,169 @@ const handleLock = async () => {
} }
}) })
} }
const cancel = () => {
dialogVisible.value = false
}
</script> </script>
<template> <template>
<Dialog <Dialog
v-model="dialogVisible" v-model="dialogVisible"
width="500px" width="440px"
max-height="170px" max-height="320px"
:class="prefixCls" :class="prefixCls"
:title="dialogTitle" :title="dialogTitle"
:fullscreen="false"
> >
<div class="flex flex-col items-center"> <template #title>
<img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" /> <div class="lock-dialog-heading">
<span class="text-14px my-10px text-[var(--top-header-text-color)]">Archer</span> <AppIcon :icon="AppIcons.lock" :size="20" />
<div>
<h2>{{ dialogTitle }}</h2>
<p>{{ t('lock.description') }}</p>
</div>
</div>
</template>
<div class="lock-dialog-body">
<div class="lock-dialog-user">
<img v-if="avatarUrl" :src="avatarUrl" alt="" class="lock-dialog-avatar" />
<span v-else class="lock-dialog-avatar lock-dialog-avatar--empty">
<AppIcon :icon="AppIcons.person" :size="28" />
</span>
<span class="lock-dialog-name">{{ displayName }}</span>
</div>
<div class="lock-password-field" @keyup.enter.prevent.stop="handleLock">
<Form
:is-col="false"
:schema="schema"
:rules="rules"
label-position="top"
@register="formRegister"
/>
</div>
</div> </div>
<Form :is-col="false" :schema="schema" :rules="rules" @register="formRegister" />
<template #footer> <template #footer>
<ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton> <ElButton class="lock-dialog-cancel" @click="cancel">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" class="lock-dialog-submit" @click="handleLock">
{{ t('lock.lock') }}
</ElButton>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
:global(.v-lock-dialog) { :global(.v-lock-dialog) {
@media (width <= 767px) { display: flex;
width: 440px !important;
height: auto !important;
max-width: calc(100vw - 24px);
max-height: 440px;
overflow: hidden;
flex-direction: column;
}
:global(.v-lock-dialog .el-dialog__header) {
height: auto;
min-height: 56px;
padding: 8px 20px 4px !important;
border-bottom: 0;
}
:global(.v-lock-dialog .el-dialog__body) {
min-height: 0;
padding: 4px 20px 8px !important;
overflow: hidden;
}
:global(.v-lock-dialog .el-dialog__footer) {
padding: 6px 20px 10px;
}
:global(.v-lock-dialog .el-dialog__footer .el-button) {
height: 36px;
min-width: 96px;
}
:global(.v-lock-dialog .el-dialog__header .absolute) {
right: -14px !important;
}
@media (width <= 767px) {
:global(.v-lock-dialog) {
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
} }
} }
.lock-dialog-heading {
display: flex;
align-items: flex-start;
gap: 10px;
padding-right: 40px;
color: var(--el-text-color-primary);
.app-icon {
margin-top: 2px;
color: var(--el-color-primary);
}
h2 {
margin: 0;
font-size: 16px;
font-weight: 650;
line-height: 1.4;
}
p {
margin: 3px 0 0;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
}
.lock-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.lock-dialog-user {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.lock-dialog-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
.lock-dialog-avatar--empty {
display: flex;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
align-items: center;
justify-content: center;
}
.lock-dialog-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.lock-password-field {
width: 100%;
}
.lock-dialog-submit {
min-width: 96px;
}
</style> </style>
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { ElInput, ElButton } from 'element-plus' import { ElInput, ElButton } from 'element-plus'
import { resetRouter } from '@/router' import { resetRouter } from '@/router'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -10,11 +10,14 @@ import { useLockStore } from '@/store/modules/lock'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { useNow } from '@/hooks/web/useNow' import { useNow } from '@/hooks/web/useNow'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { loginOutApi } from '@/api/login' import { loginOutApi } from '@/api/login'
import { useTagsViewStore } from '@/store/modules/tagsView' import { useTagsViewStore } from '@/store/modules/tagsView'
import { AppIcons } from '@/constants/icons'
import { useUserStore } from '@/store/modules/user'
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
const userStore = useUserStore()
const { clear } = useStorage() const { clear } = useStorage()
@@ -34,6 +37,11 @@ const { hour, month, minute, meridiem, year, day, week } = useNow(true)
const { t } = useI18n() const { t } = useI18n()
const displayName = computed(
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
)
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
// 解锁 // 解锁
async function unLock() { async function unLock() {
if (!password.value) { if (!password.value) {
@@ -77,7 +85,7 @@ function handleShowForm(show = false) {
@click="handleShowForm(false)" @click="handleShowForm(false)"
v-show="showDate" v-show="showDate"
> >
<Icon icon="ep:lock" /> <AppIcon :icon="AppIcons.lock" />
<span>{{ t('lock.unlock') }}</span> <span>{{ t('lock.unlock') }}</span>
</div> </div>
@@ -96,8 +104,20 @@ function handleShowForm(show = false) {
<div :class="`${prefixCls}-entry`" v-show="!showDate"> <div :class="`${prefixCls}-entry`" v-show="!showDate">
<div :class="`${prefixCls}-entry-content`"> <div :class="`${prefixCls}-entry-content`">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" /> <img
<span class="text-14px my-10px text-[var(--logo-title-text-color)]">Archer</span> v-if="avatarUrl"
:src="avatarUrl"
alt=""
class="w-70px h-70px rounded-[50%] object-cover"
/>
<span
v-else
class="w-70px h-70px rounded-[50%] flex items-center justify-center bg-[var(--el-fill-color)]"
><AppIcon :icon="AppIcons.person" :size="30"
/></span>
<span class="text-14px my-10px text-[var(--logo-title-text-color)]">{{
displayName
}}</span>
</div> </div>
<ElInput <ElInput
type="password" type="password"
@@ -4,7 +4,8 @@
import { VideoPlayer } from '@/components/VideoPlayer' import { VideoPlayer } from '@/components/VideoPlayer'
import { ElOverlay } from 'element-plus' import { ElOverlay } from 'element-plus'
import { ref, nextTick } from 'vue' import { ref, nextTick } from 'vue'
import { Icon } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -43,7 +44,7 @@ const close = async () => {
class="w-44px h-44px color-[#fff] bg-[var(--el-text-color-regular)] rounded-full border-[#fff] flex justify-center items-center cursor-pointer absolute top-40px right-40px" class="w-44px h-44px color-[#fff] bg-[var(--el-text-color-regular)] rounded-full border-[#fff] flex justify-center items-center cursor-pointer absolute top-40px right-40px"
@click="close" @click="close"
> >
<Icon icon="ep:close" :size="24" /> <AppIcon :icon="AppIcons.close" :size="24" />
</div> </div>
<VideoPlayer :url="url" :poster="poster" /> <VideoPlayer :url="url" :poster="poster" />
</div> </div>
+4
View File
@@ -2,9 +2,13 @@
import type { App } from 'vue' import type { App } from 'vue'
import { Icon } from './Icon' import { Icon } from './Icon'
import { AppIcon } from './AppIcon'
import { AppImage } from './AppImage'
import { BaseButton } from './Button' import { BaseButton } from './Button'
export const setupGlobCom = (app: App<Element>): void => { export const setupGlobCom = (app: App<Element>): void => {
app.component('Icon', Icon) app.component('Icon', Icon)
app.component('AppIcon', AppIcon)
app.component('AppImage', AppImage)
app.component('BaseButton', BaseButton) app.component('BaseButton', BaseButton)
} }
+105
View File
@@ -0,0 +1,105 @@
// Author: fruit.com.cn
/**
* 后台统一图标语义表。所有值都来自 material-symbols-rounded。
*/
export const AppIcons = {
menu: 'material-symbols:menu-rounded',
menuOpen: 'material-symbols:menu-open-rounded',
dashboard: 'material-symbols:dashboard-rounded',
content: 'material-symbols:video-library-rounded',
settings: 'material-symbols:settings-rounded',
group: 'material-symbols:group-rounded',
movieEdit: 'material-symbols:movie-edit-rounded',
policy: 'material-symbols:policy-rounded',
payments: 'material-symbols:payments-rounded',
monitoring: 'material-symbols:monitoring-rounded',
videoCameraFront: 'material-symbols:video-camera-front-rounded',
campaign: 'material-symbols:campaign-rounded',
fullscreen: 'material-symbols:fullscreen-rounded',
fullscreenExit: 'material-symbols:fullscreen-exit-rounded',
formatSize: 'material-symbols:format-size-rounded',
language: 'material-symbols:translate-rounded',
darkMode: 'material-symbols:dark-mode-rounded',
lightMode: 'material-symbols:light-mode-rounded',
refresh: 'material-symbols:refresh-rounded',
restart: 'material-symbols:restart-alt-rounded',
close: 'material-symbols:close-rounded',
chevronLeft: 'material-symbols:chevron-left-rounded',
chevronRight: 'material-symbols:chevron-right-rounded',
chevronDown: 'material-symbols:keyboard-arrow-down-rounded',
chevronUp: 'material-symbols:keyboard-arrow-up-rounded',
search: 'material-symbols:search-rounded',
viewColumn: 'material-symbols:combine-columns-rounded',
help: 'material-symbols:help-rounded',
add: 'material-symbols:add-rounded',
edit: 'material-symbols:edit-rounded',
delete: 'material-symbols:delete-rounded',
upload: 'material-symbols:upload-rounded',
download: 'material-symbols:download-rounded',
copy: 'material-symbols:content-copy-rounded',
openInNew: 'material-symbols:open-in-new-rounded',
visibility: 'material-symbols:visibility-rounded',
arrowUp: 'material-symbols:arrow-upward-rounded',
arrowDown: 'material-symbols:arrow-downward-rounded',
arrowBack: 'material-symbols:arrow-back-rounded',
arrowForward: 'material-symbols:arrow-forward-rounded',
check: 'material-symbols:check-rounded',
checkCircle: 'material-symbols:check-circle-rounded',
cancel: 'material-symbols:cancel-rounded',
lock: 'material-symbols:lock-person-rounded',
logout: 'material-symbols:logout-rounded',
person: 'material-symbols:person-rounded',
clock: 'material-symbols:schedule-rounded',
videoPlay: 'material-symbols:smart-display-rounded',
drag: 'material-symbols:drag-handle-rounded',
tag: 'material-symbols:label-rounded',
line: 'material-symbols:horizontal-rule-rounded',
splitLeft: 'material-symbols:split-scene-left-rounded',
splitRight: 'material-symbols:split-scene-right-rounded',
warning: 'material-symbols:warning-rounded',
error: 'material-symbols:error-rounded',
info: 'material-symbols:info-rounded'
} as const
/** 菜单管理提供的合法图标候选。 */
export const MenuIconOptions = [
AppIcons.dashboard,
AppIcons.content,
AppIcons.settings,
AppIcons.group,
AppIcons.movieEdit,
AppIcons.policy,
AppIcons.payments,
AppIcons.monitoring,
AppIcons.videoCameraFront,
AppIcons.campaign
] as const
const legacyIconAliases: Record<string, string> = {
Document: AppIcons.content,
DataAnalysis: AppIcons.monitoring,
Picture: AppIcons.content,
User: AppIcons.group,
Setting: AppIcons.settings,
VideoCamera: AppIcons.content,
Menu: AppIcons.menu,
Grid: AppIcons.dashboard,
'ep:setting': AppIcons.settings,
'ep:user': AppIcons.group,
'ep:user-filled': AppIcons.group,
'ep:chat-dot-round': AppIcons.policy,
'ep:coin': AppIcons.payments,
'ep:data-line': AppIcons.monitoring,
'ep:film': AppIcons.videoCameraFront,
'ep:bell': AppIcons.campaign,
'ep:video-camera': AppIcons.content,
'ant-design:dashboard-filled': AppIcons.dashboard
}
/** 兼容历史短名与旧 Iconify 名,统一返回 Material Symbols Rounded。 */
export function normalizeAppIcon(icon?: string): string {
if (!icon) return AppIcons.menu
if (icon.startsWith('material-symbols:') || icon.startsWith('svg-icon:')) return icon
return legacyIconAliases[icon] || AppIcons.menu
}
+3 -2
View File
@@ -2,8 +2,9 @@
import { h } from 'vue' import { h } from 'vue'
import type { VNode } from 'vue' import type { VNode } from 'vue'
import { Icon, IconTypes } from '@/components/Icon' import { AppIcon } from '@/components/AppIcon'
import type { IconTypes } from '@/components/Icon'
export const useIcon = (props: IconTypes): VNode => { export const useIcon = (props: IconTypes): VNode => {
return h(Icon, props) return h(AppIcon, props)
} }
+23 -3
View File
@@ -56,14 +56,20 @@ export default defineComponent({
) : undefined} ) : undefined}
<div class="h-full flex items-center"> <div class="h-full flex items-center">
{screenfull.value ? ( {screenfull.value ? (
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull> <Screenfull
class="<md:hidden custom-hover"
color="var(--top-header-text-color)"
></Screenfull>
) : undefined} ) : undefined}
{size.value ? ( {size.value ? (
<SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown> <SizeDropdown
class="<md:hidden custom-hover"
color="var(--top-header-text-color)"
></SizeDropdown>
) : undefined} ) : undefined}
{locale.value ? ( {locale.value ? (
<LocaleDropdown <LocaleDropdown
class="custom-hover" class="<sm:hidden custom-hover"
color="var(--top-header-text-color)" color="var(--top-header-text-color)"
></LocaleDropdown> ></LocaleDropdown>
) : undefined} ) : undefined}
@@ -79,6 +85,20 @@ export default defineComponent({
@prefix-cls: ~'@{namespace}-tool-header'; @prefix-cls: ~'@{namespace}-tool-header';
.@{prefix-cls} { .@{prefix-cls} {
min-width: 0;
gap: 8px;
transition: left var(--transition-time-02); transition: left var(--transition-time-02);
:deep(.el-dropdown),
:deep(.v-user-info) {
flex-shrink: 0;
}
}
@media (max-width: 768px) {
.@{prefix-cls} {
padding-right: 8px;
padding-left: 8px;
}
} }
</style> </style>
+169 -13
View File
@@ -52,6 +52,7 @@ export default {
lockScreen: 'Lock screen', lockScreen: 'Lock screen',
lock: 'Lock', lock: 'Lock',
lockPassword: 'Lock screen password', lockPassword: 'Lock screen password',
description: 'Enter your password to unlock this session.',
unlock: 'Click to unlock', unlock: 'Click to unlock',
backToLogin: 'Back to login', backToLogin: 'Back to login',
entrySystem: 'Entry the system', entrySystem: 'Entry the system',
@@ -86,6 +87,7 @@ export default {
cutMenu: 'Cut Menu', cutMenu: 'Cut Menu',
copy: 'Copy', copy: 'Copy',
clearAndReset: 'Clear cache and reset', clearAndReset: 'Clear cache and reset',
clearCacheMessage: 'Clear local cache and reload the admin console?',
copySuccess: 'Copy success', copySuccess: 'Copy success',
copyFailed: 'Copy failed', copyFailed: 'Copy failed',
footer: 'Footer', footer: 'Footer',
@@ -103,25 +105,16 @@ export default {
}, },
login: { login: {
welcome: 'Short-drama workspace', welcome: 'Short-drama workspace',
message: 'Manage dramas, episodes, publishing, and access.', consoleLabel: 'Content Operations',
hint: 'Sign in with an admin account', hint: 'Sign in with an admin account',
noAccount: 'No account yet?',
pointDrama: 'Dramas and episodes',
pointPublish: 'Publishing',
pointAccess: 'Access',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
register: 'Register',
checkPassword: 'Confirm password',
login: 'Sign in', login: 'Sign in',
otherLogin: 'Sign in with',
remember: 'Remember me', remember: 'Remember me',
hasUser: 'Existing account? Go to login',
forgetPassword: 'Forget password', forgetPassword: 'Forget password',
forgetPasswordHint: 'Please contact the administrator to reset your password',
usernamePlaceholder: 'Please input username', usernamePlaceholder: 'Please input username',
passwordPlaceholder: 'Please input password', passwordPlaceholder: 'Please input password'
code: 'Verification code',
codePlaceholder: 'Please input verification code'
}, },
router: { router: {
login: 'Login', login: 'Login',
@@ -170,7 +163,8 @@ export default {
treeTable: 'Tree table', treeTable: 'Tree table',
PicturePreview: 'Table Image Preview', PicturePreview: 'Table Image Preview',
department: 'Department management', department: 'Department management',
menuManagement: 'Menu management' menuManagement: 'Menu management',
transcodeSettings: 'Transcode settings'
}, },
permission: { permission: {
hasPermission: 'Please set the operation permission value' hasPermission: 'Please set the operation permission value'
@@ -534,5 +528,167 @@ export default {
inputPasswordDemo: { inputPasswordDemo: {
title: 'InputPassword', title: 'InputPassword',
inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus'
},
episodeTranscode: {
title: 'Transcode outputs',
description: 'Review the transcode task, renditions and frame previews for this episode.',
statusLabel: 'Status',
progress: 'Progress',
retryCount: 'Retries',
updatedAt: 'Last updated',
waitingProgress: 'Processing (waiting for progress report)',
reprocess: 'Transcode again',
reprocessing: 'Submitting',
triggerSuccess: 'Transcode task submitted',
loadFailed: 'Failed to load transcode outputs',
retryLoad: 'Reload',
triggerFailed: 'Failed to retry transcode',
noMedia: 'No uploaded media is bound to this episode yet.',
noRendition: 'No rendition outputs',
renditionTitle: 'Rendition outputs',
quality: 'Quality',
resolution: 'Resolution',
videoBitrate: 'Video bitrate',
audioBitrate: 'Audio bitrate',
codec: 'Codec',
container: 'Container',
fileSize: 'File size',
duration: 'Duration',
segments: 'Segments',
statusColumn: 'Status',
actions: 'Actions',
copyUrl: 'Copy playback URL',
openUrl: 'Open in new window',
play: 'Preview playback',
playerFailed: 'Playback failed, please check the rendition output',
playerUnsupported: 'This browser does not support HLS playback',
copySuccess: 'Playback URL copied',
copyFailed: 'Failed to copy playback URL',
openFailed: 'Unable to open playback URL',
frameTitle: 'Frame previews',
frameView: 'Frames',
spriteView: 'Sprites',
frameCount: '{total} frames',
frameTime: 'Timestamp',
previewFrame: 'Preview frame at {time}',
spriteSheet: 'Sprite {index}',
spriteSheetFrames: '{count} frames',
noFrames: 'No frame previews',
noSprites: 'No sprite sheets available',
zoomTitle: 'Frame preview',
status: {
queued: 'Queued',
running: 'Transcoding',
success: 'Success',
failed: 'Failed',
empty: 'No output',
noVideo: 'No video',
noMediaAsset: 'No media asset',
noTask: 'No transcode task',
noEpisode: 'No episode binding',
episodeNotFound: 'Episode not found',
invalid: 'Invalid output',
ready: 'Ready',
unknown: 'Unknown'
}
},
episodeBatch: {
title: 'Batch upload episodes',
entry: 'Batch upload',
selectFiles: 'Select videos',
startUpload: 'Start upload',
uploadingAction: 'Uploading',
pause: 'Pause',
resume: 'Resume',
retry: 'Retry',
retryAll: 'Retry all',
remove: 'Remove',
hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each. Files stay queued until you click Start upload.',
invalidType: 'Only MP4, MOV, MKV, WEBM are allowed',
oversize: 'File exceeds 500MB and was not queued',
overallProgress: 'Overall progress',
empty: 'No videos selected',
resultSummary: 'Upload finished: {done} succeeded, {failed} failed',
createEpisodes: 'Create episodes',
closeWhileUploading:
'Uploads are still in progress. Closing will stop dispatching new tasks. Continue?',
resumeTitle: 'Unfinished uploads found',
resumeConfirm:
'{count} unfinished upload(s) found. Resume? Re-select the same local files to match.',
resumeYes: 'Pick files to resume',
resumeNo: 'Discard',
resumeAbandoned: 'Unfinished uploads discarded',
resumeMatched: 'Matched {count} file(s). Click Start upload to continue.',
resumeUnmatched: '{count} session(s) could not match local files; pick again manually',
createTitle: 'Create episodes in batch',
createHint:
'Title uses the video file name; episode numbers are assigned automatically. Free vs paid follows the drama free-episode count; episodes beyond that are paid. You can edit them later in episode settings.',
colSource: 'Source file',
colNumber: 'Episode No.',
colTitle: 'Title',
colFree: 'Pricing',
labelFree: 'Free',
labelPaid: 'Paid',
colResult: 'Result',
missingVideo: 'Missing video URL',
createOk: 'Created',
createFailed: 'Create failed',
createAllSuccess: 'All episodes created',
createPartial: 'Some episodes failed; you can retry',
submitCreate: 'Create',
retryFailed: 'Retry failed',
status: {
waiting: 'Pending',
uploading: 'Uploading',
merging: 'Merging',
done: 'Done',
failed: 'Failed',
paused: 'Paused'
}
},
transcodeSettings: {
eyebrow: 'Content management',
title: 'Transcode settings',
description:
'Configure the quality ladder, HLS segment duration and thumbnail sprite options for new tasks.',
reload: 'Reload',
save: 'Save settings',
saved: 'Transcode settings saved',
saveFailed: 'Failed to save transcode settings',
loadFailed: 'Failed to load transcode settings',
noPermission: 'This account cannot manage transcode settings',
enabled: 'Enable transcode profile',
enabledDescription:
'Marks whether this transcode profile is enabled; the quality ladder is still validated when saved.',
segmentSeconds: 'HLS segment duration (seconds)',
ladderTitle: 'Quality ladder',
ladderDescription:
'Changes apply only to tasks queued afterwards; historical tasks are not reprocessed.',
quality: 'Quality key',
width: 'Width',
height: 'Height',
videoBitrateKbps: 'Video bitrate (kbps)',
audioBitrateKbps: 'Audio bitrate (kbps)',
rowEnabled: 'Enabled',
addRung: 'Add quality',
deleteRung: 'Delete',
emptyLadder: 'No quality rungs configured',
spriteTitle: 'Thumbnail sprite',
spriteDescription:
'Sample frames at a fixed interval, tile them into sprites and generate a WebVTT timeline.',
spriteEnabled: 'Enable thumbnail sprites',
intervalSeconds: 'Sampling interval (seconds)',
columns: 'Frames per row',
thumbWidth: 'Thumbnail width (px)',
validation: {
required: 'This field is required',
qualityUnique: 'Quality keys must be unique',
qualityTooLong: 'Quality key must not exceed 16 characters',
positiveEven: 'Enter an even number greater than 0',
positiveInteger: 'Enter an integer greater than 0',
range: 'Enter an integer between {min} and {max}',
atLeastOneEnabled: 'Enable at least one quality rung',
fixErrors: 'Fix the marked transcode fields before saving'
}
} }
} }
+163 -13
View File
@@ -52,6 +52,7 @@ export default {
lockScreen: '锁定屏幕', lockScreen: '锁定屏幕',
lock: '锁定', lock: '锁定',
lockPassword: '锁屏密码', lockPassword: '锁屏密码',
description: '输入密码解锁当前会话。',
unlock: '点击解锁', unlock: '点击解锁',
backToLogin: '返回登录', backToLogin: '返回登录',
entrySystem: '进入系统', entrySystem: '进入系统',
@@ -86,6 +87,7 @@ export default {
cutMenu: '切割菜单', cutMenu: '切割菜单',
copy: '拷贝', copy: '拷贝',
clearAndReset: '清除缓存并且重置', clearAndReset: '清除缓存并且重置',
clearCacheMessage: '确认清除本地缓存并重新加载后台吗?',
copySuccess: '拷贝成功', copySuccess: '拷贝成功',
copyFailed: '拷贝失败', copyFailed: '拷贝失败',
footer: '页脚', footer: '页脚',
@@ -103,25 +105,16 @@ export default {
}, },
login: { login: {
welcome: '短剧内容管理', welcome: '短剧内容管理',
message: '管理短剧、剧集、发布状态和后台权限。', consoleLabel: '内容运营后台',
hint: '使用管理员账号进入工作台', hint: '使用管理员账号进入工作台',
noAccount: '还没有账号?',
pointDrama: '短剧与剧集',
pointPublish: '发布状态',
pointAccess: '角色权限',
username: '用户名', username: '用户名',
password: '密码', password: '密码',
register: '注册',
checkPassword: '确认密码',
login: '登录', login: '登录',
otherLogin: '其它登录方式',
remember: '记住我', remember: '记住我',
hasUser: '已有账号?去登录',
forgetPassword: '忘记密码', forgetPassword: '忘记密码',
forgetPasswordHint: '请联系管理员重置密码',
usernamePlaceholder: '请输入用户名', usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码', passwordPlaceholder: '请输入密码'
code: '验证码',
codePlaceholder: '请输入验证码'
}, },
router: { router: {
login: '登录', login: '登录',
@@ -170,7 +163,8 @@ export default {
treeTable: '树形表格', treeTable: '树形表格',
PicturePreview: '表格图片预览', PicturePreview: '表格图片预览',
department: '部门管理', department: '部门管理',
menuManagement: '菜单管理' menuManagement: '菜单管理',
transcodeSettings: '转码设置'
}, },
permission: { permission: {
hasPermission: '请设置操作权限值' hasPermission: '请设置操作权限值'
@@ -528,5 +522,161 @@ export default {
inputPasswordDemo: { inputPasswordDemo: {
title: '密码输入框', title: '密码输入框',
inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装'
},
episodeTranscode: {
title: '转码产物',
description: '查看当前分集的转码任务、清晰度与帧图产物。',
statusLabel: '状态',
progress: '进度',
retryCount: '重试次数',
updatedAt: '最近更新',
waitingProgress: '处理中(等待进度上报)',
reprocess: '重新转码',
reprocessing: '正在触发',
triggerSuccess: '已提交转码任务',
loadFailed: '转码产物加载失败',
retryLoad: '重新加载',
triggerFailed: '重新转码失败',
noMedia: '当前分集尚未绑定已上传视频,暂无可查询的转码产物。',
noRendition: '暂无清晰度产物',
renditionTitle: '清晰度产物',
quality: '档位名称',
resolution: '分辨率',
videoBitrate: '视频码率',
audioBitrate: '音频码率',
codec: '编码',
container: '容器',
fileSize: '文件大小',
duration: '时长',
segments: '分片数',
statusColumn: '状态',
actions: '操作',
copyUrl: '复制播放地址',
openUrl: '新窗口打开',
play: '预览播放',
playerFailed: '播放失败,请检查产物是否完整',
playerUnsupported: '当前浏览器不支持 HLS 播放',
copySuccess: '播放地址已复制',
copyFailed: '复制播放地址失败',
openFailed: '无法打开播放地址',
frameTitle: '帧图预览',
frameView: '逐帧',
spriteView: '雪碧图',
frameCount: '共 {total} 帧',
frameTime: '时间点',
previewFrame: '预览 {time} 帧图',
spriteSheet: '雪碧图 {index}',
spriteSheetFrames: '{count} 帧',
noFrames: '暂无帧图产物',
noSprites: '暂无可展示的雪碧图',
zoomTitle: '帧图预览',
status: {
queued: '排队中',
running: '转码中',
success: '成功',
failed: '失败',
empty: '暂无产物',
noVideo: '无视频',
noMediaAsset: '未绑定媒资',
noTask: '无转码任务',
noEpisode: '未绑定分集',
episodeNotFound: '分集不存在',
invalid: '产物异常',
ready: '就绪',
unknown: '未知状态'
}
},
episodeBatch: {
title: '批量上传剧集',
entry: '批量上传',
selectFiles: '选择视频',
startUpload: '开始上传',
uploadingAction: '上传中',
pause: '暂停',
resume: '继续',
retry: '重试',
retryAll: '全部重试',
remove: '移除',
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;选完文件后点击「开始上传」才会真正上传',
invalidType: '仅支持 MP4、MOV、MKV、WEBM',
oversize: '文件超过 500MB,未入队',
overallProgress: '总进度',
empty: '尚未选择视频',
resultSummary: '上传结束:成功 {done} 个,失败 {failed} 个',
createEpisodes: '批量创建分集',
closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?',
resumeTitle: '发现未完成上传',
resumeConfirm: '发现 {count} 个未完成上传,是否续传?请重新选择相同本地文件以匹配。',
resumeYes: '选择文件续传',
resumeNo: '放弃',
resumeAbandoned: '已放弃未完成上传',
resumeMatched: '已匹配 {count} 个文件,请点击「开始上传」继续',
resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选',
createTitle: '批量创建分集',
createHint:
'标题取视频文件名,集号自动顺延分配;免费集数由短剧管理中的「免费集数」配置决定,超出部分为收费集;创建后可在分集编辑中修改',
colSource: '源文件',
colNumber: '集号',
colTitle: '标题',
colFree: '收费类型',
labelFree: '免费',
labelPaid: '收费',
colResult: '结果',
missingVideo: '缺少视频地址',
createOk: '已创建',
createFailed: '创建失败',
createAllSuccess: '全部分集已创建',
createPartial: '部分分集创建失败,可重试',
submitCreate: '创建',
retryFailed: '重试失败项',
status: {
waiting: '待上传',
uploading: '上传中',
merging: '合并中',
done: '已完成',
failed: '失败',
paused: '已暂停'
}
},
transcodeSettings: {
eyebrow: '内容管理',
title: '转码设置',
description: '配置之后新任务的清晰度阶梯、HLS 分片与缩略图雪碧图参数。',
reload: '重新加载',
save: '保存设置',
saved: '转码设置已保存',
saveFailed: '转码设置保存失败',
loadFailed: '转码设置加载失败',
noPermission: '当前账号没有转码设置权限',
enabled: '启用转码配置',
enabledDescription: '标记当前转码配置是否启用;保存时仍会校验下方档位。',
segmentSeconds: 'HLS 分片时长(秒)',
ladderTitle: '清晰度阶梯',
ladderDescription: '保存后只影响之后进入队列的新任务,不回跑历史任务。',
quality: '清晰度标识',
width: '宽',
height: '高',
videoBitrateKbps: '视频码率(kbps)',
audioBitrateKbps: '音频码率(kbps)',
rowEnabled: '启用',
addRung: '新增档位',
deleteRung: '删除',
emptyLadder: '尚未配置清晰度档位',
spriteTitle: '缩略图雪碧图',
spriteDescription: '按固定间隔抽帧并拼成雪碧图,同时生成 WebVTT 时间轴。',
spriteEnabled: '启用雪碧图',
intervalSeconds: '抽帧间隔(秒)',
columns: '每行帧数',
thumbWidth: '缩略图宽度(px)',
validation: {
required: '该项为必填项',
qualityUnique: '清晰度标识不能重复',
qualityTooLong: '清晰度标识不能超过 16 个字符',
positiveEven: '请输入大于 0 的偶数',
positiveInteger: '请输入大于 0 的整数',
range: '请输入 {min} 到 {max} 之间的整数',
atLeastOneEnabled: '至少需要启用一档清晰度',
fixErrors: '请修正标记的转码配置后再保存'
}
} }
} }
+5
View File
@@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user' import { useUserStoreWithOut } from '@/store/modules/user'
import { getAdminRoleApi, getProfileApi } from '@/api/login' import { getAdminRoleApi, getProfileApi } from '@/api/login'
import { landingPath } from '@/utils/landing' import { landingPath } from '@/utils/landing'
import { supplementLocalAdminRoutes } from '@/router/localRoutes'
const { start, done } = useNProgress() const { start, done } = useNProgress()
@@ -68,6 +69,10 @@ router.beforeEach(async (to, from, next) => {
} catch { } catch {
// 拉取失败时退回上次缓存的菜单,不阻塞进入 // 拉取失败时退回上次缓存的菜单,不阻塞进入
} }
const permissions = Array.isArray(userStore.getUserInfo?.permissions)
? userStore.getUserInfo.permissions
: []
routers = supplementLocalAdminRoutes(routers, permissions)
await permissionStore.generateRoutes('server', routers) await permissionStore.generateRoutes('server', routers)
} else { } else {
await permissionStore.generateRoutes('frontEnd', roleRouters as string[]) await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
+23 -5
View File
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import type { App } from 'vue' import type { App } from 'vue'
import { Layout } from '@/utils/routerHelper' import { Layout } from '@/utils/routerHelper'
import { AppIcons } from '@/constants/icons'
export const constantRouterMap: AppRouteRecordRaw[] = [ export const constantRouterMap: AppRouteRecordRaw[] = [
{ {
@@ -62,7 +63,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'Dashboard', name: 'Dashboard',
meta: { meta: {
title: '工作台', title: '工作台',
icon: 'ant-design:dashboard-filled' icon: AppIcons.dashboard
}, },
children: [ children: [
{ {
@@ -71,7 +72,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
component: () => import('@/views/common/DashboardView.vue'), component: () => import('@/views/common/DashboardView.vue'),
meta: { meta: {
title: '工作台', title: '工作台',
icon: 'ant-design:dashboard-filled', icon: AppIcons.dashboard,
affix: true affix: true
} }
} }
@@ -84,7 +85,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'Content', name: 'Content',
meta: { meta: {
title: '内容管理', title: '内容管理',
icon: 'ep:video-camera', icon: AppIcons.content,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
@@ -119,6 +120,15 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: { meta: {
title: '类型管理' title: '类型管理'
} }
},
{
path: 'media-transcode',
name: 'media-transcode',
component: () => import('@/views/systemManagement/TranscodeSettingsView.vue'),
meta: {
title: 'router.transcodeSettings',
permission: ['episode:upload']
}
} }
] ]
}, },
@@ -129,7 +139,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'UserCenter', name: 'UserCenter',
meta: { meta: {
title: '用户管理', title: '用户管理',
icon: 'ep:user', icon: AppIcons.group,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
@@ -150,7 +160,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'System', name: 'System',
meta: { meta: {
title: '后台管理', title: '后台管理',
icon: 'ep:setting', icon: AppIcons.settings,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
@@ -201,6 +211,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: { meta: {
title: '操作日志' title: '操作日志'
} }
},
{
path: 'legal-documents',
name: 'legal-documents',
component: () => import('@/views/systemManagement/LegalDocumentView.vue'),
meta: {
title: '协议设置'
}
} }
] ]
} }
+57
View File
@@ -0,0 +1,57 @@
// Author: fruit.com.cn
import { cloneDeep } from 'lodash-es'
const TRANSCODE_PERMISSION = 'episode:upload'
const CONTENT_ROUTE_PATH = '/content'
const CONTENT_ROUTE_NAME = 'Content'
const TRANSCODE_ROUTE_NAME = 'media-transcode'
const TRANSCODE_ROUTE_PATH = 'media-transcode'
const TRANSCODE_COMPONENT = 'views/systemManagement/TranscodeSettingsView'
const TRANSCODE_TITLE_KEY = 'router.transcodeSettings'
/** 服务端菜单可能用别的路由名指向同一个组件,这里按路由名或组件路径判定是否已存在 */
const hasTranscodeRoute = (routes: AppCustomRouteRecordRaw[]): boolean => {
for (const route of routes) {
if (route.name === TRANSCODE_ROUTE_NAME || route.component === TRANSCODE_COMPONENT) {
return true
}
if (hasTranscodeRoute(route.children || [])) return true
}
return false
}
/**
* 服务端动态菜单未落库时,给具备内容上传权限的账号补充受控的本地转码菜单。
*/
export const supplementLocalAdminRoutes = (
routes: AppCustomRouteRecordRaw[],
permissions: string[]
): AppCustomRouteRecordRaw[] => {
if (!permissions.includes(TRANSCODE_PERMISSION)) {
return routes
}
// 服务端菜单里已经有转码设置时不再补,否则侧边栏会出现两条
if (hasTranscodeRoute(routes)) {
return routes
}
const nextRoutes = cloneDeep(routes)
const contentRoute = nextRoutes.find(
(route) => route.path === CONTENT_ROUTE_PATH || route.name === CONTENT_ROUTE_NAME
)
if (!contentRoute) {
return routes
}
contentRoute.children = contentRoute.children || []
contentRoute.children.push({
path: TRANSCODE_ROUTE_PATH,
name: TRANSCODE_ROUTE_NAME,
component: TRANSCODE_COMPONENT,
redirect: '',
meta: {
title: TRANSCODE_TITLE_KEY,
permission: [TRANSCODE_PERMISSION]
}
})
return nextRoutes
}
+13 -8
View File
@@ -67,7 +67,7 @@ export const useAppStore = defineStore('app', {
currentSize: 'default', // 组件尺寸 currentSize: 'default', // 组件尺寸
theme: { theme: {
// 主题色 // 主题色
elColorPrimary: '#409eff', elColorPrimary: '#c4322a',
// 左侧菜单边框颜色 // 左侧菜单边框颜色
leftMenuBorderColor: 'inherit', leftMenuBorderColor: 'inherit',
// 左侧菜单背景颜色 // 左侧菜单背景颜色
@@ -75,9 +75,9 @@ export const useAppStore = defineStore('app', {
// 左侧菜单浅色背景颜色 // 左侧菜单浅色背景颜色
leftMenuBgLightColor: '#0f2438', leftMenuBgLightColor: '#0f2438',
// 左侧菜单选中背景颜色 // 左侧菜单选中背景颜色
leftMenuBgActiveColor: 'var(--el-color-primary)', leftMenuBgActiveColor: 'var(--app-primary-strong)',
// 左侧菜单收起选中背景颜色 // 左侧菜单收起选中背景颜色
leftMenuCollapseBgActiveColor: 'var(--el-color-primary)', leftMenuCollapseBgActiveColor: 'var(--app-primary-strong)',
// 左侧菜单字体颜色 // 左侧菜单字体颜色
leftMenuTextColor: '#bfcbd9', leftMenuTextColor: '#bfcbd9',
// 左侧菜单选中字体颜色 // 左侧菜单选中字体颜色
@@ -89,7 +89,7 @@ export const useAppStore = defineStore('app', {
// 头部背景颜色 // 头部背景颜色
topHeaderBgColor: '#fff', topHeaderBgColor: '#fff',
// 头部字体颜色 // 头部字体颜色
topHeaderTextColor: 'inherit', topHeaderTextColor: '#303133',
// 头部悬停颜色 // 头部悬停颜色
topHeaderHoverColor: '#f6f6f6', topHeaderHoverColor: '#f6f6f6',
// 头部边框颜色 // 头部边框颜色
@@ -288,18 +288,18 @@ export const useAppStore = defineStore('app', {
leftMenuBgLightColor: isDarkColor ? lighten(color!, 6) : color, leftMenuBgLightColor: isDarkColor ? lighten(color!, 6) : color,
// 左侧菜单选中背景颜色 // 左侧菜单选中背景颜色
leftMenuBgActiveColor: isDarkColor leftMenuBgActiveColor: isDarkColor
? 'var(--el-color-primary)' ? 'var(--app-primary-strong)'
: hexToRGB(unref(primaryColor), 0.1), : hexToRGB(unref(primaryColor), 0.1),
// 左侧菜单收起选中背景颜色 // 左侧菜单收起选中背景颜色
leftMenuCollapseBgActiveColor: isDarkColor leftMenuCollapseBgActiveColor: isDarkColor
? 'var(--el-color-primary)' ? 'var(--app-primary-strong)'
: hexToRGB(unref(primaryColor), 0.1), : hexToRGB(unref(primaryColor), 0.1),
// 左侧菜单字体颜色 // 左侧菜单字体颜色
leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333', leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333',
// 左侧菜单选中字体颜色 // 左侧菜单选中字体颜色
leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)', leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)',
// logo字体颜色 // logo字体颜色
logoTitleTextColor: isDarkColor ? '#fff' : 'inherit', logoTitleTextColor: isDarkColor ? '#fff' : '#303133',
// logo边框颜色 // logo边框颜色
logoBorderColor: isDarkColor ? color : '#eee' logoBorderColor: isDarkColor ? color : '#eee'
} }
@@ -308,7 +308,7 @@ export const useAppStore = defineStore('app', {
}, },
setHeaderTheme(color: string) { setHeaderTheme(color: string) {
const isDarkColor = colorIsDark(color) const isDarkColor = colorIsDark(color)
const textColor = isDarkColor ? '#fff' : 'inherit' const textColor = isDarkColor ? '#fff' : '#303133'
const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6' const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6'
const topToolBorderColor = isDarkColor ? color : '#eee' const topToolBorderColor = isDarkColor ? color : '#eee'
setCssVar('--top-header-bg-color', color) setCssVar('--top-header-bg-color', color)
@@ -343,6 +343,11 @@ export const useAppStore = defineStore('app', {
persist: { persist: {
afterRestore: (context) => { afterRestore: (context) => {
context.store.title = import.meta.env.VITE_APP_TITLE context.store.title = import.meta.env.VITE_APP_TITLE
if (context.store.theme?.elColorPrimary !== '#c4322a') {
context.store.setTheme({ elColorPrimary: '#c4322a' })
}
context.store.setMenuTheme(context.store.theme.leftMenuBgColor)
context.store.setHeaderTheme(context.store.theme.topHeaderBgColor)
} }
} }
}) })
+14 -2
View File
@@ -27,7 +27,7 @@ export const useUserStore = defineStore('user', {
token: '', token: '',
roleRouters: undefined, roleRouters: undefined,
// 记住我 // 记住我
rememberMe: true, rememberMe: false,
loginInfo: undefined loginInfo: undefined
} }
}, },
@@ -87,6 +87,8 @@ export const useUserStore = defineStore('user', {
this.setToken('') this.setToken('')
this.setUserInfo(undefined) this.setUserInfo(undefined)
this.setRoleRouters([]) this.setRoleRouters([])
this.setLoginInfo(undefined)
this.setRememberMe(false)
tagsViewStore.delAllViews() tagsViewStore.delAllViews()
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表 // 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
permissionStore.clearRoutes() permissionStore.clearRoutes()
@@ -102,7 +104,17 @@ export const useUserStore = defineStore('user', {
this.loginInfo = loginInfo this.loginInfo = loginInfo
} }
}, },
persist: true persist: {
afterRestore: (context) => {
const loginInfo = context.store.loginInfo as
(UserLoginType & { password?: string }) | undefined
if (loginInfo?.password !== undefined) {
context.store.setLoginInfo(
loginInfo.username ? { username: loginInfo.username } : undefined
)
}
}
}
}) })
export const useUserStoreWithOut = () => { export const useUserStoreWithOut = () => {
+2 -1
View File
@@ -53,7 +53,8 @@
line-height: 1; line-height: 1;
} }
.app-user-avatar img { .app-user-avatar img,
.app-user-avatar .app-user-avatar-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
+193 -18
View File
@@ -4,7 +4,7 @@
height: 90px; height: 90px;
object-fit: cover; object-fit: cover;
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #f0f2f4 !important; background: #f0f2f4 !important;
} }
@@ -13,37 +13,212 @@
place-content: center; place-content: center;
width: 160px; width: 160px;
height: 90px; height: 90px;
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #f0f2f4 !important; background: #f0f2f4 !important;
color: #7e8791; color: #7e8791;
font-size: 12px; font-size: 12px;
} }
.banner-field { .banner-editor-dialog {
display: flex; display: flex;
gap: 12px; max-height: calc(100vh - 40px);
align-items: flex-start; overflow: hidden;
width: 100%; flex-direction: column;
border-radius: var(--app-radius-lg) !important;
} }
.banner-field-actions {
.banner-editor-dialog .el-dialog__body {
flex: 1 1 auto;
padding: 8px 24px 0;
overflow-y: auto;
}
.banner-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.banner-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.banner-editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.banner-editor-section + .banner-editor-section {
padding-top: 18px;
}
.banner-editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.banner-editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.banner-editor-grid {
display: grid;
gap: 0 16px;
}
.banner-editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.banner-editor-number {
width: 180px !important;
}
.banner-image-editor {
display: flex;
gap: 18px;
width: 100%;
align-items: flex-start;
}
.banner-image-preview-card {
position: relative;
flex: 0 0 240px;
width: 240px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.banner-image-preview-card > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-image-empty {
display: flex;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
}
.banner-image-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.banner-image-upload-mask b {
font-size: 16px;
}
.banner-image-meta {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
flex-direction: column;
gap: 10px;
} }
/* 编辑弹窗里的轮播图预览:和上传控件并排,控制弹窗高度 */
.form-banner-preview { .banner-image-status {
display: block; display: flex;
width: 200px; align-items: center;
height: 112px; gap: 10px;
flex: 0 0 200px; min-width: 0;
object-fit: contain; }
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px; .banner-image-filename {
background: #f0f2f4 !important; overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.banner-image-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.banner-image-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.banner-image-actions .el-button {
height: 36px;
}
.banner-original-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.banner-original-link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.banner-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.banner-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.banner-editor-grid--two {
grid-template-columns: 1fr;
}
.banner-image-editor {
flex-direction: column;
align-items: center;
}
.banner-image-preview-card {
flex-basis: 135px;
}
.banner-image-meta {
width: 100%;
}
} }
.dark .banner-thumb, .dark .banner-thumb,
.dark .banner-thumb-empty, .dark .banner-thumb-empty,
.dark .form-banner-preview { .dark .banner-image-preview-card,
.dark .banner-image-empty {
background: #353b45 !important; background: #353b45 !important;
color: #b9c1cb; color: #b9c1cb;
} }
+1 -1
View File
@@ -116,7 +116,7 @@ section .console-hint.el-alert {
min-height: 74px; min-height: 74px;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid #e6e8eb; border: 1px solid #e6e8eb;
border-radius: 6px; border-radius: var(--app-radius-md);
background: #fff; background: #fff;
} }
+7 -5
View File
@@ -51,7 +51,7 @@
width: 34px; width: 34px;
height: 46px; height: 46px;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #eef0f5; background: #eef0f5;
color: #9a97a8; color: #9a97a8;
font-size: 14px; font-size: 14px;
@@ -59,8 +59,10 @@
} }
.creator-drama-cover img, .creator-drama-cover img,
.creator-drama-cover .creator-drama-image,
.creator-cover-preview img, .creator-cover-preview img,
.creator-episode-cover img { .creator-episode-cover img,
.creator-episode-cover .creator-episode-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@@ -193,7 +195,7 @@
height: 76px; height: 76px;
overflow: hidden; overflow: hidden;
border: 1px solid #e6e8eb; border: 1px solid #e6e8eb;
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #fafbfc; background: #fafbfc;
color: #9a97a8; color: #9a97a8;
font-size: 12px; font-size: 12px;
@@ -205,7 +207,7 @@
width: 48px; width: 48px;
height: 30px; height: 30px;
overflow: hidden; overflow: hidden;
border-radius: 3px; border-radius: var(--app-radius-sm);
background: #eef0f5; background: #eef0f5;
color: #9a97a8; color: #9a97a8;
font-size: 12px; font-size: 12px;
@@ -226,7 +228,7 @@
padding: 10px 14px; padding: 10px 14px;
margin-bottom: 14px; margin-bottom: 14px;
border: 1px solid #f0e6cf; border: 1px solid #f0e6cf;
border-radius: 6px; border-radius: var(--app-radius-md);
background: #fdf9ef; background: #fdf9ef;
} }
+40 -17
View File
@@ -3,7 +3,6 @@ html.dark .panel-heading h2,
html.dark .episode-toolbar h2, html.dark .episode-toolbar h2,
html.dark .stat-value, html.dark .stat-value,
html.dark .drama-cell b, html.dark .drama-cell b,
html.dark .brand b,
html.dark .role-card, html.dark .role-card,
html.dark .cover-card-info b { html.dark .cover-card-info b {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
@@ -22,25 +21,19 @@ html.dark .table-result-count {
html.dark .stat-card, html.dark .stat-card,
html.dark .panel, html.dark .panel,
html.dark .role-card, html.dark .role-card {
html.dark .topbar,
html.dark .sidebar {
background: var(--el-bg-color-overlay); background: var(--el-bg-color-overlay);
border-color: var(--el-border-color-light); border-color: var(--el-border-color-light);
} }
html.dark .stat-icon, html.dark .stat-icon,
html.dark .role-icon, html.dark .role-icon {
html.dark .help-icon, background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
html.dark .workspace-avatar,
html.dark .profile-avatar,
html.dark .top-avatar {
background: rgb(64 158 255 / 16%) !important;
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
html.dark .role-active { html.dark .role-active {
background: rgb(64 158 255 / 12%); background: color-mix(in srgb, var(--el-color-primary) 14%, transparent);
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
} }
@@ -113,12 +106,12 @@ html.dark .el-tag.status-badge.is-draft {
color: #c0c4cc !important; color: #c0c4cc !important;
} }
html.dark .el-tag.status-badge.is-pending { html.dark .el-tag.status-badge.is-pending {
--el-tag-bg-color: rgba(64, 158, 255, 0.18); --el-tag-bg-color: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
--el-tag-border-color: rgba(121, 187, 255, 0.45); --el-tag-border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent);
--el-tag-text-color: #8cc5ff; --el-tag-text-color: var(--el-color-primary-light-5);
background: rgba(64, 158, 255, 0.18) !important; background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
border-color: rgba(121, 187, 255, 0.45) !important; border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent) !important;
color: #8cc5ff !important; color: var(--el-color-primary-light-5) !important;
} }
html.dark .el-tag.status-badge.is-offline { html.dark .el-tag.status-badge.is-offline {
--el-tag-bg-color: rgba(245, 108, 108, 0.18); --el-tag-bg-color: rgba(245, 108, 108, 0.18);
@@ -128,3 +121,33 @@ html.dark .el-tag.status-badge.is-offline {
border-color: rgba(245, 108, 108, 0.45) !important; border-color: rgba(245, 108, 108, 0.45) !important;
color: #f3a6a6 !important; color: #f3a6a6 !important;
} }
.v-tool-header {
color: var(--top-header-text-color);
}
.v-tags-view {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.v-tags-view__item--close {
color: var(--el-text-color-secondary) !important;
}
html.dark .v-tool-header,
html.dark .v-tags-view {
background: var(--el-bg-color-overlay);
border-color: var(--el-border-color);
}
html.dark .v-tags-view__item {
color: var(--el-text-color-regular);
}
html.dark #v-menu .el-menu-item.is-active,
html.dark #v-menu .el-sub-menu.is-active > .el-sub-menu__title,
html.dark .v-tags-view__item.is-active {
background-color: var(--app-primary-strong) !important;
border-color: var(--app-primary-strong) !important;
}
+461 -57
View File
@@ -37,7 +37,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
white-space: nowrap; white-space: nowrap;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.table-actions .el-button { .table-actions .el-button {
/* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */ /* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */
@@ -46,25 +46,19 @@
padding: 0 10px !important; padding: 0 10px !important;
font-size: 13px !important; font-size: 13px !important;
font-weight: 500 !important; font-weight: 500 !important;
border-radius: 4px; border-radius: var(--app-radius-sm);
} }
/* Element Plus 默认给相邻按钮加 12px 外边距,会和这里的 gap 叠加导致换行 */ /* Element Plus 默认给相邻按钮加 12px 外边距,会和这里的 gap 叠加导致换行 */
.table-actions .el-button + .el-button { .table-actions .el-button + .el-button {
margin-left: 0 !important; margin-left: 0 !important;
} }
.table-actions .danger-action {
color: #a66a6a !important;
}
.table-actions .danger-action:hover {
color: #b54848 !important;
}
.drama-cell .drama-cover, .drama-cell .drama-cover,
.drama-cell .drama-cover-image { .drama-cell .drama-cover-image {
width: 44px; width: 44px;
height: 56px; height: 56px;
flex: 0 0 44px; flex: 0 0 44px;
border-radius: 4px; border-radius: var(--app-radius-sm);
object-fit: cover; object-fit: cover;
} }
.drama-cell .drama-cover { .drama-cell .drama-cover {
@@ -91,51 +85,461 @@
font-size: 13px; font-size: 13px;
} }
/* 编辑弹窗里的封面预览:固定竖版尺寸,和上传控件并排,避免弹窗过高 */ /* 短剧编辑弹窗:固定宽度、内容区滚动、底部操作固定。 */
.cover-field { .drama-editor-dialog {
display: flex; display: flex;
gap: 12px; max-height: calc(100vh - 40px);
align-items: flex-start; overflow: hidden;
width: 100%; flex-direction: column;
} border-radius: var(--app-radius-lg) !important;
.cover-field-actions {
flex: 1 1 auto;
min-width: 0;
}
.form-cover-preview {
display: block;
width: 84px;
height: 112px;
flex: 0 0 84px;
object-fit: cover;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: #f0f2f4 !important;
}
.dark .form-cover-preview {
background: #353b45 !important;
} }
/* 编辑弹窗里的视频预览 */ .drama-editor-dialog .el-dialog__body {
.video-field { flex: 1 1 auto;
display: flex; padding: 8px 24px 0;
gap: 12px; overflow-y: auto;
align-items: flex-start;
width: 100%;
} }
.video-field-actions {
.drama-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.drama-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.editor-section + .editor-section {
padding-top: 18px;
}
.editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.editor-grid {
display: grid;
gap: 0 16px;
}
.editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-number {
width: 180px !important;
}
.readonly-stat {
min-width: 180px;
height: 32px;
padding: 0 12px;
color: var(--el-text-color-secondary);
line-height: 32px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
}
.readonly-stat-hint {
margin: 4px 0 0;
color: var(--el-text-color-placeholder);
font-size: 12px;
line-height: 1.4;
}
.cover-editor {
display: flex;
gap: 18px;
align-items: flex-start;
}
.cover-preview-card {
position: relative;
display: grid;
flex: 0 0 96px;
width: 96px;
height: 128px;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.cover-preview-card img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: cover;
}
.cover-preview-empty {
display: flex;
height: 100%;
color: var(--el-text-color-placeholder);
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
}
.cover-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.cover-upload-mask b {
font-size: 16px;
}
.cover-editor-meta {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
flex-direction: column;
gap: 10px;
} }
.form-video-preview {
.cover-status-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cover-filename {
overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.cover-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.cover-actions .el-button {
height: 36px;
}
.cover-original-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.cover-original-link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.drama-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.drama-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.editor-grid--two {
grid-template-columns: 1fr;
}
.cover-editor {
flex-direction: column;
align-items: center;
}
.cover-preview-card {
flex-basis: 128px;
}
.cover-editor-meta {
width: 100%;
}
}
html.dark .cover-preview-card,
html.dark .cover-preview-empty {
background: var(--el-fill-color-light);
}
/* 剧集编辑弹窗 */
.episode-editor-dialog {
display: flex;
max-height: calc(100vh - 40px);
overflow: hidden;
flex-direction: column;
border-radius: var(--app-radius-lg) !important;
}
.episode-editor-dialog .el-dialog__body {
flex: 1 1 auto;
padding: 8px 24px 0;
overflow-y: auto;
}
.episode-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.episode-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.episode-editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.episode-editor-section + .episode-editor-section {
padding-top: 18px;
}
.episode-editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.episode-editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.episode-editor-grid {
display: grid;
gap: 0 16px;
}
.episode-editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.episode-editor-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.episode-editor-number {
width: 180px !important;
}
.episode-video-editor {
display: flex;
gap: 18px;
width: 100%;
align-items: flex-start;
}
.episode-video-preview-card {
position: relative;
flex: 0 0 240px;
width: 240px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: #000;
}
.episode-editor-video {
display: block; display: block;
width: 200px; width: 100%;
height: 112px; height: 100%;
flex: 0 0 200px;
object-fit: contain; object-fit: contain;
border: 1px solid var(--el-border-color-lighter); background: #000;
border-radius: 6px; }
background: #000 !important;
.episode-video-empty-upload,
.episode-video-empty-upload .el-upload {
width: 100%;
height: 100%;
}
.episode-video-empty-upload .el-upload-dragger {
display: flex;
width: 100%;
height: 100%;
padding: 0;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 9px;
align-items: center;
justify-content: center;
}
.episode-video-empty-copy {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--el-text-color-secondary);
}
.episode-video-empty-copy b {
color: var(--el-text-color-regular);
font-size: 13px;
}
.episode-video-empty-copy span {
font-size: 11px;
}
.episode-video-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.episode-video-upload-mask b {
font-size: 16px;
}
.episode-video-meta {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 10px;
}
.episode-video-status {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.episode-video-filename {
overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-video-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.episode-video-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.episode-video-actions .el-button {
height: 36px;
}
.episode-video-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.episode-video-link:hover {
text-decoration: underline;
}
.episode-video-empty-upload {
cursor: pointer;
}
@media (max-width: 600px) {
.episode-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.episode-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.episode-editor-grid--two,
.episode-editor-grid--three {
grid-template-columns: 1fr;
}
.episode-video-editor {
flex-direction: column;
align-items: center;
}
.episode-video-preview-card {
flex-basis: 135px;
}
.episode-video-meta {
width: 100%;
}
}
html.dark .episode-video-preview-card,
html.dark .episode-video-empty-upload .el-upload-dragger {
background: var(--el-fill-color-light);
} }
/* 剧集预览弹窗里的播放器 */ /* 剧集预览弹窗里的播放器 */
@@ -144,7 +548,7 @@
width: 100%; width: 100%;
max-height: 60vh; max-height: 60vh;
object-fit: contain; object-fit: contain;
border-radius: 8px; border-radius: var(--app-radius-md);
background: #000 !important; background: #000 !important;
} }
@@ -156,14 +560,14 @@
object-fit: contain; object-fit: contain;
cursor: pointer; cursor: pointer;
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #000 !important; background: #000 !important;
} }
/* 列表里点视频即放大播放,给个悬停反馈 */ /* 列表里点视频即放大播放,给个悬停反馈 */
.episode-video-thumb:hover { .episode-video-thumb:hover {
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25); box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 25%, transparent);
} }
.dark .table-result-count { .dark .table-result-count {
color: #a6aeb9; color: #a6aeb9;
@@ -231,10 +635,10 @@
color: #c0c6cf; color: #c0c6cf;
} }
.table-actions .el-button--primary { .table-actions .el-button--primary {
--el-button-bg-color: #409eff; --el-button-bg-color: var(--el-color-primary);
--el-button-border-color: #409eff; --el-button-border-color: var(--el-color-primary);
--el-button-hover-bg-color: #79bbff; --el-button-hover-bg-color: var(--el-color-primary-dark-2);
--el-button-hover-border-color: #79bbff; --el-button-hover-border-color: var(--el-color-primary-dark-2);
} }
.table-actions .el-button--success { .table-actions .el-button--success {
--el-button-bg-color: #4d866a; --el-button-bg-color: #4d866a;
@@ -243,10 +647,10 @@
--el-button-hover-border-color: #3c7358; --el-button-hover-border-color: #3c7358;
} }
.table-actions .el-button--danger { .table-actions .el-button--danger {
--el-button-bg-color: #bd5b5b; --el-button-bg-color: var(--el-color-danger);
--el-button-border-color: #bd5b5b; --el-button-border-color: var(--el-color-danger);
--el-button-hover-bg-color: #a94b4b; --el-button-hover-bg-color: var(--el-color-danger-dark-2);
--el-button-hover-border-color: #a94b4b; --el-button-hover-border-color: var(--el-color-danger-dark-2);
} }
.pagination { .pagination {
display: flex; display: flex;
@@ -259,7 +663,7 @@
font-size: 13px !important; font-size: 13px !important;
--el-pagination-font-size: 13px; --el-pagination-font-size: 13px;
--el-pagination-button-bg-color: #f1f3f6; --el-pagination-button-bg-color: #f1f3f6;
--el-pagination-hover-color: #409eff; --el-pagination-hover-color: var(--el-color-primary);
--el-pagination-button-color: #4c5561; --el-pagination-button-color: #4c5561;
gap: 5px; gap: 5px;
} }
+1
View File
@@ -8,4 +8,5 @@
@import './console.css'; @import './console.css';
@import './creator-center.css'; @import './creator-center.css';
@import './layout.css'; @import './layout.css';
@import './menu.css';
@import './dark.css'; @import './dark.css';
+144
View File
@@ -0,0 +1,144 @@
/* 左侧菜单:统一行高、缩进、图标和选中态。 */
#v-menu .el-menu {
padding: 4px 0;
background: transparent;
}
#v-menu .el-menu-item,
#v-menu .el-sub-menu__title {
/* 42px = 16px padding + 18px icon + 8px gap,父子菜单文字共用该基线。 */
height: 44px;
padding-left: 16px !important;
margin: 4px 8px 4px 0;
overflow: hidden;
color: var(--left-menu-text-color);
font-size: 14px;
line-height: 44px;
border-radius: 8px;
transition:
color var(--transition-time-02),
background-color var(--transition-time-02);
}
#v-menu .el-menu--inline {
padding-left: 0 !important;
}
#v-menu .el-menu--inline .el-menu-item,
#v-menu .el-menu--inline .el-sub-menu__title {
height: 40px;
padding-left: 0 !important;
margin: 3px 8px 3px 42px;
line-height: 40px;
}
#v-menu .el-menu--inline .app-icon {
display: none;
}
#v-menu .el-menu-item .app-icon,
#v-menu .el-sub-menu__title .app-icon {
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 8px;
}
#v-menu .el-menu-item:hover,
#v-menu .el-sub-menu__title:hover,
#v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
#v-menu .el-sub-menu.is-active > .el-sub-menu__title,
#v-menu .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
}
#v-menu .el-menu--inline .el-menu-item:hover,
#v-menu .el-menu--inline .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: transparent !important;
}
#v-menu .el-menu .el-menu {
background: transparent;
}
#v-menu .el-menu--collapse .el-menu-item,
#v-menu .el-menu--collapse .el-sub-menu__title {
justify-content: center;
padding: 0 !important;
margin: 4px 6px;
}
#v-menu .el-menu--collapse .app-icon {
margin-right: 0;
}
html.dark #v-menu .el-menu-item:hover,
html.dark #v-menu .el-sub-menu__title:hover {
color: #fff !important;
background-color: color-mix(in srgb, var(--el-color-primary) 72%, transparent) !important;
}
html.dark #v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
html.dark #v-menu .el-sub-menu.is-active > .el-sub-menu__title,
html.dark #v-menu .el-menu-item.is-active {
color: #fff !important;
background-color: var(--app-primary-strong) !important;
}
html.dark #v-menu .el-menu--inline .el-menu-item:hover,
html.dark #v-menu .el-menu--inline .el-menu-item.is-active {
color: #fff !important;
background-color: transparent !important;
}
.v-menu-popper--vertical,
.v-menu-popper--horizontal {
padding: 4px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color-overlay);
}
.v-menu-popper--vertical .el-menu-item,
.v-menu-popper--vertical .el-sub-menu__title,
.v-menu-popper--horizontal .el-menu-item,
.v-menu-popper--horizontal .el-sub-menu__title {
height: 40px;
padding-left: 16px !important;
margin: 2px 4px;
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 40px;
border-radius: 8px;
}
.v-menu-popper--vertical .app-icon,
.v-menu-popper--horizontal .app-icon {
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 8px;
}
html .v-menu-popper--vertical .el-menu-item:hover,
html .v-menu-popper--horizontal .el-menu-item:hover,
html .v-menu-popper--vertical .el-menu-item.is-active,
html .v-menu-popper--horizontal .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: transparent !important;
}
html.dark .v-menu-popper--vertical,
html.dark .v-menu-popper--horizontal {
border-color: var(--el-border-color);
}
html.dark .v-menu-popper--vertical .el-menu-item:hover,
html.dark .v-menu-popper--horizontal .el-menu-item:hover,
html.dark .v-menu-popper--vertical .el-menu-item.is-active,
html.dark .v-menu-popper--horizontal .el-menu-item.is-active {
color: #fff !important;
background-color: transparent !important;
}
+1 -1
View File
@@ -24,7 +24,7 @@
word-break: break-all; word-break: break-all;
background: #f6f7f9 !important; background: #f6f7f9 !important;
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 6px; border-radius: var(--app-radius-md);
} }
.dark .log-json { .dark .log-json {
+146 -392
View File
@@ -1,315 +1,11 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
:root {
font-family: 'DM Sans', 'Noto Sans SC', sans-serif;
color: var(--el-text-color-primary);
background: var(--el-bg-color-page);
font-synthesis: none;
text-rendering: optimizeLegibility;
font-optical-sizing: auto;
font-weight: 400;
--purple: #409eff;
--muted: #9695a8;
--line: #eeedf3;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
min-width: 980px; min-width: 0;
min-height: 100vh; min-height: 100vh;
} }
.app-shell {
min-height: 100vh;
}
.sidebar {
width: 250px;
position: fixed;
inset: 0 auto 0 0;
background: #fff;
border-right: 1px solid #eeedf4;
padding: 27px 17px 16px;
display: flex;
flex-direction: column;
z-index: 4;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 0 9px 28px;
}
.brand-mark {
width: 32px;
height: 32px;
border-radius: 10px;
background: #409eff;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
transform: rotate(-8deg);
}
.brand-mark span {
width: 4px;
background: white;
border-radius: 4px;
transform: skew(-15deg);
}
.brand-mark span:nth-child(1) {
height: 11px;
opacity: 0.72;
}
.brand-mark span:nth-child(2) {
height: 18px;
}
.brand-mark span:nth-child(3) {
height: 13px;
opacity: 0.85;
}
.brand b {
display: block;
font:
800 18px Manrope,
sans-serif;
letter-spacing: -0.8px;
color: #242139;
}
.brand small {
font-size: 8px;
letter-spacing: 1.5px;
color: #a3a1b1;
display: block;
margin-top: 1px;
}
.workspace {
height: 58px;
border: 1px solid #efedf4;
border-radius: 10px;
display: flex;
align-items: center;
padding: 0 10px;
margin-bottom: 27px;
gap: 10px;
}
.workspace-avatar,
.profile-avatar,
.top-avatar {
display: grid;
place-items: center;
background: #ecf5ff;
color: #409eff;
font-weight: 700;
}
.workspace-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
}
.workspace-copy {
flex: 1;
}
.workspace-copy b,
.workspace-copy small {
display: block;
}
.workspace-copy b {
font-size: 11px;
}
.workspace-copy small {
color: #a09eae;
font-size: 10px;
margin-top: 3px;
}
.workspace > .el-icon {
color: #aaa8b7;
font-size: 13px;
}
.nav-label {
padding: 0 11px 10px;
color: #aaa8b7;
font-size: 10px;
letter-spacing: 0.5px;
}
.side-menu {
border: 0 !important;
}
.side-menu .el-menu-item {
height: 43px;
line-height: 43px;
border-radius: 8px;
margin: 2px 0;
color: #777589;
font-size: 12px;
padding: 0 11px !important;
}
.side-menu .el-menu-item .el-icon {
font-size: 16px;
margin-right: 12px;
}
.side-menu .el-menu-item:hover {
background: #ecf5ff;
color: #409eff;
}
.side-menu .el-menu-item.is-active {
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
.menu-count {
margin-left: auto;
border-radius: 10px;
background: #ecf5ff;
color: #79bbff;
padding: 2px 7px;
font-size: 10px;
}
.access-label {
margin-top: 27px;
}
.sidebar-bottom {
margin-top: auto;
}
.help-card {
background: #ecf5ff;
border: 1px solid #ecf5ff;
border-radius: 10px;
padding: 13px 13px 14px;
margin: 0 2px 17px;
}
.help-icon {
width: 23px;
height: 23px;
display: grid;
place-items: center;
background: #d9ecff;
border-radius: 7px;
color: #409eff;
margin-bottom: 9px;
font-size: 13px;
}
.help-card b {
font-size: 11px;
}
.help-card p {
font-size: 10px;
line-height: 1.6;
color: #9290a2;
margin: 5px 0 9px;
}
.help-card a {
font-size: 10px;
color: #409eff;
font-weight: 600;
}
.help-card a span {
margin-left: 4px;
}
.user-profile {
border-top: 1px solid #f0eff4;
padding: 15px 4px 0;
display: flex;
align-items: center;
gap: 9px;
}
.profile-avatar {
width: 33px;
height: 33px;
border-radius: 50%;
font-size: 12px;
}
.profile-info {
flex: 1;
}
.profile-info b,
.profile-info small {
display: block;
}
.profile-info b {
font-size: 11px;
}
.profile-info small {
font-size: 10px;
color: #a09eae;
margin-top: 3px;
}
.user-profile > .el-icon {
color: #aaa8b7;
transform: rotate(90deg);
}
.main-area {
margin-left: 250px;
min-height: 100vh;
}
.topbar {
height: 64px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid #eeedf4;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 39px;
position: sticky;
top: 0;
z-index: 3;
}
.breadcrumb {
font-size: 11px;
color: #aaa8b6;
}
.breadcrumb .slash {
padding: 0 10px;
color: #d4d2dd;
}
.breadcrumb b {
font-weight: 600;
color: #555367;
}
.top-actions {
display: flex;
align-items: center;
gap: 18px;
}
.environment {
border: 1px solid #eeedf2;
border-radius: 15px;
padding: 6px 10px;
font-size: 10px;
color: #777589;
}
.environment i {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #62c491;
margin-right: 6px;
}
.icon-button {
position: relative;
color: #777589;
}
.notification-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #ef7d85;
position: absolute;
right: 6px;
top: 5px;
border: 1px solid white;
}
.top-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 11px;
}
.page-content {
max-width: 1440px;
margin: 0 auto;
padding: 35px 40px 46px;
}
.page-heading { .page-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -323,7 +19,7 @@ body {
font-weight: 600; font-weight: 600;
} }
.eyebrow span { .eyebrow span {
color: #79bbff; color: var(--el-color-primary-light-3);
margin-left: 5px; margin-left: 5px;
} }
.page-heading h1 { .page-heading h1 {
@@ -344,17 +40,17 @@ body {
font-size: 20px; font-size: 20px;
} }
.primary-button { .primary-button {
background: #409eff !important; background: var(--el-color-primary) !important;
border-color: #409eff !important; border-color: var(--el-color-primary) !important;
border-radius: 7px !important; border-radius: var(--app-radius-md) !important;
font-size: 11px !important; font-size: 11px !important;
font-weight: 600 !important; font-weight: 600 !important;
height: 37px !important; height: 37px !important;
padding: 0 15px !important; padding: 0 15px !important;
box-shadow: 0 4px 10px #409eff22; box-shadow: 0 4px 10px color-mix(in srgb, var(--el-color-primary) 13%, transparent);
} }
.primary-button:hover { .primary-button:hover {
background: #337ecc !important; background: var(--el-color-primary-dark-2) !important;
} }
.primary-button .el-icon { .primary-button .el-icon {
margin-right: 6px; margin-right: 6px;
@@ -369,7 +65,7 @@ body {
.panel { .panel {
background: #fff; background: #fff;
border: 1px solid #efedf3; border: 1px solid #efedf3;
border-radius: 10px; border-radius: var(--app-radius-md);
} }
.stat-card { .stat-card {
padding: 16px 17px 13px; padding: 16px 17px 13px;
@@ -387,12 +83,12 @@ body {
height: 27px; height: 27px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 8px; border-radius: var(--app-radius-md);
font-size: 13px; font-size: 13px;
} }
.lilac { .lilac {
background: #ecf5ff; background: var(--el-color-primary-light-9);
color: #409eff; color: var(--el-color-primary);
} }
.blush { .blush {
background: #fff0f2; background: #fff0f2;
@@ -435,7 +131,7 @@ body {
color: #49b786; color: #49b786;
background: #eef9f4; background: #eef9f4;
padding: 3px 5px; padding: 3px 5px;
border-radius: 4px; border-radius: var(--app-radius-sm);
} }
.neutral { .neutral {
color: #aaa8b6; color: #aaa8b6;
@@ -565,9 +261,9 @@ body {
.muted-count { .muted-count {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
border-radius: 10px; border-radius: var(--app-radius-md);
background: #ecf5ff; background: var(--el-color-primary-light-9);
color: #409eff; color: var(--el-color-primary);
font: font:
600 9px 'DM Sans', 600 9px 'DM Sans',
sans-serif; sans-serif;
@@ -578,7 +274,7 @@ body {
} }
.view-all { .view-all {
font-size: 10px !important; font-size: 10px !important;
color: #409eff !important; color: var(--el-color-primary) !important;
} }
.view-all span { .view-all span {
margin-left: 5px; margin-left: 5px;
@@ -599,7 +295,7 @@ body {
.review-art { .review-art {
width: 34px; width: 34px;
height: 37px; height: 37px;
border-radius: 6px; border-radius: var(--app-radius-md);
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 17px; font-size: 17px;
@@ -608,7 +304,7 @@ body {
background: #fff0ed; background: #fff0ed;
} }
.art-two { .art-two {
background: #ecf5ff; background: var(--el-color-primary-light-9);
} }
.art-three { .art-three {
background: #eaf6f7; background: #eaf6f7;
@@ -661,7 +357,7 @@ body {
padding: 10px 11px; padding: 10px 11px;
} }
.drama-table th:first-child { .drama-table th:first-child {
border-radius: 5px 0 0 5px; border-radius: var(--app-radius-sm) 0 0 5px;
padding-left: 12px; padding-left: 12px;
} }
.drama-table th:last-child { .drama-table th:last-child {
@@ -689,7 +385,7 @@ body {
.drama-cover { .drama-cover {
width: 31px; width: 31px;
height: 38px; height: 38px;
border-radius: 4px; border-radius: var(--app-radius-sm);
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 16px; font-size: 16px;
@@ -700,7 +396,11 @@ body {
background: linear-gradient(145deg, #f8d6d7, #ffece4); background: linear-gradient(145deg, #f8d6d7, #ffece4);
} }
.purple { .purple {
background: linear-gradient(145deg, #d9ecff, #ecf5ff); background: linear-gradient(
145deg,
var(--el-color-primary-light-8),
var(--el-color-primary-light-9)
);
} }
.peach { .peach {
background: linear-gradient(145deg, #ffd6bd, #fff0d7); background: linear-gradient(145deg, #ffd6bd, #fff0d7);
@@ -713,7 +413,7 @@ body {
} }
.row-action { .row-action {
font-size: 9px !important; font-size: 9px !important;
color: #409eff !important; color: var(--el-color-primary) !important;
} }
.inner-heading { .inner-heading {
margin-bottom: 24px; margin-bottom: 24px;
@@ -744,11 +444,11 @@ body {
} }
.library-toolbar .el-tabs__item.is-active, .library-toolbar .el-tabs__item.is-active,
.cover-toolbar .el-tabs__item.is-active { .cover-toolbar .el-tabs__item.is-active {
color: #409eff; color: var(--el-color-primary);
} }
.library-toolbar .el-tabs__active-bar, .library-toolbar .el-tabs__active-bar,
.cover-toolbar .el-tabs__active-bar { .cover-toolbar .el-tabs__active-bar {
background: #409eff; background: var(--el-color-primary);
} }
.toolbar-filters { .toolbar-filters {
display: flex; display: flex;
@@ -795,7 +495,7 @@ body {
} }
.pagination .el-pagination { .pagination .el-pagination {
--el-pagination-button-bg-color: #fff; --el-pagination-button-bg-color: #fff;
--el-pagination-hover-color: #409eff; --el-pagination-hover-color: var(--el-color-primary);
} }
.pagination .el-pager li { .pagination .el-pager li {
font-size: 10px; font-size: 10px;
@@ -811,6 +511,8 @@ body {
.episode-context { .episode-context {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 24px;
padding: 20px 0; padding: 20px 0;
border-bottom: 1px solid #f0eff4; border-bottom: 1px solid #f0eff4;
} }
@@ -818,34 +520,70 @@ body {
width: 48px; width: 48px;
height: 58px; height: 58px;
font-size: 22px; font-size: 22px;
border-radius: 6px; border-radius: var(--app-radius-md);
} }
.episode-context-copy { .episode-context-info {
margin-left: 12px;
display: flex; display: flex;
min-width: 0;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.episode-context-copy > span {
font-size: 9px; .episode-context-label {
color: #a7a5b3; color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
} }
.episode-context-copy b {
font-size: 13px; .episode-context-title {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 650;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
} }
.episode-context-copy small {
font-size: 9px; .episode-context-count {
color: #aaa8b6; color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
} }
.drama-select {
margin-left: auto; .episode-switcher {
width: 215px; display: flex;
flex: 0 0 clamp(320px, 32vw, 360px);
width: clamp(320px, 32vw, 360px);
min-width: 0;
flex-direction: column;
gap: 6px;
} }
.drama-select .el-input__wrapper {
.drama-select,
.status-select {
width: 100%;
}
.drama-select .el-input__wrapper,
.status-select .el-input__wrapper {
box-shadow: 0 0 0 1px #efedf3 inset !important; box-shadow: 0 0 0 1px #efedf3 inset !important;
} }
.drama-select .el-input__inner { .drama-select .el-input__inner,
font-size: 10px; .status-select .el-input__inner {
font-size: 13px;
}
@media (max-width: 768px) {
.episode-context {
align-items: stretch;
gap: 14px;
flex-direction: column;
}
.episode-switcher {
flex-basis: auto;
width: 100%;
}
} }
.episode-toolbar { .episode-toolbar {
display: flex; display: flex;
@@ -854,7 +592,7 @@ body {
padding: 20px 0 14px; padding: 20px 0 14px;
} }
.secondary-button { .secondary-button {
border-radius: 6px !important; border-radius: var(--app-radius-md) !important;
border-color: #eae8f0 !important; border-color: #eae8f0 !important;
color: #6e6c80 !important; color: #6e6c80 !important;
font-size: 10px !important; font-size: 10px !important;
@@ -866,7 +604,7 @@ body {
.episode-row { .episode-row {
height: 69px; height: 69px;
border: 1px solid #efedf4; border: 1px solid #efedf4;
border-radius: 7px; border-radius: var(--app-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -881,7 +619,7 @@ body {
.episode-thumb { .episode-thumb {
width: 62px; width: 62px;
height: 45px; height: 45px;
border-radius: 5px; border-radius: var(--app-radius-sm);
background: linear-gradient(135deg, #29273b, #55516e); background: linear-gradient(135deg, #29273b, #55516e);
display: grid; display: grid;
place-items: center; place-items: center;
@@ -919,9 +657,6 @@ body {
padding: 5px; padding: 5px;
color: #9997a7; color: #9997a7;
} }
.episode-actions .danger-action:hover {
color: #e77680;
}
.episode-footer { .episode-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -946,7 +681,7 @@ body {
.add-cover { .add-cover {
background: white; background: white;
border: 1px solid #eeedf3; border: 1px solid #eeedf3;
border-radius: 9px; border-radius: var(--app-radius-md);
overflow: hidden; overflow: hidden;
} }
.poster-art { .poster-art {
@@ -1023,7 +758,7 @@ body {
border: 1px solid #ffffff8a; border: 1px solid #ffffff8a;
padding: 4px 6px; padding: 4px 6px;
font-size: 8px; font-size: 8px;
border-radius: 3px; border-radius: var(--app-radius-sm);
} }
.cover-card-info { .cover-card-info {
padding: 10px; padding: 10px;
@@ -1063,8 +798,8 @@ body {
border-radius: 50%; border-radius: 50%;
display: grid; display: grid;
place-items: center; place-items: center;
background: #ecf5ff; background: var(--el-color-primary-light-9);
color: #409eff; color: var(--el-color-primary);
font-size: 17px; font-size: 17px;
margin-bottom: 11px; margin-bottom: 11px;
} }
@@ -1096,7 +831,7 @@ body {
width: 100%; width: 100%;
border: 1px solid #efedf4; border: 1px solid #efedf4;
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--app-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
text-align: left; text-align: left;
@@ -1106,10 +841,10 @@ body {
font: inherit; font: inherit;
} }
.role-card:hover { .role-card:hover {
border-color: #c6e2ff; border-color: var(--el-color-primary-light-7);
} }
.role-active { .role-active {
border-color: #a0cfff; border-color: var(--el-color-primary-light-5);
background: #f5faff; background: #f5faff;
} }
.role-icon { .role-icon {
@@ -1117,7 +852,7 @@ body {
height: 34px; height: 34px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 8px; border-radius: var(--app-radius-md);
font-size: 15px; font-size: 15px;
} }
.role-icon.red { .role-icon.red {
@@ -1125,12 +860,12 @@ body {
color: #e47b79; color: #e47b79;
} }
.role-icon.purple { .role-icon.purple {
background: #ecf5ff; background: var(--el-color-primary-light-9);
color: #409eff; color: var(--el-color-primary);
} }
.role-icon.blue { .role-icon.blue {
background: #edf5ff; background: #edf5ff;
color: #409eff; color: var(--el-color-primary);
} }
.role-icon.green { .role-icon.green {
background: #eaf8f0; background: #eaf8f0;
@@ -1165,9 +900,9 @@ body {
} }
.role-badge { .role-badge {
font-size: 8px; font-size: 8px;
color: #409eff; color: var(--el-color-primary);
background: #ecf5ff; background: var(--el-color-primary-light-9);
border-radius: 10px; border-radius: var(--app-radius-md);
padding: 4px 7px; padding: 4px 7px;
vertical-align: 2px; vertical-align: 2px;
margin-left: 5px; margin-left: 5px;
@@ -1193,7 +928,7 @@ body {
font-size: 12px; font-size: 12px;
vertical-align: middle; vertical-align: middle;
margin-right: 5px; margin-right: 5px;
color: #409eff; color: var(--el-color-primary);
} }
.permission-group .el-checkbox { .permission-group .el-checkbox {
height: auto; height: auto;
@@ -1204,13 +939,13 @@ body {
color: #858397; color: #858397;
} }
.permission-group .el-checkbox__input.is-checked .el-checkbox__inner { .permission-group .el-checkbox__input.is-checked .el-checkbox__inner {
background: #409eff; background: var(--el-color-primary);
border-color: #409eff; border-color: var(--el-color-primary);
} }
.permission-note { .permission-note {
padding: 10px; padding: 10px;
background: #f8f7fc; background: #f8f7fc;
border-radius: 6px; border-radius: var(--app-radius-md);
font-size: 8px; font-size: 8px;
color: #9997a8; color: #9997a8;
display: flex; display: flex;
@@ -1218,7 +953,7 @@ body {
gap: 7px; gap: 7px;
} }
.permission-note .el-icon { .permission-note .el-icon {
color: #79bbff; color: var(--el-color-primary-light-3);
} }
.placeholder-panel { .placeholder-panel {
text-align: center; text-align: center;
@@ -1229,10 +964,10 @@ body {
place-items: center; place-items: center;
width: 45px; width: 45px;
height: 45px; height: 45px;
border-radius: 12px; border-radius: var(--app-radius-lg);
margin: auto; margin: auto;
background: #ecf5ff; background: var(--el-color-primary-light-9);
color: #409eff; color: var(--el-color-primary);
font-size: 20px; font-size: 20px;
} }
.placeholder-panel h2 { .placeholder-panel h2 {
@@ -1244,7 +979,7 @@ body {
color: #a09eae; color: #a09eae;
} }
.el-dialog { .el-dialog {
border-radius: 11px !important; border-radius: var(--app-radius-md) !important;
} }
.el-dialog__header { .el-dialog__header {
padding: 20px 22px 12px !important; padding: 20px 22px 12px !important;
@@ -1296,13 +1031,13 @@ body {
.upload-area .el-upload-dragger { .upload-area .el-upload-dragger {
height: 112px; height: 112px;
padding: 14px; padding: 14px;
border-radius: 7px; border-radius: var(--app-radius-md);
border-color: #e8e5ef; border-color: #e8e5ef;
background: #fcfbfe; background: #fcfbfe;
} }
.upload-icon { .upload-icon {
font-size: 20px; font-size: 20px;
color: #409eff; color: var(--el-color-primary);
margin-bottom: 6px; margin-bottom: 6px;
} }
.upload-area .el-upload__text { .upload-area .el-upload__text {
@@ -1311,7 +1046,7 @@ body {
} }
.upload-area .el-upload__text em { .upload-area .el-upload__text em {
font-style: normal; font-style: normal;
color: #409eff; color: var(--el-color-primary);
} }
.upload-hint { .upload-hint {
font-size: 8px; font-size: 8px;
@@ -1325,10 +1060,6 @@ body {
width: 100%; width: 100%;
} }
@media (min-width: 1500px) { @media (min-width: 1500px) {
.page-content {
padding-left: 48px;
padding-right: 48px;
}
.stats-grid { .stats-grid {
gap: 18px; gap: 18px;
} }
@@ -1341,17 +1072,15 @@ body {
} }
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.sidebar { .page-heading {
width: 220px; gap: 14px;
} }
.main-area { .library-toolbar,
margin-left: 220px; .admin-user-toolbar,
} .app-user-toolbar,
.page-content { .console-toolbar {
padding: 30px 26px; flex-wrap: wrap;
} gap: 10px;
.topbar {
padding: 0 26px;
} }
.stats-grid { .stats-grid {
gap: 10px; gap: 10px;
@@ -1367,3 +1096,28 @@ body {
grid-template-columns: repeat(4, minmax(130px, 1fr)); grid-template-columns: repeat(4, minmax(130px, 1fr));
} }
} }
@media (max-width: 768px) {
.page-heading {
flex-direction: column;
align-items: stretch;
}
.page-heading h1 {
font-size: 20px;
}
.panel {
padding: 14px;
}
.library-toolbar > .el-input,
.library-toolbar > .el-select,
.admin-user-toolbar > .el-input,
.admin-user-toolbar > .el-select,
.app-user-toolbar > .el-input,
.app-user-toolbar > .el-select {
width: 100%;
}
.stats-grid,
.dashboard-grid {
grid-template-columns: 1fr;
}
}
+96 -125
View File
@@ -1,14 +1,27 @@
:root { :root {
font-family: Arial, 'Noto Sans SC', sans-serif; --font-display: 'Manrope', 'Noto Sans SC', sans-serif;
--font-body: 'DM Sans', 'Noto Sans SC', sans-serif;
--app-radius-sm: 4px;
--app-radius-md: 6px;
--app-radius-lg: 12px;
--app-brand: #c4322a;
--app-brand-pressed: #8e221c;
--app-brand-soft: #ff8a72;
--app-primary-strong: color-mix(in srgb, var(--el-color-primary) 72%, #000 28%);
--brand-mark-size: 38px;
--brand-mark-radius: var(--app-radius-lg);
--el-border-radius-base: var(--app-radius-md);
--el-border-radius-small: var(--app-radius-sm);
font-family: var(--font-body);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
background: var(--el-bg-color-page); background: var(--el-bg-color-page);
--el-color-primary: #409eff; --el-color-primary: #c4322a;
--el-color-primary-light-3: #79bbff; --el-color-primary-light-3: #d3615b;
--el-color-primary-light-5: #a0cfff; --el-color-primary-light-5: #e19995;
--el-color-primary-light-7: #c6e2ff; --el-color-primary-light-7: #f0c2c0;
--el-color-primary-light-8: #d9ecff; --el-color-primary-light-8: #f5d9d8;
--el-color-primary-light-9: #ecf5ff; --el-color-primary-light-9: #faeeed;
--el-color-primary-dark-2: #337ecc; --el-color-primary-dark-2: #9d2822;
--purple: var(--el-color-primary); --purple: var(--el-color-primary);
--muted: #909399; --muted: #909399;
--line: #e4e7ed; --line: #e4e7ed;
@@ -17,82 +30,40 @@ body {
background: var(--el-bg-color-page); background: var(--el-bg-color-page);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
} }
.sidebar { .el-button--primary {
background: #fff; --el-button-bg-color: var(--el-color-primary);
border-right-color: #e7e9ec; --el-button-border-color: var(--el-color-primary);
padding-top: 22px; --el-button-hover-bg-color: var(--el-color-primary-dark-2);
--el-button-hover-border-color: var(--el-color-primary-dark-2);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
} }
.brand { .el-button--danger {
padding-bottom: 23px; --el-button-text-color: #fff;
--el-button-bg-color: var(--el-color-danger);
--el-button-border-color: var(--el-color-danger);
--el-button-hover-bg-color: var(--el-color-danger-dark-2);
--el-button-hover-border-color: var(--el-color-danger-dark-2);
color: #fff !important;
background: var(--el-color-danger) !important;
border-color: var(--el-color-danger) !important;
} }
.brand-mark { .el-button--danger:hover {
background: #409eff; background: var(--el-color-danger-dark-2) !important;
border-radius: 7px; border-color: var(--el-color-danger-dark-2) !important;
transform: none;
} }
.brand b { .el-button--danger.is-disabled,
font: .el-button--danger.is-disabled:hover {
700 16px Arial, color: var(--el-text-color-placeholder) !important;
sans-serif; background: var(--el-fill-color-light) !important;
letter-spacing: -0.4px; border-color: var(--el-border-color-lighter) !important;
color: #29313b; cursor: not-allowed;
opacity: 1;
} }
.brand small { .el-message-box__btns .el-button--danger {
letter-spacing: 0.9px; color: #fff !important;
} background: var(--el-color-danger) !important;
.workspace { border-color: var(--el-color-danger) !important;
border-radius: 5px;
border-color: #e5e7eb;
margin-bottom: 22px;
}
.workspace-avatar,
.profile-avatar,
.top-avatar {
background: #ecf5ff;
color: #409eff;
border-radius: 5px;
}
.nav-label {
color: #9298a1;
font-size: 10px;
}
.side-menu .el-menu-item {
border-radius: 5px;
color: #616873;
}
.side-menu .el-menu-item:hover {
background: #ecf5ff;
color: #409eff;
}
.side-menu .el-menu-item.is-active {
background: #ecf5ff;
color: #409eff;
}
.menu-count {
background: #eef1f4;
color: #747d88;
}
.help-card {
background: #f7f8fa;
border-color: #eceef0;
border-radius: 5px;
}
.help-icon {
background: #ecf5ff;
color: #409eff;
border-radius: 5px;
}
.help-card a {
color: #409eff;
}
.topbar {
background: #fff;
}
.environment {
border-radius: 4px;
}
.page-content {
padding-top: 30px;
} }
.eyebrow { .eyebrow {
letter-spacing: 0.7px; letter-spacing: 0.7px;
@@ -110,14 +81,14 @@ body {
color: #818791; color: #818791;
} }
.primary-button { .primary-button {
background: #409eff !important; background: var(--el-color-primary) !important;
border-color: #409eff !important; border-color: var(--el-color-primary) !important;
border-radius: 5px !important; border-radius: var(--app-radius-sm) !important;
box-shadow: none !important; box-shadow: none !important;
} }
.primary-button:hover { .primary-button:hover {
background: #79bbff !important; background: var(--el-color-primary-dark-2) !important;
border-color: #79bbff !important; border-color: var(--el-color-primary-dark-2) !important;
} }
.stats-grid { .stats-grid {
gap: 12px; gap: 12px;
@@ -125,7 +96,7 @@ body {
.stat-card, .stat-card,
.panel { .panel {
border-color: #e6e8eb; border-color: #e6e8eb;
border-radius: 6px; border-radius: var(--app-radius-md);
box-shadow: none; box-shadow: none;
} }
.stat-card { .stat-card {
@@ -136,12 +107,12 @@ body {
color: #727983; color: #727983;
} }
.stat-icon { .stat-icon {
border-radius: 5px; border-radius: var(--app-radius-sm);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
color: #409eff !important; color: var(--el-color-primary) !important;
} }
.stat-value { .stat-value {
font-family: Arial, sans-serif; font-family: var(--font-display);
color: #303640; color: #303640;
} }
.stat-foot .positive { .stat-foot .positive {
@@ -161,18 +132,18 @@ body {
color: #61738a; color: #61738a;
} }
.chart-svg path:first-of-type { .chart-svg path:first-of-type {
fill: #d9ecff; fill: var(--el-color-primary-light-8);
} }
.chart-svg path:last-of-type { .chart-svg path:last-of-type {
stroke: #409eff; stroke: var(--el-color-primary);
} }
.view-all, .view-all,
.row-action { .row-action {
color: #409eff !important; color: var(--el-color-primary) !important;
} }
.review-art { .review-art {
background: #f1f3f5 !important; background: #f1f3f5 !important;
border-radius: 5px; border-radius: var(--app-radius-sm);
font-size: 0; font-size: 0;
} }
.review-art:after { .review-art:after {
@@ -183,12 +154,12 @@ body {
.review-row .el-tag, .review-row .el-tag,
.drama-table .el-tag, .drama-table .el-tag,
.episode-row .el-tag { .episode-row .el-tag {
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #f0f2f4 !important; background: #f0f2f4 !important;
color: #606b78 !important; color: #606b78 !important;
} }
.drama-cover { .drama-cover {
border-radius: 4px; border-radius: var(--app-radius-sm);
box-shadow: none; box-shadow: none;
font-size: 14px; font-size: 14px;
background: #eceff2 !important; background: #eceff2 !important;
@@ -220,16 +191,16 @@ body {
} }
.library-toolbar .el-tabs__item.is-active, .library-toolbar .el-tabs__item.is-active,
.cover-toolbar .el-tabs__item.is-active { .cover-toolbar .el-tabs__item.is-active {
color: #409eff; color: var(--el-color-primary);
} }
.library-toolbar .el-tabs__active-bar, .library-toolbar .el-tabs__active-bar,
.cover-toolbar .el-tabs__active-bar { .cover-toolbar .el-tabs__active-bar {
background: #409eff; background: var(--el-color-primary);
} }
.toolbar-filters .el-input__wrapper, .toolbar-filters .el-input__wrapper,
.toolbar-filters .el-select__wrapper, .toolbar-filters .el-select__wrapper,
.drama-select .el-input__wrapper { .drama-select .el-input__wrapper {
border-radius: 5px; border-radius: var(--app-radius-sm);
} }
.episode-panel { .episode-panel {
padding-left: 20px; padding-left: 20px;
@@ -237,10 +208,10 @@ body {
} }
.episode-row { .episode-row {
border-color: #e7e9ec; border-color: #e7e9ec;
border-radius: 5px; border-radius: var(--app-radius-sm);
} }
.episode-thumb { .episode-thumb {
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #e9ecef; background: #e9ecef;
color: #7d8792; color: #7d8792;
} }
@@ -256,7 +227,7 @@ body {
.cover-card, .cover-card,
.add-cover { .add-cover {
border-color: #e6e8eb; border-color: #e6e8eb;
border-radius: 5px; border-radius: var(--app-radius-sm);
} }
.poster-art { .poster-art {
background: #e9ecef !important; background: #e9ecef !important;
@@ -296,61 +267,61 @@ body {
color: #656d77; color: #656d77;
} }
.add-cover > span { .add-cover > span {
border-radius: 5px; border-radius: var(--app-radius-sm);
background: #edf0f3; background: #edf0f3;
color: #68798d; color: #68798d;
} }
.role-card { .role-card {
border-radius: 5px; border-radius: var(--app-radius-sm);
border-color: #e7e9ec; border-color: #e7e9ec;
} }
.role-active { .role-active {
border-color: #a0cfff; border-color: var(--el-color-primary-light-5);
background: #ecf5ff; background: var(--el-color-primary-light-9);
} }
.role-icon { .role-icon {
border-radius: 5px; border-radius: var(--app-radius-sm);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
color: #409eff !important; color: var(--el-color-primary) !important;
} }
.role-badge { .role-badge {
border-radius: 4px; border-radius: var(--app-radius-sm);
background: #eef1f4; background: #eef1f4;
color: #65758a; color: #65758a;
} }
.permission-group h3 .el-icon { .permission-group h3 .el-icon {
color: #409eff; color: var(--el-color-primary);
} }
.permission-group .el-checkbox__input.is-checked .el-checkbox__inner { .permission-group .el-checkbox__input.is-checked .el-checkbox__inner {
background: #409eff; background: var(--el-color-primary);
border-color: #409eff; border-color: var(--el-color-primary);
} }
.permission-note { .permission-note {
background: #f6f7f8; background: #f6f7f8;
border-radius: 4px; border-radius: var(--app-radius-sm);
} }
.el-dialog { .el-dialog {
border-radius: 6px !important; border-radius: var(--app-radius-md) !important;
} }
.el-dialog__header, .el-dialog__header,
.el-dialog__footer { .el-dialog__footer {
border-color: #e8eaed; border-color: #e8eaed;
} }
.upload-area .el-upload-dragger { .upload-area .el-upload-dragger {
border-radius: 5px; border-radius: var(--app-radius-sm);
background: #fafbfc; background: #fafbfc;
} }
.upload-icon, .upload-icon,
.upload-area .el-upload__text em { .upload-area .el-upload__text em {
color: #409eff; color: var(--el-color-primary);
} }
.el-tabs__item, .el-tabs__item,
.el-input__inner, .el-input__inner,
.el-select__placeholder { .el-select__placeholder {
font-family: Arial, 'Noto Sans SC', sans-serif; font-family: var(--font-body);
} }
.el-tag.status-badge { .el-tag.status-badge {
border-radius: 4px; border-radius: var(--app-radius-sm);
font-weight: 500; font-weight: 500;
} }
.el-tag.status-badge.is-online { .el-tag.status-badge.is-online {
@@ -378,12 +349,12 @@ body {
color: #6b7280 !important; color: #6b7280 !important;
} }
.el-tag.status-badge.is-pending { .el-tag.status-badge.is-pending {
--el-tag-bg-color: #ecf5ff; --el-tag-bg-color: var(--el-color-primary-light-9);
--el-tag-border-color: #c6e2ff; --el-tag-border-color: var(--el-color-primary-light-7);
--el-tag-text-color: #2b7de9; --el-tag-text-color: var(--el-color-primary);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
border-color: #c6e2ff !important; border-color: var(--el-color-primary-light-7) !important;
color: #2b7de9 !important; color: var(--el-color-primary) !important;
} }
.el-tag.status-badge.is-offline { .el-tag.status-badge.is-offline {
--el-tag-bg-color: #fdecec; --el-tag-bg-color: #fdecec;
+1 -1
View File
@@ -4,7 +4,7 @@
flex: 0 0 30px; flex: 0 0 30px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 5px; border-radius: var(--app-radius-sm);
} }
.stat-top > .stat-icon .el-icon { .stat-top > .stat-icon .el-icon {
font-size: 16px; font-size: 16px;
+10 -7
View File
@@ -6,17 +6,17 @@
--left-menu-min-width: 64px; --left-menu-min-width: 64px;
--left-menu-bg-color: #001529; --left-menu-bg-color: #17110f;
--left-menu-bg-light-color: #0f2438; --left-menu-bg-light-color: #221815;
--left-menu-bg-active-color: var(--el-color-primary); --left-menu-bg-active-color: var(--app-primary-strong);
--left-menu-text-color: #bfcbd9; --left-menu-text-color: #d7cbc4;
--left-menu-text-active-color: #fff; --left-menu-text-active-color: #fff;
--left-menu-collapse-bg-active-color: var(--el-color-primary); --left-menu-collapse-bg-active-color: var(--app-primary-strong);
/* left menu end */ /* left menu end */
/* logo start */ /* logo start */
@@ -26,9 +26,9 @@
/* logo end */ /* logo end */
/* header start */ /* header start */
--top-header-bg-color: '#fff'; --top-header-bg-color: #fff;
--top-header-text-color: 'inherit'; --top-header-text-color: #303133;
--top-header-hover-color: #f6f6f6; --top-header-hover-color: #f6f6f6;
@@ -58,4 +58,7 @@
.dark { .dark {
--app-content-bg-color: var(--el-bg-color); --app-content-bg-color: var(--el-bg-color);
--top-header-bg-color: var(--el-bg-color-overlay);
--top-header-text-color: var(--el-text-color-primary);
--top-header-hover-color: var(--el-fill-color);
} }
+12 -7
View File
@@ -11,17 +11,22 @@ import { ref } from 'vue'
*/ */
const STORAGE_KEY = 'creator-center:impersonate-id' const STORAGE_KEY = 'creator-center:impersonate-id'
function readStored(): number | null { /**
* 代看目标按**字符串**存:创作者 id 是雪花 id(19 位),
* 用 Number 过一遍会被 JS 四舍五入,请求头就会带一个不存在的 id(见 api-phase1.md §0)。
*/
function readStored(): string | null {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
const value = raw ? Number(raw) : NaN const value = (raw || '').trim()
return Number.isFinite(value) && value > 0 ? value : null return value && value !== '0' ? value : null
} }
export const impersonateCreatorId = ref<number | null>(readStored()) export const impersonateCreatorId = ref<string | null>(readStored())
export function setImpersonateCreatorId(id: number | null) { export function setImpersonateCreatorId(id: string | number | null) {
impersonateCreatorId.value = id const value = id === null || id === undefined || id === '' ? null : String(id)
if (id) localStorage.setItem(STORAGE_KEY, String(id)) impersonateCreatorId.value = value
if (value) localStorage.setItem(STORAGE_KEY, value)
else localStorage.removeItem(STORAGE_KEY) else localStorage.removeItem(STORAGE_KEY)
} }
+8 -6
View File
@@ -32,8 +32,9 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
bizType bizType
}) })
const info = initRes.data const info = initRes.data
const chunkSize = Number(info.chunkSize)
options.onSession?.(info.uploadId) options.onSession?.(info.uploadId)
const total = Math.max(info.totalChunks, 1) const total = Math.max(Number(info.totalChunks), 1)
const pending: number[] = Array.from({ length: total }, (_, index) => index) const pending: number[] = Array.from({ length: total }, (_, index) => index)
let finished = 0 let finished = 0
@@ -41,8 +42,8 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
for (;;) { for (;;) {
const index = pending.shift() const index = pending.shift()
if (index === undefined) return if (index === undefined) return
const start = index * info.chunkSize const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size)) const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(info.uploadId, index, chunk, file.name) await uploadChunkApi(info.uploadId, index, chunk, file.name)
finished += 1 finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95))) onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
@@ -68,7 +69,8 @@ export async function resumeUpload(
): Promise<MediaAsset> { ): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options const { concurrency = 3, onProgress } = options
const progress = (await getUploadProgressApi(uploadId)).data const progress = (await getUploadProgressApi(uploadId)).data
const total = Math.max(progress.totalChunks, 1) const chunkSize = Number(progress.chunkSize)
const total = Math.max(Number(progress.totalChunks), 1)
const received = Math.min(progress.receivedChunks || 0, total) const received = Math.min(progress.receivedChunks || 0, total)
const pending: number[] = [] const pending: number[] = []
for (let index = received; index < total; index += 1) { for (let index = received; index < total; index += 1) {
@@ -81,8 +83,8 @@ export async function resumeUpload(
for (;;) { for (;;) {
const index = pending.shift() const index = pending.shift()
if (index === undefined) return if (index === undefined) return
const start = index * progress.chunkSize const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size)) const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name) await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1 finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95))) onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
+61 -75
View File
@@ -1,18 +1,17 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { LoginForm, RegisterForm } from './components' import { LoginForm } from './components'
import { ThemeSwitch } from '@/components/ThemeSwitch' import { ThemeSwitch } from '@/components/ThemeSwitch'
import { LocaleDropdown } from '@/components/LocaleDropdown' import { LocaleDropdown } from '@/components/LocaleDropdown'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { getCssVar, underlineToHump } from '@/utils' import { getCssVar, underlineToHump } from '@/utils'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { computed, ref } from 'vue' import { computed } from 'vue'
import { ElScrollbar } from 'element-plus' import { ElScrollbar } from 'element-plus'
const appStore = useAppStore() const appStore = useAppStore()
const { t } = useI18n() const { t } = useI18n()
const isLogin = ref(true)
const isDark = computed(() => appStore.getIsDark) const isDark = computed(() => appStore.getIsDark)
const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133')) const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133'))
@@ -29,18 +28,12 @@ const themeChange = () => {
<div class="login-shell"> <div class="login-shell">
<section class="login-hero"> <section class="login-hero">
<div class="hero-brand"> <div class="hero-brand">
<img src="@/assets/imgs/logo.png" alt="" /> <img src="@/assets/imgs/brand-mark.png" alt="" />
<span>{{ underlineToHump(appStore.getTitle) }}</span> <span>{{ underlineToHump(appStore.getTitle) }}</span>
</div> </div>
<div class="hero-copy"> <div class="hero-copy">
<p class="hero-kicker">FruitShorts Studio</p> <p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
<h1>{{ t('login.welcome') }}</h1> <h1>{{ t('login.welcome') }}</h1>
<p>{{ t('login.message') }}</p>
<ul>
<li>{{ t('login.pointDrama') }}</li>
<li>{{ t('login.pointPublish') }}</li>
<li>{{ t('login.pointAccess') }}</li>
</ul>
</div> </div>
</section> </section>
@@ -50,8 +43,7 @@ const themeChange = () => {
<LocaleDropdown :color="toolColor" /> <LocaleDropdown :color="toolColor" />
</div> </div>
<div class="login-card"> <div class="login-card">
<LoginForm v-if="isLogin" @to-register="isLogin = false" /> <LoginForm />
<RegisterForm v-else @to-login="isLogin = true" />
</div> </div>
</section> </section>
</div> </div>
@@ -63,7 +55,7 @@ const themeChange = () => {
.login-page { .login-page {
height: 100%; height: 100%;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
background: #eef3f9; background: var(--el-bg-color-page);
} }
.login-scroll { .login-scroll {
@@ -82,9 +74,9 @@ const themeChange = () => {
overflow: hidden; overflow: hidden;
color: #fff; color: #fff;
background: background:
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 16%), transparent 28%), radial-gradient(circle at 18% 18%, rgb(255 255 255 / 10%), transparent 28%),
radial-gradient(circle at 82% 78%, rgb(64 158 255 / 45%), transparent 32%), radial-gradient(circle at 82% 78%, rgb(196 50 42 / 34%), transparent 34%),
linear-gradient(155deg, #0d2344 0%, #1a4f96 52%, #409eff 100%); linear-gradient(155deg, #17100f 0%, #241714 55%, #3b1d19 100%);
flex: 1.05; flex: 1.05;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -111,8 +103,10 @@ const themeChange = () => {
font-weight: 700; font-weight: 700;
img { img {
width: 42px; width: var(--brand-mark-size);
height: 42px; height: var(--brand-mark-size);
border-radius: var(--brand-mark-radius);
box-shadow: 0 10px 24px rgb(196 50 42 / 28%);
} }
} }
@@ -128,38 +122,14 @@ const themeChange = () => {
line-height: 1.2; line-height: 1.2;
color: #fff; color: #fff;
} }
p {
margin: 0;
font-size: 15px;
line-height: 1.7;
color: rgb(255 255 255 / 82%);
}
} }
.hero-kicker { .hero-kicker {
margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #d9ecff !important; color: var(--el-color-primary-light-8) !important;
}
.hero-copy ul {
display: flex;
padding: 0;
margin: 28px 0 0;
list-style: none;
flex-wrap: wrap;
gap: 10px;
li {
padding: 8px 14px;
font-size: 13px;
color: #fff;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 999px;
}
} }
.login-panel { .login-panel {
@@ -169,7 +139,7 @@ const themeChange = () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 88px 32px 48px; padding: 88px 32px 48px;
background: #f4f7fb; background: var(--el-bg-color-page);
} }
.login-tools { .login-tools {
@@ -184,28 +154,28 @@ const themeChange = () => {
.login-card { .login-card {
width: min(420px, 100%); width: min(420px, 100%);
padding: 36px 32px 28px; padding: 36px 32px 28px;
background: #fff; background: var(--el-bg-color);
border: 1px solid #e6edf5; border: 1px solid var(--el-border-color-light);
border-radius: 18px; border-radius: 18px;
box-shadow: 0 18px 50px rgb(15 39 68 / 8%); box-shadow: 0 18px 50px rgb(42 24 16 / 10%);
} }
.is-dark { .is-dark {
background: #0e1116; background: var(--el-bg-color-page);
.login-hero { .login-hero {
background: background:
radial-gradient(circle at 18% 18%, rgb(64 158 255 / 18%), transparent 30%), radial-gradient(circle at 18% 18%, rgb(255 255 255 / 8%), transparent 30%),
linear-gradient(155deg, #07111f 0%, #0e2744 58%, #163e73 100%); linear-gradient(155deg, #0e0a09 0%, #1a1110 58%, #2c1513 100%);
} }
.login-panel { .login-panel {
background: #0e1116; background: var(--el-bg-color-page);
} }
.login-card { .login-card {
background: #161b22; background: var(--el-bg-color-overlay);
border-color: #2a313c; border-color: var(--el-border-color);
box-shadow: none; box-shadow: none;
} }
} }
@@ -217,16 +187,38 @@ const themeChange = () => {
.login-hero { .login-hero {
flex: none; flex: none;
min-height: 280px; min-height: 132px;
padding: 28px 24px 32px; padding: 16px 24px 20px;
} }
.hero-copy h1 { .hero-copy h1 {
font-size: 30px; margin: 6px 0 8px;
font-size: 28px;
} }
.login-panel { .login-panel {
padding: 72px 16px 32px; padding: 44px 16px 12px;
}
.login-card {
padding-top: 28px;
padding-bottom: 20px;
}
.login-tools {
top: 8px;
right: 16px;
}
}
@media (width <= 600px) {
.login-hero {
min-height: 118px;
padding: 16px 18px 18px;
}
.login-card {
padding: 28px 20px 24px;
} }
} }
</style> </style>
@@ -250,23 +242,17 @@ const themeChange = () => {
} }
} }
.login-register { .login-card :deep(.el-button--primary) {
display: flex; --el-button-bg-color: var(--app-brand);
margin-top: 16px; --el-button-border-color: var(--app-brand);
font-size: 14px; --el-button-hover-bg-color: var(--app-brand-pressed);
color: var(--el-text-color-secondary); --el-button-hover-border-color: var(--app-brand-pressed);
gap: 6px; --el-button-active-bg-color: var(--app-brand-pressed);
align-items: center; --el-button-active-border-color: var(--app-brand-pressed);
justify-content: center; }
button { .login-card :deep(.el-input__wrapper.is-focus) {
padding: 0; box-shadow: 0 0 0 1px var(--app-brand) inset;
font: inherit;
color: var(--el-color-primary);
cursor: pointer;
background: none;
border: 0;
}
} }
body:has(.login-page) { body:has(.login-page) {
+21 -42
View File
@@ -4,9 +4,9 @@
import { reactive, ref, watch, onMounted, unref } from 'vue' import { reactive, ref, watch, onMounted, unref } from 'vue'
import { Form, FormSchema } from '@/components/Form' import { Form, FormSchema } from '@/components/Form'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ElCheckbox, ElLink } from 'element-plus' import { ElCheckbox, ElLink, ElMessage } from 'element-plus'
import { useForm } from '@/hooks/web/useForm' import { useForm } from '@/hooks/web/useForm'
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login' import { loginApi, getAdminRoleApi } from '@/api/login'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import { landingPath } from '@/utils/landing' import { landingPath } from '@/utils/landing'
@@ -19,8 +19,6 @@ import { BaseButton } from '@/components/Button'
const { required } = useValidator() const { required } = useValidator()
const emit = defineEmits(['to-register'])
const appStore = useAppStore() const appStore = useAppStore()
const userStore = useUserStore() const userStore = useUserStore()
@@ -64,7 +62,7 @@ const schema = reactive<FormSchema[]>([
span: 24 span: 24
}, },
componentProps: { componentProps: {
placeholder: 'admin' placeholder: t('login.usernamePlaceholder')
} }
}, },
{ {
@@ -79,7 +77,7 @@ const schema = reactive<FormSchema[]>([
style: { style: {
width: '100%' width: '100%'
}, },
placeholder: 'admin123' placeholder: t('login.passwordPlaceholder')
} }
}, },
{ {
@@ -94,7 +92,11 @@ const schema = reactive<FormSchema[]>([
<> <>
<div class="flex justify-between items-center w-[100%]"> <div class="flex justify-between items-center w-[100%]">
<ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" /> <ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" />
<ElLink type="primary" underline={false}> <ElLink
type="primary"
underline={false}
onClick={() => ElMessage.info(t('login.forgetPasswordHint'))}
>
{t('login.forgetPassword')} {t('login.forgetPassword')}
</ElLink> </ElLink>
</div> </div>
@@ -113,24 +115,11 @@ const schema = reactive<FormSchema[]>([
slots: { slots: {
default: () => { default: () => {
return ( return (
<> <div class="w-[100%]">
<div class="w-[100%]"> <BaseButton loading={loading.value} type="primary" class="w-[100%]" onClick={signIn}>
<BaseButton {t('login.login')}
loading={loading.value} </BaseButton>
type="primary" </div>
class="w-[100%]"
onClick={signIn}
>
{t('login.login')}
</BaseButton>
</div>
<div class="login-register">
<span>{t('login.noAccount')}</span>
<button type="button" onClick={toRegister}>
{t('login.register')}
</button>
</div>
</>
) )
} }
} }
@@ -143,8 +132,7 @@ const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => { const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo const loginInfo = userStore.getLoginInfo
if (loginInfo) { if (loginInfo) {
const { username, password } = loginInfo setValues({ username: loginInfo.username })
setValues({ username, password })
} }
} }
onMounted(() => { onMounted(() => {
@@ -170,6 +158,7 @@ watch(
// 登录 // 登录
const signIn = async () => { const signIn = async () => {
if (loading.value) return
const formRef = await getElFormExpose() const formRef = await getElFormExpose()
await formRef?.validate(async (isValid) => { await formRef?.validate(async (isValid) => {
if (isValid) { if (isValid) {
@@ -183,8 +172,7 @@ const signIn = async () => {
// 是否记住我 // 是否记住我
if (unref(remember)) { if (unref(remember)) {
userStore.setLoginInfo({ userStore.setLoginInfo({
username: formData.username, username: formData.username
password: formData.password
}) })
} else { } else {
userStore.setLoginInfo(undefined) userStore.setLoginInfo(undefined)
@@ -196,7 +184,7 @@ const signIn = async () => {
} }
// 是否使用动态路由 // 是否使用动态路由
if (appStore.getDynamicRouter) { if (appStore.getDynamicRouter) {
getRole() await getRole()
} else { } else {
await permissionStore.generateRoutes('static').catch(() => {}) await permissionStore.generateRoutes('static').catch(() => {})
permissionStore.getAddRouters.forEach((route) => { permissionStore.getAddRouters.forEach((route) => {
@@ -219,16 +207,11 @@ const getRole = async () => {
const params = { const params = {
roleName: formData.username roleName: formData.username
} }
const res = const res = await getAdminRoleApi(params)
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await getAdminRoleApi(params)
: await getTestRoleApi(params)
if (res) { if (res) {
const routers = res.data || [] const routers = res.data || []
userStore.setRoleRouters(routers) userStore.setRoleRouters(routers)
appStore.getDynamicRouter && appStore.getServerDynamicRouter await permissionStore.generateRoutes('server', routers).catch(() => {})
? await permissionStore.generateRoutes('server', routers).catch(() => {})
: await permissionStore.generateRoutes('frontEnd', routers).catch(() => {})
permissionStore.getAddRouters.forEach((route) => { permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表 addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
@@ -239,11 +222,6 @@ const getRole = async () => {
push({ path: redirect.value || landingPath() }) push({ path: redirect.value || landingPath() })
} }
} }
// 去注册页面
const toRegister = () => {
emit('to-register')
}
</script> </script>
<template> <template>
@@ -254,6 +232,7 @@ const toRegister = () => {
hide-required-asterisk hide-required-asterisk
size="large" size="large"
class="login-form" class="login-form"
@keyup.enter="signIn"
@register="formRegister" @register="formRegister"
/> />
</template> </template>
-172
View File
@@ -1,172 +0,0 @@
<!-- Author: fruit.com.cn -->
<script setup lang="tsx">
import { Form, FormSchema } from '@/components/Form'
import { reactive, ref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { useForm } from '@/hooks/web/useForm'
import { ElButton, ElInput, FormRules } from 'element-plus'
import { useValidator } from '@/hooks/web/useValidator'
const emit = defineEmits(['to-login'])
const { formRegister, formMethods } = useForm()
const { getElFormExpose } = formMethods
const { t } = useI18n()
const { required } = useValidator()
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<div class="login-heading">
<h2>{t('login.register')}</h2>
<p>{t('login.message')}</p>
</div>
)
}
}
}
},
{
field: 'username',
label: t('login.username'),
value: '',
component: 'Input',
colProps: {
span: 24
},
componentProps: {
placeholder: t('login.usernamePlaceholder')
}
},
{
field: 'password',
label: t('login.password'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'check_password',
label: t('login.checkPassword'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'code',
label: t('login.code'),
colProps: {
span: 24
},
formItemProps: {
slots: {
default: (formData) => {
return (
<div class="w-[100%] flex">
<ElInput v-model={formData.code} placeholder={t('login.codePlaceholder')} />
</div>
)
}
}
}
},
{
field: 'register',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="w-[100%]">
<ElButton
type="primary"
class="w-[100%]"
loading={loading.value}
onClick={loginRegister}
>
{t('login.register')}
</ElButton>
</div>
<div class="w-[100%] mt-15px">
<ElButton class="w-[100%]" onClick={toLogin}>
{t('login.hasUser')}
</ElButton>
</div>
</>
)
}
}
}
}
])
const rules: FormRules = {
username: [required()],
password: [required()],
check_password: [required()],
code: [required()]
}
const toLogin = () => {
emit('to-login')
}
const loading = ref(false)
const loginRegister = async () => {
const formRef = await getElFormExpose()
formRef?.validate(async (valid) => {
if (valid) {
try {
loading.value = true
toLogin()
} finally {
loading.value = false
}
}
})
}
</script>
<template>
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
class="login-form"
@register="formRegister"
/>
</template>
+1 -2
View File
@@ -1,6 +1,5 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import LoginForm from './LoginForm.vue' import LoginForm from './LoginForm.vue'
import RegisterForm from './RegisterForm.vue'
export { LoginForm, RegisterForm } export { LoginForm }
+3 -2
View File
@@ -102,6 +102,7 @@ async function refund(row) {
{ {
confirmButtonText: '确认退款', confirmButtonText: '确认退款',
cancelButtonText: '取消', cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空') inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空')
} }
) )
@@ -375,8 +376,8 @@ function onTabChange(name) {
> >
<el-button <el-button
v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')" v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')"
type="danger"
size="small" size="small"
class="danger-action"
@click="refund(row)" @click="refund(row)"
>退款</el-button >退款</el-button
> >
@@ -461,8 +462,8 @@ function onTabChange(name) {
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
v-if="canEntitlementUpdate && row.status === 'ACTIVE'" v-if="canEntitlementUpdate && row.status === 'ACTIVE'"
type="danger"
size="small" size="small"
class="danger-action"
@click="revokeEntitlement(row)" @click="revokeEntitlement(row)"
>回收</el-button >回收</el-button
> >
+2 -2
View File
@@ -3,7 +3,7 @@
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { AppIcons } from '@/constants/icons'
import { import {
createProductApi, createProductApi,
deleteProductApi, deleteProductApi,
@@ -245,7 +245,7 @@ onMounted(load)
class="primary-button" class="primary-button"
@click="openCreate" @click="openCreate"
> >
<el-icon><Plus /></el-icon> <AppIcon :icon="AppIcons.add" />
新建商品 新建商品
</el-button> </el-button>
</PageHeading> </PageHeading>
+6 -10
View File
@@ -3,7 +3,7 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { VideoCamera, CaretRight, Clock, CircleCheck, Plus } from '@element-plus/icons-vue' import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama' import { pageDramasApi } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
@@ -52,36 +52,32 @@ onMounted(async () => {
type="primary" type="primary"
class="primary-button" class="primary-button"
@click="router.push({ name: 'dramas' })" @click="router.push({ name: 'dramas' })"
><el-icon><Plus /></el-icon>上传新短剧</el-button ><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div ></div
> >
<div v-loading="loading" class="stats-grid"> <div v-loading="loading" class="stats-grid">
<div class="stat-card" <div class="stat-card"
><div class="stat-top" ><div class="stat-top"
><span>短剧总数</span ><span>短剧总数</span
><span class="stat-icon" ><span class="stat-icon"><AppIcon :icon="AppIcons.content" /></span></div
><el-icon><VideoCamera /></el-icon></span></div
><div class="stat-value">{{ total }} <small>部</small></div></div ><div class="stat-value">{{ total }} <small>部</small></div></div
> >
<div class="stat-card" <div class="stat-card"
><div class="stat-top" ><div class="stat-top"
><span>累计播放量</span ><span>累计播放量</span
><span class="stat-icon" ><span class="stat-icon"><AppIcon :icon="AppIcons.videoPlay" /></span></div
><el-icon><CaretRight /></el-icon></span></div
><div class="stat-value">{{ formatCount(totalViews) }}</div></div ><div class="stat-value">{{ formatCount(totalViews) }}</div></div
> >
<div class="stat-card" <div class="stat-card"
><div class="stat-top" ><div class="stat-top"
><span>待审核内容</span ><span>待审核内容</span
><span class="stat-icon" ><span class="stat-icon"><AppIcon :icon="AppIcons.clock" /></span></div
><el-icon><Clock /></el-icon></span></div
><div class="stat-value">{{ reviewingCount }} <small>项</small></div></div ><div class="stat-value">{{ reviewingCount }} <small>项</small></div></div
> >
<div class="stat-card" <div class="stat-card"
><div class="stat-top" ><div class="stat-top"
><span>已上线</span ><span>已上线</span
><span class="stat-icon" ><span class="stat-icon"><AppIcon :icon="AppIcons.checkCircle" /></span></div
><el-icon><CircleCheck /></el-icon></span></div
><div class="stat-value">{{ publishedCount }} <small>部</small></div></div ><div class="stat-value">{{ publishedCount }} <small>部</small></div></div
> >
</div> </div>
@@ -1,9 +1,10 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import { import {
createBannerApi, createBannerApi,
deleteBannerApi, deleteBannerApi,
@@ -32,7 +33,10 @@ const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const uploadingImage = ref(false) const uploadingImage = ref(false)
const imagePercent = ref(0) const imagePercent = ref(0)
const imageError = ref('')
const imageFileName = ref('')
const editingId = ref(null) const editingId = ref(null)
const formRef = ref()
const form = reactive({ const form = reactive({
imageUrl: '', imageUrl: '',
position: 'HOME_TOP', position: 'HOME_TOP',
@@ -53,6 +57,47 @@ const linkTypeOptions = [
{ code: 'URL', text: '打开链接' } { code: 'URL', text: '打开链接' }
] ]
const MAX_BANNER_IMAGE_SIZE = 5 * 1024 * 1024
const BANNER_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
imageUrl: [{ required: true, message: '请上传轮播图片', trigger: 'change' }],
position: [{ required: true, message: '请选择展示位置', trigger: 'change' }],
linkValue: [
{
validator: (_rule, value, callback) => {
if (form.linkType === 'DRAMA' && !value) {
callback(new Error('请选择要跳转的短剧'))
} else if (form.linkType === 'URL' && !String(value || '').trim()) {
callback(new Error('请填写跳转地址'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const bannerPreviewList = computed(() => list.value.map((item) => item.imageUrl).filter(Boolean))
const imageDisplayName = computed(
() => imageFileName.value || (form.imageUrl ? bannerBasename(form.imageUrl) : '尚未选择图片')
)
function bannerPreviewIndex(row) {
return Math.max(0, bannerPreviewList.value.indexOf(row.imageUrl))
}
function bannerBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -100,7 +145,10 @@ function openCreate() {
sort: nextSort(), sort: nextSort(),
status: 1 status: 1
}) })
imageFileName.value = ''
imageError.value = ''
dialogVisible.value = true dialogVisible.value = true
resetValidation()
} }
function openEdit(row) { function openEdit(row) {
@@ -113,7 +161,10 @@ function openEdit(row) {
sort: row.sort ?? 0, sort: row.sort ?? 0,
status: row.status status: row.status
}) })
imageFileName.value = row.imageUrl ? bannerBasename(row.imageUrl) : ''
imageError.value = ''
dialogVisible.value = true dialogVisible.value = true
resetValidation()
} }
function nextSort() { function nextSort() {
@@ -121,12 +172,11 @@ function nextSort() {
} }
async function saveBanner() { async function saveBanner() {
if (!form.imageUrl.trim()) return ElMessage.warning('请上传轮播图片') if (saving.value || uploadingImage.value) return
if (form.linkType === 'DRAMA' && !form.linkValue) { try {
return ElMessage.warning('请选择要跳转的短剧') await formRef.value?.validate()
} } catch {
if (form.linkType === 'URL' && !form.linkValue.trim()) { return
return ElMessage.warning('请填写跳转地址')
} }
const payload = { const payload = {
...form, ...form,
@@ -150,21 +200,55 @@ async function saveBanner() {
} }
} }
function beforeImageUpload(file) {
imageError.value = ''
if (!BANNER_IMAGE_TYPES.includes(file.type)) {
imageError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_BANNER_IMAGE_SIZE) {
imageError.value = '图片大小不能超过 5MB'
return false
}
return true
}
async function uploadImage(options) { async function uploadImage(options) {
uploadingImage.value = true uploadingImage.value = true
imagePercent.value = 0 imagePercent.value = 0
imageError.value = ''
try { try {
const asset = await uploadFile(options.file, { const asset = await uploadFile(options.file, {
bizType: 'BANNER', bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent) onProgress: (percent) => (imagePercent.value = percent)
}) })
form.imageUrl = asset.url form.imageUrl = asset.url
imageFileName.value = options.file.name
await resetValidation()
ElMessage.success('图片已上传') ElMessage.success('图片已上传')
} catch {
imageError.value = '图片上传失败,请检查网络后重试'
} finally { } finally {
uploadingImage.value = false uploadingImage.value = false
} }
} }
function removeImage() {
if (uploadingImage.value) return
form.imageUrl = ''
imageFileName.value = ''
imageError.value = ''
resetValidation()
}
function beforeDialogClose(done) {
if (saving.value || uploadingImage.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '图片正在上传,请稍候')
return
}
done()
}
async function toggleStatus(row) { async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1 const next = row.status === 1 ? 0 : 1
await updateBannerStatusApi(row.id, next) await updateBannerStatusApi(row.id, next)
@@ -219,7 +303,7 @@ onMounted(async () => {
class="primary-button" class="primary-button"
@click="openCreate" @click="openCreate"
> >
<el-icon><Plus /></el-icon> <AppIcon :icon="AppIcons.add" />
新建轮播图 新建轮播图
</el-button> </el-button>
</PageHeading> </PageHeading>
@@ -253,7 +337,15 @@ onMounted(async () => {
> >
<el-table-column label="预览" width="190"> <el-table-column label="预览" width="190">
<template #default="{ row }"> <template #default="{ row }">
<img v-if="row.imageUrl" :src="row.imageUrl" class="banner-thumb" /> <AppImage
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-list="bannerPreviewList"
:initial-index="bannerPreviewIndex(row)"
fit="cover"
class="banner-thumb"
:alt="row.title"
/>
<div v-else class="banner-thumb-empty">暂无图片</div> <div v-else class="banner-thumb-empty">暂无图片</div>
</template> </template>
</el-table-column> </el-table-column>
@@ -335,75 +427,160 @@ onMounted(async () => {
</div> </div>
</section> </section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑轮播图' : '新建轮播图'" width="620px"> <el-dialog
<el-form label-position="top"> v-model="dialogVisible"
<el-form-item label="轮播图片" required> :title="editingId ? '编辑轮播图' : '新建轮播图'"
<div class="banner-field"> width="680px"
<div class="banner-field-actions"> class="banner-editor-dialog"
<el-upload :close-on-click-modal="false"
:show-file-list="false" :before-close="beforeDialogClose"
accept="image/*" >
:http-request="uploadImage" <el-form
:disabled="uploadingImage" ref="formRef"
> :model="form"
<el-button :loading="uploadingImage">{{ :rules="formRules"
form.imageUrl ? '更换图片' : '选择图片' label-position="top"
}}</el-button> class="banner-editor-form"
</el-upload> >
<p v-if="uploadingImage" class="selected-file">上传中 {{ imagePercent }}%</p> <section class="banner-editor-section">
<p v-else-if="form.imageUrl" class="selected-file">图片已上传</p> <h3 class="banner-editor-section-title">轮播图片</h3>
<el-input v-model="form.imageUrl" placeholder="也可以直接填写图片地址" /> <el-form-item prop="imageUrl">
<div class="banner-image-editor">
<div class="banner-image-preview-card">
<img v-if="form.imageUrl" :src="form.imageUrl" alt="轮播图预览" />
<div v-else class="banner-image-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无图片</span>
</div>
<div v-if="uploadingImage" class="banner-image-upload-mask">
<span>上传中</span>
<b>{{ imagePercent }}%</b>
</div>
</div>
<div class="banner-image-meta">
<div class="banner-image-status">
<el-tag :type="form.imageUrl ? 'success' : 'info'" effect="light">
{{ form.imageUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="banner-image-filename" :title="imageDisplayName">
{{ imageDisplayName }}
</span>
</div>
<p class="banner-image-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<el-alert
v-if="imageError"
:title="imageError"
type="error"
:closable="false"
show-icon
/>
<div class="banner-image-actions">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeImageUpload"
:http-request="uploadImage"
:disabled="uploadingImage"
>
<el-button :loading="uploadingImage" :disabled="uploadingImage">
<AppIcon :icon="AppIcons.upload" />
{{ form.imageUrl ? '更换图片' : '选择图片' }}
</el-button>
</el-upload>
<el-button
v-if="form.imageUrl"
type="danger"
:disabled="uploadingImage"
@click="removeImage"
>
移除
</el-button>
<a
v-if="form.imageUrl"
class="banner-original-link"
:href="form.imageUrl"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div> </div>
<img v-if="form.imageUrl" :src="form.imageUrl" class="form-banner-preview" /> </el-form-item>
</section>
<section class="banner-editor-section">
<h3 class="banner-editor-section-title">跳转配置</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="展示位置" prop="position">
<el-select v-model="form.position" placeholder="请选择展示位置">
<el-option
v-for="item in positionOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
<el-form-item label="跳转类型">
<el-select v-model="form.linkType">
<el-option
v-for="item in linkTypeOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
</div> </div>
</el-form-item> <el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" prop="linkValue">
<div class="form-row"> <el-select v-model="form.linkValue" filterable placeholder="搜索短剧">
<el-form-item label="展示位置" required>
<el-select v-model="form.position">
<el-option <el-option
v-for="item in positionOptions" v-for="item in dramas"
:key="item.code" :key="item.id"
:label="item.text" :label="`${item.title}(${item.statusText})`"
:value="item.code" :value="String(item.id)"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="跳转类型"> <el-form-item v-if="form.linkType === 'URL'" label="跳转地址" prop="linkValue">
<el-select v-model="form.linkType"> <el-input v-model="form.linkValue" placeholder="https://..." />
<el-option </el-form-item>
v-for="item in linkTypeOptions" </section>
:key="item.code"
:label="item.text" <section class="banner-editor-section">
:value="item.code" <h3 class="banner-editor-section-title">排序与状态</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="排序">
<el-input-number
v-model="form.sort"
class="banner-editor-number"
controls-position="right"
:min="0"
:max="999"
/> />
</el-select> </el-form-item>
</el-form-item> <el-form-item label="状态">
</div> <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
<el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" required> </el-form-item>
<el-select v-model="form.linkValue" filterable placeholder="搜索短剧"> </div>
<el-option </section>
v-for="item in dramas"
:key="item.id"
:label="`${item.title}(${item.statusText})`"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.linkType === 'URL'" label="跳转地址" required>
<el-input v-model="form.linkValue" placeholder="https://..." />
</el-form-item>
<div class="form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="saving || uploadingImage" @click="dialogVisible = false">
<el-button type="primary" :loading="saving" @click="saveBanner">保存</el-button> 取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingImage"
@click="saveBanner"
>
保存
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more