feat(admin): 新增转码设置页面

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:29:48 +08:00
1 parent 723d056fc7
commit 218196ee9f
16 files changed
+1014 -3

No files matched your search

@@ -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`
+53
View File
@@ -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 分包警告,本次未改构建配置。
+18 -1
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { MediaAsset } from './types'
import type { MediaAsset, MediaTranscodeProfile } from './types'
export interface UploadInitData {
fileName: string
@@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => {
export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
}
/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */
export const getMediaTranscodeProfileApi = () => {
return request.get<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
silent: true
})
}
/** 保存转码档位配置,并返回服务端最终生效值。 */
export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => {
return request.put<MediaTranscodeProfile>({
url: '/admin/media/transcode-profile',
data,
silent: true
})
}
+26
View File
@@ -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
+47 -1
View File
@@ -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'
}
}
}
+43 -1
View File
@@ -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: '请修正标记的转码配置后再保存'
}
}
}
+5
View File
@@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
import { getAdminRoleApi, getProfileApi } from '@/api/login'
import { landingPath } from '@/utils/landing'
import { supplementLocalAdminRoutes } from '@/router/localRoutes'
const { start, done } = useNProgress()
@@ -68,6 +69,10 @@ router.beforeEach(async (to, from, next) => {
} catch {
// 拉取失败时退回上次缓存的菜单,不阻塞进入
}
const permissions = Array.isArray(userStore.getUserInfo?.permissions)
? userStore.getUserInfo.permissions
: []
routers = supplementLocalAdminRoutes(routers, permissions)
await permissionStore.generateRoutes('server', routers)
} else {
await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
+9
View File
@@ -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']
}
}
]
},
+59
View File
@@ -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
}
@@ -0,0 +1,350 @@
<!-- Author: fruit.com.cn -->
<!-- 后台转码档位与缩略图雪碧图配置 -->
<script setup lang="ts">
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import PageHeading from '@/views/components/PageHeading.vue'
import { useTranscodeSettings } from './composables/useTranscodeSettings'
const { t } = useI18n()
const {
form,
formRef,
rules,
loading,
saving,
loadError,
canManage,
qualityRules,
positiveEvenRule,
positiveIntegerRule,
enabledRules,
loadProfile,
addRung,
deleteRung,
saveProfile
} = useTranscodeSettings()
</script>
<template>
<PageHeading
:eyebrow="t('transcodeSettings.eyebrow')"
:title="t('transcodeSettings.title')"
:description="t('transcodeSettings.description')"
>
<el-button :disabled="loading" @click="loadProfile">
<AppIcon :icon="AppIcons.refresh" />
{{ t('transcodeSettings.reload') }}
</el-button>
<el-button
type="primary"
class="primary-button"
:loading="saving"
:disabled="!canManage || loading"
@click="saveProfile"
>
<AppIcon :icon="AppIcons.check" />
{{ t('transcodeSettings.save') }}
</el-button>
</PageHeading>
<el-alert
v-if="loadError"
class="transcode-alert"
:title="loadError"
type="error"
show-icon
:closable="false"
/>
<el-alert
v-if="!canManage"
class="transcode-alert"
:title="t('transcodeSettings.noPermission')"
type="warning"
show-icon
:closable="false"
/>
<el-form
ref="formRef"
v-loading="loading"
:model="form"
:rules="rules"
label-position="top"
class="transcode-form"
>
<section class="panel transcode-panel">
<div class="transcode-section-heading">
<div>
<h2>{{ t('transcodeSettings.enabled') }}</h2>
<p>{{ t('transcodeSettings.enabledDescription') }}</p>
</div>
<el-switch v-model="form.enabled" size="large" />
</div>
<div class="transcode-grid transcode-grid--compact">
<el-form-item :label="t('transcodeSettings.segmentSeconds')" prop="segmentSeconds">
<el-input-number
v-model="form.segmentSeconds"
controls-position="right"
:min="2"
:max="10"
:precision="0"
/>
</el-form-item>
</div>
</section>
<section class="panel transcode-panel">
<div class="transcode-section-heading">
<div>
<h2>{{ t('transcodeSettings.ladderTitle') }}</h2>
<p>{{ t('transcodeSettings.ladderDescription') }}</p>
</div>
<el-button type="primary" plain @click="addRung">
<AppIcon :icon="AppIcons.add" />
{{ t('transcodeSettings.addRung') }}
</el-button>
</div>
<el-table
:data="form.ladder"
border
class="transcode-table"
:empty-text="t('transcodeSettings.emptyLadder')"
>
<el-table-column type="index" width="60" label="#" />
<el-table-column :label="t('transcodeSettings.quality')" min-width="150">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.quality`"
:rules="qualityRules($index)"
class="table-form-item"
>
<el-input v-model="row.quality" maxlength="16" />
</el-form-item>
</template>
</el-table-column>
<el-table-column :label="t('transcodeSettings.width')" min-width="120">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.width`"
:rules="positiveEvenRule()"
class="table-form-item"
>
<el-input-number
v-model="row.width"
controls-position="right"
:min="2"
:precision="0"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column :label="t('transcodeSettings.height')" min-width="120">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.height`"
:rules="positiveEvenRule()"
class="table-form-item"
>
<el-input-number
v-model="row.height"
controls-position="right"
:min="2"
:precision="0"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column :label="t('transcodeSettings.videoBitrateKbps')" min-width="150">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.videoBitrateKbps`"
:rules="positiveIntegerRule()"
class="table-form-item"
>
<el-input-number
v-model="row.videoBitrateKbps"
controls-position="right"
:min="1"
:precision="0"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column :label="t('transcodeSettings.audioBitrateKbps')" min-width="150">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.audioBitrateKbps`"
:rules="positiveIntegerRule()"
class="table-form-item"
>
<el-input-number
v-model="row.audioBitrateKbps"
controls-position="right"
:min="1"
:precision="0"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column :label="t('transcodeSettings.rowEnabled')" width="100" align="center">
<template #default="{ row, $index }">
<el-form-item
:prop="`ladder.${$index}.enabled`"
:rules="enabledRules"
class="table-form-item table-form-item--center"
>
<el-switch v-model="row.enabled" />
</el-form-item>
</template>
</el-table-column>
<el-table-column label="" width="72" align="center" fixed="right">
<template #default="{ $index }">
<el-tooltip :content="t('transcodeSettings.deleteRung')" placement="top">
<el-button circle type="danger" plain @click="deleteRung($index)">
<AppIcon :icon="AppIcons.delete" />
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</section>
<section class="panel transcode-panel">
<div class="transcode-section-heading">
<div>
<h2>{{ t('transcodeSettings.spriteTitle') }}</h2>
<p>{{ t('transcodeSettings.spriteDescription') }}</p>
</div>
<el-switch v-model="form.sprite.enabled" size="large" />
</div>
<div class="transcode-grid">
<el-form-item :label="t('transcodeSettings.intervalSeconds')" prop="sprite.intervalSeconds">
<el-input-number
v-model="form.sprite.intervalSeconds"
controls-position="right"
:min="1"
:max="10"
:precision="0"
/>
</el-form-item>
<el-form-item :label="t('transcodeSettings.columns')" prop="sprite.columns">
<el-input-number
v-model="form.sprite.columns"
controls-position="right"
:min="5"
:max="20"
:precision="0"
/>
</el-form-item>
<el-form-item :label="t('transcodeSettings.thumbWidth')" prop="sprite.thumbWidth">
<el-input-number
v-model="form.sprite.thumbWidth"
controls-position="right"
:min="120"
:max="240"
:precision="0"
/>
</el-form-item>
</div>
<div class="transcode-footer">
<el-button
type="primary"
class="primary-button"
:loading="saving"
:disabled="!canManage || loading"
@click="saveProfile"
>
<AppIcon :icon="AppIcons.check" />
{{ t('transcodeSettings.save') }}
</el-button>
</div>
</section>
</el-form>
</template>
<style scoped lang="less">
.transcode-alert {
margin-bottom: 14px;
}
.transcode-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.transcode-panel {
overflow: hidden;
}
.transcode-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
}
p {
margin: 6px 0 0;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
}
.transcode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 16px;
}
.transcode-grid--compact {
grid-template-columns: minmax(180px, 320px);
}
.transcode-table {
width: 100%;
}
.table-form-item {
margin-bottom: 0;
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-form-item__error) {
position: static;
margin-top: 4px;
}
}
.table-form-item--center {
display: flex;
justify-content: center;
}
.transcode-footer {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 900px) {
.transcode-grid {
grid-template-columns: 1fr;
}
.transcode-section-heading {
flex-direction: column;
}
}
</style>
@@ -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()
})
@@ -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)
}
})
@@ -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, unknown>) => 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'))
)
}
}
]
@@ -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, unknown>) => string
interface SettingsContext {
form: MediaTranscodeProfile
formRef: Ref<FormInstance | undefined>
loading: Ref<boolean>
saving: Ref<boolean>
loadError: Ref<string>
canManage: ComputedRef<boolean>
t: Translate
}
/** 读取服务端配置并回填表单。 */
export const loadTranscodeProfile = async (context: SettingsContext): Promise<void> => {
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<boolean> => {
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<string, unknown>)[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<void> => {
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
}
}
@@ -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<FormInstance>()
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
const form = reactive<MediaTranscodeProfile>(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
}
}
+1
View File
@@ -48,6 +48,7 @@ declare global {
declare interface IResponse<T = any> {
code: number
message?: string
data: T extends any ? T : T & any
}