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
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
npm-debug.log
+23
View File
@@ -0,0 +1,23 @@
ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM ${NGINX_IMAGE} AS runtime
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+39
View File
@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 256m;
location = /api {
return 301 /api/;
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /swagger {
return 301 /swagger/;
}
location /swagger/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+157 -2
View File
@@ -1,10 +1,153 @@
import axios from "axios";
import { ElNotification } from "element-plus";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api",
baseURL: apiBaseUrl,
timeout: 30000
});
let lastBackendUnavailableNotificationAt = 0;
const backendUnavailableNotificationIntervalMs = 8000;
function isDefaultAxiosMessage(message: string) {
return (
/^Request failed with status code \d+$/i.test(message) ||
message === "Network Error" ||
message.toLowerCase().includes("timeout")
);
}
function joinApiUrl(path: string) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const base = apiBaseUrl.replace(/\/+$/, "");
return `${base}${normalizedPath}`;
}
function getStatusFallbackMessage(status?: number) {
switch (status) {
case 400:
return "请求参数有误,请检查后重试。";
case 401:
return "登录状态已失效,请重新登录。";
case 403:
return "当前没有权限执行该操作。";
case 404:
return "请求的接口不存在,请确认前后端版本一致。";
case 409:
return "请求发生冲突,请刷新后重试。";
case 422:
return "提交的数据格式不正确,请检查后重试。";
case 500:
return "后端服务发生内部错误(500),请查看后端日志。";
case 502:
case 503:
case 504:
return "后端服务暂时不可用,请稍后重试。";
default:
return status && status >= 500
? `后端服务发生异常(${status}),请查看后端日志。`
: "请求失败,请稍后重试。";
}
}
export function isBackendUnavailableError(error: unknown) {
if (!axios.isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
!error.response ||
error.code === "ERR_NETWORK" ||
error.code === "ECONNABORTED" ||
status === 502 ||
status === 503 ||
status === 504
);
}
export function getBackendUnavailableMessage() {
return "无法连接后端服务,请确认后端已启动后再重试。";
}
export function buildApiUrl(
path: string,
query?: Record<string, string | number | boolean | null | undefined>
) {
const url = new URL(joinApiUrl(path), window.location.origin);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") {
return;
}
url.searchParams.set(key, String(value));
});
}
return url.toString();
}
export function getApiErrorMessage(error: unknown, fallback = "请求失败,请稍后重试。") {
if (isBackendUnavailableError(error)) {
return getBackendUnavailableMessage();
}
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const responseData = error.response?.data as
| { message?: string; title?: string; detail?: string; error?: string }
| string
| undefined;
if (typeof responseData === "string" && responseData.trim().length > 0 && !isDefaultAxiosMessage(responseData)) {
return responseData;
}
if (responseData && typeof responseData === "object") {
const candidates = [responseData.message, responseData.title, responseData.detail, responseData.error];
const responseMessage = candidates.find(
(item): item is string => typeof item === "string" && item.trim().length > 0 && !isDefaultAxiosMessage(item)
);
if (responseMessage) {
return responseMessage;
}
}
if (typeof error.message === "string" && error.message.trim().length > 0 && !isDefaultAxiosMessage(error.message)) {
return error.message;
}
return getStatusFallbackMessage(status) || fallback;
}
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function notifyBackendUnavailable(message: string) {
const now = Date.now();
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
return;
}
lastBackendUnavailableNotificationAt = now;
ElNotification({
title: "后端连接异常",
message,
type: "error",
duration: 5000
});
}
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("live-recorder-token");
if (token) {
@@ -15,9 +158,13 @@ apiClient.interceptors.request.use((config) => {
});
apiClient.interceptors.response.use(
(response) => response,
(response) => {
markBackendAvailable();
return response;
},
(error) => {
if (error.response?.status === 401) {
markBackendAvailable();
localStorage.removeItem("live-recorder-token");
localStorage.removeItem("live-recorder-user");
@@ -26,6 +173,14 @@ apiClient.interceptors.response.use(
}
}
if (isBackendUnavailableError(error)) {
const message = getBackendUnavailableMessage();
markBackendUnavailable(message);
notifyBackendUnavailable(message);
} else if (error.response) {
markBackendAvailable();
}
return Promise.reject(error);
}
);
+202 -35
View File
@@ -1,22 +1,43 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import {
House,
VideoCamera,
Tickets,
Setting,
SwitchButton
SwitchButton,
Operation
} from "@element-plus/icons-vue";
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { isMobile } = useViewport();
const { backendUnavailable, backendMessage } = useBackendStatus();
const mobileNavVisible = ref(false);
const displayName = computed(() => authStore.user?.displayName ?? "Operator");
const menuItems = [
{ index: "/live-rooms", label: "直播间管理", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/logs", label: "系统日志", icon: Tickets },
{ index: "/settings", label: "系统设置", icon: Setting }
];
watch(
() => route.path,
() => {
mobileNavVisible.value = false;
}
);
async function handleLogout() {
mobileNavVisible.value = false;
await authStore.logout();
await router.push({ name: "login" });
}
@@ -24,31 +45,19 @@ async function handleLogout() {
<template>
<el-container class="shell">
<el-aside width="240px" class="sidebar">
<el-aside v-if="!isMobile" width="240px" class="sidebar">
<div class="brand">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">录制控制台</div>
<div class="brand__subtitle">直播录制控制台</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu">
<el-menu-item index="/live-rooms">
<el-icon><House /></el-icon>
<span>直播间管理</span>
</el-menu-item>
<el-menu-item index="/record-tasks">
<el-icon><VideoCamera /></el-icon>
<span>录制任务</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Tickets /></el-icon>
<span>系统日志</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
@@ -59,15 +68,72 @@ async function handleLogout() {
</div>
</el-aside>
<el-drawer
v-model="mobileNavVisible"
class="mobile-nav-drawer"
direction="ltr"
size="292px"
:with-header="false"
>
<div class="mobile-drawer">
<div class="brand brand--drawer">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">直播录制控制台</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu menu--drawer">
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer sidebar-footer--drawer">
<div class="sidebar-footer__label">当前账户</div>
<div class="sidebar-footer__name">{{ displayName }}</div>
<el-button :icon="SwitchButton" text @click="handleLogout">退出登录</el-button>
</div>
</div>
</el-drawer>
<el-container>
<el-header class="header">
<div>
<div class="header__title">直播录制控制台</div>
<div class="header__subtitle">平台适配巡检录制和结果统一管理</div>
<div class="header__shell">
<div class="header__lead">
<el-button
v-if="isMobile"
class="mobile-nav-trigger"
:icon="Operation"
@click="mobileNavVisible = true"
>
菜单
</el-button>
<div>
<div class="header__title">直播录制控制台</div>
<div class="header__subtitle">平台适配巡检录制和结果统一管理</div>
</div>
</div>
<div v-if="isMobile" class="header__account">
{{ displayName }}
</div>
</div>
</el-header>
<el-main class="main">
<el-alert
v-if="backendUnavailable"
class="backend-alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
:description="backendMessage"
/>
<router-view />
</el-main>
</el-container>
@@ -87,6 +153,15 @@ async function handleLogout() {
background: transparent;
}
.mobile-drawer {
display: flex;
min-height: 100%;
flex-direction: column;
padding: 20px 16px 20px 20px;
background:
linear-gradient(180deg, rgba(250, 248, 243, 0.98), rgba(246, 244, 239, 0.98));
}
.brand {
display: flex;
align-items: center;
@@ -96,6 +171,11 @@ async function handleLogout() {
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
}
.brand--drawer {
padding-left: 4px;
padding-right: 4px;
}
.brand__mark {
width: 42px;
height: 42px;
@@ -132,6 +212,10 @@ async function handleLogout() {
box-shadow: 0 14px 28px rgba(31, 24, 18, 0.05);
}
.menu--drawer {
flex: 1;
}
.menu :deep(.el-menu-item) {
height: 46px;
margin-bottom: 6px;
@@ -164,6 +248,10 @@ async function handleLogout() {
box-shadow: 0 12px 24px rgba(31, 24, 18, 0.04);
}
.sidebar-footer--drawer {
margin-top: 18px;
}
.sidebar-footer__label {
font-size: 12px;
letter-spacing: 0.08em;
@@ -179,10 +267,12 @@ async function handleLogout() {
}
.header {
height: auto;
flex: 0 0 auto;
padding: 22px 32px 0;
}
.header > div {
.header__shell {
display: flex;
align-items: center;
justify-content: space-between;
@@ -194,6 +284,17 @@ async function handleLogout() {
box-shadow: 0 16px 34px rgba(31, 24, 18, 0.05);
}
.header__lead {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.header__lead > div:last-child {
min-width: 0;
}
.header__title {
font-size: 22px;
font-weight: 700;
@@ -207,31 +308,97 @@ async function handleLogout() {
font-size: 13px;
}
.header__account {
flex-shrink: 0;
max-width: 120px;
color: #6f675f;
font-size: 12px;
text-align: right;
word-break: break-word;
}
.mobile-nav-trigger {
flex-shrink: 0;
}
.main {
min-height: 0;
padding: 28px 32px 36px;
}
@media (max-width: 960px) {
.shell {
flex-direction: column;
}
.backend-alert {
margin-bottom: 18px;
border-radius: 14px;
}
.sidebar {
width: 100% !important;
padding: 18px;
}
:deep(.mobile-nav-drawer .el-drawer__body) {
padding: 0;
}
@media (max-width: 768px) {
.header {
padding: 8px 18px 0;
padding: 12px 14px 0;
}
.header > div {
.header__shell {
align-items: center;
gap: 12px;
padding: 14px 16px;
}
.header__lead {
width: 100%;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
flex-direction: row;
gap: 12px;
}
.header__title {
font-size: 18px;
line-height: 1.12;
}
.header__subtitle {
max-width: 240px;
font-size: 12px;
line-height: 1.45;
}
.header__account {
display: none;
}
.mobile-nav-trigger {
min-width: 84px;
padding-inline: 14px;
}
.main {
padding: 22px 18px 24px;
padding: 18px 14px 24px;
}
.backend-alert {
margin-bottom: 14px;
}
}
@media (max-width: 480px) {
.header__shell {
padding: 12px 14px;
}
.header__title {
font-size: 17px;
}
.header__subtitle {
display: none;
}
.mobile-nav-trigger {
min-width: 72px;
padding-inline: 12px;
}
}
</style>
+44 -2
View File
@@ -66,6 +66,7 @@ body {
linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body,
@@ -106,7 +107,7 @@ select:focus-visible {
font-size: clamp(28px, 3vw, 38px);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.02;
line-height: 1.06;
color: var(--text-primary);
}
@@ -252,6 +253,14 @@ select:focus-visible {
.monospace {
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace;
word-break: break-all;
}
.table-scroll-shell {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.el-card {
@@ -536,10 +545,12 @@ select:focus-visible {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.page-title {
font-size: 30px;
font-size: clamp(22px, 7vw, 28px);
line-height: 1.08;
}
.stats-grid {
@@ -549,4 +560,35 @@ select:focus-visible {
.surface-card .el-card__body {
padding: 18px;
}
.page-subtitle {
margin-top: 10px;
font-size: 12px;
line-height: 1.62;
}
.el-dialog {
width: min(100vw - 20px, 560px) !important;
margin: 6vh auto 0 !important;
}
.el-dialog__header {
padding: 18px 18px 8px;
}
.el-dialog__body {
padding: 10px 18px 4px;
}
.el-dialog__footer {
padding: 12px 18px 18px;
}
.el-space {
width: 100%;
}
.el-space__item {
max-width: 100%;
}
}
+82 -1
View File
@@ -21,7 +21,11 @@ export interface LiveRoom {
normalizedUrl: string;
title?: string;
anchorName?: string;
anchorId?: string;
avatarUrl?: string;
coverUrl?: string;
overrides: LiveRoomSettingsOverrides;
effectiveSettings: LiveRoomEffectiveSettings;
isEnabled: boolean;
availabilityStatus: number;
lastCheckedAt?: string;
@@ -29,6 +33,69 @@ export interface LiveRoom {
updatedAt: string;
}
export interface ImportLiveRoomsResult {
totalCount: number;
successCount: number;
failedCount: number;
createdCount: number;
updatedCount: number;
items: ImportLiveRoomItemResult[];
}
export interface ImportLiveRoomItemResult {
lineNumber: number;
rawLine: string;
url?: string;
anchorName?: string;
success: boolean;
created: boolean;
errorMessage?: string;
liveRoom?: LiveRoom;
}
export interface BatchLiveRoomsResult {
requestedCount: number;
successCount: number;
failedCount: number;
items: BatchLiveRoomItemResult[];
}
export interface BatchLiveRoomItemResult {
liveRoomId: string;
success: boolean;
errorMessage?: string;
}
export interface LiveRoomSettingsOverrides {
preferredQuality?: string | null;
outputFormat?: number | null;
saveMode?: number | null;
recordingTemplate?: number | null;
segmentDurationMinutes?: number | null;
enableAutoReconnect?: boolean | null;
reconnectDelayMaxSeconds?: number | null;
readWriteTimeoutMilliseconds?: number | null;
enableDanmakuRecording?: boolean | null;
danmakuIncludeNonChatEvents?: boolean | null;
danmakuMinPollIntervalMilliseconds?: number | null;
danmakuRetryDelayMaxSeconds?: number | null;
}
export interface LiveRoomEffectiveSettings {
preferredQuality: string;
outputFormat: number;
saveMode: number;
recordingTemplate: number;
segmentDurationMinutes: number;
enableAutoReconnect: boolean;
reconnectDelayMaxSeconds: number;
readWriteTimeoutMilliseconds: number;
enableDanmakuRecording: boolean;
danmakuIncludeNonChatEvents: boolean;
danmakuMinPollIntervalMilliseconds: number;
danmakuRetryDelayMaxSeconds: number;
}
export interface RecordTask {
id: string;
liveRoomId: string;
@@ -48,6 +115,9 @@ export interface RecordTask {
startedAt?: string;
endedAt?: string;
durationSeconds?: number;
postProcessStage?: string;
postProcessProgressPercent?: number;
postProcessDetail?: string;
}
export interface RecordResult {
@@ -130,6 +200,11 @@ export interface SystemSettings {
saveMode: number;
recordingTemplate: number;
segmentDurationMinutes: number;
maxConcurrentFfmpegTranscodeTasks: number;
mp4FinalizeTimeoutMinutes: number;
enableStorageGuard: boolean;
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
enableAutoReconnect: boolean;
reconnectDelayMaxSeconds: number;
readWriteTimeoutMilliseconds: number;
@@ -140,6 +215,11 @@ export interface SystemSettings {
enableBackgroundPolling: boolean;
autoStartRecordingOnLive: boolean;
pollingIntervalSeconds: number;
enableEventScripts: boolean;
liveStartedScriptPath: string;
liveEndedScriptPath: string;
segmentCompletedScriptPath: string;
eventScriptTimeoutSeconds: number;
enableEmailNotification: boolean;
emailSmtpHost: string;
emailSmtpPort: number;
@@ -173,7 +253,8 @@ export const taskStatusLabelMap: Record<number, string> = {
3: "Stopping",
4: "Completed",
5: "Failed",
6: "Stopped"
6: "Stopped",
7: "Processing"
};
export const sessionStatusLabelMap = taskStatusLabelMap;
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>