feat: redesign responsive control room ui

This commit is contained in:
2026-04-26 02:18:03 +08:00
parent ff3e1fae70
commit 94f6e08dea
16 changed files with 4189 additions and 926 deletions
+6
View File
@@ -1,3 +1,9 @@
<script setup lang="ts">
import { useUiPreferences } from "@/composables/useUiPreferences";
useUiPreferences();
</script>
<template>
<router-view />
</template>
+519 -280
View File
@@ -4,13 +4,21 @@ import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import { useUiPreferences } from "@/composables/useUiPreferences";
import {
House,
VideoCamera,
Document,
Tickets,
RefreshRight,
Setting,
SwitchButton,
Operation
Operation,
Fold,
Expand,
Sunny,
Moon,
Monitor
} from "@element-plus/icons-vue";
const router = useRouter();
@@ -18,19 +26,115 @@ const route = useRoute();
const authStore = useAuthStore();
const { isMobile } = useViewport();
const { backendUnavailable, backendMessage } = useBackendStatus();
const { themeMode, density, resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } =
useUiPreferences();
const mobileNavVisible = ref(false);
const displayName = computed(() => authStore.user?.displayName ?? "Operator");
const menuItems = [
{ index: "/live-rooms", label: "直播间管理", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/logs", label: "系统日志", icon: Tickets },
{ index: "/settings", label: "系统设置", icon: Setting }
const navigationGroups = [
{
key: "monitor",
title: "直播监控",
items: [
{ index: "/live-rooms", label: "直播间", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
]
},
{
key: "review",
title: "回顾分析",
items: [
{ index: "/daily-reviews", label: "回顾日报", icon: Document },
{ index: "/logs", label: "系统日志", icon: Tickets }
]
},
{
key: "system",
title: "系统管理",
items: [{ index: "/settings", label: "系统设置", icon: Setting }]
}
];
const pageMeta = computed(() => {
if (route.name === "live-rooms") {
return {
title: "直播监控控制台",
subtitle: "统一管理直播间、自动开录、实时状态和异常处理。"
};
}
if (route.name === "record-tasks") {
return {
title: "录制任务工作区",
subtitle: "按会话观察录制进度、分片状态、转码后处理和删除操作。"
};
}
if (route.name === "record-session-detail") {
return {
title: "会话事件时间轴",
subtitle: "用整场视角查看分片、脚本、Webhook 与弹幕热度。"
};
}
if (route.name === "record-task-detail") {
return {
title: "分片详情",
subtitle: "查看单个分片的产物、状态、输出结果和原始日志。"
};
}
if (route.name === "daily-reviews") {
return {
title: "回顾日报",
subtitle: "按天回看录制规模、热点时刻、异常分布与重点会话。"
};
}
if (route.name === "logs") {
return {
title: "系统日志",
subtitle: "面向排障的原始事件流,支持快速缩小范围和定位异常。"
};
}
if (route.name === "recovery") {
return {
title: "恢复中心",
subtitle: "集中处理未自动拉起的直播间和等待恢复的 MP4 转码任务。"
};
}
if (route.name === "settings") {
return {
title: "系统设置",
subtitle: "录制、存储、通知、脚本和平台策略统一在这里收口。"
};
}
return {
title: "Live Recorder",
subtitle: "专业直播录制运维控制台。"
};
});
const currentThemeIcon = computed(() => {
if (themeMode.value === "light") {
return Sunny;
}
if (themeMode.value === "dark") {
return Moon;
}
return Monitor;
});
watch(
() => route.path,
() => route.fullPath,
() => {
mobileNavVisible.value = false;
}
@@ -44,94 +148,157 @@ async function handleLogout() {
</script>
<template>
<el-container class="shell">
<el-aside v-if="!isMobile" width="240px" class="sidebar">
<div class="brand">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">直播录制控制台</div>
<el-container class="app-shell">
<el-aside
v-if="!isMobile"
class="app-sidebar"
:class="{ 'app-sidebar--collapsed': sidebarCollapsed }"
:width="sidebarCollapsed ? '92px' : '276px'"
>
<div class="app-brand">
<div class="app-brand__mark">LR</div>
<div v-if="!sidebarCollapsed" class="app-brand__copy">
<div class="app-brand__title">Live Recorder</div>
<div class="app-brand__subtitle">冷静稳定可持续运维</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu">
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
<div class="app-sidebar__scroll">
<section v-for="group in navigationGroups" :key="group.key" class="app-nav-group">
<div v-if="!sidebarCollapsed" class="app-nav-group__title">{{ group.title }}</div>
<el-menu :default-active="route.path" router class="app-nav-menu" :collapse="sidebarCollapsed">
<el-menu-item v-for="item in group.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</section>
</div>
<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 class="app-sidebar__footer">
<div class="app-sidebar__status">
<span class="app-status-dot" :class="`app-status-dot--${resolvedTheme}`"></span>
<span v-if="!sidebarCollapsed">{{ resolvedTheme === "dark" ? "深色控制台" : "浅色控制台" }}</span>
</div>
<div v-if="!sidebarCollapsed" class="app-account">
<div class="app-account__label">当前账户</div>
<div class="app-account__name">{{ displayName }}</div>
</div>
<el-button
class="app-sidebar__logout"
:icon="SwitchButton"
text
@click="handleLogout"
>
<span v-if="!sidebarCollapsed">退出登录</span>
</el-button>
</div>
</el-aside>
<el-drawer
v-model="mobileNavVisible"
class="mobile-nav-drawer"
class="app-mobile-drawer"
direction="ltr"
size="292px"
size="88vw"
:with-header="false"
>
<div class="mobile-drawer">
<div class="brand brand--drawer">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">直播录制控制台</div>
<div class="app-brand app-brand--drawer">
<div class="app-brand__mark">LR</div>
<div class="app-brand__copy">
<div class="app-brand__title">Live Recorder</div>
<div class="app-brand__subtitle">专业直播录制控制台</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu menu--drawer">
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
<div class="mobile-drawer__controls">
<el-select v-model="themeMode" size="small">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
<el-select v-model="density" size="small">
<el-option label="舒适密度" value="comfortable" />
<el-option label="紧凑密度" value="compact" />
</el-select>
</div>
<div class="sidebar-footer sidebar-footer--drawer">
<div class="sidebar-footer__label">当前账户</div>
<div class="sidebar-footer__name">{{ displayName }}</div>
<el-button :icon="SwitchButton" text @click="handleLogout">退出登录</el-button>
<div class="app-sidebar__scroll">
<section v-for="group in navigationGroups" :key="group.key" class="app-nav-group">
<div class="app-nav-group__title">{{ group.title }}</div>
<el-menu :default-active="route.path" router class="app-nav-menu app-nav-menu--mobile">
<el-menu-item v-for="item in group.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</section>
</div>
<div class="mobile-drawer__footer">
<div class="app-account">
<div class="app-account__label">当前账户</div>
<div class="app-account__name">{{ displayName }}</div>
</div>
<el-button class="mobile-drawer__logout" :icon="SwitchButton" @click="handleLogout">退出登录</el-button>
</div>
</div>
</el-drawer>
<el-container>
<el-header class="header">
<div class="header__shell">
<div class="header__lead">
<el-container class="app-shell__main">
<el-header class="app-topbar">
<div class="app-topbar__main">
<div class="app-topbar__lead">
<el-button
v-if="isMobile"
class="mobile-nav-trigger"
class="app-topbar__menu-button"
:icon="Operation"
@click="mobileNavVisible = true"
>
菜单
</el-button>
<div>
<div class="header__title">直播录制控制台</div>
<div class="header__subtitle">平台适配巡检录制和结果统一管理</div>
/>
<el-button
v-else
class="app-topbar__collapse-button"
:icon="sidebarCollapsed ? Expand : Fold"
@click="toggleSidebarCollapsed"
/>
<div class="app-topbar__copy">
<div class="app-topbar__eyebrow">Control Room</div>
<div class="app-topbar__title">{{ pageMeta.title }}</div>
<div class="app-topbar__subtitle">{{ pageMeta.subtitle }}</div>
</div>
</div>
<div v-if="isMobile" class="header__account">
{{ displayName }}
<div class="app-topbar__controls desktop-only">
<el-select v-model="themeMode" size="small" class="app-topbar__select">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
<el-select v-model="density" size="small" class="app-topbar__select">
<el-option label="舒适密度" value="comfortable" />
<el-option label="紧凑密度" value="compact" />
</el-select>
<div class="app-topbar__account">
<span class="app-topbar__account-label">当前账户</span>
<strong>{{ displayName }}</strong>
</div>
<el-button :icon="SwitchButton" @click="handleLogout">退出</el-button>
</div>
<div class="app-topbar__controls mobile-only">
<el-button class="app-topbar__theme-button" :icon="currentThemeIcon" @click="cycleThemeMode" />
</div>
</div>
</el-header>
<el-main class="main">
<el-main class="app-main">
<el-alert
v-if="backendUnavailable"
class="backend-alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
title="后端服务暂时不可用"
:description="backendMessage"
/>
<router-view />
@@ -141,264 +308,336 @@ async function handleLogout() {
</template>
<style scoped>
.shell {
.app-shell {
min-height: 100vh;
background: transparent;
}
.sidebar {
.app-sidebar {
display: flex;
flex-direction: column;
padding: 20px 16px 20px 20px;
gap: 18px;
padding: 20px 16px;
border-right: 1px solid var(--border-subtle);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.16));
backdrop-filter: blur(18px);
}
:global(html[data-theme="dark"]) .app-sidebar {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
}
.app-sidebar--collapsed {
padding-inline: 12px;
}
.app-brand {
display: flex;
align-items: center;
gap: 14px;
min-height: 56px;
}
.app-brand--drawer {
margin-bottom: 10px;
}
.app-brand__mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(180deg, var(--accent), var(--accent-strong));
color: #f5fbff;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.04em;
box-shadow: 0 10px 24px rgba(47, 111, 180, 0.22);
}
.app-brand__copy {
min-width: 0;
}
.app-brand__title {
color: var(--text-primary);
font-size: 16px;
font-weight: 750;
letter-spacing: -0.03em;
}
.app-brand__subtitle {
margin-top: 4px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.4;
}
.app-sidebar__scroll {
display: grid;
gap: 14px;
min-height: 0;
}
.app-nav-group {
display: grid;
gap: 8px;
}
.app-nav-group__title {
padding-inline: 10px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.app-nav-menu {
border-right: none;
background: transparent;
}
.app-nav-menu :deep(.el-menu-item) {
height: 44px;
margin-bottom: 4px;
border-radius: 8px;
color: var(--text-secondary);
}
.app-nav-menu :deep(.el-menu-item:hover) {
background: var(--accent-soft);
color: var(--text-primary);
}
.app-nav-menu :deep(.el-menu-item.is-active) {
background: linear-gradient(180deg, rgba(47, 111, 180, 0.16), rgba(47, 111, 180, 0.08));
color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(47, 111, 180, 0.18);
}
.app-sidebar__footer {
display: grid;
gap: 12px;
margin-top: auto;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.app-sidebar--collapsed .app-sidebar__footer {
padding-inline: 10px;
justify-items: center;
}
.app-sidebar__status {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.app-status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.app-status-dot--light {
background: var(--accent);
}
.app-status-dot--dark {
background: #9ad0ff;
}
.app-account__label {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-account__name {
margin-top: 6px;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
word-break: break-word;
}
.app-sidebar__logout {
justify-content: flex-start;
}
.app-sidebar--collapsed .app-sidebar__logout {
justify-content: center;
}
.app-shell__main {
min-width: 0;
}
.app-topbar {
height: auto;
padding: 18px var(--content-padding) 0;
}
.app-topbar__main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
min-height: var(--header-row-height);
padding: 18px 20px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.56);
backdrop-filter: blur(16px);
box-shadow: var(--shadow-soft);
}
:global(html[data-theme="dark"]) .app-topbar__main {
background: rgba(15, 27, 41, 0.78);
}
.app-topbar__lead {
display: flex;
align-items: flex-start;
gap: 14px;
min-width: 0;
}
.app-topbar__copy {
min-width: 0;
}
.app-topbar__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.app-topbar__title {
color: var(--text-primary);
font-size: clamp(20px, 2vw, 28px);
font-weight: 760;
letter-spacing: -0.04em;
line-height: 1.05;
}
.app-topbar__subtitle {
margin-top: 8px;
max-width: 68ch;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.app-topbar__controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.app-topbar__select {
width: 122px;
}
.app-topbar__account {
display: grid;
gap: 3px;
min-width: 96px;
text-align: right;
color: var(--text-primary);
font-size: 13px;
}
.app-topbar__account-label {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-topbar__collapse-button,
.app-topbar__menu-button,
.app-topbar__theme-button {
width: 40px;
min-width: 40px;
padding: 0;
}
.app-main {
padding: var(--content-padding);
min-height: 0;
}
.backend-alert {
margin-bottom: 18px;
}
.mobile-drawer {
display: flex;
min-height: 100%;
flex-direction: column;
padding: 20px 16px 20px 20px;
background:
linear-gradient(180deg, rgba(250, 248, 243, 0.98), rgba(246, 244, 239, 0.98));
gap: 18px;
padding: 20px;
background: var(--bg-subtle);
}
.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--drawer {
padding-left: 4px;
padding-right: 4px;
}
.brand__mark {
width: 42px;
height: 42px;
.mobile-drawer__controls {
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);
gap: 10px;
}
.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--drawer {
flex: 1;
}
.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--drawer {
margin-top: 18px;
}
.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 {
height: auto;
flex: 0 0 auto;
padding: 22px 32px 0;
}
.header__shell {
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__lead {
display: flex;
align-items: center;
.mobile-drawer__footer {
display: grid;
gap: 14px;
min-width: 0;
margin-top: auto;
}
.header__lead > div:last-child {
min-width: 0;
.mobile-drawer__logout {
width: 100%;
}
.header__title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: #171614;
}
.header__subtitle {
margin-top: 4px;
color: #716b63;
font-size: 13px;
}
.header__account {
flex-shrink: 0;
max-width: 120px;
color: #6f675f;
font-size: 12px;
text-align: right;
word-break: break-word;
}
.mobile-nav-trigger {
flex-shrink: 0;
}
.main {
min-height: 0;
padding: 28px 32px 36px;
}
.backend-alert {
margin-bottom: 18px;
border-radius: 14px;
}
:deep(.mobile-nav-drawer .el-drawer__body) {
:deep(.app-mobile-drawer .el-drawer__body) {
padding: 0;
}
@media (max-width: 768px) {
.header {
padding: 12px 14px 0;
@media (max-width: 1023px) {
.app-topbar {
padding: 14px var(--content-padding-mobile) 0;
}
.header__shell {
align-items: center;
gap: 12px;
padding: 14px 16px;
}
.header__lead {
width: 100%;
align-items: flex-start;
justify-content: space-between;
flex-direction: row;
gap: 12px;
}
.header__title {
font-size: 18px;
line-height: 1.12;
}
.header__subtitle {
max-width: 240px;
font-size: 12px;
line-height: 1.45;
}
.header__account {
display: none;
}
.mobile-nav-trigger {
min-width: 84px;
padding-inline: 14px;
}
.main {
padding: 18px 14px 24px;
}
.backend-alert {
margin-bottom: 14px;
.app-main {
padding: var(--content-padding-mobile);
}
}
@media (max-width: 480px) {
.header__shell {
padding: 12px 14px;
@media (max-width: 767px) {
.app-topbar__main {
padding: 14px 14px 16px;
}
.header__title {
font-size: 17px;
.app-topbar__title {
font-size: clamp(18px, 6vw, 24px);
}
.header__subtitle {
display: none;
}
.mobile-nav-trigger {
min-width: 72px;
padding-inline: 12px;
.app-topbar__subtitle {
margin-top: 6px;
font-size: 12px;
}
}
</style>
@@ -0,0 +1,134 @@
import { computed, reactive } from "vue";
type ThemeMode = "light" | "dark" | "system";
type DensityMode = "comfortable" | "compact";
const STORAGE_KEYS = {
themeMode: "live-recorder-ui-theme",
density: "live-recorder-ui-density",
sidebarCollapsed: "live-recorder-ui-sidebar-collapsed"
} as const;
const state = reactive({
themeMode: "system" as ThemeMode,
density: "comfortable" as DensityMode,
sidebarCollapsed: false,
systemTheme: "light" as "light" | "dark",
initialized: false
});
let mediaQueryList: MediaQueryList | null = null;
function readStoredThemeMode(): ThemeMode {
const stored = window.localStorage.getItem(STORAGE_KEYS.themeMode);
return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
}
function readStoredDensity(): DensityMode {
return window.localStorage.getItem(STORAGE_KEYS.density) === "compact" ? "compact" : "comfortable";
}
function readStoredSidebarCollapsed() {
return window.localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === "true";
}
function resolveTheme(): "light" | "dark" {
return state.themeMode === "system" ? state.systemTheme : state.themeMode;
}
function applyDocumentPreferences() {
if (typeof document === "undefined") {
return;
}
const resolvedTheme = resolveTheme();
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themeMode = state.themeMode;
document.documentElement.dataset.density = state.density;
}
function syncSystemTheme() {
state.systemTheme = mediaQueryList?.matches ? "dark" : "light";
applyDocumentPreferences();
}
function ensureInitialized() {
if (state.initialized || typeof window === "undefined") {
return;
}
state.themeMode = readStoredThemeMode();
state.density = readStoredDensity();
state.sidebarCollapsed = readStoredSidebarCollapsed();
mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
if (typeof mediaQueryList.addEventListener === "function") {
mediaQueryList.addEventListener("change", syncSystemTheme);
} else {
mediaQueryList.addListener(syncSystemTheme);
}
syncSystemTheme();
state.initialized = true;
}
function persistPreferences() {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(STORAGE_KEYS.themeMode, state.themeMode);
window.localStorage.setItem(STORAGE_KEYS.density, state.density);
window.localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, String(state.sidebarCollapsed));
}
export function useUiPreferences() {
ensureInitialized();
const resolvedTheme = computed(() => resolveTheme());
function setThemeMode(value: ThemeMode) {
state.themeMode = value;
persistPreferences();
applyDocumentPreferences();
}
function cycleThemeMode() {
const nextValue =
state.themeMode === "light" ? "dark" : state.themeMode === "dark" ? "system" : "light";
setThemeMode(nextValue);
}
function setDensity(value: DensityMode) {
state.density = value;
persistPreferences();
applyDocumentPreferences();
}
function setSidebarCollapsed(value: boolean) {
state.sidebarCollapsed = value;
persistPreferences();
}
function toggleSidebarCollapsed() {
setSidebarCollapsed(!state.sidebarCollapsed);
}
return {
themeMode: computed({
get: () => state.themeMode,
set: (value: ThemeMode) => setThemeMode(value)
}),
resolvedTheme,
density: computed({
get: () => state.density,
set: (value: DensityMode) => setDensity(value)
}),
sidebarCollapsed: computed({
get: () => state.sidebarCollapsed,
set: (value: boolean) => setSidebarCollapsed(value)
}),
cycleThemeMode,
toggleSidebarCollapsed
};
}
+11 -3
View File
@@ -1,9 +1,11 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const MOBILE_BREAKPOINT = 768;
const TABLET_BREAKPOINT = 1024;
const DESKTOP_BREAKPOINT = 1280;
export function useViewport() {
const width = ref(typeof window === "undefined" ? 1280 : window.innerWidth);
const width = ref(typeof window === "undefined" ? DESKTOP_BREAKPOINT : window.innerWidth);
function syncViewport() {
width.value = window.innerWidth;
@@ -18,10 +20,16 @@ export function useViewport() {
window.removeEventListener("resize", syncViewport);
});
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT);
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
const isTablet = computed(() => width.value >= MOBILE_BREAKPOINT && width.value < TABLET_BREAKPOINT);
const isDesktop = computed(() => width.value >= TABLET_BREAKPOINT);
const isWideDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT);
return {
width,
isMobile
isMobile,
isTablet,
isDesktop,
isWideDesktop
};
}
+19
View File
@@ -6,7 +6,10 @@ 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 RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue");
const DailyReviewsView = () => import("@/views/DailyReviewsView.vue");
const LogsView = () => import("@/views/LogsView.vue");
const RecoveryView = () => import("@/views/RecoveryView.vue");
const SettingsView = () => import("@/views/SettingsView.vue");
const router = createRouter({
@@ -42,11 +45,27 @@ const router = createRouter({
component: RecordTaskDetailView,
props: true
},
{
path: "record-sessions/:id",
name: "record-session-detail",
component: RecordSessionDetailView,
props: true
},
{
path: "daily-reviews",
name: "daily-reviews",
component: DailyReviewsView
},
{
path: "logs",
name: "logs",
component: LogsView
},
{
path: "recovery",
name: "recovery",
component: RecoveryView
},
{
path: "settings",
name: "settings",
File diff suppressed because it is too large Load Diff
+213
View File
@@ -28,6 +28,10 @@ export interface LiveRoom {
effectiveSettings: LiveRoomEffectiveSettings;
isEnabled: boolean;
availabilityStatus: number;
lastAutoStartDecisionCode?: string;
lastAutoStartDecisionSummary?: string;
lastAutoStartDecisionDetail?: string;
lastAutoStartDecisionAt?: string;
lastCheckedAt?: string;
createdAt: string;
updatedAt: string;
@@ -187,9 +191,120 @@ export interface RecordPreviewTicket {
export interface RecordSessionDetail {
session: RecordSession;
timeline: RecordSessionTimeline;
logs: SystemLog[];
}
export interface RecordSessionTimeline {
anchorAt: string;
totalDurationSeconds: number;
segments: RecordSessionTimelineSegment[];
events: RecordSessionTimelineEvent[];
heatBuckets: RecordSessionHeatBucket[];
}
export interface RecordSessionTimelineSegment {
recordTaskId: string;
segmentIndex: number;
status: number;
startedAt: string;
endedAt: string;
offsetSeconds: number;
durationSeconds: number;
label?: string;
detail?: string;
}
export interface RecordSessionTimelineEvent {
id: string;
layer: string;
title: string;
detail?: string;
recordTaskId?: string;
segmentIndex?: number;
level?: number;
occurredAt: string;
offsetSeconds: number;
}
export interface RecordSessionHeatBucket {
recordTaskId: string;
segmentIndex: number;
bucketStartedAt: string;
offsetSeconds: number;
durationSeconds: number;
messageCount: number;
}
export interface DailyReviewReport {
date: string;
utcOffsetMinutes: number;
windowStartUtc: string;
windowEndUtc: string;
summary: DailyReviewSummary;
rooms: DailyReviewRoom[];
highlights: DailyReviewSessionHighlight[];
moments: DailyReviewMoment[];
}
export interface DailyReviewSummary {
activeLiveRoomCount: number;
sessionCount: number;
segmentCount: number;
totalDurationSeconds: number;
warningCount: number;
errorCount: number;
totalDanmakuCount: number;
}
export interface DailyReviewRoom {
liveRoomId: string;
platformName: string;
roomId: string;
title?: string;
anchorName?: string;
sessionCount: number;
segmentCount: number;
totalDurationSeconds: number;
warningCount: number;
errorCount: number;
danmakuCount: number;
}
export interface DailyReviewSessionHighlight {
key: string;
label: string;
recordSessionId: string;
liveRoomId: string;
platformName: string;
roomId: string;
liveRoomTitle: string;
anchorName?: string;
status: number;
segmentCount: number;
durationSeconds: number;
danmakuCount: number;
warningCount: number;
errorCount: number;
startedAt?: string;
endedAt?: string;
summary?: string;
}
export interface DailyReviewMoment {
liveRoomId: string;
recordSessionId: string;
recordTaskId: string;
segmentIndex: number;
platformName: string;
roomId: string;
liveRoomTitle: string;
anchorName?: string;
bucketStartedAt: string;
bucketEndedAt: string;
danmakuCount: number;
}
export interface SystemSettings {
ffmpegPath: string;
outputRoot: string;
@@ -205,6 +320,9 @@ export interface SystemSettings {
enableStorageGuard: boolean;
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
enableRetentionCleanup: boolean;
retentionDays: number;
retentionDeleteFiles: boolean;
enableAutoReconnect: boolean;
reconnectDelayMaxSeconds: number;
readWriteTimeoutMilliseconds: number;
@@ -241,11 +359,90 @@ export interface SystemSettings {
emailLiveStartedBodyTemplateHtml: string;
emailExceptionSubjectTemplate: string;
emailExceptionBodyTemplateHtml: string;
enableWebhookNotification: boolean;
webhookUrl: string;
webhookHeaders: string;
webhookTimeoutSeconds: number;
notifyWebhookOnLiveStarted: boolean;
notifyWebhookOnException: boolean;
douyinUserAgent: string;
douyinReferer: string;
douyinCookie: string;
}
export interface EventScriptTestResult {
success: boolean;
message: string;
detail?: string;
customLogOutput?: string;
}
export interface WebhookTestResult {
success: boolean;
message: string;
detail?: string;
}
export interface RetentionCleanupResult {
deletedSessionCount: number;
deletedTaskCount: number;
deletedResultCount: number;
deletedLogCount: number;
deletedFileCount: number;
deletedDanmakuFileCount: number;
warnings: string[];
}
export interface RecoveryOverview {
storage: StorageGuardStatus;
liveRooms: RecoverableLiveRoom[];
finalizations: RecoverableFinalization[];
}
export interface StorageGuardStatus {
isEnabled: boolean;
hasEnoughSpace: boolean;
checkedPath: string;
availableBytes: number;
requiredBytes: number;
message: string;
}
export interface RecoverableLiveRoom {
liveRoomId: string;
platformName: string;
roomId: string;
title?: string;
anchorName?: string;
lastAutoStartDecisionCode?: string;
lastAutoStartDecisionSummary?: string;
lastAutoStartDecisionDetail?: string;
lastAutoStartDecisionAt?: string;
lastCheckedAt?: string;
}
export interface RecoverableFinalization {
recordTaskId: string;
recordSessionId: string;
liveRoomId: string;
liveRoomTitle: string;
roomId: string;
platformName: string;
segmentIndex: number;
status: number;
outputFilePath?: string;
reason?: string;
createdAt: string;
endedAt?: string;
}
export interface RecoveryActionResult {
requestedCount: number;
successCount: number;
failedCount: number;
messages: string[];
}
export const availabilityLabelMap: Record<number, string> = {
0: "Unknown",
1: "Offline",
@@ -287,3 +484,19 @@ export const recordingTemplateLabelMap: Record<number, string> = {
1: "Balanced MP4",
2: "Archive TS"
};
export const autoStartDecisionLabelMap: Record<string, string> = {
started: "Started",
skipped_disabled: "Disabled",
skipped_storage: "Low Storage",
skipped_active_session: "Active Session",
skipped_offline: "Offline",
failed_startup: "Startup Failed"
};
export const platformLabelMap: Record<number, string> = {
0: "Unknown",
1: "Douyin",
2: "Bilibili",
3: "Huya"
};
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { DailyReviewReport } from "@/types";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const loadError = ref("");
const selectedDate = ref(defaultReviewDate());
const report = ref<DailyReviewReport | null>(null);
const roomTableHeight = computed(() => (isMobile.value ? undefined : 420));
const momentTableHeight = computed(() => (isMobile.value ? undefined : 360));
async function loadReport() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<DailyReviewReport>("/reports/daily", {
params: {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes()
}
});
report.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "回顾日报加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openSession(recordSessionId: string) {
router.push({ name: "record-session-detail", params: { id: recordSessionId } });
}
function openTask(recordTaskId: string) {
router.push({ name: "record-task-detail", params: { id: recordTaskId } });
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatDuration(seconds?: number) {
return typeof seconds === "number" && Number.isFinite(seconds)
? `${seconds.toFixed(0)}s`
: "-";
}
function getLocalUtcOffsetMinutes() {
return -new Date().getTimezoneOffset();
}
function defaultReviewDate() {
const current = new Date();
current.setDate(current.getDate() - 1);
return formatDateOnly(current);
}
function formatDateOnly(value: Date) {
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, "0");
const day = `${value.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
function disableFutureDates(value: Date) {
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
return value.getTime() > endOfToday.getTime();
}
onMounted(loadReport);
</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-date-picker
v-model="selectedDate"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
:clearable="false"
:disabled-date="disableFutureDates"
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !report" animated :rows="10" />
<template v-else-if="report">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">活跃直播间</div>
<div class="stat-card__value">{{ report.summary.activeLiveRoomCount }}</div>
<div class="stat-card__hint">{{ report.date }} 当天有录制重叠的房间数</div>
</div>
<div class="stat-card">
<div class="stat-card__label">录制会话</div>
<div class="stat-card__value">{{ report.summary.sessionCount }}</div>
<div class="stat-card__hint">按整场直播会话聚合统计</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片总数</div>
<div class="stat-card__value">{{ report.summary.segmentCount }}</div>
<div class="stat-card__hint">只统计与当天有时间重叠的分片</div>
</div>
<div class="stat-card">
<div class="stat-card__label">录制时长</div>
<div class="stat-card__value">{{ formatDuration(report.summary.totalDurationSeconds) }}</div>
<div class="stat-card__hint">跨天会话按日报窗口裁剪</div>
</div>
<div class="stat-card">
<div class="stat-card__label">警告 / 错误</div>
<div class="stat-card__value">{{ report.summary.warningCount }} / {{ report.summary.errorCount }}</div>
<div class="stat-card__hint">来自当天 Warning / Error 系统日志</div>
</div>
<div class="stat-card">
<div class="stat-card__label">弹幕事件</div>
<div class="stat-card__value">{{ report.summary.totalDanmakuCount }}</div>
<div class="stat-card__hint">优先按弹幕 XML 分钟桶统计</div>
</div>
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">直播间汇总</h3>
<p class="section-subtitle">每个房间当天的录制规模异常和弹幕数量</p>
</div>
</div>
<el-empty v-if="report.rooms.length === 0" description="当天没有录制数据" />
<div v-else class="table-scroll-shell">
<el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto">
<el-table-column label="直播间" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120" prop="platformName" />
<el-table-column label="会话" width="90" prop="sessionCount" />
<el-table-column label="分片" width="90" prop="segmentCount" />
<el-table-column label="录制时长" width="120">
<template #default="{ row }">
{{ formatDuration(row.totalDurationSeconds) }}
</template>
</el-table-column>
<el-table-column label="警告 / 错误" width="130">
<template #default="{ row }">
{{ row.warningCount }} / {{ row.errorCount }}
</template>
</el-table-column>
<el-table-column label="弹幕" width="90" prop="danmakuCount" />
</el-table>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">重点会话</h3>
<p class="section-subtitle">最长最热和异常最多的会话会出现在这里</p>
</div>
</div>
<el-empty v-if="report.highlights.length === 0" description="当天没有可展示的会话亮点" />
<div v-else class="highlight-grid">
<section
v-for="item in report.highlights"
:key="item.key"
class="highlight-item"
>
<div class="highlight-item__label">{{ item.label }}</div>
<div class="highlight-item__title">{{ item.liveRoomTitle }}</div>
<div class="highlight-item__meta">
<span>{{ item.platformName }}</span>
<span>{{ item.roomId }}</span>
<span>{{ formatDuration(item.durationSeconds) }}</span>
</div>
<div class="highlight-item__stats">
<span>分片 {{ item.segmentCount }}</span>
<span>弹幕 {{ item.danmakuCount }}</span>
<span>警告 {{ item.warningCount }}</span>
<span>错误 {{ item.errorCount }}</span>
</div>
<div class="highlight-item__summary">{{ item.summary || "-" }}</div>
<div class="highlight-item__footer">
<span>{{ formatDate(item.startedAt || item.endedAt) }}</span>
<el-button size="small" @click="openSession(item.recordSessionId)">查看会话</el-button>
</div>
</section>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">热点时刻</h3>
<p class="section-subtitle">基于弹幕 XML 的分钟级活跃度帮助快速定位当天最热的时间窗</p>
</div>
</div>
<el-empty v-if="report.moments.length === 0" description="当天没有可用的弹幕热度数据" />
<div v-else class="table-scroll-shell">
<el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto">
<el-table-column label="热度窗口" width="190">
<template #default="{ row }">
{{ formatDate(row.bucketStartedAt) }}
</template>
</el-table-column>
<el-table-column label="直播间" min-width="250">
<template #default="{ row }">
<div class="cell-title">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle">分片 #{{ row.segmentIndex }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120" prop="platformName" />
<el-table-column label="弹幕数" width="100" prop="danmakuCount" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openSession(row.recordSessionId)">查看会话</el-button>
<el-button size="small" @click="openTask(row.recordTaskId)">查看分片</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.highlight-item {
display: grid;
gap: 10px;
min-width: 0;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.highlight-item__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.highlight-item__title {
font-size: 16px;
font-weight: 700;
line-height: 1.5;
color: var(--text-primary);
}
.highlight-item__meta,
.highlight-item__stats,
.highlight-item__footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: var(--text-secondary);
font-size: 13px;
}
.highlight-item__summary {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.highlight-item__footer {
justify-content: space-between;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
@media (max-width: 1100px) {
.highlight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.highlight-grid {
grid-template-columns: 1fr;
}
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-date-editor),
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.highlight-item__footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+188 -20
View File
@@ -5,6 +5,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
autoStartDecisionLabelMap,
availabilityLabelMap,
outputFormatLabelMap,
recordingTemplateLabelMap,
@@ -395,6 +396,30 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase();
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
}
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
}
if (code === "failed_startup") {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
return "warning";
}
return "info";
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
@@ -451,18 +476,19 @@ onMounted(loadRooms);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Live Monitoring</div>
<h1 class="page-title">直播间管理</h1>
<p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置
</p>
</div>
<el-space wrap class="header-actions">
<div class="page-toolbar">
<el-button @click="loadRooms">刷新列表</el-button>
<el-button @click="openImportDialog">批量导入</el-button>
<el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button>
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
</el-space>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
@@ -496,7 +522,7 @@ onMounted(loadRooms);
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">支持刷新状态手动启动录制单房间配置覆盖以及按需删除直播间</p>
</div>
<el-space wrap class="batch-actions">
<el-space v-if="!isMobile" wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
<el-button :disabled="!hasSelectedRooms || batchActionLoading" @click="batchSetRoomsEnabled(true)">批量启用</el-button>
<el-button :disabled="!hasSelectedRooms || batchActionLoading" @click="batchSetRoomsEnabled(false)">批量停用</el-button>
@@ -506,7 +532,78 @@ onMounted(loadRooms);
</el-space>
</div>
<div class="table-scroll-shell rooms-table-shell">
<div v-if="isMobile" class="data-card-list room-card-list">
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
<div class="data-card__header">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
{{ getRoomAvatarText(row) }}
</el-avatar>
<div>
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="cell-mono">{{ row.roomId }}</div>
</div>
</div>
<div class="badge-row">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
</el-tag>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
</div>
<div class="data-card__grid">
<div>
<dt>自动开录</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</dd>
</div>
<div>
<dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
{{ row.effectiveSettings.preferredQuality }} ·
{{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[row.effectiveSettings.saveMode] }} ·
{{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
</dd>
</div>
<div v-if="row.lastAutoStartDecisionDetail" style="grid-column: 1 / -1;">
<dt>决策详情</dt>
<dd>{{ row.lastAutoStartDecisionDetail }}</dd>
</div>
</div>
<div class="room-card__toggle">
<span class="room-card__toggle-label">录制开关</span>
<el-switch
:model-value="row.isEnabled"
:loading="togglingRoomId === row.id"
inline-prompt
active-text=""
inactive-text=""
@change="setRoomEnabled(row, Boolean($event))"
/>
</div>
<div class="data-card__actions">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(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>
</div>
</article>
</div>
<div v-else class="table-scroll-shell rooms-table-shell">
<el-table
:data="rooms"
v-loading="loading"
@@ -548,6 +645,31 @@ onMounted(loadRooms);
</template>
</el-table-column>
<el-table-column label="自动开录" min-width="280">
<template #default="{ row }">
<div class="auto-start-cell">
<div class="auto-start-cell__head">
<el-tag size="small" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)" effect="plain">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<el-popover
v-if="row.lastAutoStartDecisionDetail"
trigger="hover"
placement="top-start"
:width="320"
>
<template #reference>
<el-button text size="small" class="auto-start-cell__detail-trigger">详情</el-button>
</template>
<div class="detail-popover">{{ row.lastAutoStartDecisionDetail }}</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column label="房间配置" min-width="240">
<template #default="{ row }">
<div class="config-summary">
@@ -932,7 +1054,7 @@ onMounted(loadRooms);
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
border-bottom: 1px solid var(--border-subtle);
}
.batch-actions {
@@ -965,30 +1087,76 @@ onMounted(loadRooms);
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
}
.room-card {
gap: 16px;
}
.room-card__toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.room-card__toggle-label {
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: #17202b;
color: var(--text-primary);
line-height: 1.5;
}
.cell-subtitle {
margin-top: 6px;
color: #5f6d79;
color: var(--text-secondary);
font-size: 13px;
}
.cell-mono {
margin-top: 8px;
color: #7c8690;
color: var(--text-muted);
font-size: 12px;
}
.auto-start-cell {
display: grid;
gap: 6px;
}
.auto-start-cell__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.auto-start-cell__detail-trigger {
justify-content: flex-start;
padding: 0;
}
.detail-popover {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.config-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #5f6d79;
color: var(--text-secondary);
font-size: 12px;
}
@@ -997,8 +1165,8 @@ onMounted(loadRooms);
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(243, 246, 248, 0.9);
border: 1px solid rgba(95, 109, 124, 0.08);
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
}
.table-date-text {
@@ -1024,8 +1192,8 @@ onMounted(loadRooms);
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(243, 246, 248, 0.9);
color: #51606c;
background: var(--surface-muted);
color: var(--text-secondary);
line-height: 1.6;
}
@@ -1047,13 +1215,13 @@ onMounted(loadRooms);
display: grid;
gap: 12px;
padding: 14px 16px;
border: 1px solid rgba(62, 95, 124, 0.14);
border: 1px solid rgba(47, 111, 180, 0.14);
border-radius: 14px;
background: rgba(246, 248, 250, 0.82);
background: var(--surface-muted);
}
.import-result__summary {
color: #26323d;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
line-height: 1.7;
@@ -1077,7 +1245,7 @@ onMounted(loadRooms);
}
.import-result__failure code {
color: #475569;
color: var(--text-secondary);
word-break: break-all;
}
@@ -1091,7 +1259,7 @@ onMounted(loadRooms);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e7c88;
color: var(--text-muted);
}
.delete-dialog__title {
@@ -1099,7 +1267,7 @@ onMounted(loadRooms);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: #15202b;
color: var(--text-primary);
}
.delete-dialog__body {
@@ -1108,7 +1276,7 @@ onMounted(loadRooms);
}
.delete-dialog__lead {
color: #4f5d69;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
+197 -119
View File
@@ -1,22 +1,36 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Lock, User } from "@element-plus/icons-vue";
import { Lock, Monitor, Moon, Sunny, User } from "@element-plus/icons-vue";
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useUiPreferences } from "@/composables/useUiPreferences";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const { backendUnavailable, backendMessage } = useBackendStatus();
const { themeMode, resolvedTheme } = useUiPreferences();
const form = reactive({
username: "admin",
password: "Admin@123"
});
const currentThemeIcon = computed(() => {
if (themeMode.value === "light") {
return Sunny;
}
if (themeMode.value === "dark") {
return Moon;
}
return Monitor;
});
async function handleLogin() {
loading.value = true;
@@ -28,7 +42,7 @@ async function handleLogin() {
ElMessage.error(
isBackendUnavailableError(error)
? getApiErrorMessage(error)
: "登录失败,请检查账号密码"
: "登录失败,请检查账号密码"
);
} finally {
loading.value = false;
@@ -37,37 +51,63 @@ async function handleLogin() {
</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>
<div class="login-screen">
<section class="login-hero">
<div class="login-hero__eyebrow">Live Recorder</div>
<h1 class="login-hero__title">把直播录制做成可长期维护的专业控制台</h1>
<p class="login-hero__subtitle">
统一管理直播间自动开录恢复流程系统日志事件脚本和日报回顾让录制系统像真正的运维平台一样稳定工作
</p>
<div class="login-copy__list">
<span>多平台直播适配</span>
<span>自动开播巡检</span>
<span>ffmpeg 录制编排</span>
<span>邮件与异常通知</span>
</div>
</div>
<el-card class="login-card surface-card" shadow="never">
<div class="login-hero__grid">
<article class="login-hero__tile">
<strong>实时监控</strong>
<span>直播间自动开录决策会话与分片状态集中可见</span>
</article>
<article class="login-hero__tile">
<strong>恢复能力</strong>
<span>中断转码暂停录制重启恢复都有统一入口</span>
</article>
<article class="login-hero__tile">
<strong>自动化</strong>
<span>邮件Webhook事件脚本与自定义日志全部贯通</span>
</article>
<article class="login-hero__tile">
<strong>回顾分析</strong>
<span>时间轴和日报帮助我们快速复盘每一场直播</span>
</article>
</div>
</section>
<section class="login-panel surface-card">
<div class="login-panel__header">
<div>
<div class="login-panel__kicker">Sign In</div>
<h2 class="login-panel__title">进入控制台</h2>
<p class="login-panel__subtitle">默认账户为 <span class="monospace">admin / Admin@123</span></p>
</div>
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
<template #prefix>
<el-icon><component :is="currentThemeIcon" /></el-icon>
</template>
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
</div>
<el-alert
v-if="backendUnavailable"
class="login-card__alert"
class="login-panel__alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
title="后端服务暂时不可用"
:description="backendMessage"
/>
<h3 class="section-title">登录</h3>
<p class="section-subtitle">登录后即可进入控制台管理直播间录制任务系统日志和基础设置</p>
<el-form label-position="top" @submit.prevent="handleLogin">
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input v-model="form.username" :prefix-icon="User" />
</el-form-item>
@@ -82,154 +122,192 @@ async function handleLogin() {
/>
</el-form-item>
<el-button class="login-card__submit" type="primary" :loading="loading" @click="handleLogin">
登录
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录控制台
</el-button>
</el-form>
</el-card>
<div class="login-panel__footer">
<span>当前主题{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
<span>双端适配Web / Mobile</span>
</div>
</section>
</div>
</template>
<style scoped>
.login-page {
position: relative;
overflow: hidden;
.login-screen {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
align-items: center;
gap: 56px;
padding: 56px 64px;
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 440px);
gap: 48px;
padding: 48px 56px;
}
.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-hero {
display: grid;
align-content: center;
gap: 24px;
}
.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;
.login-hero__eyebrow,
.login-panel__kicker {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.12em;
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-hero__title {
margin: 0;
max-width: 11ch;
color: var(--text-primary);
font-size: clamp(44px, 5vw, 72px);
font-weight: 780;
letter-spacing: -0.065em;
line-height: 0.94;
}
.login-copy p {
max-width: 600px;
color: #676158;
line-height: 1.85;
.login-hero__subtitle {
max-width: 60ch;
margin: 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.85;
}
.login-copy__list {
.login-hero__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
max-width: 520px;
margin-top: 28px;
max-width: 720px;
}
.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;
.login-hero__tile {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.42);
box-shadow: var(--shadow-soft);
}
:global(html[data-theme="dark"]) .login-hero__tile {
background: rgba(255, 255, 255, 0.02);
}
.login-hero__tile strong {
color: var(--text-primary);
font-size: 14px;
}
.login-hero__tile span {
color: var(--text-secondary);
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;
line-height: 1.65;
}
.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-panel {
align-self: center;
padding: 24px;
}
.login-card {
max-width: 440px;
.login-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.login-panel__title {
margin: 6px 0 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 750;
letter-spacing: -0.045em;
}
.login-panel__subtitle {
margin: 10px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.login-panel__theme-select {
width: 122px;
}
.login-panel__alert {
margin-bottom: 18px;
}
.login-form__submit {
width: 100%;
margin-left: auto;
padding: 14px;
margin-top: 8px;
}
.login-card__alert {
margin-bottom: 16px;
border-radius: 14px;
.login-panel__footer {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: 12px;
}
.login-card__submit {
width: 100%;
margin-top: 12px;
height: 46px;
}
@media (max-width: 960px) {
.login-page {
@media (max-width: 1100px) {
.login-screen {
grid-template-columns: 1fr;
gap: 28px;
padding: 28px 22px;
padding: 28px 20px;
}
.login-copy__list {
.login-hero__grid {
grid-template-columns: 1fr;
max-width: none;
}
.login-card {
margin: 0;
.login-panel {
width: 100%;
max-width: 480px;
}
}
@media (max-width: 640px) {
.login-page {
gap: 22px;
padding: 22px 14px;
@media (max-width: 767px) {
.login-screen {
padding: 18px 14px 24px;
}
.login-copy h1 {
margin-top: 18px;
font-size: clamp(30px, 10vw, 40px);
.login-hero {
gap: 18px;
}
.login-copy p {
.login-hero__title {
max-width: none;
font-size: clamp(34px, 12vw, 48px);
}
.login-hero__subtitle {
font-size: 14px;
line-height: 1.75;
}
.login-card {
padding: 8px;
.login-panel {
padding: 18px;
}
.login-panel__header,
.login-panel__footer {
flex-direction: column;
}
.login-panel__theme-select {
width: 100%;
}
}
</style>
+78 -48
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { SystemLog } from "@/types";
@@ -22,6 +22,10 @@ const logLevelOptions = Object.entries(logLevelLabelMap).map(([value, label]) =>
label
}));
const totalWarnings = computed(() => logs.value.filter((item) => item.level === 2).length);
const totalErrors = computed(() => logs.value.filter((item) => item.level === 3).length);
const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].createdAt) : "-"));
async function loadLogs() {
loading.value = true;
loadError.value = "";
@@ -67,18 +71,50 @@ onMounted(loadLogs);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Diagnostics</div>
<h1 class="page-title">系统日志</h1>
<p class="page-subtitle">
支持按直播间任务和日志级别筛选便于快速定位解析失败巡检异常和 ffmpeg 输出问题
面向排障的原始事件流这里保留最近发生的关键日志可以按直播间任务和级别快速收缩范围
</p>
</div>
<div class="page-toolbar">
<el-button @click="loadLogs">刷新日志</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">当前加载</div>
<div class="stat-card__value">{{ logs.length }}</div>
<div class="stat-card__hint">按当前筛选条件拉取到的日志条目</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Warning</div>
<div class="stat-card__value">{{ totalWarnings }}</div>
<div class="stat-card__hint">用于快速观察近期是否出现成批预警</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Error</div>
<div class="stat-card__value">{{ totalErrors }}</div>
<div class="stat-card__hint">优先定位对录制流程有破坏性的异常事件</div>
</div>
<div class="stat-card">
<div class="stat-card__label">最新事件</div>
<div class="stat-card__value" style="font-size: 18px; letter-spacing: -0.03em;">{{ newestLogTime }}</div>
<div class="stat-card__hint">默认按时间倒序优先展示最近发生的事件</div>
</div>
</div>
<el-card class="surface-card filter-card" shadow="never">
<h3 class="section-title">日志过滤</h3>
<p class="section-subtitle">默认拉取最近 200 条日志可按直播间 ID任务 ID 和级别缩小范围</p>
<div class="toolbar-row">
<div>
<h3 class="section-title">日志过滤</h3>
<p class="section-subtitle">默认拉取最近 200 条日志移动端也保留同一套过滤维度</p>
</div>
</div>
<el-form class="filter-form">
<el-form-item label="直播间 ID">
@@ -101,20 +137,51 @@ onMounted(loadLogs);
<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-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>
<div class="toolbar-row">
<div>
<h3 class="section-title">日志列表</h3>
<p class="section-subtitle">桌面端保留高密度表格移动端切成事件卡片便于单手排查</p>
</div>
</div>
<div class="table-scroll-shell logs-table-shell">
<div v-if="isMobile" class="data-card-list">
<article v-for="row in logs" :key="row.id" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ row.message }}</div>
<div class="data-card__subtitle">{{ row.category }}</div>
</div>
<el-tag :type="levelTagType(row.level)">{{ logLevelLabelMap[row.level] }}</el-tag>
</div>
<div class="data-card__grid">
<div>
<dt>时间</dt>
<dd>{{ formatDate(row.createdAt) }}</dd>
</div>
<div>
<dt>任务</dt>
<dd class="monospace">{{ row.recordTaskId || "-" }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>详情</dt>
<dd class="log-detail monospace">{{ row.detail || "-" }}</dd>
</div>
</div>
</article>
</div>
<div v-else class="table-scroll-shell logs-table-shell">
<el-table
:data="logs"
v-loading="loading"
:height="isMobile ? undefined : 720"
:height="720"
class="premium-table logs-table"
table-layout="auto"
>
@@ -135,7 +202,7 @@ onMounted(loadLogs);
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">
<span class="log-date-text">{{ formatDate(row.createdAt) }}</span>
<span class="table-date-text">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
</el-table>
@@ -145,20 +212,6 @@ onMounted(loadLogs);
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.filter-card :deep(.el-card__body),
.table-card :deep(.el-card__body) {
padding-top: 20px;
}
.filter-form {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -166,30 +219,11 @@ onMounted(loadLogs);
align-items: end;
}
.filter-form :deep(.el-form-item:last-child) {
align-self: end;
}
.log-detail {
font-size: 12px;
color: #6d665b;
line-height: 1.6;
}
.log-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.logs-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.logs-table {
min-width: 980px;
}
.logs-table :deep(.el-table__cell) {
vertical-align: top;
}
@@ -208,13 +242,9 @@ onMounted(loadLogs);
.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) {
@media (max-width: 767px) {
.filter-form {
grid-template-columns: 1fr;
}
@@ -0,0 +1,714 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
RecordSessionDetail,
RecordSessionTimelineEvent,
RecordSessionHeatBucket,
RecordSessionTimelineSegment
} from "@/types";
import {
logLevelLabelMap,
outputFormatLabelMap,
platformLabelMap,
saveModeLabelMap,
sessionStatusLabelMap,
taskStatusLabelMap
} from "@/types";
const props = defineProps<{
id: string;
}>();
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const loadError = ref("");
const detail = ref<RecordSessionDetail | null>(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
const timelineDurationSeconds = computed(() => {
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
return total > 0 ? total : 60;
});
const segmentItems = computed(() => detail.value?.timeline.segments ?? []);
const heatBuckets = computed(() => detail.value?.timeline.heatBuckets ?? []);
const sessionEvents = computed(() => filterTimelineEvents("session"));
const processingEvents = computed(() => filterTimelineEvents("processing"));
const danmakuEvents = computed(() => filterTimelineEvents("danmaku"));
const automationEvents = computed(() => filterTimelineEvents("automation"));
const maxHeatCount = computed(() =>
heatBuckets.value.reduce((max, bucket) => Math.max(max, bucket.messageCount), 0)
);
async function loadDetail() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecordSessionDetail>(`/record-sessions/${props.id}`);
detail.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "会话详情加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function filterTimelineEvents(layer: string) {
if (!detail.value || !visibleLayers.value.includes(layer)) {
return [];
}
return detail.value.timeline.events.filter((item) => item.layer === layer);
}
function toggleLayerVisibility(layer: string) {
const current = new Set(visibleLayers.value);
if (current.has(layer)) {
current.delete(layer);
} else {
current.add(layer);
}
visibleLayers.value = Array.from(current);
}
function openTaskDetail(recordTaskId?: string) {
if (!recordTaskId) {
return;
}
router.push({ name: "record-task-detail", params: { id: recordTaskId } });
}
function toLeftStyle(offsetSeconds: number) {
return {
left: `${Math.min(100, Math.max(0, (offsetSeconds / timelineDurationSeconds.value) * 100))}%`
};
}
function toSegmentStyle(segment: RecordSessionTimelineSegment) {
const left = Math.min(100, Math.max(0, (segment.offsetSeconds / timelineDurationSeconds.value) * 100));
const width = Math.max(1.2, (segment.durationSeconds / timelineDurationSeconds.value) * 100);
return {
left: `${left}%`,
width: `${Math.min(100 - left, width)}%`
};
}
function toHeatStyle(bucket: RecordSessionHeatBucket) {
const left = Math.min(100, Math.max(0, (bucket.offsetSeconds / timelineDurationSeconds.value) * 100));
const width = Math.max(1.2, (bucket.durationSeconds / timelineDurationSeconds.value) * 100);
const height = maxHeatCount.value > 0
? `${Math.max(12, Math.round((bucket.messageCount / maxHeatCount.value) * 100))}%`
: "12%";
return {
left: `${left}%`,
width: `${Math.min(100 - left, width)}%`,
height
};
}
function sessionStatusTagType(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 formatDuration(seconds?: number) {
return typeof seconds === "number" && Number.isFinite(seconds)
? `${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`;
}
function formatEventTooltip(event: RecordSessionTimelineEvent) {
return [
event.title,
event.detail,
event.segmentIndex ? `分片 #${event.segmentIndex}` : null,
event.level !== undefined ? `级别 ${logLevelLabelMap[event.level]}` : null,
formatDate(event.occurredAt)
]
.filter((item) => Boolean(item))
.join("\n");
}
function formatSegmentTooltip(segment: RecordSessionTimelineSegment) {
return [
`分片 #${segment.segmentIndex}`,
taskStatusLabelMap[segment.status],
segment.label,
segment.detail,
`${formatDate(segment.startedAt)} - ${formatDate(segment.endedAt)}`
]
.filter((item) => Boolean(item))
.join("\n");
}
function formatHeatTooltip(bucket: RecordSessionHeatBucket) {
return [
`分片 #${bucket.segmentIndex}`,
formatDate(bucket.bucketStartedAt),
`弹幕 ${bucket.messageCount}`
].join("\n");
}
watch(() => props.id, loadDetail);
onMounted(loadDetail);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<h1 class="page-title">会话详情</h1>
<p class="page-subtitle">
以整场直播会话为单位查看分片区间脚本和 webhook 事件以及弹幕热度叠层
</p>
</div>
<el-space class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetail">刷新</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !detail" animated :rows="10" />
<template v-else-if="detail">
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-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="sessionStatusTagType(detail.session.status)">
{{ sessionStatusLabelMap[detail.session.status] }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="直播间">
{{ detail.session.liveRoomTitle }}
</el-descriptions-item>
<el-descriptions-item label="平台">
{{ platformLabelMap[detail.session.platform] }}
</el-descriptions-item>
<el-descriptions-item label="Room ID">
<span class="monospace">{{ detail.session.roomId }}</span>
</el-descriptions-item>
<el-descriptions-item label="保存模式">
{{ saveModeLabelMap[detail.session.saveMode] }}
</el-descriptions-item>
<el-descriptions-item label="输出格式">
{{ outputFormatLabelMap[detail.session.outputFormat] }}
</el-descriptions-item>
<el-descriptions-item label="清晰度">
<span class="monospace">{{ detail.session.preferredQuality }}</span>
</el-descriptions-item>
<el-descriptions-item label="分片数量">
{{ detail.session.segmentCount }}
</el-descriptions-item>
<el-descriptions-item label="总文件大小">
{{ formatFileSize(detail.session.totalFileSizeBytes) }}
</el-descriptions-item>
<el-descriptions-item label="总弹幕事件">
{{ detail.session.totalDanmakuMessageCount }}
</el-descriptions-item>
<el-descriptions-item label="开始时间">
{{ formatDate(detail.session.startedAt || detail.session.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="结束时间">
{{ formatDate(detail.session.endedAt) }}
</el-descriptions-item>
<el-descriptions-item label="错误信息">
{{ detail.session.errorMessage || "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">时间轴说明</h3>
<p class="section-subtitle">时间轴以会话开始时间为零点按层叠加关键事件和热度数据</p>
<div class="legend-grid">
<button
v-for="item in [
{ key: 'session', label: '会话' },
{ key: 'segments', label: '分片' },
{ key: 'processing', label: '转码' },
{ key: 'danmaku', label: '弹幕' },
{ key: 'automation', label: '脚本 / Webhook' }
]"
:key="item.key"
type="button"
class="legend-chip"
:class="{ 'legend-chip--active': visibleLayers.includes(item.key) }"
@click="toggleLayerVisibility(item.key)"
>
{{ item.label }}
</button>
</div>
<div class="timeline-meta">
<span>锚点 {{ formatDate(detail.timeline.anchorAt) }}</span>
<span>总跨度 {{ formatDuration(detail.timeline.totalDurationSeconds) }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">整场事件时间轴</h3>
<p class="section-subtitle">用于快速判断这一场直播在什么时候开始、切片、转码、出错以及弹幕最热。</p>
</div>
</div>
<div class="timeline-shell">
<section v-if="visibleLayers.includes('session')" class="timeline-track">
<div class="timeline-track__label">会话</div>
<div class="timeline-track__body">
<div class="timeline-track__line"></div>
<el-tooltip
v-for="event in sessionEvents"
:key="event.id"
placement="top"
:content="formatEventTooltip(event)"
>
<button
type="button"
class="timeline-marker timeline-marker--session"
:style="toLeftStyle(event.offsetSeconds)"
></button>
</el-tooltip>
</div>
</section>
<section v-if="visibleLayers.includes('segments')" class="timeline-track">
<div class="timeline-track__label">分片</div>
<div class="timeline-track__body">
<div class="timeline-track__line"></div>
<el-tooltip
v-for="segment in segmentItems"
:key="segment.recordTaskId"
placement="top"
:content="formatSegmentTooltip(segment)"
>
<button
type="button"
class="timeline-segment"
:class="`timeline-segment--${sessionStatusTagType(segment.status)}`"
:style="toSegmentStyle(segment)"
@click="openTaskDetail(segment.recordTaskId)"
>
#{{ segment.segmentIndex }}
</button>
</el-tooltip>
</div>
</section>
<section v-if="visibleLayers.includes('processing')" class="timeline-track">
<div class="timeline-track__label">转码</div>
<div class="timeline-track__body">
<div class="timeline-track__line"></div>
<el-tooltip
v-for="event in processingEvents"
:key="event.id"
placement="top"
:content="formatEventTooltip(event)"
>
<button
type="button"
class="timeline-marker timeline-marker--processing"
:style="toLeftStyle(event.offsetSeconds)"
@click="openTaskDetail(event.recordTaskId)"
></button>
</el-tooltip>
</div>
</section>
<section v-if="visibleLayers.includes('danmaku')" class="timeline-track">
<div class="timeline-track__label">弹幕</div>
<div class="timeline-track__body timeline-track__body--heat">
<div class="timeline-track__line"></div>
<el-tooltip
v-for="bucket in heatBuckets"
:key="`${bucket.recordTaskId}-${bucket.bucketStartedAt}`"
placement="top"
:content="formatHeatTooltip(bucket)"
>
<button
type="button"
class="timeline-heat"
:style="toHeatStyle(bucket)"
@click="openTaskDetail(bucket.recordTaskId)"
></button>
</el-tooltip>
<el-tooltip
v-for="event in danmakuEvents"
:key="event.id"
placement="top"
:content="formatEventTooltip(event)"
>
<button
type="button"
class="timeline-marker timeline-marker--danmaku"
:style="toLeftStyle(event.offsetSeconds)"
@click="openTaskDetail(event.recordTaskId)"
></button>
</el-tooltip>
</div>
</section>
<section v-if="visibleLayers.includes('automation')" class="timeline-track">
<div class="timeline-track__label">脚本 / Webhook</div>
<div class="timeline-track__body">
<div class="timeline-track__line"></div>
<el-tooltip
v-for="event in automationEvents"
:key="event.id"
placement="top"
:content="formatEventTooltip(event)"
>
<button
type="button"
class="timeline-marker timeline-marker--automation"
:style="toLeftStyle(event.offsetSeconds)"
@click="openTaskDetail(event.recordTaskId)"
></button>
</el-tooltip>
</div>
</section>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">分片列表</h3>
<p class="section-subtitle">每个分片仍然可以继续跳转到现有分片详情页。</p>
</div>
</div>
<div class="table-scroll-shell">
<el-table :data="detail.timeline.segments" class="premium-table" table-layout="auto">
<el-table-column label="分片" width="90">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="sessionStatusTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="文件" min-width="320">
<template #default="{ row }">
<span class="monospace segment-label">{{ row.label || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="开始" width="180">
<template #default="{ row }">
{{ formatDate(row.startedAt) }}
</template>
</el-table-column>
<el-table-column label="结束" width="180">
<template #default="{ row }">
{{ formatDate(row.endedAt) }}
</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="120">
<template #default="{ row }">
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">原始关联日志</h3>
<p class="section-subtitle">时间轴只展示关键节点,完整排障仍然以原始日志为准。</p>
</div>
</div>
<div class="table-scroll-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table" table-layout="auto">
<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="260" prop="message" />
<el-table-column label="详情" min-width="320">
<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>
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.legend-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.legend-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--border-base);
border-radius: 10px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.legend-chip--active {
border-color: rgba(47, 111, 180, 0.28);
background: var(--accent-soft);
color: var(--accent);
}
.timeline-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--text-secondary);
font-size: 13px;
}
.timeline-shell {
display: grid;
gap: 18px;
}
.timeline-track {
display: grid;
gap: 10px;
}
.timeline-track__label {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.timeline-track__body {
position: relative;
min-height: 50px;
padding: 8px 0;
}
.timeline-track__body--heat {
min-height: 94px;
}
.timeline-track__line {
position: absolute;
inset: 50% 0 auto;
height: 2px;
transform: translateY(-50%);
background: var(--border-base);
}
.timeline-marker,
.timeline-segment,
.timeline-heat {
position: absolute;
border: none;
cursor: pointer;
}
.timeline-marker {
top: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 999px;
transform: translateY(-50%);
box-shadow: var(--shadow-soft);
}
.timeline-marker--session {
background: var(--accent);
}
.timeline-marker--processing {
background: var(--warning);
}
.timeline-marker--danmaku {
background: var(--success);
}
.timeline-marker--automation {
background: #7c5db0;
}
.timeline-segment {
top: 50%;
min-width: 14px;
height: 24px;
margin-top: -12px;
border-radius: 8px;
color: var(--text-inverse);
font-size: 12px;
font-weight: 700;
line-height: 24px;
text-align: center;
overflow: hidden;
}
.timeline-segment--success {
background: var(--success);
}
.timeline-segment--danger {
background: var(--danger);
}
.timeline-segment--info {
background: var(--text-muted);
}
.timeline-segment--warning {
background: var(--warning);
}
.timeline-heat {
bottom: 0;
min-width: 6px;
border-radius: 8px 8px 0 0;
background: linear-gradient(180deg, rgba(53, 139, 109, 0.92), rgba(53, 139, 109, 0.48));
}
.segment-label,
.log-detail {
font-size: 12px;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
}
</style>
+10 -10
View File
@@ -407,11 +407,11 @@ onMounted(loadDetailAndPreview);
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
border-bottom: 1px solid var(--border-subtle);
}
.preview-meta {
color: #7c7367;
color: var(--text-muted);
font-size: 12px;
}
@@ -425,8 +425,8 @@ onMounted(loadDetailAndPreview);
width: 100%;
max-height: 560px;
border-radius: 12px;
background: #111;
box-shadow: 0 18px 36px rgba(17, 17, 17, 0.18);
background: #09121d;
box-shadow: var(--shadow-card);
}
.preview-empty {
@@ -435,9 +435,9 @@ onMounted(loadDetailAndPreview);
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));
border: 1px dashed var(--border-base);
color: var(--text-muted);
background: var(--surface-muted);
}
.detail-postprocess {
@@ -448,18 +448,18 @@ onMounted(loadDetailAndPreview);
.detail-postprocess__stage {
font-size: 13px;
font-weight: 600;
color: #31465b;
color: var(--text-primary);
}
.detail-postprocess__detail {
font-size: 12px;
color: #6c7a86;
color: var(--text-secondary);
line-height: 1.4;
}
.log-detail {
font-size: 12px;
color: #6d665b;
color: var(--text-secondary);
}
@media (max-width: 768px) {
+171 -23
View File
@@ -215,6 +215,10 @@ function openDetail(task: RecordTask) {
router.push({ name: "record-task-detail", params: { id: task.id } });
}
function openSessionDetail(session: RecordSession) {
router.push({ name: "record-session-detail", params: { id: session.id } });
}
function openDeleteTaskDialog(taskIds: string[]) {
if (taskIds.length === 0) {
ElMessage.warning("请选择可删除的分片任务。");
@@ -387,13 +391,14 @@ onBeforeUnmount(() => {
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Recording Workspace</div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
按直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变更保留手动刷新入口用于兜底
</p>
</div>
<el-space wrap class="header-actions">
<div class="page-toolbar">
<el-button @click="loadSessions()">刷新列表</el-button>
<el-button
type="danger"
@@ -404,7 +409,7 @@ onBeforeUnmount(() => {
>
删除已选分片{{ selectedTasks.length > 0 ? `${selectedTasks.length}` : "" }}
</el-button>
</el-space>
</div>
</div>
<el-alert
@@ -453,6 +458,116 @@ onBeforeUnmount(() => {
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
<div v-else-if="isMobile" class="data-card-list session-card-list">
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
</div>
<el-tag :type="sessionTagType(session.status)">
{{ sessionStatusLabelMap[session.status] }}
</el-tag>
</div>
<div class="badge-row">
<span class="info-pill">{{ saveModeLabelMap[session.saveMode] }}</span>
<span class="info-pill">{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span class="info-pill">分片 {{ session.segmentCount }}</span>
<span class="info-pill">{{ formatFileSize(session.totalFileSizeBytes) }}</span>
</div>
<div class="data-card__grid">
<div>
<dt>开始时间</dt>
<dd>{{ formatDate(session.startedAt || session.createdAt) }}</dd>
</div>
<div>
<dt>结束时间</dt>
<dd>{{ formatDate(session.endedAt) }}</dd>
</div>
<div>
<dt>活动分片</dt>
<dd>{{ session.activeSegmentIndex || "-" }}</dd>
</div>
<div>
<dt>弹幕事件</dt>
<dd>{{ session.totalDanmakuMessageCount }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button size="small" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-button
v-if="isActiveStatus(session.status)"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click.stop="stopSession(session)"
>
停止会话
</el-button>
<el-button size="small" type="danger" plain :loading="deleting" @click.stop="openDeleteSessionDialog([session.id])">
删除会话
</el-button>
</div>
<div class="session-card__tasks">
<article v-for="task in session.tasks" :key="task.id" class="session-task-card">
<div class="session-task-card__head">
<div>
<strong class="monospace">#{{ task.segmentIndex }}</strong>
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
</div>
<el-tag :type="taskTagType(task.status)">{{ taskStatusLabelMap[task.status] }}</el-tag>
</div>
<div v-if="hasPostProcess(task)" class="session-task-card__progress">
<div class="cell-subtitle">{{ task.postProcessStage }}</div>
<el-progress
:percentage="Math.max(0, Math.min(100, task.postProcessProgressPercent ?? 0))"
:stroke-width="6"
:show-text="false"
/>
<div class="task-status-cell__detail">
{{ task.postProcessDetail || formatProgress(task.postProcessProgressPercent) }}
</div>
</div>
<div class="data-card__grid">
<div>
<dt>清晰度</dt>
<dd class="monospace">{{ task.preferredQuality }}</dd>
</div>
<div>
<dt>时长</dt>
<dd>{{ formatDuration(task.durationSeconds) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>输出文件</dt>
<dd class="monospace">{{ task.outputFilePath || "-" }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button size="small" @click="openDetail(task)">详情</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([task.id])"
>
删除
</el-button>
</div>
</article>
</div>
</article>
</div>
<el-collapse v-else v-model="activeSessionPanels" class="session-collapse">
<el-collapse-item
v-for="session in sessions"
@@ -503,6 +618,9 @@ onBeforeUnmount(() => {
</div>
<div class="session-actions">
<el-button @click.stop="openSessionDetail(session)">
查看会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
type="danger"
@@ -687,7 +805,7 @@ onBeforeUnmount(() => {
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
border-bottom: 1px solid var(--border-subtle);
}
.session-collapse {
@@ -696,16 +814,16 @@ onBeforeUnmount(() => {
}
.session-panel {
border: 1px solid rgba(95, 109, 124, 0.12);
border: 1px solid var(--border-base);
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
}
.session-panel :deep(.el-collapse-item__header) {
height: auto;
padding: 18px 20px;
border-bottom: 1px solid rgba(95, 109, 124, 0.08);
border-bottom: 1px solid var(--border-subtle);
background: transparent;
}
@@ -733,14 +851,14 @@ onBeforeUnmount(() => {
.session-title__name {
font-size: 16px;
font-weight: 700;
color: #17202b;
color: var(--text-primary);
}
.session-title__meta {
display: flex;
gap: 12px;
margin-top: 6px;
color: #6d7781;
color: var(--text-muted);
font-size: 12px;
}
@@ -750,7 +868,7 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
color: #61707d;
color: var(--text-secondary);
font-size: 12px;
}
@@ -771,13 +889,13 @@ onBeforeUnmount(() => {
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;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.session-summary__label {
color: #75808a;
color: var(--text-muted);
font-size: 12px;
}
@@ -787,6 +905,36 @@ onBeforeUnmount(() => {
gap: 12px;
}
.session-card {
gap: 16px;
}
.session-card__tasks {
display: grid;
gap: 10px;
}
.session-task-card {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.session-task-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.session-task-card__progress {
display: grid;
gap: 8px;
}
.nested-table {
border-radius: 12px;
min-width: 1220px;
@@ -802,7 +950,7 @@ onBeforeUnmount(() => {
.path-text {
font-size: 12px;
color: #65717c;
color: var(--text-secondary);
line-height: 1.6;
}
@@ -820,12 +968,12 @@ onBeforeUnmount(() => {
.task-status-cell__stage {
font-size: 12px;
font-weight: 600;
color: #31465b;
color: var(--text-primary);
}
.task-status-cell__detail {
font-size: 12px;
color: #6c7a86;
color: var(--text-secondary);
line-height: 1.4;
}
@@ -851,7 +999,7 @@ onBeforeUnmount(() => {
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e7c88;
color: var(--text-muted);
}
.delete-dialog__title {
@@ -859,7 +1007,7 @@ onBeforeUnmount(() => {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: #15202b;
color: var(--text-primary);
}
.delete-dialog__body {
@@ -868,7 +1016,7 @@ onBeforeUnmount(() => {
}
.delete-dialog__lead {
color: #4f5d69;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
@@ -878,19 +1026,19 @@ onBeforeUnmount(() => {
gap: 8px;
padding: 16px 18px;
border-radius: 14px;
border: 1px solid rgba(183, 80, 70, 0.14);
background: rgba(183, 80, 70, 0.06);
border: 1px solid rgba(199, 81, 81, 0.18);
background: rgba(199, 81, 81, 0.08);
}
.delete-dialog__note-title {
font-size: 13px;
font-weight: 700;
color: #8f443a;
color: var(--danger);
}
.delete-dialog__note p {
margin: 0;
color: #6b5d57;
color: var(--text-secondary);
line-height: 1.7;
}
+452
View File
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
RecoverableLiveRoom,
RecoveryActionResult,
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
const loading = ref(false);
const retryAllLoading = ref(false);
const resumeAllLoading = ref(false);
const runningKey = ref("");
const loadError = ref("");
const overview = ref<RecoveryOverview | null>(null);
const { isMobile } = useViewport();
const storage = computed(() => overview.value?.storage ?? null);
const liveRooms = computed(() => overview.value?.liveRooms ?? []);
const finalizations = computed(() => overview.value?.finalizations ?? []);
async function loadOverview() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecoveryOverview>("/recovery");
overview.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "恢复中心加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
async function retryLiveRoom(liveRoomId: string) {
runningKey.value = `retry:${liveRoomId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(`/recovery/live-rooms/${liveRoomId}/retry`);
showActionResult(data);
await loadOverview();
} finally {
runningKey.value = "";
}
}
async function retryAllLiveRooms() {
retryAllLoading.value = true;
try {
const { data } = await apiClient.post<RecoveryActionResult>("/recovery/live-rooms/retry-all");
showActionResult(data);
await loadOverview();
} finally {
retryAllLoading.value = false;
}
}
async function resumeFinalization(recordTaskId: string) {
runningKey.value = `resume:${recordTaskId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(`/recovery/finalizations/${recordTaskId}/resume`);
showActionResult(data);
await loadOverview();
} finally {
runningKey.value = "";
}
}
async function resumeAllFinalizations() {
resumeAllLoading.value = true;
try {
const { data } = await apiClient.post<RecoveryActionResult>("/recovery/finalizations/resume-all");
showActionResult(data);
await loadOverview();
} finally {
resumeAllLoading.value = false;
}
}
function showActionResult(result: RecoveryActionResult) {
const summary = result.messages[0] ?? `成功 ${result.successCount},失败 ${result.failedCount}`;
if (result.failedCount > 0 && result.successCount === 0) {
ElMessage.warning(summary);
return;
}
if (result.failedCount > 0) {
ElMessage.warning(`成功 ${result.successCount} 项,失败 ${result.failedCount}`);
return;
}
ElMessage.success(summary);
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) {
return "-";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
}
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
}
if (code === "failed_startup") {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
return "warning";
}
return "info";
}
function liveRoomTitle(item: RecoverableLiveRoom) {
return item.title || item.anchorName || item.roomId;
}
function finalizationTitle(item: RecoverableFinalization) {
return item.liveRoomTitle || item.roomId;
}
onMounted(loadOverview);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Recovery</div>
<h1 class="page-title">恢复中心</h1>
<p class="page-subtitle">
这里集中处理没有自动拉起的直播间和可继续的 MP4 转码任务上方的存储状态直接复用当前输出目录的保护检查结果
</p>
</div>
<div class="page-toolbar">
<el-button :loading="loading" @click="loadOverview">刷新总览</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid" v-loading="loading">
<div class="stat-card">
<div class="stat-card__label">存储保护</div>
<div class="stat-card__value">{{ storage?.hasEnoughSpace ? "可恢复" : "受限" }}</div>
<div class="stat-card__hint">{{ storage?.message || "暂无数据" }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可重试开录</div>
<div class="stat-card__value">{{ liveRooms.length }}</div>
<div class="stat-card__hint">在线启用中且当前没有活动会话的直播间</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可恢复转码</div>
<div class="stat-card__value">{{ finalizations.length }}</div>
<div class="stat-card__hint">等待继续或可手动补转码的 MP4 任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">剩余空间</div>
<div class="stat-card__value">{{ storage ? formatBytes(storage.availableBytes) : "-" }}</div>
<div class="stat-card__hint">恢复阈值 {{ storage ? formatBytes(storage.requiredBytes) : "-" }}</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>
<el-button
type="primary"
:loading="retryAllLoading"
:disabled="liveRooms.length === 0"
@click="retryAllLiveRooms"
>
重试全部直播间
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
</div>
<div class="badge-row">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span>
</div>
<div class="data-card__grid">
<div>
<dt>房间 ID</dt>
<dd class="monospace">{{ row.roomId }}</dd>
</div>
<div>
<dt>最近决策</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无摘要" }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.lastAutoStartDecisionDetail">
<dt>原因详情</dt>
<dd>{{ row.lastAutoStartDecisionDetail }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button
type="primary"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>
立即重试
</el-button>
</div>
</article>
</div>
<el-table
v-else
:data="liveRooms"
v-loading="loading"
class="premium-table"
table-layout="auto"
row-key="liveRoomId"
>
<el-table-column label="直播间" min-width="280">
<template #default="{ row }">
<div class="cell-title">{{ liveRoomTitle(row) }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近决策" min-width="320">
<template #default="{ row }">
<div class="decision-cell">
<div class="decision-cell__head">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<div v-if="row.lastAutoStartDecisionDetail" class="decision-cell__detail">
{{ row.lastAutoStartDecisionDetail }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="最近巡检" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>
立即重试
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
<h3 class="section-title">可恢复的 MP4 转码任务</h3>
<p class="section-subtitle">这里列出已暂停重启中断或仍保留中间文件可继续补转码的任务</p>
</div>
<el-button
type="primary"
:loading="resumeAllLoading"
:disabled="finalizations.length === 0"
@click="resumeAllFinalizations"
>
恢复全部转码
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<article v-for="row in finalizations" :key="row.recordTaskId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
</div>
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
</div>
<div class="data-card__grid">
<div>
<dt>房间 ID</dt>
<dd class="monospace">{{ row.roomId }}</dd>
</div>
<div>
<dt>结束时间</dt>
<dd>{{ formatDate(row.endedAt || row.createdAt) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>原因</dt>
<dd>{{ row.reason || "可继续恢复转码" }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.outputFilePath">
<dt>输出路径</dt>
<dd class="monospace">{{ row.outputFilePath }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button
type="primary"
size="small"
:loading="runningKey === `resume:${row.recordTaskId}`"
@click="resumeFinalization(row.recordTaskId)"
>
恢复转码
</el-button>
</div>
</article>
</div>
<el-table
v-else
:data="finalizations"
v-loading="loading"
class="premium-table"
table-layout="auto"
row-key="recordTaskId"
>
<el-table-column label="任务" min-width="280">
<template #default="{ row }">
<div class="cell-title">{{ finalizationTitle(row) }}</div>
<div class="cell-subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="300">
<template #default="{ row }">
<div class="cell-subtitle">{{ row.reason || "可继续恢复转码" }}</div>
<div v-if="row.outputFilePath" class="cell-mono monospace">{{ row.outputFilePath }}</div>
</template>
</el-table-column>
<el-table-column label="结束时间" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.endedAt || row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="runningKey === `resume:${row.recordTaskId}`"
@click="resumeFinalization(row.recordTaskId)"
>
恢复转码
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.decision-cell {
display: grid;
gap: 6px;
}
.decision-cell__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.decision-cell__detail {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
File diff suppressed because it is too large Load Diff