feat: add transcode workspace and media browser
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
VideoCamera,
|
||||
Document,
|
||||
Tickets,
|
||||
FolderOpened,
|
||||
RefreshRight,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
|
||||
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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="{ "event": "{{eventType}}", "summary": "{{summary}}", "roomId": "{{liveRoom.roomId}}" }"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user