feat: redesign responsive control room ui
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
|
||||
useUiPreferences();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+493
-228
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
+601
-195
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user