Author SHA1 Message Date
Zhou Weidong 88db96c374 feat(commerce): 新增打赏任务管理页
web-ci / build (push) Failing after 8s
2026-11-09 10:27:50 +08:00
Zhou Weidong 96b213354b fix(layout): 扩大顶栏图标与用户菜单点击热区 2026-11-09 10:27:49 +08:00
Zhou Weidong 0d862dfe1d fix(router): 按账号菜单落地避免创作者进入 404 2026-11-09 10:27:48 +08:00
Zhou Weidong 8535305185 style(creator-center): 数据看板改用管理端卡片布局 2026-11-09 10:27:47 +08:00
Zhou Weidong 837266ce7e feat(creator): 内容审核展示创作者与分集信息 2026-11-09 10:27:42 +08:00
Zhou Weidong 71ee469563 feat(creator-center): 分集支持视频预览与表格横向滚动 2026-11-09 10:27:41 +08:00
Zhou Weidong 3c730c7d33 feat(creator-center): 新增两步发布作品并支持删除作品 2026-11-09 10:27:40 +08:00
Vicky 4055b7a2a9 fix(layout): 修复顶栏通知角标脱离铃铛右上角
web-ci / build (push) Failing after 10s
角标改为锚定图标右上角(right 9px + 右移 50%),
位数变化时位置不漂移。
2026-11-05 10:56:09 +08:00
Vicky bbff52f324 feat(drama): 短剧审核合并为审核通过并上线一键操作
审核列草稿/已下线/审核中统一为「审核通过并上线」,
审核中保留驳回,待发布与已上线维持原按钮。
2026-11-05 10:56:09 +08:00
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 e9a956f16c chore(web): 忽略运行时产物(日志/启动脚本) 2026-10-19 22:43:29 +08:00
119 changed files with 11885 additions and 667 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
@@ -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 为项目既有警告。
+2
View File
@@ -24,6 +24,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "2.3.2",
"@iconify/iconify": "^3.1.1", "@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2", "@vueuse/core": "^10.7.2",
@@ -38,6 +39,7 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "2.5.5", "element-plus": "2.5.5",
"hls.js": "^1.7.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
+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> <template>
<ConfigGlobal :size="currentSize"> <ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" /> <RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<footer class="app-copyright">© FruitShorts</footer>
</ConfigGlobal> </ConfigGlobal>
</template> </template>
@@ -48,4 +49,15 @@ body {
.@{prefix-cls}-grey-mode { .@{prefix-cls}-grey-mode {
filter: grayscale(100%); 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> </style>
+4
View File
@@ -11,6 +11,8 @@ export interface AppUserPageParams {
status?: string status?: string
countryCode?: string countryCode?: string
registerSource?: string registerSource?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
} }
export interface WatchHistoryPageParams { export interface WatchHistoryPageParams {
@@ -21,6 +23,8 @@ export interface WatchHistoryPageParams {
dramaTitle?: string dramaTitle?: string
dramaId?: number dramaId?: number
episodeId?: number episodeId?: number
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
source?: string source?: string
finished?: boolean finished?: boolean
startTime?: string startTime?: string
+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}` })
}
+5
View File
@@ -98,6 +98,11 @@ export const pageSubmissionsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params }) return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
} }
/** 内容提交详情:附带短剧预览 + 分集列表,审核弹窗展示用 */
export const detailSubmissionApi = (id: number) => {
return request.get<SubmissionItem>({ url: `/admin/submissions/${id}` })
}
/** action: APPROVE / REJECT / WITHDRAW */ /** action: APPROVE / REJECT / WITHDRAW */
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => { export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data }) return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
+39
View File
@@ -109,6 +109,35 @@ export interface CreatorDramaEditData {
language?: string language?: string
} }
/** 一键发布的分集入参:assetId 来自已完成的分片上传 */
export interface PublishEpisodeItem {
assetId: number
title?: string
durationSec?: number
cover?: string
}
export interface PublishDramaPayload {
title: string
subtitle?: string
/** 短剧分类 id(下拉选择) */
categoryId?: number
/** 题材标签(自由文本,可选) */
genre?: string
description?: string
cover?: string
language?: string
/** 默认 true:创建后立即提交审核 */
submit?: boolean
episodes: PublishEpisodeItem[]
}
export interface PublishDramaResult {
dramaId: number
submissionId?: number | null
status: string
}
export interface PriceEpisodeConfig { export interface PriceEpisodeConfig {
episodeId: number episodeId: number
number: number number: number
@@ -176,6 +205,11 @@ export const createDramaApi = (data: CreatorDramaEditData) => {
return request.post<number>({ url: '/admin/creator-center/dramas', data }) return request.post<number>({ url: '/admin/creator-center/dramas', data })
} }
/** 一键发布:一个事务里建作品 + 建分集 + 绑定视频(可选提交审核) */
export const publishDramaApi = (data: PublishDramaPayload) => {
return request.post<PublishDramaResult>({ url: '/admin/creator-center/publish', data })
}
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => { export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data }) return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
} }
@@ -205,6 +239,11 @@ export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
}) })
} }
/** 删除作品(草稿 / 已下线 / 审核中可删,已上线需先下线) */
export const deleteDramaApi = (dramaId: number) => {
return request.delete<void>({ url: `/admin/creator-center/dramas/${dramaId}` })
}
export const sortEpisodesApi = (dramaId: number, ids: number[]) => { export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
return request.put<void>({ return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`, url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
+30
View File
@@ -27,6 +27,28 @@ export interface TranscodeBoard {
notes: string[] 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 = () => { export const getDashboardOverviewApi = () => {
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' }) return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
} }
@@ -34,3 +56,11 @@ export const getDashboardOverviewApi = () => {
export const getTranscodeBoardApi = () => { export const getTranscodeBoardApi = () => {
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' }) 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' })
}
+4 -2
View File
@@ -40,9 +40,10 @@ export const deleteDramaApi = (id: number) => {
return request.delete<void>({ url: `/admin/dramas/${id}` }) return request.delete<void>({ url: `/admin/dramas/${id}` })
} }
/** 审核与发布:submit / approve / reject / publish / offline */ /** 审核与发布:submit / approve / reject / publish / offline / approvePublish */
export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => { export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => {
return request.post<void>({ url: `/admin/dramas/${id}/${action}`, data: { reason } }) const path = action === 'approvePublish' ? 'approve-publish' : action
return request.post<void>({ url: `/admin/dramas/${id}/${path}`, data: { reason } })
} }
/** 下拉选项(短剧表单用),只返回 id/name */ /** 下拉选项(短剧表单用),只返回 id/name */
@@ -52,6 +53,7 @@ export const listDramaCategoryOptionsApi = () => {
export interface DramaCategorySaveData { export interface DramaCategorySaveData {
name: string name: string
locales: Record<string, string>
sort?: number sort?: number
status?: 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 })
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface LegalDocument {
docCode: 'terms' | 'privacy' | string
lang: 'zh' | 'en' | 'id' | string
title: string
content: string
}
export const listLegalDocumentsApi = () => {
return request.get<LegalDocument[]>({ url: '/admin/legal-documents' })
}
export const saveLegalDocumentsApi = (items: LegalDocument[]) => {
return request.put<void>({ url: '/admin/legal-documents', data: items })
}
+18 -1
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import request from '@/axios' import request from '@/axios'
import type { MediaAsset } from './types' import type { MediaAsset, MediaTranscodeProfile } from './types'
export interface UploadInitData { export interface UploadInitData {
fileName: string fileName: string
@@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => {
export const getMediaApi = (id: number) => { export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` }) return request.get<MediaAsset>({ url: `/admin/media/${id}` })
} }
/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */
export const getMediaTranscodeProfileApi = () => {
return request.get<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
silent: true
})
}
/** 保存转码档位配置,并返回服务端最终生效值。 */
export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => {
return request.put<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
data,
silent: true
})
}
+93
View File
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface RewardTaskItem {
id: string
code: string
sectionCode: string
type: string
verifyMode: string
targetValue: number
rewardCoin: number
cycleType: string
cycleLimit: number
actionType: string
actionUrl: string
iconUrl: string
titleId: string
titleEn: string
titleZh: string
subtitleId: string
subtitleEn: string
subtitleZh: string
ctaId: string
ctaEn: string
ctaZh: string
sectionTitleId: string
sectionTitleEn: string
sectionTitleZh: string
configJson: string
sort: number
status: number
statusText: string
startAt?: string
endAt?: string
}
export interface RewardTaskPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
type?: string
sectionCode?: string
status?: number
}
export type RewardTaskSaveData = Omit<RewardTaskItem, 'id' | 'status' | 'statusText'>
export interface RewardRuleDay {
dayIndex: number
rewardCoin: number
}
export interface RewardGrantItem {
id: string
userId: string
taskId: string
cycleKey: string
amount: number
status: string
idempotentKey: string
coinLedgerId: string
source: string
operatorId: string
failureReason: string
grantedAt?: string
createdAt?: string
}
export const pageRewardTasksApi = (params: RewardTaskPageParams) =>
request.get<PageResult<RewardTaskItem>>({ url: '/admin/rewards/tasks/page', params })
export const createRewardTaskApi = (data: RewardTaskSaveData) =>
request.post<string>({ url: '/admin/rewards/tasks', data })
export const updateRewardTaskApi = (id: string, data: RewardTaskSaveData) =>
request.put<void>({ url: `/admin/rewards/tasks/${id}`, data })
export const updateRewardTaskStatusApi = (id: string, status: number) =>
request.put<void>({ url: `/admin/rewards/tasks/${id}/status`, params: { status } })
export const getRewardRulesApi = () =>
request.get<RewardRuleDay[]>({ url: '/admin/rewards/check-in/rules' })
export const saveRewardRulesApi = (days: RewardRuleDay[]) =>
request.put<void>({ url: '/admin/rewards/check-in/rules', data: { days } })
export const pageRewardGrantsApi = (params: Record<string, unknown>) =>
request.get<PageResult<RewardGrantItem>>({ url: '/admin/rewards/grants/page', params })
export const manualRewardGrantApi = (data: Record<string, unknown>) =>
request.post<RewardGrantItem>({ url: '/admin/rewards/grants/manual', data })
+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' })
}
+124 -1
View File
@@ -8,6 +8,9 @@ export interface PageResult<T> {
export interface DramaCategory { export interface DramaCategory {
id: number id: number
name: string name: string
nameEn?: string
nameId?: string
locales?: Record<string, string>
sort?: number sort?: number
status?: number status?: number
/** 该类型下的短剧数量,管理列表才有 */ /** 该类型下的短剧数量,管理列表才有 */
@@ -26,6 +29,8 @@ export interface DramaItem {
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE' dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'> dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -54,6 +59,8 @@ export interface DramaSaveData {
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE' dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'> dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -66,7 +73,8 @@ export interface DramaSaveData {
sort?: number sort?: number
} }
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline' export type DramaReviewAction =
'submit' | 'approve' | 'reject' | 'publish' | 'offline' | 'approvePublish'
export interface EpisodeItem { export interface EpisodeItem {
id: number id: number
@@ -222,6 +230,115 @@ export interface MediaAsset {
createdAt?: string createdAt?: string
} }
/** 单档清晰度配置,字段与 FruitServer MediaTranscodeProfile.Rung 一致。 */
export interface MediaTranscodeLadderRung {
quality: string
width: number
height: number
videoBitrateKbps: number
audioBitrateKbps: number
enabled: boolean
}
/** 雪碧图采样配置,字段与 FruitServer MediaTranscodeProfile.Sprite 一致。 */
export interface MediaTranscodeSprite {
enabled: boolean
intervalSeconds: number
columns: number
thumbWidth: number
}
/** 转码档位配置,字段与 FruitServer MediaTranscodeProfile 一致。 */
export interface MediaTranscodeProfile {
enabled: boolean
segmentSeconds: number
ladder: MediaTranscodeLadderRung[]
sprite: MediaTranscodeSprite
}
export interface MediaRenditionItem {
id: string
quality: string
qualityName: string
width: number
height: number
resolution: string
videoBitrateKbps: number
audioBitrateKbps: number
videoCodec: string
audioCodec?: string
container: string
hlsUrl: string
sizeBytes: number
durationSec: number
segmentCount: number
status: string
}
export interface MediaThumbnailFrameItem {
frameIndex: number
timeMs: number
endTimeMs?: number
imageUrl: string
spriteUrl: string
x: number
y: number
width: number
height: number
}
export interface MediaThumbnailSpriteInfo {
spriteUrl?: string
spriteUrls: string[]
vttUrl?: string
columns: number
rows: number
totalFrames: number
thumbWidth: number
thumbHeight: number
intervalSeconds: number
}
export interface MediaThumbnailResult {
mediaAssetId?: string | null
episodeId?: string | null
/** READY / FAILED / EMPTY / INVALID / NO_EPISODE */
status: string
errorMsg?: string
pageIndex: number
pageSize: number
frames: PageResult<MediaThumbnailFrameItem>
sprite?: MediaThumbnailSpriteInfo | null
}
export interface EpisodeTranscodeItem {
episodeId: string
mediaAssetId?: string | null
taskId?: string | null
status: string
progress?: number
retryCount?: number
errorMsg?: string
updatedAt?: string
renditions: MediaRenditionItem[]
thumbnails?: MediaThumbnailResult | null
}
export interface TranscodeTriggerResult {
taskId: string
status: string
}
export interface TranscodeBackfillItem {
episodeId: string
taskId: string
}
export interface TranscodeBackfillResult {
triggered: number
items: TranscodeBackfillItem[]
}
export interface BannerItem { export interface BannerItem {
id: number id: number
imageUrl: string imageUrl: string
@@ -287,6 +404,10 @@ export interface AppUserItem {
maskedTarget: string maskedTarget: string
createdAt?: string createdAt?: string
lastLoginAt?: string lastLoginAt?: string
/** ANDROID / IOS / WEB */
platform?: string
/** 系统版本,例如 Android 16 */
osVersion?: string
/** 预留:会员等级与到期(第二阶段) */ /** 预留:会员等级与到期(第二阶段) */
vipLevel?: number vipLevel?: number
vipExpireAt?: string vipExpireAt?: string
@@ -326,6 +447,8 @@ export interface WatchHistoryItem {
finished?: boolean finished?: boolean
source: string source: string
deviceId: string deviceId: string
platform?: string
osVersion?: string
watchedAt?: string watchedAt?: string
} }
+1
View File
@@ -2,6 +2,7 @@
export type UserLoginType = { export type UserLoginType = {
username: string username: string
password?: string
} }
export type UserType = { 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
+2 -10
View File
@@ -5,7 +5,7 @@ import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus' import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue/offline'
import { normalizeAppIcon } from '@/constants/icons' import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -29,11 +29,6 @@ const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon) 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 getIconifyStyle = computed(() => {
const { color, size } = props const { color, size } = props
return { return {
@@ -49,10 +44,7 @@ const getIconifyStyle = computed(() => {
<use :xlink:href="symbolId" /> <use :xlink:href="symbolId" />
</svg> </svg>
<template v-else> <Icon v-else :icon="normalizedIcon" :style="getIconifyStyle" />
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template>
</ElIcon> </ElIcon>
</template> </template>
+12 -7
View File
@@ -40,10 +40,19 @@ const dialogVisible = ref<boolean>(false)
const lockScreen = () => { const lockScreen = () => {
dialogVisible.value = true dialogVisible.value = true
} }
// 点击整行菜单项都能触发(之前 click 绑在内层文字 div 上,点到菜单项左右内边距没反应)
const handleCommand = (command: string) => {
if (command === 'lock') {
lockScreen()
} else if (command === 'logout') {
loginOut()
}
}
</script> </script>
<template> <template>
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click"> <ElDropdown class="custom-hover" :class="prefixCls" trigger="click" @command="handleCommand">
<div class="flex items-center"> <div class="flex items-center">
<img <img
v-if="avatarUrl" v-if="avatarUrl"
@@ -63,12 +72,8 @@ const lockScreen = () => {
</div> </div>
<template #dropdown> <template #dropdown>
<ElDropdownMenu> <ElDropdownMenu>
<ElDropdownItem> <ElDropdownItem command="lock">{{ t('lock.lockScreen') }}</ElDropdownItem>
<div @click="lockScreen">{{ t('lock.lockScreen') }}</div> <ElDropdownItem command="logout">{{ t('common.loginOut') }}</ElDropdownItem>
</ElDropdownItem>
<ElDropdownItem>
<div @click="loginOut">{{ t('common.loginOut') }}</div>
</ElDropdownItem>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
</ElDropdown> </ElDropdown>
+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', delete: 'material-symbols:delete-rounded',
upload: 'material-symbols:upload-rounded', upload: 'material-symbols:upload-rounded',
download: 'material-symbols:download-rounded', download: 'material-symbols:download-rounded',
copy: 'material-symbols:content-copy-rounded',
openInNew: 'material-symbols:open-in-new-rounded',
visibility: 'material-symbols:visibility-rounded', visibility: 'material-symbols:visibility-rounded',
arrowUp: 'material-symbols:arrow-upward-rounded', arrowUp: 'material-symbols:arrow-upward-rounded',
arrowDown: 'material-symbols:arrow-downward-rounded', arrowDown: 'material-symbols:arrow-downward-rounded',
+29 -1
View File
@@ -6,9 +6,11 @@ import { Collapse } from '@/components/Collapse'
import { LocaleDropdown } from '@/components/LocaleDropdown' import { LocaleDropdown } from '@/components/LocaleDropdown'
import { SizeDropdown } from '@/components/SizeDropdown' import { SizeDropdown } from '@/components/SizeDropdown'
import { UserInfo } from '@/components/UserInfo' import { UserInfo } from '@/components/UserInfo'
import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
import { Screenfull } from '@/components/Screenfull' import { Screenfull } from '@/components/Screenfull'
import { Breadcrumb } from '@/components/Breadcrumb' import { Breadcrumb } from '@/components/Breadcrumb'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { useUserStore } from '@/store/modules/user'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls, variables } = useDesign() const { getPrefixCls, variables } = useDesign()
@@ -17,6 +19,16 @@ const prefixCls = getPrefixCls('tool-header')
const appStore = useAppStore() const appStore = useAppStore()
const userStore = useUserStore()
// 转码通知接口要求 drama:list,创作者账号没有该权限。
// 不给入口,避免顶栏每 30 秒轮询打一次 403。
const canSeeTranscodeNotice = computed(() => {
const permissions = userStore.getUserInfo?.permissions
if (Array.isArray(permissions)) return permissions.includes('drama:list')
return typeof permissions === 'string' && permissions.split(',').includes('drama:list')
})
// 面包屑 // 面包屑
const breadcrumb = computed(() => appStore.getBreadcrumb) const breadcrumb = computed(() => appStore.getBreadcrumb)
@@ -73,6 +85,9 @@ export default defineComponent({
color="var(--top-header-text-color)" color="var(--top-header-text-color)"
></LocaleDropdown> ></LocaleDropdown>
) : undefined} ) : undefined}
{canSeeTranscodeNotice.value ? (
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
) : undefined}
<UserInfo></UserInfo> <UserInfo></UserInfo>
</div> </div>
</div> </div>
@@ -93,9 +108,22 @@ export default defineComponent({
:deep(.v-user-info) { :deep(.v-user-info) {
flex-shrink: 0; flex-shrink: 0;
} }
// 顶栏图标的点击热区原来只有 18px 图标本身,容易点偏导致「点了没反应」。
// 统一撑满顶栏高度并左右留出内边距。
:deep(.v-screenfull),
:deep(.v-size-dropdown),
:deep(.v-locale-dropdown),
:deep(.v-user-info) {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
cursor: pointer;
}
} }
@media (max-width: 768px) { @media (width <= 768px) {
.@{prefix-cls} { .@{prefix-cls} {
padding-right: 8px; padding-right: 8px;
padding-left: 8px; padding-left: 8px;
@@ -0,0 +1,91 @@
<!-- 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) {
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
top: calc(50% - 9px);
right: 9px;
transform: translate(50%, -50%);
}
</style>
+115 -1
View File
@@ -163,7 +163,8 @@ export default {
treeTable: 'Tree table', treeTable: 'Tree table',
PicturePreview: 'Table Image Preview', PicturePreview: 'Table Image Preview',
department: 'Department management', department: 'Department management',
menuManagement: 'Menu management' menuManagement: 'Menu management',
transcodeSettings: 'Transcode settings'
}, },
permission: { permission: {
hasPermission: 'Please set the operation permission value' hasPermission: 'Please set the operation permission value'
@@ -528,6 +529,69 @@ export default {
title: 'InputPassword', title: 'InputPassword',
inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus'
}, },
episodeTranscode: {
title: 'Transcode outputs',
description: 'Review the transcode task, renditions and frame previews for this episode.',
statusLabel: 'Status',
progress: 'Progress',
retryCount: 'Retries',
updatedAt: 'Last updated',
waitingProgress: 'Processing (waiting for progress report)',
reprocess: 'Transcode again',
reprocessing: 'Submitting',
triggerSuccess: 'Transcode task submitted',
loadFailed: 'Failed to load transcode outputs',
retryLoad: 'Reload',
triggerFailed: 'Failed to retry transcode',
noMedia: 'No uploaded media is bound to this episode yet.',
noRendition: 'No rendition outputs',
renditionTitle: 'Rendition outputs',
quality: 'Quality',
resolution: 'Resolution',
videoBitrate: 'Video bitrate',
audioBitrate: 'Audio bitrate',
codec: 'Codec',
container: 'Container',
fileSize: 'File size',
duration: 'Duration',
segments: 'Segments',
statusColumn: 'Status',
actions: 'Actions',
copyUrl: 'Copy playback URL',
openUrl: 'Open in new window',
play: 'Preview playback',
playerFailed: 'Playback failed, please check the rendition output',
playerUnsupported: 'This browser does not support HLS playback',
copySuccess: 'Playback URL copied',
copyFailed: 'Failed to copy playback URL',
openFailed: 'Unable to open playback URL',
frameTitle: 'Frame previews',
frameView: 'Frames',
spriteView: 'Sprites',
frameCount: '{total} frames',
frameTime: 'Timestamp',
previewFrame: 'Preview frame at {time}',
spriteSheet: 'Sprite {index}',
spriteSheetFrames: '{count} frames',
noFrames: 'No frame previews',
noSprites: 'No sprite sheets available',
zoomTitle: 'Frame preview',
status: {
queued: 'Queued',
running: 'Transcoding',
success: 'Success',
failed: 'Failed',
empty: 'No output',
noVideo: 'No video',
noMediaAsset: 'No media asset',
noTask: 'No transcode task',
noEpisode: 'No episode binding',
episodeNotFound: 'Episode not found',
invalid: 'Invalid output',
ready: 'Ready',
unknown: 'Unknown'
}
},
episodeBatch: { episodeBatch: {
title: 'Batch upload episodes', title: 'Batch upload episodes',
entry: 'Batch upload', entry: 'Batch upload',
@@ -581,5 +645,55 @@ export default {
failed: 'Failed', failed: 'Failed',
paused: 'Paused' 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'
}
} }
} }
+111 -1
View File
@@ -163,7 +163,8 @@ export default {
treeTable: '树形表格', treeTable: '树形表格',
PicturePreview: '表格图片预览', PicturePreview: '表格图片预览',
department: '部门管理', department: '部门管理',
menuManagement: '菜单管理' menuManagement: '菜单管理',
transcodeSettings: '转码设置'
}, },
permission: { permission: {
hasPermission: '请设置操作权限值' hasPermission: '请设置操作权限值'
@@ -522,6 +523,69 @@ export default {
title: '密码输入框', title: '密码输入框',
inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装'
}, },
episodeTranscode: {
title: '转码产物',
description: '查看当前分集的转码任务、清晰度与帧图产物。',
statusLabel: '状态',
progress: '进度',
retryCount: '重试次数',
updatedAt: '最近更新',
waitingProgress: '处理中(等待进度上报)',
reprocess: '重新转码',
reprocessing: '正在触发',
triggerSuccess: '已提交转码任务',
loadFailed: '转码产物加载失败',
retryLoad: '重新加载',
triggerFailed: '重新转码失败',
noMedia: '当前分集尚未绑定已上传视频,暂无可查询的转码产物。',
noRendition: '暂无清晰度产物',
renditionTitle: '清晰度产物',
quality: '档位名称',
resolution: '分辨率',
videoBitrate: '视频码率',
audioBitrate: '音频码率',
codec: '编码',
container: '容器',
fileSize: '文件大小',
duration: '时长',
segments: '分片数',
statusColumn: '状态',
actions: '操作',
copyUrl: '复制播放地址',
openUrl: '新窗口打开',
play: '预览播放',
playerFailed: '播放失败,请检查产物是否完整',
playerUnsupported: '当前浏览器不支持 HLS 播放',
copySuccess: '播放地址已复制',
copyFailed: '复制播放地址失败',
openFailed: '无法打开播放地址',
frameTitle: '帧图预览',
frameView: '逐帧',
spriteView: '雪碧图',
frameCount: '共 {total} 帧',
frameTime: '时间点',
previewFrame: '预览 {time} 帧图',
spriteSheet: '雪碧图 {index}',
spriteSheetFrames: '{count} 帧',
noFrames: '暂无帧图产物',
noSprites: '暂无可展示的雪碧图',
zoomTitle: '帧图预览',
status: {
queued: '排队中',
running: '转码中',
success: '成功',
failed: '失败',
empty: '暂无产物',
noVideo: '无视频',
noMediaAsset: '未绑定媒资',
noTask: '无转码任务',
noEpisode: '未绑定分集',
episodeNotFound: '分集不存在',
invalid: '产物异常',
ready: '就绪',
unknown: '未知状态'
}
},
episodeBatch: { episodeBatch: {
title: '批量上传剧集', title: '批量上传剧集',
entry: '批量上传', entry: '批量上传',
@@ -573,5 +637,51 @@ export default {
failed: '失败', failed: '失败',
paused: '已暂停' 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 // 引入unocss
import '@/plugins/unocss' import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标 // 导入全局的svg图标
import '@/plugins/svgIcon' import '@/plugins/svgIcon'
+19 -2
View File
@@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user' import { useUserStoreWithOut } from '@/store/modules/user'
import { getAdminRoleApi, getProfileApi } from '@/api/login' import { getAdminRoleApi, getProfileApi } from '@/api/login'
import { landingPath } from '@/utils/landing' import { landingPath } from '@/utils/landing'
import { supplementLocalAdminRoutes } from '@/router/localRoutes'
const { start, done } = useNProgress() const { start, done } = useNProgress()
@@ -24,13 +25,20 @@ router.beforeEach(async (to, from, next) => {
const userStore = useUserStoreWithOut() const userStore = useUserStoreWithOut()
if (userStore.getUserInfo) { if (userStore.getUserInfo) {
if (to.path === '/login') { if (to.path === '/login') {
next({ path: '/' }) // 已登录再访问登录页:按当前账号的菜单落地,不能回 '/'(静态路由会 redirect 到 /dashboard/index,
// 创作者没有该路由,直接掉 404)
next({ path: landingPath(), replace: true })
} else { } else {
if (permissionStore.getIsAddRouters) { if (permissionStore.getIsAddRouters) {
// 已加过路由:只有还是同一个账号才能直接放行。 // 已加过路由:只有还是同一个账号才能直接放行。
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的 // 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
const username = userStore.getUserInfo?.username || '' const username = userStore.getUserInfo?.username || ''
if (permissionStore.getRoutersOwner === username) { if (permissionStore.getRoutersOwner === username) {
// 访问根路径时同样按账号菜单落地,避免走静态 redirect 掉进 /dashboard/index
if (to.path === '/') {
next({ path: landingPath(), replace: true })
return
}
next() next()
return return
} }
@@ -68,6 +76,10 @@ router.beforeEach(async (to, from, next) => {
} catch { } catch {
// 拉取失败时退回上次缓存的菜单,不阻塞进入 // 拉取失败时退回上次缓存的菜单,不阻塞进入
} }
const permissions = Array.isArray(userStore.getUserInfo?.permissions)
? userStore.getUserInfo.permissions
: []
routers = supplementLocalAdminRoutes(routers, permissions)
await permissionStore.generateRoutes('server', routers) await permissionStore.generateRoutes('server', routers)
} else { } else {
await permissionStore.generateRoutes('frontEnd', roleRouters as string[]) await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
@@ -91,7 +103,12 @@ router.beforeEach(async (to, from, next) => {
const redirectPath = from.query.redirect || to.path const redirectPath = from.query.redirect || to.path
const redirect = decodeURIComponent(redirectPath as string) 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) permissionStore.setIsAddRouters(true)
next(nextData) next(nextData)
} }
+2 -18
View File
@@ -2,22 +2,12 @@
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { import { BarChart, LineChart, PieChart } from 'echarts/charts'
BarChart,
LineChart,
PieChart,
MapChart,
PictorialBarChart,
RadarChart
} from 'echarts/charts'
import { import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
GridComponent, GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent LegendComponent
} from 'echarts/components' } from 'echarts/components'
@@ -28,16 +18,10 @@ echarts.use([
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
GridComponent, GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
BarChart, BarChart,
LineChart, LineChart,
PieChart, PieChart,
MapChart, CanvasRenderer
CanvasRenderer,
PictorialBarChart,
RadarChart
]) ])
export default echarts 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)
+56 -2
View File
@@ -8,9 +8,10 @@ import { AppIcons } from '@/constants/icons'
export const constantRouterMap: AppRouteRecordRaw[] = [ export const constantRouterMap: AppRouteRecordRaw[] = [
{ {
// 不写死 redirect:vue-router 会在守卫之前就把 '/' 解析成 redirect 目标,
// 创作者没有 /dashboard/index 路由会直接 404。落地页交给 permission.ts 按账号菜单决定。
path: '/', path: '/',
component: Layout, component: Layout,
redirect: '/dashboard/index',
name: 'Root', name: 'Root',
meta: { meta: {
hidden: true hidden: true
@@ -52,6 +53,35 @@ export const constantRouterMap: AppRouteRecordRaw[] = [
title: '404', title: '404',
noTagsView: true 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 +150,15 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: { meta: {
title: '类型管理' title: '类型管理'
} }
},
{
path: 'media-transcode',
name: 'media-transcode',
component: () => import('@/views/systemManagement/TranscodeSettingsView.vue'),
meta: {
title: 'router.transcodeSettings',
permission: ['episode:upload']
}
} }
] ]
}, },
@@ -202,6 +241,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: { meta: {
title: '操作日志' title: '操作日志'
} }
},
{
path: 'legal-documents',
name: 'legal-documents',
component: () => import('@/views/systemManagement/LegalDocumentView.vue'),
meta: {
title: '协议设置'
}
} }
] ]
} }
@@ -215,7 +262,14 @@ const router = createRouter({
}) })
export const resetRouter = (): void => { export const resetRouter = (): void => {
const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root'] const resetWhiteNameList = [
'Redirect',
'RedirectParent',
'Login',
'NoFind',
'Root',
'UnresolvedPath'
]
router.getRoutes().forEach((route) => { router.getRoutes().forEach((route) => {
const { name } = route const { name } = route
if (name && !resetWhiteNameList.includes(name as string)) { 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, // 面包屑 breadcrumb: true, // 面包屑
breadcrumbIcon: true, // 面包屑图标 breadcrumbIcon: true, // 面包屑图标
collapse: false, // 折叠菜单 collapse: false, // 折叠菜单
uniqueOpened: false, // 是否只保持一个子菜单的展开 uniqueOpened: true, // 是否只保持一个子菜单的展开(菜单手风琴)
hamburger: true, // 折叠图标 hamburger: true, // 折叠图标
screenfull: true, // 全屏图标 screenfull: true, // 全屏图标
size: true, // 尺寸图标 size: true, // 尺寸图标
@@ -71,9 +71,9 @@ export const useAppStore = defineStore('app', {
// 左侧菜单边框颜色 // 左侧菜单边框颜色
leftMenuBorderColor: 'inherit', leftMenuBorderColor: 'inherit',
// 左侧菜单背景颜色 // 左侧菜单背景颜色
leftMenuBgColor: '#001529', leftMenuBgColor: '#fff',
// 左侧菜单浅色背景颜色 // 左侧菜单浅色背景颜色
leftMenuBgLightColor: '#0f2438', leftMenuBgLightColor: '#fff',
// 左侧菜单选中背景颜色 // 左侧菜单选中背景颜色
leftMenuBgActiveColor: 'var(--app-primary-strong)', leftMenuBgActiveColor: 'var(--app-primary-strong)',
// 左侧菜单收起选中背景颜色 // 左侧菜单收起选中背景颜色
@@ -346,8 +346,10 @@ export const useAppStore = defineStore('app', {
if (context.store.theme?.elColorPrimary !== '#c4322a') { if (context.store.theme?.elColorPrimary !== '#c4322a') {
context.store.setTheme({ 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.setToken('')
this.setUserInfo(undefined) this.setUserInfo(undefined)
this.setRoleRouters([]) this.setRoleRouters([])
this.setLoginInfo(undefined) // 注意:不能清 loginInfo / rememberMe —— 那是「记住我」存的账号密码,
this.setRememberMe(false) // 退出登录后回到登录页还要靠它来回填,清了就等于「记住我」没生效。
tagsViewStore.delAllViews() tagsViewStore.delAllViews()
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表 // 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
permissionStore.clearRoutes() permissionStore.clearRoutes()
@@ -104,17 +104,7 @@ export const useUserStore = defineStore('user', {
this.loginInfo = loginInfo this.loginInfo = loginInfo
} }
}, },
persist: { persist: true
afterRestore: (context) => {
const loginInfo = context.store.loginInfo as
(UserLoginType & { password?: string }) | undefined
if (loginInfo?.password !== undefined) {
context.store.setLoginInfo(
loginInfo.username ? { username: loginInfo.username } : undefined
)
}
}
}
}) })
export const useUserStoreWithOut = () => { export const useUserStoreWithOut = () => {
+3 -1
View File
@@ -18,7 +18,8 @@
} }
.app-user-toolbar .app-user-status, .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; width: 150px;
flex: 0 0 150px; flex: 0 0 150px;
} }
@@ -63,6 +64,7 @@
.app-user-meta { .app-user-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
gap: 2px; gap: 2px;
min-width: 0; min-width: 0;
} }
+159
View File
@@ -188,6 +188,19 @@
gap: 10px; 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 { .creator-cover-preview {
display: grid; display: grid;
place-content: center; place-content: center;
@@ -285,6 +298,11 @@ html.dark .creator-guide-title {
margin-bottom: 14px; margin-bottom: 14px;
} }
.creator-switch-hint {
margin-left: 10px;
font-size: 12px;
}
html.dark .creator-profile-name, html.dark .creator-profile-name,
html.dark .creator-profile-meta b, html.dark .creator-profile-meta b,
html.dark .creator-drama-title, html.dark .creator-drama-title,
@@ -303,3 +321,144 @@ html.dark .creator-episode-cover {
border-color: var(--el-border-color-light); border-color: var(--el-border-color-light);
background: var(--el-fill-color-light); background: var(--el-fill-color-light);
} }
/* ---------- 移动端(创作者多手机上传) ---------- */
.creator-mobile-only {
display: none;
}
/* 作品表格:列宽合计超过容器时由外层横向滚动,
不依赖 el-table 内部的 scrollbar(其 header/body 会被各自裁切)。 */
.creator-desktop-only {
overflow-x: auto;
}
.creator-desktop-only .el-table.drama-el-table {
min-width: 1150px;
}
/* 作品编辑页:分集 / 提交记录表格由外层容器横向滚动(宽度见各表格 inline min-width) */
.creator-table-scroll {
overflow-x: auto;
}
/* 操作列按钮放不下时换行,避免「删除」被表格裁掉 */
.creator-table-scroll .table-actions {
flex-wrap: wrap;
white-space: normal;
row-gap: 6px;
}
.creator-video-preview {
display: block;
width: 100%;
max-height: 70vh;
background: #000;
}
@media (width <= 768px) {
.creator-desktop-only {
display: none;
}
.creator-mobile-only {
display: block;
}
.creator-profile-meta {
flex-wrap: wrap;
gap: 8px 16px;
}
.console-toolbar {
flex-wrap: wrap;
}
.console-toolbar .console-keyword,
.console-toolbar .console-select {
width: 100%;
}
.creator-drama-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.creator-drama-card {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid #e6e8eb;
border-radius: var(--app-radius-md);
background: #fff;
}
.creator-drama-card-cover {
display: grid;
flex-shrink: 0;
place-content: center;
width: 60px;
height: 80px;
overflow: hidden;
color: #9a97a8;
font-size: 16px;
background: #eef0f5;
border-radius: var(--app-radius-sm);
}
.creator-drama-card-cover img,
.creator-drama-card-cover .creator-drama-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-drama-card-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.creator-drama-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.creator-drama-card-head .creator-drama-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.creator-drama-card-meta {
display: flex;
gap: 12px;
color: #727983;
font-size: 12px;
}
.creator-drama-card-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.pagination {
justify-content: center;
}
html.dark .creator-drama-card {
border-color: var(--el-border-color-light);
background: var(--el-bg-color-overlay);
}
html.dark .creator-drama-card-cover {
background: var(--el-fill-color-light);
}
}
+23
View File
@@ -165,6 +165,19 @@
line-height: 1.4; 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 { .cover-editor {
display: flex; display: flex;
gap: 18px; gap: 18px;
@@ -183,6 +196,12 @@
background: var(--el-fill-color-lighter); background: var(--el-fill-color-lighter);
} }
.cover-preview-card--landscape {
flex: 0 0 160px;
width: 160px;
height: 90px;
}
.cover-preview-card img { .cover-preview-card img {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -297,6 +316,10 @@
flex-basis: 128px; flex-basis: 128px;
} }
.cover-preview-card--landscape {
flex-basis: 160px;
}
.cover-editor-meta { .cover-editor-meta {
width: 100%; width: 100%;
} }
+9 -7
View File
@@ -6,23 +6,25 @@
--left-menu-min-width: 64px; --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 */ /* left menu end */
/* logo start */ /* logo start */
--logo-height: 50px; --logo-height: 50px;
--logo-title-text-color: #fff; --logo-title-text-color: #303133;
/* logo end */ /* logo end */
/* header start */ /* 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))
}
+78
View File
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
/** 从本地视频文件截取封面(3:4 居中裁切),抖音式自动封面用 */
export interface CapturedCover {
file: File
previewUrl: string
}
function loadVideo(file: File): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
const src = URL.createObjectURL(file)
const fail = (message: string) => {
URL.revokeObjectURL(src)
reject(new Error(message))
}
video.onloadedmetadata = () => {
URL.revokeObjectURL(src)
resolve(video)
}
video.onerror = () => fail('当前浏览器无法解析该视频格式')
video.src = src
})
}
/** 截取第 atSeconds 秒画面,按 3:4 居中裁切输出 720×960 JPEG */
export async function captureVideoCover(file: File, atSeconds = 1): Promise<CapturedCover> {
const video = await loadVideo(file)
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0
// 太短的视频从开头截,正常视频取第 1 秒,避免黑场
const target = duration ? Math.min(atSeconds, Math.max(0.1, duration * 0.2)) : 0
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('截取封面超时')), 8000)
video.onseeked = () => {
window.clearTimeout(timer)
resolve()
}
video.currentTime = target
})
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) throw new Error('无法读取视频尺寸')
const ratio = 3 / 4
let sw = width
let sh = height
if (width / height > ratio) {
sw = height * ratio
} else {
sh = width / ratio
}
const sx = (width - sw) / 2
const sy = (height - sh) / 2
const canvas = document.createElement('canvas')
canvas.width = 720
canvas.height = 960
const context = canvas.getContext('2d')
if (!context) throw new Error('当前浏览器不支持 canvas')
context.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('封面生成失败'))),
'image/jpeg',
0.85
)
})
const baseName = file.name.replace(/\.[^.]+$/, '') || 'cover'
return {
file: new File([blob], `${baseName}-cover.jpg`, { type: 'image/jpeg' }),
previewUrl: URL.createObjectURL(blob)
}
}
+146 -11
View File
@@ -31,6 +31,94 @@ const themeChange = () => {
<img src="@/assets/imgs/brand-mark.png" alt="" /> <img src="@/assets/imgs/brand-mark.png" alt="" />
<span>{{ underlineToHump(appStore.getTitle) }}</span> <span>{{ underlineToHump(appStore.getTitle) }}</span>
</div> </div>
<div class="hero-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"> <div class="hero-copy">
<p class="hero-kicker">{{ t('login.consoleLabel') }}</p> <p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
<h1>{{ t('login.welcome') }}</h1> <h1>{{ t('login.welcome') }}</h1>
@@ -81,16 +169,58 @@ const themeChange = () => {
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
&::before,
&::after { &::after {
position: absolute; position: absolute;
right: -80px; border: 1px solid rgb(255 255 255 / 12%);
bottom: -120px;
width: 360px;
height: 360px;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 50%; border-radius: 50%;
content: ''; 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 { .hero-brand {
@@ -113,13 +243,14 @@ const themeChange = () => {
.hero-copy { .hero-copy {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: 460px; max-width: 420px;
h1 { h1 {
margin: 10px 0 12px; margin: 12px 0 0;
font-size: 40px; font-size: 44px;
font-weight: 700; font-weight: 600;
line-height: 1.2; line-height: 1.15;
letter-spacing: -0.03em;
color: #fff; color: #fff;
} }
} }
@@ -191,8 +322,12 @@ const themeChange = () => {
padding: 16px 24px 20px; padding: 16px 24px 20px;
} }
.hero-fruits {
display: none;
}
.hero-copy h1 { .hero-copy h1 {
margin: 6px 0 8px; margin: 6px 0 0;
font-size: 28px; font-size: 28px;
} }
+5 -3
View File
@@ -132,7 +132,7 @@ const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => { const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo const loginInfo = userStore.getLoginInfo
if (loginInfo) { if (loginInfo) {
setValues({ username: loginInfo.username }) setValues({ username: loginInfo.username, password: loginInfo.password })
} }
} }
onMounted(() => { onMounted(() => {
@@ -169,10 +169,12 @@ const signIn = async () => {
const res = await loginApi(formData) const res = await loginApi(formData)
if (res) { if (res) {
// 是否记住我 // 是否记住我:勾选时把账号密码一起持久化到 localStorage,
// 下次打开登录页自动回填,免重复输入。
if (unref(remember)) { if (unref(remember)) {
userStore.setLoginInfo({ userStore.setLoginInfo({
username: formData.username username: formData.username,
password: formData.password
}) })
} else { } else {
userStore.setLoginInfo(undefined) userStore.setLoginInfo(undefined)
@@ -0,0 +1,616 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell, formatCount } from '@/utils/format'
import {
createRewardTaskApi,
getRewardRulesApi,
manualRewardGrantApi,
pageRewardGrantsApi,
pageRewardTasksApi,
saveRewardRulesApi,
updateRewardTaskApi,
updateRewardTaskStatusApi
} from '@/api/admin/reward'
const { can } = useRbac()
const activeTab = ref('tasks')
const typeOptions = [
['DAILY_CHECK_IN', '每日签到'],
['WATCH_DURATION', '观看时长'],
['WATCH_EPISODES', '观看集数'],
['FIRST_LOGIN', '首次登录'],
['COMPLETE_PROFILE', '完善资料'],
['FIRST_PURCHASE', '首次购买'],
['FOLLOW_SOCIAL', '关注社交账号'],
['INVITE_FRIEND', '邀请好友'],
['CUSTOM_LINK', '自定义任务']
]
const cycleOptions = [
['ONCE', '一次性'],
['DAILY', '每日'],
['WEEKLY', '每周']
]
const sectionOptions = [
['CHECK_IN', '签到奖励'],
['DAILY', '每日任务'],
['NEWBIE', '新手任务'],
['ACTIVITY', '活动任务']
]
const actionOptions = [
['NONE', '无动作'],
['OPEN_HOME', '打开首页'],
['OPEN_PROFILE', '打开个人资料'],
['OPEN_WALLET', '打开钱包'],
['OPEN_URL', '打开链接']
]
const tasks = reactive({ rows: [], total: 0, page: 1, size: 10, loading: false })
const filters = reactive({
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const emptyForm = () => ({
code: '',
sectionCode: 'DAILY',
type: 'WATCH_DURATION',
verifyMode: 'SERVER',
targetValue: 1,
rewardCoin: 10,
cycleType: 'DAILY',
cycleLimit: 1,
actionType: 'OPEN_HOME',
actionUrl: '',
iconUrl: '',
titleId: '',
titleEn: '',
titleZh: '',
subtitleId: '',
subtitleEn: '',
subtitleZh: '',
ctaId: 'Mulai',
ctaEn: 'Start',
ctaZh: '去完成',
sectionTitleId: 'Tugas harian',
sectionTitleEn: 'Daily tasks',
sectionTitleZh: '每日任务',
configJson: '',
sort: 0,
startAt: null,
endAt: null
})
const form = reactive(emptyForm())
async function loadTasks() {
tasks.loading = true
try {
const res = await pageRewardTasksApi({
pageIndex: tasks.page,
pageSize: tasks.size,
keyword: filters.keyword.trim() || undefined,
type: filters.type,
sectionCode: filters.sectionCode,
status: filters.status
})
tasks.rows = res.data?.list || []
tasks.total = res.data?.total || 0
} finally {
tasks.loading = false
}
}
function searchTasks() {
tasks.page = 1
loadTasks()
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
searchTasks()
}
function openCreate() {
editingId.value = null
Object.assign(form, emptyForm(), { sort: tasks.total + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, emptyForm(), row)
dialogVisible.value = true
}
function validateTask() {
if (!form.code.trim() || !form.titleId.trim() || !form.sectionTitleId.trim())
return '请填写编码、印尼语标题和分组标题'
if (form.targetValue <= 0 || form.rewardCoin < 0) return '目标值必须大于 0,奖励金币不能小于 0'
return ''
}
async function saveTask() {
const invalid = validateTask()
if (invalid) return ElMessage.warning(invalid)
saving.value = true
try {
const payload = {
...form,
code: form.code.trim(),
titleId: form.titleId.trim(),
sectionTitleId: form.sectionTitleId.trim()
}
if (editingId.value) await updateRewardTaskApi(editingId.value, payload)
else await createRewardTaskApi(payload)
ElMessage.success(editingId.value ? '任务已更新' : '任务已创建,默认下线')
dialogVisible.value = false
await loadTasks()
} finally {
saving.value = false
}
}
async function toggleTask(row) {
await updateRewardTaskStatusApi(row.id, row.status === 1 ? 0 : 1)
await loadTasks()
}
const rules = ref([])
const rulesLoading = ref(false)
const rulesSaving = ref(false)
async function loadRules() {
rulesLoading.value = true
try {
rules.value = (await getRewardRulesApi()).data || []
} finally {
rulesLoading.value = false
}
}
async function saveRules() {
rulesSaving.value = true
try {
await saveRewardRulesApi(
rules.value.map(({ dayIndex, rewardCoin }) => ({ dayIndex, rewardCoin }))
)
ElMessage.success('签到规则已保存')
} finally {
rulesSaving.value = false
}
}
const grants = reactive({
rows: [],
total: 0,
page: 1,
size: 10,
loading: false,
userId: '',
status: undefined
})
const grantVisible = ref(false)
const grantSaving = ref(false)
const grantForm = reactive({ userId: '', taskId: '', amount: 10, idempotentKey: '', reason: '' })
async function loadGrants() {
grants.loading = true
try {
const res = await pageRewardGrantsApi({
pageIndex: grants.page,
pageSize: grants.size,
userId: grants.userId || undefined,
status: grants.status
})
grants.rows = res.data?.list || []
grants.total = res.data?.total || 0
} finally {
grants.loading = false
}
}
function queryGrants() {
grants.page = 1
loadGrants()
}
function openGrant() {
Object.assign(grantForm, {
userId: grants.userId,
taskId: '',
amount: 10,
idempotentKey: '',
reason: ''
})
grantVisible.value = true
}
async function confirmGrant() {
if (!grantForm.userId || grantForm.amount <= 0)
return ElMessage.warning('请填写用户 ID 和补发金币')
grantSaving.value = true
try {
await manualRewardGrantApi({
...grantForm,
taskId: grantForm.taskId || undefined,
idempotentKey: grantForm.idempotentKey || undefined
})
ElMessage.success('奖励已通过钱包流水补发')
grantVisible.value = false
await loadGrants()
} finally {
grantSaving.value = false
}
}
function handleTab(name) {
if (name === 'rules' && !rules.value.length) loadRules()
if (name === 'grants' && !grants.rows.length) loadGrants()
}
function optionLabel(options, value) {
return options.find((item) => item[0] === value)?.[1] || value || '-'
}
onMounted(loadTasks)
</script>
<template>
<PageHeading
eyebrow="订单与权益"
title="奖励任务"
description="配置奖励页任务、签到规则和发放记录,所有金币变动均写钱包流水。"
>
<el-button
v-if="activeTab === 'tasks' && can('reward:update')"
type="primary"
@click="openCreate"
>新建任务</el-button
>
<el-button
v-if="activeTab === 'grants' && can('reward:grant')"
type="primary"
@click="openGrant"
>人工补发</el-button
>
</PageHeading>
<section class="panel reward-panel">
<el-tabs v-model="activeTab" @tab-change="handleTab">
<el-tab-pane label="任务配置" name="tasks">
<div class="reward-toolbar">
<el-input
v-model="filters.keyword"
clearable
placeholder="任务编码 / 标题"
@keyup.enter="searchTasks"
/>
<el-select v-model="filters.type" clearable placeholder="全部类型" @change="searchTasks"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select
v-model="filters.sectionCode"
clearable
placeholder="全部分组"
@change="searchTasks"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="searchTasks"
><el-option label="已上线" :value="1" /><el-option label="已下线" :value="0"
/></el-select>
<el-button @click="searchTasks">查询</el-button
><el-button @click="resetFilters">重置</el-button>
</div>
<el-table
v-loading="tasks.loading"
:data="tasks.rows"
row-key="id"
empty-text="暂无奖励任务"
>
<el-table-column prop="code" label="任务编码" min-width="150" />
<el-table-column label="标题" min-width="180"
><template #default="{ row }"
><strong>{{ row.titleId }}</strong
><div class="sub-copy">{{ row.titleZh || row.titleEn }}</div></template
></el-table-column
>
<el-table-column label="类型" width="130"
><template #default="{ row }">{{
optionLabel(typeOptions, row.type)
}}</template></el-table-column
>
<el-table-column label="分组" width="110"
><template #default="{ row }">{{
optionLabel(sectionOptions, row.sectionCode)
}}</template></el-table-column
>
<el-table-column label="目标 / 奖励" width="130"
><template #default="{ row }"
>{{ formatCount(row.targetValue) }} / +{{ formatCount(row.rewardCoin) }}</template
></el-table-column
>
<el-table-column label="周期" width="100"
><template #default="{ row }">{{
optionLabel(cycleOptions, row.cycleType)
}}</template></el-table-column
>
<el-table-column label="状态" width="90"
><template #default="{ row }"
><el-tag :type="row.status === 1 ? 'success' : 'info'">{{
row.statusText
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="170" fixed="right"
><template #default="{ row }"
><el-button v-if="can('reward:update')" link type="primary" @click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('reward:update')"
link
:type="row.status === 1 ? 'danger' : 'success'"
@click="toggleTask(row)"
>{{ row.status === 1 ? '下线' : '上线' }}</el-button
></template
></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="tasks.page"
v-model:page-size="tasks.size"
:total="tasks.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTasks"
@size-change="searchTasks"
/>
</el-tab-pane>
<el-tab-pane label="签到规则" name="rules">
<div v-loading="rulesLoading" class="rule-grid"
><div v-for="day in rules" :key="day.dayIndex" class="rule-card"
><span>第 {{ day.dayIndex }} 天</span
><el-input-number v-model="day.rewardCoin" :min="0" /><small>金币</small></div
></div
>
<el-button
v-if="can('reward:checkin')"
type="primary"
:loading="rulesSaving"
@click="saveRules"
>保存签到规则</el-button
>
</el-tab-pane>
<el-tab-pane label="发放记录" name="grants">
<div class="reward-toolbar"
><el-input
v-model="grants.userId"
clearable
placeholder="用户 ID"
@keyup.enter="queryGrants"
/><el-select
v-model="grants.status"
clearable
placeholder="全部状态"
@change="queryGrants"
><el-option label="已发放" value="GRANTED" /><el-option
label="待处理"
value="PENDING" /><el-option label="失败" value="FAILED" /></el-select
><el-button @click="queryGrants">查询</el-button></div
>
<el-table
v-loading="grants.loading"
:data="grants.rows"
row-key="id"
empty-text="暂无发放记录"
>
<el-table-column prop="userId" label="用户 ID" min-width="160" /><el-table-column
prop="taskId"
label="任务 ID"
min-width="160"
/><el-table-column label="金币" width="100"
><template #default="{ row }">+{{ formatCount(row.amount) }}</template></el-table-column
><el-table-column prop="source" label="来源" width="90" /><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="idempotentKey"
label="幂等键"
min-width="240"
show-overflow-tooltip
/><el-table-column label="发放时间" width="180"
><template #default="{ row }">{{
dateCell(row.grantedAt || row.createdAt)
}}</template></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="grants.page"
v-model:page-size="grants.size"
:total="grants.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadGrants"
@size-change="queryGrants"
/>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑奖励任务' : '新建奖励任务'"
width="780px"
>
<el-form label-position="top" class="task-form"
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="任务编码"
><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="任务类型"
><el-select v-model="form.type"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="分组"
><el-select v-model="form.sectionCode"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="6"
><el-form-item label="目标值"
><el-input-number v-model="form.targetValue" :min="1" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="奖励金币"
><el-input-number v-model="form.rewardCoin" :min="0" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="周期"
><el-select v-model="form.cycleType"
><el-option
v-for="item in cycleOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="排序"
><el-input-number v-model="form.sort" :min="0" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">多语言文案</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语标题"
><el-input v-model="form.titleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文标题"><el-input v-model="form.titleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文标题"><el-input v-model="form.titleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语说明"
><el-input v-model="form.subtitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文说明"
><el-input v-model="form.subtitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文说明"
><el-input v-model="form.subtitleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语分组标题"
><el-input v-model="form.sectionTitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文分组标题"
><el-input v-model="form.sectionTitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文分组标题"
><el-input v-model="form.sectionTitleZh" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">动作与扩展</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="完成按钮"><el-input v-model="form.ctaId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作"
><el-select v-model="form.actionType"
><el-option
v-for="item in actionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作链接"
><el-input v-model="form.actionUrl" /></el-form-item></el-col></el-row
><el-form-item label="扩展配置 JSON"
><el-input v-model="form.configJson" type="textarea" :rows="3"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" :loading="saving" @click="saveTask">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="grantVisible" title="人工补发奖励" width="520px"
><el-form label-position="top"
><el-form-item label="用户 ID"><el-input v-model="grantForm.userId" /></el-form-item
><el-form-item label="任务 ID(可选)"><el-input v-model="grantForm.taskId" /></el-form-item
><el-form-item label="补发金币"
><el-input-number v-model="grantForm.amount" :min="1" /></el-form-item
><el-form-item label="幂等键(重复补发时必须相同)"
><el-input v-model="grantForm.idempotentKey" /></el-form-item
><el-form-item label="原因"><el-input v-model="grantForm.reason" /></el-form-item></el-form
><template #footer
><el-button @click="grantVisible = false">取消</el-button
><el-button type="primary" :loading="grantSaving" @click="confirmGrant"
>确认补发</el-button
></template
></el-dialog
>
</template>
<style scoped>
.reward-panel {
padding: 20px;
}
.reward-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.reward-toolbar .el-input,
.reward-toolbar .el-select {
width: 180px;
}
.sub-copy {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-top: 4px;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(100px, 1fr));
gap: 12px;
margin: 8px 0 20px;
}
.rule-card {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
background: var(--el-fill-color-lighter);
}
.rule-card small {
color: var(--el-text-color-secondary);
}
.task-form :deep(.el-select),
.task-form :deep(.el-input-number) {
width: 100%;
}
.el-pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 1100px) {
.rule-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
+56 -29
View File
@@ -5,6 +5,8 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama' 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 { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status' import { statusClass } from '@/utils/status'
@@ -15,6 +17,8 @@ const { can } = useRbac()
const loading = ref(false) const loading = ref(false)
const dramas = ref([]) const dramas = ref([])
const total = ref(0) const total = ref(0)
const overview = ref(null)
const trend = ref(null)
const totalViews = computed(() => const totalViews = computed(() =>
dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0) dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0)
@@ -25,6 +29,12 @@ const reviewingCount = computed(
const publishedCount = computed( const publishedCount = computed(
() => dramas.value.filter((item) => item.status === 'published').length () => 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(() => const recentDramas = computed(() =>
[...dramas.value] [...dramas.value]
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))) .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
@@ -34,10 +44,16 @@ const recentDramas = computed(() =>
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
try { 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,这里不能让页面跟着崩 // 接口失败时 axios 拦截器会把业务错误吞成 undefined,这里不能让页面跟着崩
dramas.value = res?.data?.list || [] dramas.value = res?.data?.list || []
total.value = res?.data?.total || 0 total.value = res?.data?.total || 0
overview.value = board?.data || null
trend.value = trendRes?.data || null
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -45,8 +61,11 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="workbench">
<div class="page-heading" <div class="page-heading"
><div><div class="eyebrow">内容概览</div><h1>工作台</h1><p>查看短剧内容与近期处理事项。</p></div ><div
><div class="eyebrow">内容概览</div><h1>工作台</h1
><p>用户、设备、留存与短剧内容总览。</p></div
><el-button ><el-button
v-if="can('drama:create')" v-if="can('drama:create')"
type="primary" type="primary"
@@ -55,32 +74,12 @@ onMounted(async () => {
><AppIcon :icon="AppIcons.add" />上传新短剧</el-button ><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div ></div
> >
<div v-loading="loading" class="stats-grid"> <DashboardMetricWall
<div class="stat-card" v-loading="loading"
><div class="stat-top" :overview="overview"
><span>短剧总数</span :trend="trend"
><span class="stat-icon"><AppIcon :icon="AppIcons.content" /></span></div :fallback="metricFallback"
><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" <section class="panel recent-panel"
><div class="panel-heading" ><div class="panel-heading"
><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div ><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div
@@ -100,7 +99,9 @@ onMounted(async () => {
width="150" /><el-table-column label="集数" width="120" width="150" /><el-table-column label="集数" width="120"
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column ><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column label="播放量" width="140" ><el-table-column label="播放量" width="140"
><template #default="scope">{{ formatCount(scope?.row?.views) }}</template></el-table-column ><template #default="scope">{{
formatCount(scope?.row?.views)
}}</template></el-table-column
><el-table-column label="状态" width="130" ><el-table-column label="状态" width="130"
><template #default="scope" ><template #default="scope"
><el-tag ><el-tag
@@ -113,4 +114,30 @@ onMounted(async () => {
><el-table-column prop="updatedAt" label="更新时间" width="180" ><el-table-column prop="updatedAt" label="更新时间" width="180"
/></el-table> /></el-table>
</section> </section>
</div>
</template> </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 { pageDramasApi } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { uploadFile } from '@/utils/uploader' import { uploadFile } from '@/utils/uploader'
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac() const { can } = useRbac()
@@ -218,12 +219,13 @@ async function uploadImage(options) {
imagePercent.value = 0 imagePercent.value = 0
imageError.value = '' imageError.value = ''
try { try {
const asset = await uploadFile(options.file, { const file = await compressForPreset(options.file, BANNER_IMAGE)
const asset = await uploadFile(file, {
bizType: 'BANNER', bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent) onProgress: (percent) => (imagePercent.value = percent)
}) })
form.imageUrl = asset.url form.imageUrl = asset.url
imageFileName.value = options.file.name imageFileName.value = file.name
await resetValidation() await resetValidation()
ElMessage.success('图片已上传') ElMessage.success('图片已上传')
} catch { } catch {
@@ -467,7 +469,9 @@ onMounted(async () => {
{{ imageDisplayName }} {{ imageDisplayName }}
</span> </span>
</div> </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 <el-alert
v-if="imageError" v-if="imageError"
:title="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 { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import { import {
createDramaCategoryApi, createDramaCategoryApi,
deleteDramaCategoryApi, deleteDramaCategoryApi,
@@ -11,6 +17,7 @@ import {
updateDramaCategoryApi updateDramaCategoryApi
} from '@/api/admin/drama' } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac() const { can } = useRbac()
@@ -20,7 +27,8 @@ const list = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const editingId = ref(null) 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() { async function load() {
loading.value = true loading.value = true
@@ -38,21 +46,29 @@ function nextSort() {
function openCreate() { function openCreate() {
editingId.value = null 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 dialogVisible.value = true
} }
function openEdit(row) { function openEdit(row) {
editingId.value = row.id 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 dialogVisible.value = true
} }
async function saveCategory() { 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 saving.value = true
try { 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) { if (editingId.value) {
await updateDramaCategoryApi(editingId.value, payload) await updateDramaCategoryApi(editingId.value, payload)
ElMessage.success('类型已更新') ElMessage.success('类型已更新')
@@ -115,7 +131,20 @@ onMounted(load)
empty-text="还没有短剧类型" empty-text="还没有短剧类型"
> >
<el-table-column type="index" label="序号" width="80" /> <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"> <el-table-column label="短剧数" width="110">
<template #default="{ row }">{{ row.dramaCount || 0 }} 部</template> <template #default="{ row }">{{ row.dramaCount || 0 }} 部</template>
</el-table-column> </el-table-column>
@@ -146,11 +175,15 @@ onMounted(load)
</el-table> </el-table>
</section> </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 label-position="top">
<el-form-item label="类型名称" required> <LocaleTextFields
<el-input v-model="form.name" maxlength="20" placeholder="例如:都市情感" /> v-model:languages="languages"
</el-form-item> :locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
<div class="form-row"> <div class="form-row">
<el-form-item label="排序"> <el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" /> <el-input-number v-model="form.sort" :min="0" :max="999" />
@@ -166,3 +199,9 @@ onMounted(load)
</template> </template>
</el-dialog> </el-dialog>
</template> </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 { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' 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 { AppImage } from '@/components/AppImage'
import { import {
changeDramaStatusApi, changeDramaStatusApi,
@@ -16,11 +23,17 @@ import {
} from '@/api/admin/drama' } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
import { listContentTagsApi } from '@/api/admin/contentTag'
import { statusClass } from '@/utils/status' import { statusClass } from '@/utils/status'
import { uploadFile } from '@/utils/uploader' import { uploadFile } from '@/utils/uploader'
import {
DRAMA_COVER_LANDSCAPE,
DRAMA_COVER_PORTRAIT,
compressForPreset
} from '@/views/contentManagement/upload/imagePresets'
const router = useRouter() const router = useRouter()
const { can, hasAny } = useRbac() const { can } = useRbac()
const loading = ref(false) const loading = ref(false)
const list = ref([]) const list = ref([])
@@ -30,8 +43,7 @@ const categories = ref([])
const keyword = ref('') const keyword = ref('')
const statusFilter = ref(undefined) const statusFilter = ref(undefined)
const categoryIds = ref([]) const categoryIds = ref([])
const dramaTypes = ref([]) const tags = ref([])
const hotFilter = ref(undefined)
const pageIndex = ref(1) const pageIndex = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
@@ -41,40 +53,43 @@ const uploadingCover = ref(false)
const coverPercent = ref(0) const coverPercent = ref(0)
const coverError = ref('') const coverError = ref('')
const coverFileName = ref('') const coverFileName = ref('')
const uploadingHorizontal = ref(false)
const horizontalPercent = ref(0)
const horizontalError = ref('')
const horizontalFileName = ref('')
const editingId = ref(null) const editingId = ref(null)
const editingCommentCount = ref(0) const editingCommentCount = ref(0)
const editingLikeCount = ref(0) const editingLikeCount = ref(0)
const formRef = ref() const formRef = ref()
const textLanguages = ref(['zh'])
const form = reactive({ const form = reactive({
title: '', title: '',
titleLocales: { zh: '' },
categoryIds: [], categoryIds: [],
dramaTypes: ['LIVE'], tagId: null,
description: '', description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3, freeEpisodes: 3,
unlockCoin: 0, unlockCoin: 0,
favoriteCount: 0, favoriteCount: 0,
heat: 0, heat: 0,
isHot: false, isHot: false,
sort: 0, sort: 0,
cover: '' cover: '',
horizontalCover: ''
}) })
const MAX_COVER_SIZE = 5 * 1024 * 1024 const MAX_COVER_SIZE = 5 * 1024 * 1024
const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }],
categoryIds: [
{ type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' }
],
dramaTypes: [
{ type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' }
]
}
const coverDisplayName = computed( const coverDisplayName = computed(
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面') () => 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)) const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
@@ -90,17 +105,13 @@ const statusOptions = [
{ code: 'offline', text: '已下线' } { code: 'offline', text: '已下线' }
] ]
const dramaTypeOptions = [
{ value: 'ANIME', label: '漫剧' },
{ value: 'LIVE', label: '真人剧' }
]
const actionMeta = { const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' }, submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' }, approve: { label: '审核通过', done: '已通过审核' },
reject: { label: '驳回', done: '已驳回' }, reject: { label: '驳回', done: '已驳回' },
publish: { label: '上线', done: '已上线' }, publish: { label: '上线', done: '已上线' },
offline: { label: '下线', done: '已下线' } offline: { label: '下线', done: '已下线' },
approvePublish: { label: '审核通过并上线', done: '已通过审核并上线' }
} }
async function load() { async function load() {
@@ -111,9 +122,7 @@ async function load() {
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined, keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined, status: statusFilter.value || undefined,
categoryIds: categoryIds.value.length ? categoryIds.value : undefined, categoryIds: categoryIds.value.length ? categoryIds.value : undefined
dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined,
isHot: hotFilter.value
}) })
list.value = res.data?.list || [] list.value = res.data?.list || []
total.value = res.data?.total || 0 total.value = res.data?.total || 0
@@ -127,6 +136,11 @@ async function loadCategories() {
categories.value = res.data || [] categories.value = res.data || []
} }
async function loadTags() {
const res = await listContentTagsApi(true)
tags.value = res.data || []
}
function search() { function search() {
pageIndex.value = 1 pageIndex.value = 1
load() load()
@@ -136,8 +150,6 @@ function resetFilters() {
keyword.value = '' keyword.value = ''
statusFilter.value = undefined statusFilter.value = undefined
categoryIds.value = [] categoryIds.value = []
dramaTypes.value = []
hotFilter.value = undefined
search() search()
} }
@@ -146,34 +158,63 @@ function handleSizeChange() {
load() 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) { function coverBasename(path) {
if (!path) return '' if (!path) return ''
const clean = path.split('?')[0].split('#')[0] const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean) return decodeURIComponent(clean.split('/').pop() || clean)
} }
/** 长整型计数字段在接口里是字符串,数字输入框只接受 number。 */
function toCount(value) {
const count = Number(value)
return Number.isFinite(count) ? count : 0
}
async function resetValidation() { async function resetValidation() {
await nextTick() await nextTick()
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
function changeTextLanguages(codes) {
textLanguages.value = codes
codes.forEach((code) => {
if (form.synopsisLocales[code] == null) form.synopsisLocales[code] = ''
})
}
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
textLanguages.value = ['zh']
Object.assign(form, { Object.assign(form, {
title: '', title: '',
titleLocales: { zh: '' },
categoryIds: [], categoryIds: [],
dramaTypes: ['LIVE'], tagId: null,
description: '', description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3, freeEpisodes: 3,
unlockCoin: 0, unlockCoin: 0,
favoriteCount: 0, favoriteCount: 0,
heat: 0, heat: 0,
isHot: false, isHot: false,
sort: 0, sort: 0,
cover: '' cover: '',
horizontalCover: ''
}) })
coverFileName.value = '' coverFileName.value = ''
coverError.value = '' coverError.value = ''
horizontalFileName.value = ''
horizontalError.value = ''
editingCommentCount.value = 0 editingCommentCount.value = 0
editingLikeCount.value = 0 editingLikeCount.value = 0
dialogVisible.value = true dialogVisible.value = true
@@ -182,41 +223,38 @@ function openCreate() {
function openEdit(row) { function openEdit(row) {
editingId.value = row.id 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, { Object.assign(form, {
title: row.title, title: row.title,
categoryIds: titleLocales,
Array.isArray(row.categoryIds) && row.categoryIds.length categoryIds: selectedCategoryIds(row),
? row.categoryIds tagId: Number(row.tagId) > 0 ? row.tagId : null,
: row.categoryId
? [row.categoryId]
: [],
dramaTypes:
Array.isArray(row.dramaTypes) && row.dramaTypes.length
? row.dramaTypes
: row.dramaType
? [row.dramaType]
: ['LIVE'],
description: row.description || '', description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3, synopsisLocales,
unlockCoin: row.unlockCoin ?? 0, freeEpisodes: toCount(row.freeEpisodes ?? 3),
favoriteCount: row.favoriteCount ?? 0, unlockCoin: toCount(row.unlockCoin),
heat: row.views ?? 0, favoriteCount: toCount(row.favoriteCount),
heat: toCount(row.views),
isHot: !!row.isHot, isHot: !!row.isHot,
sort: row.sort ?? 0, sort: toCount(row.sort),
cover: row.cover || '' cover: row.cover || '',
horizontalCover: row.horizontalCover || ''
}) })
coverFileName.value = row.cover ? coverBasename(row.cover) : '' coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = '' coverError.value = ''
editingCommentCount.value = row.commentCount ?? 0 horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
editingLikeCount.value = row.likeCount ?? 0 horizontalError.value = ''
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
dialogVisible.value = true dialogVisible.value = true
resetValidation() resetValidation()
} }
function dramaTypeLabel(value) {
return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧'
}
function rowCategoryList(row) { function rowCategoryList(row) {
if (Array.isArray(row.categoryNames) && row.categoryNames.length) { if (Array.isArray(row.categoryNames) && row.categoryNames.length) {
return row.categoryNames.filter(Boolean) return row.categoryNames.filter(Boolean)
@@ -228,25 +266,26 @@ function rowCategoryList(row) {
.filter(Boolean) .filter(Boolean)
} }
function rowDramaTypes(row) {
if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes
return row.dramaType ? [row.dramaType] : []
}
async function saveDrama() { async function saveDrama() {
if (saving.value || uploadingCover.value) return if (saving.value || uploadingCover.value || uploadingHorizontal.value) return
try { const titles = payloadLocales(form.titleLocales, textLanguages.value)
await formRef.value?.validate() const missing = textLanguages.value.find((code) => !titles[code])
} catch { if (!titles.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请输入短剧名称')
return return
} }
const synopses = payloadLocales(form.synopsisLocales, textLanguages.value)
const categoryNames = form.categoryIds const categoryNames = form.categoryIds
.map((id) => categories.value.find((item) => item.id === id)?.name) .map((id) => categories.value.find((item) => item.id === id)?.name)
.filter(Boolean) .filter(Boolean)
const payload = { const payload = {
...form, ...form,
title: form.title.trim(), title: titles.zh,
genre: categoryNames.join(' / ') titleLocales: titles,
description: synopses.zh || '',
synopsisLocales: synopses,
genre: categoryNames.join(' / '),
tagId: form.tagId || 0
} }
saving.value = true saving.value = true
try { try {
@@ -265,32 +304,41 @@ async function saveDrama() {
} }
} }
function beforeCoverUpload(file) { function validateCoverFile(file, errorRef, label) {
coverError.value = '' errorRef.value = ''
if (!COVER_TYPES.includes(file.type)) { if (!COVER_TYPES.includes(file.type)) {
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式' errorRef.value = `${label}仅支持 JPG、PNG 或 WebP 格式`
return false return false
} }
if (file.size > MAX_COVER_SIZE) { if (file.size > MAX_COVER_SIZE) {
coverError.value = '封面大小不能超过 5MB' errorRef.value = `${label}大小不能超过 5MB`
return false return false
} }
return true return true
} }
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */ function beforeCoverUpload(file) {
return validateCoverFile(file, coverError, '竖版封面')
}
function beforeHorizontalUpload(file) {
return validateCoverFile(file, horizontalError, '横版封面')
}
/** 封面走分片上传(图片只有一个分片),上传前按展示规格等比压缩,成功后回填地址。 */
async function uploadCover(options) { async function uploadCover(options) {
uploadingCover.value = true uploadingCover.value = true
coverPercent.value = 0 coverPercent.value = 0
coverError.value = '' coverError.value = ''
try { try {
const asset = await uploadFile(options.file, { const file = await compressForPreset(options.file, DRAMA_COVER_PORTRAIT)
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER', bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent) onProgress: (percent) => (coverPercent.value = percent)
}) })
form.cover = asset.url form.cover = asset.url
coverFileName.value = options.file.name coverFileName.value = file.name
ElMessage.success('封面已上传') ElMessage.success('竖版封面已上传')
} catch { } catch {
coverError.value = '封面上传失败,请检查网络后重试' coverError.value = '封面上传失败,请检查网络后重试'
} finally { } finally {
@@ -298,6 +346,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() { function removeCover() {
if (uploadingCover.value) return if (uploadingCover.value) return
form.cover = '' form.cover = ''
@@ -305,8 +373,15 @@ function removeCover() {
coverError.value = '' coverError.value = ''
} }
function removeHorizontalCover() {
if (uploadingHorizontal.value) return
form.horizontalCover = ''
horizontalFileName.value = ''
horizontalError.value = ''
}
function beforeDialogClose(done) { function beforeDialogClose(done) {
if (saving.value || uploadingCover.value) { if (saving.value || uploadingCover.value || uploadingHorizontal.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候') ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
return return
} }
@@ -361,16 +436,16 @@ async function runAction(row, action) {
function reviewActions(row) { function reviewActions(row) {
const canReview = can('drama:review') const canReview = can('drama:review')
if (row.status === 'draft' || row.status === 'offline') { if (row.status === 'draft' || row.status === 'offline') {
return hasAny('drama:update', 'drama:review') ? ['submit'] : [] return canReview ? ['approvePublish'] : []
} }
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : [] if (row.status === 'reviewing') return canReview ? ['approvePublish', 'reject'] : []
if (row.status === 'approved') return canReview ? ['publish'] : [] if (row.status === 'approved') return canReview ? ['publish'] : []
if (row.status === 'published') return canReview ? ['offline'] : [] if (row.status === 'published') return canReview ? ['offline'] : []
return [] return []
} }
onMounted(async () => { onMounted(async () => {
await loadCategories() await Promise.all([loadCategories(), loadTags()])
await load() await load()
}) })
</script> </script>
@@ -413,22 +488,6 @@ onMounted(async () => {
:key="item.id" :key="item.id"
:label="item.name" :label="item.name"
:value="item.id" /></el-select :value="item.id" /></el-select
><el-select
v-model="dramaTypes"
class="drama-multi-select"
multiple
clearable
placeholder="全部内容类型"
@change="search"
><el-option
v-for="item in dramaTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></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 ><el-button @click="resetFilters">重置</el-button></div
><span class="table-result-count">共 {{ total }} 部短剧</span></div ><span class="table-result-count">共 {{ total }} 部短剧</span></div
> >
@@ -465,24 +524,17 @@ onMounted(async () => {
> >
<el-table-column label="类型" min-width="200" <el-table-column label="类型" min-width="200"
><template #default="{ row }" ><template #default="{ row }"
><div class="tag-wrap" ><div v-if="rowCategoryList(row).length" class="tag-wrap"
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{ ><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
name name
}}</el-tag></div }}</el-tag></div
></template ><span v-else class="table-empty-text">—</span></template
> >
</el-table-column> </el-table-column>
<el-table-column label="内容类型" min-width="180" <el-table-column label="标签" min-width="120"
><template #default="{ row }" ><template #default="{ row }"
><div class="tag-wrap" ><span v-if="row.tagName">{{ row.tagName }}</span
><el-tag ><span v-else class="table-empty-text">—</span></template
v-for="type in rowDramaTypes(row)"
:key="type"
:type="type === 'ANIME' ? 'warning' : 'info'"
effect="light"
>{{ dramaTypeLabel(type) }}</el-tag
></div
></template
></el-table-column ></el-table-column
> >
<el-table-column label="集数" width="90" <el-table-column label="集数" width="90"
@@ -513,12 +565,6 @@ onMounted(async () => {
}}</el-tag></template }}</el-tag></template
></el-table-column ></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 prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="审核" width="140" <el-table-column label="审核" width="140"
><template #default="{ row }" ><template #default="{ row }"
@@ -576,31 +622,172 @@ onMounted(async () => {
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:title="editingId ? '编辑短剧' : '新建短剧'" :title="editingId ? '编辑短剧' : '新建短剧'"
width="680px" width="760px"
class="drama-editor-dialog" class="drama-editor-dialog"
:close-on-click-modal="false" :close-on-click-modal="false"
:before-close="beforeDialogClose" :before-close="beforeDialogClose"
> >
<el-form <el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
ref="formRef" <section class="editor-section">
:model="form" <h3 class="editor-section-title">封面</h3>
:rules="formRules" <div class="cover-block">
label-position="top" <p class="cover-label">竖版封面</p>
class="drama-editor-form" <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"> <section class="editor-section">
<h3 class="editor-section-title">基础信息</h3> <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"> <div class="editor-grid editor-grid--two">
<el-form-item label="短剧名称" prop="title"> <el-form-item label="类型">
<el-input v-model="form.title" maxlength="80" placeholder="请输入短剧名称" />
</el-form-item>
<el-form-item label="类型" prop="categoryIds">
<el-select <el-select
v-model="form.categoryIds" v-model="form.categoryIds"
class="drama-multi-select" class="drama-multi-select"
multiple multiple
filterable filterable
placeholder="请选择类型" clearable
placeholder="可不选"
> >
<el-option <el-option
v-for="item in categories" v-for="item in categories"
@@ -610,33 +797,22 @@ onMounted(async () => {
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="内容类型" prop="dramaTypes"> <el-form-item label="标签">
<el-select <el-select v-model="form.tagId" clearable filterable placeholder="可不选">
v-model="form.dramaTypes" <el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
class="drama-multi-select"
multiple
filterable
placeholder="请选择内容类型"
>
<el-option
v-for="item in dramaTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
<el-form-item label="简介"> <LocaleTextFields
<el-input :languages="textLanguages"
v-model="form.description" :locales="form.synopsisLocales"
type="textarea" @update:locales="form.synopsisLocales = $event"
:rows="3" label="简介"
maxlength="200" :maxlength="1000"
show-word-limit multiline
placeholder="简要介绍剧情" :show-languages="false"
:required="false"
/> />
</el-form-item>
</section> </section>
<section class="editor-section"> <section class="editor-section">
@@ -660,12 +836,7 @@ onMounted(async () => {
:max="9999" :max="9999"
/> />
</el-form-item> </el-form-item>
</div> <el-form-item label="排序">
<div class="editor-grid editor-grid--two">
<el-form-item label="是否热门">
<el-switch v-model="form.isHot" />
</el-form-item>
<el-form-item label="排序权重">
<el-input-number <el-input-number
v-model="form.sort" v-model="form.sort"
class="editor-number" class="editor-number"
@@ -710,72 +881,6 @@ onMounted(async () => {
</el-form-item> </el-form-item>
</div> </div>
</section> </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
/>
<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>
</section>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -816,6 +921,20 @@ onMounted(async () => {
min-width: 0; 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 { .tag-wrap {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -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>
@@ -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>
@@ -326,17 +326,17 @@ function onEpisodesCreated(): void {
.batch-upload-hint { .batch-upload-hint {
margin: 0 0 12px; margin: 0 0 12px;
color: var(--el-text-color-secondary);
font-size: 13px; font-size: 13px;
color: var(--el-text-color-secondary);
} }
.batch-upload-rejected { .batch-upload-rejected {
margin-bottom: 12px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; margin-bottom: 12px;
background: color-mix(in srgb, var(--el-color-danger) 8%, transparent);
color: var(--el-color-danger);
font-size: 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 { .batch-upload-rejected p {
@@ -348,12 +348,12 @@ function onEpisodesCreated(): void {
} }
.batch-upload-summary { .batch-upload-summary {
margin: 0 0 12px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; margin: 0 0 12px;
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
color: var(--el-text-color-primary);
font-size: 13px; 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 { .batch-upload-list {
@@ -202,9 +202,9 @@ function hasFailed(): boolean {
<style scoped> <style scoped>
.create-episode-hint { .create-episode-hint {
margin: 0 0 12px; margin: 0 0 12px;
color: var(--el-text-color-secondary);
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
color: var(--el-text-color-secondary);
} }
.create-episode-head, .create-episode-head,
@@ -229,8 +229,8 @@ function hasFailed(): boolean {
.create-episode-head { .create-episode-head {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--el-text-color-secondary);
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary);
} }
.create-episode-list { .create-episode-list {
@@ -253,8 +253,8 @@ function hasFailed(): boolean {
} }
.create-episode-result { .create-episode-result {
color: var(--el-text-color-secondary);
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary);
} }
.create-episode-row.is-saved .create-episode-result { .create-episode-row.is-saved .create-episode-result {
@@ -6,6 +6,7 @@ import { nextTick, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode' import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
import EpisodeTranscodePanel from './EpisodeTranscodePanel.vue'
import EpisodeVideoUploader from './EpisodeVideoUploader.vue' import EpisodeVideoUploader from './EpisodeVideoUploader.vue'
const props = defineProps({ const props = defineProps({
@@ -263,6 +264,8 @@ defineExpose({ openCreate, openEdit })
</section> </section>
</el-form> </el-form>
<EpisodeTranscodePanel v-if="editingId" :episode-id="editingId" :active="dialogVisible" />
<template #footer> <template #footer>
<el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false"> <el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false">
取消 取消
@@ -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,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>
@@ -100,10 +100,10 @@ const canRetry = computed(() => props.item.status === 'failed')
} }
.upload-task-row__index { .upload-task-row__index {
flex: 0 0 auto;
min-width: 1.5em; min-width: 1.5em;
color: var(--el-text-color-secondary);
font-size: 13px; font-size: 13px;
color: var(--el-text-color-secondary);
flex: 0 0 auto;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -116,22 +116,22 @@ const canRetry = computed(() => props.item.status === 'failed')
.upload-task-row__error { .upload-task-row__error {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--el-color-danger);
font-size: 12px; font-size: 12px;
color: var(--el-color-danger);
} }
.upload-task-row__actions { .upload-task-row__actions {
margin-top: 8px;
display: flex; display: flex;
margin-top: 8px;
margin-left: auto;
gap: 8px; gap: 8px;
justify-content: flex-end; justify-content: flex-end;
margin-left: auto;
} }
.upload-task-row.is-failed { .upload-task-row.is-failed {
padding-right: 8px;
padding-left: 8px;
background: color-mix(in srgb, var(--el-color-danger) 6%, transparent); background: color-mix(in srgb, var(--el-color-danger) 6%, transparent);
border-radius: 6px; border-radius: 6px;
padding-left: 8px;
padding-right: 8px;
} }
</style> </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
})
}
+452 -19
View File
@@ -1,39 +1,315 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue' import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard' import { useCreatorDashboard } from './composables/useCreatorDashboard'
import { formatCount } from '@/utils/format'
import echarts from '@/plugins/echarts'
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard() const { loading, board, lastUpdated, load } = useCreatorDashboard()
const playbackRef = ref(null)
const statusRef = ref(null)
const mediaRef = ref(null)
const interactionRef = ref(null)
const revenueRef = ref(null)
const chartInstances = []
const COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E86452', '#945FB9', '#C9C9C9']
const statusLabel = [
{ key: 'draft', name: '草稿' },
{ key: 'reviewing', name: '审核中' },
{ key: 'approved', name: '待发布' },
{ key: 'published', name: '已上线' },
{ key: 'offline', name: '已下线' }
]
function num(group, key) {
const value = board.value?.[group]?.[key]
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function metricValue(group, key) {
const value = board.value?.[group]?.[key]
if (value === null || value === undefined) return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
function metricText(group, key) {
const value = metricValue(group, key)
return value === null ? '--' : formatCount(value)
}
const finishRate = computed(() => {
const value = metricValue('playback', 'finishRate')
if (value === null) return null
return Math.max(0, Math.min(100, value <= 1 ? value * 100 : value))
})
const finishRateText = computed(() =>
finishRate.value === null ? '--' : `${finishRate.value.toFixed(1)}%`
)
const kpis = computed(() => [
{ label: '作品总数', value: metricText('drama', 'total'), accent: '#5B8FF9' },
{ label: '累计观看', value: metricText('playback', 'viewTotal'), accent: '#5AD8A6' },
{ label: '累计播放', value: metricText('playback', 'playTotal'), accent: '#5B8FF9' },
{ label: '完播率', value: finishRateText.value, accent: '#F6BD16' },
{ label: '解锁笔数', value: metricText('revenue', 'unlockCount'), accent: '#E86452' },
{ label: '金币收入', value: metricText('revenue', 'coinIncome'), accent: '#945FB9' }
])
const statusItems = computed(() =>
statusLabel.map((item) => ({ name: item.name, value: num('drama', item.key) }))
)
const mediaItems = computed(() => {
const total = num('drama', 'episodeTotal')
const withMedia = num('drama', 'episodeWithMedia')
return [
{ name: '有片源', value: withMedia },
{ name: '无片源', value: Math.max(0, total - withMedia) }
]
})
const playbackItems = computed(() => [
{ name: '累计观看', value: num('playback', 'viewTotal') },
{ name: '累计播放', value: num('playback', 'playTotal') },
{ name: '近 7 天播放流水', value: num('playback', 'playEvents7d') },
{ name: '播放流水总量', value: num('playback', 'playEventsTotal') },
{ name: '完播事件', value: num('playback', 'finishEvents') }
])
const interactionItems = computed(() => [
{ name: '收藏', value: num('interaction', 'favoriteTotal') },
{ name: '评论', value: num('interaction', 'commentTotal') },
{ name: '待审评论', value: num('interaction', 'commentPending') }
])
const revenueItems = computed(() => [
{ name: '解锁笔数', value: num('revenue', 'unlockCount') },
{ name: '金币收入', value: num('revenue', 'coinIncome') },
{ name: '近 7 天金币', value: num('revenue', 'coinIncome7d') },
{ name: '法币金额', value: num('revenue', 'paidOrderAmount') }
])
const metrics = computed(() => [
{ label: '收藏总数', value: metricText('interaction', 'favoriteTotal') },
{ label: '评论总数', value: metricText('interaction', 'commentTotal') },
{ label: '待审评论', value: metricText('interaction', 'commentPending') },
{ label: '金币收入', value: metricText('revenue', 'coinIncome') },
{ label: '近 7 天金币', value: metricText('revenue', 'coinIncome7d') },
{ label: '法币金额', value: metricText('revenue', 'paidOrderAmount') }
])
const pieBase = {
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } }
}
function renderPlayback() {
if (!playbackRef.value) return
const chart = echarts.init(playbackRef.value)
chartInstances.push(chart)
const items = playbackItems.value
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 90, right: 40, top: 12, bottom: 12 },
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: {
type: 'category',
inverse: true,
data: items.map((item) => item.name),
axisLine: { show: false },
axisTick: { show: false }
},
series: [
{
type: 'bar',
barWidth: 12,
data: items.map((item) => item.value),
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#5B8FF9' },
label: { show: true, position: 'right', formatter: ({ value }) => formatCount(value) }
}
]
})
}
function renderPie(el, name, data) {
if (!el) return
const chart = echarts.init(el)
chartInstances.push(chart)
const valid = data.filter((item) => item.value > 0)
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
color: COLORS,
series: [
{
...pieBase,
name,
data: valid.length
? valid
: [{ name: '暂无数据', value: 1, itemStyle: { color: '#E6E8EB' } }]
}
]
})
}
function renderBar(el, items) {
if (!el) return
const chart = echarts.init(el)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 12, right: 16, top: 24, bottom: 12, containLabel: true },
xAxis: {
type: 'category',
data: items.map((item) => item.name),
axisTick: { show: false },
axisLabel: { interval: 0, fontSize: 11 }
},
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: [
{
type: 'bar',
barWidth: 22,
data: items.map((item, index) => ({
value: item.value,
itemStyle: { color: COLORS[index % COLORS.length], borderRadius: [4, 4, 0, 0] }
})),
label: { show: true, position: 'top', fontSize: 11 }
}
]
})
}
function disposeAll() {
chartInstances.forEach((chart) => chart.dispose())
chartInstances.length = 0
}
function renderCharts() {
disposeAll()
renderPlayback()
renderPie(statusRef.value, '作品状态', statusItems.value)
renderPie(mediaRef.value, '片源覆盖', mediaItems.value)
renderBar(interactionRef.value, interactionItems.value)
renderBar(revenueRef.value, revenueItems.value)
}
function onResize() {
chartInstances.forEach((chart) => chart.resize())
}
watch(
() => board.value,
async (value) => {
if (!value) return
await nextTick()
renderCharts()
},
{ immediate: true }
)
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
disposeAll()
})
</script> </script>
<template> <template>
<CreatorCenterPage> <CreatorCenterPage>
<PageHeading <PageHeading
eyebrow="创作者中心" eyebrow="数据看板"
title="数据看板" title="创作者数据"
description="我的作品在播放、互动与解锁收入上的表现。" description="本人作品的播放、互动与解锁收入总览。"
> >
<el-button :loading="loading" @click="load">刷新</el-button> <el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading> </PageHeading>
<section v-loading="loading" class="panel"> <div v-loading="loading" class="board-wrap">
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p> <section class="panel">
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group"> <div class="panel-heading">
<h3 class="board-title">{{ group.title }}</h3> <div>
<div class="board-grid"> <h2>创作概览</h2>
<div v-for="metric in group.metrics" :key="metric.key" class="board-card"> <p>作品规模、播放与收入核心指标</p>
<span class="board-label">{{ metric.label }}</span> </div>
<span <span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
class="board-value" </div>
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }" <div class="kpi-row">
> <article v-for="item in kpis" :key="item.label" class="kpi">
{{ display(groupValue(group.key, metric.key)) }} <i :style="{ background: item.accent }"></i>
</span> <div>
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</article>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>播放与片源</h2>
<p>播放漏斗、作品状态与分集片源覆盖</p>
</div> </div>
</div> </div>
<div class="viz-row">
<div class="viz-cell">
<h3>播放表现</h3>
<div ref="playbackRef" class="chart chart-bar"></div>
</div> </div>
<div class="viz-cell">
<h3>作品状态</h3>
<div ref="statusRef" class="chart chart-pie"></div>
</div>
<div class="viz-cell">
<h3>片源覆盖</h3>
<div ref="mediaRef" class="chart chart-pie"></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>互动与收入</h2>
<p>收藏、评论与解锁收入</p>
</div>
</div>
<div class="viz-row">
<div class="viz-cell">
<h3>互动</h3>
<div ref="interactionRef" class="chart chart-bar"></div>
</div>
<div class="viz-cell">
<h3>解锁收入</h3>
<div ref="revenueRef" class="chart chart-bar"></div>
</div>
<div class="viz-cell">
<h3>关键指标</h3>
<ul class="metric-list">
<li v-for="item in metrics" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</li>
</ul>
</div>
</div>
</section>
<el-alert <el-alert
v-if="board?.notes?.length" v-if="board?.notes?.length"
class="board-notes" class="board-notes"
@@ -46,6 +322,163 @@ const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDas
<li v-for="note in board.notes" :key="note">{{ note }}</li> <li v-for="note in board.notes" :key="note">{{ note }}</li>
</ul> </ul>
</el-alert> </el-alert>
</section> </div>
</CreatorCenterPage> </CreatorCenterPage>
</template> </template>
<style scoped lang="less">
.board-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
padding: 20px 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.panel-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.kpi-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
min-height: 72px;
padding: 12px 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
i {
flex: none;
width: 3px;
height: 32px;
border-radius: 3px;
}
strong {
display: block;
overflow: hidden;
font-size: 22px;
line-height: 1.1;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.viz-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
gap: 16px;
}
.viz-cell {
min-width: 0;
h3 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
}
.chart {
width: 100%;
}
.chart-bar {
height: 240px;
}
.chart-pie {
height: 240px;
}
.metric-list {
margin: 8px 0 0;
padding: 0;
list-style: none;
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
font-size: 13px;
&:last-child {
border-bottom: 0;
}
span {
color: var(--el-text-color-secondary);
}
strong {
font-variant-numeric: tabular-nums;
color: var(--el-text-color-primary);
}
}
}
.board-notes {
:deep(ul) {
padding-left: 18px;
margin: 4px 0 0;
font-size: 12px;
}
}
@media (width <= 1100px) {
.kpi-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.viz-row {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+55 -21
View File
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage' import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
@@ -10,6 +10,7 @@ import { useRbac } from '@/stores/rbac'
import { useDramaEdit } from './composables/useDramaEdit' import { useDramaEdit } from './composables/useDramaEdit'
import { useCreatorAccess } from './composables/useCreatorAccess' import { useCreatorAccess } from './composables/useCreatorAccess'
import { dramaStatusMeta } from './composables/useCreatorWorkspace' import { dramaStatusMeta } from './composables/useCreatorWorkspace'
import { DRAMA_COVER_PORTRAIT, EPISODE_COVER } from '@/views/contentManagement/upload/imagePresets'
const { can } = useRbac() const { can } = useRbac()
const canEdit = can('creator-center:drama:edit') const canEdit = can('creator-center:drama:edit')
@@ -44,7 +45,6 @@ const {
moveEpisode, moveEpisode,
uploadVideo, uploadVideo,
uploadCover, uploadCover,
hasResumeSession,
submit, submit,
withdraw withdraw
} = useDramaEdit() } = useDramaEdit()
@@ -61,6 +61,16 @@ function back() {
window.history.back() window.history.back()
} }
/* ---------- 分集视频预览 ---------- */
const videoPreviewVisible = ref(false)
const videoPreviewUrl = ref('')
function playEpisode(row) {
if (!row.videoUrl) return
videoPreviewUrl.value = row.videoUrl
videoPreviewVisible.value = true
}
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */ /** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
function requestVideo(row, options) { function requestVideo(row, options) {
return uploadVideo(row, options.file) return uploadVideo(row, options.file)
@@ -122,15 +132,9 @@ function requestDramaCover(options) {
<el-form-item label="作品名称" required> <el-form-item label="作品名称" required>
<el-input v-model="infoForm.title" maxlength="64" /> <el-input v-model="infoForm.title" maxlength="64" />
</el-form-item> </el-form-item>
<el-form-item label="副标题">
<el-input v-model="infoForm.subtitle" maxlength="64" />
</el-form-item>
<el-form-item label="题材"> <el-form-item label="题材">
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" /> <el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item> </el-form-item>
<el-form-item label="语言">
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
</el-form-item>
</div> </div>
<el-form-item label="简介"> <el-form-item label="简介">
<el-input <el-input
@@ -148,6 +152,7 @@ function requestDramaCover(options) {
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" /> <img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
<span v-else>无</span> <span v-else>无</span>
</div> </div>
<div class="creator-cover-actions">
<el-upload <el-upload
v-if="canUpload" v-if="canUpload"
:show-file-list="false" :show-file-list="false"
@@ -157,14 +162,9 @@ function requestDramaCover(options) {
> >
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button> <el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload> </el-upload>
<p class="creator-cover-hint">{{ DRAMA_COVER_PORTRAIT.hint }}</p>
</div>
</div> </div>
</el-form-item>
<el-form-item label="预告片地址">
<el-input
v-model="infoForm.trailerUrl"
maxlength="512"
placeholder="整剧一支预告片,可留空"
/>
</el-form-item> </el-form-item>
</div> </div>
<el-button <el-button
@@ -198,7 +198,15 @@ function requestDramaCover(options) {
show-icon show-icon
:title="resumeHint" :title="resumeHint"
/> />
<el-table :data="episodes" row-key="id" border class="drama-el-table" empty-text="还没有分集"> <div class="creator-table-scroll creator-table-scroll--episodes">
<el-table
:data="episodes"
row-key="id"
border
class="drama-el-table"
style="min-width: 1150px"
empty-text="还没有分集"
>
<el-table-column prop="number" label="集号" width="70" /> <el-table-column prop="number" label="集号" width="70" />
<el-table-column label="封面" width="88"> <el-table-column label="封面" width="88">
<template #default="{ row }"> <template #default="{ row }">
@@ -220,7 +228,10 @@ function requestDramaCover(options) {
<el-table-column prop="duration" label="时长" width="90" /> <el-table-column prop="duration" label="时长" width="90" />
<el-table-column label="片源" min-width="120"> <el-table-column label="片源" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span> <el-button v-if="row.videoUrl" link type="primary" @click="playEpisode(row)"
>播放</el-button
>
<span v-else class="console-muted">未上传</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="90"> <el-table-column label="状态" width="90">
@@ -234,7 +245,7 @@ function requestDramaCover(options) {
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="340">
<template #default="{ row, $index }"> <template #default="{ row, $index }">
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
@@ -259,7 +270,7 @@ function requestDramaCover(options) {
:http-request="(options) => requestVideo(row, options)" :http-request="(options) => requestVideo(row, options)"
:disabled="!editable || uploadingId !== null" :disabled="!editable || uploadingId !== null"
> >
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button> <el-button size="small">上传视频</el-button>
</el-upload> </el-upload>
<el-button <el-button
type="danger" type="danger"
@@ -272,6 +283,7 @@ function requestDramaCover(options) {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<p v-if="uploadingId !== null" class="creator-upload-hint" <p v-if="uploadingId !== null" class="creator-upload-hint"
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p >上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
> >
@@ -279,11 +291,13 @@ function requestDramaCover(options) {
<section class="panel"> <section class="panel">
<h3 class="creator-section-title">提交记录</h3> <h3 class="creator-section-title">提交记录</h3>
<div class="creator-table-scroll creator-table-scroll--submissions">
<el-table <el-table
:data="submissions" :data="submissions"
row-key="id" row-key="id"
border border
class="drama-el-table" class="drama-el-table"
style="min-width: 1000px"
empty-text="还没有提交记录" empty-text="还没有提交记录"
> >
<el-table-column prop="version" label="版本" width="70" /> <el-table-column prop="version" label="版本" width="70" />
@@ -311,8 +325,13 @@ function requestDramaCover(options) {
:formatter="dateCell" :formatter="dateCell"
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip /> <el-table-column
<el-table-column label="操作" width="110" fixed="right"> prop="reviewNote"
label="审核意见"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="操作" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
@@ -327,8 +346,20 @@ function requestDramaCover(options) {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
</section> </section>
<el-dialog v-model="videoPreviewVisible" title="视频预览" width="720px" append-to-body>
<video
v-if="videoPreviewUrl"
:src="videoPreviewUrl"
controls
autoplay
playsinline
class="creator-video-preview"
></video>
</el-dialog>
<el-dialog <el-dialog
v-model="episodeVisible" v-model="episodeVisible"
:title="episodeEditingId ? '编辑分集' : '新增分集'" :title="episodeEditingId ? '编辑分集' : '新增分集'"
@@ -353,6 +384,7 @@ function requestDramaCover(options) {
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" /> <img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
<span v-else>无</span> <span v-else>无</span>
</div> </div>
<div class="creator-cover-actions">
<el-upload <el-upload
v-if="canUpload" v-if="canUpload"
:show-file-list="false" :show-file-list="false"
@@ -361,6 +393,8 @@ function requestDramaCover(options) {
> >
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button> <el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload> </el-upload>
<p class="creator-cover-hint">{{ EPISODE_COVER.hint }}</p>
</div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item v-if="episodeEditingId" label="集号"> <el-form-item v-if="episodeEditingId" label="集号">
File diff suppressed because it is too large. Load diff
+93 -46
View File
@@ -30,16 +30,12 @@ const {
profileLoading, profileLoading,
dramas, dramas,
notices, notices,
createVisible,
createSaving,
createForm,
queryDramas, queryDramas,
loadDramas, loadDramas,
loadNotices, loadNotices,
submit, submit,
withdraw, withdraw,
openCreate, remove
confirmCreate
} = useCreatorWorkspace() } = useCreatorWorkspace()
const activeTab = ref('dramas') const activeTab = ref('dramas')
@@ -59,6 +55,11 @@ function onTabChange(name) {
function openEdit(row) { function openEdit(row) {
router.push(`/creator-center/dramas/${row.id}`) router.push(`/creator-center/dramas/${row.id}`)
} }
/** 抖音式发布:直接进发布页选视频,不再先建空草稿 */
function openPublish() {
router.push('/creator-center/publish')
}
</script> </script>
<template> <template>
@@ -68,8 +69,8 @@ function openEdit(row) {
title="我的作品" title="我的作品"
description="查看作品与审核进度,提交上线 / 下线申请。" description="查看作品与审核进度,提交上线 / 下线申请。"
> >
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate" <el-button v-if="canEdit && !impersonating" type="primary" @click="openPublish"
>新建作品</el-button >发布作品</el-button
> >
</PageHeading> </PageHeading>
@@ -114,7 +115,7 @@ function openEdit(row) {
/> />
</section> </section>
<section class="panel"> <section class="panel creator-workspace-wrap">
<el-tabs v-model="activeTab" @tab-change="onTabChange"> <el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="我的作品" name="dramas"> <el-tab-pane label="我的作品" name="dramas">
<div class="console-toolbar"> <div class="console-toolbar">
@@ -146,13 +147,14 @@ function openEdit(row) {
<span class="console-count">共 {{ dramas.total }} 部作品</span> <span class="console-count">共 {{ dramas.total }} 部作品</span>
</div> </div>
<div class="creator-desktop-only">
<el-table <el-table
v-loading="dramas.loading" v-loading="dramas.loading"
:data="dramas.rows" :data="dramas.rows"
row-key="id" row-key="id"
border border
class="drama-el-table" class="drama-el-table"
empty-text="还没有作品,点右上角「新建作品」开始" empty-text="还没有作品,点右上角「发布作品」开始"
> >
<el-table-column label="作品" min-width="220"> <el-table-column label="作品" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
@@ -211,7 +213,7 @@ function openEdit(row) {
:formatter="dateCell" :formatter="dateCell"
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column label="操作" width="210" fixed="right"> <el-table-column label="操作" width="280">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
@@ -251,10 +253,91 @@ function openEdit(row) {
@click="withdraw(row)" @click="withdraw(row)"
>撤回</el-button >撤回</el-button
> >
<el-button
v-if="canEdit && !impersonating"
text
type="danger"
size="small"
@click="remove(row)"
>删除</el-button
>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div v-loading="dramas.loading" class="creator-mobile-only creator-drama-list">
<el-empty v-if="!dramas.rows.length" description="还没有作品,点下方「发布作品」开始" />
<article v-for="row in dramas.rows" :key="row.id" class="creator-drama-card">
<div class="creator-drama-card-cover" @click="openEdit(row)">
<AppImage
v-if="row.cover"
:src="row.cover"
fit="cover"
class="creator-drama-image"
:alt="row.title"
/>
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
</div>
<div class="creator-drama-card-body">
<div class="creator-drama-card-head">
<span class="creator-drama-title">{{ row.title }}</span>
<el-tag size="small" :type="dramaStatusMeta(row.status).tag" effect="light">
{{ dramaStatusMeta(row.status).label }}
</el-tag>
</div>
<span class="creator-drama-code">{{ row.code }}</span>
<div class="creator-drama-card-meta">
<span>{{ row.episodes }} 集</span>
<span>{{ row.views }} 播放</span>
</div>
<div v-if="row.submissionStatus" class="creator-progress">
<el-tag
size="small"
:type="submissionStatusMeta(row.submissionStatus).tag"
effect="plain"
>
{{ submissionTypeText(row.submissionType) }}·{{
submissionStatusMeta(row.submissionStatus).label
}}
</el-tag>
</div>
<span v-else class="console-muted">尚未提交</span>
<div v-if="canEdit && !impersonating" class="creator-drama-card-actions">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-dropdown
v-if="submitOptions(row).length"
trigger="click"
@command="(type) => submit(row, type)"
>
<el-button size="small">提交审核</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="type in submitOptions(row)"
:key="type"
:command="type"
>
{{ submissionTypeText(type) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
row.submissionId && ['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
"
type="danger"
size="small"
@click="withdraw(row)"
>撤回</el-button
>
<el-button text type="danger" size="small" @click="remove(row)">删除</el-button>
</div>
</div>
</article>
</div>
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
@@ -290,41 +373,5 @@ function openEdit(row) {
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</section> </section>
<el-dialog v-model="createVisible" title="新建作品" width="480px">
<el-form label-position="top">
<el-form-item label="作品名称" required>
<el-input
v-model="createForm.title"
maxlength="64"
placeholder="例如:闪婚后,傅先生每天都在撒糖"
/>
</el-form-item>
<el-form-item label="题材">
<el-input v-model="createForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="createForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="先建草稿,再去作品编辑页完善信息、分集与视频,然后提交审核"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate"
>创建草稿</el-button
>
</template>
</el-dialog>
</CreatorCenterPage> </CreatorCenterPage>
</template> </template>
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
createDramaApi, createDramaApi,
deleteDramaApi,
getCreatorProfileApi, getCreatorProfileApi,
listCreatorNoticesApi, listCreatorNoticesApi,
pageMyDramasApi, pageMyDramasApi,
@@ -168,6 +169,32 @@ export function useCreatorWorkspace() {
await loadDramas() await loadDramas()
} }
/** 删作品。已上线的后端会拦,前端按状态给提示。 */
async function remove(row) {
const label = row.title || '该作品'
try {
await ElMessageBox.confirm(
`《${label}》将被永久删除(分集、封面、审核记录一并清理),且不可恢复。确认删除?`,
'删除作品',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
try {
await deleteDramaApi(row.id)
ElMessage.success('作品已删除')
await loadDramas()
} catch (e) {
// 后端可能抛"已上线,请先下线"之类的业务错误;ElMessage 全局拦截器会弹 message
}
}
const createVisible = ref(false) const createVisible = ref(false)
const createSaving = ref(false) const createSaving = ref(false)
const createForm = reactive({ title: '', genre: '', description: '' }) const createForm = reactive({ title: '', genre: '', description: '' })
@@ -216,6 +243,7 @@ export function useCreatorWorkspace() {
loadNotices, loadNotices,
submit, submit,
withdraw, withdraw,
remove,
openCreate, openCreate,
confirmCreate confirmCreate
} }
@@ -18,6 +18,11 @@ import {
} from '@/api/admin/creatorCenter' } from '@/api/admin/creatorCenter'
import { dateCell } from '@/utils/format' import { dateCell } from '@/utils/format'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader' 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 { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
import { useCreatorAccess } from './useCreatorAccess' import { useCreatorAccess } from './useCreatorAccess'
@@ -254,7 +259,7 @@ export function useDramaEdit() {
ElMessage.success('视频已上传并挂到该分集') ElMessage.success('视频已上传并挂到该分集')
await loadEpisodes() await loadEpisodes()
} catch { } catch {
resumeHint.value = `「${file.name}」上传中断,进度已记住,可点「继续上传」接着传` resumeHint.value = `「${file.name}」上传中断,进度已记住;重新选择同一个文件即可续传`
} finally { } finally {
uploadingId.value = null uploadingId.value = null
uploadPercent.value = 0 uploadPercent.value = 0
@@ -266,7 +271,9 @@ export function useDramaEdit() {
uploadingId.value = row ? row.id : -1 uploadingId.value = row ? row.id : -1
uploadPercent.value = 0 uploadPercent.value = 0
try { 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', bizType: 'DRAMA_COVER',
onProgress: (percent) => (uploadPercent.value = percent) onProgress: (percent) => (uploadPercent.value = percent)
}) })
+24 -9
View File
@@ -43,6 +43,7 @@ const {
openAudit, openAudit,
confirmAudit, confirmAudit,
freeze, freeze,
unfreeze,
createVisible, createVisible,
createSaving, createSaving,
createForm, createForm,
@@ -61,8 +62,8 @@ const {
<template> <template>
<PageHeading <PageHeading
eyebrow="创作者管理" eyebrow="创作者管理"
title="创作者入驻" title="创作者账号"
description="审核创作者主体与资质,通过后创作者才能提交内容。" description="录入创作者并直接开通后台账号,账号即开即用,无需入驻审核。"
> >
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button> <el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
</PageHeading> </PageHeading>
@@ -170,13 +171,13 @@ const {
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button> <el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
<!-- 状态机:通过可从 pending/rejected/frozen 发起,驳回只允许 pending,冻结只允许 approved。 <!-- 账号由后台直接发放:已通过 / 冻结行都不再给「审核」入口;
已通过的行走「冻结 / 重置密码」,不再给「审核」入口(点了必然被后端拒) --> C 端提交的入驻申请仍是 pending,可在这里一次性通过 -->
<el-button <el-button
v-if="canAudit && row.status !== 'approved'" v-if="canAudit && row.status === 'pending'"
size="small" size="small"
@click="openAudit(row, 'APPROVE')" @click="openAudit(row, 'APPROVE')"
>审核</el-button >通过</el-button
> >
<el-button <el-button
v-if="canAudit && row.status === 'approved'" v-if="canAudit && row.status === 'approved'"
@@ -185,12 +186,18 @@ const {
@click="freeze(row)" @click="freeze(row)"
>冻结</el-button >冻结</el-button
> >
<el-button
v-if="canAudit && row.status === 'frozen'"
size="small"
@click="unfreeze(row)"
>解冻</el-button
>
<el-button <el-button
v-if="canOpenAccount && !row.adminUserId" v-if="canOpenAccount && !row.adminUserId"
type="primary" type="primary"
size="small" size="small"
:loading="accountSaving" :loading="accountSaving"
:disabled="row.status !== 'approved'" :disabled="row.status === 'frozen'"
@click="openAccount(row)" @click="openAccount(row)"
>开通账号</el-button >开通账号</el-button
> >
@@ -299,16 +306,24 @@ const {
<el-form-item label="国家 / 地区"> <el-form-item label="国家 / 地区">
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" /> <el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
</el-form-item> </el-form-item>
<el-form-item label="同时开通后台账号">
<el-switch v-model="createForm.openAccount" />
<span class="console-muted creator-switch-hint"
>开启后保存即生成登录账号与初始密码,一次完成</span
>
</el-form-item>
<el-alert <el-alert
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;录入后需要先通过入驻审核才能开通账号" title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;无需入驻审核"
/> />
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible = false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate">录入</el-button> <el-button type="primary" :loading="createSaving" @click="confirmCreate">{{
createForm.openAccount ? '创建并开通账号' : '仅创建'
}}</el-button>
</template> </template>
</el-dialog> </el-dialog>
+263 -15
View File
@@ -4,6 +4,7 @@
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format' import { dateCell } from '@/utils/format'
import { detailSubmissionApi } from '@/api/admin/creator'
import { import {
SUBMISSION_STATUS, SUBMISSION_STATUS,
SUBMISSION_TYPE, SUBMISSION_TYPE,
@@ -11,6 +12,7 @@ import {
submissionTypeText, submissionTypeText,
useSubmissionAudit useSubmissionAudit
} from './composables/useSubmissionAudit' } from './composables/useSubmissionAudit'
import { ref } from 'vue'
const { can } = useRbac() const { can } = useRbac()
const canAudit = can('submission:audit') const canAudit = can('submission:audit')
@@ -32,9 +34,25 @@ const {
query, query,
reset, reset,
handleSizeChange, handleSizeChange,
openAudit, openAudit: rawOpenAudit,
confirmAudit confirmAudit
} = useSubmissionAudit() } = useSubmissionAudit()
const auditDetailLoading = ref(false)
async function openAudit(row, defaultAction = 'APPROVE') {
rawOpenAudit(row, defaultAction)
auditDetailLoading.value = true
try {
const detail = await detailSubmissionApi(row.id)
// 用 detail 里的完整字段覆盖 auditTarget(保留原始 openAudit 设置的 action)
Object.assign(auditTarget.value, detail)
} catch (e) {
console.warn('加载提交详情失败', e)
} finally {
auditDetailLoading.value = false
}
}
</script> </script>
<template> <template>
@@ -94,9 +112,19 @@ const {
class="drama-el-table" class="drama-el-table"
empty-text="没有符合条件的提交记录" empty-text="没有符合条件的提交记录"
> >
<el-table-column prop="id" label="提交ID" width="90" /> <el-table-column prop="id" label="提交ID" width="140" />
<el-table-column prop="dramaId" label="短剧ID" width="90" /> <el-table-column prop="dramaId" label="短剧ID" width="140" />
<el-table-column prop="creatorId" label="创作者ID" width="100" /> <el-table-column label="创作者" min-width="180">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; line-height: 1.4">
<span style="font-weight: 500">{{ row.creatorName || '—' }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ row.creatorCode || '' }}{{ row.creatorCode && row.creatorMobile ? ' · ' : ''
}}{{ row.creatorMobile || '' }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="提交类型" width="110"> <el-table-column label="提交类型" width="110">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template> <template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column> </el-table-column>
@@ -153,18 +181,93 @@ const {
</div> </div>
</section> </section>
<el-dialog v-model="auditVisible" title="内容提交审核" width="480px"> <el-dialog v-model="auditVisible" title="内容提交审核" width="640px">
<el-form label-position="top"> <div v-loading="auditDetailLoading" class="audit-detail">
<el-form-item label="提交信息"> <!-- 顶部:创作者 + 提交信息 -->
<el-input <div class="audit-meta">
:model-value=" <div class="audit-meta-item">
auditTarget <span class="audit-meta-label">创作者</span>
? `提交 #${auditTarget.id} · 短剧 ${auditTarget.dramaId} · ${submissionTypeText(auditTarget.type)}` <span class="audit-meta-value">
: '' {{ auditTarget?.creatorName || '—' }}
" <span v-if="auditTarget?.creatorCode" class="audit-meta-sub">
disabled · {{ auditTarget.creatorCode }}
</span>
<span v-if="auditTarget?.creatorMobile" class="audit-meta-sub">
· {{ auditTarget.creatorMobile }}
</span>
</span>
</div>
<div class="audit-meta-item">
<span class="audit-meta-label">提交</span>
<span class="audit-meta-value">
#{{ auditTarget?.id }} ·
<el-tag size="small" effect="plain">
{{ submissionTypeText(auditTarget?.type) }}
</el-tag>
·
<el-tag size="small" :type="submissionStatusMeta(auditTarget?.status).tag">
{{ submissionStatusMeta(auditTarget?.status).label }}
</el-tag>
<span v-if="auditTarget?.version" class="audit-meta-sub">
· v{{ auditTarget.version }}
</span>
</span>
</div>
</div>
<!-- 短剧预览卡片 -->
<div v-if="auditTarget?.dramaTitle" class="audit-drama">
<div class="audit-drama-cover">
<img
v-if="auditTarget.dramaCover"
:src="auditTarget.dramaCover"
:alt="auditTarget.dramaTitle"
/> />
</el-form-item> <div v-else class="audit-drama-cover-placeholder">无封面</div>
</div>
<div class="audit-drama-info">
<h4 class="audit-drama-title">{{ auditTarget.dramaTitle }}</h4>
<div class="audit-drama-tags">
<el-tag v-if="auditTarget.dramaGenre" size="small">{{ auditTarget.dramaGenre }}</el-tag>
<el-tag v-if="auditTarget.dramaLanguage" size="small" type="info">
{{ auditTarget.dramaLanguage }}
</el-tag>
<el-tag v-if="auditTarget.dramaTotalEpisodes != null" size="small" type="success">
共 {{ auditTarget.dramaTotalEpisodes }} 集
</el-tag>
</div>
<p v-if="auditTarget.dramaDescription" class="audit-drama-desc">
{{ auditTarget.dramaDescription }}
</p>
</div>
</div>
<div v-else class="audit-drama audit-drama-empty">短剧预览加载中…</div>
<!-- 分集预览 -->
<div v-if="auditTarget?.episodePreviews?.length" class="audit-episodes">
<div class="audit-section-title">分集列表({{ auditTarget.episodePreviews.length }})</div>
<div class="audit-episode-list">
<div v-for="ep in auditTarget.episodePreviews" :key="ep.id" class="audit-episode-item">
<div class="audit-episode-no">第{{ ep.epNo }}集</div>
<div class="audit-episode-title">{{ ep.title || '(未命名)' }}</div>
<div class="audit-episode-meta">
<span v-if="ep.durationSec">{{ ep.durationSec }}s</span>
<el-tag
v-if="ep.status"
size="small"
:type="
ep.status === 'success' ? 'success' : ep.status === 'failed' ? 'danger' : 'info'
"
>
{{ ep.status }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<el-form label-position="top" style="margin-top: 16px">
<el-form-item label="审核结果"> <el-form-item label="审核结果">
<el-radio-group v-model="auditForm.action"> <el-radio-group v-model="auditForm.action">
<el-radio label="APPROVE">通过</el-radio> <el-radio label="APPROVE">通过</el-radio>
@@ -201,3 +304,148 @@ const {
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<style scoped>
.audit-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.audit-meta {
display: flex;
flex-wrap: wrap;
gap: 16px 28px;
padding: 10px 14px;
background: var(--el-fill-color-light);
border-radius: 8px;
font-size: 13px;
}
.audit-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.audit-meta-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.audit-meta-value {
font-weight: 500;
color: var(--el-text-color-primary);
}
.audit-meta-sub {
font-weight: 400;
color: var(--el-text-color-secondary);
}
.audit-drama {
display: flex;
gap: 14px;
padding: 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
}
.audit-drama-cover {
width: 84px;
height: 112px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
}
.audit-drama-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audit-drama-cover-placeholder {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.audit-drama-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.audit-drama-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.audit-drama-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.audit-drama-desc {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.audit-drama-empty {
justify-content: center;
align-items: center;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.audit-episodes {
display: flex;
flex-direction: column;
gap: 8px;
}
.audit-section-title {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.audit-episode-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.audit-episode-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
font-size: 13px;
}
.audit-episode-no {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 56px;
}
.audit-episode-title {
flex: 1;
min-width: 0;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-episode-meta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more