Author SHA1 Message Date
Zhou Weidong 9ce014e7b3 feat(classification): 分类支持榜单布局与顺序维护
web-ci / build (push) Failing after 12s
2026-11-02 14:47:59 +08:00
Vicky 23026dc371 style(setting): 按 eslint 规则规范系统设置模板格式
web-ci / build (push) Canceled after 0s
2026-10-30 17:17:39 +08:00
Vicky 2f52011491 perf(build): 图标改为离线子集并精简图表依赖
web-ci / build (push) Canceled after 0s
2026-10-30 17:14:51 +08:00
Vicky dd4be08d49 feat(setting): 系统设置支持上传 App 登录页背景 2026-10-30 17:14:50 +08:00
Vicky a94018d5d3 feat(prototype): 新增印尼市场短剧 App 静态原型
web-ci / build (push) Canceled after 0s
- public/indonesia/ 下独立静态原型,不影响后台构建
- 覆盖首页、发现、详情、播放、收藏、个人中心、金币解锁等核心流程
2026-10-29 17:33:07 +08:00
Vicky 923b7dce1e feat(upload): 封面上传支持横版与前端压缩
- 新增 imageCompress 与 imagePresets,上传前按规格高质量等比压缩
- 短剧封面拆分为竖版/横版两个上传位,带建议尺寸提示
- 轮播图、创作者中心封面与分集封面接入压缩与提示
2026-10-29 17:32:56 +08:00
Vicky dea414739e feat(tag): 标签管理支持配置语义码
web-ci / build (push) Canceled after 0s
客户端按语义码判断板块归属(如推荐页「上新」横滑),标签改名或换语言
都不再影响;新增/编辑弹窗增加语义码输入并做格式校验,留空则按名称兜底。
2026-10-29 15:44:42 +08:00
Zhou Weidong d13bdd5dc8 ci: 新增 Gitea Actions 流水线(测试/分析/构建门禁)
web-ci / build (push) Failing after 36s
2026-10-29 10:47:47 +08:00
Zhou Weidong 040ec3f382 fix(login): 退出登录不清除记住我的账号密码 2026-10-29 10:08:21 +08:00
Zhou Weidong 9a58b98f12 fix(login): 记住我同时记住密码并回填 2026-10-29 09:58:57 +08:00
Zhou Weidong e2618735ba chore(merge): 合并 main 的样式排序到 develop 2026-10-29 09:21:32 +08:00
Zhou Weidong b68361b037 chore(merge): 合并 phase1 UI prep 后台用户系统筛选与列表展示
合并后新增收藏/点赞列表页、用户单元格组件与系统筛选,
并统一观看时间格式与标签文案。
2026-10-27 20:20:31 +08:00
Vicky bedae02946 feat: phase1 UI prep — 后台 App 用户列表支持系统筛选 + 国家中文名 + 标签语义优化
新增: 系统下拉 (ANDROID/IOS)、国家中文名映射、用户列昵称/UID 冒号标签
详情抽屉: 同步系统/国家/标签展示
2026-10-27 15:45:23 +08:00
Vicky daf681ae1a fix(admin): 观看时间统一格式化为 yyyy-MM-dd HH:mm:ss
后端改输出格式后仍保留前端兜底,同时修掉用户详情里的原始 ISO 展示。
2026-10-27 14:39:18 +08:00
Vicky 111bbdaeb7 fix(admin): 用户列标签改用半角冒号 2026-10-27 14:29:56 +08:00
Vicky 5da72d1aa2 fix(admin): 用户列补全「昵称 / UID / 设备」标签与冒号
两行都以「字段名:值」呈现,避免只看到一串随机字母。
2026-10-27 14:28:59 +08:00
Vicky e1eb58d4c4 feat(admin): 列表支持按系统筛选,用户列补语义标签
- 收藏/点赞/观看增加「全部系统 / Android / iOS」下拉
- 用户列第二行标注 UID,游客行标注设备,避免一串字母看不出含义
2026-10-27 14:27:16 +08:00
Vicky 5dbc752798 feat(admin): 观看/收藏/点赞列表单独增加系统列
用户单元格只保留身份信息,Android/iOS 抽成独立「系统」列,三页列结构一致。
2026-10-27 14:17:37 +08:00
Vicky c89c5668e1 refactor(admin): 用户单元格只保留平台标识
机型与系统版本号属于多余信息,Android/iOS 加游客标签已足够区分。
2026-10-27 14:00:07 +08:00
Vicky 9ea22d5951 chore(merge): 合并后台用户单元格统一 2026-10-27 13:53:54 +08:00
Vicky f3fbc57c14 feat(admin): 统一用户单元格,展示游客设备号与客户端系统
- 新增 AppUserCell,游客显示标签+deviceId,第二行统一展示系统与机型
- 收藏、点赞、观看记录三页复用,观看记录去掉重复的系统列
2026-10-27 13:53:53 +08:00
Zhou Weidong c45820258e chore(merge): 合并游客标签样式修正 2026-10-27 13:11:10 +08:00
Zhou Weidong 12d63e9670 fix(admin): 修正游客标签拉伸 2026-10-27 13:10:19 +08:00
Zhou Weidong 7aa1571c15 chore(merge): 合并后台互动列表展示调整 2026-10-27 13:05:14 +08:00
Zhou Weidong 8cd4569a20 fix(admin): 收紧收藏与点赞列表展示 2026-10-27 13:03:53 +08:00
Zhou Weidong 83bb87eeb3 chore(merge): 合并后台 props lint 修复 2026-10-27 12:47:46 +08:00
Zhou Weidong 3409a2b427 fix(admin): 修复组件 props 变异的 lint 基线 2026-10-27 12:47:45 +08:00
Zhou Weidong a21fe6f189 chore(merge): 合并后台收藏与点赞列表页 2026-10-27 12:38:40 +08:00
Zhou Weidong 5580f9b99b feat(admin): 新增收藏与点赞列表页
全量 lint 仍有 5 处既有 vue/no-mutating-props 基线错误,
位于 LocaleTextFields.vue 与 ClassificationDramaDialogs.vue,本次未处理。
2026-10-27 12:38:31 +08:00
Zhou Weidong d846d2d864 Merge branch fix/icons-vue-deps 2026-10-26 13:06:45 +08:00
Zhou Weidong e6d1d6997b fix(deps): 补充 icons-vue 依赖声明 2026-10-26 13:06:38 +08:00
Zhou Weidong d661b808d1 chore(merge): 合并后台多语言工作台和登录页 2026-10-24 17:32:51 +08:00
Zhou Weidong d42a09fa36 feat(admin): 后台页面显示版权标识 2026-10-24 17:32:32 +08:00
Zhou Weidong 33bf5bd3bb feat(admin): 登录页补充水果装饰 2026-10-24 17:32:12 +08:00
Zhou Weidong a266d44483 feat(admin): 增加工作台大屏和转码通知 2026-10-24 17:31:16 +08:00
Zhou Weidong 2b2c6bd708 feat(admin): 内容管理支持多语言和标签 2026-10-24 17:30:52 +08:00
Zhou Weidong eaee765239 fix(app-version): 修复版本页类型检查 2026-10-24 17:30:29 +08:00
Zhou Weidong 18234ebd5b Merge branch fix/app-version-batch 2026-10-23 16:46:29 +08:00
Zhou Weidong 93d6d8c59f fix(app-version): 修复弹窗禁用并改批量提交 2026-10-23 16:46:22 +08:00
Zhou Weidong 9fc57e5c1a chore(merge): 合并 App 版本管理页面 2026-10-23 15:27:46 +08:00
Zhou Weidong 0a28eb0237 feat(admin): 新增 App 版本管理页面 2026-10-23 15:27:45 +08:00
Zhou Weidong 73a9b078ee chore(merge): 合并后台默认主题色调整 2026-10-23 15:13:00 +08:00
Zhou Weidong bb48f14ba7 feat(admin): 头部与菜单主题默认改为白色 2026-10-23 15:12:59 +08:00
Zhou Weidong ada74efc3a chore(merge): 合并运营大盘 ECharts 图表 2026-10-23 14:58:56 +08:00
Zhou Weidong 10372524ba feat(admin): 运营大盘接入 ECharts 图表 2026-10-23 14:58:55 +08:00
Zhou Weidong fd9a9c50c4 chore(merge): 合并立即补齐转码按钮 2026-10-23 14:14:48 +08:00
Zhou Weidong e51d371e07 feat(admin): 转码设置页新增立即补齐转码按钮 2026-10-23 14:14:46 +08:00
Zhou Weidong ada3b56d14 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:08:30 +08:00
Zhou Weidong 7e8ae86d9f chore(merge): 对齐远程 main 2026-10-23 13:08:28 +08:00
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
120 changed files with 11668 additions and 704 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Author: fruit.com.cn
# ReelShortWeb 后台 CI:lint + 构建门禁
name: web-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: pnpm
- name: 依赖
run: pnpm install --frozen-lockfile
- name: 类型检查
run: pnpm ts:check
- name: 构建
run: pnpm build:pro
+6
View File
@@ -7,3 +7,9 @@ dist-ssr
*-lock.*
pnpm-debug
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
. "$(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"
@@ -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`。
@@ -0,0 +1,29 @@
# 印尼市场短剧 App UI / UX 原型
## 范围
- 新增独立静态原型 `public/indonesia/`,不修改管理后台、Flutter 或接口契约。
- 沿用现有 FruitShorts / DramaPulse 设计方向:深色、橙红主色、印尼语、三个主导航。
- 覆盖首页、发现/搜索、详情/选集、播放交互、收藏/历史、个人中心、金币解锁。
- 原型数据及钱包均仅保存在浏览器;不调用真实支付,不伪造支付成功。
## 步骤
1. 阅读项目规范、PRD、契约与设计基线,保留用户现有修改。
2. 独立实现响应式界面、可访问交互与本地状态。
3. 检查桌面/移动布局、搜索空态、收藏持久化与解锁幂等。
4. 验证原后台构建,记录结果并提供可直接打开的入口。
## 风险 / 待确认
- 当前目录为管理后台,故交付独立设计原型,不将 C 端覆盖到后台。
- 原型不代表已实现 Flutter App;虚构剧名、热度、价格只用于界面设计。
- 生图工具没有返回可用图片,采用示意照片;正式剧集图需替换为已授权内容。
- 播放器为分镜交互演示,不将静态图片伪装成已接入的剧集视频。
- 金币不足仅展示充值方案与商店结算说明,不接外部钱包或真实扣款。
- 原有 `TagManagementView.vue` 修改不属于本任务,保持原样。
- INBOX 内旧任务不属于本次用户明确要求,不执行、不标记完成。
## 验收
- 待执行。
+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 为项目既有警告。
+3 -1
View File
@@ -6,7 +6,7 @@
"private": true,
"scripts": {
"i": "pnpm install",
"dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base",
"dev": "vite --mode base",
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
"build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro",
"build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev",
@@ -24,6 +24,7 @@
"prepare": "husky install"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2",
@@ -38,6 +39,7 @@
"echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.5.5",
"hls.js": "^1.7.3",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+12
View File
@@ -0,0 +1,12 @@
/**
* @license lucide v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
(function(a,n){typeof exports=="object"&&typeof module<"u"?n(exports):typeof define=="function"&&define.amd?define(["exports"],n):(a=typeof globalThis<"u"?globalThis:a||self,n(a.lucide={}))})(this,function(a){"use strict";const n=(t,d,c=[])=>{const p=document.createElementNS("http://www.w3.org/2000/svg",t);return Object.keys(d).forEach(M=>{p.setAttribute(M,String(d[M]))}),c.length&&c.forEach(M=>{const v=n(...M);p.appendChild(v)}),p};var I0=([t,d,c])=>n(t,d,c);const Q$=t=>Array.from(t.attributes).reduce((d,c)=>(d[c.name]=c.value,d),{}),j$=t=>typeof t=="string"?t:!t||!t.class?"":t.class&&typeof t.class=="string"?t.class.split(" "):t.class&&Array.isArray(t.class)?t.class:"",Y$=t=>t.flatMap(j$).map(d=>d.trim()).filter(Boolean).filter((d,c,p)=>p.indexOf(d)===c).join(" "),_$=t=>t.replace(/(\w)(\w*)(_|-|\s*)/g,(d,c,p)=>c.toUpperCase()+p.toLowerCase()),E0=(t,{nameAttr:d,icons:c,attrs:p})=>{const M=t.getAttribute(d);if(M==null)return;const v=_$(M),X$=c[v];if(!X$)return console.warn(`${t.outerHTML} icon name was not found in the provided icons object.`);const N$=Q$(t),[hm,tm,dm]=X$,K$={...tm,"data-lucide":M,...p,...N$},J$=Y$(["lucide",`lucide-${M}`,N$,p]);J$&&Object.assign(K$,{class:J$});const cm=I0([hm,K$,dm]);return t.parentNode?.replaceChild(cm,t)},h={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"},x0=["svg",h,[["path",{d:"M3.5 13h6"}],["path",{d:"m2 16 4.5-9 4.5 9"}],["path",{d:"M18 7v9"}],["path",{d:"m14 12 4 4 4-4"}]]],W0=["svg",h,[["path",{d:"M3.5 13h6"}],["path",{d:"m2 16 4.5-9 4.5 9"}],["path",{d:"M18 16V7"}],["path",{d:"m14 11 4-4 4 4"}]]],X0=["svg",h,[["path",{d:"M21 14h-5"}],["path",{d:"M16 16v-3.5a2.5 2.5 0 0 1 5 0V16"}],["path",{d:"M4.5 13h6"}],["path",{d:"m3 16 4.5-9 4.5 9"}]]],N0=["svg",h,[["circle",{cx:"16",cy:"4",r:"1"}],["path",{d:"m18 19 1-7-6 1"}],["path",{d:"m5 8 3-3 5.5 3-2.36 3.5"}],["path",{d:"M4.24 14.5a5 5 0 0 0 6.88 6"}],["path",{d:"M13.76 17.5a5 5 0 0 0-6.88-6"}]]],K0=["svg",h,[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"}]]],J0=["svg",h,[["path",{d:"M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"}],["path",{d:"M6 8h12"}],["path",{d:"M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12"}],["path",{d:"M6.6 15.6A2 2 0 1 0 10 17v-5"}]]],Q0=["svg",h,[["path",{d:"M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"}],["path",{d:"m12 15 5 6H7Z"}]]],o=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"m9 13 2 2 4-4"}]]],s=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"M9 13h6"}]]],j0=["svg",h,[["path",{d:"M6.87 6.87a8 8 0 1 0 11.26 11.26"}],["path",{d:"M19.9 14.25a8 8 0 0 0-9.15-9.15"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.26 18.67 4 21"}],["path",{d:"m2 2 20 20"}],["path",{d:"M4 4 2 6"}]]],r=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"M12 10v6"}],["path",{d:"M9 13h6"}]]],Y0=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M12 9v4l2 2"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}]]],_0=["svg",h,[["path",{d:"M11 21c0-2.5 2-2.5 2-5"}],["path",{d:"M16 21c0-2.5 2-2.5 2-5"}],["path",{d:"m19 8-.8 3a1.25 1.25 0 0 1-1.2 1H7a1.25 1.25 0 0 1-1.2-1L5 8"}],["path",{d:"M21 3a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a1 1 0 0 1 1-1z"}],["path",{d:"M6 21c0-2.5 2-2.5 2-5"}]]],aa=["svg",h,[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["polyline",{points:"11 3 11 11 14 8 17 11 17 3"}]]],ha=["svg",h,[["path",{d:"M2 12h20"}],["path",{d:"M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4"}],["path",{d:"M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4"}],["path",{d:"M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1"}],["path",{d:"M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1"}]]],ta=["svg",h,[["path",{d:"M12 2v20"}],["path",{d:"M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4"}],["path",{d:"M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4"}],["path",{d:"M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1"}],["path",{d:"M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1"}]]],da=["svg",h,[["path",{d:"M17 12H7"}],["path",{d:"M19 18H5"}],["path",{d:"M21 6H3"}]]],ca=["svg",h,[["rect",{width:"6",height:"16",x:"4",y:"2",rx:"2"}],["rect",{width:"6",height:"9",x:"14",y:"9",rx:"2"}],["path",{d:"M22 22H2"}]]],Ma=["svg",h,[["rect",{width:"16",height:"6",x:"2",y:"4",rx:"2"}],["rect",{width:"9",height:"6",x:"9",y:"14",rx:"2"}],["path",{d:"M22 22V2"}]]],pa=["svg",h,[["rect",{width:"6",height:"14",x:"4",y:"5",rx:"2"}],["rect",{width:"6",height:"10",x:"14",y:"7",Line truncated
If you want to use all the icons you can import it like:
\`import { createIcons, icons } from 'lucide';
lucide.createIcons({icons});\``);if(typeof document>"u")throw new Error("`createIcons()` only works in a browser environment.");const p=document.querySelectorAll(`[${d}]`);if(Array.from(p).forEach(M=>E0(M,{nameAttr:d,icons:t,attrs:c})),d==="data-lucide"){const M=document.querySelectorAll("[icon-name]");M.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(M).forEach(v=>E0(v,{nameAttr:"icon-name",icons:t,attrs:c})))}};a.AArrowDown=x0,a.AArrowUp=W0,a.ALargeSmall=X0,a.Accessibility=N0,a.Activity=K0,a.ActivitySquare=L2,a.AirVent=J0,a.Airplay=Q0,a.AlarmCheck=o,a.AlarmClock=Y0,a.AlarmClockCheck=o,a.AlarmClockMinus=s,a.AlarmClockOff=j0,a.AlarmClockPlus=r,a.AlarmMinus=s,a.AlarmPlus=r,a.AlarmSmoke=_0,a.Album=aa,a.AlertCircle=G,a.AlertOctagon=t2,a.AlertTriangle=P0,a.AlignCenter=da,a.AlignCenterHorizontal=ha,a.AlignCenterVertical=ta,a.AlignEndHorizontal=ca,a.AlignEndVertical=Ma,a.AlignHorizontalDistributeCenter=pa,a.AlignHorizontalDistributeEnd=ea,a.AlignHorizontalDistributeStart=na,a.AlignHorizontalJustifyCenter=ia,a.AlignHorizontalJustifyEnd=la,a.AlignHorizontalJustifyStart=va,a.AlignHorizontalSpaceAround=oa,a.AlignHorizontalSpaceBetween=sa,a.AlignJustify=ra,a.AlignLeft=ga,a.AlignRight=ya,a.AlignStartHorizontal=$a,a.AlignStartVertical=ma,a.AlignVerticalDistributeCenter=Ca,a.AlignVerticalDistributeEnd=ua,a.AlignVerticalDistributeStart=Ha,a.AlignVerticalJustifyCenter=wa,a.AlignVerticalJustifyEnd=Va,a.AlignVerticalJustifyStart=Aa,a.AlignVerticalSpaceAround=Sa,a.AlignVerticalSpaceBetween=La,a.Ambulance=fa,a.Ampersand=Pa,a.Ampersands=ka,a.Amphora=Ba,a.Anchor=Fa,a.Angry=Da,a.Annoyed=Ra,a.Antenna=za,a.Anvil=qa,a.Aperture=Ta,a.AppWindow=ba,a.AppWindowMac=Za,a.Apple=Ua,a.Archive=Ia,a.ArchiveRestore=Oa,a.ArchiveX=Ga,a.AreaChart=P,a.Armchair=Ea,a.ArrowBigDown=Wa,a.ArrowBigDownDash=xa,a.ArrowBigLeft=Na,a.ArrowBigLeftDash=Xa,a.ArrowBigRight=Ja,a.ArrowBigRightDash=Ka,a.ArrowBigUp=ja,a.ArrowBigUpDash=Qa,a.ArrowDown=eh,a.ArrowDown01=Ya,a.ArrowDown10=_a,a.ArrowDownAZ=g,a.ArrowDownAz=g,a.ArrowDownCircle=I,a.ArrowDownFromLine=ah,a.ArrowDownLeft=hh,a.ArrowDownLeftFromCircle=x,a.ArrowDownLeftFromSquare=F2,a.ArrowDownLeftSquare=f2,a.ArrowDownNarrowWide=th,a.ArrowDownRight=dh,a.ArrowDownRightFromCircle=W,a.ArrowDownRightFromSquare=D2,a.ArrowDownRightSquare=P2,a.ArrowDownSquare=k2,a.ArrowDownToDot=ch,a.ArrowDownToLine=Mh,a.ArrowDownUp=ph,a.ArrowDownWideNarrow=y,a.ArrowDownZA=$,a.ArrowDownZa=$,a.ArrowLeft=vh,a.ArrowLeftCircle=E,a.ArrowLeftFromLine=nh,a.ArrowLeftRight=ih,a.ArrowLeftSquare=B2,a.ArrowLeftToLine=lh,a.ArrowRight=gh,a.ArrowRightCircle=K,a.ArrowRightFromLine=oh,a.ArrowRightLeft=sh,a.ArrowRightSquare=q2,a.ArrowRightToLine=rh,a.ArrowUp=Sh,a.ArrowUp01=yh,a.ArrowUp10=$h,a.ArrowUpAZ=m,a.ArrowUpAz=m,a.ArrowUpCircle=J,a.ArrowUpDown=mh,a.ArrowUpFromDot=Ch,a.ArrowUpFromLine=uh,a.ArrowUpLeft=Hh,a.ArrowUpLeftFromCircle=X,a.ArrowUpLeftFromSquare=R2,a.ArrowUpLeftSquare=T2,a.ArrowUpNarrowWide=C,a.ArrowUpRight=wh,a.ArrowUpRightFromCircle=N,a.ArrowUpRightFromSquare=z2,a.ArrowUpRightSquare=Z2,a.ArrowUpSquare=b2,a.ArrowUpToLine=Vh,a.ArrowUpWideNarrow=Ah,a.ArrowUpZA=u,a.ArrowUpZa=u,a.ArrowsUpFromLine=Lh,a.Asterisk=fh,a.AsteriskSquare=U2,a.AtSign=Ph,a.Atom=kh,a.AudioLines=Bh,a.AudioWaveform=Fh,a.Award=Dh,a.Axe=Rh,a.Axis3D=H,a.Axis3d=H,a.Baby=zh,a.Backpack=qh,a.Badge=jh,a.BadgeAlert=Th,a.BadgeCent=Zh,a.BadgeCheck=w,a.BadgeDollarSign=bh,a.BadgeEuro=Uh,a.BadgeHelp=Oh,a.BadgeIndianRupee=Gh,a.BadgeInfo=Ih,a.BadgeJapaneseYen=Eh,a.BadgeMinus=xh,a.BadgePercent=Wh,a.BadgePlus=Xh,a.BadgePoundSterling=Nh,a.BadgeRussianRuble=Kh,a.BadgeSwissFranc=Jh,a.BadgeX=Qh,a.BaggageClaim=Yh,a.Ban=_h,a.Banana=at,a.Bandage=ht,a.Banknote=tt,a.BarChart=T,a.BarChart2=Z,a.BarChart3=z,a.BarChart4=R,a.BarChartBig=D,a.BarChartHorizontal=B,a.BarChartHorizontalBig=k,a.Barcode=dt,a.Baseline=ct,a.Bath=Mt,a.Battery=vt,a.BatteryCharging=pt,a.BatteryFull=et,a.BatteryLow=nt,a.BatteryMedium=it,a.BatteryWarning=lt,a.Beaker=ot,a.Bean=rt,a.BeanOff=st,a.Bed=$t,a.BedDouble=gt,a.BedSingle=yt,a.Beef=mt,a.Beer=ut,a.BeerOff=Ct,a.Bell=ft,a.BellDot=Ht,a.BellElectric=wt,a.BellMinus=Vt,a.BellOff=At,a.BellPlus=St,a.BellRing=Lt,a.BetweenHorizonalEnd=V,a.BetweenHorizonalStart=A,a.BetweenHorizontalEnd=V,a.BetweenHorizontalStart=A,a.BetweenVerticalEnd=Pt,a.BetweenVerticalStart=kt,a.BicepsFlexed=Bt,a.Bike=Ft,a.Binary=Dt,a.Binoculars=Rt,a.Biohazard=zt,a.Bird=qt,a.Bitcoin=Tt,a.Blend=Zt,a.Blinds=bt,a.Blocks=Ut,a.Bluetooth=Et,a.BluetoothConnected=Ot,a.BluetoothOff=Gt,a.BluetoothSearching=It,a.Bold=xt,a.Bolt=Wt,a.Bomb=Xt,a.Bone=Nt,a.Book=y4,a.BookA=Kt,a.BookAudio=Jt,a.BookCheck=Qt,a.BookCopy=jt,a.BookDashed=S,a.BookDown=Yt,a.BookHeadphones=_t,a.BookHeart=a4,a.BookImage=h4,a.BookKey=t4,a.BookLock=d4,a.BookMarked=c4,a.BookMinus=M4,a.BookOpen=n4,a.BookOpenCheck=p4,a.BookOpenText=e4,a.BookPlus=i4,a.BookTemplate=S,a.BookText=lLine truncated
//# sourceMappingURL=lucide.min.js.map
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

