feat: improve recording automation and task workflows

This commit is contained in:
2026-04-23 23:18:11 +08:00
parent 1c892259a9
commit 23ead56781
88 changed files with 9579 additions and 1581 deletions
File diff suppressed because it is too large Load Diff
+48 -5
View File
@@ -3,11 +3,14 @@ import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Lock, User } from "@element-plus/icons-vue";
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const { backendUnavailable, backendMessage } = useBackendStatus();
const form = reactive({
username: "admin",
@@ -22,7 +25,11 @@ async function handleLogin() {
ElMessage.success("登录成功");
await router.push({ name: "live-rooms" });
} catch (error) {
ElMessage.error("登录失败,请检查账号密码");
ElMessage.error(
isBackendUnavailableError(error)
? getApiErrorMessage(error)
: "登录失败,请检查账号密码"
);
} finally {
loading.value = false;
}
@@ -35,20 +42,30 @@ async function handleLogin() {
<div class="login-copy__eyebrow">Live Recorder</div>
<h1>把直播录制做成可长期维护的系统而不是一次性的脚本</h1>
<p>
平台适配后台巡检录制任务日志和系统设置统一收口
平台适配后台巡检录制任务系统日志和设置统一管理
默认账号为 <span class="monospace">admin / Admin@123</span>
</p>
<div class="login-copy__list">
<span>抖音 API 适配</span>
<span>多平台直播适配</span>
<span>自动开播巡检</span>
<span>ffmpeg 录制编排</span>
<span>邮件通知</span>
<span>邮件与异常通知</span>
</div>
</div>
<el-card class="login-card surface-card" shadow="never">
<el-alert
v-if="backendUnavailable"
class="login-card__alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
:description="backendMessage"
/>
<h3 class="section-title">登录</h3>
<p class="section-subtitle">进入控制台后可管理直播间录制任务日志和系统设置</p>
<p class="section-subtitle">登录后即可进入控制台管理直播间录制任务系统日志和基础设置</p>
<el-form label-position="top" @submit.prevent="handleLogin">
<el-form-item label="用户名">
@@ -168,6 +185,11 @@ async function handleLogin() {
padding: 14px;
}
.login-card__alert {
margin-bottom: 16px;
border-radius: 14px;
}
.login-card__submit {
width: 100%;
margin-top: 12px;
@@ -189,4 +211,25 @@ async function handleLogin() {
margin: 0;
}
}
@media (max-width: 640px) {
.login-page {
gap: 22px;
padding: 22px 14px;
}
.login-copy h1 {
margin-top: 18px;
font-size: clamp(30px, 10vw, 40px);
}
.login-copy p {
font-size: 14px;
line-height: 1.75;
}
.login-card {
padding: 8px;
}
}
</style>
+98 -26
View File
@@ -1,31 +1,44 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import apiClient from "@/api/client";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { SystemLog } from "@/types";
import { logLevelLabelMap } from "@/types";
const loading = ref(false);
const logs = ref<SystemLog[]>([]);
const loadError = ref("");
const { isMobile } = useViewport();
const filters = reactive({
liveRoomId: "",
level: undefined as number | undefined,
recordTaskId: "",
take: 200
});
const logLevelOptions = Object.entries(logLevelLabelMap).map(([value, label]) => ({
value: Number(value),
label
}));
async function loadLogs() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<SystemLog[]>("/logs", {
params: {
liveRoomId: filters.liveRoomId || undefined,
level: typeof filters.level === "number" ? filters.level : undefined,
recordTaskId: filters.recordTaskId || undefined,
take: filters.take
}
});
logs.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -55,18 +68,32 @@ onMounted(loadLogs);
<div class="page-header">
<div>
<h1 class="page-title">系统日志</h1>
<p class="page-subtitle">支持按直播间或任务过滤日志便于定位解析失败巡检异常和 ffmpeg 输出问题</p>
<p class="page-subtitle">
支持按直播间任务和日志级别筛选便于快速定位解析失败巡检异常和 ffmpeg 输出问题
</p>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-card class="surface-card filter-card" shadow="never">
<h3 class="section-title">日志过滤</h3>
<p class="section-subtitle">可选传入直播间 ID 或录制任务 ID数量默认取最近 200 </p>
<p class="section-subtitle">默认拉取最近 200 条日志可按直播间 ID任务 ID 和级别缩小范围</p>
<el-form class="filter-form">
<el-form-item label="直播间 ID">
<el-input v-model="filters.liveRoomId" placeholder="可选" />
</el-form-item>
<el-form-item label="日志级别">
<el-select v-model="filters.level" clearable placeholder="全部级别">
<el-option
v-for="option in logLevelOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="任务 ID">
<el-input v-model="filters.recordTaskId" placeholder="可选" />
</el-form-item>
@@ -81,30 +108,38 @@ onMounted(loadLogs);
<el-card class="surface-card table-card" shadow="never">
<h3 class="section-title">日志列表</h3>
<p class="section-subtitle">按时间倒序排列便于快速回看最近一次异常</p>
<p class="section-subtitle">按时间倒序排列优先展示最近发生的事件</p>
<el-table :data="logs" v-loading="loading" height="720" class="premium-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)">
{{ logLevelLabelMap[row.level] }}
</el-tag>
</template>
</el-table-column>
<div class="table-scroll-shell logs-table-shell">
<el-table
:data="logs"
v-loading="loading"
:height="isMobile ? undefined : 720"
class="premium-table logs-table"
table-layout="auto"
>
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)">
{{ logLevelLabelMap[row.level] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="120" prop="category" />
<el-table-column label="消息" min-width="240" prop="message" />
<el-table-column label="详情" min-width="360">
<template #default="{ row }">
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-table-column label="分类" width="128" prop="category" />
<el-table-column label="消息" min-width="240" prop="message" />
<el-table-column label="详情" min-width="360">
<template #default="{ row }">
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">
<span class="log-date-text">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
@@ -115,6 +150,10 @@ onMounted(loadLogs);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.filter-card :deep(.el-card__body),
.table-card :deep(.el-card__body) {
padding-top: 20px;
@@ -122,7 +161,7 @@ onMounted(loadLogs);
.filter-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px 12px;
align-items: end;
}
@@ -134,6 +173,35 @@ onMounted(loadLogs);
.log-detail {
font-size: 12px;
color: #6d665b;
line-height: 1.6;
}
.log-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.logs-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.logs-table {
min-width: 980px;
}
.logs-table :deep(.el-table__cell) {
vertical-align: top;
}
.logs-table :deep(.cell) {
overflow: visible;
}
@media (max-width: 1200px) {
.filter-form {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -150,5 +218,9 @@ onMounted(loadLogs);
.filter-form {
grid-template-columns: 1fr;
}
.filter-form :deep(.el-button) {
width: 100%;
}
}
</style>
+162 -10
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import apiClient from "@/api/client";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { RecordPreviewTicket, RecordTaskDetail } from "@/types";
import {
logLevelLabelMap,
@@ -17,18 +19,38 @@ const props = defineProps<{
const router = useRouter();
const loading = ref(false);
const previewLoading = ref(false);
const manualTranscodeLoading = ref(false);
const detail = ref<RecordTaskDetail | null>(null);
const loadError = ref("");
const previewUrl = ref("");
const previewExpiresAt = ref("");
const previewMessage = ref("");
const { isMobile } = useViewport();
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
const activeTaskStatuses = new Set([1, 2, 3, 7]);
const canManualTranscode = computed(() => {
const task = detail.value?.task;
const result = detail.value?.result;
if (!task || task.outputFormat !== 0 || task.postProcessStage || activeTaskStatuses.has(task.status)) {
return false;
}
const resultPath = result?.filePath?.toLowerCase() ?? "";
const errorText = `${task.errorMessage ?? ""} ${result?.errorMessage ?? ""}`.toLowerCase();
return resultPath.endsWith(".ts") || errorText.includes("finaliz") || errorText.includes("intermediate ts");
});
async function loadDetailAndPreview() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecordTaskDetail>(`/record-tasks/${props.id}`);
detail.value = data;
await loadPreview();
} catch (error) {
loadError.value = getApiErrorMessage(error, "任务详情加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -43,8 +65,10 @@ async function loadPreview() {
return;
}
if (detail.value.task.status !== 4) {
previewMessage.value = "录制未完成时不提供视频预览。";
if (detail.value.task.postProcessStage) {
previewMessage.value = detail.value.task.postProcessDetail
? `${detail.value.task.postProcessStage}${detail.value.task.postProcessDetail}`
: `${detail.value.task.postProcessStage},完成后即可预览并拖动进度条。`;
return;
}
@@ -58,6 +82,23 @@ async function loadPreview() {
return;
}
if (detail.value.result.filePath.toLowerCase().endsWith(".ts")) {
previewMessage.value = canManualTranscode.value
? "当前分片仍保留中间 TS 文件,可手动触发转码后再预览。"
: "当前分片仍保留中间 TS 文件,暂时无法直接预览。";
return;
}
if (detail.value.task.status !== 4 && detail.value.task.status !== 6) {
previewMessage.value = "当前仅支持已完成收尾的 MP4 分片预览。";
return;
}
if (!detail.value.result.filePath.toLowerCase().endsWith(".mp4")) {
previewMessage.value = "当前仅支持 MP4 分片预览。";
return;
}
previewLoading.value = true;
try {
@@ -71,11 +112,30 @@ async function loadPreview() {
}
}
async function startManualTranscode() {
manualTranscodeLoading.value = true;
try {
const { data } = await apiClient.post<RecordTaskDetail>(`/record-tasks/${props.id}/transcode`);
detail.value = data;
await loadPreview();
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败,请稍后重试。"));
} finally {
manualTranscodeLoading.value = false;
}
}
function statusTagType(status: number) {
if (status === 2) {
return "success";
}
if (status === 7) {
return "warning";
}
if (status === 5) {
return "danger";
}
@@ -123,6 +183,10 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatProgress(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-";
}
watch(() => props.id, loadDetailAndPreview);
onMounted(loadDetailAndPreview);
</script>
@@ -143,10 +207,12 @@ onMounted(loadDetailAndPreview);
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !detail" animated :rows="8" />
<template v-else-if="detail">
<el-row :gutter="18">
<el-row :gutter="rowGutter">
<el-col :lg="12" :sm="24">
<el-card class="surface-card detail-card" shadow="never">
<h3 class="section-title">任务信息</h3>
@@ -173,6 +239,22 @@ onMounted(loadDetailAndPreview);
<el-descriptions-item label="输出文件">
<span class="monospace">{{ detail.task.outputFilePath || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="后处理状态">
<template v-if="detail.task.postProcessStage">
<div class="detail-postprocess">
<div class="detail-postprocess__stage">{{ detail.task.postProcessStage }}</div>
<el-progress
:percentage="Math.max(0, Math.min(100, detail.task.postProcessProgressPercent ?? 0))"
:stroke-width="6"
:show-text="false"
/>
<div class="detail-postprocess__detail">
{{ detail.task.postProcessDetail || formatProgress(detail.task.postProcessProgressPercent) }}
</div>
</div>
</template>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="错误信息">
{{ detail.task.errorMessage || "-" }}
</el-descriptions-item>
@@ -223,11 +305,22 @@ onMounted(loadDetailAndPreview);
<div>
<h3 class="section-title">视频预览</h3>
<p class="section-subtitle">
仅支持已完成的 MP4 分片预览票据会短期失效过期后刷新即可重新获取
仅支持已完成收尾 MP4 分片预览票据会短期失效过期后刷新即可重新获取
</p>
</div>
<div class="preview-meta" v-if="previewExpiresAt">
票据有效至 {{ formatDate(previewExpiresAt) }}
<div class="preview-actions">
<div class="preview-meta" v-if="previewExpiresAt">
票据有效至 {{ formatDate(previewExpiresAt) }}
</div>
<el-button
v-if="canManualTranscode"
type="primary"
plain
:loading="manualTranscodeLoading"
@click="startManualTranscode"
>
手动转码
</el-button>
</div>
</div>
@@ -249,7 +342,8 @@ onMounted(loadDetailAndPreview);
<h3 class="section-title">关联日志</h3>
<p class="section-subtitle">日志已带上会话与任务关联可看到 ffmpeg巡检和弹幕采集的上下文</p>
<el-table :data="detail.logs" height="420" class="premium-table">
<div class="table-scroll-shell detail-logs-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table detail-logs-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -270,7 +364,8 @@ onMounted(loadDetailAndPreview);
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-card>
</template>
</div>
@@ -282,10 +377,23 @@ onMounted(loadDetailAndPreview);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.header-actions {
align-self: center;
}
.detail-logs-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.detail-logs-table {
min-width: 940px;
}
.detail-card :deep(.el-card__body),
.preview-card :deep(.el-card__body),
.logs-card :deep(.el-card__body) {
@@ -307,6 +415,12 @@ onMounted(loadDetailAndPreview);
font-size: 12px;
}
.preview-actions {
display: flex;
align-items: center;
gap: 12px;
}
.preview-player {
width: 100%;
max-height: 560px;
@@ -326,15 +440,53 @@ onMounted(loadDetailAndPreview);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(246, 243, 238, 0.78));
}
.detail-postprocess {
display: grid;
gap: 8px;
}
.detail-postprocess__stage {
font-size: 13px;
font-weight: 600;
color: #31465b;
}
.detail-postprocess__detail {
font-size: 12px;
color: #6c7a86;
line-height: 1.4;
}
.log-detail {
font-size: 12px;
color: #6d665b;
}
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.preview-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 16px;
}
.preview-actions {
width: 100%;
justify-content: space-between;
}
.preview-player {
max-height: 360px;
}
}
</style>
+344 -101
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import apiClient from "@/api/client";
import apiClient, {
buildApiUrl,
getApiErrorMessage,
getBackendUnavailableMessage
} from "@/api/client";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import type { DeleteCompletedRecordTasksResult, RecordSession, RecordTask } from "@/types";
import {
outputFormatLabelMap,
@@ -20,8 +26,14 @@ const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
const deleteDialogTaskIds = ref<string[]>([]);
const deleteDialogSessionIds = ref<string[]>([]);
const sessions = ref<RecordSession[]>([]);
const loadError = ref("");
const activeSessionPanels = ref<string[]>([]);
const selectedTaskMap = ref<Record<string, RecordTask>>({});
const realtimeConnected = ref(false);
const realtimeError = ref("");
const { isMobile } = useViewport();
let sessionsEventSource: EventSource | null = null;
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
@@ -29,12 +41,13 @@ const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum +
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
const deleteDialogEyebrow = computed(() => deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认");
const deleteDialogTitle = computed(() => deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务");
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
const deleteDialogEyebrow = computed(() => (deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认"));
const deleteDialogTitle = computed(() => (deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务"));
const deleteDialogLead = computed(() =>
deleteDialogMode.value === "sessions"
? `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`
: `将删除 ${deleteDialogTaskCount.value} 个已结束分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
: `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
);
const deleteDialogNote = computed(() =>
deleteDialogMode.value === "sessions"
@@ -42,21 +55,8 @@ const deleteDialogNote = computed(() =>
: "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。"
);
async function loadSessions() {
loading.value = true;
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
sessions.value = data;
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
selectedTaskMap.value = {};
} finally {
loading.value = false;
}
}
function isActiveStatus(status: number) {
return status === 1 || status === 2 || status === 3;
return status === 1 || status === 2 || status === 3 || status === 7;
}
function isDeletableTask(task: RecordTask) {
@@ -67,6 +67,123 @@ function selectableTask(row: RecordTask) {
return isDeletableTask(row);
}
function applySessionsSnapshot(
data: RecordSession[],
options?: {
resetPanels?: boolean;
resetSelection?: boolean;
}
) {
const nextSessionIds = new Set(data.map((item) => item.id));
const nextTaskMap = new Map<string, RecordTask>();
data.forEach((session) => {
session.tasks.forEach((task) => {
nextTaskMap.set(task.id, task);
});
});
sessions.value = data;
if (options?.resetPanels || activeSessionPanels.value.length === 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
} else {
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => nextSessionIds.has(sessionId));
if (activeSessionPanels.value.length === 0 && data.length > 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
}
}
if (options?.resetSelection) {
selectedTaskMap.value = {};
return;
}
selectedTaskMap.value = Object.fromEntries(
Object.entries(selectedTaskMap.value)
.map(([taskId]) => {
const task = nextTaskMap.get(taskId);
return task && isDeletableTask(task) ? [taskId, task] : null;
})
.filter((item): item is [string, RecordTask] => Boolean(item))
);
}
async function loadSessions(options?: { resetPanels?: boolean; resetSelection?: boolean }) {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
applySessionsSnapshot(data, {
resetPanels: options?.resetPanels ?? true,
resetSelection: options?.resetSelection ?? true
});
markBackendAvailable();
} catch (error) {
loadError.value = getApiErrorMessage(error, "录制会话加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function connectRealtimeUpdates() {
if (typeof window === "undefined" || typeof EventSource === "undefined") {
realtimeError.value = "当前浏览器不支持实时更新,仍可手动刷新列表。";
return;
}
const token = localStorage.getItem("live-recorder-token");
if (!token) {
realtimeError.value = "未检测到登录凭证,实时更新未启用。";
return;
}
closeRealtimeUpdates();
sessionsEventSource = new EventSource(
buildApiUrl("/record-sessions/stream", {
access_token: token
})
);
sessionsEventSource.onopen = () => {
realtimeConnected.value = true;
realtimeError.value = "";
loadError.value = "";
markBackendAvailable();
};
sessionsEventSource.addEventListener("sessions", (event) => {
try {
const nextSessions = JSON.parse((event as MessageEvent<string>).data) as RecordSession[];
applySessionsSnapshot(nextSessions, { resetPanels: false, resetSelection: false });
realtimeConnected.value = true;
realtimeError.value = "";
loadError.value = "";
markBackendAvailable();
} catch {
realtimeConnected.value = false;
realtimeError.value = "实时更新数据解析失败,已保留手动刷新入口。";
}
});
sessionsEventSource.onerror = () => {
realtimeConnected.value = false;
realtimeError.value = "实时更新连接中断,正在自动重连。";
markBackendUnavailable(getBackendUnavailableMessage());
};
}
function closeRealtimeUpdates() {
if (!sessionsEventSource) {
return;
}
sessionsEventSource.close();
sessionsEventSource = null;
}
function handleSelectionChange(session: RecordSession, selection: RecordTask[]) {
const nextMap = { ...selectedTaskMap.value };
session.tasks.forEach((task) => {
@@ -87,8 +204,8 @@ async function stopSession(session: RecordSession) {
try {
await apiClient.post(`/record-sessions/${session.id}/stop`);
ElMessage.success("已发送停止会话请求");
await loadSessions();
ElMessage.success("已发送停止会话请求");
await loadSessions({ resetPanels: false, resetSelection: false });
} finally {
stoppingSessionId.value = null;
}
@@ -100,7 +217,7 @@ function openDetail(task: RecordTask) {
function openDeleteTaskDialog(taskIds: string[]) {
if (taskIds.length === 0) {
ElMessage.warning("请选择可删除的分片任务");
ElMessage.warning("请选择可删除的分片任务");
return;
}
@@ -112,7 +229,7 @@ function openDeleteTaskDialog(taskIds: string[]) {
function openDeleteSessionDialog(sessionIds: string[]) {
if (sessionIds.length === 0) {
ElMessage.warning("请选择要删除的录制会话");
ElMessage.warning("请选择要删除的录制会话");
return;
}
@@ -177,15 +294,15 @@ async function confirmDelete(deleteFiles: boolean) {
ElMessage.success(
deletingSessions
? `已删除 ${data.deletedSessionIds.length} 个录制会话`
: `已删除 ${data.deletedTaskIds.length} 个分片任务`
? `已删除 ${data.deletedSessionIds.length} 个录制会话`
: `已删除 ${data.deletedTaskIds.length} 个分片任务`
);
applyDeleteResult(data);
if (data.warnings.length > 0) {
ElNotification({
title: "删除完成,但有提示",
title: "删除完成,但有提示",
message: data.warnings.join("\n"),
type: "warning",
duration: 8000
@@ -194,7 +311,7 @@ async function confirmDelete(deleteFiles: boolean) {
deleteDialogVisible.value = false;
resetDeleteDialogState();
await loadSessions();
await loadSessions({ resetPanels: false, resetSelection: false });
} finally {
deleting.value = false;
}
@@ -220,6 +337,14 @@ function taskTagType(status: number) {
return sessionTagType(status);
}
function hasPostProcess(task: RecordTask) {
return Boolean(task.postProcessStage);
}
function formatProgress(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "处理中";
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
@@ -248,7 +373,14 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
onMounted(loadSessions);
onMounted(async () => {
await loadSessions();
connectRealtimeUpdates();
});
onBeforeUnmount(() => {
closeRealtimeUpdates();
});
</script>
<template>
@@ -257,12 +389,12 @@ onMounted(loadSessions);
<div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
每一场直播会话分组展示分段模式下一个分片就是一个任务结束后的分片可按需删除整场会话也支持直接清理
直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变更保留手动刷新入口用于兜底
</p>
</div>
<el-space wrap class="header-actions">
<el-button @click="loadSessions">刷新列表</el-button>
<el-button @click="loadSessions()">刷新列表</el-button>
<el-button
type="danger"
plain
@@ -270,11 +402,22 @@ onMounted(loadSessions);
:loading="deleting"
@click="openDeleteTaskDialog(selectedTasks.map((item) => item.id))"
>
批量删除已结束任务
删除已选分片{{ selectedTasks.length > 0 ? `${selectedTasks.length}` : "" }}
</el-button>
</el-space>
</div>
<el-alert
v-if="realtimeError"
class="page-stream-alert"
type="warning"
:closable="false"
show-icon
:title="realtimeError"
/>
<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>
@@ -284,7 +427,7 @@ onMounted(loadSessions);
<div class="stat-card">
<div class="stat-card__label">活跃会话</div>
<div class="stat-card__value">{{ activeSessionCount }}</div>
<div class="stat-card__hint">Starting / Running / Stopping</div>
<div class="stat-card__hint">Starting / Running / Stopping / Processing</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片任务</div>
@@ -303,7 +446,7 @@ onMounted(loadSessions);
<div>
<h3 class="section-title">会话列表</h3>
<p class="section-subtitle">
会话层负责整场直播分片层负责单个视频文件停止入口作用于整个会话详情入口作用于单个分片
会话层代表整场录制分片层代表单个视频文件停止入口作用于整个会话详情入口作用于单个分片
</p>
</div>
</div>
@@ -379,80 +522,96 @@ onMounted(loadSessions);
</el-button>
</div>
<el-table
:data="session.tasks"
row-key="id"
class="premium-table nested-table"
@selection-change="handleSelectionChange(session, $event)"
>
<el-table-column type="selection" width="48" :selectable="selectableTask" />
<div class="table-scroll-shell tasks-table-shell">
<el-table
:data="session.tasks"
row-key="id"
class="premium-table nested-table"
table-layout="auto"
@selection-change="handleSelectionChange(session, $event)"
>
<el-table-column type="selection" width="48" :selectable="selectableTask" :reserve-selection="true" />
<el-table-column label="分片" width="90">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="分片" width="90">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="taskTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="220">
<template #default="{ row }">
<div class="task-status-cell">
<el-tag :type="taskTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
<template v-if="hasPostProcess(row)">
<div class="task-status-cell__stage">{{ row.postProcessStage }}</div>
<el-progress
:percentage="Math.max(0, Math.min(100, row.postProcessProgressPercent ?? 0))"
:stroke-width="6"
:show-text="false"
/>
<div class="task-status-cell__detail">
{{ row.postProcessDetail || formatProgress(row.postProcessProgressPercent) }}
</div>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span class="monospace">{{ row.preferredQuality }}</span>
</template>
</el-table-column>
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span class="monospace">{{ row.preferredQuality }}</span>
</template>
</el-table-column>
<el-table-column label="输出文件" min-width="320">
<template #default="{ row }">
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="输出文件" min-width="320">
<template #default="{ row }">
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">
{{ formatDate(row.startedAt || row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">
<div class="task-date-text">{{ formatDate(row.startedAt || row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="时长" width="100">
<template #default="{ row }">
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="时长" width="100">
<template #default="{ row }">
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-space wrap>
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click="stopSession(session)"
>
停止
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([row.id])"
>
删除
</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click="stopSession(session)"
>
停止
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([row.id])"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-collapse-item>
</el-collapse>
@@ -461,7 +620,7 @@ onMounted(loadSessions);
<el-dialog
v-model="deleteDialogVisible"
class="task-delete-dialog"
width="clamp(480px, 48vw, 560px)"
:width="deleteDialogWidth"
:show-close="!deleting"
:close-on-click-modal="!deleting"
:close-on-press-escape="!deleting"
@@ -507,6 +666,16 @@ onMounted(loadSessions);
align-self: center;
}
.page-error-alert,
.page-stream-alert {
border-radius: 14px;
}
.tasks-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.sessions-card :deep(.el-card__body) {
padding-top: 18px;
}
@@ -620,11 +789,56 @@ onMounted(loadSessions);
.nested-table {
border-radius: 12px;
min-width: 1220px;
}
.nested-table :deep(.el-table__cell) {
vertical-align: top;
}
.nested-table :deep(.cell) {
overflow: visible;
}
.path-text {
font-size: 12px;
color: #65717c;
line-height: 1.6;
}
.task-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.task-status-cell {
display: grid;
gap: 8px;
min-width: 0;
}
.task-status-cell__stage {
font-size: 12px;
font-weight: 600;
color: #31465b;
}
.task-status-cell__detail {
font-size: 12px;
color: #6c7a86;
line-height: 1.4;
}
.task-actions-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.task-actions-cell :deep(.el-button) {
margin: 0;
}
.delete-dialog__header {
@@ -707,6 +921,16 @@ onMounted(loadSessions);
}
@media (max-width: 960px) {
.header-actions {
align-self: stretch;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.session-title {
display: grid;
}
@@ -718,9 +942,28 @@ onMounted(loadSessions);
.session-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-actions {
justify-content: stretch;
flex-wrap: wrap;
}
.session-actions :deep(.el-button) {
flex: 1 1 calc(50% - 6px);
margin: 0;
}
}
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.session-summary {
grid-template-columns: 1fr;
}
+133 -1
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient from "@/api/client";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { SystemSettings } from "@/types";
import { outputFormatLabelMap, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
const loading = ref(false);
const saving = ref(false);
const testingEmail = ref(false);
const loadError = ref("");
const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
@@ -19,6 +20,11 @@ const form = reactive<SystemSettings>({
saveMode: 0,
recordingTemplate: 0,
segmentDurationMinutes: 30,
maxConcurrentFfmpegTranscodeTasks: 1,
mp4FinalizeTimeoutMinutes: 60,
enableStorageGuard: true,
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 5,
readWriteTimeoutMilliseconds: 15000000,
@@ -29,6 +35,11 @@ const form = reactive<SystemSettings>({
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
enableEventScripts: false,
liveStartedScriptPath: "",
liveEndedScriptPath: "",
segmentCompletedScriptPath: "",
eventScriptTimeoutSeconds: 60,
enableEmailNotification: false,
emailSmtpHost: "",
emailSmtpPort: 587,
@@ -120,10 +131,13 @@ const nestedSegmentedExamplePath = computed(() => {
async function loadSettings() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<SystemSettings>("/settings");
Object.assign(form, data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -185,6 +199,8 @@ onMounted(loadSettings);
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-grid" v-loading="loading">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
@@ -248,6 +264,16 @@ onMounted(loadSettings);
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="同时转码任务数">
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="MP4 转码超时(分钟)">
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重连延迟(秒)">
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
@@ -267,6 +293,45 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">存储保护</h3>
<p class="section-subtitle">磁盘空间低于暂停阈值时会停止活跃录制并暂停 MP4 转码空间恢复到恢复阈值后后台巡检会重新启动录制并补转码</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用存储空间保护">
<el-switch v-model="form.enableStorageGuard" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="低于该空间暂停录制(MB">
<el-input-number
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="高于该空间恢复录制/转码(MB)">
<el-input-number
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议大于暂停阈值避免磁盘空间在临界值附近反复暂停和恢复MP4 转码会额外预留源 TS 文件大小避免再次出现 No space left on device
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">控制是否并行录制弹幕 XML是否保留非聊天事件以及轮询和失败重连节奏</p>
@@ -375,6 +440,50 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">主播开播下播分片完成时可执行自定义脚本脚本通过环境变量获取上下文分片完成时可读取 LIVE_RECORDER_SEGMENT_FILE_PATH</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="启用事件脚本">
<el-switch v-model="form.enableEventScripts" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="脚本超时(秒)">
<el-input-number
v-model="form.eventScriptTimeoutSeconds"
:min="1"
:max="3600"
:disabled="!form.enableEventScripts"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="开播脚本路径">
<el-input v-model="form.liveStartedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-started.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="下播脚本路径">
<el-input v-model="form.liveEndedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-ended.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="分片完成脚本路径">
<el-input v-model="form.segmentCompletedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/segment-completed.sh" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
常用环境变量LIVE_RECORDER_EVENTLIVE_RECORDER_PLATFORMLIVE_RECORDER_ROOM_IDLIVE_RECORDER_ANCHORLIVE_RECORDER_RECORD_SESSION_IDLIVE_RECORDER_RECORD_TASK_IDLIVE_RECORDER_SEGMENT_INDEXLIVE_RECORDER_SEGMENT_FILE_PATHLIVE_RECORDER_DANMAKU_FILE_PATH
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">支持 SMTP开播提醒和异常提醒 HTML 模板测试发送会直接使用当前表单中的 SMTP 与模板</p>
@@ -531,6 +640,10 @@ onMounted(loadSettings);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
@@ -649,9 +762,28 @@ onMounted(loadSettings);
}
@media (max-width: 900px) {
.page-header :deep(.el-button) {
width: 100%;
}
.email-actions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.settings-card :deep(.el-col) {
flex: 0 0 100%;
max-width: 100%;
}
.template-section {
padding: 16px 16px 2px;
}
.email-actions :deep(.el-button) {
width: 100%;
}
}
</style>