This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
+12
View File
@@ -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>
+2046
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+33
View File
@@ -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>
+6
View File
@@ -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;
}
+9
View File
@@ -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");
+74
View File
@@ -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;
+60
View File
@@ -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
};
});
+552
View File
@@ -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;
}
}
+202
View File
@@ -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"
};
+570
View File
@@ -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>
+192
View File
@@ -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>
+154
View File
@@ -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>
+340
View File
@@ -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>
+738
View File
@@ -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>
+657
View File
@@ -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>&lt;d&gt;</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>
+21
View File
@@ -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"]
}
+45
View File
@@ -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
}
}
}
});