20 changed files with 1150 additions and 41 deletions

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
+53 -4
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'
@@ -532,16 +533,19 @@ export default {
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',
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?',
@@ -551,7 +555,7 @@ export default {
resumeYes: 'Pick files to resume',
resumeNo: 'Discard',
resumeAbandoned: 'Unfinished uploads discarded',
resumeMatched: 'Matched {count} file(s) and resumed',
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:
@@ -571,12 +575,57 @@ export default {
submitCreate: 'Create',
retryFailed: 'Retry failed',
status: {
waiting: 'Waiting',
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'
}
}
}
+49 -4
View File
@@ -163,7 +163,8 @@ export default {
treeTable: '树形表格',
PicturePreview: '表格图片预览',
department: '部门管理',
menuManagement: '菜单管理'
menuManagement: '菜单管理',
transcodeSettings: '转码设置'
},
permission: {
hasPermission: '请设置操作权限值'
@@ -526,16 +527,19 @@ export default {
title: '批量上传剧集',
entry: '批量上传',
selectFiles: '选择视频',
startUpload: '开始上传',
uploadingAction: '上传中',
pause: '暂停',
resume: '继续',
retry: '重试',
retryAll: '全部重试',
remove: '移除',
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB',
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;选完文件后点击「开始上传」才会真正上传',
invalidType: '仅支持 MP4、MOV、MKV、WEBM',
oversize: '文件超过 500MB,未入队',
overallProgress: '总进度',
empty: '尚未选择视频',
resultSummary: '上传结束:成功 {done} 个,失败 {failed} 个',
createEpisodes: '批量创建分集',
closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?',
resumeTitle: '发现未完成上传',
@@ -543,7 +547,7 @@ export default {
resumeYes: '选择文件续传',
resumeNo: '放弃',
resumeAbandoned: '已放弃未完成上传',
resumeMatched: '已匹配 {count} 个文件并开始续传',
resumeMatched: '已匹配 {count} 个文件,请点击「开始上传」继续',
resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选',
createTitle: '批量创建分集',
createHint:
@@ -563,12 +567,53 @@ export default {
submitCreate: '创建',
retryFailed: '重试失败项',
status: {
waiting: '等待中',
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: '请修正标记的转码配置后再保存'
}
}
}
+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
}
@@ -50,11 +50,6 @@ const existingEpisodeNumbers = computed(() =>
snapshotEpisodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
)
const showPagination = computed(() => {
if (total.value <= 0) return false
return Math.ceil(total.value / pageSize.value) > 1
})
const showEmpty = computed(() => !loading.value && episodes.value.length === 0)
async function loadDramas() {
@@ -441,7 +436,7 @@ onMounted(async () => {
></el-table-column
>
</el-table>
<div v-if="showPagination" class="pagination"
<div class="pagination"
><el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
@@ -1,5 +1,5 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集批量上传弹窗:多选、队列、总进度、续传与批量建集入口 -->
<!-- 剧集批量上传弹窗:先入队待上传,点「开始上传」后再执行 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
@@ -60,6 +60,10 @@ watch(
const canCreateEpisodes = computed(() => queue.doneItems.value.length > 0)
const startButtonLabel = computed(() =>
queue.isUploading.value ? t('episodeBatch.uploadingAction') : t('episodeBatch.startUpload')
)
function extensionOf(fileName: string): string {
const parts = String(fileName || '').split('.')
return (parts.pop() || '').toLowerCase()
@@ -92,14 +96,19 @@ function onPickFiles(event: Event): void {
}
rejectedNames.value = rejected
if (!accepted.length) return
// 只入队,不发起上传;等用户点「开始上传」
queue.enqueue(accepted)
queue.start()
}
function openFilePicker(): void {
fileInputRef.value?.click()
}
function onStartUpload(): void {
if (!queue.canStart.value) return
queue.start()
}
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!queue.hasActiveUpload.value) return
event.preventDefault()
@@ -158,7 +167,6 @@ async function onResumeFiles(event: Event): Promise<void> {
}
if (matched.length) {
ElMessage.success(t('episodeBatch.resumeMatched', { count: matched.length }))
queue.start()
}
}
@@ -229,10 +237,13 @@ function onEpisodesCreated(): void {
<AppIcon :icon="AppIcons.upload" />
{{ t('episodeBatch.selectFiles') }}
</el-button>
<el-button :disabled="!queue.items.value.length" @click="queue.pause()">
<el-button
:disabled="!queue.started.value || !queue.items.value.length"
@click="queue.pause()"
>
{{ t('episodeBatch.pause') }}
</el-button>
<el-button :disabled="!queue.items.value.length" @click="queue.start()">
<el-button :disabled="!queue.canStart.value" @click="queue.start()">
{{ t('episodeBatch.resume') }}
</el-button>
<el-button
@@ -254,11 +265,22 @@ function onEpisodesCreated(): void {
<el-progress :percentage="queue.overallPercent.value" :stroke-width="12" />
</div>
<p v-if="queue.showResultSummary.value" class="batch-upload-summary">
{{
t('episodeBatch.resultSummary', {
done: queue.doneItems.value.length,
failed: queue.failedCount.value
})
}}
</p>
<div class="batch-upload-list">
<UploadTaskRow
v-for="item in queue.items.value"
v-for="(item, index) in queue.items.value"
:key="item.id"
:item="item"
:index="index + 1"
:allow-remove="!queue.started.value && item.status === 'waiting'"
@retry="queue.retry"
@remove="queue.remove"
/>
@@ -267,7 +289,15 @@ function onEpisodesCreated(): void {
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :disabled="!canCreateEpisodes" @click="openCreateDialog">
<el-button
type="primary"
:disabled="!queue.canStart.value"
:loading="queue.isUploading.value"
@click="onStartUpload"
>
{{ startButtonLabel }}
</el-button>
<el-button type="success" :disabled="!canCreateEpisodes" @click="openCreateDialog">
{{ t('episodeBatch.createEpisodes') }}
</el-button>
</template>
@@ -317,6 +347,15 @@ function onEpisodesCreated(): void {
margin-bottom: 12px;
}
.batch-upload-summary {
margin: 0 0 12px;
padding: 8px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
color: var(--el-text-color-primary);
font-size: 13px;
}
.batch-upload-list {
max-height: 360px;
overflow: auto;
@@ -1,5 +1,5 @@
<!-- Author: fruit.com.cn -->
<!-- 批量上传队列单行:文件名、大小、进度、状态与操作 -->
<!-- 批量上传队列单行:序号、文件名、大小、进度、状态与操作 -->
<script setup lang="ts">
import { computed } from 'vue'
@@ -8,6 +8,10 @@ import type { UploadQueueItem } from './useUploadQueue'
const props = defineProps<{
item: UploadQueueItem
/** 自然排序后的展示序号(从 1 起) */
index: number
/** 选择阶段才允许移除 */
allowRemove: boolean
}>()
const emit = defineEmits<{
@@ -47,14 +51,12 @@ const statusType = computed(() => {
})
const canRetry = computed(() => props.item.status === 'failed')
const canRemove = computed(
() => props.item.status !== 'uploading' && props.item.status !== 'merging'
)
</script>
<template>
<div class="upload-task-row" :class="{ 'is-failed': item.status === 'failed' }">
<div class="upload-task-row__meta">
<span class="upload-task-row__index">{{ index }}</span>
<b class="upload-task-row__name" :title="item.fileName">{{ item.fileName }}</b>
<small>{{ sizeLabel }}</small>
<el-tag :type="statusType" effect="light" size="small">{{ statusLabel }}</el-tag>
@@ -71,7 +73,13 @@ const canRemove = computed(
<el-button v-if="canRetry" size="small" type="primary" @click="emit('retry', item.id)">
{{ t('episodeBatch.retry') }}
</el-button>
<el-button v-if="canRemove" size="small" type="danger" text @click="emit('remove', item.id)">
<el-button
v-if="allowRemove"
size="small"
type="danger"
text
@click="emit('remove', item.id)"
>
{{ t('episodeBatch.remove') }}
</el-button>
</div>
@@ -91,6 +99,14 @@ const canRemove = computed(
margin-bottom: 8px;
}
.upload-task-row__index {
flex: 0 0 auto;
min-width: 1.5em;
color: var(--el-text-color-secondary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.upload-task-row__name {
flex: 1;
overflow: hidden;
@@ -1,5 +1,5 @@
// Author: fruit.com.cn
/** 剧集批量上传队列:文件并发、进度加权、断点会话、失败隔离 */
/** 剧集批量上传队列:手动开始、文件并发、进度加权、断点会话、失败隔离 */
import { computed, ref } from 'vue'
import { getUploadProgressApi } from '@/api/admin/media'
@@ -58,6 +58,11 @@ function isHttpNotFound(error: unknown): boolean {
return status === 404
}
/** 文件名自然排序(数字感知),保证队列序号稳定。 */
function compareFileName(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
}
/**
* 创建上传队列控制器。
* @param options dramaId 与可选 bizType
@@ -65,6 +70,8 @@ function isHttpNotFound(error: unknown): boolean {
export function useUploadQueue(options: UseUploadQueueOptions) {
const items = ref<UploadQueueItem[]>([])
const paused = ref(false)
/** 用户是否已点过「开始上传」;选文件阶段不派发、不占用并发。 */
const started = ref(false)
const bizType = options.bizType || DEFAULT_BIZ_TYPE
let running = 0
@@ -77,18 +84,36 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes))
})
const hasActiveUpload = computed(() =>
items.value.some(
const isUploading = computed(() =>
items.value.some((item) => item.status === 'uploading' || item.status === 'merging')
)
/** 关闭弹窗需二次确认:仅在已开始且仍有未完成任务时。 */
const hasActiveUpload = computed(() => {
if (!started.value) return false
return items.value.some(
(item) =>
item.status === 'waiting' ||
item.status === 'uploading' ||
item.status === 'merging' ||
item.status === 'paused'
)
)
})
const canStart = computed(() => {
if (isUploading.value) return false
return items.value.some((item) => item.status === 'waiting' || item.status === 'paused')
})
const doneItems = computed(() => items.value.filter((item) => item.status === 'done'))
const failedCount = computed(() => items.value.filter((item) => item.status === 'failed').length)
/** 已开始且队列已无进行中任务时,可展示结果汇总。 */
const showResultSummary = computed(
() => started.value && items.value.length > 0 && !hasActiveUpload.value
)
function persistSession(item: UploadQueueItem, uploadId: string): void {
item.uploadId = uploadId
upsertUploadSession({
@@ -106,6 +131,10 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
if (uploadId) removeUploadSession(uploadId)
}
function sortItemsByName(): void {
items.value.sort((a, b) => compareFileName(a.fileName, b.fileName))
}
async function runOne(item: UploadQueueItem, resumeId?: string): Promise<void> {
item.status = 'uploading'
item.error = ''
@@ -140,7 +169,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
}
function pump(): void {
if (paused.value) return
if (!started.value || paused.value) return
while (running < FILE_CONCURRENCY && !paused.value) {
const next = items.value.find((item) => item.status === 'waiting')
if (!next) return
@@ -155,9 +184,10 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
}
}
/** 入队本地文件(调用方已完成类型/大小校验)。 */
/** 入队本地文件(调用方已完成类型/大小校验);只展示待上传,不发起请求。 */
function enqueue(files: File[]): void {
for (const file of files) {
const sorted = [...files].sort((a, b) => compareFileName(a.name, b.name))
for (const file of sorted) {
items.value.push({
id: nextItemId(),
file,
@@ -165,15 +195,18 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
fileSize: file.size,
lastModified: file.lastModified,
percent: 0,
status: paused.value ? 'paused' : 'waiting',
status: 'waiting',
error: '',
uploadId: ''
})
}
sortItemsByName()
}
/** 开始或继续派发。 */
/** 开始或继续派发(含断点续传项上的 uploadId)。 */
function start(): void {
if (!items.value.length) return
started.value = true
paused.value = false
for (const item of items.value) {
if (item.status === 'paused') item.status = 'waiting'
@@ -183,6 +216,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
/** 暂停:不派新任务;进行中分片跑完。 */
function pause(): void {
if (!started.value) return
paused.value = true
for (const item of items.value) {
if (item.status === 'waiting') item.status = 'paused'
@@ -196,7 +230,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
item.error = ''
item.percent = item.uploadId ? item.percent : 0
item.status = paused.value ? 'paused' : 'waiting'
if (!paused.value) void pump()
if (started.value && !paused.value) void pump()
}
/** 重试全部失败项。 */
@@ -207,11 +241,12 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
item.status = paused.value ? 'paused' : 'waiting'
}
}
if (!paused.value) void pump()
if (started.value && !paused.value) void pump()
}
/** 移除单项并清理会话。 */
/** 仅选择阶段可移除;已开始上传后禁止直接移除,避免脏会话。 */
function remove(id: string): void {
if (started.value) return
const index = items.value.findIndex((row) => row.id === id)
if (index < 0) return
const [removed] = items.value.splice(index, 1)
@@ -225,6 +260,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
}
items.value = []
paused.value = false
started.value = false
running = 0
}
@@ -234,7 +270,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
}
/**
* 用本地重选文件匹配会话并入队续传。
* 用本地重选文件匹配会话并入队(待上传);不自动开始,由用户点「开始上传」续传。
* @returns matched / unmatched 会话列表
*/
async function resumeFromSessions(
@@ -267,7 +303,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
fileSize: file.size,
lastModified: file.lastModified,
percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0),
status: paused.value ? 'paused' : 'waiting',
status: 'waiting',
error: '',
uploadId: session.uploadId
})
@@ -280,6 +316,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
}
}
}
sortItemsByName()
return { matched, unmatched }
}
@@ -294,7 +331,12 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
items,
overallPercent,
hasActiveUpload,
isUploading,
canStart,
doneItems,
failedCount,
showResultSummary,
started,
paused,
enqueue,
start,
@@ -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;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
}
.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 (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
}