diff --git a/.gitignore b/.gitignore index 2051c8c..89dc256 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ dist-ssr *-lock.* pnpm-debug stats.html -dev.out.log -dev.err.log -start-dev.bat +*.apk + +# 本机运行时产物(start-dev.bat / vite 日志) +start-dev.bat +dev.out.log +dev.err.log diff --git a/.husky/commit-msg b/.husky/commit-msg index 10cb551..ae34e3a 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,9 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then + echo "commit message contains AI attribution, remove it before committing" >&2 + exit 1 +fi + pnpm commitlint --edit "$1" diff --git a/docs/plans/2026-10-22-transcode-media-id-fix-ui.md b/docs/plans/2026-10-22-transcode-media-id-fix-ui.md new file mode 100644 index 0000000..fc50d28 --- /dev/null +++ b/docs/plans/2026-10-22-transcode-media-id-fix-ui.md @@ -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`。 diff --git a/docs/plans/2026-10-22-transcode-panel-split.md b/docs/plans/2026-10-22-transcode-panel-split.md new file mode 100644 index 0000000..7d27a25 --- /dev/null +++ b/docs/plans/2026-10-22-transcode-panel-split.md @@ -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`。 diff --git a/docs/plans/2026-10-22-transcode-profile-settings.md b/docs/plans/2026-10-22-transcode-profile-settings.md new file mode 100644 index 0000000..31eaf61 --- /dev/null +++ b/docs/plans/2026-10-22-transcode-profile-settings.md @@ -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` diff --git a/docs/plans/2026-10-22-transcode-progress-ui.md b/docs/plans/2026-10-22-transcode-progress-ui.md new file mode 100644 index 0000000..09d29fa --- /dev/null +++ b/docs/plans/2026-10-22-transcode-progress-ui.md @@ -0,0 +1,12 @@ +# 转码面板实时进度与更新时间 + +## 目标 + +- 展示后端 task progress、状态文案与 updatedAt。 +- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。 +- FAILED 保持错误信息与重新转码入口。 +- 保持现有轮询停止、关闭清理与字符串 ID 逻辑。 + +## 验证 + +- `pnpm lint:eslint`、`pnpm build:dev`。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md new file mode 100644 index 0000000..17e05b2 --- /dev/null +++ b/docs/plans/PROGRESS.md @@ -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 为项目既有警告。 diff --git a/package.json b/package.json index 0c3f7e6..9fa89f3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "i": "pnpm install", - "dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base", + "dev": "vite --mode base", "ts:check": "pnpm vue-tsc --noEmit --skipLibCheck", "build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro", "build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev", @@ -38,6 +38,7 @@ "echarts": "^5.4.3", "echarts-wordcloud": "^2.1.0", "element-plus": "2.5.5", + "hls.js": "^1.7.3", "lodash-es": "^4.17.21", "mitt": "^3.0.1", "nprogress": "^0.2.0", diff --git a/src/api/admin/appUser.ts b/src/api/admin/appUser.ts index ae6bfa7..884938d 100644 --- a/src/api/admin/appUser.ts +++ b/src/api/admin/appUser.ts @@ -13,6 +13,20 @@ export interface AppUserPageParams { registerSource?: string } +export interface WatchHistoryPageParams { + pageIndex?: number + pageSize?: number + keyword?: string + userId?: number + dramaTitle?: string + dramaId?: number + episodeId?: number + source?: string + finished?: boolean + startTime?: string + endTime?: string +} + export const pageAppUsersApi = (params: AppUserPageParams) => { return request.get>({ url: '/admin/app-users/page', params }) } @@ -32,6 +46,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => { }) } +export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => { + return request.get>({ + url: '/admin/watch-history/page', + params + }) +} + /** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */ export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => { return request.put({ diff --git a/src/api/admin/drama.ts b/src/api/admin/drama.ts index 753bff4..abe00bf 100644 --- a/src/api/admin/drama.ts +++ b/src/api/admin/drama.ts @@ -15,7 +15,9 @@ export interface DramaPageParams { keyword?: string status?: string categoryId?: number + categoryIds?: number[] isHot?: boolean + dramaTypes?: Array<'ANIME' | 'LIVE'> } export const pageDramasApi = (params: DramaPageParams) => { diff --git a/src/api/admin/dramaClassification.ts b/src/api/admin/dramaClassification.ts new file mode 100644 index 0000000..246539d --- /dev/null +++ b/src/api/admin/dramaClassification.ts @@ -0,0 +1,85 @@ +// Author: fruit.com.cn + +import request from '@/axios' +import type { PageResult } from './types' + +export interface DramaClassificationItem { + id: number + name: string + sort: number + status: number + dramaCount: number + updatedAt?: string +} + +export interface DramaClassificationSaveData { + name: string + sort?: number + status?: number +} + +export interface ClassificationDramaItem { + id: number + title: string + cover: string + status: string + totalEpisodes: number + sort: number + updatedAt: string +} + +export interface ClassificationDramaPageParams { + keyword?: string + pageIndex?: number + pageSize?: number +} + +export const listClassificationsApi = () => { + return request.get({ url: '/admin/drama-classifications' }) +} + +export const createClassificationApi = (data: DramaClassificationSaveData) => { + return request.post({ url: '/admin/drama-classifications', data }) +} + +export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => { + return request.put({ url: `/admin/drama-classifications/${id}`, data }) +} + +export const deleteClassificationApi = (id: number) => { + return request.delete({ url: `/admin/drama-classifications/${id}` }) +} + +/** 分类下已关联的短剧 */ +export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => { + return request.get>({ + url: `/admin/drama-classifications/${id}/dramas`, + params + }) +} + +/** 尚未关联到该分类的短剧(添加弹窗用) */ +export const listClassificationDramaCandidatesApi = ( + id: number, + params: ClassificationDramaPageParams +) => { + return request.get>({ + url: `/admin/drama-classifications/${id}/dramas/candidates`, + params + }) +} + +/** 批量关联短剧,返回实际新增数量 */ +export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => { + return request.post({ + url: `/admin/drama-classifications/${id}/dramas`, + data: { dramaIds } + }) +} + +/** 从分类移除短剧 */ +export const unbindClassificationDramaApi = (id: number, dramaId: number) => { + return request.delete({ + url: `/admin/drama-classifications/${id}/dramas/${dramaId}` + }) +} diff --git a/src/api/admin/dramaPlaylistCategory.ts b/src/api/admin/dramaPlaylistCategory.ts new file mode 100644 index 0000000..45c87de --- /dev/null +++ b/src/api/admin/dramaPlaylistCategory.ts @@ -0,0 +1,80 @@ +// Author: fruit.com.cn + +import request from '@/axios' +import type { PageResult } from './types' +import type { ClassificationDramaItem } from './dramaClassification' + +export interface DramaPlaylistCategoryItem { + id: number + name: string + description: string + sort: number + status: number + dramaCount: number +} + +export interface DramaPlaylistCategorySaveData { + name: string + description?: string + sort?: number + status?: number +} + +export interface PlaylistCategoryDramaPageParams { + keyword?: string + pageIndex?: number + pageSize?: number +} + +export const listPlaylistCategoriesApi = () => { + return request.get({ url: '/admin/drama-playlist-categories' }) +} + +export const createPlaylistCategoryApi = (data: DramaPlaylistCategorySaveData) => { + return request.post({ url: '/admin/drama-playlist-categories', data }) +} + +export const updatePlaylistCategoryApi = (id: number, data: DramaPlaylistCategorySaveData) => { + return request.put({ url: `/admin/drama-playlist-categories/${id}`, data }) +} + +export const deletePlaylistCategoryApi = (id: number) => { + return request.delete({ url: `/admin/drama-playlist-categories/${id}` }) +} + +/** 分类下已关联的短剧 */ +export const listPlaylistCategoryDramasApi = ( + id: number, + params: PlaylistCategoryDramaPageParams +) => { + return request.get>({ + url: `/admin/drama-playlist-categories/${id}/dramas`, + params + }) +} + +/** 尚未关联到该分类的短剧(选择弹窗用) */ +export const listPlaylistCategoryDramaCandidatesApi = ( + id: number, + params: PlaylistCategoryDramaPageParams +) => { + return request.get>({ + url: `/admin/drama-playlist-categories/${id}/dramas/candidates`, + params + }) +} + +/** 批量关联短剧,返回实际新增数量 */ +export const bindPlaylistCategoryDramasApi = (id: number, dramaIds: number[]) => { + return request.post({ + url: `/admin/drama-playlist-categories/${id}/dramas`, + data: { dramaIds } + }) +} + +/** 从分类移除短剧 */ +export const unbindPlaylistCategoryDramaApi = (id: number, dramaId: number) => { + return request.delete({ + url: `/admin/drama-playlist-categories/${id}/dramas/${dramaId}` + }) +} diff --git a/src/api/admin/episode.ts b/src/api/admin/episode.ts index 6488c53..7657106 100644 --- a/src/api/admin/episode.ts +++ b/src/api/admin/episode.ts @@ -1,10 +1,35 @@ // Author: fruit.com.cn import request from '@/axios' -import type { EpisodeItem, EpisodeSaveData } from './types' +import type { EpisodeItem, EpisodeSaveData, PageResult } from './types' -export const listEpisodesApi = (dramaId: number, status?: string) => { - return request.get({ url: '/admin/episodes', params: { dramaId, status } }) +export interface EpisodePageParams { + dramaId: number | string + status?: string + pageIndex?: number + pageSize?: number +} + +/** + * 解包剧集列表分页。 + * 契约(OpenAPI PageResultEpisodeRespVO):data = { total, list }。 + * 兼容误把 data 当数组的旧调用,避免列表空白。 + */ +export function parseEpisodePage(payload: unknown): PageResult { + if (Array.isArray(payload)) { + return { total: payload.length, list: payload as EpisodeItem[] } + } + if (payload && typeof payload === 'object') { + const page = payload as { total?: unknown; list?: unknown } + const list = Array.isArray(page.list) ? (page.list as EpisodeItem[]) : [] + const total = Number(page.total) + return { total: Number.isFinite(total) ? total : list.length, list } + } + return { total: 0, list: [] } +} + +export const listEpisodesApi = (params: EpisodePageParams) => { + return request.get>({ url: '/admin/episodes', params }) } export const createEpisodeApi = (data: EpisodeSaveData) => { diff --git a/src/api/admin/legalDocument.ts b/src/api/admin/legalDocument.ts new file mode 100644 index 0000000..02514e8 --- /dev/null +++ b/src/api/admin/legalDocument.ts @@ -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({ url: '/admin/legal-documents' }) +} + +export const saveLegalDocumentsApi = (items: LegalDocument[]) => { + return request.put({ url: '/admin/legal-documents', data: items }) +} diff --git a/src/api/admin/media.ts b/src/api/admin/media.ts index aa7077e..73d31ca 100644 --- a/src/api/admin/media.ts +++ b/src/api/admin/media.ts @@ -1,7 +1,7 @@ // Author: fruit.com.cn import request from '@/axios' -import type { MediaAsset } from './types' +import type { MediaAsset, MediaTranscodeProfile } from './types' export interface UploadInitData { fileName: string @@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => { export const getMediaApi = (id: number) => { return request.get({ url: `/admin/media/${id}` }) } + +/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */ +export const getMediaTranscodeProfileApi = () => { + return request.get({ + url: '/admin/media/transcode-profile', + silent: true + }) +} + +/** 保存转码档位配置,并返回服务端最终生效值。 */ +export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => { + return request.put({ + url: '/admin/media/transcode-profile', + data, + silent: true + }) +} diff --git a/src/api/admin/transcode.ts b/src/api/admin/transcode.ts new file mode 100644 index 0000000..fe436fa --- /dev/null +++ b/src/api/admin/transcode.ts @@ -0,0 +1,30 @@ +// Author: fruit.com.cn + +import request from '@/axios' +import type { EpisodeTranscodeItem, MediaThumbnailResult, TranscodeTriggerResult } from './types' + +/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */ +export const getEpisodeTranscodeApi = (episodeId: number | string) => { + return request.get({ + url: `/admin/episodes/${episodeId}/transcode` + }) +} + +/** 按媒体分页读取帧图;页面切换时复用该接口。 */ +export const getMediaThumbnailsApi = ( + mediaAssetId: string, + pageIndex: number, + pageSize: number +) => { + return request.get({ + url: `/admin/media/${mediaAssetId}/thumbnails`, + params: { pageIndex, pageSize } + }) +} + +/** 手动触发或重转当前分集绑定的媒体。 */ +export const triggerMediaTranscodeApi = (mediaAssetId: string) => { + return request.post({ + url: `/admin/media/${mediaAssetId}/transcode` + }) +} diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 15780cb..8641c5f 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -20,11 +20,15 @@ export interface DramaItem { title: string subtitle?: string categoryId?: number + categoryIds?: number[] + categoryNames?: string[] category?: string genre?: string description?: string cover?: string language?: string + dramaType?: 'ANIME' | 'LIVE' + dramaTypes?: Array<'ANIME' | 'LIVE'> episodes?: number freeEpisodes?: number unlockCoin?: number @@ -33,6 +37,9 @@ export interface DramaItem { status: string statusText: string views?: number + favoriteCount?: number + likeCount?: number + commentCount?: number sort?: number publishAt?: string createdAt?: string @@ -43,12 +50,18 @@ export interface DramaSaveData { title: string subtitle?: string categoryId?: number + categoryIds?: number[] genre?: string description?: string cover?: string language?: string + dramaType?: 'ANIME' | 'LIVE' + dramaTypes?: Array<'ANIME' | 'LIVE'> freeEpisodes?: number unlockCoin?: number + favoriteCount?: number + likeCount?: number + heat?: number isHot?: boolean sort?: number } @@ -69,6 +82,10 @@ export interface EpisodeItem { status: string statusText: string views?: number + likeCount?: number + commentCount?: number + favoriteCount?: number + heat?: number sort?: number } @@ -81,6 +98,7 @@ export interface EpisodeSaveData { videoUrl?: string isFree?: boolean unlockCoin?: number + likeCount?: number status?: string sort?: number } @@ -204,6 +222,105 @@ export interface MediaAsset { createdAt?: string } +/** 单档清晰度配置,字段与 FruitServer MediaTranscodeProfile.Rung 一致。 */ +export interface MediaTranscodeLadderRung { + quality: string + width: number + height: number + videoBitrateKbps: number + audioBitrateKbps: number + enabled: boolean +} + +/** 雪碧图采样配置,字段与 FruitServer MediaTranscodeProfile.Sprite 一致。 */ +export interface MediaTranscodeSprite { + enabled: boolean + intervalSeconds: number + columns: number + thumbWidth: number +} + +/** 转码档位配置,字段与 FruitServer MediaTranscodeProfile 一致。 */ +export interface MediaTranscodeProfile { + enabled: boolean + segmentSeconds: number + ladder: MediaTranscodeLadderRung[] + sprite: MediaTranscodeSprite +} + +export interface MediaRenditionItem { + id: string + quality: string + qualityName: string + width: number + height: number + resolution: string + videoBitrateKbps: number + audioBitrateKbps: number + videoCodec: string + audioCodec?: string + container: string + hlsUrl: string + sizeBytes: number + durationSec: number + segmentCount: number + status: string +} + +export interface MediaThumbnailFrameItem { + frameIndex: number + timeMs: number + endTimeMs?: number + imageUrl: string + spriteUrl: string + x: number + y: number + width: number + height: number +} + +export interface MediaThumbnailSpriteInfo { + spriteUrl?: string + spriteUrls: string[] + vttUrl?: string + columns: number + rows: number + totalFrames: number + thumbWidth: number + thumbHeight: number + intervalSeconds: number +} + +export interface MediaThumbnailResult { + mediaAssetId?: string | null + episodeId?: string | null + /** READY / FAILED / EMPTY / INVALID / NO_EPISODE */ + status: string + errorMsg?: string + pageIndex: number + pageSize: number + frames: PageResult + sprite?: MediaThumbnailSpriteInfo | null +} + +export interface EpisodeTranscodeItem { + episodeId: string + mediaAssetId?: string | null + taskId?: string | null + status: string + progress?: number + retryCount?: number + errorMsg?: string + updatedAt?: string + renditions: MediaRenditionItem[] + thumbnails?: MediaThumbnailResult | null +} + +export interface TranscodeTriggerResult { + taskId: string + status: string +} + export interface BannerItem { id: number imageUrl: string @@ -269,6 +386,10 @@ export interface AppUserItem { maskedTarget: string createdAt?: string lastLoginAt?: string + /** ANDROID / IOS / WEB */ + platform?: string + /** 系统版本,例如 Android 16 */ + osVersion?: string /** 预留:会员等级与到期(第二阶段) */ vipLevel?: number vipExpireAt?: string @@ -294,16 +415,22 @@ export interface AppUserBanRecord { export interface WatchHistoryItem { id: number + userId?: number + uid?: string + nickname?: string dramaId: number dramaTitle?: string dramaPublished?: boolean episodeId: number episodeNo?: number + episodeTitle?: string positionMs: number durationMs: number finished?: boolean source: string deviceId: string + platform?: string + osVersion?: string watchedAt?: string } @@ -311,7 +438,10 @@ export interface WatchHistoryItem { export interface CommentItem { id: number dramaId: number + dramaTitle?: string episodeId: number + episodeNo?: number + episodeTitle?: string parentId: number rootId: number userId: number @@ -330,9 +460,20 @@ export interface CommentItem { export interface ReportItem { id: number reporterId: number + reporterNickname?: string + reporterUid?: string /** COMMENT / USER / DRAMA */ targetType: string targetId: number + targetSummary?: string + dramaId?: number + dramaTitle?: string + episodeId?: number + episodeNo?: number + episodeTitle?: string + commentContent?: string + targetNickname?: string + targetUid?: string reasonCode: string description: string /** pending / handled / rejected */ diff --git a/src/components/Form/src/helper/componentMap.ts b/src/components/Form/src/helper/componentMap.ts index 67b06ef..1858a19 100644 --- a/src/components/Form/src/helper/componentMap.ts +++ b/src/components/Form/src/helper/componentMap.ts @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import { defineAsyncComponent } from 'vue' import type { Component } from 'vue' import { ElCascader, @@ -23,9 +24,12 @@ import { ElUpload } from 'element-plus' import { InputPassword } from '@/components/InputPassword' -import { Editor } from '@/components/Editor' import { ComponentName } from '../types' +// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载, +// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。 +const Editor = defineAsyncComponent(() => import('@/components/Editor')) + const componentMap: Recordable = { RadioGroup: ElRadioGroup, RadioButton: ElRadioGroup, diff --git a/src/components/Form/src/types/index.ts b/src/components/Form/src/types/index.ts index 402116d..a9f1e84 100644 --- a/src/components/Form/src/types/index.ts +++ b/src/components/Form/src/types/index.ts @@ -22,7 +22,8 @@ import { ISelectProps, UploadProps } from 'element-plus' -import { IEditorConfig } from '@wangeditor/editor' +// 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链 +import type { IEditorConfig } from '@wangeditor/editor' import { CSSProperties } from 'vue' export interface PlaceholderModel { diff --git a/src/constants/icons.ts b/src/constants/icons.ts index e1b530c..2a448d5 100644 --- a/src/constants/icons.ts +++ b/src/constants/icons.ts @@ -37,6 +37,8 @@ export const AppIcons = { delete: 'material-symbols:delete-rounded', upload: 'material-symbols:upload-rounded', download: 'material-symbols:download-rounded', + copy: 'material-symbols:content-copy-rounded', + openInNew: 'material-symbols:open-in-new-rounded', visibility: 'material-symbols:visibility-rounded', arrowUp: 'material-symbols:arrow-upward-rounded', arrowDown: 'material-symbols:arrow-downward-rounded', diff --git a/src/locales/en.ts b/src/locales/en.ts index 3e35f06..26c7faa 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -112,6 +112,7 @@ export default { login: 'Sign in', remember: 'Remember me', forgetPassword: 'Forget password', + forgetPasswordHint: 'Please contact the administrator to reset your password', usernamePlaceholder: 'Please input username', passwordPlaceholder: 'Please input password' }, @@ -162,7 +163,8 @@ export default { treeTable: 'Tree table', PicturePreview: 'Table Image Preview', department: 'Department management', - menuManagement: 'Menu management' + menuManagement: 'Menu management', + transcodeSettings: 'Transcode settings' }, permission: { hasPermission: 'Please set the operation permission value' @@ -526,5 +528,167 @@ export default { inputPasswordDemo: { title: 'InputPassword', inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' + }, + episodeTranscode: { + title: 'Transcode outputs', + description: 'Review the transcode task, renditions and frame previews for this episode.', + statusLabel: 'Status', + progress: 'Progress', + retryCount: 'Retries', + updatedAt: 'Last updated', + waitingProgress: 'Processing (waiting for progress report)', + reprocess: 'Transcode again', + reprocessing: 'Submitting', + triggerSuccess: 'Transcode task submitted', + loadFailed: 'Failed to load transcode outputs', + retryLoad: 'Reload', + triggerFailed: 'Failed to retry transcode', + noMedia: 'No uploaded media is bound to this episode yet.', + noRendition: 'No rendition outputs', + renditionTitle: 'Rendition outputs', + quality: 'Quality', + resolution: 'Resolution', + videoBitrate: 'Video bitrate', + audioBitrate: 'Audio bitrate', + codec: 'Codec', + container: 'Container', + fileSize: 'File size', + duration: 'Duration', + segments: 'Segments', + statusColumn: 'Status', + actions: 'Actions', + copyUrl: 'Copy playback URL', + openUrl: 'Open in new window', + play: 'Preview playback', + playerFailed: 'Playback failed, please check the rendition output', + playerUnsupported: 'This browser does not support HLS playback', + copySuccess: 'Playback URL copied', + copyFailed: 'Failed to copy playback URL', + openFailed: 'Unable to open playback URL', + frameTitle: 'Frame previews', + frameView: 'Frames', + spriteView: 'Sprites', + frameCount: '{total} frames', + frameTime: 'Timestamp', + previewFrame: 'Preview frame at {time}', + spriteSheet: 'Sprite {index}', + spriteSheetFrames: '{count} frames', + noFrames: 'No frame previews', + noSprites: 'No sprite sheets available', + zoomTitle: 'Frame preview', + status: { + queued: 'Queued', + running: 'Transcoding', + success: 'Success', + failed: 'Failed', + empty: 'No output', + noVideo: 'No video', + noMediaAsset: 'No media asset', + noTask: 'No transcode task', + noEpisode: 'No episode binding', + episodeNotFound: 'Episode not found', + invalid: 'Invalid output', + ready: 'Ready', + unknown: 'Unknown' + } + }, + episodeBatch: { + title: 'Batch upload episodes', + entry: 'Batch upload', + selectFiles: 'Select videos', + startUpload: 'Start upload', + uploadingAction: 'Uploading', + pause: 'Pause', + resume: 'Resume', + retry: 'Retry', + retryAll: 'Retry all', + remove: 'Remove', + hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each. Files stay queued until you click Start upload.', + invalidType: 'Only MP4, MOV, MKV, WEBM are allowed', + oversize: 'File exceeds 500MB and was not queued', + overallProgress: 'Overall progress', + empty: 'No videos selected', + resultSummary: 'Upload finished: {done} succeeded, {failed} failed', + createEpisodes: 'Create episodes', + closeWhileUploading: + 'Uploads are still in progress. Closing will stop dispatching new tasks. Continue?', + resumeTitle: 'Unfinished uploads found', + resumeConfirm: + '{count} unfinished upload(s) found. Resume? Re-select the same local files to match.', + resumeYes: 'Pick files to resume', + resumeNo: 'Discard', + resumeAbandoned: 'Unfinished uploads discarded', + resumeMatched: 'Matched {count} file(s). Click Start upload to continue.', + resumeUnmatched: '{count} session(s) could not match local files; pick again manually', + createTitle: 'Create episodes in batch', + createHint: + 'Title uses the video file name; episode numbers are assigned automatically. Free vs paid follows the drama free-episode count; episodes beyond that are paid. You can edit them later in episode settings.', + colSource: 'Source file', + colNumber: 'Episode No.', + colTitle: 'Title', + colFree: 'Pricing', + labelFree: 'Free', + labelPaid: 'Paid', + colResult: 'Result', + missingVideo: 'Missing video URL', + createOk: 'Created', + createFailed: 'Create failed', + createAllSuccess: 'All episodes created', + createPartial: 'Some episodes failed; you can retry', + submitCreate: 'Create', + retryFailed: 'Retry failed', + status: { + waiting: 'Pending', + uploading: 'Uploading', + merging: 'Merging', + done: 'Done', + failed: 'Failed', + paused: 'Paused' + } + }, + transcodeSettings: { + eyebrow: 'Content management', + title: 'Transcode settings', + description: + 'Configure the quality ladder, HLS segment duration and thumbnail sprite options for new tasks.', + reload: 'Reload', + save: 'Save settings', + saved: 'Transcode settings saved', + saveFailed: 'Failed to save transcode settings', + loadFailed: 'Failed to load transcode settings', + noPermission: 'This account cannot manage transcode settings', + 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' + } } } diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 1961130..1387d44 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -112,6 +112,7 @@ export default { login: '登录', remember: '记住我', forgetPassword: '忘记密码', + forgetPasswordHint: '请联系管理员重置密码', usernamePlaceholder: '请输入用户名', passwordPlaceholder: '请输入密码' }, @@ -162,7 +163,8 @@ export default { treeTable: '树形表格', PicturePreview: '表格图片预览', department: '部门管理', - menuManagement: '菜单管理' + menuManagement: '菜单管理', + transcodeSettings: '转码设置' }, permission: { hasPermission: '请设置操作权限值' @@ -520,5 +522,161 @@ export default { inputPasswordDemo: { title: '密码输入框', inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' + }, + episodeTranscode: { + title: '转码产物', + description: '查看当前分集的转码任务、清晰度与帧图产物。', + statusLabel: '状态', + progress: '进度', + retryCount: '重试次数', + updatedAt: '最近更新', + waitingProgress: '处理中(等待进度上报)', + reprocess: '重新转码', + reprocessing: '正在触发', + triggerSuccess: '已提交转码任务', + loadFailed: '转码产物加载失败', + retryLoad: '重新加载', + triggerFailed: '重新转码失败', + noMedia: '当前分集尚未绑定已上传视频,暂无可查询的转码产物。', + noRendition: '暂无清晰度产物', + renditionTitle: '清晰度产物', + quality: '档位名称', + resolution: '分辨率', + videoBitrate: '视频码率', + audioBitrate: '音频码率', + codec: '编码', + container: '容器', + fileSize: '文件大小', + duration: '时长', + segments: '分片数', + statusColumn: '状态', + actions: '操作', + copyUrl: '复制播放地址', + openUrl: '新窗口打开', + play: '预览播放', + playerFailed: '播放失败,请检查产物是否完整', + playerUnsupported: '当前浏览器不支持 HLS 播放', + copySuccess: '播放地址已复制', + copyFailed: '复制播放地址失败', + openFailed: '无法打开播放地址', + frameTitle: '帧图预览', + frameView: '逐帧', + spriteView: '雪碧图', + frameCount: '共 {total} 帧', + frameTime: '时间点', + previewFrame: '预览 {time} 帧图', + spriteSheet: '雪碧图 {index}', + spriteSheetFrames: '{count} 帧', + noFrames: '暂无帧图产物', + noSprites: '暂无可展示的雪碧图', + zoomTitle: '帧图预览', + status: { + queued: '排队中', + running: '转码中', + success: '成功', + failed: '失败', + empty: '暂无产物', + noVideo: '无视频', + noMediaAsset: '未绑定媒资', + noTask: '无转码任务', + noEpisode: '未绑定分集', + episodeNotFound: '分集不存在', + invalid: '产物异常', + ready: '就绪', + unknown: '未知状态' + } + }, + episodeBatch: { + title: '批量上传剧集', + entry: '批量上传', + selectFiles: '选择视频', + startUpload: '开始上传', + uploadingAction: '上传中', + pause: '暂停', + resume: '继续', + retry: '重试', + retryAll: '全部重试', + remove: '移除', + hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;选完文件后点击「开始上传」才会真正上传', + invalidType: '仅支持 MP4、MOV、MKV、WEBM', + oversize: '文件超过 500MB,未入队', + overallProgress: '总进度', + empty: '尚未选择视频', + resultSummary: '上传结束:成功 {done} 个,失败 {failed} 个', + createEpisodes: '批量创建分集', + closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?', + resumeTitle: '发现未完成上传', + resumeConfirm: '发现 {count} 个未完成上传,是否续传?请重新选择相同本地文件以匹配。', + resumeYes: '选择文件续传', + resumeNo: '放弃', + resumeAbandoned: '已放弃未完成上传', + resumeMatched: '已匹配 {count} 个文件,请点击「开始上传」继续', + resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选', + createTitle: '批量创建分集', + createHint: + '标题取视频文件名,集号自动顺延分配;免费集数由短剧管理中的「免费集数」配置决定,超出部分为收费集;创建后可在分集编辑中修改', + colSource: '源文件', + colNumber: '集号', + colTitle: '标题', + colFree: '收费类型', + labelFree: '免费', + labelPaid: '收费', + colResult: '结果', + missingVideo: '缺少视频地址', + createOk: '已创建', + createFailed: '创建失败', + createAllSuccess: '全部分集已创建', + createPartial: '部分分集创建失败,可重试', + submitCreate: '创建', + retryFailed: '重试失败项', + status: { + waiting: '待上传', + uploading: '上传中', + merging: '合并中', + done: '已完成', + failed: '失败', + paused: '已暂停' + } + }, + transcodeSettings: { + eyebrow: '内容管理', + title: '转码设置', + description: '配置之后新任务的清晰度阶梯、HLS 分片与缩略图雪碧图参数。', + reload: '重新加载', + save: '保存设置', + saved: '转码设置已保存', + saveFailed: '转码设置保存失败', + loadFailed: '转码设置加载失败', + noPermission: '当前账号没有转码设置权限', + 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: '请修正标记的转码配置后再保存' + } } } diff --git a/src/permission.ts b/src/permission.ts index 720df79..dc61a1a 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants' import { useUserStoreWithOut } from '@/store/modules/user' import { getAdminRoleApi, getProfileApi } from '@/api/login' import { landingPath } from '@/utils/landing' +import { supplementLocalAdminRoutes } from '@/router/localRoutes' const { start, done } = useNProgress() @@ -68,6 +69,10 @@ router.beforeEach(async (to, from, next) => { } catch { // 拉取失败时退回上次缓存的菜单,不阻塞进入 } + const permissions = Array.isArray(userStore.getUserInfo?.permissions) + ? userStore.getUserInfo.permissions + : [] + routers = supplementLocalAdminRoutes(routers, permissions) await permissionStore.generateRoutes('server', routers) } else { await permissionStore.generateRoutes('frontEnd', roleRouters as string[]) diff --git a/src/router/index.ts b/src/router/index.ts index d4bd294..123ec04 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -120,6 +120,15 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [ meta: { title: '类型管理' } + }, + { + path: 'media-transcode', + name: 'media-transcode', + component: () => import('@/views/systemManagement/TranscodeSettingsView.vue'), + meta: { + title: 'router.transcodeSettings', + permission: ['episode:upload'] + } } ] }, @@ -202,6 +211,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [ meta: { title: '操作日志' } + }, + { + path: 'legal-documents', + name: 'legal-documents', + component: () => import('@/views/systemManagement/LegalDocumentView.vue'), + meta: { + title: '协议设置' + } } ] } diff --git a/src/router/localRoutes.ts b/src/router/localRoutes.ts new file mode 100644 index 0000000..bfbda35 --- /dev/null +++ b/src/router/localRoutes.ts @@ -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 +} diff --git a/src/styles/business/drama-management.css b/src/styles/business/drama-management.css index da70d90..4cb4cf9 100644 --- a/src/styles/business/drama-management.css +++ b/src/styles/business/drama-management.css @@ -147,6 +147,24 @@ width: 180px !important; } +.readonly-stat { + min-width: 180px; + height: 32px; + padding: 0 12px; + color: var(--el-text-color-secondary); + line-height: 32px; + background: var(--el-fill-color-lighter); + border: 1px solid var(--el-border-color-lighter); + border-radius: var(--el-border-radius-base); +} + +.readonly-stat-hint { + margin: 4px 0 0; + color: var(--el-text-color-placeholder); + font-size: 12px; + line-height: 1.4; +} + .cover-editor { display: flex; gap: 18px; diff --git a/src/styles/business/style.css b/src/styles/business/style.css index d08deb0..5ef09b3 100644 --- a/src/styles/business/style.css +++ b/src/styles/business/style.css @@ -560,13 +560,16 @@ body { gap: 6px; } -.drama-select { +.drama-select, +.status-select { width: 100%; } -.drama-select .el-input__wrapper { +.drama-select .el-input__wrapper, +.status-select .el-input__wrapper { box-shadow: 0 0 0 1px #efedf3 inset !important; } -.drama-select .el-input__inner { +.drama-select .el-input__inner, +.status-select .el-input__inner { font-size: 13px; } diff --git a/src/views/Login/components/LoginForm.vue b/src/views/Login/components/LoginForm.vue index ec583b9..bf7f6fd 100644 --- a/src/views/Login/components/LoginForm.vue +++ b/src/views/Login/components/LoginForm.vue @@ -4,7 +4,7 @@ import { reactive, ref, watch, onMounted, unref } from 'vue' import { Form, FormSchema } from '@/components/Form' import { useI18n } from '@/hooks/web/useI18n' -import { ElCheckbox, ElLink } from 'element-plus' +import { ElCheckbox, ElLink, ElMessage } from 'element-plus' import { useForm } from '@/hooks/web/useForm' import { loginApi, getAdminRoleApi } from '@/api/login' import { useAppStore } from '@/store/modules/app' @@ -92,7 +92,11 @@ const schema = reactive([ <>
- + ElMessage.info(t('login.forgetPasswordHint'))} + > {t('login.forgetPassword')}
diff --git a/src/views/contentManagement/CategoryDramaView.vue b/src/views/contentManagement/CategoryDramaView.vue new file mode 100644 index 0000000..8107ff2 --- /dev/null +++ b/src/views/contentManagement/CategoryDramaView.vue @@ -0,0 +1,594 @@ + + + + + + + + diff --git a/src/views/contentManagement/DramaManagementView.vue b/src/views/contentManagement/DramaManagementView.vue index 9941efe..ba65d0c 100644 --- a/src/views/contentManagement/DramaManagementView.vue +++ b/src/views/contentManagement/DramaManagementView.vue @@ -29,7 +29,8 @@ const categories = ref([]) const keyword = ref('') const statusFilter = ref(undefined) -const categoryId = ref(undefined) +const categoryIds = ref([]) +const dramaTypes = ref([]) const hotFilter = ref(undefined) const pageIndex = ref(1) const pageSize = ref(10) @@ -41,14 +42,20 @@ const coverPercent = ref(0) const coverError = ref('') const coverFileName = ref('') const editingId = ref(null) +const editingCommentCount = ref(0) +const editingLikeCount = ref(0) const formRef = ref() const form = reactive({ title: '', - categoryId: undefined, + categoryIds: [], + dramaTypes: ['LIVE'], description: '', freeEpisodes: 3, unlockCoin: 0, + favoriteCount: 0, + heat: 0, isHot: false, + sort: 0, cover: '' }) @@ -57,7 +64,12 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const formRules = { title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }], - categoryId: [{ required: true, message: '请选择短剧类型', trigger: 'change' }] + categoryIds: [ + { type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' } + ], + dramaTypes: [ + { type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' } + ] } const coverDisplayName = computed( @@ -78,6 +90,11 @@ const statusOptions = [ { code: 'offline', text: '已下线' } ] +const dramaTypeOptions = [ + { value: 'ANIME', label: '漫剧' }, + { value: 'LIVE', label: '真人剧' } +] + const actionMeta = { submit: { label: '提交审核', done: '已提交审核' }, approve: { label: '审核通过', done: '已通过审核' }, @@ -94,7 +111,8 @@ async function load() { pageSize: pageSize.value, keyword: keyword.value.trim() || undefined, status: statusFilter.value || undefined, - categoryId: categoryId.value, + categoryIds: categoryIds.value.length ? categoryIds.value : undefined, + dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined, isHot: hotFilter.value }) list.value = res.data?.list || [] @@ -117,7 +135,8 @@ function search() { function resetFilters() { keyword.value = '' statusFilter.value = undefined - categoryId.value = undefined + categoryIds.value = [] + dramaTypes.value = [] hotFilter.value = undefined search() } @@ -142,15 +161,21 @@ function openCreate() { editingId.value = null Object.assign(form, { title: '', - categoryId: undefined, + categoryIds: [], + dramaTypes: ['LIVE'], description: '', freeEpisodes: 3, unlockCoin: 0, + favoriteCount: 0, + heat: 0, isHot: false, + sort: 0, cover: '' }) coverFileName.value = '' coverError.value = '' + editingCommentCount.value = 0 + editingLikeCount.value = 0 dialogVisible.value = true resetValidation() } @@ -159,19 +184,55 @@ function openEdit(row) { editingId.value = row.id Object.assign(form, { title: row.title, - categoryId: row.categoryId || undefined, + categoryIds: + Array.isArray(row.categoryIds) && row.categoryIds.length + ? row.categoryIds + : row.categoryId + ? [row.categoryId] + : [], + dramaTypes: + Array.isArray(row.dramaTypes) && row.dramaTypes.length + ? row.dramaTypes + : row.dramaType + ? [row.dramaType] + : ['LIVE'], description: row.description || '', freeEpisodes: row.freeEpisodes ?? 3, unlockCoin: row.unlockCoin ?? 0, + favoriteCount: row.favoriteCount ?? 0, + heat: row.views ?? 0, isHot: !!row.isHot, + sort: row.sort ?? 0, cover: row.cover || '' }) coverFileName.value = row.cover ? coverBasename(row.cover) : '' coverError.value = '' + editingCommentCount.value = row.commentCount ?? 0 + editingLikeCount.value = row.likeCount ?? 0 dialogVisible.value = true resetValidation() } +function dramaTypeLabel(value) { + return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧' +} + +function rowCategoryList(row) { + if (Array.isArray(row.categoryNames) && row.categoryNames.length) { + return row.categoryNames.filter(Boolean) + } + if (!row.genre) return [] + return row.genre + .split('/') + .map((name) => name.trim()) + .filter(Boolean) +} + +function rowDramaTypes(row) { + if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes + return row.dramaType ? [row.dramaType] : [] +} + async function saveDrama() { if (saving.value || uploadingCover.value) return try { @@ -179,11 +240,13 @@ async function saveDrama() { } catch { return } - const category = categories.value.find((item) => item.id === form.categoryId) + const categoryNames = form.categoryIds + .map((id) => categories.value.find((item) => item.id === id)?.name) + .filter(Boolean) const payload = { ...form, title: form.title.trim(), - genre: category?.name || '' + genre: categoryNames.join(' / ') } saving.value = true try { @@ -338,12 +401,30 @@ onMounted(async () => { :key="item.code" :label="item.text" :value="item.code" /> { >— - + + + - + + + + + diff --git a/src/views/contentManagement/DramaPlaylistView.vue b/src/views/contentManagement/DramaPlaylistView.vue new file mode 100644 index 0000000..5115a74 --- /dev/null +++ b/src/views/contentManagement/DramaPlaylistView.vue @@ -0,0 +1,619 @@ + + + + + + + + diff --git a/src/views/contentManagement/EpisodeManagementView.vue b/src/views/contentManagement/EpisodeManagementView.vue index 6e1b4a9..ef50955 100644 --- a/src/views/contentManagement/EpisodeManagementView.vue +++ b/src/views/contentManagement/EpisodeManagementView.vue @@ -1,92 +1,56 @@ @@ -310,13 +268,17 @@ onMounted(async () => { >
内容管理

剧集管理

为短剧维护剧集信息、视频地址与播放顺序。

新增剧集
+ {{ t('episodeBatch.entry') }}新增剧集
@@ -325,7 +287,9 @@ onMounted(async () => { 当前短剧 {{ selectedDrama?.title || '暂无短剧' }} 共 {{ selectedDrama?.episodes ?? episodes.length }} 集共 {{ selectedDrama?.episodes ?? total }} 集 · 收藏 + {{ formatCount(selectedDrama?.favoriteCount ?? 0) }} · 热度 + {{ formatCount(selectedDrama?.views ?? 0) }}
@@ -343,23 +307,40 @@ onMounted(async () => { :value="item.id" /> + 状态 + + + + +

剧集列表({{ episodes.length }})

按列表顺序播放

剧集列表({{ total }})

按列表顺序播放

+ { + + + +