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
@@ -10,6 +10,7 @@ import {
VideoCamera,
Document,
Tickets,
FolderOpened,
RefreshRight,
Setting,
SwitchButton,
+12
View File
@@ -5,8 +5,10 @@ const LoginView = () => import("@/views/LoginView.vue");
const MainLayout = () => import("@/components/layout/MainLayout.vue");
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue");
const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue");
const MediaBrowserView = () => import("@/views/MediaBrowserView.vue");
const DailyReviewsView = () => import("@/views/DailyReviewsView.vue");
const LogsView = () => import("@/views/LogsView.vue");
const RecoveryView = () => import("@/views/RecoveryView.vue");
@@ -39,6 +41,16 @@ const router = createRouter({
name: "record-tasks",
component: RecordTasksView
},
{
path: "transcode-tasks",
name: "transcode-tasks",
component: TranscodeTasksView
},
{
path: "media-browser",
name: "media-browser",
component: MediaBrowserView
},
{
path: "record-tasks/:id",
name: "record-task-detail",
+66 -5
View File
@@ -351,6 +351,23 @@ export interface DailyReviewMoment {
danmakuCount: number;
}
export interface PushDailyReviewRequest {
date?: string | null;
utcOffsetMinutes: number;
channels: string[];
}
export interface DailyReviewPushChannelResult {
channel: string;
success: boolean;
message: string;
}
export interface DailyReviewPushResult {
date: string;
results: DailyReviewPushChannelResult[];
}
export interface SystemSettings {
ffmpegPath: string;
outputRoot: string;
@@ -424,6 +441,7 @@ export interface SystemSettings {
webhookTimeoutSeconds: number;
notifyWebhookOnLiveStarted: boolean;
notifyWebhookOnException: boolean;
webhookBodyTemplate: string;
douyinUserAgent: string;
douyinReferer: string;
douyinCookie: string;
@@ -524,6 +542,42 @@ export interface RecoveryActionResult {
messages: string[];
}
export interface TranscodeTaskItem {
task: RecordTask;
result?: RecordResult;
sourceFilePath?: string;
canManualTranscode: boolean;
}
export interface MediaBrowserBreadcrumb {
label: string;
relativePath: string;
}
export interface MediaBrowserItem {
name: string;
relativePath: string;
type: string;
sizeBytes?: number;
modifiedAt?: string;
canTranscode: boolean;
canPreview: boolean;
}
export interface MediaBrowserResponse {
currentPath: string;
parentPath?: string | null;
breadcrumbs: MediaBrowserBreadcrumb[];
items: MediaBrowserItem[];
}
export interface TranscodeMediaFileResult {
success: boolean;
message: string;
sourcePath?: string;
outputPath?: string;
}
export const availabilityLabelMap: Record<number, string> = {
0: "未知",
1: "未开播",
@@ -579,11 +633,18 @@ export const qualityLabelMap: Record<string, string> = {
SD: "标清"
};
export const qualityDisplayLabelMap: Record<string, string> = {
origin: "原画",
FULL_HD: "超清",
HD: "高清",
SD: "标清"
};
export const qualityOptionList = [
{ value: "origin", label: qualityLabelMap.origin },
{ value: "FULL_HD", label: qualityLabelMap.FULL_HD },
{ value: "HD", label: qualityLabelMap.HD },
{ value: "SD", label: qualityLabelMap.SD }
{ value: "origin", label: qualityDisplayLabelMap.origin },
{ value: "FULL_HD", label: qualityDisplayLabelMap.FULL_HD },
{ value: "HD", label: qualityDisplayLabelMap.HD },
{ value: "SD", label: qualityDisplayLabelMap.SD }
] as const;
export function formatQualityLabel(value?: string | null) {
@@ -591,7 +652,7 @@ export function formatQualityLabel(value?: string | null) {
return "-";
}
return qualityLabelMap[value] ?? value;
return qualityDisplayLabelMap[value] ?? value;
}
export const autoStartDecisionLabelMap: Record<string, string> = {
+74 -24
View File
@@ -1,22 +1,31 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { DailyReviewReport } from "@/types";
import { useViewport } from "@/composables/useViewport";
import type {
DailyReviewPushResult,
DailyReviewReport,
PushDailyReviewRequest
} from "@/types";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const pushing = ref(false);
const pushDialogVisible = ref(false);
const loadError = ref("");
const selectedDate = ref(defaultReviewDate());
const report = ref<DailyReviewReport | null>(null);
const roomTableHeight = computed(() => (isMobile.value ? undefined : 420));
const momentTableHeight = computed(() => (isMobile.value ? undefined : 360));
const pushChannels = reactive({
webhook: true,
email: false
});
async function loadReport() {
loading.value = true;
@@ -40,16 +49,39 @@ async function loadReport() {
}
}
function openPushDialog() {
pushDialogVisible.value = true;
}
async function pushReport() {
const channels = Object.entries(pushChannels)
.filter(([, enabled]) => enabled)
.map(([channel]) => channel);
if (channels.length === 0) {
ElMessage.warning("请至少选择一种推送方式。");
return;
}
pushing.value = true;
try {
const { data } = await apiClient.post("/reports/daily/push", null, {
params: {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes()
}
});
ElMessage.success(data.message);
const payload: PushDailyReviewRequest = {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes(),
channels
};
const { data } = await apiClient.post<DailyReviewPushResult>("/reports/daily/push", payload);
const successCount = data.results.filter((item) => item.success).length;
const summary = data.results.map((item) => `${item.channel}: ${item.message}`).join(" | ");
if (successCount === data.results.length) {
ElMessage.success(summary);
} else if (successCount > 0) {
ElMessage.warning(summary);
} else {
ElMessage.error(summary);
}
pushDialogVisible.value = false;
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "日报推送失败。"));
} finally {
@@ -107,7 +139,7 @@ onMounted(loadReport);
<div>
<h1 class="page-title">回顾日报</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件
</p>
</div>
@@ -122,7 +154,7 @@ onMounted(loadReport);
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
<el-button type="primary" :loading="pushing" :disabled="!report" @click="pushReport">推送日报</el-button>
<el-button type="primary" :disabled="!report" @click="openPushDialog">推送日报</el-button>
</el-space>
</div>
@@ -275,6 +307,24 @@ onMounted(loadReport);
</div>
</el-card>
</template>
<el-dialog
v-model="pushDialogVisible"
title="推送日报"
width="420px"
destroy-on-close
>
<div class="push-dialog">
<p class="push-dialog__hint">选择要推送的通道第一版支持 Webhook 和邮件可同时勾选</p>
<el-checkbox v-model="pushChannels.webhook">Webhook</el-checkbox>
<el-checkbox v-model="pushChannels.email">邮件</el-checkbox>
</div>
<template #footer>
<el-button @click="pushDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pushing" @click="pushReport">开始推送</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -292,16 +342,6 @@ onMounted(loadReport);
border-radius: 14px;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -373,6 +413,17 @@ onMounted(loadReport);
flex-wrap: nowrap;
}
.push-dialog {
display: grid;
gap: 14px;
}
.push-dialog__hint {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}
@media (max-width: 1100px) {
.highlight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -405,4 +456,3 @@ onMounted(loadReport);
}
}
</style>
+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
const loading = ref(false);
const transcodePath = ref<string | null>(null);
const loadError = ref("");
const browser = ref<MediaBrowserResponse | null>(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
params: { path }
});
browser.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "录制目录加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openDirectory(path: string) {
loadDirectory(path);
}
function refreshCurrentDirectory() {
loadDirectory(browser.value?.currentPath ?? "");
}
async function transcodeFile(item: MediaBrowserItem) {
transcodePath.value = item.relativePath;
try {
const { data } = await apiClient.post<TranscodeMediaFileResult>("/media/transcode-file", {
relativePath: item.relativePath
});
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "目录转码启动失败。"));
} finally {
transcodePath.value = null;
}
}
function previewVideo(item: MediaBrowserItem) {
previewTitle.value = item.name;
previewUrl.value = buildApiUrl("/media/file", { path: item.relativePath });
previewVisible.value = true;
}
function openFile(item: MediaBrowserItem, download = false) {
const url = buildApiUrl("/media/file", {
path: item.relativePath,
download: download ? "true" : undefined
});
window.open(url, "_blank", "noopener,noreferrer");
}
function iconForItem(item: MediaBrowserItem) {
switch (item.type) {
case "directory":
return FolderOpened;
case "mp4":
return VideoPlay;
default:
return Document;
}
}
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 typeLabel(type: string) {
switch (type) {
case "directory":
return "目录";
case "mp4":
return "MP4";
case "ts":
return "TS";
case "xml":
return "XML";
default:
return "其他";
}
}
onMounted(() => {
loadDirectory();
});
</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查看 XML并对单个 .ts 文件发起转码
</p>
</div>
<el-space wrap class="header-actions">
<el-button :icon="RefreshRight" @click="refreshCurrentDirectory">刷新目录</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">{{ currentPathLabel }}</h3>
<p class="section-subtitle">目录浏览严格限制在录制输出根目录内不会暴露系统其它路径</p>
</div>
</div>
<div class="breadcrumb-row">
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="breadcrumb in browser?.breadcrumbs ?? []"
:key="breadcrumb.relativePath || 'root'"
>
<button class="breadcrumb-button" type="button" @click="openDirectory(breadcrumb.relativePath)">
{{ breadcrumb.label }}
</button>
</el-breadcrumb-item>
</el-breadcrumb>
<el-button
v-if="browser?.parentPath !== undefined && browser?.parentPath !== null"
size="small"
@click="openDirectory(browser?.parentPath || '')"
>
返回上级
</el-button>
</div>
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" />
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="file-cell">
<el-icon class="file-cell__icon"><component :is="iconForItem(row)" /></el-icon>
<div>
<div class="cell-title">{{ row.name }}</div>
<div class="cell-subtitle">{{ row.relativePath }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ typeLabel(row.type) }}
</template>
</el-table-column>
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.sizeBytes) }}
</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">
{{ formatDate(row.modifiedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
<el-button
v-if="row.type !== 'directory'"
size="small"
plain
@click="openFile(row, true)"
>
下载
</el-button>
<el-button
v-if="row.canTranscode"
size="small"
type="primary"
plain
:loading="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>
转码为 MP4
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewTitle" width="min(980px, 92vw)" destroy-on-close>
<video v-if="previewUrl" class="preview-player" :src="previewUrl" controls preload="metadata" />
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.breadcrumb-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.breadcrumb-button {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
}
.file-cell {
display: flex;
align-items: flex-start;
gap: 12px;
}
.file-cell__icon {
margin-top: 3px;
color: var(--accent);
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
overflow-wrap: anywhere;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preview-player {
width: 100%;
max-height: 72vh;
border-radius: 12px;
background: #09121d;
}
@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;
}
.breadcrumb-row {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+2
View File
@@ -588,6 +588,8 @@ onBeforeUnmount(() => {
</div>
<div class="page-toolbar">
<el-button @click="router.push({ name: 'transcode-tasks' })">转码任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadSessions()">刷新列表</el-button>
<el-button
type="danger"
+47 -1
View File
@@ -179,6 +179,7 @@ const form = reactive<SystemSettings>({
webhookTimeoutSeconds: 15,
notifyWebhookOnLiveStarted: true,
notifyWebhookOnException: true,
webhookBodyTemplate: "",
douyinUserAgent: "",
douyinReferer: "https://live.douyin.com/",
douyinCookie: ""
@@ -217,6 +218,34 @@ const emailTemplateTokens = [
"{{occurredAtUtc}}"
];
const webhookTemplateTokens = [
"{{appName}}",
"{{eventType}}",
"{{sentAtUtc}}",
"{{summary}}",
"{{detail}}",
"{{source}}",
"{{liveRoom.id}}",
"{{liveRoom.platform}}",
"{{liveRoom.roomId}}",
"{{liveRoom.title}}",
"{{liveRoom.anchorName}}",
"{{liveRoom.sourceUrl}}",
"{{recordTask.id}}",
"{{recordTask.recordSessionId}}",
"{{recordTask.status}}",
"{{recordTask.segmentIndex}}",
"{{recordTask.outputFilePath}}",
"{{report.date}}",
"{{report.summary.activeLiveRoomCount}}",
"{{report.summary.sessionCount}}",
"{{report.summary.segmentCount}}",
"{{report.summary.totalDurationSeconds}}",
"{{report.summary.warningCount}}",
"{{report.summary.errorCount}}",
"{{report.summary.totalDanmakuCount}}"
];
const eventScriptEnvironmentExamples = [
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "全部事件" },
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "全部事件" },
@@ -404,7 +433,8 @@ async function testWebhook() {
const { data } = await apiClient.post<WebhookTestResult>("/settings/test-webhook", {
webhookUrl: form.webhookUrl,
webhookHeaders: form.webhookHeaders,
webhookTimeoutSeconds: form.webhookTimeoutSeconds
webhookTimeoutSeconds: form.webhookTimeoutSeconds,
webhookBodyTemplate: form.webhookBodyTemplate
});
webhookTestResult.value = data;
ElMessage[data.success ? "success" : "warning"](data.message);
@@ -1331,8 +1361,24 @@ onMounted(loadSettings);
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="自定义 JSON Body 模板">
<el-input
v-model="form.webhookBodyTemplate"
type="textarea"
:rows="8"
placeholder="{&#10; &quot;event&quot;: &quot;{{eventType}}&quot;,&#10; &quot;summary&quot;: &quot;{{summary}}&quot;,&#10; &quot;roomId&quot;: &quot;{{liveRoom.roomId}}&quot;&#10;}"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">Webhook 变量</div>
<div class="token-list">
<span v-for="token in webhookTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="action-strip">
<div class="helper-text">测试会发送一份样例 live_started 负载直接使用当前表单里的 URL请求头和超时配置</div>
+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>