319 lines
9.5 KiB
Vue
319 lines
9.5 KiB
Vue
<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>
|