feat: add transcode workspace and media browser

This commit is contained in:
2026-05-05 02:14:52 +08:00
parent f0f9ed3456
commit 56432a9ece
26 changed files with 2013 additions and 267 deletions
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { TranscodeTaskItem } from "@/types";
import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types";
import { useViewport } from "@/composables/useViewport";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const transcodeStartingTaskId = ref<string | null>(null);
const loadError = ref("");
const items = ref<TranscodeTaskItem[]>([]);
const tableHeight = computed(() => (isMobile.value ? undefined : 560));
let refreshTimer: number | null = null;
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
async function loadItems() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<TranscodeTaskItem[]>("/transcode-tasks");
items.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
async function startManualTranscode(taskId: string) {
transcodeStartingTaskId.value = taskId;
try {
await apiClient.post(`/record-tasks/${taskId}/transcode`);
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
await loadItems();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
} finally {
transcodeStartingTaskId.value = null;
}
}
function openTask(taskId: string) {
router.push({ name: "record-task-detail", params: { id: taskId } });
}
function openSession(sessionId: string) {
router.push({ name: "record-session-detail", params: { id: sessionId } });
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatFileSize(bytes?: number) {
if (typeof bytes !== "number") {
return "-";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatProgress(item: TranscodeTaskItem) {
const percent = item.task.postProcessProgressPercent;
if (typeof percent === "number" && Number.isFinite(percent)) {
return `${percent.toFixed(0)}%`;
}
return item.task.postProcessDetail || "-";
}
function resolveStageLabel(item: TranscodeTaskItem) {
if (item.task.postProcessStage) {
return item.task.postProcessStage;
}
if (item.canManualTranscode) {
return "可手动转码";
}
return taskStatusLabelMap[item.task.status] ?? String(item.task.status);
}
function setupAutoRefresh() {
if (typeof window === "undefined") {
return;
}
refreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadItems();
}
}, 15000);
}
onMounted(async () => {
await loadItems();
setupAutoRefresh();
});
onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
}
});
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">后处理工作台</div>
<h1 class="page-title">转码任务</h1>
<p class="page-subtitle">
这里聚焦 MP4 收尾修复转码和保留中间 .ts 的任务方便从录制视角里拆出来单独处理
</p>
</div>
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回录制任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadItems">刷新列表</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">转码任务</div>
<div class="stat-card__value">{{ items.length }}</div>
<div class="stat-card__hint">当前可观察的后处理 / 转码任务总数</div>
</div>
<div class="stat-card">
<div class="stat-card__label">处理中</div>
<div class="stat-card__value">{{ activeCount }}</div>
<div class="stat-card__hint">有后处理阶段和进度的任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可手动转码</div>
<div class="stat-card__value">{{ manualCount }}</div>
<div class="stat-card__hint">保留中间 .ts 或可补转码的任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">MP4 输出</div>
<div class="stat-card__value">{{ mp4Count }}</div>
<div class="stat-card__hint">目标输出格式为 MP4 的任务</div>
</div>
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">转码列表</h3>
<p class="section-subtitle">保留录制上下文但把注意力集中在当前阶段输入输出和手动转码入口上</p>
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="当前没有需要关注的转码任务" />
<div v-else class="table-scroll-shell">
<el-table :data="items" :height="tableHeight" class="premium-table" table-layout="auto">
<el-table-column label="直播间 / 分片" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.task.liveRoomTitle }}</div>
<div class="cell-subtitle">会话 {{ row.task.recordSessionId.slice(0, 8) }} / 分片 #{{ row.task.segmentIndex }}</div>
</template>
</el-table-column>
<el-table-column label="画质" width="110">
<template #default="{ row }">
{{ formatQualityLabel(row.task.preferredQuality) }}
</template>
</el-table-column>
<el-table-column label="输出格式" width="110">
<template #default="{ row }">
{{ outputFormatLabelMap[row.task.outputFormat] }}
</template>
</el-table-column>
<el-table-column label="当前阶段" min-width="180">
<template #default="{ row }">
<div class="stage-cell">
<div class="stage-cell__title">{{ resolveStageLabel(row) }}</div>
<div class="stage-cell__detail">{{ formatProgress(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="输入 / 输出" min-width="320">
<template #default="{ row }">
<div class="path-cell">
<code>{{ row.sourceFilePath || "-" }}</code>
<code>{{ row.result?.filePath || row.task.outputFilePath || "-" }}</code>
<span class="path-cell__meta">
{{ formatFileSize(row.result?.fileSizeBytes) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近时间" width="170">
<template #default="{ row }">
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openTask(row.task.id)">查看分片</el-button>
<el-button size="small" @click="openSession(row.task.recordSessionId)">查看会话</el-button>
<el-button
v-if="row.canManualTranscode"
size="small"
type="primary"
plain
:loading="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>
手动转码
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.stage-cell,
.path-cell {
display: grid;
gap: 6px;
}
.stage-cell__title {
font-weight: 700;
color: var(--text-primary);
}
.stage-cell__detail,
.path-cell__meta {
color: var(--text-secondary);
font-size: 13px;
}
.path-cell code {
overflow-wrap: anywhere;
color: var(--text-primary);
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 720px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
}
</style>