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/PROGRESS.md b/docs/plans/PROGRESS.md new file mode 100644 index 0000000..fc4aa24 --- /dev/null +++ b/docs/plans/PROGRESS.md @@ -0,0 +1,53 @@ +## 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 分包警告,本次未改构建配置。 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/types.ts b/src/api/admin/types.ts index 4040874..d58e598 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -222,6 +222,32 @@ 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 BannerItem { id: number imageUrl: string diff --git a/src/locales/en.ts b/src/locales/en.ts index f0222af..5d3fcf7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -163,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' @@ -581,5 +582,50 @@ export default { 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 d0b21b1..796b199 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -163,7 +163,8 @@ export default { treeTable: '树形表格', PicturePreview: '表格图片预览', department: '部门管理', - menuManagement: '菜单管理' + menuManagement: '菜单管理', + transcodeSettings: '转码设置' }, permission: { hasPermission: '请设置操作权限值' @@ -573,5 +574,46 @@ export default { 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..2dbd084 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'] + } } ] }, diff --git a/src/router/localRoutes.ts b/src/router/localRoutes.ts new file mode 100644 index 0000000..0d5762f --- /dev/null +++ b/src/router/localRoutes.ts @@ -0,0 +1,59 @@ +// 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 findRouteByName = ( + routes: AppCustomRouteRecordRaw[], + name: string +): AppCustomRouteRecordRaw | undefined => { + for (const route of routes) { + if (route.name === name) return route + const child = findRouteByName(route.children || [], name) + if (child) return child + } + return undefined +} + +/** + * 服务端动态菜单未落库时,给具备内容上传权限的账号补充受控的本地转码菜单。 + */ +export const supplementLocalAdminRoutes = ( + routes: AppCustomRouteRecordRaw[], + permissions: string[] +): AppCustomRouteRecordRaw[] => { + if (!permissions.includes(TRANSCODE_PERMISSION)) { + return routes + } + const nextRoutes = cloneDeep(routes) + const existing = findRouteByName(nextRoutes, TRANSCODE_ROUTE_NAME) + if (existing) { + existing.meta = { ...existing.meta, title: TRANSCODE_TITLE_KEY } + return nextRoutes + } + 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/views/systemManagement/TranscodeSettingsView.vue b/src/views/systemManagement/TranscodeSettingsView.vue new file mode 100644 index 0000000..a3fb062 --- /dev/null +++ b/src/views/systemManagement/TranscodeSettingsView.vue @@ -0,0 +1,350 @@ + + + + + + + + diff --git a/src/views/systemManagement/composables/transcodeProfileDefaults.ts b/src/views/systemManagement/composables/transcodeProfileDefaults.ts new file mode 100644 index 0000000..146cfbe --- /dev/null +++ b/src/views/systemManagement/composables/transcodeProfileDefaults.ts @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import type { + MediaTranscodeLadderRung, + MediaTranscodeProfile, + MediaTranscodeSprite +} from '@/api/admin/types' + +export const MIN_SEGMENT_SECONDS = 2 +export const MAX_SEGMENT_SECONDS = 10 +export const MIN_SPRITE_INTERVAL = 1 +export const MAX_SPRITE_INTERVAL = 10 +export const MIN_SPRITE_COLUMNS = 5 +export const MAX_SPRITE_COLUMNS = 20 +export const MIN_THUMB_WIDTH = 120 +export const MAX_THUMB_WIDTH = 240 +export const MAX_QUALITY_LENGTH = 16 + +export const defaultSprite = (): MediaTranscodeSprite => ({ + enabled: true, + intervalSeconds: 1, + columns: 10, + thumbWidth: 160 +}) + +export const defaultRung = (): MediaTranscodeLadderRung => ({ + quality: '', + width: 640, + height: 360, + videoBitrateKbps: 400, + audioBitrateKbps: 64, + enabled: true +}) + +/** 与后端缺省值一致的四档配置。 */ +export const defaultProfile = (): MediaTranscodeProfile => ({ + enabled: true, + segmentSeconds: 6, + ladder: [ + { + quality: '360p', + width: 640, + height: 360, + videoBitrateKbps: 400, + audioBitrateKbps: 64, + enabled: true + }, + { + quality: '480p', + width: 854, + height: 480, + videoBitrateKbps: 800, + audioBitrateKbps: 64, + enabled: true + }, + { + quality: '720p', + width: 1280, + height: 720, + videoBitrateKbps: 1600, + audioBitrateKbps: 96, + enabled: true + }, + { + quality: '1080p', + width: 1920, + height: 1080, + videoBitrateKbps: 3200, + audioBitrateKbps: 128, + enabled: true + } + ], + sprite: defaultSprite() +}) diff --git a/src/views/systemManagement/composables/transcodeProfileModel.ts b/src/views/systemManagement/composables/transcodeProfileModel.ts new file mode 100644 index 0000000..7753e7b --- /dev/null +++ b/src/views/systemManagement/composables/transcodeProfileModel.ts @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import type { MediaTranscodeProfile } from '@/api/admin/types' +import { defaultProfile, defaultRung, defaultSprite } from './transcodeProfileDefaults' + +/** 用服务端响应回填表单,同时保持字段名与后端完全一致。 */ +export const applyProfileToForm = ( + form: MediaTranscodeProfile, + profile?: MediaTranscodeProfile | null +): void => { + const source = profile || defaultProfile() + form.enabled = Boolean(source.enabled) + form.segmentSeconds = Number(source.segmentSeconds) + form.sprite = { ...defaultSprite(), ...(source.sprite || {}) } + const ladder = + Array.isArray(source.ladder) && source.ladder.length ? source.ladder : [defaultRung()] + form.ladder.splice( + 0, + form.ladder.length, + ...ladder.map((row) => ({ + quality: row.quality || '', + width: Number(row.width), + height: Number(row.height), + videoBitrateKbps: Number(row.videoBitrateKbps), + audioBitrateKbps: Number(row.audioBitrateKbps), + enabled: Boolean(row.enabled) + })) + ) +} + +/** 生成只包含后端约定字段的 PUT 请求体。 */ +export const buildProfilePayload = (form: MediaTranscodeProfile): MediaTranscodeProfile => ({ + enabled: form.enabled, + segmentSeconds: Number(form.segmentSeconds), + ladder: form.ladder.map((row) => ({ + quality: row.quality.trim(), + width: Number(row.width), + height: Number(row.height), + videoBitrateKbps: Number(row.videoBitrateKbps), + audioBitrateKbps: Number(row.audioBitrateKbps), + enabled: Boolean(row.enabled) + })), + sprite: { + enabled: Boolean(form.sprite.enabled), + intervalSeconds: Number(form.sprite.intervalSeconds), + columns: Number(form.sprite.columns), + thumbWidth: Number(form.sprite.thumbWidth) + } +}) diff --git a/src/views/systemManagement/composables/transcodeProfileRules.ts b/src/views/systemManagement/composables/transcodeProfileRules.ts new file mode 100644 index 0000000..7640ac2 --- /dev/null +++ b/src/views/systemManagement/composables/transcodeProfileRules.ts @@ -0,0 +1,98 @@ +// Author: fruit.com.cn + +import type { FormItemRule, FormRules } from 'element-plus' +import type { MediaTranscodeProfile } from '@/api/admin/types' +import { + MAX_QUALITY_LENGTH, + MAX_SEGMENT_SECONDS, + MAX_SPRITE_COLUMNS, + MAX_SPRITE_INTERVAL, + MAX_THUMB_WIDTH, + MIN_SPRITE_COLUMNS, + MIN_SEGMENT_SECONDS, + MIN_SPRITE_INTERVAL, + MIN_THUMB_WIDTH +} from './transcodeProfileDefaults' + +type Translate = (key: string, named?: Record) => string + +const rangeRule = (min: number, max: number, t: Translate): FormItemRule => ({ + trigger: 'change', + validator: (_rule, value, callback) => { + const parsed = Number(value) + callback( + Number.isInteger(parsed) && parsed >= min && parsed <= max + ? undefined + : new Error(t('transcodeSettings.validation.range', { min, max })) + ) + } +}) + +export const positiveIntegerRule = (t: Translate): FormItemRule => ({ + trigger: 'change', + validator: (_rule, value, callback) => { + const parsed = Number(value) + callback( + Number.isInteger(parsed) && parsed > 0 + ? undefined + : new Error(t('transcodeSettings.validation.positiveInteger')) + ) + } +}) + +export const positiveEvenRule = (t: Translate): FormItemRule => ({ + trigger: 'change', + validator: (_rule, value, callback) => { + const parsed = Number(value) + callback( + Number.isInteger(parsed) && parsed > 0 && parsed % 2 === 0 + ? undefined + : new Error(t('transcodeSettings.validation.positiveEven')) + ) + } +}) + +export const createProfileRules = (t: Translate): FormRules => ({ + segmentSeconds: [rangeRule(MIN_SEGMENT_SECONDS, MAX_SEGMENT_SECONDS, t)], + 'sprite.intervalSeconds': [rangeRule(MIN_SPRITE_INTERVAL, MAX_SPRITE_INTERVAL, t)], + 'sprite.columns': [rangeRule(MIN_SPRITE_COLUMNS, MAX_SPRITE_COLUMNS, t)], + 'sprite.thumbWidth': [rangeRule(MIN_THUMB_WIDTH, MAX_THUMB_WIDTH, t)] +}) + +export const createQualityRules = ( + form: MediaTranscodeProfile, + index: number, + t: Translate +): FormItemRule[] => [ + { + trigger: 'blur', + validator: (_rule, value, callback) => { + const quality = String(value ?? '').trim() + if (!quality) { + callback(new Error(t('transcodeSettings.validation.required'))) + return + } + if (quality.length > MAX_QUALITY_LENGTH) { + callback(new Error(t('transcodeSettings.validation.qualityTooLong'))) + return + } + const duplicate = form.ladder.some( + (row, rowIndex) => rowIndex !== index && row.quality.trim() === quality + ) + callback(duplicate ? new Error(t('transcodeSettings.validation.qualityUnique')) : undefined) + } + } +] + +export const createEnabledRules = (form: MediaTranscodeProfile, t: Translate): FormItemRule[] => [ + { + trigger: 'change', + validator: (_rule, _value, callback) => { + callback( + form.ladder.some((row) => row.enabled) + ? undefined + : new Error(t('transcodeSettings.validation.atLeastOneEnabled')) + ) + } + } +] diff --git a/src/views/systemManagement/composables/transcodeSettingsActions.ts b/src/views/systemManagement/composables/transcodeSettingsActions.ts new file mode 100644 index 0000000..126184a --- /dev/null +++ b/src/views/systemManagement/composables/transcodeSettingsActions.ts @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +import { nextTick } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { FormInstance } from 'element-plus' +import { getMediaTranscodeProfileApi, saveMediaTranscodeProfileApi } from '@/api/admin/media' +import type { MediaTranscodeProfile } from '@/api/admin/types' +import { applyProfileToForm, buildProfilePayload } from './transcodeProfileModel' + +type Translate = (key: string, named?: Record) => string + +interface SettingsContext { + form: MediaTranscodeProfile + formRef: Ref + loading: Ref + saving: Ref + loadError: Ref + canManage: ComputedRef + t: Translate +} + +/** 读取服务端配置并回填表单。 */ +export const loadTranscodeProfile = async (context: SettingsContext): Promise => { + context.loading.value = true + context.loadError.value = '' + try { + const response = await getMediaTranscodeProfileApi() + if (response.code !== 0) { + context.loadError.value = response.message || context.t('transcodeSettings.loadFailed') + return + } + applyProfileToForm(context.form, response.data) + } catch { + context.loadError.value = context.t('transcodeSettings.loadFailed') + } finally { + context.loading.value = false + } +} + +const validateProfile = async (context: SettingsContext): Promise => { + if (!context.form.ladder.length) { + ElMessage.warning(context.t('transcodeSettings.validation.atLeastOneEnabled')) + return false + } + try { + await context.formRef.value?.validate() + return true + } catch (fields) { + const firstField = Object.keys((fields || {}) as Record)[0] + if (firstField) { + await nextTick() + context.formRef.value?.scrollToField(firstField) + } + ElMessage.warning(context.t('transcodeSettings.validation.fixErrors')) + return false + } +} + +/** 校验并保存,成功后用服务端返回值回填。 */ +export const saveTranscodeProfile = async (context: SettingsContext): Promise => { + if (!context.canManage.value || context.saving.value || !(await validateProfile(context))) { + return + } + context.saving.value = true + try { + const response = await saveMediaTranscodeProfileApi(buildProfilePayload(context.form)) + if (response.code !== 0) { + ElMessage.error(response.message || context.t('transcodeSettings.saveFailed')) + return + } + applyProfileToForm(context.form, response.data) + ElMessage.success(context.t('transcodeSettings.saved')) + } catch { + ElMessage.error(context.t('transcodeSettings.saveFailed')) + } finally { + context.saving.value = false + } +} diff --git a/src/views/systemManagement/composables/useTranscodeSettings.ts b/src/views/systemManagement/composables/useTranscodeSettings.ts new file mode 100644 index 0000000..31a94d8 --- /dev/null +++ b/src/views/systemManagement/composables/useTranscodeSettings.ts @@ -0,0 +1,64 @@ +// Author: fruit.com.cn + +import { computed, nextTick, onMounted, reactive, ref } from 'vue' +import type { FormInstance } from 'element-plus' +import type { MediaTranscodeProfile } from '@/api/admin/types' +import { useI18n } from '@/hooks/web/useI18n' +import { useRbac } from '@/stores/rbac' +import { defaultProfile, defaultRung } from './transcodeProfileDefaults' +import { + createEnabledRules, + createProfileRules, + createQualityRules, + positiveEvenRule, + positiveIntegerRule +} from './transcodeProfileRules' +import { loadTranscodeProfile, saveTranscodeProfile } from './transcodeSettingsActions' + +/** 转码设置页的响应式状态与动作装配。 */ +export function useTranscodeSettings() { + const { t } = useI18n() + const { can } = useRbac() + const formRef = ref() + const loading = ref(false) + const saving = ref(false) + const loadError = ref('') + const form = reactive(defaultProfile()) + const canManage = computed(() => can('episode:upload')) + const context = { form, formRef, loading, saving, loadError, canManage, t } + const rules = computed(() => createProfileRules(t)) + const enabledRules = computed(() => createEnabledRules(form, t)) + const qualityRules = (index: number) => createQualityRules(form, index, t) + + const addRung = (): void => { + form.ladder.push(defaultRung()) + nextTick(() => formRef.value?.clearValidate()) + } + + const deleteRung = (index: number): void => { + form.ladder.splice(index, 1) + nextTick(() => formRef.value?.clearValidate()) + } + + const loadProfile = () => loadTranscodeProfile(context) + const saveProfile = () => saveTranscodeProfile(context) + onMounted(loadProfile) + + return { + form, + formRef, + rules, + loading, + saving, + loadError, + canManage, + qualityRules, + positiveEvenRule: () => positiveEvenRule(t), + positiveIntegerRule: () => positiveIntegerRule(t), + enabledRules, + loadProfile, + addRung, + deleteRung, + saveProfile + } +} diff --git a/types/global.d.ts b/types/global.d.ts index 418b981..6d371a5 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -48,6 +48,7 @@ declare global { declare interface IResponse { code: number + message?: string data: T extends any ? T : T & any }