feat: improve recording automation and task workflows
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_EVENT、LIVE_RECORDER_PLATFORM、LIVE_RECORDER_ROOM_ID、LIVE_RECORDER_ANCHOR、LIVE_RECORDER_RECORD_SESSION_ID、LIVE_RECORDER_RECORD_TASK_ID、LIVE_RECORDER_SEGMENT_INDEX、LIVE_RECORDER_SEGMENT_FILE_PATH、LIVE_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>
|
||||
|
||||
Reference in New Issue
Block a user