+72
View File
@@ -0,0 +1,72 @@
<!-- Author: fruit.com.cn -->
<!-- 印尼市场短剧客户端交互设计入口。 -->
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0c10" />
<meta name="description" content="FruitShorts — drama pendek, cerita yang dekat." />
<title>FruitShorts · Drama pendek pilihanmu</title>
<link rel="icon" href="../brand-mark.png" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="screens.css" />
<script defer src="assets/lucide.min.js"></script>
<script defer src="data.js"></script>
<script defer src="state.js"></script>
<script defer src="views.js"></script>
<script defer src="dialogs.js"></script>
<script defer src="app.js"></script>
</head>
<body>
<a class="skip-link" href="#main">Langsung ke konten</a>
<aside class="sidebar" aria-label="Navigasi utama">
<a class="brand" href="#home" aria-label="FruitShorts Beranda">
<span class="brand-mark"><i data-lucide="play" aria-hidden="true"></i></span>
<span>Fruit<span class="brand-accent">Shorts</span></span>
</a>
<span class="nav-caption">MENU UTAMA</span>
<nav class="main-nav">
<a href="#home" data-nav="home"><i data-lucide="house"></i><span>Beranda</span></a>
<a href="#discover" data-nav="discover"><i data-lucide="compass"></i><span>Jelajahi</span></a>
<a href="#me" data-nav="me"><i data-lucide="circle-user-round"></i><span>Saya</span></a>
</nav>
<div class="sidebar-divider"></div>
<span class="nav-caption">KOLEKSI SAYA</span>
<nav class="collection-nav">
<a href="#library"><i data-lucide="bookmark"></i>Daftar saya<span id="saved-count">0</span></a>
<a href="#history"><i data-lucide="history"></i>Riwayat tontonan</a>
</nav>
<div class="sidebar-bottom">
<div class="local-note"><i data-lucide="clapperboard"></i><span>Cerita singkat.<br /><strong>Rasa yang melekat.</strong></span></div>
<button class="help-link" data-action="help"><i data-lucide="circle-help"></i>Pusat bantuan<i data-lucide="arrow-up-right"></i></button>
<span class="copyright">© 2026 FruitShorts</span>
</div>
</aside>
<div class="app-shell">
<header class="topbar">
<a class="brand mobile-brand" href="#home"><span class="brand-mark"><i data-lucide="play"></i></span><span>Fruit<span class="brand-accent">Shorts</span></span></a>
<form class="search-form" role="search">
<i data-lucide="search" aria-hidden="true"></i>
<input id="global-search" type="search" placeholder="Cari drama, temukan cerita..." aria-label="Cari drama" autocomplete="off" />
<kbd aria-hidden="true">/</kbd>
</form>
<div class="topbar-actions">
<button class="language-button" data-action="language" title="Bahasa"><i data-lucide="globe-2"></i><span>ID</span><i data-lucide="chevron-down"></i></button>
<button class="wallet-button" data-action="wallet" title="Dompet koin"><i data-lucide="coins"></i><span data-balance>120</span><span class="wallet-add">+</span></button>
<button class="icon-button notification-button" data-action="notifications" aria-label="Notifikasi" title="Notifikasi"><i data-lucide="bell"></i><span></span></button>
<a class="avatar" href="#me" aria-label="Profil saya">N</a>
</div>
</header>
<main id="main"></main>
<footer class="page-footer"><a class="brand" href="#home">Fruit<span class="brand-accent">Shorts</span></a><span>Drama pendek. Momen besar.</span><button data-action="privacy">Privasi</button><button data-action="terms">Ketentuan</button><span>Indonesia · Bahasa Indonesia</span></footer>
</div>
<nav class="bottom-nav" aria-label="Navigasi seluler">
<a href="#home" data-nav="home"><i data-lucide="house"></i><span>Beranda</span></a>
<a href="#discover" data-nav="discover"><i data-lucide="compass"></i><span>Jelajahi</span></a>
<a href="#me" data-nav="me"><i data-lucide="circle-user-round"></i><span>Saya</span></a>
</nav>
<dialog id="app-dialog" aria-labelledby="dialog-title"></dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>
+12
View File
@@ -22,6 +22,7 @@ appStore.initTheme()
<template>
<ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<footer class="app-copyright">© FruitShorts</footer>
</ConfigGlobal>
</template>
@@ -48,4 +49,15 @@ body {
.@{prefix-cls}-grey-mode {
filter: grayscale(100%);
}
.app-copyright {
position: fixed;
right: 20px;
bottom: 10px;
z-index: 5;
font-size: 12px;
line-height: 1;
color: var(--el-text-color-secondary, #909399);
pointer-events: none;
}
</style>
+25
View File
@@ -11,6 +11,24 @@ export interface AppUserPageParams {
status?: string
countryCode?: string
registerSource?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
}
export interface WatchHistoryPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: number
dramaTitle?: string
dramaId?: number
episodeId?: number
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
source?: string
finished?: boolean
startTime?: string
endTime?: string
}
export const pageAppUsersApi = (params: AppUserPageParams) => {
@@ -32,6 +50,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,封禁与限制会立即吊销该用户全部会话 */
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
return request.put<void>({
+69
View File
@@ -0,0 +1,69 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface AppVersionItem {
id: string
platform: 'ANDROID' | 'IOS'
platformText: string
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate: number
minSupportedCode: number
status: number
statusText: string
releasedAt?: string
createdAt?: string
}
export interface AppVersionSaveData {
platform: 'ANDROID' | 'IOS'
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate?: 0 | 1
minSupportedCode?: number
}
export const listAppVersionsApi = (params: {
pageIndex?: number
pageSize?: number
platform?: string
status?: number
keyword?: string
}) => {
return request.get<{ total: number; list: AppVersionItem[] }>({
url: '/admin/app-versions/page',
params
})
}
export const getAppVersionApi = (id: string | number) => {
return request.get<AppVersionItem>({ url: `/admin/app-versions/${id}` })
}
export const createAppVersionApi = (data: AppVersionSaveData) => {
return request.post<string>({ url: '/admin/app-versions', data })
}
export const batchCreateAppVersionsApi = (data: AppVersionSaveData[]) => {
return request.post<string[]>({ url: '/admin/app-versions/batch', data })
}
export const updateAppVersionApi = (id: string | number, data: AppVersionSaveData) => {
return request.post<void>({ url: `/admin/app-versions/${id}`, data })
}
export const updateAppVersionStatusApi = (id: string | number, status: 0 | 1) => {
return request.post<void>({
url: `/admin/app-versions/${id}/status`,
params: { status }
})
}
export const deleteAppVersionApi = (id: string | number) => {
return request.delete<void>({ url: `/admin/app-versions/${id}` })
}
+41
View File
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface ContentTagItem {
id: number
name: string
nameEn: string
nameId: string
locales?: Record<string, string>
bgColor?: string
sort: number
status: number
}
export interface ContentTagSaveData {
name: string
locales: Record<string, string>
bgColor?: string
sort?: number
status?: number
}
export const listContentTagsApi = (enabledOnly = false) => {
return request.get<ContentTagItem[]>({
url: '/admin/content-tags',
params: enabledOnly ? { enabledOnly: true } : undefined
})
}
export const createContentTagApi = (data: ContentTagSaveData) => {
return request.post<number>({ url: '/admin/content-tags', data })
}
export const updateContentTagApi = (id: number, data: ContentTagSaveData) => {
return request.put<void>({ url: `/admin/content-tags/${id}`, data })
}
export const deleteContentTagApi = (id: number) => {
return request.delete<void>({ url: `/admin/content-tags/${id}` })
}
+30
View File
@@ -27,6 +27,28 @@ export interface TranscodeBoard {
notes: string[]
}
/** 近 7 天每日趋势(给折线图用)。 */
export interface DashboardTrend {
dates: string[]
newUsers: number[]
playEvents: number[]
newTranscodes: number[]
transcodeSuccess: number[]
}
/** 分布项(给饼图用)。 */
export interface DistributionItem {
label: string
value: number
}
export interface DashboardDistribution {
dramaStatus: Record<string, number>
transcodeStatus: Record<string, number>
episodeMedia: Record<string, number>
pendingModeration: DistributionItem[]
}
export const getDashboardOverviewApi = () => {
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
}
@@ -34,3 +56,11 @@ export const getDashboardOverviewApi = () => {
export const getTranscodeBoardApi = () => {
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' })
}
export const getDashboardTrendApi = () => {
return request.get<DashboardTrend>({ url: '/admin/dashboard/trend' })
}
export const getDashboardDistributionApi = () => {
return request.get<DashboardDistribution>({ url: '/admin/dashboard/distribution' })
}
+3
View File
@@ -15,7 +15,9 @@ export interface DramaPageParams {
keyword?: string
status?: string
categoryId?: number
categoryIds?: number[]
isHot?: boolean
dramaTypes?: Array<'ANIME' | 'LIVE'>
}
export const pageDramasApi = (params: DramaPageParams) => {
@@ -50,6 +52,7 @@ export const listDramaCategoryOptionsApi = () => {
export interface DramaCategorySaveData {
name: string
locales: Record<string, string>
sort?: number
status?: number
}
+103
View File
@@ -0,0 +1,103 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface DramaClassificationItem {
id: string
name: string
code: string
nameEn: string
nameId: string
locales?: Record<string, string>
layoutType: 'GRID' | 'RANKING'
revision: string
sort: number
status: number
dramaCount: number
updatedAt?: string
}
export interface DramaClassificationSaveData {
name: string
locales: Record<string, string>
code?: string
layoutType?: 'GRID' | 'RANKING'
sort?: number
status?: number
}
export interface ClassificationDramaItem {
id: string
title: string
cover: string
status: string
totalEpisodes: number
sort: number
position?: 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<string>({ url: '/admin/drama-classifications', data })
}
export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
}
export const deleteClassificationApi = (id: string) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
}
/** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`,
params
})
}
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: string,
params: ClassificationDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas/candidates`,
params
})
}
/** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds }
})
}
/** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: string, dramaId: string) => {
return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
})
}
/** 原子保存推荐榜完整内容与顺序 */
export const saveRankingItemsApi = (id: string, dramaIds: string[]) => {
return request.put<void>({
url: `/admin/drama-classifications/${id}/ranking-items`,
data: { dramaIds }
})
}
+87
View File
@@ -0,0 +1,87 @@
// 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
nameEn: string
nameId: string
description: string
descriptionEn: string
descriptionId: string
locales?: Record<string, string>
descriptionLocales?: Record<string, string>
sort: number
status: number
dramaCount: number
}
export interface DramaPlaylistCategorySaveData {
name: string
locales: Record<string, string>
descriptionLocales?: Record<string, 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}`
})
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 收藏与点赞只读分页筛选条件。 */
export interface EngagementPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: string | number
deviceId?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
dramaId?: string | number
dramaTitle?: string
targetType?: string
targetId?: string | number
episodeId?: string | number
startTime?: string
endTime?: string
}
/** 后台互动列表行。 */
export interface EngagementItem {
id: string
userId: string | null
uid: string | null
nickname: string | null
deviceId: string | null
/** 客户端平台 ANDROID / IOS / WEB;老客户端未上报时为空。 */
platform: string | null
anonymous: boolean
dramaId: string | null
dramaTitle: string | null
dramaCover: string | null
dramaStatus: string | null
dramaPublished: boolean | null
targetType: string
targetId: string
episodeId: string | null
episodeNo: number | null
episodeTitle: string | null
commentSummary: string | null
createdAt: string
}
/** 查询收藏分页。 */
export const pageFavoritesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/favorites/page', params })
/** 查询点赞分页。 */
export const pageLikesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/likes/page', params })
+28 -3
View File
@@ -1,10 +1,35 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { EpisodeItem, EpisodeSaveData } from './types'
import type { EpisodeItem, EpisodeSaveData, PageResult } from './types'
export const listEpisodesApi = (dramaId: number, status?: string) => {
return request.get<EpisodeItem[]>({ url: '/admin/episodes', params: { dramaId, status } })
export interface EpisodePageParams {
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) => {
+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
import request from '@/axios'
import type { MediaAsset } from './types'
import type { MediaAsset, MediaTranscodeProfile } from './types'
export interface UploadInitData {
fileName: string
@@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => {
export const getMediaApi = (id: number) => {
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
})
}
+43
View File
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import request from '@/axios'
import type {
EpisodeTranscodeItem,
MediaThumbnailResult,
TranscodeBackfillResult,
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`
})
}
/** 手动补齐转码:扫描缺码率产物/帧图的分集并入队。 */
export const runTranscodeBackfillApi = (batchSize?: number) => {
return request.post<TranscodeBackfillResult>({
url: '/admin/media/transcode/backfill',
params: batchSize ? { batchSize } : undefined
})
}
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface TranscodeNoticeItem {
id: number
status: string
dramaTitle: string
episodeNo?: number
episodeTitle?: string
progress?: number
errorMsg?: string
updatedAt?: string
}
export interface TranscodeNotice {
running: number
success: number
failed: number
runningList: TranscodeNoticeItem[]
successList: TranscodeNoticeItem[]
failedList: TranscodeNoticeItem[]
}
export const getTranscodeNoticesApi = () => {
return request.get<TranscodeNotice>({ url: '/admin/notifications/transcode' })
}
+158
View File
@@ -8,6 +8,9 @@ export interface PageResult<T> {
export interface DramaCategory {
id: number
name: string
nameEn?: string
nameId?: string
locales?: Record<string, string>
sort?: number
status?: number
/** 该类型下的短剧数量,管理列表才有 */
@@ -20,11 +23,17 @@ export interface DramaItem {
title: string
subtitle?: string
categoryId?: number
categoryIds?: number[]
categoryNames?: string[]
category?: string
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
episodes?: number
freeEpisodes?: number
unlockCoin?: number
@@ -33,6 +42,9 @@ export interface DramaItem {
status: string
statusText: string
views?: number
favoriteCount?: number
likeCount?: number
commentCount?: number
sort?: number
publishAt?: string
createdAt?: string
@@ -43,12 +55,20 @@ export interface DramaSaveData {
title: string
subtitle?: string
categoryId?: number
categoryIds?: number[]
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
freeEpisodes?: number
unlockCoin?: number
favoriteCount?: number
likeCount?: number
heat?: number
isHot?: boolean
sort?: number
}
@@ -69,6 +89,10 @@ export interface EpisodeItem {
status: string
statusText: string
views?: number
likeCount?: number
commentCount?: number
favoriteCount?: number
heat?: number
sort?: number
}
@@ -81,6 +105,7 @@ export interface EpisodeSaveData {
videoUrl?: string
isFree?: boolean
unlockCoin?: number
likeCount?: number
status?: string
sort?: number
}
@@ -204,6 +229,115 @@ export interface MediaAsset {
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 TranscodeBackfillItem {
episodeId: string
taskId: string
}
export interface TranscodeBackfillResult {
triggered: number
items: TranscodeBackfillItem[]
}
export interface BannerItem {
id: number
imageUrl: string
@@ -269,6 +403,10 @@ export interface AppUserItem {
maskedTarget: string
createdAt?: string
lastLoginAt?: string
/** ANDROID / IOS / WEB */
platform?: string
/** 系统版本,例如 Android 16 */
osVersion?: string
/** 预留:会员等级与到期(第二阶段) */
vipLevel?: number
vipExpireAt?: string
@@ -294,16 +432,22 @@ export interface AppUserBanRecord {
export interface WatchHistoryItem {
id: number
userId?: number
uid?: string
nickname?: string
dramaId: number
dramaTitle?: string
dramaPublished?: boolean
episodeId: number
episodeNo?: number
episodeTitle?: string
positionMs: number
durationMs: number
finished?: boolean
source: string
deviceId: string
platform?: string
osVersion?: string
watchedAt?: string
}
@@ -311,7 +455,10 @@ export interface WatchHistoryItem {
export interface CommentItem {
id: number
dramaId: number
dramaTitle?: string
episodeId: number
episodeNo?: number
episodeTitle?: string
parentId: number
rootId: number
userId: number
@@ -330,9 +477,20 @@ export interface CommentItem {
export interface ReportItem {
id: number
reporterId: number
reporterNickname?: string
reporterUid?: string
/** COMMENT / USER / DRAMA */
targetType: string
targetId: number
targetSummary?: string
dramaId?: number
dramaTitle?: string
episodeId?: number
episodeNo?: number
episodeTitle?: string
commentContent?: string
targetNickname?: string
targetUid?: string
reasonCode: string
description: string
/** pending / handled / rejected */
+1
View File
@@ -2,6 +2,7 @@
export type UserLoginType = {
username: string
password?: string
}
export type UserType = {
@@ -0,0 +1 @@
{"prefix":"material-symbols","info":{"name":"Material Symbols","total":15728,"author":{"name":"Google","url":"https://github.com/google/material-design-icons"},"license":{"title":"Apache 2.0","spdx":"Apache-2.0","url":"https://github.com/google/material-design-icons/blob/master/LICENSE"},"samples":["downloading","privacy-tip","filter-drama-outline","assignment-ind","monitoring","desktop-access-disabled-outline-sharp"],"height":24,"category":"Material","tags":["Has Padding"],"palette":false},"lastModified":1791524209,"width":24,"height":24,"icons":{"menu-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h16q.425 0 .713.288T21 17t-.288.713T20 18zm0-5q-.425 0-.712-.288T3 12t.288-.712T4 11h16q.425 0 .713.288T21 12t-.288.713T20 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h16q.425 0 .713.288T21 7t-.288.713T20 8z\"/>"},"menu-open-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h11q.425 0 .713.288T16 17t-.288.713T15 18zm14.9-1.7l-3.6-3.6q-.3-.3-.3-.7t.3-.7l3.6-3.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L17.4 12l2.9 2.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275M4 13q-.425 0-.712-.288T3 12t.288-.712T4 11h8q.425 0 .713.288T13 12t-.288.713T12 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h11q.425 0 .713.288T16 7t-.288.713T15 8z\"/>"},"dashboard-rounded":{"body":"<path fill=\"currentColor\" d=\"M14 9q-.425 0-.712-.288T13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9zM4 13q-.425 0-.712-.288T3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13zm10 8q-.425 0-.712-.288T13 20v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21zM4 21q-.425 0-.712-.288T3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21z\"/>"},"video-library-rounded":{"body":"<path fill=\"currentColor\" d=\"M17.525 10.625q.35-.225.35-.625t-.35-.625L12.65 6.25q-.375-.25-.763-.038t-.387.663v6.25q0 .45.388.663t.762-.038zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22z\"/>"},"settings-rounded":{"body":"<path fill=\"currentColor\" d=\"M10.825 22q-.675 0-1.162-.45t-.588-1.1L8.85 18.8q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.225-1.65q.1-.65.588-1.1T10.825 2h2.35q.675 0 1.163.45t.587 1.1l.225 1.65q.325.125.613.3t.562.375l1.55-.65q.625-.275 1.25-.05t.975.8l1.175 2.05q.35.575.2 1.225t-.675 1.075l-1.325 1q.025.175.025.338v.674q0 .163-.05.338l1.325 1q.525.425.675 1.075t-.2 1.225l-1.2 2.05q-.35.575-.975.8t-1.25-.05l-1.5-.65q-.275.2-.575.375t-.6.3l-.225 1.65q-.1.65-.587 1.1t-1.163.45zm1.225-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5\"/>"},"group-rounded":{"body":"<path fill=\"currentColor\" d=\"M1 17.2q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18zM18.45 20q.275-.45.413-.962T19 18v-1q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v1q0 .825-.587 1.413T21 20zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"},"movie-edit-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L7 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L12 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L17 4h3q.825 0 1.413.587T22 6v3.45q0 .3-.25.475t-.55.1q-.825-.15-1.638.087T18.1 11l-5.525 5.525q-.275.275-.425.638t-.15.762V19q0 .425-.288.713T11 20zm10 0v-1.65q0-.2.075-.387t.225-.338l5.225-5.2q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.2 5.2q-.15.15-.337.225T16.65 21H15q-.425 0-.712-.287T14 20m6.575-4.6l.925-.975l-.925-.925l-.95.95z\"/>"},"policy-rounded":{"body":"<path fill=\"currentColor\" d=\"M11.675 21.875q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 1.575-.413 3.063T18.4 17l-2.95-2.95q.275-.475.413-.987T16 12q0-1.65-1.175-2.825T12 8T9.175 9.175T8 12t1.175 2.825T12 16q.525 0 1.038-.137T14 15.45l3.225 3.2q-.95 1.125-2.087 1.913T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025m-1.088-8.462Q10 12.825 10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14t-1.412-.587\"/>"},"payments-rounded":{"body":"<path fill=\"currentColor\" d=\"M3 20q-.825 0-1.412-.587T1 18V8q0-.425.288-.712T2 7t.713.288T3Line truncated
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import {
ElCascader,
@@ -23,9 +24,12 @@ import {
ElUpload
} from 'element-plus'
import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { ComponentName } from '../types'
// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载,
// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。
const Editor = defineAsyncComponent(() => import('@/components/Editor'))
const componentMap: Recordable<Component, ComponentName> = {
RadioGroup: ElRadioGroup,
RadioButton: ElRadioGroup,
+2 -1
View File
@@ -22,7 +22,8 @@ import {
ISelectProps,
UploadProps
} from 'element-plus'
import { IEditorConfig } from '@wangeditor/editor'
// 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链
import type { IEditorConfig } from '@wangeditor/editor'
import { CSSProperties } from 'vue'
export interface PlaceholderModel {
+2 -10
View File
@@ -5,7 +5,7 @@ import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue'
import { Icon } from '@iconify/vue/offline'
import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign()
@@ -29,11 +29,6 @@ const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
})
// 是否使用在线图标
const isUseOnline = computed(() => {
return import.meta.env.VITE_USE_ONLINE_ICON === 'true'
})
const getIconifyStyle = computed(() => {
const { color, size } = props
return {
@@ -49,10 +44,7 @@ const getIconifyStyle = computed(() => {
<use :xlink:href="symbolId" />
</svg>
<template v-else>
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template>
<Icon v-else :icon="normalizedIcon" :style="getIconifyStyle" />
</ElIcon>
</template>
+46
View File
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
export interface ContentLanguage {
code: string
label: string
}
export const CONTENT_LANGUAGES: ContentLanguage[] = [
{ code: 'zh', label: '中文' },
{ code: 'en', label: '英文' },
{ code: 'id', label: '印尼语' }
]
export function languageLabel(code: string) {
return CONTENT_LANGUAGES.find((item) => item.code === code)?.label || code
}
export function languageCodes(locales?: Record<string, string> | null) {
const source = locales || {}
return CONTENT_LANGUAGES.map((item) => item.code).filter((code) => source[code])
}
export function localesFromColumns(
locales: Record<string, string> | null | undefined,
zh?: string,
en?: string,
id?: string
) {
if (locales && Object.keys(locales).length) {
return { ...locales }
}
const result: Record<string, string> = {}
if (zh) result.zh = zh
if (en) result.en = en
if (id) result.id = id
if (!result.zh) result.zh = ''
return result
}
export function payloadLocales(locales: Record<string, string>, codes: string[]) {
const result: Record<string, string> = {}
codes.forEach((code) => {
result[code] = String(locales[code] || '').trim()
})
return result
}
+2
View File
@@ -37,6 +37,8 @@ export const AppIcons = {
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',
+3 -1
View File
@@ -6,6 +6,7 @@ import { Collapse } from '@/components/Collapse'
import { LocaleDropdown } from '@/components/LocaleDropdown'
import { SizeDropdown } from '@/components/SizeDropdown'
import { UserInfo } from '@/components/UserInfo'
import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
import { Screenfull } from '@/components/Screenfull'
import { Breadcrumb } from '@/components/Breadcrumb'
import { useAppStore } from '@/store/modules/app'
@@ -73,6 +74,7 @@ export default defineComponent({
color="var(--top-header-text-color)"
></LocaleDropdown>
) : undefined}
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
<UserInfo></UserInfo>
</div>
</div>
@@ -95,7 +97,7 @@ export default defineComponent({
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.@{prefix-cls} {
padding-right: 8px;
padding-left: 8px;
@@ -0,0 +1,89 @@
<!-- Author: fruit.com.cn -->
<!-- 顶栏转码通知:角标汇总转码中、成功、失败,点击进入通知页。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Bell } from '@element-plus/icons-vue'
import { getTranscodeNoticesApi } from '@/api/admin/transcodeNotice'
import { TRANSCODE_NOTICE_SEEN, unreadCounts } from '@/utils/transcodeNoticeSeen'
const POLL_MS = 30000
const BADGE_MAX = 99
defineProps<{ color?: string }>()
const router = useRouter()
const running = ref(0)
const success = ref(0)
const failed = ref(0)
const seenTick = ref(0)
let timer = 0
const total = computed(() => {
seenTick.value
const unread = unreadCounts({
running: running.value,
success: success.value,
failed: failed.value
})
return unread.running + unread.success + unread.failed
})
async function refresh() {
const res = await getTranscodeNoticesApi()
running.value = Number(res.data?.running || 0)
success.value = Number(res.data?.success || 0)
failed.value = Number(res.data?.failed || 0)
}
function open() {
router.push({ name: 'transcode-notices' })
}
function onSeen() {
seenTick.value += 1
}
onMounted(() => {
refresh().catch(() => undefined)
window.addEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
timer = window.setInterval(() => {
refresh().catch(() => undefined)
}, POLL_MS)
})
onBeforeUnmount(() => {
window.removeEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
window.clearInterval(timer)
})
</script>
<template>
<el-badge :value="total" :max="BADGE_MAX" :hidden="total === 0" class="notice-badge">
<div class="notice-button" :style="{ color }" @click="open">
<el-icon :size="18"><Bell /></el-icon>
</div>
</el-badge>
</template>
<style scoped>
.notice-badge {
display: flex;
align-items: center;
height: 100%;
}
.notice-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 100%;
cursor: pointer;
}
.notice-badge :deep(.el-badge__content.is-fixed) {
top: 8px;
right: 2px;
}
</style>
+170 -1
View File
@@ -112,6 +112,7 @@ export default {
login: 'Sign in',
remember: 'Remember me',
forgetPassword: 'Forget password',
forgetPasswordHint: 'Please contact the administrator to reset your password',
usernamePlaceholder: 'Please input username',
passwordPlaceholder: 'Please input password'
},
@@ -162,7 +163,8 @@ export default {
treeTable: 'Tree table',
PicturePreview: 'Table Image Preview',
department: 'Department management',
menuManagement: 'Menu management'
menuManagement: 'Menu management',
transcodeSettings: 'Transcode settings'
},
permission: {
hasPermission: 'Please set the operation permission value'
@@ -526,5 +528,172 @@ export default {
inputPasswordDemo: {
title: 'InputPassword',
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',
backfill: 'Backfill transcodes',
backfillTip: 'Scan episodes missing renditions or thumbnails and enqueue them for transcoding',
backfillDone: 'Enqueued {count} episodes into the transcode queue',
backfillEmpty: 'No episodes need transcoding',
backfillFailed: 'Failed to run transcode backfill',
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'
}
}
}
+164 -1
View File
@@ -112,6 +112,7 @@ export default {
login: '登录',
remember: '记住我',
forgetPassword: '忘记密码',
forgetPasswordHint: '请联系管理员重置密码',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码'
},
@@ -162,7 +163,8 @@ export default {
treeTable: '树形表格',
PicturePreview: '表格图片预览',
department: '部门管理',
menuManagement: '菜单管理'
menuManagement: '菜单管理',
transcodeSettings: '转码设置'
},
permission: {
hasPermission: '请设置操作权限值'
@@ -520,5 +522,166 @@ export default {
inputPasswordDemo: {
title: '密码输入框',
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: '当前账号没有转码设置权限',
backfill: '立即补齐转码',
backfillTip: '扫描缺少清晰度产物或帧图的分集并自动入队转码',
backfillDone: '本轮已补入 {count} 个分集进入转码队列',
backfillEmpty: '没有需要补转的分集',
backfillFailed: '补齐转码执行失败',
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: '请修正标记的转码配置后再保存'
}
}
}
+3
View File
@@ -5,6 +5,9 @@ import 'vue/jsx'
// 引入unocss
import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标
import '@/plugins/svgIcon'
+11 -1
View File
@@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
import { getAdminRoleApi, getProfileApi } from '@/api/login'
import { landingPath } from '@/utils/landing'
import { supplementLocalAdminRoutes } from '@/router/localRoutes'
const { start, done } = useNProgress()
@@ -68,6 +69,10 @@ router.beforeEach(async (to, from, next) => {
} catch {
// 拉取失败时退回上次缓存的菜单,不阻塞进入
}
const permissions = Array.isArray(userStore.getUserInfo?.permissions)
? userStore.getUserInfo.permissions
: []
routers = supplementLocalAdminRoutes(routers, permissions)
await permissionStore.generateRoutes('server', routers)
} else {
await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
@@ -91,7 +96,12 @@ router.beforeEach(async (to, from, next) => {
const redirectPath = from.query.redirect || to.path
const redirect = decodeURIComponent(redirectPath as string)
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
// 只按路径重进。刷新时动态路由还没注册,先被兜底路由接住;
// 若把当时匹配到的 name 带回去,会一直停在 404。
const nextData =
to.path === redirect
? { path: to.path, query: to.query, hash: to.hash, replace: true }
: { path: redirect, replace: true }
permissionStore.setIsAddRouters(true)
next(nextData)
}
+2 -18
View File
@@ -2,22 +2,12 @@
import * as echarts from 'echarts/core'
import {
BarChart,
LineChart,
PieChart,
MapChart,
PictorialBarChart,
RadarChart
} from 'echarts/charts'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent
} from 'echarts/components'
@@ -28,16 +18,10 @@ echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
BarChart,
LineChart,
PieChart,
MapChart,
CanvasRenderer,
PictorialBarChart,
RadarChart
CanvasRenderer
])
export default echarts
+6
View File
@@ -0,0 +1,6 @@
// Author: fruit.com.cn
import { addCollection } from '@iconify/vue/offline'
import materialSymbols from '@/assets/icons/material-symbols-subset.json'
addCollection(materialSymbols)
+54 -1
View File
@@ -52,6 +52,35 @@ export const constantRouterMap: AppRouteRecordRaw[] = [
title: '404',
noTagsView: true
}
},
{
path: '/notifications',
component: Layout,
name: 'Notifications',
meta: {
hidden: true,
title: '通知'
},
children: [
{
path: 'transcode',
name: 'transcode-notices',
component: () => import('@/views/common/TranscodeNoticeView.vue'),
meta: {
title: '转码通知'
}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'UnresolvedPath',
component: () => import('@/views/Error/404.vue'),
meta: {
hidden: true,
title: '404',
noTagsView: true
}
}
]
@@ -120,6 +149,15 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: {
title: '类型管理'
}
},
{
path: 'media-transcode',
name: 'media-transcode',
component: () => import('@/views/systemManagement/TranscodeSettingsView.vue'),
meta: {
title: 'router.transcodeSettings',
permission: ['episode:upload']
}
}
]
},
@@ -202,6 +240,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: {
title: '操作日志'
}
},
{
path: 'legal-documents',
name: 'legal-documents',
component: () => import('@/views/systemManagement/LegalDocumentView.vue'),
meta: {
title: '协议设置'
}
}
]
}
@@ -215,7 +261,14 @@ const router = createRouter({
})
export const resetRouter = (): void => {
const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root']
const resetWhiteNameList = [
'Redirect',
'RedirectParent',
'Login',
'NoFind',
'Root',
'UnresolvedPath'
]
router.getRoutes().forEach((route) => {
const { name } = route
if (name && !resetWhiteNameList.includes(name as string)) {
+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
}
+7 -5
View File
@@ -47,7 +47,7 @@ export const useAppStore = defineStore('app', {
breadcrumb: true, // 面包屑
breadcrumbIcon: true, // 面包屑图标
collapse: false, // 折叠菜单
uniqueOpened: false, // 是否只保持一个子菜单的展开
uniqueOpened: true, // 是否只保持一个子菜单的展开(菜单手风琴)
hamburger: true, // 折叠图标
screenfull: true, // 全屏图标
size: true, // 尺寸图标
@@ -71,9 +71,9 @@ export const useAppStore = defineStore('app', {
// 左侧菜单边框颜色
leftMenuBorderColor: 'inherit',
// 左侧菜单背景颜色
leftMenuBgColor: '#001529',
leftMenuBgColor: '#fff',
// 左侧菜单浅色背景颜色
leftMenuBgLightColor: '#0f2438',
leftMenuBgLightColor: '#fff',
// 左侧菜单选中背景颜色
leftMenuBgActiveColor: 'var(--app-primary-strong)',
// 左侧菜单收起选中背景颜色
@@ -346,8 +346,10 @@ export const useAppStore = defineStore('app', {
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)
// 头部主题默认强制为白色、菜单主题也强制为白色
// 避免老用户浏览器残留其他主题色,清缓存也能恢复到项目品牌配色
context.store.setHeaderTheme('#fff')
context.store.setMenuTheme('#fff')
}
}
})
+3 -13
View File
@@ -87,8 +87,8 @@ export const useUserStore = defineStore('user', {
this.setToken('')
this.setUserInfo(undefined)
this.setRoleRouters([])
this.setLoginInfo(undefined)
this.setRememberMe(false)
// 注意:不能清 loginInfo / rememberMe —— 那是「记住我」存的账号密码,
// 退出登录后回到登录页还要靠它来回填,清了就等于「记住我」没生效。
tagsViewStore.delAllViews()
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
permissionStore.clearRoutes()
@@ -104,17 +104,7 @@ export const useUserStore = defineStore('user', {
this.loginInfo = loginInfo
}
},
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
)
}
}
}
persist: true
})
export const useUserStoreWithOut = () => {
+3 -1
View File
@@ -18,7 +18,8 @@
}
.app-user-toolbar .app-user-status,
.app-user-toolbar .app-user-country {
.app-user-toolbar .app-user-country,
.app-user-toolbar .app-user-platform {
width: 150px;
flex: 0 0 150px;
}
@@ -63,6 +64,7 @@
.app-user-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
+13
View File
@@ -188,6 +188,19 @@
gap: 10px;
}
.creator-cover-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.creator-cover-hint {
margin: 0;
color: #9a97a8;
font-size: 12px;
line-height: 1.4;
}
.creator-cover-preview {
display: grid;
place-content: center;
+269 -15
View File
@@ -147,6 +147,37 @@
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-block + .cover-block {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.cover-label {
margin: 0 0 8px;
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 600;
}
.cover-editor {
display: flex;
gap: 18px;
@@ -165,6 +196,12 @@
background: var(--el-fill-color-lighter);
}
.cover-preview-card--landscape {
flex: 0 0 160px;
width: 160px;
height: 90px;
}
.cover-preview-card img {
position: absolute;
inset: 0;
@@ -279,6 +316,10 @@
flex-basis: 128px;
}
.cover-preview-card--landscape {
flex-basis: 160px;
}
.cover-editor-meta {
width: 100%;
}
@@ -289,26 +330,239 @@ html.dark .cover-preview-empty {
background: var(--el-fill-color-light);
}
/* 编辑弹窗里的视频预览 */
.video-field {
/* 剧集编辑弹窗 */
.episode-editor-dialog {
display: flex;
gap: 12px;
align-items: flex-start;
width: 100%;
max-height: calc(100vh - 40px);
overflow: hidden;
flex-direction: column;
border-radius: var(--app-radius-lg) !important;
}
.video-field-actions {
.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;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.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;
}
.form-video-preview {
display: block;
width: 200px;
height: 112px;
flex: 0 0 200px;
object-fit: contain;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--app-radius-md);
background: #000 !important;
.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);
}
/* 剧集预览弹窗里的播放器 */
+6 -3
View File
@@ -560,13 +560,16 @@ body {
gap: 6px;
}
.drama-select {
.drama-select,
.status-select {
width: 100%;
}
.drama-select .el-input__wrapper {
.drama-select .el-input__wrapper,
.status-select .el-input__wrapper {
box-shadow: 0 0 0 1px #efedf3 inset !important;
}
.drama-select .el-input__inner {
.drama-select .el-input__inner,
.status-select .el-input__inner {
font-size: 13px;
}
+9 -7
View File
@@ -6,23 +6,25 @@
--left-menu-min-width: 64px;
--left-menu-bg-color: #17110f;
--left-menu-bg-color: #fff;
--left-menu-bg-light-color: #221815;
--left-menu-bg-light-color: #fff;
--left-menu-bg-active-color: var(--app-primary-strong);
--left-menu-bg-active-color: rgba(196, 50, 42, 0.1);
--left-menu-text-color: #d7cbc4;
--left-menu-text-color: #333;
--left-menu-text-active-color: #fff;
--left-menu-text-active-color: var(--el-color-primary);
--left-menu-collapse-bg-active-color: var(--app-primary-strong);
--left-menu-border-color: #eee;
--left-menu-collapse-bg-active-color: rgba(196, 50, 42, 0.1);
/* left menu end */
/* logo start */
--logo-height: 50px;
--logo-title-text-color: #fff;
--logo-title-text-color: #303133;
/* logo end */
/* header start */
+154
View File
@@ -0,0 +1,154 @@
// Author: fruit.com.cn
/**
* 上传前的图片压缩:只做「超出目标尺寸才等比缩小 + 同格式高质量重编码」,
* 尺寸没超、体积也不大时原样返回,避免无意义的二次编码损失。
*
* 说明:
* - 保留原格式(JPEG→JPEG / PNG→PNG / WebP→WebP),避免 PNG 透明底被抹掉;
* - GIF / SVG 直接跳过,动图与矢量图重编码会丢帧或失真;
* - 缩放用浏览器原生解码 + canvas,配合 EXIF 方向校正,避免竖拍图躺倒。
*/
export interface ImageCompressOptions {
/** 目标最大宽(像素)。 */
maxWidth: number
/** 目标最大高(像素)。 */
maxHeight: number
/** 重编码质量,默认 0.92,肉眼看不出差异。 */
quality?: number
/** 原图小于该体积且尺寸已达标时直接原样上传,默认 300KB。 */
skipBelowBytes?: number
}
const PASSTHROUGH_TYPES = ['image/gif', 'image/svg+xml']
/** 可压缩的位图格式;其余类型原样返回。 */
export function canCompressImage(file: File): boolean {
return file.type.startsWith('image/') && !PASSTHROUGH_TYPES.includes(file.type)
}
/** 按比例算出目标尺寸;不需要缩放时返回 null。 */
export function fitWithin(
width: number,
height: number,
maxWidth: number,
maxHeight: number
): { width: number; height: number } | null {
if (width <= 0 || height <= 0) return null
const scale = Math.min(maxWidth / width, maxHeight / height, 1)
if (scale >= 1) return null
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
}
}
export async function compressImage(file: File, options: ImageCompressOptions): Promise<File> {
if (!canCompressImage(file)) return file
const quality = options.quality ?? 0.92
const skipBelowBytes = options.skipBelowBytes ?? 300 * 1024
const bitmap = await decode(file)
if (!bitmap) return file
const { width, height } = bitmap
const target = fitWithin(width, height, options.maxWidth, options.maxHeight)
// 尺寸已经在目标范围内、体积也不大:直接用原图,一点不损失。
if (!target && file.size <= skipBelowBytes) {
close(bitmap)
return file
}
const canvas = document.createElement('canvas')
canvas.width = target?.width ?? width
canvas.height = target?.height ?? height
const context = canvas.getContext('2d')
if (!context) {
close(bitmap)
return file
}
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(bitmap.source, 0, 0, canvas.width, canvas.height)
close(bitmap)
const mimeType = outputMime(file.type)
const blob = await toBlob(canvas, mimeType, quality)
if (!blob || blob.size >= file.size) return file
return new File([blob], renameWithMime(file.name, mimeType), {
type: mimeType,
lastModified: file.lastModified
})
}
function outputMime(type: string): string {
if (type === 'image/png' || type === 'image/webp') return type
return 'image/jpeg'
}
function renameWithMime(name: string, mimeType: string): string {
const ext = mimeType === 'image/png' ? 'png' : mimeType === 'image/webp' ? 'webp' : 'jpg'
const base = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name
return `${base}.${ext}`
}
interface DecodedBitmap {
source: CanvasImageSource
width: number
height: number
release?: () => void
}
async function decode(file: File): Promise<DecodedBitmap | null> {
if (typeof createImageBitmap === 'function') {
try {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
release: () => bitmap.close()
}
} catch {
// 部分浏览器不支持 imageOrientation 选项,退回 <img> 解码。
}
}
const url = URL.createObjectURL(file)
try {
const image = await loadImage(url)
return {
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
release: () => URL.revokeObjectURL(url)
}
} catch {
URL.revokeObjectURL(url)
return null
}
}
function close(bitmap: DecodedBitmap): void {
bitmap.release?.()
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('image decode failed'))
image.src = url
})
}
function toBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality: number
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), mimeType, quality)
})
}
+51
View File
@@ -0,0 +1,51 @@
// Author: fruit.com.cn
const SEEN_KEY = 'transcode-notice-seen'
export const TRANSCODE_NOTICE_SEEN = 'transcode-notice-seen'
export interface NoticeCounts {
running: number
success: number
failed: number
}
/** 上次打开通知页时已经看过的数量。 */
export function readSeen(): NoticeCounts {
try {
const raw = localStorage.getItem(SEEN_KEY)
if (!raw) return { running: 0, success: 0, failed: 0 }
const data = JSON.parse(raw) as NoticeCounts
return {
running: Number(data.running || 0),
success: Number(data.success || 0),
failed: Number(data.failed || 0)
}
} catch {
return { running: 0, success: 0, failed: 0 }
}
}
/** 比上次查看多出来的数量,看过的不再计入角标。 */
export function unreadCounts(current: NoticeCounts): NoticeCounts {
const seen = readSeen()
return {
running: Math.max(0, current.running - seen.running),
success: Math.max(0, current.success - seen.success),
failed: Math.max(0, current.failed - seen.failed)
}
}
/** 把当前数量记为已读,并通知顶栏收起角标。 */
export function markTranscodeNoticeSeen(current: NoticeCounts) {
const seen = readSeen()
localStorage.setItem(
SEEN_KEY,
JSON.stringify({
running: Math.max(seen.running, current.running),
success: Math.max(seen.success, current.success),
failed: Math.max(seen.failed, current.failed)
})
)
window.dispatchEvent(new Event(TRANSCODE_NOTICE_SEEN))
}
+8 -6
View File
@@ -32,8 +32,9 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
bizType
})
const info = initRes.data
const chunkSize = Number(info.chunkSize)
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)
let finished = 0
@@ -41,8 +42,8 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * info.chunkSize
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size))
const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(info.uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
@@ -68,7 +69,8 @@ export async function resumeUpload(
): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options
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 pending: number[] = []
for (let index = received; index < total; index += 1) {
@@ -81,8 +83,8 @@ export async function resumeUpload(
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * progress.chunkSize
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size))
const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
+146 -11
View File
@@ -31,6 +31,94 @@ const themeChange = () => {
<img src="@/assets/imgs/brand-mark.png" alt="" />
<span>{{ underlineToHump(appStore.getTitle) }}</span>
</div>
<div class="hero-fruits" aria-hidden="true">
<svg class="fruit cherry" viewBox="0 0 120 140">
<path
d="M62 38c8-16 24-24 36-22"
fill="none"
stroke="#7d9a62"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M58 40c2-18-8-30-6-36"
fill="none"
stroke="#6d8a55"
stroke-width="3"
stroke-linecap="round"
/>
<ellipse
cx="34"
cy="28"
rx="14"
ry="7"
fill="#8fb56f"
transform="rotate(-28 34 28)"
/>
<circle cx="42" cy="78" r="28" fill="#c4322a" />
<circle cx="78" cy="86" r="26" fill="#a82822" />
<circle cx="32" cy="68" r="8" fill="#fff" opacity=".28" />
</svg>
<svg class="fruit mango" viewBox="0 0 150 170">
<path d="M78 28c18 2 28 8 34 16-22 4-36 2-48-8 4-4 8-8 14-8z" fill="#6d8a55" />
<path
d="M86 36c6-16 2-24-2-28"
fill="none"
stroke="#5f7a4c"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M46 58c-18 28-16 62 8 84 28 24 62 10 74-16 14-30 4-62-16-80-18-16-46-10-66 12z"
fill="#f0b429"
/>
<path
d="M58 78c8 22 10 40 4 58"
fill="none"
stroke="#ffe08a"
stroke-width="8"
stroke-linecap="round"
opacity=".55"
/>
<ellipse
cx="62"
cy="86"
rx="14"
ry="22"
fill="#fff"
opacity=".2"
transform="rotate(-24 62 86)"
/>
</svg>
<svg class="fruit banana" viewBox="0 0 160 150">
<path
d="M28 108c18-46 48-74 92-86 8 10 10 22 4 34-28 10-50 28-66 58-6 12-16 16-30 12-8-2-12-10 0-18z"
fill="#f2d34b"
/>
<path
d="M36 102c16-38 42-62 80-74"
fill="none"
stroke="#ffe98a"
stroke-width="7"
stroke-linecap="round"
opacity=".7"
/>
<path
d="M118 24c8 2 14 10 12 16"
fill="none"
stroke="#6d8a55"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M30 112c-6 8-4 16 4 16"
fill="none"
stroke="#c9a227"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
</div>
<div class="hero-copy">
<p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
<h1>{{ t('login.welcome') }}</h1>
@@ -81,16 +169,58 @@ const themeChange = () => {
flex-direction: column;
justify-content: space-between;
&::before,
&::after {
position: absolute;
right: -80px;
bottom: -120px;
width: 360px;
height: 360px;
border: 1px solid rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 50%;
content: '';
}
&::before {
top: -140px;
left: -80px;
width: 420px;
height: 420px;
}
&::after {
right: -120px;
bottom: -160px;
width: 460px;
height: 460px;
}
}
.hero-fruits {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.fruit {
position: absolute;
filter: drop-shadow(0 18px 24px rgb(0 0 0 / 28%));
}
.cherry {
top: 16%;
right: 14%;
width: 150px;
}
.mango {
top: 30%;
left: 38%;
width: 210px;
}
.banana {
top: 54%;
left: 12%;
width: 200px;
transform: rotate(-8deg);
}
.hero-brand {
@@ -113,13 +243,14 @@ const themeChange = () => {
.hero-copy {
position: relative;
z-index: 1;
max-width: 460px;
max-width: 420px;
h1 {
margin: 10px 0 12px;
font-size: 40px;
font-weight: 700;
line-height: 1.2;
margin: 12px 0 0;
font-size: 44px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.03em;
color: #fff;
}
}
@@ -191,8 +322,12 @@ const themeChange = () => {
padding: 16px 24px 20px;
}
.hero-fruits {
display: none;
}
.hero-copy h1 {
margin: 6px 0 8px;
margin: 6px 0 0;
font-size: 28px;
}
+11 -5
View File
@@ -4,7 +4,7 @@
import { reactive, ref, watch, onMounted, unref } from 'vue'
import { Form, FormSchema } from '@/components/Form'
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 { loginApi, getAdminRoleApi } from '@/api/login'
import { useAppStore } from '@/store/modules/app'
@@ -92,7 +92,11 @@ const schema = reactive<FormSchema[]>([
<>
<div class="flex justify-between items-center w-[100%]">
<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')}
</ElLink>
</div>
@@ -128,7 +132,7 @@ const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo
if (loginInfo) {
setValues({ username: loginInfo.username })
setValues({ username: loginInfo.username, password: loginInfo.password })
}
}
onMounted(() => {
@@ -165,10 +169,12 @@ const signIn = async () => {
const res = await loginApi(formData)
if (res) {
// 是否记住我
// 是否记住我:勾选时把账号密码一起持久化到 localStorage,
// 下次打开登录页自动回填,免重复输入。
if (unref(remember)) {
userStore.setLoginInfo({
username: formData.username
username: formData.username,
password: formData.password
})
} else {
userStore.setLoginInfo(undefined)
+93 -66
View File
@@ -5,6 +5,8 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama'
import { getDashboardOverviewApi, getDashboardTrendApi } from '@/api/admin/dashboard'
import DashboardMetricWall from '@/views/dashboard/DashboardMetricWall.vue'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status'
@@ -15,6 +17,8 @@ const { can } = useRbac()
const loading = ref(false)
const dramas = ref([])
const total = ref(0)
const overview = ref(null)
const trend = ref(null)
const totalViews = computed(() =>
dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0)
@@ -25,6 +29,12 @@ const reviewingCount = computed(
const publishedCount = computed(
() => dramas.value.filter((item) => item.status === 'published').length
)
const metricFallback = computed(() => ({
'content.dramaTotal': total.value,
'content.dramaReviewing': reviewingCount.value,
'content.dramaPublished': publishedCount.value,
'playback.viewTotal': totalViews.value
}))
const recentDramas = computed(() =>
[...dramas.value]
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
@@ -34,10 +44,16 @@ const recentDramas = computed(() =>
onMounted(async () => {
loading.value = true
try {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
const [res, board, trendRes] = await Promise.all([
pageDramasApi({ pageIndex: 1, pageSize: 200 }),
getDashboardOverviewApi().catch(() => null),
getDashboardTrendApi().catch(() => null)
])
// 接口失败时 axios 拦截器会把业务错误吞成 undefined,这里不能让页面跟着崩
dramas.value = res?.data?.list || []
total.value = res?.data?.total || 0
overview.value = board?.data || null
trend.value = trendRes?.data || null
} finally {
loading.value = false
}
@@ -45,72 +61,83 @@ onMounted(async () => {
</script>
<template>
<div class="page-heading"
><div><div class="eyebrow">内容概览</div><h1>工作台</h1><p>查看短剧内容与近期处理事项。</p></div
><el-button
v-if="can('drama:create')"
type="primary"
class="primary-button"
@click="router.push({ name: 'dramas' })"
><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div
>
<div v-loading="loading" class="stats-grid">
<div class="stat-card"
><div class="stat-top"
><span>短剧总数</span
><span class="stat-icon"><AppIcon :icon="AppIcons.content" /></span></div
><div class="stat-value">{{ total }} <small>部</small></div></div
>
<div class="stat-card"
><div class="stat-top"
><span>累计播放量</span
><span class="stat-icon"><AppIcon :icon="AppIcons.videoPlay" /></span></div
><div class="stat-value">{{ formatCount(totalViews) }}</div></div
>
<div class="stat-card"
><div class="stat-top"
><span>待审核内容</span
><span class="stat-icon"><AppIcon :icon="AppIcons.clock" /></span></div
><div class="stat-value">{{ reviewingCount }} <small>项</small></div></div
>
<div class="stat-card"
><div class="stat-top"
><span>已上线</span
><span class="stat-icon"><AppIcon :icon="AppIcons.checkCircle" /></span></div
><div class="stat-value">{{ publishedCount }} <small>部</small></div></div
>
</div>
<section class="panel recent-panel"
><div class="panel-heading"
><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div
><el-button text class="view-all" @click="router.push({ name: 'dramas' })"
>短剧管理</el-button
<div class="workbench">
<div class="page-heading"
><div
><div class="eyebrow">内容概览</div><h1>工作台</h1
><p>用户、设备、留存与短剧内容总览。</p></div
><el-button
v-if="can('drama:create')"
type="primary"
class="primary-button"
@click="router.push({ name: 'dramas' })"
><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div
>
<el-table
<DashboardMetricWall
v-loading="loading"
:data="recentDramas"
row-key="id"
empty-text="暂无短剧数据"
class="dashboard-el-table"
><el-table-column prop="title" label="短剧名称" min-width="280" /><el-table-column
prop="genre"
label="类型"
width="150" /><el-table-column label="集数" width="120"
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column label="播放量" width="140"
><template #default="scope">{{ formatCount(scope?.row?.views) }}</template></el-table-column
><el-table-column label="状态" width="130"
><template #default="scope"
><el-tag
v-if="scope?.row"
:class="['status-badge', statusClass(scope.row.status)]"
effect="light"
>{{ scope.row.statusText }}</el-tag
></template
></el-table-column
><el-table-column prop="updatedAt" label="更新时间" width="180"
/></el-table>
</section>
:overview="overview"
:trend="trend"
:fallback="metricFallback"
/>
<section class="panel recent-panel"
><div class="panel-heading"
><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div
><el-button text class="view-all" @click="router.push({ name: 'dramas' })"
>短剧管理</el-button
></div
>
<el-table
v-loading="loading"
:data="recentDramas"
row-key="id"
empty-text="暂无短剧数据"
class="dashboard-el-table"
><el-table-column prop="title" label="短剧名称" min-width="280" /><el-table-column
prop="genre"
label="类型"
width="150" /><el-table-column label="集数" width="120"
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column label="播放量" width="140"
><template #default="scope">{{
formatCount(scope?.row?.views)
}}</template></el-table-column
><el-table-column label="状态" width="130"
><template #default="scope"
><el-tag
v-if="scope?.row"
:class="['status-badge', statusClass(scope.row.status)]"
effect="light"
>{{ scope.row.statusText }}</el-tag
></template
></el-table-column
><el-table-column prop="updatedAt" label="更新时间" width="180"
/></el-table>
</section>
</div>
</template>
<style scoped>
.workbench {
display: flex;
flex-direction: column;
min-width: 0;
}
.workbench .page-heading {
flex: none;
margin-bottom: 12px;
}
.workbench :deep(.screen) {
flex: 1;
min-height: 0;
}
.recent-panel {
flex: none;
max-height: 220px;
margin-top: 12px;
overflow: auto;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<!-- Author: fruit.com.cn -->
<!-- 转码通知页:按转码中、成功、失败查看短剧和集数。 -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import {
getTranscodeNoticesApi,
type TranscodeNotice,
type TranscodeNoticeItem
} from '@/api/admin/transcodeNotice'
import { formatDateTime } from '@/utils/format'
import { markTranscodeNoticeSeen } from '@/utils/transcodeNoticeSeen'
const loading = ref(false)
const tab = ref('RUNNING')
const notice = ref<TranscodeNotice | null>(null)
const rows = computed<TranscodeNoticeItem[]>(() => {
if (tab.value === 'SUCCESS') return notice.value?.successList || []
if (tab.value === 'FAILED') return notice.value?.failedList || []
return notice.value?.runningList || []
})
function episodeText(row: TranscodeNoticeItem) {
const no = row.episodeNo ? `第${row.episodeNo}集` : ''
if (!row.episodeTitle || row.episodeTitle === no) return no || '集数未知'
return no ? `${no} ${row.episodeTitle}` : row.episodeTitle
}
async function load() {
loading.value = true
try {
const res = await getTranscodeNoticesApi()
notice.value = res.data || null
if (notice.value) {
markTranscodeNoticeSeen({
running: Number(notice.value.running || 0),
success: Number(notice.value.success || 0),
failed: Number(notice.value.failed || 0)
})
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="通知"
title="转码通知"
description="查看转码中、转码成功和转码失败的短剧与集数。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section class="panel notice-panel">
<el-tabs v-model="tab">
<el-tab-pane label="转码中" name="RUNNING" />
<el-tab-pane label="转码成功" name="SUCCESS" />
<el-tab-pane label="转码失败" name="FAILED" />
</el-tabs>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
border
class="drama-el-table"
empty-text="暂无通知"
>
<el-table-column prop="dramaTitle" label="短剧" min-width="220" show-overflow-tooltip />
<el-table-column label="集数" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ episodeText(row) }}</template>
</el-table-column>
<el-table-column label="进度" width="100">
<template #default="{ row }">{{ row.progress ?? 0 }}%</template>
</el-table-column>
<el-table-column
v-if="tab === 'FAILED'"
prop="errorMsg"
label="失败原因"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</el-table-column>
</el-table>
</section>
</template>
<style scoped>
.notice-panel {
padding: 8px 16px 16px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { computed } from 'vue'
/**
* 后台各列表共用的「用户」单元格。
*
* 口径:已登录显示昵称 + `UID xxx`;未登录显示「游客」标签 + `设备 xxx`。
* 两行都带标签,避免一串随机字母看不出是什么。平台由独立的「系统」列展示。
*/
const props = defineProps({
row: { type: Object, required: true }
})
const isGuest = computed(() => props.row?.userId == null)
</script>
<template>
<div class="app-user-meta">
<template v-if="isGuest">
<el-tag type="info" size="small">游客</el-tag>
<span class="app-user-uid app-user-device" :title="row.deviceId || ''">
设备: {{ row.deviceId || '—' }}
</span>
</template>
<template v-else>
<span class="app-user-name" :title="row.nickname || ''">
昵称: {{ row.nickname || '未设置昵称' }}
</span>
<span class="app-user-uid">UID: {{ row.uid || `#${row.userId}` }}</span>
</template>
</div>
</template>
<style scoped>
.app-user-device {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!-- Author: fruit.com.cn -->
<!-- 先勾选语言,再填写对应文案。中文始终保留。 -->
<script setup>
import { CONTENT_LANGUAGES, languageLabel } from '@/constants/contentLanguages'
const props = defineProps({
languages: { type: Array, required: true },
locales: { type: Object, required: true },
label: { type: String, default: '名称' },
maxlength: { type: Number, default: 64 },
multiline: { type: Boolean, default: false },
showLanguages: { type: Boolean, default: true },
required: { type: Boolean, default: true }
})
const emit = defineEmits(['update:languages', 'update:locales'])
function changeLanguages(codes) {
const next = codes.includes('zh') ? codes : ['zh', ...codes]
const locales = { ...props.locales }
next.forEach((code) => {
if (locales[code] == null) locales[code] = ''
})
emit('update:locales', locales)
emit('update:languages', next)
}
function changeLocale(code, value) {
emit('update:locales', { ...props.locales, [code]: value })
}
</script>
<template>
<el-form-item v-if="showLanguages" label="语言" required>
<el-select
:model-value="languages"
multiple
placeholder="选择语言"
style="width: 100%"
@change="changeLanguages"
>
<el-option
v-for="item in CONTENT_LANGUAGES"
:key="item.code"
:label="item.label"
:value="item.code"
:disabled="item.code === 'zh'"
/>
</el-select>
</el-form-item>
<el-form-item
v-for="code in languages"
:key="label + code"
:label="languageLabel(code) + label"
:required="required"
>
<el-input
v-if="!multiline"
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
:maxlength="maxlength"
:placeholder="'请填写' + languageLabel(code) + label"
/>
<el-input
v-else
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
type="textarea"
:rows="2"
:maxlength="maxlength"
show-word-limit
:placeholder="'请填写' + languageLabel(code) + label"
/>
</el-form-item>
</template>
@@ -16,6 +16,7 @@ import {
import { pageDramasApi } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { uploadFile } from '@/utils/uploader'
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -218,12 +219,13 @@ async function uploadImage(options) {
imagePercent.value = 0
imageError.value = ''
try {
const asset = await uploadFile(options.file, {
const file = await compressForPreset(options.file, BANNER_IMAGE)
const asset = await uploadFile(file, {
bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent)
})
form.imageUrl = asset.url
imageFileName.value = options.file.name
imageFileName.value = file.name
await resetValidation()
ElMessage.success('图片已上传')
} catch {
@@ -467,7 +469,9 @@ onMounted(async () => {
{{ imageDisplayName }}
</span>
</div>
<p class="banner-image-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<p class="banner-image-hint"
>{{ BANNER_IMAGE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
>
<el-alert
v-if="imageError"
:title="imageError"
@@ -0,0 +1,614 @@
<!-- Author: fruit.com.cn -->
<!-- 分类管理:运营自定义分类(与「类型」是两个概念),并关联任意多部短剧 -->
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable } from 'vue-draggable-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
bindClassificationDramasApi,
createClassificationApi,
deleteClassificationApi,
listClassificationDramaCandidatesApi,
listClassificationDramasApi,
listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi,
updateClassificationApi
} from '@/api/admin/dramaClassification'
import { statusClass } from '@/utils/status'
import { useRbac } from '@/stores/rbac'
import ClassificationDramaDialogs from './ClassificationDramaDialogs.vue'
import ClassificationSideList from './ClassificationSideList.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const STATUS_TEXT = {
draft: '草稿',
reviewing: '审核中',
approved: '待发布',
published: '已上线',
offline: '已下线'
}
const classifications = ref([])
const activeId = ref(null)
const activeClassification = computed(
() => classifications.value.find((item) => item.id === activeId.value) || null
)
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
const loading = ref(false)
const dramas = ref([])
const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const rankingDirty = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const languages = ref(['zh'])
const emptyForm = () => ({
locales: { zh: '' },
code: '',
layoutType: 'GRID',
sort: 1,
status: 1
})
const form = reactive(emptyForm())
const candidateVisible = ref(false)
const candidateLoading = ref(false)
const candidates = ref([])
const candidateTotal = ref(0)
const candidatePage = ref(1)
const candidatePageSize = ref(10)
const candidateKeyword = ref('')
const selectedDramas = ref([])
const binding = ref(false)
async function loadClassifications() {
const res = await listClassificationsApi()
classifications.value = res.data || []
if (activeId.value && !classifications.value.some((item) => item.id === activeId.value)) {
activeId.value = classifications.value[0]?.id ?? null
}
if (!activeId.value) {
activeId.value = classifications.value[0]?.id ?? null
}
await loadDramas()
}
async function loadDramas() {
if (!activeId.value) {
dramas.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await listClassificationDramasApi(activeId.value, {
keyword: keyword.value || undefined,
pageIndex: pageIndex.value,
pageSize: isRanking.value ? 50 : pageSize.value
})
dramas.value = res.data?.list || []
total.value = res.data?.total || 0
rankingDirty.value = false
} finally {
loading.value = false
}
}
function selectClassification(row) {
if (activeId.value === row.id) return
activeId.value = row.id
pageIndex.value = 1
keyword.value = ''
rankingDirty.value = false
loadDramas()
}
function search() {
pageIndex.value = 1
loadDramas()
}
function changePage(value) {
pageIndex.value = value
loadDramas()
}
function openCreate() {
editingId.value = null
languages.value = ['zh']
const maxSort = classifications.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function saveClassification() {
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = {
name: locales.zh,
locales,
code:
form.code ||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
layoutType: form.layoutType,
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updateClassificationApi(editingId.value, payload)
ElMessage.success('分类已更新')
} else {
const res = await createClassificationApi(payload)
activeId.value = res.data
pageIndex.value = 1
ElMessage.success('分类已创建')
}
dialogVisible.value = false
await loadClassifications()
} finally {
saving.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除分类「${row.name}」吗?关联关系会一起删除,短剧本身不受影响。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
await deleteClassificationApi(row.id)
if (activeId.value === row.id) {
activeId.value = null
}
ElMessage.success('分类已删除')
await loadClassifications()
}
async function removeDrama(row) {
try {
await ElMessageBox.confirm(
`确定把「${row.title}」从「${activeClassification.value?.name}」移出吗?`,
'移除确认',
{ confirmButtonText: '确认移出', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
if (isRanking.value) {
const remaining = dramas.value.filter((item) => item.id !== row.id).map((item) => item.id)
await saveRankingItemsApi(activeId.value, remaining)
} else {
await unbindClassificationDramaApi(activeId.value, row.id)
}
ElMessage.success('已移出')
await Promise.all([loadDramas(), loadClassifications()])
}
function openCandidates() {
candidateVisible.value = true
candidatePage.value = 1
candidateKeyword.value = ''
selectedDramas.value = []
loadCandidates()
}
async function loadCandidates() {
if (!activeId.value) return
candidateLoading.value = true
try {
const res = await listClassificationDramaCandidatesApi(activeId.value, {
keyword: candidateKeyword.value || undefined,
pageIndex: candidatePage.value,
pageSize: candidatePageSize.value
})
candidates.value = res.data?.list || []
candidateTotal.value = res.data?.total || 0
} finally {
candidateLoading.value = false
}
}
function candidateSearch() {
candidatePage.value = 1
loadCandidates()
}
function changeCandidatePage(value) {
candidatePage.value = value
loadCandidates()
}
function onSelectionChange(rows) {
selectedDramas.value = rows
}
async function confirmBind() {
if (!selectedDramas.value.length) {
ElMessage.warning('请先选择要关联的短剧')
return
}
binding.value = true
try {
if (isRanking.value) {
const existingIds = dramas.value.map((item) => item.id)
const selectedIds = selectedDramas.value.map((item) => item.id)
const nextIds = [...new Set([...existingIds, ...selectedIds])]
if (nextIds.length > 50) {
ElMessage.warning('推荐榜最多 50 部短剧')
return
}
await saveRankingItemsApi(activeId.value, nextIds)
ElMessage.success(`推荐榜已增加 ${selectedIds.length} 部短剧`)
} else {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
}
candidateVisible.value = false
await Promise.all([loadDramas(), loadClassifications()])
} finally {
binding.value = false
}
}
function onRankingChange() {
dramas.value = dramas.value.map((item, index) => ({ ...item, position: index + 1 }))
rankingDirty.value = true
}
async function saveRankingOrder() {
if (!activeId.value || !dramas.value.length) return
await saveRankingItemsApi(
activeId.value,
dramas.value.map((item) => item.id)
)
rankingDirty.value = false
ElMessage.success('推荐榜顺序已保存')
await loadClassifications()
}
function formatTime(value) {
if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19)
}
onMounted(loadClassifications)
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="分类管理"
description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
新建分类
</el-button>
</PageHeading>
<div class="classification-layout">
<ClassificationSideList
:classifications="classifications"
:active-id="activeId"
@select="selectClassification"
@edit="openEdit"
@remove="confirmDelete"
/>
<section class="panel classification-layout__main">
<div class="classification-toolbar">
<div class="classification-toolbar__title">
{{ activeClassification?.name || '请选择分类' }}
<small>共 {{ total }} 部关联短剧</small>
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
</div>
<div class="classification-toolbar__search">
<el-input
v-if="!isRanking"
v-model="keyword"
placeholder="搜索短剧名称"
clearable
style="width: 200px"
@keyup.enter="search"
@clear="search"
/>
<el-button v-if="!isRanking" type="primary" plain @click="search">查询</el-button>
<el-button
v-if="isRanking && can('drama:update')"
type="primary"
:disabled="!rankingDirty"
@click="saveRankingOrder"
>
保存排序
</el-button>
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
<AppIcon :icon="AppIcons.add" />
添加短剧
</el-button>
</div>
</div>
<VueDraggable
v-if="isRanking"
v-model="dramas"
item-key="id"
handle=".ranking-row__drag"
class="ranking-list"
@end="onRankingChange"
>
<div v-for="(row, index) in dramas" :key="row.id" class="ranking-row">
<button class="ranking-row__drag" type="button" title="拖动调整排名">
<AppIcon :icon="AppIcons.drag" />
</button>
<span class="ranking-row__rank">{{ index + 1 }}</span>
<img v-if="row.cover" :src="row.cover" class="ranking-row__cover" alt="封面" />
<div v-else class="ranking-row__cover ranking-row__cover--empty">无</div>
<div class="ranking-row__content">
<strong>{{ row.title }}</strong>
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }} · {{ row.totalEpisodes || 0 }} 集
</span>
</div>
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>
移出
</el-button>
</div>
<el-empty v-if="!dramas.length" description="推荐榜还没有短剧" />
</VueDraggable>
<el-table
v-else
v-loading="loading"
:data="dramas"
row-key="id"
border
class="drama-el-table"
empty-text="该分类下还没有关联短剧"
>
<el-table-column label="封面" width="88">
<template #default="{ row }">
<img v-if="row.cover" :src="row.cover" class="classification-cover" alt="封面" />
<div v-else class="classification-cover classification-cover--empty">无</div>
</template>
</el-table-column>
<el-table-column prop="title" label="短剧名称" min-width="200" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
<el-table-column label="更新时间" width="180">
<template #default="{ row }">{{ formatTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>移出</el-button
>
</template>
</el-table-column>
</el-table>
<div v-if="!isRanking" class="classification-pagination">
<el-pagination
:current-page="pageIndex"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="changePage"
/>
</div>
</section>
</div>
<ClassificationDramaDialogs
v-model:editor-visible="dialogVisible"
v-model:languages="languages"
v-model:candidate-visible="candidateVisible"
v-model:candidate-keyword="candidateKeyword"
:editing-id="editingId"
:form="form"
@update:form="Object.assign(form, $event)"
:saving="saving"
:candidate-loading="candidateLoading"
:candidates="candidates"
:candidate-page="candidatePage"
:candidate-page-size="candidatePageSize"
:candidate-total="candidateTotal"
:binding="binding"
:selected-count="selectedDramas.length"
@save="saveClassification"
@search="candidateSearch"
@page="changeCandidatePage"
@selection="onSelectionChange"
@confirm="confirmBind"
/>
</template>
<style scoped>
.classification-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.classification-layout__main {
padding: 16px;
}
.classification-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.classification-toolbar__title {
font-size: 16px;
font-weight: 600;
}
.classification-toolbar__title small {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.classification-toolbar__search {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.classification-cover {
width: 48px;
height: 64px;
object-fit: cover;
border-radius: 4px;
}
.ranking-list {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--el-border-color);
}
.ranking-row {
display: grid;
grid-template-columns: 36px 48px 52px minmax(0, 1fr) auto;
min-height: 72px;
padding: 8px 12px;
background: var(--el-bg-color);
align-items: center;
gap: 12px;
}
.ranking-row + .ranking-row {
border-top: 1px solid var(--el-border-color-lighter);
}
.ranking-row__drag {
display: inline-flex;
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: grab;
background: transparent;
border: 0;
align-items: center;
justify-content: center;
}
.ranking-row__rank {
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
text-align: center;
}
.ranking-row__cover {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 4px;
}
.ranking-row__cover--empty {
display: flex;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
align-items: center;
justify-content: center;
}
.ranking-row__content {
display: grid;
min-width: 0;
gap: 6px;
}
.ranking-row__content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-cover--empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
.classification-pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
@@ -4,6 +4,12 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
createDramaCategoryApi,
deleteDramaCategoryApi,
@@ -11,6 +17,7 @@ import {
updateDramaCategoryApi
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -20,7 +27,8 @@ const list = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', sort: 1, status: 1 })
const languages = ref(['zh'])
const form = reactive({ locales: { zh: '' }, sort: 1, status: 1 })
async function load() {
loading.value = true
@@ -38,21 +46,29 @@ function nextSort() {
function openCreate() {
editingId.value = null
Object.assign(form, { name: '', sort: nextSort(), status: 1 })
languages.value = ['zh']
Object.assign(form, { locales: { zh: '' }, sort: nextSort(), status: 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, { locales, sort: row.sort ?? 0, status: row.status ?? 1 })
dialogVisible.value = true
}
async function saveCategory() {
if (!form.name.trim()) return ElMessage.warning('请填写类型名称')
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
const payload = { name: locales.zh, locales, sort: form.sort, status: form.status }
if (editingId.value) {
await updateDramaCategoryApi(editingId.value, payload)
ElMessage.success('类型已更新')
@@ -115,7 +131,20 @@ onMounted(load)
empty-text="还没有短剧类型"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="类型名称" min-width="200" />
<el-table-column prop="name" label="类型名称" min-width="160" />
<el-table-column label="语言" min-width="180">
<template #default="{ row }">
<el-tag
v-for="code in languageCodes(
row.locales || localesFromColumns(null, row.name, row.nameEn, row.nameId)
)"
:key="code"
effect="light"
class="locale-tag"
>{{ languageLabel(code) }}</el-tag
>
</template>
</el-table-column>
<el-table-column label="短剧数" width="110">
<template #default="{ row }">{{ row.dramaCount || 0 }} 部</template>
</el-table-column>
@@ -146,11 +175,15 @@ onMounted(load)
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="460px">
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="560px">
<el-form label-position="top">
<el-form-item label="类型名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:都市情感" />
</el-form-item>
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
<div class="form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
@@ -166,3 +199,9 @@ onMounted(load)
</template>
</el-dialog>
</template>
<style scoped>
.locale-tag + .locale-tag {
margin-left: 6px;
}
</style>
@@ -0,0 +1,188 @@
<!-- Author: fruit.com.cn -->
<!-- 分类管理弹窗:编辑分类,以及把短剧关联进分类。 -->
<script setup>
import { statusClass } from '@/utils/status'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
const SORT_MAX = 9999
const STATUS_TEXT = {
draft: '草稿',
reviewing: '审核中',
approved: '待发布',
published: '已上线',
offline: '已下线'
}
defineProps({
editorVisible: { type: Boolean, default: false },
editingId: { type: [String, Number], default: null },
languages: { type: Array, default: () => ['zh'] },
form: { type: Object, required: true },
saving: { type: Boolean, default: false },
candidateVisible: { type: Boolean, default: false },
candidateLoading: { type: Boolean, default: false },
candidates: { type: Array, default: () => [] },
candidateKeyword: { type: String, default: '' },
candidatePage: { type: Number, default: 1 },
candidatePageSize: { type: Number, default: 10 },
candidateTotal: { type: Number, default: 0 },
binding: { type: Boolean, default: false },
selectedCount: { type: Number, default: 0 }
})
const emit = defineEmits([
'update:editorVisible',
'update:languages',
'update:form',
'update:candidateVisible',
'update:candidateKeyword',
'save',
'search',
'page',
'selection',
'confirm'
])
</script>
<template>
<el-dialog
:model-value="editorVisible"
:title="editingId ? '编辑分类' : '新建分类'"
width="560px"
@update:model-value="emit('update:editorVisible', $event)"
>
<el-form label-position="top">
<LocaleTextFields
:languages="languages"
:locales="form.locales"
label="名称"
:maxlength="64"
@update:languages="emit('update:languages', $event)"
@update:locales="emit('update:form', { ...form, locales: $event })"
/>
<el-form-item label="展示样式">
<el-radio-group
:model-value="form.layoutType"
@update:model-value="emit('update:form', { ...form, layoutType: $event })"
>
<el-radio-button label="GRID">普通分类</el-radio-button>
<el-radio-button label="RANKING">推荐榜</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="分类编码">
<el-input
:model-value="form.code"
maxlength="64"
placeholder="例如 RECOMMEND_RANKING"
@update:model-value="emit('update:form', { ...form, code: $event.toUpperCase() })"
/>
</el-form-item>
<div class="classification-form-row">
<el-form-item label="排序">
<el-input-number
:model-value="form.sort"
:min="0"
:max="SORT_MAX"
controls-position="right"
@update:model-value="emit('update:form', { ...form, sort: $event })"
/>
</el-form-item>
<el-form-item label="状态">
<el-switch
:model-value="form.status"
:active-value="1"
:inactive-value="0"
@update:model-value="emit('update:form', { ...form, status: $event })"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="emit('update:editorVisible', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('save')">保存</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="candidateVisible"
title="添加关联短剧"
width="760px"
@update:model-value="emit('update:candidateVisible', $event)"
>
<div class="classification-toolbar__search">
<el-input
:model-value="candidateKeyword"
placeholder="搜索短剧名称"
clearable
style="width: 220px"
@update:model-value="emit('update:candidateKeyword', $event)"
@keyup.enter="emit('search')"
@clear="emit('search')"
/>
<el-button type="primary" plain @click="emit('search')">查询</el-button>
</div>
<el-table
v-loading="candidateLoading"
:data="candidates"
row-key="id"
border
max-height="380"
empty-text="没有可关联的短剧"
@selection-change="emit('selection', $event)"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="短剧名称" min-width="220" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
</el-table>
<div class="classification-pagination">
<el-pagination
:current-page="candidatePage"
:page-size="candidatePageSize"
:total="candidateTotal"
layout="total, prev, pager, next"
@current-change="emit('page', $event)"
/>
</div>
<template #footer>
<el-button @click="emit('update:candidateVisible', false)">取消</el-button>
<el-button type="primary" :loading="binding" @click="emit('confirm')">
确定关联{{ selectedCount ? `(${selectedCount})` : '' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.classification-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.classification-toolbar__search {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.classification-pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
@@ -0,0 +1,143 @@
<!-- Author: fruit.com.cn -->
<!-- 分类管理左侧列表:切换、编辑、删除。 -->
<script setup>
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import { useRbac } from '@/stores/rbac'
defineProps({
classifications: { type: Array, default: () => [] },
activeId: { type: [String, Number], default: null }
})
const emit = defineEmits(['select', 'edit', 'remove'])
const { can } = useRbac()
</script>
<template>
<aside class="panel classification-layout__side">
<div class="classification-layout__side-title">全部分类</div>
<el-scrollbar height="560px">
<ul class="classification-list">
<li
v-for="item in classifications"
:key="item.id"
class="classification-list__item"
:class="{ 'is-active': item.id === activeId }"
@click="emit('select', item)"
>
<div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span>
<el-tag v-if="item.layoutType === 'RANKING'" size="small" type="warning">
推荐榜
</el-tag>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div>
<div class="classification-list__actions" @click.stop>
<el-button
v-if="can('drama:update')"
text
circle
size="small"
@click="emit('edit', item)"
>
<AppIcon :icon="AppIcons.edit" :size="15" />
</el-button>
<el-button
v-if="can('drama:delete')"
text
circle
size="small"
@click="emit('remove', item)"
>
<AppIcon :icon="AppIcons.delete" :size="15" />
</el-button>
</div>
</li>
<li v-if="!classifications.length" class="classification-list__empty">
还没有分类,点右上角「新建分类」开始
</li>
</ul>
</el-scrollbar>
</aside>
</template>
<style scoped>
.classification-layout__side {
padding: 12px 0 8px;
}
.classification-layout__side-title {
padding: 0 16px 10px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.classification-list {
padding: 0;
margin: 0;
list-style: none;
}
.classification-list__item {
display: flex;
padding: 8px 12px 8px 16px;
color: var(--el-text-color-regular);
cursor: pointer;
border-left: 3px solid transparent;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.classification-list__item:hover {
background: var(--el-fill-color-light);
}
.classification-list__item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.classification-list__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.classification-list__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-list__count {
min-width: 24px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color);
border-radius: 9px;
}
.classification-list__actions {
display: none;
gap: 2px;
}
.classification-list__item:hover .classification-list__actions {
display: flex;
}
.classification-list__empty {
padding: 16px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>
@@ -5,6 +5,13 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageLabel,
languageCodes,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import { AppImage } from '@/components/AppImage'
import {
changeDramaStatusApi,
@@ -16,8 +23,14 @@ import {
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { listContentTagsApi } from '@/api/admin/contentTag'
import { statusClass } from '@/utils/status'
import { uploadFile } from '@/utils/uploader'
import {
DRAMA_COVER_LANDSCAPE,
DRAMA_COVER_PORTRAIT,
compressForPreset
} from '@/views/contentManagement/upload/imagePresets'
const router = useRouter()
const { can, hasAny } = useRbac()
@@ -29,8 +42,8 @@ const categories = ref([])
const keyword = ref('')
const statusFilter = ref(undefined)
const categoryId = ref(undefined)
const hotFilter = ref(undefined)
const categoryIds = ref([])
const tags = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
@@ -40,29 +53,43 @@ const uploadingCover = ref(false)
const coverPercent = ref(0)
const coverError = ref('')
const coverFileName = ref('')
const uploadingHorizontal = ref(false)
const horizontalPercent = ref(0)
const horizontalError = ref('')
const horizontalFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const editingLikeCount = ref(0)
const formRef = ref()
const textLanguages = ref(['zh'])
const form = reactive({
title: '',
categoryId: undefined,
titleLocales: { zh: '' },
categoryIds: [],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
cover: ''
sort: 0,
cover: '',
horizontalCover: ''
})
const MAX_COVER_SIZE = 5 * 1024 * 1024
const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择短剧类型', trigger: 'change' }]
}
const coverDisplayName = computed(
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面')
)
const horizontalDisplayName = computed(
() =>
horizontalFileName.value ||
(form.horizontalCover ? coverBasename(form.horizontalCover) : '尚未选择横版封面')
)
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
@@ -94,8 +121,7 @@ async function load() {
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined,
categoryId: categoryId.value,
isHot: hotFilter.value
categoryIds: categoryIds.value.length ? categoryIds.value : undefined
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -109,6 +135,11 @@ async function loadCategories() {
categories.value = res.data || []
}
async function loadTags() {
const res = await listContentTagsApi(true)
tags.value = res.data || []
}
function search() {
pageIndex.value = 1
load()
@@ -117,8 +148,7 @@ function search() {
function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
categoryId.value = undefined
hotFilter.value = undefined
categoryIds.value = []
search()
}
@@ -127,63 +157,134 @@ function handleSizeChange() {
load()
}
function selectedCategoryIds(row) {
const values =
Array.isArray(row.categoryIds) && row.categoryIds.length
? row.categoryIds
: row.categoryId
? [row.categoryId]
: []
return values.filter((id) => Number(id) > 0)
}
function coverBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
/** 长整型计数字段在接口里是字符串,数字输入框只接受 number。 */
function toCount(value) {
const count = Number(value)
return Number.isFinite(count) ? count : 0
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function changeTextLanguages(codes) {
textLanguages.value = codes
codes.forEach((code) => {
if (form.synopsisLocales[code] == null) form.synopsisLocales[code] = ''
})
}
function openCreate() {
editingId.value = null
textLanguages.value = ['zh']
Object.assign(form, {
title: '',
categoryId: undefined,
titleLocales: { zh: '' },
categoryIds: [],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
cover: ''
sort: 0,
cover: '',
horizontalCover: ''
})
coverFileName.value = ''
coverError.value = ''
horizontalFileName.value = ''
horizontalError.value = ''
editingCommentCount.value = 0
editingLikeCount.value = 0
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
editingId.value = row.id
const titleLocales = localesFromColumns(row.titleLocales, row.title)
const synopsisLocales = localesFromColumns(row.synopsisLocales, row.description)
textLanguages.value = languageCodes(titleLocales)
textLanguages.value.forEach((code) => {
if (synopsisLocales[code] == null) synopsisLocales[code] = ''
})
Object.assign(form, {
title: row.title,
categoryId: row.categoryId || undefined,
titleLocales,
categoryIds: selectedCategoryIds(row),
tagId: Number(row.tagId) > 0 ? row.tagId : null,
description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3,
unlockCoin: row.unlockCoin ?? 0,
synopsisLocales,
freeEpisodes: toCount(row.freeEpisodes ?? 3),
unlockCoin: toCount(row.unlockCoin),
favoriteCount: toCount(row.favoriteCount),
heat: toCount(row.views),
isHot: !!row.isHot,
cover: row.cover || ''
sort: toCount(row.sort),
cover: row.cover || '',
horizontalCover: row.horizontalCover || ''
})
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
horizontalError.value = ''
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
dialogVisible.value = true
resetValidation()
}
function rowCategoryList(row) {
if (Array.isArray(row.categoryNames) && row.categoryNames.length) {
return row.categoryNames.filter(Boolean)
}
if (!row.genre) return []
return row.genre
.split('/')
.map((name) => name.trim())
.filter(Boolean)
}
async function saveDrama() {
if (saving.value || uploadingCover.value) return
try {
await formRef.value?.validate()
} catch {
if (saving.value || uploadingCover.value || uploadingHorizontal.value) return
const titles = payloadLocales(form.titleLocales, textLanguages.value)
const missing = textLanguages.value.find((code) => !titles[code])
if (!titles.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请输入短剧名称')
return
}
const category = categories.value.find((item) => item.id === form.categoryId)
const synopses = payloadLocales(form.synopsisLocales, textLanguages.value)
const categoryNames = form.categoryIds
.map((id) => categories.value.find((item) => item.id === id)?.name)
.filter(Boolean)
const payload = {
...form,
title: form.title.trim(),
genre: category?.name || ''
title: titles.zh,
titleLocales: titles,
description: synopses.zh || '',
synopsisLocales: synopses,
genre: categoryNames.join(' / '),
tagId: form.tagId || 0
}
saving.value = true
try {
@@ -202,32 +303,41 @@ async function saveDrama() {
}
}
function beforeCoverUpload(file) {
coverError.value = ''
function validateCoverFile(file, errorRef, label) {
errorRef.value = ''
if (!COVER_TYPES.includes(file.type)) {
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
errorRef.value = `${label}仅支持 JPG、PNG 或 WebP 格式`
return false
}
if (file.size > MAX_COVER_SIZE) {
coverError.value = '封面大小不能超过 5MB'
errorRef.value = `${label}大小不能超过 5MB`
return false
}
return true
}
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
function beforeCoverUpload(file) {
return validateCoverFile(file, coverError, '竖版封面')
}
function beforeHorizontalUpload(file) {
return validateCoverFile(file, horizontalError, '横版封面')
}
/** 封面走分片上传(图片只有一个分片),上传前按展示规格等比压缩,成功后回填地址。 */
async function uploadCover(options) {
uploadingCover.value = true
coverPercent.value = 0
coverError.value = ''
try {
const asset = await uploadFile(options.file, {
const file = await compressForPreset(options.file, DRAMA_COVER_PORTRAIT)
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent)
})
form.cover = asset.url
coverFileName.value = options.file.name
ElMessage.success('封面已上传')
coverFileName.value = file.name
ElMessage.success('竖版封面已上传')
} catch {
coverError.value = '封面上传失败,请检查网络后重试'
} finally {
@@ -235,6 +345,26 @@ async function uploadCover(options) {
}
}
async function uploadHorizontalCover(options) {
uploadingHorizontal.value = true
horizontalPercent.value = 0
horizontalError.value = ''
try {
const file = await compressForPreset(options.file, DRAMA_COVER_LANDSCAPE)
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (horizontalPercent.value = percent)
})
form.horizontalCover = asset.url
horizontalFileName.value = file.name
ElMessage.success('横版封面已上传')
} catch {
horizontalError.value = '横版封面上传失败,请检查网络后重试'
} finally {
uploadingHorizontal.value = false
}
}
function removeCover() {
if (uploadingCover.value) return
form.cover = ''
@@ -242,8 +372,15 @@ function removeCover() {
coverError.value = ''
}
function removeHorizontalCover() {
if (uploadingHorizontal.value) return
form.horizontalCover = ''
horizontalFileName.value = ''
horizontalError.value = ''
}
function beforeDialogClose(done) {
if (saving.value || uploadingCover.value) {
if (saving.value || uploadingCover.value || uploadingHorizontal.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
return
}
@@ -307,7 +444,7 @@ function reviewActions(row) {
}
onMounted(async () => {
await loadCategories()
await Promise.all([loadCategories(), loadTags()])
await load()
})
</script>
@@ -338,16 +475,18 @@ onMounted(async () => {
:key="item.code"
:label="item.text"
:value="item.code" /></el-select
><el-select v-model="categoryId" clearable placeholder="全部类型" @change="search"
><el-select
v-model="categoryIds"
class="drama-multi-select"
multiple
clearable
placeholder="全部类型"
@change="search"
><el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id" /></el-select
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
><el-option label="仅热门" :value="true" /><el-option
label="非热门"
:value="false" /></el-select
><el-button @click="resetFilters">重置</el-button></div
><span class="table-result-count">共 {{ total }} 部短剧</span></div
>
@@ -382,13 +521,42 @@ onMounted(async () => {
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column prop="genre" label="类型" width="110" />
<el-table-column label="类型" min-width="200"
><template #default="{ row }"
><div v-if="rowCategoryList(row).length" class="tag-wrap"
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
name
}}</el-tag></div
><span v-else class="table-empty-text">—</span></template
>
</el-table-column>
<el-table-column label="标签" min-width="120"
><template #default="{ row }"
><span v-if="row.tagName">{{ row.tagName }}</span
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column label="集数" width="90"
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
>
<el-table-column label="播放量" width="110"
<el-table-column label="热度" width="110"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
>
<el-table-column label="收藏" width="90"
><template #default="{ row }">{{
formatCount(row.favoriteCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="点赞" width="90"
><template #default="{ row }">{{
formatCount(row.likeCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="评论" width="90"
><template #default="{ row }">{{
formatCount(row.commentCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="状态" width="110"
><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
@@ -396,12 +564,6 @@ onMounted(async () => {
}}</el-tag></template
></el-table-column
>
<el-table-column label="热门" width="90"
><template #default="{ row }"
><el-tag v-if="row.isHot" type="danger" effect="light">热门</el-tag
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="审核" width="140"
><template #default="{ row }"
@@ -459,26 +621,173 @@ onMounted(async () => {
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑短剧' : '新建短剧'"
width="680px"
width="760px"
class="drama-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="drama-editor-form"
>
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
<section class="editor-section">
<h3 class="editor-section-title">封面</h3>
<div class="cover-block">
<p class="cover-label">竖版封面</p>
<div class="cover-editor">
<div class="cover-preview-card">
<img v-if="form.cover" :src="form.cover" alt="短剧竖版封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无封面</span>
</div>
<div v-if="uploadingCover" class="cover-upload-mask">
<span>上传中</span>
<b>{{ coverPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
{{ form.cover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
</div>
<p class="cover-hint"
>{{ DRAMA_COVER_PORTRAIT.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
>
<el-alert
v-if="coverError"
:title="coverError"
type="error"
:closable="false"
show-icon
/>
<div class="cover-actions">
<el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeCoverUpload"
:http-request="uploadCover"
:disabled="uploadingCover"
>
<el-button :loading="uploadingCover" :disabled="uploadingCover">
<AppIcon :icon="AppIcons.upload" />
{{ form.cover ? '更换封面' : '选择封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.cover"
type="danger"
:disabled="uploadingCover"
@click="removeCover"
>
移除
</el-button>
<a
v-if="form.cover"
class="cover-original-link"
:href="form.cover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</div>
<div class="cover-block">
<p class="cover-label">横版封面</p>
<div class="cover-editor">
<div class="cover-preview-card cover-preview-card--landscape">
<img v-if="form.horizontalCover" :src="form.horizontalCover" alt="短剧横版封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无横版封面</span>
</div>
<div v-if="uploadingHorizontal" class="cover-upload-mask">
<span>上传中</span>
<b>{{ horizontalPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.horizontalCover ? 'success' : 'info'" effect="light">
{{ form.horizontalCover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="horizontalDisplayName">{{
horizontalDisplayName
}}</span>
</div>
<p class="cover-hint">
{{ DRAMA_COVER_LANDSCAPE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。
</p>
<el-alert
v-if="horizontalError"
:title="horizontalError"
type="error"
:closable="false"
show-icon
/>
<div class="cover-actions">
<el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeHorizontalUpload"
:http-request="uploadHorizontalCover"
:disabled="uploadingHorizontal"
>
<el-button :loading="uploadingHorizontal" :disabled="uploadingHorizontal">
<AppIcon :icon="AppIcons.upload" />
{{ form.horizontalCover ? '更换横版封面' : '选择横版封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.horizontalCover"
type="danger"
:disabled="uploadingHorizontal"
@click="removeHorizontalCover"
>
移除
</el-button>
<a
v-if="form.horizontalCover"
class="cover-original-link"
:href="form.horizontalCover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">基础信息</h3>
<LocaleTextFields
:languages="textLanguages"
:locales="form.titleLocales"
@update:locales="form.titleLocales = $event"
label="名称"
:maxlength="80"
@update:languages="changeTextLanguages"
/>
<div class="editor-grid editor-grid--two">
<el-form-item label="短剧名称" prop="title">
<el-input v-model="form.title" maxlength="80" placeholder="请输入短剧名称" />
</el-form-item>
<el-form-item label="类型" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="请选择类型">
<el-form-item label="类型">
<el-select
v-model="form.categoryIds"
class="drama-multi-select"
multiple
filterable
clearable
placeholder="可不选"
>
<el-option
v-for="item in categories"
:key="item.id"
@@ -487,17 +796,22 @@ onMounted(async () => {
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagId" clearable filterable placeholder="可不选">
<el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="简要介绍剧情"
/>
</el-form-item>
<LocaleTextFields
:languages="textLanguages"
:locales="form.synopsisLocales"
@update:locales="form.synopsisLocales = $event"
label="简介"
:maxlength="1000"
multiline
:show-languages="false"
:required="false"
/>
</section>
<section class="editor-section">
@@ -521,75 +835,49 @@ onMounted(async () => {
:max="9999"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="form.sort"
class="editor-number"
controls-position="right"
:min="0"
:max="99999"
:precision="0"
/>
<p class="readonly-stat-hint">数值越大越靠前,相同权重按 ID 倒序</p>
</el-form-item>
</div>
<el-form-item label="是否热门">
<el-switch v-model="form.isHot" />
</el-form-item>
</section>
<section class="editor-section">
<h3 class="editor-section-title">封面</h3>
<div class="cover-editor">
<div class="cover-preview-card">
<img v-if="form.cover" :src="form.cover" alt="短剧封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无封面</span>
</div>
<div v-if="uploadingCover" class="cover-upload-mask">
<span>上传中</span>
<b>{{ coverPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
{{ form.cover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
</div>
<p class="cover-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<el-alert
v-if="coverError"
:title="coverError"
type="error"
:closable="false"
show-icon
<h3 class="editor-section-title">互动数据</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="收藏数">
<el-input-number
v-model="form.favoriteCount"
class="editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
<div class="cover-actions">
<el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeCoverUpload"
:http-request="uploadCover"
:disabled="uploadingCover"
>
<el-button :loading="uploadingCover" :disabled="uploadingCover">
<AppIcon :icon="AppIcons.upload" />
{{ form.cover ? '更换封面' : '选择封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.cover"
type="danger"
:disabled="uploadingCover"
@click="removeCover"
>
移除
</el-button>
<a
v-if="form.cover"
class="cover-original-link"
:href="form.cover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</el-form-item>
<el-form-item label="点赞数">
<div class="readonly-stat">{{ formatCount(editingLikeCount) }}</div>
<p class="readonly-stat-hint">全剧分集汇总,只读</p>
</el-form-item>
<el-form-item label="热度">
<el-input-number
v-model="form.heat"
class="editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">全剧汇总,实时统计,只读</p>
</el-form-item>
</div>
</section>
</el-form>
@@ -609,3 +897,46 @@ onMounted(async () => {
</template>
</el-dialog>
</template>
<style scoped>
.drama-multi-select {
min-width: 220px;
}
.drama-multi-select :deep(.el-select__wrapper) {
height: auto;
min-height: 32px;
padding-top: 3px;
padding-bottom: 3px;
}
.drama-multi-select :deep(.el-select__selection) {
flex-wrap: wrap;
row-gap: 4px;
}
.drama-editor-form .drama-multi-select {
width: 100%;
min-width: 0;
}
.drama-editor-form :deep(.el-form-item) {
margin-right: 0;
}
.drama-editor-form :deep(.el-form-item__content) {
min-width: 0;
}
.drama-editor-form :deep(.el-input),
.drama-editor-form :deep(.el-textarea),
.drama-editor-form :deep(.el-select) {
width: 100%;
}
.tag-wrap {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>
@@ -0,0 +1,660 @@
<!-- Author: fruit.com.cn -->
<!-- 剧单管理:剧单使用自己的一套分类(与「分类管理」无关),分类下直接关联短剧 -->
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
bindPlaylistCategoryDramasApi,
createPlaylistCategoryApi,
deletePlaylistCategoryApi,
listPlaylistCategoriesApi,
listPlaylistCategoryDramaCandidatesApi,
listPlaylistCategoryDramasApi,
unbindPlaylistCategoryDramaApi,
updatePlaylistCategoryApi
} from '@/api/admin/dramaPlaylistCategory'
import { statusClass } from '@/utils/status'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const STATUS_TEXT = {
draft: '草稿',
reviewing: '审核中',
approved: '待发布',
published: '已上线',
offline: '已下线'
}
const categories = ref([])
const activeId = ref(null)
const activeCategory = computed(
() => categories.value.find((item) => item.id === activeId.value) || null
)
const loading = ref(false)
const dramas = ref([])
const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const languages = ref(['zh'])
const emptyForm = () => ({
locales: { zh: '' },
descriptionLocales: { zh: '' },
sort: 1,
status: 1
})
const form = reactive(emptyForm())
const candidateVisible = ref(false)
const candidateLoading = ref(false)
const candidates = ref([])
const candidateTotal = ref(0)
const candidatePage = ref(1)
const candidatePageSize = ref(10)
const candidateKeyword = ref('')
const selectedDramas = ref([])
const binding = ref(false)
async function loadCategories() {
const res = await listPlaylistCategoriesApi()
categories.value = res.data || []
if (activeId.value && !categories.value.some((item) => item.id === activeId.value)) {
activeId.value = categories.value[0]?.id ?? null
}
if (!activeId.value) {
activeId.value = categories.value[0]?.id ?? null
}
await loadDramas()
}
async function loadDramas() {
if (!activeId.value) {
dramas.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await listPlaylistCategoryDramasApi(activeId.value, {
keyword: keyword.value || undefined,
pageIndex: pageIndex.value,
pageSize: pageSize.value
})
dramas.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function selectCategory(row) {
if (activeId.value === row.id) return
activeId.value = row.id
pageIndex.value = 1
keyword.value = ''
loadDramas()
}
function search() {
pageIndex.value = 1
loadDramas()
}
function changePage(value) {
pageIndex.value = value
loadDramas()
}
function changeLanguages(codes) {
languages.value = codes
codes.forEach((code) => {
if (form.descriptionLocales[code] == null) form.descriptionLocales[code] = ''
})
}
function openCreate() {
editingId.value = null
languages.value = ['zh']
const maxSort = categories.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
const descriptionLocales = localesFromColumns(
row.descriptionLocales,
row.description,
row.descriptionEn,
row.descriptionId
)
languages.value.forEach((code) => {
if (descriptionLocales[code] == null) descriptionLocales[code] = ''
})
Object.assign(form, {
locales,
descriptionLocales,
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function saveCategory() {
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = {
name: locales.zh,
locales,
descriptionLocales: payloadLocales(form.descriptionLocales, languages.value),
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updatePlaylistCategoryApi(editingId.value, payload)
ElMessage.success('剧单分类已更新')
} else {
const res = await createPlaylistCategoryApi(payload)
activeId.value = res.data
pageIndex.value = 1
ElMessage.success('剧单分类已创建')
}
dialogVisible.value = false
await loadCategories()
} finally {
saving.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除剧单分类「${row.name}」吗?关联关系会一起删除,短剧本身不受影响。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
await deletePlaylistCategoryApi(row.id)
if (activeId.value === row.id) {
activeId.value = null
}
ElMessage.success('剧单分类已删除')
await loadCategories()
}
async function removeDrama(row) {
try {
await ElMessageBox.confirm(
`确定把「${row.title}」从「${activeCategory.value?.name}」移出吗?`,
'移除确认',
{ confirmButtonText: '确认移出', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
await unbindPlaylistCategoryDramaApi(activeId.value, row.id)
ElMessage.success('已移出')
await Promise.all([loadDramas(), loadCategories()])
}
function openCandidates() {
candidateVisible.value = true
candidatePage.value = 1
candidateKeyword.value = ''
selectedDramas.value = []
loadCandidates()
}
async function loadCandidates() {
if (!activeId.value) return
candidateLoading.value = true
try {
const res = await listPlaylistCategoryDramaCandidatesApi(activeId.value, {
keyword: candidateKeyword.value || undefined,
pageIndex: candidatePage.value,
pageSize: candidatePageSize.value
})
candidates.value = res.data?.list || []
candidateTotal.value = res.data?.total || 0
} finally {
candidateLoading.value = false
}
}
function candidateSearch() {
candidatePage.value = 1
loadCandidates()
}
function changeCandidatePage(value) {
candidatePage.value = value
loadCandidates()
}
function onSelectionChange(rows) {
selectedDramas.value = rows
}
async function confirmBind() {
if (!selectedDramas.value.length) {
ElMessage.warning('请先选择要加入的短剧')
return
}
binding.value = true
try {
const res = await bindPlaylistCategoryDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已加入 ${res.data ?? selectedDramas.value.length} 部短剧`)
candidateVisible.value = false
await Promise.all([loadDramas(), loadCategories()])
} finally {
binding.value = false
}
}
function formatTime(value) {
if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19)
}
onMounted(loadCategories)
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="剧单管理"
description="剧单使用自己的分类体系(与「分类管理」无关),每个分类下直接选择短剧。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
新建分类
</el-button>
</PageHeading>
<div class="playlist-layout">
<aside class="panel playlist-layout__side">
<div class="playlist-layout__side-title">剧单分类</div>
<el-scrollbar height="560px">
<ul class="playlist-list">
<li
v-for="item in categories"
:key="item.id"
class="playlist-list__item"
:class="{ 'is-active': item.id === activeId }"
@click="selectCategory(item)"
>
<div class="playlist-list__main">
<span class="playlist-list__name">{{ item.name }}</span>
<span class="playlist-list__count">{{ item.dramaCount || 0 }}</span>
</div>
<div class="playlist-list__actions" @click.stop>
<el-button
v-if="can('drama:update')"
text
circle
size="small"
@click="openEdit(item)"
>
<AppIcon :icon="AppIcons.edit" :size="15" />
</el-button>
<el-button
v-if="can('drama:delete')"
text
circle
size="small"
@click="confirmDelete(item)"
>
<AppIcon :icon="AppIcons.delete" :size="15" />
</el-button>
</div>
</li>
<li v-if="!categories.length" class="playlist-list__empty">
还没有剧单分类,点右上角「新建分类」开始
</li>
</ul>
</el-scrollbar>
</aside>
<section class="panel playlist-layout__main">
<div class="playlist-toolbar">
<div class="playlist-toolbar__title">
{{ activeCategory?.name || '请选择剧单分类' }}
<small>共 {{ total }} 部短剧</small>
<small v-if="activeCategory?.description">{{ activeCategory.description }}</small>
</div>
<div class="playlist-toolbar__search">
<el-input
v-model="keyword"
placeholder="搜索短剧名称"
clearable
style="width: 200px"
@keyup.enter="search"
@clear="search"
/>
<el-button type="primary" plain @click="search">查询</el-button>
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
<AppIcon :icon="AppIcons.add" />
选择短剧
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="dramas"
row-key="id"
border
class="drama-el-table"
empty-text="该剧单分类下还没有短剧"
>
<el-table-column label="封面" width="88">
<template #default="{ row }">
<img v-if="row.cover" :src="row.cover" class="playlist-cover" alt="封面" />
<div v-else class="playlist-cover playlist-cover--empty">无</div>
</template>
</el-table-column>
<el-table-column prop="title" label="短剧名称" min-width="200" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
<el-table-column label="更新时间" width="180">
<template #default="{ row }">{{ formatTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>移出</el-button
>
</template>
</el-table-column>
</el-table>
<div class="playlist-pagination">
<el-pagination
:current-page="pageIndex"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="changePage"
/>
</div>
</section>
</div>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑剧单分类' : '新建剧单分类'"
width="640px"
>
<el-form label-position="top">
<LocaleTextFields
:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
@update:languages="changeLanguages"
/>
<LocaleTextFields
:languages="languages"
:locales="form.descriptionLocales"
@update:locales="form.descriptionLocales = $event"
label="描述"
:maxlength="255"
multiline
:show-languages="false"
:required="false"
/>
<div class="playlist-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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveCategory">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="candidateVisible" title="选择短剧" width="760px">
<div class="playlist-toolbar__search" style="margin-bottom: 12px">
<el-input
v-model="candidateKeyword"
placeholder="搜索短剧名称"
clearable
style="width: 220px"
@keyup.enter="candidateSearch"
@clear="candidateSearch"
/>
<el-button type="primary" plain @click="candidateSearch">查询</el-button>
</div>
<el-table
v-loading="candidateLoading"
:data="candidates"
row-key="id"
border
max-height="380"
empty-text="没有可选择的短剧"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="短剧名称" min-width="220" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
</el-table>
<div class="playlist-pagination">
<el-pagination
:current-page="candidatePage"
:page-size="candidatePageSize"
:total="candidateTotal"
layout="total, prev, pager, next"
@current-change="changeCandidatePage"
/>
</div>
<template #footer>
<el-button @click="candidateVisible = false">取消</el-button>
<el-button type="primary" :loading="binding" @click="confirmBind">
确定添加{{ selectedDramas.length ? `(${selectedDramas.length})` : '' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.playlist-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.playlist-layout__side {
padding: 12px 0 8px;
}
.playlist-layout__side-title {
padding: 0 16px 10px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.playlist-list {
padding: 0;
margin: 0;
list-style: none;
}
.playlist-list__item {
display: flex;
padding: 8px 12px 8px 16px;
color: var(--el-text-color-regular);
cursor: pointer;
border-left: 3px solid transparent;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.playlist-list__item:hover {
background: var(--el-fill-color-light);
}
.playlist-list__item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.playlist-list__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.playlist-list__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-list__count {
min-width: 24px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color);
border-radius: 9px;
}
.playlist-list__actions {
display: none;
gap: 2px;
}
.playlist-list__item:hover .playlist-list__actions {
display: flex;
}
.playlist-list__empty {
padding: 16px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.playlist-layout__main {
padding: 16px;
}
.playlist-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.playlist-toolbar__title {
font-size: 16px;
font-weight: 600;
}
.playlist-toolbar__title small {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.playlist-toolbar__search {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.playlist-cover {
width: 48px;
height: 64px;
object-fit: cover;
border-radius: 4px;
}
.playlist-cover--empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
.playlist-pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.playlist-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
</style>
@@ -1,56 +1,56 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama'
import {
createEpisodeApi,
deleteEpisodeApi,
listEpisodesApi,
sortEpisodesApi,
updateEpisodeApi
parseEpisodePage,
sortEpisodesApi
} from '@/api/admin/episode'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
import { useI18n } from '@/hooks/web/useI18n'
import BatchUploadDialog from './upload/BatchUploadDialog.vue'
import EpisodeFormDialog from './upload/EpisodeFormDialog.vue'
import { listPendingSessions } from './upload/uploadSessions'
/** 排序/批量建集需要全集快照,单次请求不超过接口 pageSize 上限。 */
const SNAPSHOT_PAGE_SIZE = 200
const DEFAULT_PAGE_SIZE = 100
const PAGE_SIZE_OPTIONS = [50, 100, 200]
const route = useRoute()
const { can } = useRbac()
const { t } = useI18n()
const loading = ref(false)
const dramas = ref([])
const episodes = ref([])
const total = ref(0)
const selectedDramaId = ref(undefined)
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const statusFilter = ref(undefined)
const pageIndex = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const batchUploadVisible = ref(false)
const previewVisible = ref(false)
const previewEpisode = ref(null)
const editingId = ref(null)
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const formDialogRef = ref()
/** 上移/下移与批量建集用的全集快照。 */
const snapshotEpisodes = ref([])
const batchNextNumber = computed(() => nextNumber())
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
const existingEpisodeNumbers = computed(() =>
snapshotEpisodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
)
const showEmpty = computed(() => !loading.value && episodes.value.length === 0)
async function loadDramas() {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
@@ -67,95 +67,89 @@ async function loadDramas() {
async function loadEpisodes() {
if (!selectedDramaId.value) {
episodes.value = []
total.value = 0
snapshotEpisodes.value = []
return
}
loading.value = true
try {
const res = await listEpisodesApi(selectedDramaId.value)
episodes.value = res.data || []
const res = await listEpisodesApi({
dramaId: selectedDramaId.value,
status: statusFilter.value || undefined,
pageIndex: pageIndex.value,
pageSize: pageSize.value
})
// 按 OpenAPI PageResult{total,list} 解包,禁止再把 data 当数组切片
const page = parseEpisodePage(res.data)
episodes.value = page.list
total.value = page.total
await clampOrRetreatPage()
} finally {
loading.value = false
}
}
function nextNumber() {
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
/** 拉当前剧全集快照,供排序与批量建集集号计算。不过滤 status,按页取完。 */
async function refreshSnapshot() {
if (!selectedDramaId.value) {
snapshotEpisodes.value = []
return
}
const collected = []
let page = 1
let totalCount = 0
do {
const res = await listEpisodesApi({
dramaId: selectedDramaId.value,
pageIndex: page,
pageSize: SNAPSHOT_PAGE_SIZE
})
const parsed = parseEpisodePage(res.data)
totalCount = parsed.total
collected.push(...parsed.list)
if (!parsed.list.length) break
page += 1
} while (collected.length < totalCount)
snapshotEpisodes.value = collected
}
function openCreate() {
editingId.value = null
Object.assign(form, {
number: nextNumber(),
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: selectedDrama.value?.unlockCoin ?? 0,
status: 'draft'
})
dialogVisible.value = true
/**
* 删除后若当前页空且仍有数据,回退上一页再拉;否则夹页。
*/
async function clampOrRetreatPage() {
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1)
if (episodes.value.length === 0 && total.value > 0 && pageIndex.value > 1) {
pageIndex.value = Math.min(pageIndex.value - 1, maxPage)
await loadEpisodes()
return
}
if (pageIndex.value > maxPage) {
pageIndex.value = maxPage
await loadEpisodes()
}
}
function nextNumber() {
return snapshotEpisodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
}
async function openBatchUpload() {
if (!selectedDramaId.value) return
await refreshSnapshot()
batchUploadVisible.value = true
}
async function onBatchEpisodesCreated() {
await loadEpisodes()
}
async function openCreate() {
await refreshSnapshot()
formDialogRef.value?.openCreate()
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
number: row.number,
title: row.title,
durationSec: row.durationSec || 0,
videoUrl: row.videoUrl || '',
isFree: !!row.isFree,
unlockCoin: row.unlockCoin || 0,
status: row.status
})
dialogVisible.value = true
}
async function saveEpisode() {
if (!form.title.trim()) return ElMessage.warning('请填写剧集标题')
if (!form.number || form.number < 1) return ElMessage.warning('请填写正确的集号')
const payload = {
dramaId: selectedDramaId.value,
number: form.number,
title: form.title.trim(),
durationSec: form.durationSec || 0,
videoUrl: form.videoUrl.trim(),
isFree: form.isFree,
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateEpisodeApi(editingId.value, payload)
ElMessage.success('剧集已更新')
} else {
await createEpisodeApi(payload)
ElMessage.success('剧集已创建')
}
dialogVisible.value = false
await loadEpisodes()
} finally {
saving.value = false
}
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
form.videoUrl = asset.url
if (duration > 0) form.durationSec = duration
ElMessage.success('视频已上传')
} finally {
uploadingVideo.value = false
}
formDialogRef.value?.openEdit(row)
}
async function confirmDelete(row) {
@@ -171,6 +165,9 @@ async function confirmDelete(row) {
}
await deleteEpisodeApi(row.id)
ElMessage.success('剧集已删除')
if (episodes.value.length === 1 && pageIndex.value > 1) {
pageIndex.value -= 1
}
await loadEpisodes()
}
@@ -199,11 +196,32 @@ function stopThumb(event) {
}
}
/** 上移/下移后把新顺序整表提交,由后端重排 sort。 */
async function move(index, offset) {
function pageLocalIndex(row) {
return episodes.value.findIndex((item) => item.id === row.id)
}
function globalIndex(row) {
const local = pageLocalIndex(row)
if (local < 0) return -1
return (pageIndex.value - 1) * pageSize.value + local
}
function canMoveUp(row) {
return globalIndex(row) > 0
}
function canMoveDown(row) {
const index = globalIndex(row)
return index >= 0 && index < total.value - 1
}
/** 上移/下移后把快照新顺序整表提交,由后端重排 sort。 */
async function move(row, offset) {
await refreshSnapshot()
const index = snapshotEpisodes.value.findIndex((item) => item.id === row.id)
const target = index + offset
if (target < 0 || target >= episodes.value.length) return
const next = [...episodes.value]
if (index < 0 || target < 0 || target >= snapshotEpisodes.value.length) return
const next = [...snapshotEpisodes.value]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
await sortEpisodesApi(next.map((item) => item.id))
@@ -212,12 +230,36 @@ async function move(index, offset) {
}
async function onDramaChange() {
pageIndex.value = 1
await loadEpisodes()
openBatchIfPendingSessions()
}
function onStatusChange() {
pageIndex.value = 1
loadEpisodes()
}
function handleSizeChange() {
pageIndex.value = 1
loadEpisodes()
}
function handlePageChange() {
loadEpisodes()
}
/** 进入页面或切换短剧时,若有 24h 内未完成上传则打开批量上传弹窗以提示续传。 */
function openBatchIfPendingSessions() {
if (!selectedDramaId.value || !can('episode:upload')) return
const pending = listPendingSessions(String(selectedDramaId.value))
if (pending.length) batchUploadVisible.value = true
}
onMounted(async () => {
await loadDramas()
await loadEpisodes()
openBatchIfPendingSessions()
})
</script>
@@ -226,13 +268,17 @@ onMounted(async () => {
><div
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
><el-button
v-if="can('episode:upload')"
type="primary"
class="primary-button"
:disabled="!selectedDramaId"
@click="openCreate"
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
><div class="page-heading-actions">
<el-button v-if="can('episode:upload')" :disabled="!selectedDramaId" @click="openBatchUpload"
><AppIcon :icon="AppIcons.upload" />{{ t('episodeBatch.entry') }}</el-button
><el-button
v-if="can('episode:upload')"
type="primary"
class="primary-button"
:disabled="!selectedDramaId"
@click="openCreate"
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
></div
></div
>
<section class="panel episode-panel">
@@ -241,7 +287,9 @@ onMounted(async () => {
<span class="episode-context-label">当前短剧</span>
<b class="episode-context-title">{{ selectedDrama?.title || '暂无短剧' }}</b>
<small class="episode-context-count"
>共 {{ selectedDrama?.episodes ?? episodes.length }} 集</small
>共 {{ selectedDrama?.episodes ?? total }} 集 · 收藏
{{ formatCount(selectedDrama?.favoriteCount ?? 0) }} · 热度
{{ formatCount(selectedDrama?.views ?? 0) }}</small
>
</div>
<div class="episode-switcher">
@@ -259,23 +307,40 @@ onMounted(async () => {
:value="item.id"
/>
</el-select>
<span class="episode-context-label">状态</span>
<el-select
v-model="statusFilter"
clearable
placeholder="全部状态"
class="status-select"
@change="onStatusChange"
>
<el-option label="草稿" value="draft" />
<el-option label="已上线" value="published" />
<el-option label="已下线" value="offline" />
</el-select>
</div>
</div>
<div class="episode-toolbar"
><div
><h2>剧集列表({{ episodes.length }})</h2><p>按列表顺序播放</p></div
><h2>剧集列表({{ total }})</h2><p>按列表顺序播放</p></div
></div
>
<el-table
v-loading="loading"
:data="episodes"
row-key="id"
empty-text="该短剧暂无剧集"
class="drama-el-table episode-el-table"
>
<template #empty>
<el-empty
v-if="showEmpty"
:description="selectedDramaId ? '该短剧暂无剧集' : '请先选择短剧'"
/>
</template>
<el-table-column label="集数" width="90"
><template #default="{ row }">{{
String(row.number).padStart(2, '0')
String(row.number ?? 0).padStart(2, '0')
}}</template></el-table-column
>
<el-table-column label="剧集名称" min-width="240"
@@ -314,6 +379,24 @@ onMounted(async () => {
<el-table-column label="播放量" width="120"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
>
<el-table-column label="点赞(本集)" width="120"
><template #default="{ row }">{{
formatCount(row.likeCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="评论(本集)" width="120"
><template #default="{ row }">{{
formatCount(row.commentCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="所属剧收藏" width="120"
><template #default="{ row }">{{
formatCount(row.favoriteCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="所属剧热度" width="120"
><template #default="{ row }">{{ formatCount(row.heat ?? 0) }}</template></el-table-column
>
<el-table-column label="状态" width="110"
><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
@@ -322,7 +405,7 @@ onMounted(async () => {
></el-table-column
>
<el-table-column label="操作" width="250" fixed="right"
><template #default="{ row, $index }"
><template #default="{ row }"
><div class="table-actions"
><el-button
v-if="can('episode:upload')"
@@ -333,14 +416,14 @@ onMounted(async () => {
><el-button
v-if="can('episode:sort')"
size="small"
:disabled="$index === 0"
@click="move($index, -1)"
:disabled="!canMoveUp(row)"
@click="move(row, -1)"
>上移</el-button
><el-button
v-if="can('episode:sort')"
size="small"
:disabled="$index === episodes.length - 1"
@click="move($index, 1)"
:disabled="!canMoveDown(row)"
@click="move(row, 1)"
>下移</el-button
><el-button
v-if="can('episode:upload')"
@@ -353,54 +436,25 @@ onMounted(async () => {
></el-table-column
>
</el-table>
<div class="pagination"
><el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZE_OPTIONS"
:total="total"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/></div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑剧集' : '新增剧集'" width="560px"
><el-form label-position="top"
><div class="form-row"
><el-form-item label="集号" required
><el-input-number v-model="form.number" :min="1" :max="999" /></el-form-item
><el-form-item label="时长(秒)"
><el-input-number v-model="form.durationSec" :min="0" :max="9999" /></el-form-item></div
><el-form-item label="剧集标题" required
><el-input v-model="form.title" placeholder="例如:命运的相遇" /></el-form-item
><el-form-item label="视频文件"
><div class="video-field"
><div class="video-field-actions"
><el-upload
drag
:show-file-list="false"
accept="video/*"
:http-request="uploadVideo"
:disabled="uploadingVideo"
><div class="upload-copy"
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV,自动分片上传</small></div
></el-upload
><p v-if="uploadingVideo" class="selected-file">上传中 {{ videoPercent }}%</p
><p v-else-if="form.videoUrl" class="selected-file">视频已上传</p
><el-input v-model="form.videoUrl" placeholder="也可以直接填写视频地址" /></div
><video
v-if="form.videoUrl"
:src="form.videoUrl"
controls
class="form-video-preview"
></video></div></el-form-item
><div class="form-row"
><el-form-item label="是否免费"><el-switch v-model="form.isFree" /></el-form-item
><el-form-item v-if="!form.isFree" label="解锁金币"
><el-input-number v-model="form.unlockCoin" :min="0" :max="9999" /></el-form-item
><el-form-item label="状态"
><el-select v-model="form.status"
><el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code" /></el-select></el-form-item></div
></el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" :loading="saving" @click="saveEpisode">保存</el-button></template
></el-dialog
>
<EpisodeFormDialog
ref="formDialogRef"
:drama-options="selectedDrama"
:next-number="batchNextNumber"
@saved="loadEpisodes"
/>
<el-dialog
v-model="previewVisible"
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
@@ -416,4 +470,15 @@ onMounted(async () => {
></video>
<el-empty v-else description="该剧集还没有上传视频" />
</el-dialog>
<BatchUploadDialog
v-if="selectedDramaId"
v-model="batchUploadVisible"
:drama-id="selectedDramaId"
:next-episode-number="batchNextNumber"
:default-unlock-coin="selectedDrama?.unlockCoin ?? 0"
:free-episodes="selectedDrama?.freeEpisodes ?? 0"
:existing-numbers="existingEpisodeNumbers"
@refreshed="loadEpisodes"
@created="onBatchEpisodesCreated"
/>
</template>
@@ -0,0 +1,264 @@
<!-- Author: fruit.com.cn -->
<!-- 标签管理:名称分中文、英文、印尼语。短剧编辑时单选一个标签。 -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
createContentTagApi,
deleteContentTagApi,
listContentTagsApi,
updateContentTagApi
} from '@/api/admin/contentTag'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const tags = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
const languages = ref(['zh'])
const DEFAULT_TAG_COLOR = '#E5484D'
const emptyForm = () => ({
locales: { zh: '' },
bgColor: DEFAULT_TAG_COLOR,
code: '',
sort: 1,
status: 1
})
const form = reactive(emptyForm())
async function load() {
loading.value = true
try {
const res = await listContentTagsApi()
tags.value = res.data || []
} finally {
loading.value = false
}
}
function nextSort() {
return tags.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
}
function openCreate() {
editingId.value = null
languages.value = ['zh']
Object.assign(form, emptyForm(), { sort: nextSort() })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
bgColor: row.bgColor || DEFAULT_TAG_COLOR,
code: row.code || '',
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function save() {
const locales = payloadLocales(form.locales, languages.value)
if (!locales.zh) {
ElMessage.warning('请填写中文名称')
return
}
const missing = languages.value.find((code) => !locales[code])
if (missing) {
ElMessage.warning(`请填写${languageLabel(missing)}名称`)
return
}
const code = (form.code || '').trim().toUpperCase()
if (code && !/^[A-Z][A-Z0-9_]{0,31}$/.test(code)) {
ElMessage.warning('语义码需为大写字母、数字或下划线,且以字母开头')
return
}
saving.value = true
try {
const payload = {
name: locales.zh,
locales,
bgColor: form.bgColor || DEFAULT_TAG_COLOR,
code,
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updateContentTagApi(editingId.value, payload)
ElMessage.success('标签已更新')
} else {
await createContentTagApi(payload)
ElMessage.success('标签已创建')
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除标签「${row.name}」吗?已选该标签的短剧会取消选择。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await deleteContentTagApi(row.id)
ElMessage.success('标签已删除')
await load()
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="标签管理"
description="维护短剧标签。名称分中文、英文、印尼语,短剧编辑时单选一个。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
新建标签
</el-button>
</PageHeading>
<section class="panel">
<el-table
v-loading="loading"
:data="tags"
row-key="id"
border
class="drama-el-table"
empty-text="还没有标签"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="中文名称" min-width="160" />
<el-table-column label="语言" min-width="220">
<template #default="{ row }">
<el-tag
v-for="code in languageCodes(
row.locales || localesFromColumns(null, row.name, row.nameEn, row.nameId)
)"
:key="code"
effect="light"
class="locale-tag"
>{{ languageLabel(code) }}</el-tag
>
</template>
</el-table-column>
<el-table-column label="背景色" width="100">
<template #default="{ row }">
<span class="tag-color" :style="{ background: row.bgColor || DEFAULT_TAG_COLOR }"></span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.status === 1 ? '启用' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button v-if="can('drama:update')" type="primary" size="small" @click="openEdit(row)"
>编辑</el-button
>
<el-button
v-if="can('drama:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑标签' : '新建标签'" width="560px">
<el-form label-position="top">
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
<el-form-item label="背景色">
<el-color-picker v-model="form.bgColor" color-format="hex" />
</el-form-item>
<el-form-item label="语义码">
<el-input
v-model="form.code"
maxlength="32"
placeholder="如 NEW_ARRIVAL,留空则按名称匹配"
/>
<div class="form-hint">
客户端按语义码判断板块(如「上新」横滑),改名或换语言都不受影响。
</div>
</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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.locale-tag + .locale-tag {
margin-left: 6px;
}
.tag-color {
display: inline-block;
width: 28px;
height: 16px;
border-radius: 4px;
}
.form-hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,363 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集批量上传弹窗:先入队待上传,点「开始上传」后再执行 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import CreateEpisodesDialog from './CreateEpisodesDialog.vue'
import UploadTaskRow from './UploadTaskRow.vue'
import { useUploadQueue } from './useUploadQueue'
import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
import type { UploadSessionRecord } from './uploadSessions'
const props = withDefaults(
defineProps<{
modelValue: boolean
dramaId: string | number
nextEpisodeNumber: number
defaultUnlockCoin: number
existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
refreshed: []
}>()
const { t } = useI18n()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const dramaKey = computed(() => String(props.dramaId || ''))
const fileInputRef = ref<HTMLInputElement | null>(null)
const resumeFileInputRef = ref<HTMLInputElement | null>(null)
const rejectedNames = ref<string[]>([])
const pendingSessions = ref<UploadSessionRecord[]>([])
const createVisible = ref(false)
const resumePromptShown = ref(false)
const queue = useUploadQueue({ getDramaId: () => dramaKey.value })
watch(
() => props.dramaId,
() => {
queue.clear()
resumePromptShown.value = false
}
)
const canCreateEpisodes = computed(() => queue.doneItems.value.length > 0)
const startButtonLabel = computed(() =>
queue.isUploading.value ? t('episodeBatch.uploadingAction') : t('episodeBatch.startUpload')
)
function extensionOf(fileName: string): string {
const parts = String(fileName || '').split('.')
return (parts.pop() || '').toLowerCase()
}
function validateFile(file: File): string {
if (!VIDEO_EXTENSIONS.includes(extensionOf(file.name))) {
return t('episodeBatch.invalidType')
}
if (file.size > MAX_VIDEO_SIZE) {
return t('episodeBatch.oversize')
}
return ''
}
function onPickFiles(event: Event): void {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (!files.length) return
const accepted: File[] = []
const rejected: string[] = []
for (const file of files) {
const reason = validateFile(file)
if (reason) {
rejected.push(`${file.name}: ${reason}`)
} else {
accepted.push(file)
}
}
rejectedNames.value = rejected
if (!accepted.length) return
// 只入队,不发起上传;等用户点「开始上传」
queue.enqueue(accepted)
}
function openFilePicker(): void {
fileInputRef.value?.click()
}
function onStartUpload(): void {
if (!queue.canStart.value) return
queue.start()
}
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!queue.hasActiveUpload.value) return
event.preventDefault()
event.returnValue = ''
}
watch(
() => queue.hasActiveUpload.value,
(active) => {
if (active) {
window.addEventListener('beforeunload', onBeforeUnload)
} else {
window.removeEventListener('beforeunload', onBeforeUnload)
}
}
)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
async function promptResumeIfNeeded(): Promise<void> {
if (resumePromptShown.value || !dramaKey.value) return
const sessions = queue.getPendingSessions()
if (!sessions.length) return
resumePromptShown.value = true
pendingSessions.value = sessions
try {
await ElMessageBox.confirm(
t('episodeBatch.resumeConfirm', { count: sessions.length }),
t('episodeBatch.resumeTitle'),
{
confirmButtonText: t('episodeBatch.resumeYes'),
cancelButtonText: t('episodeBatch.resumeNo'),
type: 'info'
}
)
resumeFileInputRef.value?.click()
} catch {
queue.abandonSessions(sessions)
pendingSessions.value = []
ElMessage.info(t('episodeBatch.resumeAbandoned'))
}
}
async function onResumeFiles(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
const sessions = pendingSessions.value
if (!files.length || !sessions.length) return
const { matched, unmatched } = await queue.resumeFromSessions(sessions, files)
pendingSessions.value = []
if (unmatched.length) {
ElMessage.warning(t('episodeBatch.resumeUnmatched', { count: unmatched.length }))
}
if (matched.length) {
ElMessage.success(t('episodeBatch.resumeMatched', { count: matched.length }))
}
}
function beforeClose(done: () => void): void {
if (!queue.hasActiveUpload.value) {
done()
return
}
ElMessageBox.confirm(t('episodeBatch.closeWhileUploading'), t('common.reminder'), {
type: 'warning',
confirmButtonText: t('common.ok'),
cancelButtonText: t('common.cancel')
})
.then(() => {
queue.pause()
done()
})
.catch(() => undefined)
}
watch(visible, (open) => {
if (open) {
void promptResumeIfNeeded()
}
})
function openCreateDialog(): void {
if (!canCreateEpisodes.value) return
createVisible.value = true
}
function onEpisodesCreated(): void {
createVisible.value = false
emit('created')
visible.value = false
queue.clear()
}
</script>
<template>
<el-dialog
v-model="visible"
:title="t('episodeBatch.title')"
width="720px"
class="batch-upload-dialog"
:close-on-click-modal="false"
:before-close="beforeClose"
>
<input
ref="fileInputRef"
type="file"
multiple
hidden
:accept="VIDEO_ACCEPT_ATTR"
@change="onPickFiles"
/>
<input
ref="resumeFileInputRef"
type="file"
multiple
hidden
:accept="VIDEO_ACCEPT_ATTR"
@change="onResumeFiles"
/>
<div class="batch-upload-toolbar">
<el-button type="primary" @click="openFilePicker">
<AppIcon :icon="AppIcons.upload" />
{{ t('episodeBatch.selectFiles') }}
</el-button>
<el-button
:disabled="!queue.started.value || !queue.items.value.length"
@click="queue.pause()"
>
{{ t('episodeBatch.pause') }}
</el-button>
<el-button :disabled="!queue.canStart.value" @click="queue.start()">
{{ t('episodeBatch.resume') }}
</el-button>
<el-button
:disabled="!queue.items.value.some((item) => item.status === 'failed')"
@click="queue.retryAll()"
>
{{ t('episodeBatch.retryAll') }}
</el-button>
</div>
<p class="batch-upload-hint">{{ t('episodeBatch.hint') }}</p>
<div v-if="rejectedNames.length" class="batch-upload-rejected">
<p v-for="name in rejectedNames" :key="name">{{ name }}</p>
</div>
<div v-if="queue.items.value.length" class="batch-upload-overall">
<span>{{ t('episodeBatch.overallProgress') }}</span>
<el-progress :percentage="queue.overallPercent.value" :stroke-width="12" />
</div>
<p v-if="queue.showResultSummary.value" class="batch-upload-summary">
{{
t('episodeBatch.resultSummary', {
done: queue.doneItems.value.length,
failed: queue.failedCount.value
})
}}
</p>
<div class="batch-upload-list">
<UploadTaskRow
v-for="(item, index) in queue.items.value"
:key="item.id"
:item="item"
:index="index + 1"
:allow-remove="!queue.started.value && item.status === 'waiting'"
@retry="queue.retry"
@remove="queue.remove"
/>
<el-empty v-if="!queue.items.value.length" :description="t('episodeBatch.empty')" />
</div>
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button
type="primary"
:disabled="!queue.canStart.value"
:loading="queue.isUploading.value"
@click="onStartUpload"
>
{{ startButtonLabel }}
</el-button>
<el-button type="success" :disabled="!canCreateEpisodes" @click="openCreateDialog">
{{ t('episodeBatch.createEpisodes') }}
</el-button>
</template>
</el-dialog>
<CreateEpisodesDialog
v-model="createVisible"
:drama-id="dramaId"
:done-items="queue.doneItems.value"
:next-episode-number="nextEpisodeNumber"
:default-unlock-coin="defaultUnlockCoin"
:free-episodes="freeEpisodes"
:existing-numbers="existingNumbers"
@settled="emit('refreshed')"
@created="onEpisodesCreated"
/>
</template>
<style scoped>
.batch-upload-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.batch-upload-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.batch-upload-rejected {
padding: 8px 12px;
margin-bottom: 12px;
font-size: 12px;
color: var(--el-color-danger);
background: color-mix(in srgb, var(--el-color-danger) 8%, transparent);
border-radius: 6px;
}
.batch-upload-rejected p {
margin: 0;
}
.batch-upload-overall {
margin-bottom: 12px;
}
.batch-upload-summary {
padding: 8px 12px;
margin: 0 0 12px;
font-size: 13px;
color: var(--el-text-color-primary);
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
border-radius: 6px;
}
.batch-upload-list {
max-height: 360px;
overflow: auto;
}
</style>
@@ -0,0 +1,263 @@
<!-- Author: fruit.com.cn -->
<!-- 批量创建分集确认清单:标题取文件名,集号自动顺延,失败可重试 -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createEpisodeApi } from '@/api/admin/episode'
import { useI18n } from '@/hooks/web/useI18n'
import type { UploadQueueItem } from './useUploadQueue'
const props = withDefaults(
defineProps<{
modelValue: boolean
dramaId: string | number
doneItems: UploadQueueItem[]
nextEpisodeNumber: number
defaultUnlockCoin: number
existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
settled: []
created: []
}>()
const { t } = useI18n()
interface EpisodeDraft {
key: string
sourceName: string
number: number
title: string
isFree: boolean
videoUrl: string
durationSec: number
saved: boolean
error: string
}
const PUBLISHED_STATUS = 'published'
const rows = ref<EpisodeDraft[]>([])
const submitting = ref(false)
function baseName(fileName: string): string {
const dot = fileName.lastIndexOf('.')
if (dot <= 0) return fileName
return fileName.slice(0, dot)
}
function compareNatural(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
}
/** 从起点起跳过已占用集号,返回下一个可用值。 */
function nextFreeNumber(start: number, used: Set<number>): number {
let candidate = start
while (used.has(candidate)) {
candidate += 1
}
return candidate
}
function buildRows(): void {
const sorted = [...props.doneItems].sort((left, right) =>
compareNatural(baseName(left.fileName), baseName(right.fileName))
)
const used = new Set(props.existingNumbers)
let cursor = props.nextEpisodeNumber
const freeLimit = props.freeEpisodes
rows.value = sorted.map((item) => {
const number = nextFreeNumber(cursor, used)
used.add(number)
cursor = number + 1
const isFree = number <= freeLimit
return {
key: item.id,
sourceName: item.fileName,
number,
title: baseName(item.fileName),
isFree,
videoUrl: item.url || '',
durationSec: item.durationSec || 0,
saved: false,
error: ''
}
})
}
watch(
() => props.modelValue,
(open) => {
if (open) buildRows()
}
)
function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message
return t('episodeBatch.createFailed')
}
async function createOne(row: EpisodeDraft): Promise<void> {
if (!row.videoUrl) {
row.error = t('episodeBatch.missingVideo')
return
}
try {
await createEpisodeApi({
dramaId: props.dramaId as unknown as number,
number: row.number,
title: baseName(row.sourceName),
durationSec: row.durationSec,
videoUrl: row.videoUrl,
isFree: row.isFree,
unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin,
status: PUBLISHED_STATUS
})
row.saved = true
row.error = ''
} catch (error) {
row.error = errorText(error)
}
}
async function submit(): Promise<void> {
if (submitting.value) return
submitting.value = true
try {
for (const row of rows.value) {
if (!row.saved) await createOne(row)
}
} finally {
submitting.value = false
}
emit('settled')
if (rows.value.every((row) => row.saved)) {
ElMessage.success(t('episodeBatch.createAllSuccess'))
emit('created')
return
}
ElMessage.warning(t('episodeBatch.createPartial'))
}
function closeDialog(): void {
emit('update:modelValue', false)
}
function hasFailed(): boolean {
return rows.value.some((row) => !row.saved && !!row.error)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('episodeBatch.createTitle')"
width="760px"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
>
<p class="create-episode-hint">{{ t('episodeBatch.createHint') }}</p>
<div class="create-episode-head">
<span>{{ t('episodeBatch.colSource') }}</span>
<span>{{ t('episodeBatch.colNumber') }}</span>
<span>{{ t('episodeBatch.colTitle') }}</span>
<span>{{ t('episodeBatch.colFree') }}</span>
<span>{{ t('episodeBatch.colResult') }}</span>
</div>
<div class="create-episode-list">
<div
v-for="row in rows"
:key="row.key"
class="create-episode-row"
:class="{ 'is-saved': row.saved }"
>
<b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b>
<span class="create-episode-number">{{ row.number }}</span>
<span class="create-episode-title" :title="row.title">{{ row.title }}</span>
<span class="create-episode-free" :class="row.isFree ? 'is-free' : 'is-paid'">
{{ row.isFree ? t('episodeBatch.labelFree') : t('episodeBatch.labelPaid') }}
</span>
<span class="create-episode-result">
{{ row.saved ? t('episodeBatch.createOk') : row.error }}
</span>
</div>
</div>
<template #footer>
<el-button @click="closeDialog">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ hasFailed() ? t('episodeBatch.retryFailed') : t('episodeBatch.submitCreate') }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.create-episode-hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.create-episode-head,
.create-episode-row {
display: grid;
grid-template-columns: minmax(120px, 1.2fr) 72px minmax(140px, 1.4fr) 56px minmax(80px, 0.8fr);
gap: 8px;
align-items: center;
}
.create-episode-free {
font-size: 12px;
}
.create-episode-free.is-free {
color: var(--el-color-success);
}
.create-episode-free.is-paid {
color: var(--el-color-warning);
}
.create-episode-head {
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.create-episode-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 420px;
overflow: auto;
}
.create-episode-source,
.create-episode-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.create-episode-number {
font-variant-numeric: tabular-nums;
}
.create-episode-result {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.create-episode-row.is-saved .create-episode-result {
color: var(--el-color-success);
}
</style>
@@ -0,0 +1,283 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集新建/编辑弹窗:表单校验、单集视频上传与保存 -->
<script setup>
import { nextTick, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode'
import { formatCount } from '@/utils/format'
import EpisodeTranscodePanel from './EpisodeTranscodePanel.vue'
import EpisodeVideoUploader from './EpisodeVideoUploader.vue'
const props = defineProps({
dramaOptions: { type: Object, default: null },
nextNumber: { type: Number, default: 1 },
row: { type: Object, default: null }
})
const emit = defineEmits(['saved'])
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingVideo = ref(false)
const videoFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const formRef = ref()
const uploaderRef = ref()
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
likeCount: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const formRules = {
number: [{ required: true, message: '请输入集号', trigger: 'change' }],
title: [{ required: true, message: '请输入剧集标题', trigger: 'blur' }],
videoUrl: [
{
validator: (_rule, value, callback) => {
if (form.status === 'published' && !value) {
callback(new Error('上线剧集必须上传视频'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
function episodeBasename(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()
}
function openCreate() {
editingId.value = null
Object.assign(form, {
number: props.nextNumber,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: props.dramaOptions?.unlockCoin ?? 0,
likeCount: 0,
status: 'draft'
})
videoFileName.value = ''
uploaderRef.value?.clearError()
editingCommentCount.value = 0
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
const target = row || props.row
if (!target) return
editingId.value = target.id
Object.assign(form, {
number: target.number,
title: target.title,
durationSec: target.durationSec || 0,
videoUrl: target.videoUrl || '',
isFree: !!target.isFree,
unlockCoin: target.unlockCoin || 0,
likeCount: target.likeCount ?? 0,
status: target.status
})
videoFileName.value = target.videoUrl ? episodeBasename(target.videoUrl) : ''
uploaderRef.value?.clearError()
editingCommentCount.value = target.commentCount ?? 0
dialogVisible.value = true
resetValidation()
}
async function saveEpisode() {
if (saving.value || uploadingVideo.value) return
try {
await formRef.value?.validate()
} catch {
return
}
const dramaId = props.dramaOptions?.id
if (!dramaId) return
const payload = {
dramaId,
number: form.number,
title: form.title.trim(),
durationSec: form.durationSec || 0,
videoUrl: form.videoUrl.trim(),
isFree: form.isFree,
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
likeCount: form.likeCount,
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateEpisodeApi(editingId.value, payload)
ElMessage.success('剧集已更新')
} else {
await createEpisodeApi(payload)
ElMessage.success('剧集已创建')
}
dialogVisible.value = false
emit('saved')
} finally {
saving.value = false
}
}
function beforeDialogClose(done) {
if (saving.value || uploadingVideo.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '视频正在上传,请稍候')
return
}
done()
}
defineExpose({ openCreate, openEdit })
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑剧集' : '新增剧集'"
width="680px"
class="episode-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="episode-editor-form"
>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">基础信息</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="集号" prop="number">
<el-input-number
v-model="form.number"
class="episode-editor-number"
controls-position="right"
:min="1"
:max="999"
/>
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="form.durationSec"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
</div>
<el-form-item label="剧集标题" prop="title">
<el-input v-model="form.title" maxlength="100" placeholder="例如:命运的相遇" />
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">视频文件</h3>
<el-form-item prop="videoUrl">
<EpisodeVideoUploader
ref="uploaderRef"
v-model:video-url="form.videoUrl"
v-model:duration-sec="form.durationSec"
v-model:file-name="videoFileName"
v-model:uploading="uploadingVideo"
@change="resetValidation"
/>
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">互动数据</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="本集点赞数">
<el-input-number
v-model="form.likeCount"
class="episode-editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="本集评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">按集实时统计,不可编辑</p>
</el-form-item>
</div>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">付费与状态</h3>
<div class="episode-editor-grid episode-editor-grid--three">
<el-form-item label="是否免费">
<el-switch v-model="form.isFree" />
</el-form-item>
<el-form-item v-show="!form.isFree" label="解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option
v-for="item in statusOptions"
:key="item.code"
:label="item.text"
:value="item.code"
/>
</el-select>
</el-form-item>
</div>
</section>
</el-form>
<EpisodeTranscodePanel v-if="editingId" :episode-id="editingId" :active="dialogVisible" />
<template #footer>
<el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingVideo"
@click="saveEpisode"
>
保存
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,315 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码逐帧、雪碧图与分页预览 -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type {
MediaThumbnailFrameItem,
MediaThumbnailResult,
MediaThumbnailSpriteInfo
} from '@/api/admin/types'
import { useI18n } from '@/hooks/web/useI18n'
import { formatTimeMs, THUMBNAIL_PAGE_SIZES } from './episodeTranscodeUi'
interface FramesView {
data: MediaThumbnailResult | null
loading: boolean
errorMessage: string
pageIndex: number
pageSize: number
}
const props = defineProps<{
view: FramesView
}>()
const emit = defineEmits<{
pageChange: [value: number]
pageSizeChange: [value: number]
retry: []
}>()
const FRAME_CARD_WIDTH = 128
const PREVIEW_MAX_WIDTH = 520
const PREVIEW_MAX_HEIGHT = 360
const { t } = useI18n()
const frameView = ref<'frames' | 'sprites'>('frames')
const previewVisible = ref(false)
const previewFrame = ref<MediaThumbnailFrameItem | null>(null)
const frames = computed(() => props.view.data?.frames?.list || [])
const sprite = computed(() => props.view.data?.sprite || null)
const spriteUrls = computed(() => sprite.value?.spriteUrls || [])
const framesTotal = computed(() => Number(props.view.data?.frames?.total || 0))
const previewStyle = computed(() => {
const frame = previewFrame.value
const info = sprite.value
if (!frame || !info) return {}
const scale = Math.min(
PREVIEW_MAX_WIDTH / Math.max(1, frame.width),
PREVIEW_MAX_HEIGHT / Math.max(1, frame.height)
)
return frameStyle(frame, info, scale)
})
function openFramePreview(frame: MediaThumbnailFrameItem) {
previewFrame.value = frame
previewVisible.value = true
}
function frameStyle(frame: MediaThumbnailFrameItem, info: MediaThumbnailSpriteInfo, scale: number) {
return {
width: `${Math.round(frame.width * scale)}px`,
height: `${Math.round(frame.height * scale)}px`,
backgroundImage: `url("${frame.spriteUrl || frame.imageUrl}")`,
backgroundPosition: `-${Math.round(frame.x * scale)}px -${Math.round(frame.y * scale)}px`,
backgroundSize: `${Math.round((info.columns || 1) * frame.width * scale)}px ${Math.round(
(info.rows || 1) * frame.height * scale
)}px`
}
}
function frameCardStyle(frame: MediaThumbnailFrameItem) {
if (!sprite.value) return {}
return frameStyle(frame, sprite.value, FRAME_CARD_WIDTH / Math.max(1, frame.width))
}
function spriteFrameCount(index: number) {
const info = sprite.value
if (!info) return 0
const capacity = Math.max(1, info.columns * info.rows)
return Math.max(0, Math.min(capacity, info.totalFrames - index * capacity))
}
</script>
<template>
<div class="episode-transcode-frames__heading">
<h4>{{ t('episodeTranscode.frameTitle') }}</h4>
<div class="episode-transcode-frames__tools">
<span>{{ t('episodeTranscode.frameCount', { total: framesTotal }) }}</span>
<el-radio-group v-model="frameView" size="small">
<el-radio-button label="frames">{{ t('episodeTranscode.frameView') }}</el-radio-button>
<el-radio-button label="sprites">{{ t('episodeTranscode.spriteView') }}</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="view.errorMessage"
type="error"
:title="view.errorMessage"
:closable="false"
show-icon
>
<template #default>
<el-button size="small" text @click="emit('retry')">
{{ t('episodeTranscode.retryLoad') }}
</el-button>
</template>
</el-alert>
<div v-loading="view.loading">
<template v-if="frameView === 'frames'">
<div v-if="frames.length" class="episode-transcode-frames__grid">
<button
v-for="frame in frames"
:key="frame.frameIndex"
class="episode-transcode-frames__card"
type="button"
:aria-label="t('episodeTranscode.previewFrame', { time: formatTimeMs(frame.timeMs) })"
@click="openFramePreview(frame)"
>
<span class="episode-transcode-frames__image" :style="frameCardStyle(frame)"></span>
<span>{{ formatTimeMs(frame.timeMs) }}</span>
</button>
</div>
<el-empty v-else :description="t('episodeTranscode.noFrames')" :image-size="58" />
<el-pagination
v-if="framesTotal"
class="episode-transcode-frames__pagination"
background
layout="sizes, prev, pager, next"
:current-page="view.pageIndex"
:page-size="view.pageSize"
:page-sizes="THUMBNAIL_PAGE_SIZES"
:total="framesTotal"
@current-change="emit('pageChange', $event)"
@size-change="emit('pageSizeChange', $event)"
/>
</template>
<div v-else-if="spriteUrls.length && sprite" class="episode-transcode-frames__sprites">
<figure
v-for="(url, index) in spriteUrls"
:key="url"
class="episode-transcode-frames__sprite"
>
<div
class="episode-transcode-frames__sprite-frame"
:style="{
aspectRatio: `${sprite.columns * sprite.thumbWidth} / ${
sprite.rows * sprite.thumbHeight
}`
}"
>
<el-image
:src="url"
:preview-src-list="spriteUrls"
:initial-index="index"
fit="contain"
preview-teleported
hide-on-click-modal
/>
<span
class="episode-transcode-frames__sprite-grid"
:style="{
backgroundSize: `calc(100% / ${sprite.columns}) calc(100% / ${sprite.rows})`
}"
></span>
</div>
<figcaption>
<b>{{ t('episodeTranscode.spriteSheet', { index: index + 1 }) }}</b>
<span>{{
t('episodeTranscode.spriteSheetFrames', { count: spriteFrameCount(index) })
}}</span>
</figcaption>
</figure>
</div>
<el-empty v-else :description="t('episodeTranscode.noSprites')" :image-size="58" />
</div>
<el-dialog
v-model="previewVisible"
:title="t('episodeTranscode.zoomTitle')"
width="min(92vw, 600px)"
append-to-body
destroy-on-close
>
<div class="episode-transcode-frames__preview-shell">
<div class="episode-transcode-frames__preview-image" :style="previewStyle"></div>
</div>
</el-dialog>
</template>
<style scoped>
.episode-transcode-frames__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 18px 0 10px;
}
.episode-transcode-frames__heading h4 {
margin: 0;
font-size: 13px;
}
.episode-transcode-frames__tools {
display: flex;
font-size: 12px;
color: var(--el-text-color-secondary);
align-items: center;
gap: 12px;
}
.episode-transcode-frames__grid {
display: grid;
justify-content: center;
gap: 10px;
grid-template-columns: repeat(4, 128px);
}
.episode-transcode-frames__card {
display: flex;
min-width: 0;
padding: 0;
font-size: 11px;
color: var(--el-text-color-secondary);
cursor: zoom-in;
background: transparent;
border: 0;
flex-direction: column;
gap: 5px;
}
.episode-transcode-frames__image {
display: block;
overflow: hidden;
background-color: var(--el-fill-color-dark);
background-repeat: no-repeat;
border: 1px solid var(--el-border-color-lighter);
border-radius: 7px;
}
.episode-transcode-frames__card:hover .episode-transcode-frames__image {
border-color: var(--el-color-primary);
}
.episode-transcode-frames__pagination {
margin-top: 14px;
justify-content: flex-end;
}
.episode-transcode-frames__sprites {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.episode-transcode-frames__sprite {
min-width: 0;
margin: 0;
}
.episode-transcode-frames__sprite-frame {
position: relative;
overflow: hidden;
background: var(--el-fill-color-dark);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.episode-transcode-frames__sprite-frame :deep(.el-image) {
display: block;
width: 100%;
height: 100%;
}
.episode-transcode-frames__sprite-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgb(255 255 255 / 34%) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 34%) 1px, transparent 1px);
background-repeat: repeat;
}
.episode-transcode-frames__sprite figcaption {
display: flex;
padding-top: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
justify-content: space-between;
gap: 8px;
}
.episode-transcode-frames__preview-shell {
display: flex;
min-height: 240px;
background: var(--el-fill-color-dark);
border-radius: 8px;
align-items: center;
justify-content: center;
}
.episode-transcode-frames__preview-image {
background-repeat: no-repeat;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
@media (width <= 600px) {
.episode-transcode-frames__grid {
grid-template-columns: repeat(2, 128px);
}
}
</style>
@@ -0,0 +1,266 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码面板:数据获取、轮询与子组件组合 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
getEpisodeTranscodeApi,
getMediaThumbnailsApi,
triggerMediaTranscodeApi
} from '@/api/admin/transcode'
import type { EpisodeTranscodeItem, MediaThumbnailResult } from '@/api/admin/types'
import { useI18n } from '@/hooks/web/useI18n'
import EpisodeTranscodeFrames from './EpisodeTranscodeFrames.vue'
import EpisodeTranscodeRenditions from './EpisodeTranscodeRenditions.vue'
import EpisodeTranscodeStatusBadge from './EpisodeTranscodeStatusBadge.vue'
import EpisodeTranscodeStatusPanel from './EpisodeTranscodeStatusPanel.vue'
import {
safeTranscodeProgress,
THUMBNAIL_PAGE_SIZE,
transcodeProgressStatus,
transcodeStatusKey,
transcodeStatusType
} from './episodeTranscodeUi'
const props = defineProps<{
episodeId: string | number
active: boolean
}>()
const POLL_INTERVAL_MS = 4000
const { t } = useI18n()
const expanded = ref<string[]>(['transcode'])
const detail = ref<EpisodeTranscodeItem | null>(null)
const thumbnails = ref<MediaThumbnailResult | null>(null)
const loading = ref(false)
const thumbnailLoading = ref(false)
const retriggering = ref(false)
const errorMessage = ref('')
const thumbnailError = ref('')
const pageIndex = ref(1)
const pageSize = ref(THUMBNAIL_PAGE_SIZE)
let pollTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
let thumbnailSeq = 0
const mediaAssetId = computed(() => detail.value?.mediaAssetId || '')
const hasMedia = computed(() => /^[1-9]\d*$/.test(mediaAssetId.value))
const normalizedStatus = computed(() => String(detail.value?.status || '').toUpperCase())
const renditions = computed(() => detail.value?.renditions || [])
const isProcessing = computed(
() => normalizedStatus.value === 'QUEUED' || normalizedStatus.value === 'RUNNING'
)
const statusLabel = computed(() =>
t(`episodeTranscode.status.${transcodeStatusKey(detail.value?.status)}`)
)
const statusType = computed(() => transcodeStatusType(detail.value?.status))
const progressStatus = computed(() => transcodeProgressStatus(detail.value?.status))
const progress = computed(() => safeTranscodeProgress(detail.value?.progress))
const showOutputs = computed(() => !!detail.value && hasMedia.value && !errorMessage.value)
const statusView = computed(() => ({
loading: loading.value,
errorMessage: errorMessage.value,
hasMedia: hasMedia.value,
retriggering: retriggering.value,
isProcessing: isProcessing.value,
statusLabel: statusLabel.value,
statusType: statusType.value,
progressStatus: progressStatus.value,
progress: progress.value
}))
const framesView = computed(() => ({
data: thumbnails.value,
loading: thumbnailLoading.value,
errorMessage: thumbnailError.value,
pageIndex: pageIndex.value,
pageSize: pageSize.value
}))
watch(
[() => props.episodeId, () => props.active],
([episodeId, active], [previousEpisodeId]) => {
if (episodeId !== previousEpisodeId) resetState()
if (!active) {
stopPolling()
return
}
if (episodeId) void loadDetail()
},
{ immediate: true }
)
onBeforeUnmount(stopPolling)
function resetState() {
stopPolling()
requestSeq += 1
thumbnailSeq += 1
detail.value = null
thumbnails.value = null
errorMessage.value = ''
thumbnailError.value = ''
pageIndex.value = 1
pageSize.value = THUMBNAIL_PAGE_SIZE
}
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
}
function schedulePolling() {
stopPolling()
if (!props.active || !isProcessing.value) return
pollTimer = setTimeout(() => {
pollTimer = null
void loadDetail(true)
}, POLL_INTERVAL_MS)
}
async function loadDetail(silent = false) {
if (!props.active || !props.episodeId) return
const seq = ++requestSeq
stopPolling()
if (!silent) loading.value = true
errorMessage.value = ''
try {
const response = await getEpisodeTranscodeApi(props.episodeId)
if (seq !== requestSeq) return
const result = response.data
detail.value = result
if (result.thumbnails) {
thumbnails.value = result.thumbnails
pageIndex.value = result.thumbnails.pageIndex || 1
pageSize.value = result.thumbnails.pageSize || THUMBNAIL_PAGE_SIZE
thumbnailError.value = ''
}
if (isProcessing.value) schedulePolling()
} catch (error) {
if (seq !== requestSeq) return
errorMessage.value = errorText(error, t('episodeTranscode.loadFailed'))
stopPolling()
} finally {
if (seq === requestSeq) loading.value = false
}
}
async function loadThumbnailPage() {
if (!hasMedia.value || thumbnailLoading.value) return
const seq = ++thumbnailSeq
thumbnailLoading.value = true
thumbnailError.value = ''
try {
const response = await getMediaThumbnailsApi(
mediaAssetId.value,
pageIndex.value,
pageSize.value
)
if (seq !== thumbnailSeq) return
const result = response.data
thumbnails.value = result
pageIndex.value = result.pageIndex || pageIndex.value
pageSize.value = result.pageSize || pageSize.value
} catch (error) {
if (seq !== thumbnailSeq) return
thumbnailError.value = errorText(error, t('episodeTranscode.loadFailed'))
} finally {
if (seq === thumbnailSeq) thumbnailLoading.value = false
}
}
async function retrigger() {
if (!hasMedia.value || isProcessing.value || retriggering.value) return
retriggering.value = true
errorMessage.value = ''
try {
await triggerMediaTranscodeApi(mediaAssetId.value)
if (detail.value) {
detail.value = { ...detail.value, status: 'QUEUED', progress: 0, errorMsg: '' }
}
ElMessage.success(t('episodeTranscode.triggerSuccess'))
await loadDetail(true)
schedulePolling()
} catch (error) {
errorMessage.value = errorText(error, t('episodeTranscode.triggerFailed'))
} finally {
retriggering.value = false
}
}
function changePage(value: number) {
pageIndex.value = value
void loadThumbnailPage()
}
function changePageSize(value: number) {
pageSize.value = value
pageIndex.value = 1
void loadThumbnailPage()
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback
}
</script>
<template>
<section class="episode-editor-section episode-transcode-panel">
<el-collapse v-model="expanded">
<el-collapse-item name="transcode">
<template #title>
<div class="episode-transcode-panel__heading">
<span>{{ t('episodeTranscode.title') }}</span>
<EpisodeTranscodeStatusBadge
:visible="!!detail"
:status-label="statusLabel"
:status-type="statusType"
:progress="progress"
:progress-status="progressStatus"
:show-progress="!!detail && hasMedia"
/>
</div>
</template>
<EpisodeTranscodeStatusPanel
:detail="detail"
:view="statusView"
@retry="loadDetail()"
@retrigger="retrigger"
/>
<template v-if="showOutputs">
<EpisodeTranscodeRenditions :renditions="renditions" />
<EpisodeTranscodeFrames
:view="framesView"
@page-change="changePage"
@page-size-change="changePageSize"
@retry="loadThumbnailPage"
/>
</template>
</el-collapse-item>
</el-collapse>
</section>
</template>
<style scoped>
.episode-transcode-panel :deep(.el-collapse) {
border: 0;
}
.episode-transcode-panel :deep(.el-collapse-item__header),
.episode-transcode-panel :deep(.el-collapse-item__wrap) {
background: transparent;
}
.episode-transcode-panel__heading {
display: flex;
width: 100%;
padding-right: 12px;
font-size: 15px;
font-weight: 650;
color: var(--el-text-color-primary);
align-items: center;
gap: 10px;
}
</style>
@@ -0,0 +1,106 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码清晰度表格与播放地址操作 -->
<script setup lang="ts">
import { ref } from 'vue'
import type { MediaRenditionItem } from '@/api/admin/types'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import RenditionPlayerDialog from './RenditionPlayerDialog.vue'
import {
formatBytes,
formatDuration,
renditionStatusLabelKey,
renditionStatusType
} from './episodeTranscodeUi'
defineProps<{
renditions: MediaRenditionItem[]
}>()
const { t } = useI18n()
const playerVisible = ref(false)
const playerUrl = ref('')
const playerTitle = ref('')
function statusLabel(rendition: MediaRenditionItem) {
return t(`episodeTranscode.status.${renditionStatusLabelKey(rendition.status)}`)
}
function playRendition(rendition: MediaRenditionItem) {
if (!rendition.hlsUrl) return
playerUrl.value = rendition.hlsUrl
playerTitle.value = `${rendition.qualityName} · ${rendition.resolution}`
playerVisible.value = true
}
</script>
<template>
<div class="episode-transcode-renditions__heading">
<h4>{{ t('episodeTranscode.renditionTitle') }}</h4>
</div>
<el-table
:data="renditions"
size="small"
border
class="episode-transcode-renditions__table"
:empty-text="t('episodeTranscode.noRendition')"
>
<el-table-column prop="qualityName" :label="t('episodeTranscode.quality')" min-width="88" />
<el-table-column prop="resolution" :label="t('episodeTranscode.resolution')" min-width="105" />
<el-table-column :label="t('episodeTranscode.videoBitrate')" min-width="105">
<template #default="{ row }">{{ row.videoBitrateKbps }} kbps</template>
</el-table-column>
<el-table-column :label="t('episodeTranscode.audioBitrate')" min-width="105">
<template #default="{ row }">{{ row.audioBitrateKbps }} kbps</template>
</el-table-column>
<el-table-column prop="videoCodec" :label="t('episodeTranscode.codec')" min-width="82" />
<el-table-column prop="container" :label="t('episodeTranscode.container')" min-width="82" />
<el-table-column :label="t('episodeTranscode.fileSize')" min-width="92">
<template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
</el-table-column>
<el-table-column :label="t('episodeTranscode.duration')" min-width="76">
<template #default="{ row }">{{ formatDuration(row.durationSec) }}</template>
</el-table-column>
<el-table-column prop="segmentCount" :label="t('episodeTranscode.segments')" min-width="76" />
<el-table-column :label="t('episodeTranscode.statusColumn')" min-width="82">
<template #default="{ row }">
<el-tag :type="renditionStatusType(row.status)" effect="light" size="small">
{{ statusLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('episodeTranscode.actions')" width="76" fixed="right" align="center">
<template #default="{ row }">
<el-tooltip :content="t('episodeTranscode.play')" placement="top">
<el-button text circle @click="playRendition(row)">
<AppIcon :icon="AppIcons.videoPlay" :size="17" />
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<RenditionPlayerDialog v-model="playerVisible" :url="playerUrl" :title="playerTitle" />
</template>
<style scoped>
.episode-transcode-renditions__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 18px 0 10px;
}
.episode-transcode-renditions__heading h4 {
margin: 0;
font-size: 13px;
}
.episode-transcode-renditions__table {
width: 100%;
}
</style>
@@ -0,0 +1,45 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码折叠标题状态徽标 -->
<script setup lang="ts">
import type { TranscodeProgressStatus, TranscodeTagType } from './episodeTranscodeUi'
defineProps<{
visible: boolean
statusLabel: string
statusType: TranscodeTagType
progress: number
progressStatus: TranscodeProgressStatus
showProgress: boolean
}>()
</script>
<template>
<span class="episode-transcode-badge">
<el-tag v-if="visible" :type="statusType" effect="light" size="small">
{{ statusLabel }}
</el-tag>
<el-progress
v-if="showProgress"
class="episode-transcode-badge__progress"
:percentage="progress"
:status="progressStatus"
:stroke-width="6"
:show-text="false"
/>
</span>
</template>
<style scoped>
.episode-transcode-badge {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
}
.episode-transcode-badge__progress {
width: 110px;
margin-left: auto;
}
</style>
@@ -0,0 +1,169 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码状态、进度、错误与重新转码操作 -->
<script setup lang="ts">
import type { EpisodeTranscodeItem } from '@/api/admin/types'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import { formatDateTime } from '@/utils/format'
import type { TranscodeProgressStatus, TranscodeTagType } from './episodeTranscodeUi'
interface StatusView {
loading: boolean
errorMessage: string
hasMedia: boolean
retriggering: boolean
isProcessing: boolean
statusLabel: string
statusType: TranscodeTagType
progressStatus: TranscodeProgressStatus
progress: number
}
defineProps<{
detail: EpisodeTranscodeItem | null
view: StatusView
}>()
const emit = defineEmits<{
retry: []
retrigger: []
}>()
const { t } = useI18n()
</script>
<template>
<p class="episode-transcode-status__description">
{{ t('episodeTranscode.description') }}
</p>
<el-skeleton v-if="view.loading && !detail" :rows="4" animated />
<div v-else-if="view.errorMessage" class="episode-transcode-status__error">
<el-alert type="error" :title="view.errorMessage" :closable="false" show-icon />
<el-button size="small" @click="emit('retry')">
<AppIcon :icon="AppIcons.refresh" :size="16" />
{{ t('episodeTranscode.retryLoad') }}
</el-button>
</div>
<el-empty
v-else-if="detail && !view.hasMedia"
:description="detail.errorMsg || t('episodeTranscode.noMedia')"
:image-size="72"
/>
<template v-else-if="detail">
<div class="episode-transcode-status__summary">
<div class="episode-transcode-status__metric">
<span>{{ t('episodeTranscode.statusLabel') }}</span>
<el-tag :type="view.statusType" effect="light">{{ view.statusLabel }}</el-tag>
</div>
<div class="episode-transcode-status__metric">
<span>{{ t('episodeTranscode.progress') }}</span>
<b>{{ view.progress }}%</b>
</div>
<div class="episode-transcode-status__metric">
<span>{{ t('episodeTranscode.retryCount') }}</span>
<b>{{ detail.retryCount || 0 }}</b>
</div>
<div class="episode-transcode-status__metric">
<span>{{ t('episodeTranscode.updatedAt') }}</span>
<b>{{ formatDateTime(detail.updatedAt) }}</b>
</div>
<el-button
class="episode-transcode-status__retry"
type="primary"
plain
:loading="view.retriggering"
:disabled="!view.hasMedia || view.isProcessing || view.retriggering"
@click="emit('retrigger')"
>
<AppIcon :icon="AppIcons.restart" :size="16" />
{{
view.retriggering ? t('episodeTranscode.reprocessing') : t('episodeTranscode.reprocess')
}}
</el-button>
</div>
<el-progress
class="episode-transcode-status__progress"
:percentage="view.progress"
:status="view.progressStatus"
:stroke-width="8"
/>
<p v-if="view.isProcessing && view.progress === 0" class="episode-transcode-status__waiting">
{{ t('episodeTranscode.waitingProgress') }}
</p>
<el-alert
v-if="detail.errorMsg"
class="episode-transcode-status__error-alert"
type="error"
:title="detail.errorMsg"
:closable="false"
show-icon
/>
</template>
</template>
<style scoped>
.episode-transcode-status__description {
margin: 0 0 14px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.episode-transcode-status__error {
display: flex;
align-items: flex-start;
gap: 10px;
flex-direction: column;
}
.episode-transcode-status__summary {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.episode-transcode-status__metric {
display: flex;
font-size: 12px;
color: var(--el-text-color-secondary);
align-items: center;
gap: 8px;
}
.episode-transcode-status__metric b {
font-size: 14px;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
}
.episode-transcode-status__retry {
margin-left: auto;
}
.episode-transcode-status__progress {
margin-top: 12px;
}
.episode-transcode-status__waiting {
margin: 8px 0 0;
font-size: 12px;
color: var(--el-color-warning);
}
.episode-transcode-status__error-alert {
margin-top: 12px;
}
@media (width <= 600px) {
.episode-transcode-status__summary {
align-items: flex-start;
}
.episode-transcode-status__retry {
width: 100%;
margin-left: 0;
}
}
</style>
@@ -0,0 +1,172 @@
<!-- Author: fruit.com.cn -->
<!-- 单集视频上传:格式/大小校验、分片上传、预览与移除 -->
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
const props = defineProps({
videoUrl: { type: String, default: '' },
durationSec: { type: Number, default: 0 },
fileName: { type: String, default: '' }
})
const emit = defineEmits([
'update:videoUrl',
'update:durationSec',
'update:fileName',
'update:uploading',
'change'
])
const { t } = useI18n()
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const videoError = ref('')
const videoDisplayName = computed(
() => props.fileName || (props.videoUrl ? episodeBasename(props.videoUrl) : '尚未选择视频')
)
watch(uploadingVideo, (value) => emit('update:uploading', value))
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
function clearError() {
videoError.value = ''
}
function beforeVideoUpload(file) {
videoError.value = ''
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase()
if (!VIDEO_EXTENSIONS.includes(extension)) {
videoError.value = t('episodeBatch.invalidType')
return false
}
if (file.size > MAX_VIDEO_SIZE) {
videoError.value = '视频大小不能超过 500MB'
return false
}
return true
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
videoError.value = ''
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
emit('update:videoUrl', asset.url)
emit('update:fileName', options.file.name)
if (duration > 0) emit('update:durationSec', duration)
emit('change')
ElMessage.success('视频已上传')
} catch {
videoError.value = '视频上传失败,请检查网络后重试'
} finally {
uploadingVideo.value = false
}
}
function removeVideo() {
if (uploadingVideo.value) return
emit('update:videoUrl', '')
emit('update:fileName', '')
videoError.value = ''
emit('change')
}
defineExpose({ clearError, uploadingVideo })
</script>
<template>
<div class="episode-video-editor">
<div class="episode-video-preview-card">
<video
v-if="videoUrl"
:src="videoUrl"
controls
preload="metadata"
class="episode-editor-video"
></video>
<el-upload
v-else
drag
class="episode-video-empty-upload"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<div class="episode-video-empty-copy">
<AppIcon :icon="AppIcons.videoCameraFront" :size="30" />
<b>拖放或点击上传</b>
<span>MP4、MOV、MKV、WEBM</span>
</div>
</el-upload>
<div v-if="uploadingVideo" class="episode-video-upload-mask">
<span>上传中</span>
<b>{{ videoPercent }}%</b>
</div>
</div>
<div class="episode-video-meta">
<div class="episode-video-status">
<el-tag :type="videoUrl ? 'success' : 'info'" effect="light">
{{ videoUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="episode-video-filename" :title="videoDisplayName">
{{ videoDisplayName }}
</span>
</div>
<p class="episode-video-hint">{{ t('episodeBatch.hint') }}</p>
<el-alert v-if="videoError" :title="videoError" type="error" :closable="false" show-icon />
<div class="episode-video-actions">
<el-upload
v-if="videoUrl"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<el-button :loading="uploadingVideo" :disabled="uploadingVideo">
<AppIcon :icon="AppIcons.upload" />
更换视频
</el-button>
</el-upload>
<el-button v-if="videoUrl" type="danger" :disabled="uploadingVideo" @click="removeVideo">
移除
</el-button>
<a
v-if="videoUrl"
class="episode-video-link"
:href="videoUrl"
target="_blank"
rel="noopener noreferrer"
>
查看视频
</a>
</div>
</div>
</div>
</template>
@@ -0,0 +1,126 @@
<!-- Author: fruit.com.cn -->
<!-- 清晰度产物 HLS 预览播放器:Chrome/Edge 用 hls.js,Safari 走原生 -->
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type Hls from 'hls.js'
import { useI18n } from '@/hooks/web/useI18n'
const props = defineProps<{
modelValue: boolean
url: string
title: string
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const videoRef = ref<HTMLVideoElement>()
const errorMsg = ref('')
let hls: Hls | null = null
function destroy() {
hls?.destroy()
hls = null
const video = videoRef.value
if (video) {
video.pause()
video.removeAttribute('src')
video.load()
}
}
async function start(url: string) {
await nextTick()
const video = videoRef.value
if (!video || !url) return
errorMsg.value = ''
destroy()
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
video.play().catch(() => {})
return
}
const { default: HlsPlayer } = await import('hls.js')
if (!HlsPlayer.isSupported()) {
errorMsg.value = t('episodeTranscode.playerUnsupported')
return
}
const instance = new HlsPlayer()
hls = instance
instance.on(HlsPlayer.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {})
})
instance.on(HlsPlayer.Events.ERROR, (_event, data) => {
if (data?.fatal) {
errorMsg.value = t('episodeTranscode.playerFailed')
}
})
instance.loadSource(url)
instance.attachMedia(video)
}
watch(
() => props.modelValue,
(visible) => {
if (visible) {
start(props.url)
} else {
destroy()
}
}
)
onBeforeUnmount(destroy)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="720px"
destroy-on-close
class="rendition-player-dialog"
@update:model-value="emit('update:modelValue', $event)"
@closed="destroy"
>
<div class="rendition-player">
<video ref="videoRef" class="rendition-player__video" controls playsinline></video>
<p v-if="errorMsg" class="rendition-player__error">{{ errorMsg }}</p>
<p class="rendition-player__url" :title="url">{{ url }}</p>
</div>
</el-dialog>
</template>
<style scoped>
.rendition-player {
display: flex;
flex-direction: column;
gap: 10px;
}
.rendition-player__video {
width: 100%;
max-height: 62vh;
background: #000;
border-radius: 8px;
}
.rendition-player__error {
margin: 0;
font-size: 13px;
color: var(--el-color-danger);
}
.rendition-player__url {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
</style>
@@ -0,0 +1,137 @@
<!-- Author: fruit.com.cn -->
<!-- 批量上传队列单行:序号、文件名、大小、进度、状态与操作 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import type { UploadQueueItem } from './useUploadQueue'
const props = defineProps<{
item: UploadQueueItem
/** 自然排序后的展示序号(从 1 起) */
index: number
/** 选择阶段才允许移除 */
allowRemove: boolean
}>()
const emit = defineEmits<{
retry: [id: string]
remove: [id: string]
}>()
const { t } = useI18n()
const BYTES_PER_KB = 1024
const BYTES_PER_MB = 1024 * 1024
const sizeLabel = computed(() => {
const size = props.item.fileSize
if (size >= BYTES_PER_MB) {
return `${(size / BYTES_PER_MB).toFixed(1)} MB`
}
return `${Math.max(1, Math.round(size / BYTES_PER_KB))} KB`
})
const statusLabel = computed(() => t(`episodeBatch.status.${props.item.status}`))
const statusType = computed(() => {
switch (props.item.status) {
case 'done':
return 'success'
case 'failed':
return 'danger'
case 'paused':
return 'warning'
case 'merging':
case 'uploading':
return ''
default:
return 'info'
}
})
const canRetry = computed(() => props.item.status === 'failed')
</script>
<template>
<div class="upload-task-row" :class="{ 'is-failed': item.status === 'failed' }">
<div class="upload-task-row__meta">
<span class="upload-task-row__index">{{ index }}</span>
<b class="upload-task-row__name" :title="item.fileName">{{ item.fileName }}</b>
<small>{{ sizeLabel }}</small>
<el-tag :type="statusType" effect="light" size="small">{{ statusLabel }}</el-tag>
</div>
<el-progress
:percentage="item.percent"
:status="
item.status === 'failed' ? 'exception' : item.status === 'done' ? 'success' : undefined
"
:stroke-width="10"
/>
<p v-if="item.error" class="upload-task-row__error">{{ item.error }}</p>
<div class="upload-task-row__actions">
<el-button v-if="canRetry" size="small" type="primary" @click="emit('retry', item.id)">
{{ t('episodeBatch.retry') }}
</el-button>
<el-button
v-if="allowRemove"
size="small"
type="danger"
text
@click="emit('remove', item.id)"
>
{{ t('episodeBatch.remove') }}
</el-button>
</div>
</div>
</template>
<style scoped>
.upload-task-row {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.upload-task-row__meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.upload-task-row__index {
min-width: 1.5em;
font-size: 13px;
color: var(--el-text-color-secondary);
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.upload-task-row__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-task-row__error {
margin: 6px 0 0;
font-size: 12px;
color: var(--el-color-danger);
}
.upload-task-row__actions {
display: flex;
margin-top: 8px;
margin-left: auto;
gap: 8px;
justify-content: flex-end;
}
.upload-task-row.is-failed {
padding-right: 8px;
padding-left: 8px;
background: color-mix(in srgb, var(--el-color-danger) 6%, transparent);
border-radius: 6px;
}
</style>
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
// 剧集转码面板的状态映射与展示格式化
export type TranscodeTagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
export type TranscodeProgressStatus = 'success' | 'exception' | undefined
export const THUMBNAIL_PAGE_SIZE = 100
export const THUMBNAIL_PAGE_SIZES = [100, 200, 500]
const STATUS_KEYS: Record<string, string> = {
QUEUED: 'queued',
RUNNING: 'running',
SUCCESS: 'success',
FAILED: 'failed',
EMPTY: 'empty',
NO_VIDEO: 'noVideo',
NO_MEDIA_ASSET: 'noMediaAsset',
NO_TASK: 'noTask',
NO_EPISODE: 'noEpisode',
EPISODE_NOT_FOUND: 'episodeNotFound',
INVALID: 'invalid',
READY: 'ready'
}
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const BYTES_PER_GB = BYTES_PER_MB * 1024
const SECONDS_PER_MINUTE = 60
const MILLIS_PER_SECOND = 1000
export function transcodeStatusKey(status?: string): string {
return STATUS_KEYS[String(status || '').toUpperCase()] || 'unknown'
}
export function transcodeStatusType(status?: string): TranscodeTagType {
switch (String(status || '').toUpperCase()) {
case 'SUCCESS':
return 'success'
case 'RUNNING':
return 'warning'
case 'FAILED':
return 'danger'
case 'QUEUED':
return 'primary'
default:
return 'info'
}
}
export function transcodeProgressStatus(status?: string): TranscodeProgressStatus {
const normalized = String(status || '').toUpperCase()
if (normalized === 'SUCCESS') return 'success'
if (normalized === 'FAILED') return 'exception'
return undefined
}
export function safeTranscodeProgress(value?: number): number {
return Math.min(100, Math.max(0, Number(value || 0)))
}
export function renditionStatusLabelKey(status?: string): string {
return transcodeStatusKey(status)
}
export function renditionStatusType(status?: string): TranscodeTagType {
const normalized = String(status || '').toUpperCase()
if (normalized === 'READY') return 'success'
if (normalized === 'FAILED') return 'danger'
return 'info'
}
export function formatBytes(value?: number): string {
const size = Number(value || 0)
if (size >= BYTES_PER_GB) return `${(size / BYTES_PER_GB).toFixed(2)} GB`
if (size >= BYTES_PER_MB) return `${(size / BYTES_PER_MB).toFixed(1)} MB`
if (size >= BYTES_PER_KB) return `${Math.round(size / BYTES_PER_KB)} KB`
return `${size} B`
}
export function formatDuration(seconds?: number): string {
const total = Math.max(0, Number(seconds || 0))
const minutes = Math.floor(total / SECONDS_PER_MINUTE)
const remainder = total % SECONDS_PER_MINUTE
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`
}
export function formatTimeMs(milliseconds?: number): string {
const totalMs = Math.max(0, Number(milliseconds || 0))
const totalSeconds = Math.floor(totalMs / MILLIS_PER_SECOND)
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE)
const seconds = totalSeconds % SECONDS_PER_MINUTE
const tenths = Math.floor((totalMs % MILLIS_PER_SECOND) / 100)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${tenths}`
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import { compressImage } from '@/utils/imageCompress'
/**
* 图片上传规格:建议尺寸来自客户端实际展示位,按 3 倍屏预留像素。
*
* - 竖版封面 3:4:首页三列 ~118dp、推荐分类两列 ~175dp、追剧收藏 ~118dp,
* 同时作为播放器起播首帧铺满竖屏,1080×1440 是兼顾清晰度与体积的平衡点。
* - 横版封面 16:9:追剧页「继续观看」横幅(约 2.06:1)与后续横向位共用。
* - 推荐轮播:客户端轮播高度固定 420dp、按屏宽铺满(约 1:1.08)。
*/
export interface ImagePreset {
label: string
/** 建议尺寸文案,展示在上传按钮旁。 */
hint: string
maxWidth: number
maxHeight: number
}
export const DRAMA_COVER_PORTRAIT: ImagePreset = {
label: '竖版封面',
hint: '建议 1080×1440(3:4),最小 720×960',
maxWidth: 1080,
maxHeight: 1440
}
export const DRAMA_COVER_LANDSCAPE: ImagePreset = {
label: '横版封面',
hint: '建议 1280×720(16:9),用于继续观看横幅等横向位',
maxWidth: 1280,
maxHeight: 720
}
export const EPISODE_COVER: ImagePreset = {
label: '分集封面',
hint: '建议 720×960(3:4)',
maxWidth: 720,
maxHeight: 960
}
export const BANNER_IMAGE: ImagePreset = {
label: '推荐轮播图',
hint: '建议 1080×1164(约 1:1.08,与客户端 420dp 轮播高度一致)',
maxWidth: 1080,
maxHeight: 1164
}
export const LOGIN_BACKGROUND: ImagePreset = {
label: '登录页背景',
hint: '建议 1170×2532(390×844dp × 3,iPhone 整屏竖图),min 720×1560',
maxWidth: 1170,
maxHeight: 2532
}
/** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */
export function compressForPreset(file: File, preset: ImagePreset): Promise<File> {
return compressImage(file, {
maxWidth: preset.maxWidth,
maxHeight: preset.maxHeight
})
}
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
/** 剧集批量上传断点会话:localStorage 读写与清理 */
export const UPLOAD_SESSION_KEY = 'reel_upload_sessions'
export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000
export interface UploadSessionRecord {
uploadId: string
fileName: string
fileSize: number
lastModified: number
dramaId: string
bizType: string
createdAt: number
}
/** 读取全部会话,解析失败时返回空数组。 */
export function loadUploadSessions(): UploadSessionRecord[] {
try {
const raw = localStorage.getItem(UPLOAD_SESSION_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as UploadSessionRecord[]) : []
} catch {
return []
}
}
/** 覆盖写入全部会话。 */
export function saveUploadSessions(sessions: UploadSessionRecord[]): void {
localStorage.setItem(UPLOAD_SESSION_KEY, JSON.stringify(sessions))
}
/** 按 dramaId 过滤且未超过 TTL 的未完成会话。 */
export function listPendingSessions(dramaId: string, now = Date.now()): UploadSessionRecord[] {
const dramaKey = String(dramaId)
return loadUploadSessions().filter(
(item) =>
String(item.dramaId) === dramaKey &&
now - Number(item.createdAt || 0) <= UPLOAD_SESSION_TTL_MS
)
}
/** 新增或按 uploadId 覆盖一条会话。 */
export function upsertUploadSession(record: UploadSessionRecord): void {
const next = loadUploadSessions().filter((item) => item.uploadId !== record.uploadId)
next.push(record)
saveUploadSessions(next)
}
/** 按 uploadId 移除会话。 */
export function removeUploadSession(uploadId: string): void {
if (!uploadId) return
saveUploadSessions(loadUploadSessions().filter((item) => item.uploadId !== uploadId))
}
/** 批量移除会话。 */
export function removeUploadSessions(uploadIds: string[]): void {
if (!uploadIds.length) return
const idSet = new Set(uploadIds)
saveUploadSessions(loadUploadSessions().filter((item) => !idSet.has(item.uploadId)))
}
/** 用文件名 + size + lastModified 匹配本地文件。 */
export function matchFileForSession(session: UploadSessionRecord, files: File[]): File | undefined {
return files.find(
(file) =>
file.name === session.fileName &&
file.size === session.fileSize &&
file.lastModified === session.lastModified
)
}
@@ -0,0 +1,352 @@
// Author: fruit.com.cn
/** 剧集批量上传队列:手动开始、文件并发、进度加权、断点会话、失败隔离 */
import { computed, ref } from 'vue'
import { getUploadProgressApi } from '@/api/admin/media'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
import {
listPendingSessions,
matchFileForSession,
removeUploadSession,
type UploadSessionRecord,
upsertUploadSession
} from './uploadSessions'
export type UploadItemStatus = 'waiting' | 'uploading' | 'merging' | 'done' | 'failed' | 'paused'
export interface UploadQueueItem {
id: string
file: File
fileName: string
fileSize: number
lastModified: number
percent: number
status: UploadItemStatus
error: string
uploadId: string
assetId?: number
url?: string
durationSec?: number
}
export interface UseUploadQueueOptions {
/** 当前短剧 id(调用时读取,避免切换剧后闭包过期) */
getDramaId: () => string
bizType?: string
}
const FILE_CONCURRENCY = 2
const CHUNK_CONCURRENCY = 3
const DEFAULT_BIZ_TYPE = 'EPISODE'
const MERGING_PERCENT = 95
const MAX_PERCENT = 100
let queueSeq = 0
function nextItemId(): string {
queueSeq += 1
return `uq-${Date.now()}-${queueSeq}`
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message
return String(error || 'upload failed')
}
function isHttpNotFound(error: unknown): boolean {
const status = (error as { response?: { status?: number } })?.response?.status
return status === 404
}
/** 文件名自然排序(数字感知),保证队列序号稳定。 */
function compareFileName(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
}
/**
* 创建上传队列控制器。
* @param options dramaId 与可选 bizType
*/
export function useUploadQueue(options: UseUploadQueueOptions) {
const items = ref<UploadQueueItem[]>([])
const paused = ref(false)
/** 用户是否已点过「开始上传」;选文件阶段不派发、不占用并发。 */
const started = ref(false)
const bizType = options.bizType || DEFAULT_BIZ_TYPE
let running = 0
const overallPercent = computed(() => {
const list = items.value
if (!list.length) return 0
const totalBytes = list.reduce((sum, item) => sum + item.fileSize, 0)
if (totalBytes <= 0) return 0
const weighted = list.reduce((sum, item) => sum + item.fileSize * item.percent, 0)
return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes))
})
const isUploading = computed(() =>
items.value.some((item) => item.status === 'uploading' || item.status === 'merging')
)
/** 关闭弹窗需二次确认:仅在已开始且仍有未完成任务时。 */
const hasActiveUpload = computed(() => {
if (!started.value) return false
return items.value.some(
(item) =>
item.status === 'waiting' ||
item.status === 'uploading' ||
item.status === 'merging' ||
item.status === 'paused'
)
})
const canStart = computed(() => {
if (isUploading.value) return false
return items.value.some((item) => item.status === 'waiting' || item.status === 'paused')
})
const doneItems = computed(() => items.value.filter((item) => item.status === 'done'))
const failedCount = computed(() => items.value.filter((item) => item.status === 'failed').length)
/** 已开始且队列已无进行中任务时,可展示结果汇总。 */
const showResultSummary = computed(
() => started.value && items.value.length > 0 && !hasActiveUpload.value
)
function persistSession(item: UploadQueueItem, uploadId: string): void {
item.uploadId = uploadId
upsertUploadSession({
uploadId,
fileName: item.fileName,
fileSize: item.fileSize,
lastModified: item.lastModified,
dramaId: String(options.getDramaId()),
bizType,
createdAt: Date.now()
})
}
function clearSession(uploadId: string): void {
if (uploadId) removeUploadSession(uploadId)
}
function sortItemsByName(): void {
items.value.sort((a, b) => compareFileName(a.fileName, b.fileName))
}
async function runOne(item: UploadQueueItem, resumeId?: string): Promise<void> {
item.status = 'uploading'
item.error = ''
try {
const durationSec = await probeVideoDuration(item.file)
item.durationSec = durationSec
const uploadOptions = {
bizType,
concurrency: CHUNK_CONCURRENCY,
durationSec,
onSession: (uploadId: string) => persistSession(item, uploadId),
onProgress: (percent: number) => {
item.percent = percent
if (percent >= MERGING_PERCENT && item.status === 'uploading') {
item.status = 'merging'
}
}
}
const asset = resumeId
? await resumeUpload(item.file, resumeId, uploadOptions)
: await uploadFile(item.file, uploadOptions)
item.percent = MAX_PERCENT
item.status = 'done'
item.assetId = asset.id
item.url = asset.url
if (asset.durationSec != null) item.durationSec = asset.durationSec
clearSession(item.uploadId || resumeId || '')
} catch (error) {
item.status = 'failed'
item.error = extractErrorMessage(error)
}
}
function pump(): void {
if (!started.value || paused.value) return
while (running < FILE_CONCURRENCY && !paused.value) {
const next = items.value.find((item) => item.status === 'waiting')
if (!next) return
// 立刻占位,避免同步 while 循环把同一项派发两次
next.status = 'uploading'
running += 1
const resumeId = next.uploadId || undefined
void runOne(next, resumeId).finally(() => {
running -= 1
if (!paused.value) pump()
})
}
}
/** 入队本地文件(调用方已完成类型/大小校验);只展示待上传,不发起请求。 */
function enqueue(files: File[]): void {
const sorted = [...files].sort((a, b) => compareFileName(a.name, b.name))
for (const file of sorted) {
items.value.push({
id: nextItemId(),
file,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
percent: 0,
status: 'waiting',
error: '',
uploadId: ''
})
}
sortItemsByName()
}
/** 开始或继续派发(含断点续传项上的 uploadId)。 */
function start(): void {
if (!items.value.length) return
started.value = true
paused.value = false
for (const item of items.value) {
if (item.status === 'paused') item.status = 'waiting'
}
void pump()
}
/** 暂停:不派新任务;进行中分片跑完。 */
function pause(): void {
if (!started.value) return
paused.value = true
for (const item of items.value) {
if (item.status === 'waiting') item.status = 'paused'
}
}
/** 重试单个失败项。 */
function retry(id: string): void {
const item = items.value.find((row) => row.id === id)
if (!item || item.status !== 'failed') return
item.error = ''
item.percent = item.uploadId ? item.percent : 0
item.status = paused.value ? 'paused' : 'waiting'
if (started.value && !paused.value) void pump()
}
/** 重试全部失败项。 */
function retryAll(): void {
for (const item of items.value) {
if (item.status === 'failed') {
item.error = ''
item.status = paused.value ? 'paused' : 'waiting'
}
}
if (started.value && !paused.value) void pump()
}
/** 仅选择阶段可移除;已开始上传后禁止直接移除,避免脏会话。 */
function remove(id: string): void {
if (started.value) return
const index = items.value.findIndex((row) => row.id === id)
if (index < 0) return
const [removed] = items.value.splice(index, 1)
clearSession(removed.uploadId)
}
/** 清空队列并清理本队列会话。 */
function clear(): void {
for (const item of items.value) {
clearSession(item.uploadId)
}
items.value = []
paused.value = false
started.value = false
running = 0
}
/** 读取当前剧未完成会话(24h 内)。 */
function getPendingSessions(): UploadSessionRecord[] {
return listPendingSessions(String(options.getDramaId()))
}
/**
* 用本地重选文件匹配会话并入队(待上传);不自动开始,由用户点「开始上传」续传。
* @returns matched / unmatched 会话列表
*/
async function resumeFromSessions(
sessions: UploadSessionRecord[],
files: File[]
): Promise<{ matched: UploadSessionRecord[]; unmatched: UploadSessionRecord[] }> {
const matched: UploadSessionRecord[] = []
const unmatched: UploadSessionRecord[] = []
for (const session of sessions) {
const file = matchFileForSession(session, files)
if (!file) {
unmatched.push(session)
continue
}
try {
const progressRes = await getUploadProgressApi(session.uploadId)
const progress = progressRes?.data
if (!progress) {
unmatched.push(session)
continue
}
if (String(progress.status || '').toLowerCase() === 'completed') {
clearSession(session.uploadId)
continue
}
items.value.push({
id: nextItemId(),
file,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0),
status: 'waiting',
error: '',
uploadId: session.uploadId
})
matched.push(session)
} catch (error) {
if (isHttpNotFound(error)) {
clearSession(session.uploadId)
} else {
unmatched.push(session)
}
}
}
sortItemsByName()
return { matched, unmatched }
}
/** 放弃续传:清理给定会话。 */
function abandonSessions(sessions: UploadSessionRecord[]): void {
for (const session of sessions) {
clearSession(session.uploadId)
}
}
return {
items,
overallPercent,
hasActiveUpload,
isUploading,
canStart,
doneItems,
failedCount,
showResultSummary,
started,
paused,
enqueue,
start,
pause,
retry,
retryAll,
remove,
clear,
getPendingSessions,
resumeFromSessions,
abandonSessions
}
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/** 剧集视频大小上限,单文件上传与批量上传共用。 */
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024
/** 允许的视频扩展名(小写,不含点),单文件与批量上传同源。 */
export const VIDEO_EXTENSIONS: string[] = ['mp4', 'mov', 'mkv', 'webm']
/** el-upload / input[type=file] 的 accept 属性,与 VIDEO_EXTENSIONS 保持一致。 */
export const VIDEO_ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*'
+24 -17
View File
@@ -10,6 +10,7 @@ import { useRbac } from '@/stores/rbac'
import { useDramaEdit } from './composables/useDramaEdit'
import { useCreatorAccess } from './composables/useCreatorAccess'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
import { DRAMA_COVER_PORTRAIT, EPISODE_COVER } from '@/views/contentManagement/upload/imagePresets'
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
@@ -148,15 +149,18 @@ function requestDramaCover(options) {
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<div class="creator-cover-actions">
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<p class="creator-cover-hint">{{ DRAMA_COVER_PORTRAIT.hint }}</p>
</div>
</div>
</el-form-item>
<el-form-item label="预告片地址">
@@ -353,14 +357,17 @@ function requestDramaCover(options) {
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<div class="creator-cover-actions">
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<p class="creator-cover-hint">{{ EPISODE_COVER.hint }}</p>
</div>
</div>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="集号">
@@ -18,6 +18,11 @@ import {
} from '@/api/admin/creatorCenter'
import { dateCell } from '@/utils/format'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
import {
DRAMA_COVER_PORTRAIT,
EPISODE_COVER,
compressForPreset
} from '@/views/contentManagement/upload/imagePresets'
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
import { useCreatorAccess } from './useCreatorAccess'
@@ -266,7 +271,9 @@ export function useDramaEdit() {
uploadingId.value = row ? row.id : -1
uploadPercent.value = 0
try {
const asset = await uploadFile(file, {
const preset = row ? EPISODE_COVER : DRAMA_COVER_PORTRAIT
const compressed = await compressForPreset(file, preset)
const asset = await uploadFile(compressed, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (uploadPercent.value = percent)
})
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more