1111
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Live Recorder Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "live-recorder-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.9.0",
|
||||
"element-plus": "^2.10.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import axios from "axios";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api",
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem("live-recorder-token");
|
||||
localStorage.removeItem("live-recorder-user");
|
||||
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
House,
|
||||
VideoCamera,
|
||||
Tickets,
|
||||
Setting,
|
||||
SwitchButton
|
||||
} from "@element-plus/icons-vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const displayName = computed(() => authStore.user?.displayName ?? "Operator");
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
await router.push({ name: "login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="shell">
|
||||
<el-aside 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>
|
||||
</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>
|
||||
</el-menu>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer__label">当前账户</div>
|
||||
<div class="sidebar-footer__name">{{ displayName }}</div>
|
||||
<el-button :icon="SwitchButton" text @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div>
|
||||
<div class="header__title">直播录制控制台</div>
|
||||
<div class="header__subtitle">平台适配、巡检、录制和结果统一管理</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 16px 20px 20px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 8px 10px 16px;
|
||||
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(62, 95, 124, 0.96), rgba(47, 76, 102, 0.96));
|
||||
color: #f8fcfb;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 18px 30px rgba(62, 95, 124, 0.22);
|
||||
}
|
||||
|
||||
.brand__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.brand__subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8379;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 8px;
|
||||
border-radius: 18px;
|
||||
border-right: none;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(251, 248, 243, 0.9));
|
||||
border: 1px solid rgba(82, 74, 63, 0.08);
|
||||
box-shadow: 0 14px 28px rgba(31, 24, 18, 0.05);
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item) {
|
||||
height: 46px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 12px;
|
||||
color: #504941;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item:hover) {
|
||||
background: rgba(62, 95, 124, 0.08);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item.is-active) {
|
||||
background: linear-gradient(135deg, #4a6984, #2f4c66);
|
||||
color: #f8fcfb;
|
||||
box-shadow: 0 12px 22px rgba(62, 95, 124, 0.22);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 18px 14px 6px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(82, 74, 63, 0.08);
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
box-shadow: 0 12px 24px rgba(31, 24, 18, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-footer__label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #91897f;
|
||||
}
|
||||
|
||||
.sidebar-footer__name {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 22px 32px 0;
|
||||
}
|
||||
|
||||
.header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px 22px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(82, 74, 63, 0.08);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(251, 248, 243, 0.86));
|
||||
box-shadow: 0 16px 34px rgba(31, 24, 18, 0.05);
|
||||
}
|
||||
|
||||
.header__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.header__subtitle {
|
||||
margin-top: 4px;
|
||||
color: #716b63;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 28px 32px 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100% !important;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 8px 18px 0;
|
||||
}
|
||||
|
||||
.header > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 22px 18px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles/main.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const LoginView = () => import("@/views/LoginView.vue");
|
||||
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
||||
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
||||
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
||||
const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue");
|
||||
const LogsView = () => import("@/views/LogsView.vue");
|
||||
const SettingsView = () => import("@/views/SettingsView.vue");
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView,
|
||||
meta: { anonymous: true }
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
redirect: "/live-rooms"
|
||||
},
|
||||
{
|
||||
path: "live-rooms",
|
||||
name: "live-rooms",
|
||||
component: LiveRoomsView
|
||||
},
|
||||
{
|
||||
path: "record-tasks",
|
||||
name: "record-tasks",
|
||||
component: RecordTasksView
|
||||
},
|
||||
{
|
||||
path: "record-tasks/:id",
|
||||
name: "record-task-detail",
|
||||
component: RecordTaskDetailView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
name: "logs",
|
||||
component: LogsView
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
name: "settings",
|
||||
component: SettingsView
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (!to.meta.anonymous && !authStore.isAuthenticated) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
if (to.name === "login" && authStore.isAuthenticated) {
|
||||
return { name: "live-rooms" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import apiClient from "@/api/client";
|
||||
import type { AuthenticatedUser, LoginResponse } from "@/types";
|
||||
|
||||
const USER_STORAGE_KEY = "live-recorder-user";
|
||||
const TOKEN_STORAGE_KEY = "live-recorder-token";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? "");
|
||||
const user = ref<AuthenticatedUser | null>(
|
||||
(() => {
|
||||
const raw = localStorage.getItem(USER_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as AuthenticatedUser) : null;
|
||||
})()
|
||||
);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
|
||||
function persistSession(session: LoginResponse) {
|
||||
token.value = session.token;
|
||||
user.value = session.user;
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
token.value = "";
|
||||
user.value = null;
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(USER_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
persistSession(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiClient.post("/auth/logout");
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
clearSession
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color: #171614;
|
||||
background: #f6f4ef;
|
||||
font-family: "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
|
||||
--bg-base: #f6f4ef;
|
||||
--bg-soft: #fbfaf7;
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--surface-strong: #fffcf7;
|
||||
--surface-muted: #f3f0ea;
|
||||
--border-light: rgba(82, 74, 63, 0.08);
|
||||
--border-base: rgba(82, 74, 63, 0.12);
|
||||
--border-strong: rgba(82, 74, 63, 0.18);
|
||||
--text-primary: #171614;
|
||||
--text-secondary: #6d675f;
|
||||
--text-tertiary: #948d83;
|
||||
--accent: #3e5f7c;
|
||||
--accent-strong: #2f4c66;
|
||||
--accent-soft: rgba(62, 95, 124, 0.12);
|
||||
--danger-soft: rgba(183, 80, 70, 0.1);
|
||||
--shadow-soft: 0 10px 28px rgba(31, 24, 18, 0.05);
|
||||
--shadow-card: 0 18px 48px rgba(31, 24, 18, 0.07);
|
||||
--shadow-hover: 0 20px 40px rgba(31, 24, 18, 0.1);
|
||||
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #6683a0;
|
||||
--el-color-primary-light-5: #8da4ba;
|
||||
--el-color-primary-light-7: #bcc9d7;
|
||||
--el-color-primary-light-8: #d8e0e8;
|
||||
--el-color-primary-light-9: #edf2f6;
|
||||
--el-color-primary-dark-2: var(--accent-strong);
|
||||
--el-text-color-primary: var(--text-primary);
|
||||
--el-text-color-regular: var(--text-secondary);
|
||||
--el-text-color-secondary: var(--text-tertiary);
|
||||
--el-border-color: var(--border-base);
|
||||
--el-border-color-light: var(--border-light);
|
||||
--el-border-radius-base: 10px;
|
||||
--el-bg-color: transparent;
|
||||
--el-fill-color-blank: rgba(255, 255, 255, 0.72);
|
||||
--el-fill-color-light: rgba(244, 241, 236, 0.94);
|
||||
--el-mask-color: rgba(17, 19, 17, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
color: #ffffff;
|
||||
background: rgba(62, 95, 124, 0.75);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.8) 0, rgba(255, 255, 255, 0) 32%),
|
||||
radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 28%),
|
||||
linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header > div:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 3vw, 38px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.02;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
max-width: 760px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-base);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 252, 247, 0.84) 100%);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(14px);
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.surface-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surface-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.surface-card .el-card__body {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(208px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 18px 18px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 48%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(250, 248, 244, 0.92) 100%);
|
||||
box-shadow: 0 12px 26px rgba(31, 24, 18, 0.05);
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.stat-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(62, 95, 124, 0.12);
|
||||
box-shadow: 0 0 0 6px rgba(62, 95, 124, 0.05);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--border-base);
|
||||
box-shadow: 0 18px 32px rgba(31, 24, 18, 0.08);
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
margin-top: 14px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-card__hint {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0 0 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.token-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
--el-card-border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
height: 38px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.el-button.el-button--default:not(.is-text):not(.is-link) {
|
||||
border-color: rgba(91, 110, 129, 0.16);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #22303e;
|
||||
}
|
||||
|
||||
.el-button:not(.is-disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px rgba(31, 24, 18, 0.08);
|
||||
}
|
||||
|
||||
.el-button:not(.is-disabled):active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 6px 14px rgba(31, 24, 18, 0.06);
|
||||
}
|
||||
|
||||
.el-button.el-button--primary {
|
||||
background: linear-gradient(180deg, #4a6984 0%, #3e5f7c 100%) !important;
|
||||
border-color: #35516a !important;
|
||||
color: #f7fafc !important;
|
||||
box-shadow: 0 12px 22px rgba(62, 95, 124, 0.2);
|
||||
}
|
||||
|
||||
.el-button.el-button--primary:not(.is-disabled):hover {
|
||||
background: linear-gradient(180deg, #3d5b75 0%, #2f4c66 100%) !important;
|
||||
border-color: #2f4c66 !important;
|
||||
color: #f7fafc !important;
|
||||
}
|
||||
|
||||
.el-button.el-button--primary:not(.is-disabled):active {
|
||||
background: #2f4c66 !important;
|
||||
border-color: #29445c !important;
|
||||
}
|
||||
|
||||
.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) {
|
||||
background: rgba(62, 95, 124, 0.08) !important;
|
||||
border-color: rgba(62, 95, 124, 0.18) !important;
|
||||
color: #35536d !important;
|
||||
}
|
||||
|
||||
.el-button.el-button--danger.is-plain {
|
||||
background: rgba(183, 80, 70, 0.08) !important;
|
||||
border-color: rgba(183, 80, 70, 0.22) !important;
|
||||
color: #a1473d !important;
|
||||
}
|
||||
|
||||
.el-button.is-disabled,
|
||||
.el-button.is-disabled:hover,
|
||||
.el-button.is-disabled:active {
|
||||
background: rgba(233, 237, 241, 0.92) !important;
|
||||
border-color: rgba(91, 110, 129, 0.12) !important;
|
||||
color: #9aa5b1 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.el-button.is-text {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-date-editor,
|
||||
.el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number,
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.1) !important;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-select__wrapper:hover,
|
||||
.el-textarea__inner:hover,
|
||||
.el-input-number:hover {
|
||||
box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.16) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus,
|
||||
.el-input-number:focus-within {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(62, 95, 124, 0.34),
|
||||
0 0 0 4px rgba(62, 95, 124, 0.08) !important;
|
||||
}
|
||||
|
||||
.el-textarea__inner {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
padding-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.el-switch {
|
||||
--el-switch-on-color: var(--accent);
|
||||
--el-switch-off-color: rgba(120, 114, 105, 0.28);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background: rgba(40, 132, 95, 0.12);
|
||||
color: #1d6d53;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background: rgba(189, 131, 37, 0.12);
|
||||
color: #8a6221;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background: rgba(183, 80, 70, 0.12);
|
||||
color: #9c4036;
|
||||
}
|
||||
|
||||
.el-tag--info,
|
||||
.el-tag.el-tag--primary {
|
||||
background: rgba(101, 95, 86, 0.12);
|
||||
color: #5d554d;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: rgba(62, 95, 124, 0.05);
|
||||
--el-table-current-row-bg-color: rgba(62, 95, 124, 0.07);
|
||||
--el-table-header-bg-color: transparent;
|
||||
--el-table-bg-color: transparent;
|
||||
--el-fill-color-blank: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table::before,
|
||||
.el-table__inner-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
padding: 4px 0 12px;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell > .cell {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(82, 74, 63, 0.06);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-table .cell {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.el-table tr {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-descriptions {
|
||||
--el-descriptions-table-border: 1px solid rgba(82, 74, 63, 0.08);
|
||||
}
|
||||
|
||||
.el-descriptions__body .el-descriptions__table {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(252, 249, 244, 0.94));
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 22px 24px 10px;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 12px 24px 6px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 12px 24px 22px;
|
||||
}
|
||||
|
||||
.el-empty__description,
|
||||
.el-result__subtitle,
|
||||
.el-empty__description p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.surface-card .el-card__body {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export interface LiveRoom {
|
||||
id: string;
|
||||
platform: number;
|
||||
platformName: string;
|
||||
sourceUrl: string;
|
||||
roomId: string;
|
||||
normalizedUrl: string;
|
||||
title?: string;
|
||||
anchorName?: string;
|
||||
coverUrl?: string;
|
||||
isEnabled: boolean;
|
||||
availabilityStatus: number;
|
||||
lastCheckedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RecordTask {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
recordSessionId: string;
|
||||
segmentIndex: number;
|
||||
liveRoomTitle: string;
|
||||
platform: number;
|
||||
roomId: string;
|
||||
status: number;
|
||||
preferredQuality: string;
|
||||
outputFormat: number;
|
||||
streamUrl?: string;
|
||||
outputFilePath?: string;
|
||||
recorderProcessId?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface RecordResult {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileSizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
danmakuFilePath?: string;
|
||||
danmakuMessageCount: number;
|
||||
finalStatus: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecordSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
platform: number;
|
||||
roomId: string;
|
||||
status: number;
|
||||
preferredQuality: string;
|
||||
outputFormat: number;
|
||||
saveMode: number;
|
||||
activeSegmentIndex: number;
|
||||
segmentCount: number;
|
||||
recorderProcessId?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
totalFileSizeBytes: number;
|
||||
totalDanmakuMessageCount: number;
|
||||
tasks: RecordTask[];
|
||||
}
|
||||
|
||||
export interface SystemLog {
|
||||
id: string;
|
||||
level: number;
|
||||
category: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
liveRoomId?: string;
|
||||
recordSessionId?: string;
|
||||
recordTaskId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecordTaskDetail {
|
||||
task: RecordTask;
|
||||
result?: RecordResult;
|
||||
logs: SystemLog[];
|
||||
}
|
||||
|
||||
export interface DeleteCompletedRecordTasksResult {
|
||||
deletedTaskIds: string[];
|
||||
deletedFilePaths: string[];
|
||||
deletedDanmakuPaths: string[];
|
||||
deletedSessionIds: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface RecordPreviewTicket {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RecordSessionDetail {
|
||||
session: RecordSession;
|
||||
logs: SystemLog[];
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
ffmpegPath: string;
|
||||
outputRoot: string;
|
||||
outputDirectoryTemplate: string;
|
||||
outputFileNameTemplate: string;
|
||||
defaultQuality: string;
|
||||
defaultOutputFormat: number;
|
||||
saveMode: number;
|
||||
recordingTemplate: number;
|
||||
segmentDurationMinutes: number;
|
||||
enableAutoReconnect: boolean;
|
||||
reconnectDelayMaxSeconds: number;
|
||||
readWriteTimeoutMilliseconds: number;
|
||||
enableDanmakuRecording: boolean;
|
||||
danmakuIncludeNonChatEvents: boolean;
|
||||
danmakuMinPollIntervalMilliseconds: number;
|
||||
danmakuRetryDelayMaxSeconds: number;
|
||||
enableBackgroundPolling: boolean;
|
||||
autoStartRecordingOnLive: boolean;
|
||||
pollingIntervalSeconds: number;
|
||||
enableEmailNotification: boolean;
|
||||
emailSmtpHost: string;
|
||||
emailSmtpPort: number;
|
||||
emailUseSsl: boolean;
|
||||
emailUsername: string;
|
||||
emailPassword: string;
|
||||
emailFromAddress: string;
|
||||
emailFromDisplayName: string;
|
||||
emailToAddresses: string;
|
||||
notifyOnLiveStarted: boolean;
|
||||
notifyOnException: boolean;
|
||||
emailLiveStartedSubjectTemplate: string;
|
||||
emailLiveStartedBodyTemplateHtml: string;
|
||||
emailExceptionSubjectTemplate: string;
|
||||
emailExceptionBodyTemplateHtml: string;
|
||||
douyinUserAgent: string;
|
||||
douyinReferer: string;
|
||||
douyinCookie: string;
|
||||
}
|
||||
|
||||
export const availabilityLabelMap: Record<number, string> = {
|
||||
0: "Unknown",
|
||||
1: "Offline",
|
||||
2: "Live"
|
||||
};
|
||||
|
||||
export const taskStatusLabelMap: Record<number, string> = {
|
||||
0: "Pending",
|
||||
1: "Starting",
|
||||
2: "Running",
|
||||
3: "Stopping",
|
||||
4: "Completed",
|
||||
5: "Failed",
|
||||
6: "Stopped"
|
||||
};
|
||||
|
||||
export const sessionStatusLabelMap = taskStatusLabelMap;
|
||||
|
||||
export const logLevelLabelMap: Record<number, string> = {
|
||||
0: "Trace",
|
||||
1: "Info",
|
||||
2: "Warning",
|
||||
3: "Error"
|
||||
};
|
||||
|
||||
export const outputFormatLabelMap: Record<number, string> = {
|
||||
0: "MP4",
|
||||
1: "TS"
|
||||
};
|
||||
|
||||
export const saveModeLabelMap: Record<number, string> = {
|
||||
0: "Single File",
|
||||
1: "Segmented"
|
||||
};
|
||||
|
||||
export const recordingTemplateLabelMap: Record<number, string> = {
|
||||
0: "Stream Copy",
|
||||
1: "Balanced MP4",
|
||||
2: "Archive TS"
|
||||
};
|
||||
@@ -0,0 +1,570 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient from "@/api/client";
|
||||
import type { LiveRoom, RecordTask } from "@/types";
|
||||
import { availabilityLabelMap, outputFormatLabelMap } from "@/types";
|
||||
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const startLoading = ref(false);
|
||||
const togglingRoomId = ref("");
|
||||
const createDialogVisible = ref(false);
|
||||
const recordDialogVisible = ref(false);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deletingRoom = ref(false);
|
||||
const pendingDeleteRoom = ref<LiveRoom | null>(null);
|
||||
const rooms = ref<LiveRoom[]>([]);
|
||||
|
||||
const createForm = reactive({
|
||||
url: "",
|
||||
platformOverride: null as number | null
|
||||
});
|
||||
|
||||
const recordForm = reactive({
|
||||
liveRoomId: "",
|
||||
preferredQuality: "origin",
|
||||
outputFormat: 0
|
||||
});
|
||||
|
||||
const platformOptions = [
|
||||
{ label: "自动识别", value: null },
|
||||
{ label: "抖音", value: 1 },
|
||||
{ label: "Bilibili", value: 2 },
|
||||
{ label: "虎牙", value: 3 }
|
||||
];
|
||||
|
||||
const canSubmit = computed(() => createForm.url.trim().length > 0);
|
||||
const totalRooms = computed(() => rooms.value.length);
|
||||
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
|
||||
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
|
||||
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
createDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.url = "";
|
||||
createForm.platformOverride = null;
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
if (!canSubmit.value) {
|
||||
ElMessage.warning("请输入直播间链接或 roomId");
|
||||
return;
|
||||
}
|
||||
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post<LiveRoom>("/live-rooms", {
|
||||
url: createForm.url,
|
||||
platformOverride: createForm.platformOverride
|
||||
});
|
||||
|
||||
ElMessage.success("直播间已创建");
|
||||
createDialogVisible.value = false;
|
||||
resetCreateForm();
|
||||
await loadRooms();
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRoom(room: LiveRoom) {
|
||||
await apiClient.post(`/live-rooms/${room.id}/refresh`);
|
||||
ElMessage.success("状态已刷新");
|
||||
await loadRooms();
|
||||
}
|
||||
|
||||
function openRecordDialog(room: LiveRoom) {
|
||||
if (!room.isEnabled) {
|
||||
ElMessage.warning("该直播间当前已停用,请先启用");
|
||||
return;
|
||||
}
|
||||
|
||||
recordForm.liveRoomId = room.id;
|
||||
recordForm.preferredQuality = "origin";
|
||||
recordForm.outputFormat = 0;
|
||||
recordDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
startLoading.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post<RecordTask>("/record-tasks/start", {
|
||||
liveRoomId: recordForm.liveRoomId,
|
||||
preferredQuality: recordForm.preferredQuality,
|
||||
outputFormat: recordForm.outputFormat
|
||||
});
|
||||
|
||||
ElMessage.success("录制任务已启动");
|
||||
recordDialogVisible.value = false;
|
||||
} finally {
|
||||
startLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteDialog(room: LiveRoom) {
|
||||
pendingDeleteRoom.value = room;
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deletingRoom.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDialogVisible.value = false;
|
||||
pendingDeleteRoom.value = null;
|
||||
}
|
||||
|
||||
async function removeRoom() {
|
||||
if (!pendingDeleteRoom.value) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
deletingRoom.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/live-rooms/${pendingDeleteRoom.value.id}`);
|
||||
ElMessage.success("直播间已删除");
|
||||
deleteDialogVisible.value = false;
|
||||
pendingDeleteRoom.value = null;
|
||||
await loadRooms();
|
||||
} finally {
|
||||
deletingRoom.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setRoomEnabled(room: LiveRoom, isEnabled: boolean) {
|
||||
togglingRoomId.value = room.id;
|
||||
|
||||
try {
|
||||
await apiClient.put<LiveRoom>(`/live-rooms/${room.id}/enabled`, { isEnabled });
|
||||
ElMessage.success(isEnabled ? "直播间已启用" : "直播间已停用");
|
||||
await loadRooms();
|
||||
} finally {
|
||||
togglingRoomId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function availabilityTagType(status: number) {
|
||||
if (status === 2) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === 1) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
onMounted(loadRooms);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">直播间管理</h1>
|
||||
<p class="page-subtitle">
|
||||
直播间长期保留在系统中。临时不想录制时直接停用即可,停用后后台巡检不会再为该直播间自动创建任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-space wrap class="header-actions">
|
||||
<el-button @click="loadRooms">刷新列表</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">直播间总数</div>
|
||||
<div class="stat-card__value">{{ totalRooms }}</div>
|
||||
<div class="stat-card__hint">当前已接入系统的直播间数量</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">启用中</div>
|
||||
<div class="stat-card__value">{{ enabledRooms }}</div>
|
||||
<div class="stat-card__hint">参与后台巡检与自动录制</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">已停用</div>
|
||||
<div class="stat-card__value">{{ disabledRooms }}</div>
|
||||
<div class="stat-card__hint">保留解析结果,但不再自动录制</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">当前开播</div>
|
||||
<div class="stat-card__value">{{ liveRooms }}</div>
|
||||
<div class="stat-card__hint">基于最近一次状态检测</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">直播间列表</h3>
|
||||
<p class="section-subtitle">支持手动刷新、直接启动录制、启用/停用自动录制,以及按需删除直播间。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="rooms" v-loading="loading" height="700" class="premium-table">
|
||||
<el-table-column label="封面" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.coverUrl"
|
||||
:src="row.coverUrl"
|
||||
fit="cover"
|
||||
class="cover-thumb"
|
||||
/>
|
||||
<el-tag v-else effect="plain">N/A</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="直播间" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
<div class="monospace cell-mono">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="平台" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ row.platformName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="直播状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="availabilityTagType(row.availabilityStatus)">
|
||||
{{ availabilityLabelMap[row.availabilityStatus] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="录制开关" width="128">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.isEnabled"
|
||||
:loading="togglingRoomId === row.id"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="停"
|
||||
@change="setRoomEnabled(row, Boolean($event))"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.lastCheckedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-space wrap>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
|
||||
开始录制
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="createDialogVisible"
|
||||
class="form-dialog"
|
||||
title="新增直播间"
|
||||
width="540px"
|
||||
@closed="resetCreateForm"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="直播间链接 / roomId">
|
||||
<el-input
|
||||
v-model="createForm.url"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="粘贴抖音分享链接、直播间地址或 roomId"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="平台覆盖">
|
||||
<el-select v-model="createForm.platformOverride" clearable placeholder="自动识别">
|
||||
<el-option
|
||||
v-for="option in platformOptions"
|
||||
:key="String(option.value)"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer dialog-footer--end">
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="createRoom">创建直播间</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="recordDialogVisible"
|
||||
class="form-dialog"
|
||||
title="启动录制任务"
|
||||
width="420px"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="清晰度">
|
||||
<el-select v-model="recordForm.preferredQuality">
|
||||
<el-option label="最高质量 origin" value="origin" />
|
||||
<el-option label="FULL_HD" value="FULL_HD" />
|
||||
<el-option label="HD" value="HD" />
|
||||
<el-option label="SD" value="SD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="输出格式">
|
||||
<el-select v-model="recordForm.outputFormat">
|
||||
<el-option
|
||||
v-for="(label, value) in outputFormatLabelMap"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer dialog-footer--end">
|
||||
<el-button @click="recordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="startLoading" @click="startRecording">启动</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="deleteDialogVisible"
|
||||
class="form-dialog delete-room-dialog"
|
||||
width="clamp(480px, 46vw, 560px)"
|
||||
:show-close="!deletingRoom"
|
||||
:close-on-click-modal="!deletingRoom"
|
||||
:close-on-press-escape="!deletingRoom"
|
||||
destroy-on-close
|
||||
@closed="pendingDeleteRoom = null"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-dialog__header">
|
||||
<span class="delete-dialog__eyebrow">删除确认</span>
|
||||
<h3 class="delete-dialog__title">删除直播间</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-dialog__body">
|
||||
<div class="delete-dialog__lead">
|
||||
确认删除直播间
|
||||
<span class="monospace">{{ pendingDeleteRoom?.roomId || "-" }}</span>
|
||||
吗?删除后,该直播间的关联记录会按当前后端规则自动清理。
|
||||
</div>
|
||||
|
||||
<div class="delete-dialog__note">
|
||||
<div class="delete-dialog__note-title">影响范围</div>
|
||||
<p>只删除当前直播间本身。满足 stopped orphan 规则且没有有效视频文件的空会话会被后端一并清理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer dialog-footer--end">
|
||||
<el-button :disabled="deletingRoom" @click="closeDeleteDialog">取消</el-button>
|
||||
<el-button type="danger" :loading="deletingRoom" @click="removeRoom">确认删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.table-card :deep(.el-card__body) {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
|
||||
}
|
||||
|
||||
.cover-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 22px rgba(31, 24, 18, 0.08);
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.cell-subtitle {
|
||||
margin-top: 6px;
|
||||
color: #6f675f;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
margin-top: 8px;
|
||||
color: #90887d;
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.delete-dialog__header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-dialog__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6e7c88;
|
||||
}
|
||||
|
||||
.delete-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #15202b;
|
||||
}
|
||||
|
||||
.delete-dialog__body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.delete-dialog__lead {
|
||||
color: #4f5d69;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.delete-dialog__note {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(183, 80, 70, 0.14);
|
||||
background: rgba(183, 80, 70, 0.06);
|
||||
}
|
||||
|
||||
.delete-dialog__note-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #8f443a;
|
||||
}
|
||||
|
||||
.delete-dialog__note p {
|
||||
margin: 0;
|
||||
color: #6b5d57;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-footer--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__header) {
|
||||
padding: 24px 24px 8px;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__body) {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__footer) {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__header) {
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__body) {
|
||||
padding: 18px 24px 10px;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__footer) {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-row {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-footer :deep(.el-button) {
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Lock, User } from "@element-plus/icons-vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
password: "Admin@123"
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await authStore.login(form.username, form.password);
|
||||
ElMessage.success("登录成功");
|
||||
await router.push({ name: "live-rooms" });
|
||||
} catch (error) {
|
||||
ElMessage.error("登录失败,请检查账号密码");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-copy">
|
||||
<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>ffmpeg 录制编排</span>
|
||||
<span>邮件通知</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="login-card surface-card" shadow="never">
|
||||
<h3 class="section-title">登录</h3>
|
||||
<p class="section-subtitle">进入控制台后可管理直播间、录制任务、日志和系统设置。</p>
|
||||
|
||||
<el-form label-position="top" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-card__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
padding: 56px 64px;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(62, 95, 124, 0.08), transparent 28%),
|
||||
radial-gradient(circle at 84% 18%, rgba(255, 255, 255, 0.72), transparent 22%),
|
||||
radial-gradient(circle at 65% 82%, rgba(62, 95, 124, 0.05), transparent 24%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-copy,
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-copy__eyebrow {
|
||||
display: inline-flex;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(62, 95, 124, 0.08);
|
||||
color: #3e5f7c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
max-width: 720px;
|
||||
margin: 26px 0 16px;
|
||||
font-size: clamp(38px, 5vw, 64px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.05em;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.login-copy p {
|
||||
max-width: 600px;
|
||||
color: #676158;
|
||||
line-height: 1.85;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-copy__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
max-width: 520px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.login-copy__list span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(82, 74, 63, 0.08);
|
||||
color: #4f4a42;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 12px 24px rgba(31, 24, 18, 0.04);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-copy__list span:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(62, 95, 124, 0.18);
|
||||
box-shadow: 0 18px 32px rgba(31, 24, 18, 0.08);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.login-card__submit {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.login-page {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.login-copy__list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import apiClient from "@/api/client";
|
||||
import type { SystemLog } from "@/types";
|
||||
import { logLevelLabelMap } from "@/types";
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<SystemLog[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
liveRoomId: "",
|
||||
recordTaskId: "",
|
||||
take: 200
|
||||
});
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SystemLog[]>("/logs", {
|
||||
params: {
|
||||
liveRoomId: filters.liveRoomId || undefined,
|
||||
recordTaskId: filters.recordTaskId || undefined,
|
||||
take: filters.take
|
||||
}
|
||||
});
|
||||
|
||||
logs.value = data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function levelTagType(level: number) {
|
||||
if (level === 3) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (level === 2) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">系统日志</h1>
|
||||
<p class="page-subtitle">支持按直播间或任务过滤日志,便于定位解析失败、巡检异常和 ffmpeg 输出问题。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card filter-card" shadow="never">
|
||||
<h3 class="section-title">日志过滤</h3>
|
||||
<p class="section-subtitle">可选传入直播间 ID 或录制任务 ID,数量默认取最近 200 条。</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="任务 ID">
|
||||
<el-input v-model="filters.recordTaskId" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数量">
|
||||
<el-input-number v-model="filters.take" :min="1" :max="500" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadLogs">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<h3 class="section-title">日志列表</h3>
|
||||
<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>
|
||||
|
||||
<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-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body),
|
||||
.table-card :deep(.el-card__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item:last-child) {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
font-size: 12px;
|
||||
color: #6d665b;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item:last-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.filter-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import apiClient from "@/api/client";
|
||||
import type { RecordPreviewTicket, RecordTaskDetail } from "@/types";
|
||||
import {
|
||||
logLevelLabelMap,
|
||||
outputFormatLabelMap,
|
||||
sessionStatusLabelMap,
|
||||
taskStatusLabelMap
|
||||
} from "@/types";
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const detail = ref<RecordTaskDetail | null>(null);
|
||||
const previewUrl = ref("");
|
||||
const previewExpiresAt = ref("");
|
||||
const previewMessage = ref("");
|
||||
|
||||
async function loadDetailAndPreview() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<RecordTaskDetail>(`/record-tasks/${props.id}`);
|
||||
detail.value = data;
|
||||
await loadPreview();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
previewUrl.value = "";
|
||||
previewExpiresAt.value = "";
|
||||
previewMessage.value = "";
|
||||
|
||||
if (!detail.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.value.task.status !== 4) {
|
||||
previewMessage.value = "录制未完成时不提供视频预览。";
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.value.task.outputFormat !== 0) {
|
||||
previewMessage.value = "当前仅支持 MP4 分片预览。";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detail.value.result?.filePath) {
|
||||
previewMessage.value = "当前任务缺少可预览的视频文件。";
|
||||
return;
|
||||
}
|
||||
|
||||
previewLoading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordPreviewTicket>(`/record-tasks/${props.id}/preview-ticket`);
|
||||
previewUrl.value = data.url;
|
||||
previewExpiresAt.value = data.expiresAt;
|
||||
} catch (error: any) {
|
||||
previewMessage.value = error?.response?.data?.message ?? "暂时无法预览该录制文件。";
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusTagType(status: number) {
|
||||
if (status === 2) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === 5) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (status === 4 || status === 6) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function logTagType(level: number) {
|
||||
if (level === 3) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (level === 2) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number) {
|
||||
if (typeof bytes !== "number") {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
watch(() => props.id, loadDetailAndPreview);
|
||||
onMounted(loadDetailAndPreview);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">分片任务详情</h1>
|
||||
<p class="page-subtitle">
|
||||
查看当前分片的状态、视频结果、弹幕 XML 和关联日志。MP4 分片完成后可直接在此预览。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-space class="header-actions">
|
||||
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
|
||||
<el-button @click="loadDetailAndPreview">刷新</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading && !detail" animated :rows="8" />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<el-row :gutter="18">
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card detail-card" shadow="never">
|
||||
<h3 class="section-title">任务信息</h3>
|
||||
<p class="section-subtitle">包含所属会话、分片序号、输出路径和任务错误信息。</p>
|
||||
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="任务状态">
|
||||
<el-tag :type="statusTagType(detail.task.status)">
|
||||
{{ taskStatusLabelMap[detail.task.status] }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播间">{{ detail.task.liveRoomTitle }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会话 ID">
|
||||
<span class="monospace">{{ detail.task.recordSessionId }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分片序号">
|
||||
<span class="monospace">#{{ detail.task.segmentIndex }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="RoomId">{{ detail.task.roomId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="清晰度">{{ detail.task.preferredQuality }}</el-descriptions-item>
|
||||
<el-descriptions-item label="输出格式">
|
||||
{{ outputFormatLabelMap[detail.task.outputFormat] }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="输出文件">
|
||||
<span class="monospace">{{ detail.task.outputFilePath || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="错误信息">
|
||||
{{ detail.task.errorMessage || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">
|
||||
{{ formatDate(detail.task.startedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">
|
||||
{{ formatDate(detail.task.endedAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card detail-card" shadow="never">
|
||||
<h3 class="section-title">录制结果</h3>
|
||||
<p class="section-subtitle">视频文件与弹幕文件分离保存,当前页展示落盘路径和统计信息。</p>
|
||||
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="视频文件">
|
||||
<span class="monospace">{{ detail.result?.filePath || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="视频大小">
|
||||
{{ formatFileSize(detail.result?.fileSizeBytes) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="弹幕 XML">
|
||||
<span class="monospace">{{ detail.result?.danmakuFilePath || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="弹幕事件">
|
||||
{{ detail.result?.danmakuMessageCount ?? 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="录制时长">
|
||||
{{ detail.result?.durationSeconds ?? "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最终状态">
|
||||
{{ detail.result ? sessionStatusLabelMap[detail.result.finalStatus] : "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDate(detail.result?.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="surface-card preview-card" shadow="never">
|
||||
<div class="preview-header">
|
||||
<div>
|
||||
<h3 class="section-title">视频预览</h3>
|
||||
<p class="section-subtitle">
|
||||
仅支持已完成的 MP4 分片。预览票据会短期失效,过期后刷新即可重新获取。
|
||||
</p>
|
||||
</div>
|
||||
<div class="preview-meta" v-if="previewExpiresAt">
|
||||
票据有效至 {{ formatDate(previewExpiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<video
|
||||
v-else-if="previewUrl"
|
||||
:key="previewUrl"
|
||||
class="preview-player"
|
||||
:src="previewUrl"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<div v-else class="preview-empty">
|
||||
{{ previewMessage || "当前没有可预览的视频。" }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card logs-card" shadow="never">
|
||||
<h3 class="section-title">关联日志</h3>
|
||||
<p class="section-subtitle">日志已带上会话与任务关联,可看到 ffmpeg、巡检和弹幕采集的上下文。</p>
|
||||
|
||||
<el-table :data="detail.logs" height="420" class="premium-table">
|
||||
<el-table-column label="级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="logTagType(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="220" prop="message" />
|
||||
<el-table-column label="详情" min-width="300">
|
||||
<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-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.detail-card :deep(.el-card__body),
|
||||
.preview-card :deep(.el-card__body),
|
||||
.logs-card :deep(.el-card__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
color: #7c7367;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-player {
|
||||
width: 100%;
|
||||
max-height: 560px;
|
||||
border-radius: 12px;
|
||||
background: #111;
|
||||
box-shadow: 0 18px 36px rgba(17, 17, 17, 0.18);
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(117, 104, 86, 0.2);
|
||||
color: #766d61;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(246, 243, 238, 0.78));
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
font-size: 12px;
|
||||
color: #6d665b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,738 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
import apiClient from "@/api/client";
|
||||
import type { DeleteCompletedRecordTasksResult, RecordSession, RecordTask } from "@/types";
|
||||
import {
|
||||
outputFormatLabelMap,
|
||||
saveModeLabelMap,
|
||||
sessionStatusLabelMap,
|
||||
taskStatusLabelMap
|
||||
} from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const stoppingSessionId = ref<string | null>(null);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
|
||||
const deleteDialogTaskIds = ref<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
const sessions = ref<RecordSession[]>([]);
|
||||
const activeSessionPanels = ref<string[]>([]);
|
||||
const selectedTaskMap = ref<Record<string, RecordTask>>({});
|
||||
|
||||
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
|
||||
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
|
||||
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
|
||||
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 deleteDialogLead = computed(() =>
|
||||
deleteDialogMode.value === "sessions"
|
||||
? `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`
|
||||
: `将删除 ${deleteDialogTaskCount.value} 个已结束分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
|
||||
);
|
||||
const deleteDialogNote = computed(() =>
|
||||
deleteDialogMode.value === "sessions"
|
||||
? "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。"
|
||||
: "“记录 + 文件”会尝试删除视频文件和对应弹幕 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;
|
||||
}
|
||||
|
||||
function isDeletableTask(task: RecordTask) {
|
||||
return !isActiveStatus(task.status);
|
||||
}
|
||||
|
||||
function selectableTask(row: RecordTask) {
|
||||
return isDeletableTask(row);
|
||||
}
|
||||
|
||||
function handleSelectionChange(session: RecordSession, selection: RecordTask[]) {
|
||||
const nextMap = { ...selectedTaskMap.value };
|
||||
session.tasks.forEach((task) => {
|
||||
delete nextMap[task.id];
|
||||
});
|
||||
|
||||
selection
|
||||
.filter((task) => isDeletableTask(task))
|
||||
.forEach((task) => {
|
||||
nextMap[task.id] = task;
|
||||
});
|
||||
|
||||
selectedTaskMap.value = nextMap;
|
||||
}
|
||||
|
||||
async function stopSession(session: RecordSession) {
|
||||
stoppingSessionId.value = session.id;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/record-sessions/${session.id}/stop`);
|
||||
ElMessage.success("已发送停止会话请求");
|
||||
await loadSessions();
|
||||
} finally {
|
||||
stoppingSessionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(task: RecordTask) {
|
||||
router.push({ name: "record-task-detail", params: { id: task.id } });
|
||||
}
|
||||
|
||||
function openDeleteTaskDialog(taskIds: string[]) {
|
||||
if (taskIds.length === 0) {
|
||||
ElMessage.warning("请选择可删除的分片任务");
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDialogMode.value = "tasks";
|
||||
deleteDialogTaskIds.value = [...taskIds];
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeleteSessionDialog(sessionIds: string[]) {
|
||||
if (sessionIds.length === 0) {
|
||||
ElMessage.warning("请选择要删除的录制会话");
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDialogMode.value = "sessions";
|
||||
deleteDialogSessionIds.value = [...sessionIds];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
}
|
||||
|
||||
function resetDeleteDialogState() {
|
||||
deleteDialogMode.value = "tasks";
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogSessionIds.value = [];
|
||||
}
|
||||
|
||||
function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
|
||||
const deletedTaskIds = new Set(data.deletedTaskIds);
|
||||
const deletedSessionIds = new Set(data.deletedSessionIds);
|
||||
|
||||
sessions.value = sessions.value
|
||||
.filter((session) => !deletedSessionIds.has(session.id))
|
||||
.map((session) => ({
|
||||
...session,
|
||||
tasks: session.tasks.filter((task) => !deletedTaskIds.has(task.id))
|
||||
}));
|
||||
|
||||
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
|
||||
selectedTaskMap.value = Object.fromEntries(
|
||||
Object.entries(selectedTaskMap.value).filter(([taskId]) => !deletedTaskIds.has(taskId))
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDelete(deleteFiles: boolean) {
|
||||
const deletingSessions = deleteDialogMode.value === "sessions";
|
||||
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
|
||||
if (selectedIds.length === 0) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
const { data } = deletingSessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
|
||||
sessionIds: deleteDialogSessionIds.value,
|
||||
deleteFiles
|
||||
})
|
||||
: await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
|
||||
ElMessage.success(
|
||||
deletingSessions
|
||||
? `已删除 ${data.deletedSessionIds.length} 个录制会话`
|
||||
: `已删除 ${data.deletedTaskIds.length} 个分片任务`
|
||||
);
|
||||
|
||||
applyDeleteResult(data);
|
||||
|
||||
if (data.warnings.length > 0) {
|
||||
ElNotification({
|
||||
title: "删除已完成,但有提示",
|
||||
message: data.warnings.join("\n"),
|
||||
type: "warning",
|
||||
duration: 8000
|
||||
});
|
||||
}
|
||||
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
await loadSessions();
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionTagType(status: number) {
|
||||
if (status === 2) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === 5) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (status === 4 || status === 6) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function taskTagType(status: number) {
|
||||
return sessionTagType(status);
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
return typeof seconds === "number" ? `${seconds.toFixed(0)}s` : "-";
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number) {
|
||||
if (typeof bytes !== "number" || Number.isNaN(bytes)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
onMounted(loadSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<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
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="selectedTasks.length === 0"
|
||||
:loading="deleting"
|
||||
@click="openDeleteTaskDialog(selectedTasks.map((item) => item.id))"
|
||||
>
|
||||
批量删除已结束任务
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制会话</div>
|
||||
<div class="stat-card__value">{{ sessions.length }}</div>
|
||||
<div class="stat-card__hint">按整场直播聚合分片任务</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">分片任务</div>
|
||||
<div class="stat-card__value">{{ totalTaskCount }}</div>
|
||||
<div class="stat-card__hint">包含单文件模式下的唯一任务</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">弹幕事件</div>
|
||||
<div class="stat-card__value">{{ totalDanmakuCount }}</div>
|
||||
<div class="stat-card__hint">累计写入 XML 的事件总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card sessions-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">会话列表</h3>
|
||||
<p class="section-subtitle">
|
||||
会话层负责整场直播,分片层负责单个视频文件。停止入口作用于整个会话,详情入口作用于单个分片。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
|
||||
|
||||
<el-collapse v-else v-model="activeSessionPanels" class="session-collapse">
|
||||
<el-collapse-item
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
:name="session.id"
|
||||
class="session-panel"
|
||||
>
|
||||
<template #title>
|
||||
<div class="session-title">
|
||||
<div class="session-title__main">
|
||||
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
|
||||
<div class="session-title__meta">
|
||||
<span class="monospace">{{ session.roomId }}</span>
|
||||
<span>会话 {{ session.id.slice(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-title__stats">
|
||||
<el-tag :type="sessionTagType(session.status)">
|
||||
{{ sessionStatusLabelMap[session.status] }}
|
||||
</el-tag>
|
||||
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span>分片 {{ session.segmentCount }}</span>
|
||||
<span>{{ formatFileSize(session.totalFileSizeBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="session-body">
|
||||
<div class="session-summary">
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">开始时间</span>
|
||||
<span>{{ formatDate(session.startedAt || session.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">结束时间</span>
|
||||
<span>{{ formatDate(session.endedAt) }}</span>
|
||||
</div>
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">活跃分片</span>
|
||||
<span>{{ session.activeSegmentIndex || "-" }}</span>
|
||||
</div>
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">弹幕事件</span>
|
||||
<span>{{ session.totalDanmakuMessageCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<el-button
|
||||
v-if="isActiveStatus(session.status)"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="stoppingSessionId === session.id"
|
||||
@click.stop="stopSession(session)"
|
||||
>
|
||||
停止会话
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="deleting"
|
||||
@click.stop="openDeleteSessionDialog([session.id])"
|
||||
>
|
||||
删除会话
|
||||
</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" />
|
||||
|
||||
<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="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="开始时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.startedAt || row.createdAt) }}
|
||||
</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>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="deleteDialogVisible"
|
||||
class="task-delete-dialog"
|
||||
width="clamp(480px, 48vw, 560px)"
|
||||
:show-close="!deleting"
|
||||
:close-on-click-modal="!deleting"
|
||||
:close-on-press-escape="!deleting"
|
||||
destroy-on-close
|
||||
@closed="resetDeleteDialogState"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-dialog__header">
|
||||
<span class="delete-dialog__eyebrow">{{ deleteDialogEyebrow }}</span>
|
||||
<h3 class="delete-dialog__title">{{ deleteDialogTitle }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-dialog__body">
|
||||
<div class="delete-dialog__lead">
|
||||
{{ deleteDialogLead }}
|
||||
</div>
|
||||
|
||||
<div class="delete-dialog__note">
|
||||
<div class="delete-dialog__note-title">风险说明</div>
|
||||
<p>{{ deleteDialogNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer dialog-footer--end">
|
||||
<el-button :disabled="deleting" @click="closeDeleteDialog">取消</el-button>
|
||||
<el-button plain :loading="deleting" @click="confirmDelete(false)">仅删记录</el-button>
|
||||
<el-button type="danger" :loading="deleting" @click="confirmDelete(true)">记录 + 文件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.sessions-card :deep(.el-card__body) {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
|
||||
}
|
||||
|
||||
.session-collapse {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
border: 1px solid rgba(95, 109, 124, 0.12);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.session-panel :deep(.el-collapse-item__header) {
|
||||
height: auto;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(95, 109, 124, 0.08);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.session-panel :deep(.el-collapse-item__wrap) {
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.session-panel :deep(.el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-title__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-title__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #17202b;
|
||||
}
|
||||
|
||||
.session-title__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: #6d7781;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-title__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
color: #61707d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.session-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-summary__item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(243, 246, 248, 0.9);
|
||||
border: 1px solid rgba(95, 109, 124, 0.08);
|
||||
color: #243342;
|
||||
}
|
||||
|
||||
.session-summary__label {
|
||||
color: #75808a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nested-table {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-size: 12px;
|
||||
color: #65717c;
|
||||
}
|
||||
|
||||
.delete-dialog__header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-dialog__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6e7c88;
|
||||
}
|
||||
|
||||
.delete-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #15202b;
|
||||
}
|
||||
|
||||
.delete-dialog__body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.delete-dialog__lead {
|
||||
color: #4f5d69;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.delete-dialog__note {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(183, 80, 70, 0.14);
|
||||
background: rgba(183, 80, 70, 0.06);
|
||||
}
|
||||
|
||||
.delete-dialog__note-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #8f443a;
|
||||
}
|
||||
|
||||
.delete-dialog__note p {
|
||||
margin: 0;
|
||||
color: #6b5d57;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-footer--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.task-delete-dialog) {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
:deep(.task-delete-dialog .el-dialog__header) {
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
:deep(.task-delete-dialog .el-dialog__body) {
|
||||
padding: 18px 24px 10px;
|
||||
}
|
||||
|
||||
:deep(.task-delete-dialog .el-dialog__footer) {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.session-title {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.session-title__stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.session-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.session-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-wrap: wrap;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.dialog-footer :deep(.el-button) {
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,657 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient 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 form = reactive<SystemSettings>({
|
||||
ffmpegPath: "ffmpeg",
|
||||
outputRoot: "records",
|
||||
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
|
||||
outputFileNameTemplate: "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
|
||||
defaultQuality: "origin",
|
||||
defaultOutputFormat: 0,
|
||||
saveMode: 0,
|
||||
recordingTemplate: 0,
|
||||
segmentDurationMinutes: 30,
|
||||
enableAutoReconnect: true,
|
||||
reconnectDelayMaxSeconds: 5,
|
||||
readWriteTimeoutMilliseconds: 15000000,
|
||||
enableDanmakuRecording: true,
|
||||
danmakuIncludeNonChatEvents: true,
|
||||
danmakuMinPollIntervalMilliseconds: 1000,
|
||||
danmakuRetryDelayMaxSeconds: 15,
|
||||
enableBackgroundPolling: true,
|
||||
autoStartRecordingOnLive: true,
|
||||
pollingIntervalSeconds: 60,
|
||||
enableEmailNotification: false,
|
||||
emailSmtpHost: "",
|
||||
emailSmtpPort: 587,
|
||||
emailUseSsl: true,
|
||||
emailUsername: "",
|
||||
emailPassword: "",
|
||||
emailFromAddress: "",
|
||||
emailFromDisplayName: "Live Recorder",
|
||||
emailToAddresses: "",
|
||||
notifyOnLiveStarted: true,
|
||||
notifyOnException: true,
|
||||
emailLiveStartedSubjectTemplate: "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
|
||||
emailLiveStartedBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
|
||||
<p>The monitored live room is now online.</p>
|
||||
<ul>
|
||||
<li><strong>Platform:</strong> {{platform}}</li>
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Title:</strong> {{title}}</li>
|
||||
<li><strong>Anchor:</strong> {{anchor}}</li>
|
||||
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
</div>`,
|
||||
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
|
||||
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
|
||||
<p>{{summary}}</p>
|
||||
<ul>
|
||||
<li><strong>Source:</strong> {{source}}</li>
|
||||
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
|
||||
<li><strong>Room ID:</strong> {{roomId}}</li>
|
||||
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
|
||||
<li><strong>Task Status:</strong> {{taskStatus}}</li>
|
||||
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
|
||||
</div>`,
|
||||
douyinUserAgent: "",
|
||||
douyinReferer: "https://live.douyin.com/",
|
||||
douyinCookie: ""
|
||||
});
|
||||
|
||||
const outputTemplateTokens = [
|
||||
"{platform}",
|
||||
"{roomId}",
|
||||
"{anchor}",
|
||||
"{title}",
|
||||
"{yyyy}",
|
||||
"{MM}",
|
||||
"{dd}",
|
||||
"{HHmmss}",
|
||||
"{date}",
|
||||
"{time}",
|
||||
"{fileStem}",
|
||||
"{segmentSuffix}"
|
||||
];
|
||||
|
||||
const emailTemplateTokens = [
|
||||
"{{appName}}",
|
||||
"{{platform}}",
|
||||
"{{roomId}}",
|
||||
"{{title}}",
|
||||
"{{anchor}}",
|
||||
"{{sourceUrl}}",
|
||||
"{{detectedAtUtc}}",
|
||||
"{{source}}",
|
||||
"{{summary}}",
|
||||
"{{detail}}",
|
||||
"{{liveRoomId}}",
|
||||
"{{recordTaskId}}",
|
||||
"{{taskStatus}}",
|
||||
"{{occurredAtUtc}}"
|
||||
];
|
||||
|
||||
const canSendTestEmail = computed(() =>
|
||||
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
|
||||
);
|
||||
|
||||
const segmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
const nestedSegmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789/221530_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SystemSettings>("/settings");
|
||||
Object.assign(form, data);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.put<SystemSettings>("/settings", form);
|
||||
Object.assign(form, data);
|
||||
ElMessage.success("设置已保存");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail() {
|
||||
testingEmail.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post("/settings/test-email", {
|
||||
emailSmtpHost: form.emailSmtpHost,
|
||||
emailSmtpPort: form.emailSmtpPort,
|
||||
emailUseSsl: form.emailUseSsl,
|
||||
emailUsername: form.emailUsername,
|
||||
emailPassword: form.emailPassword,
|
||||
emailFromAddress: form.emailFromAddress,
|
||||
emailFromDisplayName: form.emailFromDisplayName,
|
||||
emailToAddresses: form.emailToAddresses,
|
||||
emailLiveStartedSubjectTemplate: form.emailLiveStartedSubjectTemplate,
|
||||
emailLiveStartedBodyTemplateHtml: form.emailLiveStartedBodyTemplateHtml,
|
||||
emailExceptionSubjectTemplate: form.emailExceptionSubjectTemplate,
|
||||
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
|
||||
});
|
||||
|
||||
ElMessage.success("测试邮件已发送,请检查收件箱");
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message ?? error?.message ?? "测试邮件发送失败";
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
testingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">
|
||||
配置录制参数、路径模板、弹幕录制、后台巡检和邮件通知。测试邮件会直接使用当前表单值,不要求先保存。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid" v-loading="loading">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">录制基础</h3>
|
||||
<p class="section-subtitle">默认清晰度、输出格式、分段时长、ffmpeg 模板和网络容错参数统一在这里维护。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="ffmpeg 路径">
|
||||
<el-input v-model="form.ffmpegPath" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="录制输出根目录">
|
||||
<el-input v-model="form.outputRoot" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="默认清晰度">
|
||||
<el-input v-model="form.defaultQuality" placeholder="origin / FULL_HD / HD / SD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="直播保存格式">
|
||||
<el-select v-model="form.defaultOutputFormat">
|
||||
<el-option
|
||||
v-for="(label, value) in outputFormatLabelMap"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="保存模式">
|
||||
<el-select v-model="form.saveMode">
|
||||
<el-option
|
||||
v-for="(label, value) in saveModeLabelMap"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="录制模板">
|
||||
<el-select v-model="form.recordingTemplate">
|
||||
<el-option
|
||||
v-for="(label, value) in recordingTemplateLabelMap"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分段时长(分钟)">
|
||||
<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.reconnectDelayMaxSeconds" :min="1" :max="300" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="读写超时(毫秒)">
|
||||
<el-input-number v-model="form.readWriteTimeoutMilliseconds" :min="1000" :max="60000000" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="启用 ffmpeg 自动重连">
|
||||
<el-switch v-model="form.enableAutoReconnect" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">弹幕录制</h3>
|
||||
<p class="section-subtitle">控制是否并行录制弹幕 XML、是否保留非聊天事件,以及轮询和失败重连节奏。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="启用弹幕录制">
|
||||
<el-switch v-model="form.enableDanmakuRecording" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="记录非聊天事件">
|
||||
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最小轮询间隔(毫秒)">
|
||||
<el-input-number
|
||||
v-model="form.danmakuMinPollIntervalMilliseconds"
|
||||
:min="100"
|
||||
:max="60000"
|
||||
:disabled="!form.enableDanmakuRecording"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大重试退避(秒)">
|
||||
<el-input-number
|
||||
v-model="form.danmakuRetryDelayMaxSeconds"
|
||||
:min="1"
|
||||
:max="300"
|
||||
:disabled="!form.enableDanmakuRecording"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="helper-panel">
|
||||
XML 文件会继续与视频同 stem 保存,不额外新增单独的弹幕路径模板。关闭“记录非聊天事件”后,只会保留聊天 <code><d></code> 节点。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">路径模板</h3>
|
||||
<p class="section-subtitle">目录层级和文件名都支持变量。分段模式不再强制多建一层目录,是否分出子目录完全由模板决定。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="目录模板">
|
||||
<el-input
|
||||
v-model="form.outputDirectoryTemplate"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="{platform}/{yyyy}/{MM}/{dd}/{anchor}"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="文件名模板">
|
||||
<el-input
|
||||
v-model="form.outputFileNameTemplate"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="example-box">
|
||||
<div class="example-box__label">示例输出</div>
|
||||
<div class="monospace example-box__value">默认分段:{{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">目录模板含 {fileStem}:{{ nestedSegmentedExamplePath }}</div>
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
`{fileStem}` 只用于目录模板,表示按文件模板渲染后的基础文件名;`{segmentSuffix}` 只用于文件模板,分段模式下会渲染为 `_00001` 这类后缀,单文件模式下为空。
|
||||
</div>
|
||||
|
||||
<div class="token-list">
|
||||
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">后台巡检</h3>
|
||||
<p class="section-subtitle">控制是否定时检测直播状态,并在开播后自动创建录制任务。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="启用后台巡检">
|
||||
<el-switch v-model="form.enableBackgroundPolling" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="检测到开播后自动录制">
|
||||
<el-switch v-model="form.autoStartRecordingOnLive" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="巡检间隔(秒)">
|
||||
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</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">支持 SMTP、开播提醒和异常提醒 HTML 模板。测试发送会直接使用当前表单中的 SMTP 与模板。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="form.enableEmailNotification" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="启用 SSL">
|
||||
<el-switch v-model="form.emailUseSsl" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="开播提醒">
|
||||
<el-switch v-model="form.notifyOnLiveStarted" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="异常提醒">
|
||||
<el-switch v-model="form.notifyOnException" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP Host">
|
||||
<el-input v-model="form.emailSmtpHost" placeholder="smtp.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP Port">
|
||||
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="发件人名称">
|
||||
<el-input v-model="form.emailFromDisplayName" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP 用户名">
|
||||
<el-input v-model="form.emailUsername" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP 密码">
|
||||
<el-input v-model="form.emailPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="发件地址">
|
||||
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<el-form-item label="收件人列表">
|
||||
<el-input
|
||||
v-model="form.emailToAddresses"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="多个地址可用逗号、分号或换行分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">开播提醒模板</h4>
|
||||
<p class="template-section__subtitle">主题模板输出纯文本,正文模板支持 HTML。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">异常提醒模板</h4>
|
||||
<p class="template-section__subtitle">异常邮件会把错误摘要和任务上下文按变量注入到 HTML 中。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailExceptionSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="template-help">
|
||||
<div class="template-help__label">可用占位符</div>
|
||||
<div class="token-list">
|
||||
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-actions">
|
||||
<div class="helper-text">测试发送不会自动保存配置,邮件中会同时渲染“开播提醒示例”和“异常提醒示例”。</div>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试发送</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Douyin 请求头</h3>
|
||||
<p class="section-subtitle">这些伪装参数会同时用于 Douyin API 请求和 ffmpeg 输入拉流,适合处理直播流读取校验问题。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="Douyin User-Agent">
|
||||
<el-input v-model="form.douyinUserAgent" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Douyin Referer">
|
||||
<el-input v-model="form.douyinReferer" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Douyin Cookie">
|
||||
<el-input
|
||||
v-model="form.douyinCookie"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="可粘贴 ttwid、msToken 等 Cookie"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
|
||||
align-items: start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.example-box {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(243, 246, 250, 0.92), rgba(255, 255, 255, 0.84));
|
||||
border: 1px solid rgba(91, 110, 129, 0.14);
|
||||
}
|
||||
|
||||
.example-box__label {
|
||||
font-size: 12px;
|
||||
color: #6e7883;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.example-box__value {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #44505c;
|
||||
}
|
||||
|
||||
.helper-panel {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(245, 248, 251, 0.74);
|
||||
border: 1px solid rgba(91, 110, 129, 0.12);
|
||||
color: #627080;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.template-section {
|
||||
padding: 18px 18px 4px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(91, 110, 129, 0.12);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.84));
|
||||
}
|
||||
|
||||
.template-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-section__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #1e2937;
|
||||
}
|
||||
|
||||
.template-section__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #697586;
|
||||
}
|
||||
|
||||
.template-help {
|
||||
margin-top: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(91, 110, 129, 0.12);
|
||||
background: rgba(245, 248, 251, 0.72);
|
||||
}
|
||||
|
||||
.template-help__label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #687381;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.email-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(91, 110, 129, 0.12);
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
color: #657180;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.email-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/element-plus")) {
|
||||
return "element-plus";
|
||||
}
|
||||
|
||||
if (id.includes("node_modules/@element-plus/icons-vue")) {
|
||||
return "element-plus-icons";
|
||||
}
|
||||
|
||||
if (id.includes("node_modules/vue") || id.includes("node_modules/pinia") || id.includes("node_modules/vue-router")) {
|
||||
return "vue-vendor";
|
||||
}
|
||||
|
||||
if (id.includes("node_modules/axios")) {
|
||||
return "network-vendor";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:5000",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user