feat: improve recording automation and task workflows
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
@@ -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;"]
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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