feat: improve recording recovery and upload workflow

This commit is contained in:
2026-08-10 11:14:34 +08:00
parent e0d3969e46
commit 19d358b312
58 changed files with 5979 additions and 586 deletions
+173 -117
View File
@@ -1,130 +1,107 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useUiPreferences } from "@/composables/useUiPreferences";
import { useViewport } from "@/composables/useViewport";
import {
Bell,
Collection,
DataAnalysis,
Document,
FolderOpened,
Fold,
House,
Menu,
Moon,
RefreshRight,
Setting,
Sunny,
Tickets,
Upload,
VideoCamera
} from "@element-plus/icons-vue";
import { Bell, Collection, DataAnalysis, Menu, Moon, Setting, Sunny, Tickets, VideoCamera } from "@element-plus/icons-vue";
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { isMobile } = useViewport();
const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus();
const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences();
const mobileNavVisible = ref(false);
const navigationGroups = [
{
key: "overview",
title: "总览",
key: "workspace",
title: "工作区",
items: [
{ index: "/", label: "仪表盘", icon: DataAnalysis },
{ index: "/", label: "运行中心", shortLabel: "运行", icon: DataAnalysis, names: ["dashboard"] },
{ index: "/live-rooms", label: "直播与录制", shortLabel: "录制", icon: VideoCamera, names: ["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"] },
{ index: "/transcode-tasks", label: "处理与归档", shortLabel: "归档", icon: Collection, names: ["transcode-tasks", "upload-tasks", "media-browser"] },
{ index: "/daily-reviews", label: "分析与排障", shortLabel: "分析", icon: Tickets, names: ["daily-reviews", "logs"] }
]
},
{
key: "monitor",
title: "监控录制",
key: "manage",
title: "管理",
items: [
{ index: "/live-rooms", label: "直播间", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
]
},
{
key: "media",
title: "媒资归档",
items: [
{ index: "/transcode-tasks", label: "转码任务", icon: Collection },
{ index: "/upload-tasks", label: "上传任务", icon: Upload },
{ index: "/media-browser", label: "文件库", icon: FolderOpened }
]
},
{
key: "analysis",
title: "分析",
items: [
{ index: "/daily-reviews", label: "回顾日报", icon: Document },
{ index: "/logs", label: "系统日志", icon: Tickets }
]
},
{
key: "system",
title: "系统",
items: [
{ index: "/settings", label: "系统设置", icon: Setting }
{ index: "/settings", label: "系统设置", shortLabel: "设置", icon: Setting, names: ["settings"] }
]
}
];
const bottomNavigation = computed(() => navigationGroups.flatMap((group) => group.items));
const workspaceTabs = computed(() => {
const name = String(route.name ?? "");
if (["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"].includes(name)) {
return [
{ name: "live-rooms", label: "直播间" },
{ name: "record-tasks", label: "录制会话" },
{ name: "recovery", label: "恢复中心" }
];
}
if (["transcode-tasks", "upload-tasks", "media-browser"].includes(name)) {
return [
{ name: "transcode-tasks", label: "转码队列" },
{ name: "upload-tasks", label: "上传队列" },
{ name: "media-browser", label: "文件库" }
];
}
if (["daily-reviews", "logs"].includes(name)) {
return [
{ name: "daily-reviews", label: "回顾日报" },
{ name: "logs", label: "系统日志" }
];
}
return [];
});
const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
function isNavItemActive(index: string) {
return route.path === index || route.path.startsWith(`${index}/`);
function isNavItemActive(item: { names: string[] }) {
return item.names.includes(String(route.name ?? ""));
}
function navigate(index: string) { void router.push(index); }
function isWorkspaceTabActive(name: string) {
const current = String(route.name ?? "");
if (name === "record-tasks") return ["record-tasks", "record-task-detail", "record-session-detail"].includes(current);
return current === name;
}
const pageBreadcrumb = computed(() => {
const m: Record<string, string> = {
dashboard: "总览 / 仪表盘",
"live-rooms": "监控录制 / 直播间",
"record-tasks": "监控录制 / 录制任务",
"transcode-tasks": "媒资归档 / 转码任务",
"upload-tasks": "媒资归档 / 上传任务",
"media-browser": "媒资归档 / 文件库",
"record-task-detail": "监控录制 / 录制任务 / 分片详情",
"record-session-detail": "监控录制 / 录制任务 / 会话详情",
"daily-reviews": "分析 / 回顾日报",
logs: "分析 / 系统日志",
recovery: "监控录制 / 恢复中心",
dashboard: "工作台 / 运行中心",
"live-rooms": "直播与录制 / 直播间",
"record-tasks": "直播与录制 / 录制会话",
"transcode-tasks": "处理与归档 / 转码队列",
"upload-tasks": "处理与归档 / 上传队列",
"media-browser": "处理与归档 / 文件库",
"record-task-detail": "直播与录制 / 分片详情",
"record-session-detail": "直播与录制 / 会话详情",
"daily-reviews": "分析与排障 / 回顾日报",
logs: "分析与排障 / 系统日志",
recovery: "直播与录制 / 恢复中心",
settings: "系统 / 系统设置"
};
return m[String(route.name)] || "控制台";
});
async function handleLogout() {
mobileNavVisible.value = false;
await authStore.logout();
await router.push({ name: "login" });
}
function openMobileNav() { mobileNavVisible.value = true; }
function closeMobileNav() { mobileNavVisible.value = false; }
watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
</script>
<template>
<div class="app-shell">
<!-- mobile scrim -->
<div
class="scrim"
:class="{ open: mobileNavVisible }"
@click="closeMobileNav"
/>
<!-- sidebar -->
<aside class="app-sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileNavVisible }">
<aside class="app-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="app-brand">
<div class="app-brand__mark">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
@@ -151,7 +128,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
<button
type="button"
class="app-nav-icon-item"
:class="{ 'is-active': isNavItemActive(item.index) }"
:class="{ 'is-active': isNavItemActive(item) }"
@click="navigate(item.index)"
>
<el-icon :size="18"><component :is="item.icon" /></el-icon>
@@ -163,7 +140,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
v-for="item in group.items" :key="item.index"
type="button"
class="app-nav-item"
:class="{ 'is-active': isNavItemActive(item.index) }"
:class="{ 'is-active': isNavItemActive(item) }"
@click="navigate(item.index)"
>
<el-icon :size="18"><component :is="item.icon" /></el-icon>
@@ -186,9 +163,10 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
<!-- main -->
<div class="app-main-col">
<header class="app-topbar">
<button class="app-topbar__menu-btn" @click="isMobile ? openMobileNav() : toggleSidebarCollapsed()">
<button class="app-topbar__menu-btn" @click="toggleSidebarCollapsed">
<el-icon :size="20"><Menu /></el-icon>
</button>
<div class="app-mobile-brand" aria-label="Live Recorder">LR</div>
<div class="app-breadcrumb">{{ pageBreadcrumb }}</div>
<div class="app-topbar__spacer" />
@@ -201,8 +179,18 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
</button>
<el-dropdown trigger="click">
<button type="button" class="app-user-btn">
<span class="app-user-btn__avatar">{{ userAvatarText }}</span>
<button type="button" class="app-user-btn" aria-label="管理员菜单" title="管理员菜单">
<svg
class="app-user-btn__avatar"
viewBox="0 0 36 36"
width="36"
height="36"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<circle class="app-user-btn__avatar-circle" cx="18" cy="18" r="17.5" />
<text class="app-user-btn__avatar-text" x="18" y="18">{{ userAvatarText }}</text>
</svg>
<span class="app-user-btn__name desktop-only">{{ userDisplayName }}</span>
</button>
<template #dropdown>
@@ -215,14 +203,49 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
</header>
<main class="app-main">
<nav v-if="workspaceTabs.length" class="workspace-tabs" aria-label="工作区页面切换">
<button
v-for="tab in workspaceTabs"
:key="tab.name"
type="button"
:class="{ 'is-active': isWorkspaceTabActive(tab.name) }"
@click="router.push({ name: tab.name })"
>
{{ tab.label }}
</button>
</nav>
<el-alert
v-if="backendUnavailable && route.name !== 'record-tasks'"
class="backend-alert"
type="error" :closable="false" show-icon
title="后端服务暂时不可用" :description="backendMessage"
/>
<router-view />
<router-view v-slot="{ Component, route: viewRoute }">
<transition name="page-swap" mode="out-in">
<component
:is="Component"
:key="String(viewRoute.name ?? viewRoute.path)"
class="app-route-view"
/>
</transition>
</router-view>
</main>
<nav class="app-bottom-nav" aria-label="移动端主导航">
<button
v-for="item in bottomNavigation"
:key="item.index"
type="button"
:class="{ 'is-active': isNavItemActive(item) }"
:aria-current="isNavItemActive(item) ? 'page' : undefined"
@click="navigate(item.index)"
>
<span class="app-bottom-nav__icon" aria-hidden="true">
<el-icon :size="19"><component :is="item.icon" /></el-icon>
</span>
<span class="app-bottom-nav__label">{{ item.shortLabel }}</span>
</button>
</nav>
</div>
</div>
</template>
@@ -230,9 +253,6 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
<style scoped>
/* ==================== Shell ==================== */
.app-shell { display: flex; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); z-index: 39; opacity: 0; pointer-events: none; transition: opacity .2s; }
.scrim.open { opacity: 1; pointer-events: auto; }
/* ==================== Sidebar ==================== */
.app-sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 30;
@@ -242,45 +262,44 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
}
.app-sidebar.collapsed { width: var(--sidebar-w-collapsed); }
.app-brand { display: flex; align-items: center; gap: 11px; height: var(--topbar-h); padding: 0 18px; flex-shrink: 0; border-bottom: 1px solid var(--side-border); }
.app-brand__mark { width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; display: grid; place-items: center; color: #fff; background: var(--accent); box-shadow: 0 2px 8px rgba(26,86,219,.25); }
html[data-theme="dark"] .app-brand__mark { background: #1a2438; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
.app-brand__name { font-weight: 800; font-size: 16px; letter-spacing: -.02em; color: var(--text-primary); white-space: nowrap; }
html[data-theme="dark"] .app-brand__name { color: #eaf0fb; }
.app-brand__sub { font-size: 11px; color: var(--side-title); white-space: nowrap; margin-top: 1px; }
.app-brand { display: flex; align-items: center; gap: 11px; height: var(--topbar-h); padding: 0 17px; flex-shrink: 0; border-bottom: 1px solid var(--side-border); }
.app-brand__mark { width: 34px; height: 34px; flex-shrink: 0; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--action); box-shadow: 0 8px 22px color-mix(in srgb, var(--action) 26%, transparent); }
html[data-theme="dark"] .app-brand__mark { background: var(--action); box-shadow: 0 8px 22px rgba(0,0,0,.28); }
.app-brand__name { font-weight: 780; font-size: 14px; letter-spacing: -.01em; color: var(--text-primary); white-space: nowrap; }
.app-brand__sub { font-size: 10px; color: var(--side-title); white-space: nowrap; margin-top: 1px; }
.collapsed .app-brand__copy { display: none; }
.app-nav { flex: 1; overflow-y: auto; padding: 14px 12px 8px; }
.app-nav-group { margin-bottom: 16px; }
.app-nav-group__title { padding: 6px 10px; font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--side-title); }
.app-nav { flex: 1; overflow-y: auto; padding: 14px 10px 8px; }
.app-nav-group { margin-bottom: 10px; }
.app-nav-group__title { padding: 8px 10px 7px; font-size: 10px; font-weight: 750; letter-spacing: .12em; color: var(--side-title); }
.collapsed .app-nav-group__title { font-size: 0; padding: 6px 0; text-align: center; }
.collapsed .app-nav-group__title::after { content: "•"; font-size: 13px; }
.app-nav-item {
display: flex; align-items: center; gap: 11px; width: 100%;
padding: 9px 11px; margin-bottom: 2px; border: 0; border-radius: 6px;
background: transparent; color: var(--side-item); font-weight: 600; font-size: 14px;
min-height: 43px; padding: 0 11px; margin-bottom: 4px; border: 0; border-radius: 9px;
background: transparent; color: var(--side-item); font-weight: 650; font-size: 13px;
text-align: left; cursor: pointer; transition: background .15s, color .15s;
}
.app-nav-item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
.app-nav-item.is-active { background: var(--side-active-bg); color: var(--side-active); }
.app-nav-item.is-active { background: var(--side-active-bg); color: var(--side-active); box-shadow: inset 1px 0 var(--side-active); }
.app-nav-icon-list { display: grid; gap: 8px; }
.app-nav-icon-item {
display: grid; place-items: center; min-height: 46px; border: 0; border-radius: 6px;
display: grid; place-items: center; min-height: 43px; border: 0; border-radius: 9px;
background: transparent; color: var(--side-item); cursor: pointer;
}
.app-nav-icon-item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
.app-nav-icon-item.is-active { background: var(--side-active-bg); color: var(--side-active); }
.app-nav-icon-item.is-active { background: var(--side-active-bg); color: var(--side-active); box-shadow: inset 1px 0 var(--side-active); }
.app-sidebar__foot { padding: 12px; border-top: 1px solid var(--side-border); }
.app-health { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: rgba(22,163,74,.10); border: 1px solid rgba(22,163,74,.20); }
.app-health { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 10px; border-radius: 10px; background: var(--success-soft); border: 1px solid color-mix(in srgb, var(--success) 22%, var(--side-border)); }
html[data-theme="dark"] .app-health { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.22); }
.app-health.is-danger { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.app-health__dot { width: 8px; height: 8px; border-radius: 99px; background: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.18); }
html[data-theme="dark"] .app-health__dot { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.22); }
.app-health.is-danger .app-health__dot { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
.app-health__txt { font-size: 12.5px; font-weight: 600; color: #15803d; }
.app-health__txt { font-size: 11px; font-weight: 700; color: var(--success); }
html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
.app-health.is-danger .app-health__txt { color: var(--danger); }
.collapsed .app-health__txt { display: none; }
@@ -298,41 +317,78 @@ html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
.app-topbar {
position: relative; z-index: 20; flex: 0 0 var(--topbar-h);
height: var(--topbar-h); display: flex; align-items: center; gap: 12px;
padding: 0 24px;
background: color-mix(in srgb, var(--surface) 70%, transparent);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(15,23,42,.07);
padding: 0 22px;
background: color-mix(in srgb, var(--surface) 96%, transparent);
border-bottom: 1px solid var(--border-subtle);
}
html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06); }
.app-topbar__menu-btn { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; }
.app-topbar__menu-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.app-mobile-brand { display: none; width: 27px; height: 27px; place-items: center; border-radius: 8px; color: #fff; background: var(--action); font-size: 10px; font-weight: 800; }
.app-breadcrumb { font-size: 13.5px; color: var(--text-muted); min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.app-topbar__spacer { flex: 1; }
.app-topbar__icon-btn { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; }
.app-topbar__icon-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.app-user-btn { display: flex; align-items: center; gap: 9px; padding: 4px 10px 4px 4px; border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface); cursor: pointer; }
.app-user-btn:hover { background: var(--surface-hover); }
.app-user-btn__avatar { width: 30px; height: 30px; flex-shrink: 0; border-radius: 99px; display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 13px; background: var(--accent); }
.app-user-btn__avatar { display: block; width: 30px; min-width: 30px; height: 30px; min-height: 30px; flex: 0 0 30px; overflow: visible; }
.app-user-btn__avatar-circle { fill: var(--purple); stroke: color-mix(in srgb, var(--purple) 76%, var(--border-subtle)); stroke-width: 1; vector-effect: non-scaling-stroke; }
.app-user-btn__avatar-text { fill: #fff; font-family: var(--font-sans); font-size: 13px; font-weight: 750; text-anchor: middle; dominant-baseline: central; }
.app-user-btn__name { font-size: 13px; font-weight: 700; }
/* ==================== Main ==================== */
.app-main {
flex: 1; min-width: 0; min-height: 0; padding: 24px 28px 0;
overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable;
background:
radial-gradient(ellipse at 88% -12%, var(--ambient-blue), transparent 34%),
radial-gradient(ellipse at 98% 8%, var(--ambient-violet), transparent 28%),
radial-gradient(ellipse at 76% -18%, var(--ambient-warm), transparent 25%),
var(--bg-base);
}
.backend-alert { width: min(100%, var(--page-max)); margin: 0 auto 18px; border-radius: var(--radius-md); }
.workspace-tabs { display: flex; align-items: center; gap: 4px; width: min(100%, var(--page-max)); margin: 0 auto 16px; padding: 4px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); }
.workspace-tabs button { min-height: 33px; padding: 0 13px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; font-size: 11px; font-weight: 700; }
.workspace-tabs button:hover { color: var(--text-primary); background: var(--surface-muted); }
.workspace-tabs button.is-active { color: var(--accent); background: var(--accent-soft); }
.app-bottom-nav { display: none; height: calc(65px + env(safe-area-inset-bottom)); flex: 0 0 calc(65px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid var(--border-subtle); background: var(--surface); }
.app-bottom-nav button { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; padding: 4px 0 3px; border: 0; color: var(--text-muted); background: transparent; font-size: 9px; transition: color .2s ease; }
.app-bottom-nav__icon { display: grid; width: 44px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 99px; background: transparent; transform: scale(.84); transition: background-color .18s ease, transform .24s cubic-bezier(.2,.8,.2,1); }
.app-bottom-nav__icon :deep(.el-icon) { transition: transform .24s cubic-bezier(.2,.8,.2,1); }
.app-bottom-nav__label { max-width: 100%; overflow: hidden; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; transition: color .2s ease, font-weight .2s ease; }
.app-bottom-nav button.is-active { color: var(--accent); }
.app-bottom-nav button.is-active .app-bottom-nav__icon { background: var(--accent-soft); transform: scale(1); }
.app-bottom-nav button.is-active .app-bottom-nav__icon :deep(.el-icon) { transform: scale(1.05); }
.app-bottom-nav button.is-active .app-bottom-nav__label { font-weight: 750; }
.app-bottom-nav button:active .app-bottom-nav__icon { transform: scale(.9); transition-duration: .08s; }
.page-swap-enter-active { transition: opacity .2s ease, transform .24s cubic-bezier(.2,.8,.2,1); }
.page-swap-leave-active { transition: opacity .12s ease, transform .12s ease; }
.page-swap-enter-from { opacity: 0; transform: translateY(7px); }
.page-swap-leave-to { opacity: 0; transform: translateY(-3px); }
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.app-sidebar { transform: translateX(-100%); transition: transform .24s cubic-bezier(.4,0,.2,1); }
.app-sidebar.mobile-open { transform: translateX(0); width: 280px; z-index: 50; }
.app-sidebar:not(.mobile-open) .app-brand { display: none; }
.app-sidebar:not(.mobile-open) .app-sidebar__foot { display: none; }
.app-sidebar { display: none; }
.app-main-col { margin-left: 0 !important; }
.app-main { padding: 12px 12px 0; }
.app-main {
padding: 12px 12px 0;
background:
radial-gradient(240px 170px at 108% -5%, color-mix(in srgb, var(--ambient-blue) 58%, transparent), transparent 74%),
radial-gradient(180px 130px at 96% -12%, color-mix(in srgb, var(--ambient-violet) 46%, transparent), transparent 76%),
var(--bg-base);
}
.app-topbar { padding: 0 10px; gap: 6px; }
.app-topbar__menu-btn { display: grid; }
.app-topbar__menu-btn { display: none; }
.app-mobile-brand { display: grid; }
.app-breadcrumb { font-size: 11.5px; max-width: 130px; }
.app-topbar :deep(.el-dropdown) { display: block; width: 36px; min-width: 36px; height: 36px; flex: 0 0 36px; line-height: 0; }
.app-user-btn { display: grid; width: 36px; min-width: 36px; height: 36px; min-height: 36px; padding: 0; flex: 0 0 36px; place-items: center; border: 0; border-radius: 50%; background: transparent; box-shadow: none; }
.app-user-btn:hover { background: transparent; }
.app-user-btn__avatar { width: 36px; min-width: 36px; height: 36px; min-height: 36px; flex-basis: 36px; filter: drop-shadow(0 3px 5px color-mix(in srgb, var(--purple) 24%, transparent)); }
.app-user-btn__name { display: none; }
.workspace-tabs { width: 100%; overflow-x: auto; margin-bottom: 12px; }
.workspace-tabs button { flex: 1; min-width: max-content; }
.app-bottom-nav { display: flex; }
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from "vue";
const props = withDefaults(defineProps<{
name?: string | null;
showLabel?: boolean;
}>(), {
name: "",
showLabel: true
});
const label = computed(() => props.name?.trim() || "未知平台");
const kind = computed(() => {
const value = label.value.toLowerCase();
if (value.includes("哔哩") || value.includes("bili")) return "bilibili";
if (value.includes("抖音") || value.includes("douyin") || value.includes("tiktok")) return "douyin";
if (value.includes("斗鱼") || value.includes("douyu")) return "douyu";
return "generic";
});
</script>
<template>
<span class="platform-mark" :class="`platform-mark--${kind}`" :title="label">
<svg v-if="kind === 'bilibili'" viewBox="0 0 24 24" aria-hidden="true">
<path d="m8 3 3 3M16 3l-3 3" />
<rect x="3" y="6" width="18" height="14" rx="4" />
<path d="M8.5 11.5v2.5M15.5 11.5v2.5M9 17c1.8 1.1 4.2 1.1 6 0" />
</svg>
<svg v-else-if="kind === 'douyin'" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 4v10.2a4 4 0 1 1-3-3.86V7.1c2.25 1.9 4.35 2.9 7 2.9V7.1c-1.8-.1-3.15-1.05-4-3.1Z" />
</svg>
<svg v-else-if="kind === 'douyu'" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12c3.8-5.4 9.5-6.9 16-4.1l2-2v7.8l-2-1.6C14.1 17.6 7.8 17.8 3 12Z" />
<circle cx="15.8" cy="9.5" r="1" />
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17M12 3.5c2.4 2.4 3.6 5.2 3.6 8.5S14.4 18.1 12 20.5C9.6 18.1 8.4 15.3 8.4 12S9.6 5.9 12 3.5Z" />
</svg>
<span v-if="showLabel">{{ label }}</span>
<span v-else class="sr-only">{{ label }}</span>
</span>
</template>
<style scoped>
.platform-mark { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: var(--text-muted); font-size: inherit; line-height: 1.2; white-space: nowrap; }
.platform-mark svg { width: 17px; height: 17px; flex: 0 0 auto; padding: 2px; border-radius: 4px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.platform-mark--bilibili svg { color: #c93e6d; background: #ffedf3; }
.platform-mark--douyin svg { color: #141923; background: #edf0f5; }
.platform-mark--douyu svg { color: #b94c00; background: #fff0e4; }
.platform-mark--generic svg { color: var(--accent); background: var(--accent-soft); }
:global(html[data-theme="dark"]) .platform-mark--bilibili svg { color: #ff92b4; background: rgba(217, 72, 117, .20); }
:global(html[data-theme="dark"]) .platform-mark--douyin svg { color: #f7f9fc; background: rgba(236, 241, 248, .14); }
:global(html[data-theme="dark"]) .platform-mark--douyu svg { color: #ff9b57; background: rgba(218, 92, 8, .20); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
</style>
+51 -1
View File
@@ -51,8 +51,14 @@ const visible = computed({
</template>
<style scoped>
:deep(.right-drawer .el-drawer__body) {
:global(.right-drawer) {
max-width: 100vw;
max-width: 100dvw;
}
:global(.right-drawer .el-drawer__body) {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
@@ -76,6 +82,10 @@ const visible = computed({
flex: 0 0 auto;
}
.right-drawer__copy {
min-width: 0;
}
.right-drawer__eyebrow {
margin-bottom: 8px;
color: var(--accent);
@@ -111,6 +121,8 @@ const visible = computed({
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.right-drawer__footer {
@@ -122,4 +134,42 @@ const visible = computed({
flex: 0 0 auto;
background: var(--surface);
}
@media (max-width: 640px) {
.right-drawer__header {
gap: 10px;
padding: 18px 16px 14px;
}
.right-drawer__eyebrow {
margin-bottom: 5px;
}
.right-drawer__title {
font-size: 20px;
}
.right-drawer__subtitle {
margin-top: 5px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.right-drawer__body {
padding: 16px;
scrollbar-gutter: auto;
}
.right-drawer__footer {
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
}
:global(.right-drawer__footer .el-button) {
min-width: 0;
flex: 1 1 0;
margin: 0;
padding-inline: 8px;
}
}
</style>
+46 -43
View File
@@ -43,6 +43,9 @@ const barColor = computed(() => {
if (tier.value === "yellow") return "var(--warning)";
return "var(--danger)";
});
const ringStyle = computed(() => ({
background: `conic-gradient(${barColor.value} ${percentage.value}%, var(--surface-strong) 0)`
}));
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) return "--";
@@ -59,58 +62,58 @@ function formatBytes(bytes: number) {
<template>
<section class="storage-capacity" data-testid="storage-capacity">
<div class="storage-capacity__header">
<div>
<div class="storage-capacity__eyebrow">录制存储</div>
<div class="storage-capacity__headline">
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
<span>已使用</span>
</div>
<div
class="storage-capacity__ring"
:style="ringStyle"
role="img"
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
>
<div class="storage-capacity__ring-inner">
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
<span>已使用</span>
</div>
<el-tag :type="tagType" effect="light">{{ statusLabel }}</el-tag>
</div>
<el-progress
:percentage="percentage"
:stroke-width="12"
:show-text="false"
:color="barColor"
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
/>
<div class="storage-capacity__detail">
<div class="storage-capacity__summary">
<strong>{{ status.isAvailable ? `${formatBytes(status.usedBytes)} / ${formatBytes(status.totalBytes)}` : "容量不可用" }}</strong>
<el-tooltip v-if="status.message" :content="status.message" placement="top">
<el-tag :type="tagType" effect="light" tabindex="0">{{ statusLabel }}</el-tag>
</el-tooltip>
<el-tag v-else :type="tagType" effect="light">{{ statusLabel }}</el-tag>
</div>
<p class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</p>
<dl class="storage-capacity__metrics">
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
<div><dt>总容量</dt><dd>{{ status.isAvailable ? formatBytes(status.totalBytes) : "--" }}</dd></div>
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
</dl>
<div class="storage-capacity__foot">
<span class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</span>
<el-tooltip v-if="status.message" :content="status.message" placement="top">
<span class="storage-capacity__help" tabindex="0">状态说明</span>
</el-tooltip>
<dl class="storage-capacity__metrics">
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
</dl>
</div>
</section>
</template>
<style scoped>
.storage-capacity { display: grid; gap: 18px; }
.storage-capacity__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.storage-capacity__eyebrow { color: var(--text-muted); font-size: 12px; font-weight: 700; }
.storage-capacity__headline { display: flex; align-items: baseline; gap: 8px; margin-top: 5px; }
.storage-capacity__headline strong { color: var(--text-primary); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
.storage-capacity__headline span { color: var(--text-muted); font-size: 13px; }
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; }
.storage-capacity__metrics > div { min-width: 0; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-muted); }
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 11px; }
.storage-capacity__metrics dd { margin: 4px 0 0; color: var(--text-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.storage-capacity__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; color: var(--text-muted); font-size: 12px; }
.storage-capacity__path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
.storage-capacity__help { flex: 0 0 auto; color: var(--accent); cursor: help; }
.storage-capacity { display: grid; grid-template-columns: 106px minmax(0, 1fr); align-items: center; gap: 20px; }
.storage-capacity__ring { width: 100px; height: 100px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; }
.storage-capacity__ring-inner { width: 76px; height: 76px; display: grid; place-content: center; border-radius: 50%; background: var(--surface); text-align: center; }
.storage-capacity__ring strong { display: block; color: var(--text-primary); font-size: 19px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.storage-capacity__ring span { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
.storage-capacity__detail { min-width: 0; }
.storage-capacity__summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.storage-capacity__summary > strong { min-width: 0; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; }
.storage-capacity__path { margin: 7px 0 0; overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 13px 0 0; }
.storage-capacity__metrics > div { min-width: 0; padding: 9px; border-radius: var(--radius-sm); background: var(--surface-muted); }
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 9px; }
.storage-capacity__metrics dd { margin: 3px 0 0; overflow: hidden; color: var(--text-primary); font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 640px) {
.storage-capacity__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.storage-capacity__foot { align-items: flex-start; flex-direction: column; }
.storage-capacity__path { width: 100%; white-space: normal; overflow-wrap: anywhere; }
.storage-capacity { grid-template-columns: 88px minmax(0, 1fr); gap: 14px; }
.storage-capacity__ring { width: 82px; height: 82px; }
.storage-capacity__ring-inner { width: 62px; height: 62px; }
.storage-capacity__ring strong { font-size: 16px; }
.storage-capacity__summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.storage-capacity__metrics { gap: 5px; }
.storage-capacity__metrics > div { padding: 7px; }
}
</style>
+3 -1
View File
@@ -1,6 +1,8 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const MOBILE_BREAKPOINT = 768;
// CSS mobile rules use `max-width: 768px`, so 768px must follow the same
// rendering branch instead of receiving the desktop/table markup.
const MOBILE_BREAKPOINT = 769;
const TABLET_BREAKPOINT = 1024;
const DESKTOP_BREAKPOINT = 1280;
+126 -90
View File
@@ -13,65 +13,73 @@
-webkit-font-smoothing: antialiased;
/* neutrals */
--bg-base: #eef1f6;
--bg-subtle: #e7ebf2;
--bg-base: #f4f7fc;
--bg-subtle: #edf2f9;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-muted: #f4f7fb;
--surface-strong: #eef2f8;
--surface-hover: #eef2f8;
--border-subtle: #e3e7ef;
--border-base: #d2d8e3;
--text-primary: #0f172a;
--text-secondary: #45526a;
--text-muted: #6a7689;
--text-soft: #97a1b3;
--surface-muted: #f8fafe;
--surface-subtle: #f8fafe;
--surface-strong: #edf2f9;
--surface-hover: #f1f6ff;
--border-subtle: #e3eaf4;
--border-base: #cfd9e8;
--text-primary: #17233a;
--text-secondary: #42526d;
--text-muted: #62718a;
--text-soft: #68768d;
/* sidebar */
--side-bg: #f3f5f9;
--side-border: #e3e7ef;
--side-title: #7a8699;
--side-item: #44526b;
--side-item-hover-bg: rgba(26, 86, 219, 0.07);
--side-item-hover: #1e40af;
--side-active-bg: rgba(26, 86, 219, 0.10);
--side-active: #1a56db;
--side-bg: #fbfcff;
--side-border: #e3eaf4;
--side-title: #68768d;
--side-item: #40506a;
--side-item-hover-bg: #f8fafe;
--side-item-hover: #17233a;
--side-active-bg: #f1f6ff;
--side-active: #185dcc;
/* brand */
--accent: #1a56db;
--accent-strong: #1e40af;
--accent-soft: rgba(26, 86, 219, 0.12);
--accent-soft-2: rgba(26, 86, 219, 0.07);
--info: #2563eb;
--info-soft: rgba(37, 99, 235, 0.11);
--success: #16a34a;
--success-soft: rgba(22, 163, 74, 0.12);
--warning: #d97706;
--warning-soft: rgba(217, 119, 6, 0.13);
--danger: #dc2626;
--danger-soft: rgba(220, 38, 38, 0.11);
--purple: #7c3aed;
--purple-soft: rgba(124, 58, 237, 0.11);
--accent: #185dcc;
--accent-strong: #144ba8;
--accent-soft: #eaf2ff;
--accent-soft-2: #f1f6ff;
--action: #246bfe;
--action-hover: #1856d7;
--info: #185dcc;
--info-soft: #eaf2ff;
--cyan: #0d89ad;
--cyan-soft: #e7f7fb;
--success: #137e5c;
--success-soft: #e6f7f0;
--warning: #a45f00;
--warning-soft: #fff4dd;
--danger: #b83342;
--danger-soft: #ffebef;
--purple: #6558d9;
--purple-soft: #f0edff;
--ambient-blue: rgba(86, 137, 255, .13);
--ambient-violet: rgba(146, 119, 255, .09);
--ambient-warm: rgba(255, 187, 112, .08);
/* elevation */
--shadow-xs: 0 1px 3px rgba(16, 24, 40, 0.09), 0 1px 2px rgba(16, 24, 40, 0.05);
--shadow-sm: 0 2px 8px rgba(16, 24, 40, 0.10), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-md: 0 8px 20px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.07);
--shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.16), 0 4px 10px rgba(16, 24, 40, 0.08);
--shadow-float: 0 24px 56px rgba(16, 24, 40, 0.22);
--shadow-xs: 0 2px 8px rgba(37, 61, 98, .045);
--shadow-sm: 0 8px 24px rgba(37, 61, 98, .08);
--shadow-md: 0 14px 38px rgba(37, 61, 98, .10);
--shadow-lg: 0 20px 52px rgba(22, 38, 68, .16);
--shadow-float: 0 28px 76px rgba(22, 38, 68, .22);
/* radius */
--radius-xs: 7px;
--radius-sm: 9px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-md: 12px;
--radius-lg: 18px;
/* layout */
--sidebar-w: 244px;
--sidebar-w: 224px;
--sidebar-w-collapsed: 72px;
--topbar-h: 62px;
--page-max: 1320px;
--page-gap: 16px;
--topbar-h: 64px;
--page-max: 1440px;
--page-gap: 14px;
--content-padding: 20px;
--content-padding-mobile: 14px;
--control-height: 40px;
@@ -88,42 +96,56 @@ html[data-density="compact"] {
--control-height-sm: 30px;
--table-row-padding: 12px;
--header-row-height: 56px;
--topbar-h: 56px;
--topbar-h: 64px;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg-base: #0a1120;
--bg-subtle: #0f172a;
--surface: #121c30;
--surface-raised: #162238;
--surface-muted: #16223899;
--surface-strong: #1b2942;
--surface-hover: #1b2942;
--border-subtle: #1f2c44;
--border-base: #2c3a55;
--text-primary: #e9eefb;
--text-secondary: #b6c4dd;
--text-muted: #8295b3;
--text-soft: #647a9a;
--bg-base: #0f1726;
--bg-subtle: #111d30;
--surface: #172235;
--surface-raised: #1b2940;
--surface-muted: #1b2940;
--surface-subtle: #1b2940;
--surface-strong: #22324a;
--surface-hover: #22324a;
--border-subtle: #2c3c55;
--border-base: #40516a;
--text-primary: #f2f6fd;
--text-secondary: #d2dbea;
--text-muted: #a9b7cb;
--text-soft: #9aa9be;
--side-bg: #0c1424;
--side-border: #1a2740;
--side-title: #4a607d;
--side-item: #8295b3;
--side-item-hover-bg: rgba(255, 255, 255, 0.05);
--side-item-hover: #d4def0;
--side-active-bg: rgba(96, 165, 250, 0.18);
--side-active: #93c5fd;
--side-bg: #131e30;
--side-border: #2c3c55;
--side-title: #a1aec2;
--side-item: #d6e0ee;
--side-item-hover-bg: #1b2940;
--side-item-hover: #f2f6fd;
--side-active-bg: #1b2b43;
--side-active: #79a9ff;
--accent: #60a5fa;
--accent-strong: #3b82f6;
--accent-soft: rgba(96, 165, 250, 0.18);
--accent-soft-2: rgba(96, 165, 250, 0.10);
--info: #60a5fa;
--success: #34d399;
--warning: #fbbf24;
--danger: #f87171;
--accent: #79a9ff;
--accent-strong: #a5c6ff;
--accent-soft: rgba(86, 143, 238, .20);
--accent-soft-2: rgba(86, 143, 238, .12);
--action: #2763d2;
--action-hover: #3575e6;
--info: #79a9ff;
--info-soft: rgba(86, 143, 238, .20);
--cyan: #62c9e4;
--cyan-soft: rgba(43, 165, 196, .17);
--success: #65d0a4;
--success-soft: rgba(40, 159, 114, .20);
--warning: #f0b75c;
--warning-soft: rgba(207, 139, 35, .20);
--danger: #ff8b94;
--danger-soft: rgba(211, 68, 82, .22);
--purple: #aa9cff;
--purple-soft: rgba(123, 102, 221, .20);
--ambient-blue: rgba(58, 112, 220, .17);
--ambient-violet: rgba(119, 88, 207, .13);
--ambient-warm: rgba(205, 132, 55, .07);
--shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45);
@@ -133,6 +155,7 @@ html[data-theme="dark"] {
}
/* ============================ Element Plus overrides ============================ */
:root,
#app {
--el-color-primary: var(--accent);
--el-color-primary-light-3: #5b8cf0;
@@ -162,16 +185,18 @@ html[data-theme="dark"] {
--el-mask-color: rgba(15, 23, 42, 0.54);
}
html[data-theme="dark"],
html[data-theme="dark"] #app {
--el-color-primary-light-3: #93c5fd;
--el-color-primary-light-5: #bfdbfe;
--el-color-primary-light-7: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-light-3: #5d8fe7;
--el-color-primary-light-5: #426baf;
--el-color-primary-light-7: #2c4775;
--el-color-primary-light-9: #1b2b43;
--el-mask-color: rgba(3, 7, 14, 0.72);
}
/* ============================ Base ============================ */
* { box-sizing: border-box; }
html { -webkit-tap-highlight-color: transparent; }
html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; }
body {
color: var(--text-primary);
@@ -182,6 +207,14 @@ body {
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* Keep keyboard focus visible without leaving a browser-default frame after
mouse or touch activation. */
:where(a, button, [role="button"], input, textarea, select):focus { outline: none; }
:where(a, button, [role="button"], input, textarea, select):focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent);
outline-offset: 2px;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-soft); background-clip: content-box; }
@@ -193,9 +226,9 @@ button { font-family: inherit; cursor: pointer; }
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; padding-bottom: 24px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-header > div:first-child { flex: 1; min-width: 0; }
.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); }
.page-subtitle { max-width: 76ch; margin: 8px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; }
.page-kicker { margin-bottom: 7px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.page-title { margin: 0; font-size: clamp(24px, 2vw, 28px); font-weight: 780; letter-spacing: -.035em; line-height: 1.2; color: var(--text-primary); }
.page-subtitle { max-width: 72ch; margin: 7px 0 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; }
.page-kicker { margin-bottom: 5px; color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.page-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
/* cards */
@@ -241,6 +274,7 @@ button { font-family: inherit; cursor: pointer; }
.cell-mono { margin-top: 4px; color: var(--text-muted); font-size: 12px; word-break: break-all; }
.table-date-text { color: var(--text-muted); font-size: 12px; white-space: nowrap; }
.path-text, .log-detail, .detail-popover { color: var(--text-secondary); font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
.platform-line { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 5px; min-width: 0; }
/* badges/tags */
.badge-row { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -288,12 +322,12 @@ button { font-family: inherit; cursor: pointer; }
/* primary button — flat, blue glow */
.el-button.el-button--primary {
background: var(--accent) !important; border-color: var(--accent-strong) !important; color: #fff !important;
box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2);
background: var(--action) !important; border-color: var(--action) !important; color: #fff !important;
box-shadow: 0 7px 18px color-mix(in srgb, var(--action) 22%, transparent);
}
.el-button.el-button--primary:not(.is-disabled):hover {
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important; color: #fff !important;
box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28);
background: var(--action-hover) !important; border-color: var(--action-hover) !important; color: #fff !important;
box-shadow: 0 9px 22px color-mix(in srgb, var(--action) 28%, transparent);
}
.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { background: var(--accent-soft) !important; border-color: transparent !important; color: var(--accent) !important; }
.el-button.el-button--danger.is-plain { background: var(--danger-soft) !important; border-color: transparent !important; color: var(--danger) !important; }
@@ -327,10 +361,10 @@ button { font-family: inherit; cursor: pointer; }
background: transparent;
}
.el-table::before, .el-table__inner-wrapper::before { display: none; }
.el-table th.el-table__cell { padding: 12px 20px; border-bottom: 2px solid var(--border-base); background: #eef2f8; }
html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; }
.el-table th.el-table__cell { padding: 11px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--surface-muted); }
html[data-theme="dark"] .el-table th.el-table__cell { background: var(--surface-muted); }
.el-table th.el-table__cell > .cell { color: var(--text-muted); font-size: 12px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase; }
.el-table td.el-table__cell { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); background: transparent; }
.el-table td.el-table__cell { padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); background: transparent; }
.el-table tr { background: transparent; }
.el-table tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); }
html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
@@ -351,7 +385,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
border-radius: var(--radius-md); border: 1px solid var(--border-base);
background: var(--surface); box-shadow: var(--shadow-float);
}
.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); }
.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper, .el-dropdown__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); }
.el-dropdown__popper.el-popper .el-dropdown-menu { border-color: var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-md); border-radius: var(--radius-sm); }
.el-dropdown-menu__item.danger-menu-item { color: var(--danger); }
.el-dialog__header { margin: 0; padding: 20px 24px 10px; }
@@ -368,6 +402,8 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
.el-tabs--border-card > .el-tabs__header { background: transparent; }
.el-tabs--border-card > .el-tabs__header .el-tabs__item { color: var(--text-secondary); }
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { color: var(--text-primary); background: var(--surface); }
.session-recovery-alert { margin-block-end: 16px; }
.session-recovery-alert .el-alert__description, .recovery-status-copy { overflow-wrap: anywhere; }
/* ============================ Skeleton loading ============================ */
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
@@ -388,7 +424,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
@media (max-width: 768px) {
.page-header, .section-header, .toolbar-row { flex-direction: column; }
.page-title { font-size: 22px; }
.page-subtitle { display: none; }
.page-subtitle { display: block; font-size: 11px; }
.surface-card .el-card__body { padding: 14px; }
.stats-grid, .data-card__grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-card { padding: 14px; gap: 8px; }
+56
View File
@@ -141,6 +141,8 @@ export interface RecordTask {
startedAt?: string;
endedAt?: string;
durationSeconds?: number;
isHiddenArtifactSource: boolean;
mergedIntoRecordTaskId?: string;
uploadStatus?: number;
postProcessStage?: string;
postProcessProgressPercent?: number;
@@ -170,6 +172,7 @@ export interface RecordSession {
id: string;
liveRoomId: string;
liveRoomTitle: string;
anchorName?: string;
platform: number;
roomId: string;
status: number;
@@ -180,6 +183,8 @@ export interface RecordSession {
segmentCount: number;
recorderProcessId?: number;
errorMessage?: string;
isRecovering: boolean;
recoveryReason?: string;
createdAt: string;
startedAt?: string;
endedAt?: string;
@@ -318,6 +323,16 @@ export interface UploadTaskListResponse {
queuedCount: number;
uploadingCount: number;
waitingRetryCount: number;
matchingRetryableCount: number;
queueHealth: UploadQueueHealth;
}
export interface UploadQueueHealth {
state: "Healthy" | "RateLimited" | "AuthenticationBlocked" | "Disabled" | string;
isPaused: boolean;
reason?: string;
retryAt?: string;
lastErrorAt?: string;
}
export interface ManualSegmentCompletedTriggerResult {
@@ -668,6 +683,19 @@ export interface RecoveryOverview {
storage: StorageGuardStatus;
liveRooms: RecoverableLiveRoom[];
finalizations: RecoverableFinalization[];
mergedArtifacts: MergedArtifactRecord[];
}
export interface MergedArtifactRecord {
sourceRecordTaskId: string;
recordSessionId: string;
mergedIntoRecordTaskId?: string;
sourceVideoPath?: string;
mergedVideoPath?: string;
recoveryDirectory?: string;
manifestPath?: string;
sourceDurationSeconds?: number;
createdAt: string;
}
export interface StorageGuardStatus {
@@ -722,6 +750,34 @@ export interface RecoveryActionResult {
messages: string[];
}
export interface RecordingFailureListResponse {
items: RecordingFailureItem[];
totalCount: number;
}
export interface RecordingFailureItem {
recordTaskId: string;
recordSessionId: string;
liveRoomId: string;
liveRoomTitle: string;
roomId: string;
platformName: string;
segmentIndex: number;
failureKind: string;
failureLabel: string;
recommendedAction: string;
errorMessage?: string;
filePath?: string;
fileSizeBytes?: number;
durationSeconds?: number;
fileExists: boolean;
canAccept: boolean;
canRepair: boolean;
canRetryRoom: boolean;
isRepairing: boolean;
createdAt: string;
}
export interface TranscodeTaskItem {
task: RecordTask;
result?: RecordResult;
+8 -3
View File
@@ -6,6 +6,7 @@ import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import { useViewport } from "@/composables/useViewport";
import type {
DailyReviewPushResult,
@@ -194,7 +195,9 @@ onMounted(loadReport);
<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="140">
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
</el-table-column>
<el-table-column label="会话" width="90" prop="sessionCount" />
<el-table-column label="分片" width="90" prop="segmentCount" />
<el-table-column label="录制时长" width="120">
@@ -231,7 +234,7 @@ onMounted(loadReport);
<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>
<PlatformMark :name="item.platformName" />
<span>{{ item.roomId }}</span>
<span>{{ formatDuration(item.durationSeconds) }}</span>
</div>
@@ -273,7 +276,9 @@ onMounted(loadReport);
<div class="cell-subtitle">分片 #{{ row.segmentIndex }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120" prop="platformName" />
<el-table-column label="平台" width="140">
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
</el-table-column>
<el-table-column label="弹幕数" width="100" prop="danmakuCount" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
+218 -98
View File
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Clock, House, Refresh, Upload, VideoCamera, Warning } from "@element-plus/icons-vue";
import {
ArrowRight,
Film,
FolderOpened,
Plus,
Refresh,
Upload,
Warning
} from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import type { DashboardData, DashboardRecentSession } from "@/types";
@@ -16,19 +24,29 @@ const loadError = ref("");
const data = ref<DashboardData | null>(null);
const queueTotal = computed(() => (data.value?.pendingTranscodeCount ?? 0) + (data.value?.pendingUploadCount ?? 0));
const hasAttention = computed(() => Boolean(
data.value && (
data.value.currentErrorCount > 0 ||
data.value.storageStatus.tier !== "Green" ||
queueTotal.value > 0
)
));
const attentionCount = computed(() => {
if (!data.value) return 0;
return Number(data.value.currentErrorCount > 0)
+ Number(data.value.storageStatus.tier !== "Green")
+ Number(queueTotal.value > 0);
});
const hasAttention = computed(() => attentionCount.value > 0);
const activeSessions = computed(() => data.value?.recentSessions.filter((item) => [1, 2, 3, 7].includes(item.status)).slice(0, 2) ?? []);
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours} 小时 ${minutes}` : `${minutes} 分钟`;
if (hours > 0) return `${hours} 小时 ${minutes}`;
return `${Math.max(1, minutes)} 分钟`;
}
function formatTimer(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return "--:--:--";
const hours = Math.floor(seconds / 3600).toString().padStart(2, "0");
const minutes = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0");
const remainder = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${hours}:${minutes}:${remainder}`;
}
function formatDataSize(bytes?: number) {
@@ -36,17 +54,31 @@ function formatDataSize(bytes?: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "--";
if (!value) return "--";
return new Date(value).toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function formatRelativeHint(value?: string | null) {
if (!value) return "暂无等待中的任务";
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
const minutes = Math.floor(elapsed / 60_000);
if (minutes < 1) return "最早任务刚刚进入队列";
if (minutes < 60) return `最早任务已等待 ${minutes} 分钟`;
return `最早任务已等待 ${Math.floor(minutes / 60)} 小时`;
}
function sessionStatus(session: DashboardRecentSession) {
return sessionStatusLabelMap[session.status] ?? "未知";
}
function roomInitial(session: DashboardRecentSession) {
return session.liveRoomTitle.trim().slice(0, 1) || "播";
}
async function loadData() {
loading.value = true;
loadError.value = "";
@@ -54,7 +86,7 @@ async function loadData() {
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
data.value = result;
} catch (error) {
loadError.value = getApiErrorMessage(error, "仪表盘数据加载失败。");
loadError.value = getApiErrorMessage(error, "运行中心数据加载失败。");
} finally {
loading.value = false;
}
@@ -67,12 +99,13 @@ onMounted(loadData);
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">运行中心</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">先处理异常积压查看录制产出和最近活动</p>
<div class="page-kicker">OPERATIONS</div>
<h1 class="page-title">运行中心</h1>
<p class="page-subtitle">先处理异常积压关注正在进行的录制与今日产出</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新状态</el-button>
<el-button type="primary" :icon="Plus" @click="router.push({ name: 'live-rooms', query: { action: 'create' } })">添加直播间</el-button>
</div>
</div>
@@ -81,120 +114,207 @@ onMounted(loadData);
<template v-else-if="data">
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
<div class="attention-bar__icon"><el-icon><Warning /></el-icon></div>
<div class="attention-bar__copy">
<strong>有需要关注的运行状态</strong>
<span class="attention-bar__icon"><el-icon><Warning /></el-icon></span>
<span class="attention-bar__copy">
<strong> {{ attentionCount }} 需要关注</strong>
<span>
最近 30 分钟 {{ data.currentErrorCount }} 个异常
{{ queueTotal }} 个处理任务等待完成存储状态 {{ data.storageStatus.tier === "Green" ? "正常" : "受限" }}
最近 30 分钟 {{ data.currentErrorCount }} 个异常{{ queueTotal }} 个处理任务等待完成
存储状态{{ data.storageStatus.tier === "Green" ? "正常" : "已触发预警" }}
</span>
</div>
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
</span>
<el-button size="small" @click="router.push({ name: queueTotal > 0 ? 'upload-tasks' : 'logs' })">立即处理</el-button>
</section>
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
24 小时共记录 {{ data.recentErrorCount }} 个历史异常最近 30 分钟为 {{ data.currentErrorCount }} 历史记录不代表系统当前仍有故障
<span> 24 小时共记录 {{ data.recentErrorCount }} 个历史异常当前状态以最近 30 分钟的 {{ data.currentErrorCount }} 个异常为准</span>
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<div class="dashboard-metrics">
<MetricCard label="正在录制" :value="data.activeRecordingCount" description="当前活动录制会话" :icon="VideoCamera" />
<MetricCard label="在线直播间" :value="`${data.liveRoomCount} / ${data.totalRoomCount}`" description="在线 / 已接入" :icon="House" />
<MetricCard label="今日录制" :value="formatDuration(data.todayRecordingSeconds)" :description="`${formatDataSize(data.todayDataBytes)} · ${data.todayDanmakuCount.toLocaleString()} 条弹幕`" :icon="Clock" />
<MetricCard label="待处理" :value="queueTotal" :description="`${data.pendingTranscodeCount} 转码 · ${data.pendingUploadCount} 上传`" :icon="Upload" />
<div class="primary-grid">
<section class="live-hero" aria-label="当前录制概况">
<div class="live-hero__top">
<span class="live-hero__label"><i class="live-pulse" />正在录制</span>
<span class="live-hero__badge">{{ data.activeRecordingCount }} 路活跃</span>
</div>
<div class="live-hero__count">{{ String(data.activeRecordingCount).padStart(2, "0") }}<small>当前录制会话</small></div>
<div v-if="activeSessions.length" class="active-session-list">
<button
v-for="session in activeSessions"
:key="session.id"
class="active-session"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<span class="active-session__avatar">{{ roomInitial(session) }}</span>
<span class="active-session__copy">
<strong>{{ session.liveRoomTitle }}</strong>
<span><PlatformMark :name="session.platformName" /> · {{ session.segmentCount }} 个分片</span>
</span>
<time>{{ formatTimer(session.durationSeconds) }}</time>
</button>
</div>
<div v-else class="live-hero__empty">
{{ data.activeRecordingCount > 0 ? "活动会话将在下一次状态刷新后显示" : "当前没有正在录制的直播间" }}
</div>
</section>
<el-card class="surface-card process-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<div><h2>处理概况</h2><p>录制后的自动化流水线</p></div>
<el-button link @click="router.push({ name: 'transcode-tasks' })">查看队列</el-button>
</div>
<div class="process-list">
<button class="process-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
<span class="process-row__icon"><el-icon><Film /></el-icon></span>
<span><strong>等待转码</strong><small>{{ formatRelativeHint(data.oldestTranscodeUpdatedAt) }}</small></span>
<b>{{ data.pendingTranscodeCount }}</b>
</button>
<button class="process-row" type="button" @click="router.push({ name: 'upload-tasks' })">
<span class="process-row__icon"><el-icon><Upload /></el-icon></span>
<span><strong>等待上传</strong><small>{{ data.stalledUploadCount ?? 0 }} 个停滞 · {{ data.uploadCleanupFailureCount ?? 0 }} 个待清理</small></span>
<b>{{ data.pendingUploadCount }}</b>
</button>
<button class="process-row" type="button" @click="router.push({ name: 'media-browser' })">
<span class="process-row__icon"><el-icon><FolderOpened /></el-icon></span>
<span><strong>今日产出</strong><small>{{ formatDuration(data.todayRecordingSeconds) }} · {{ data.todayDanmakuCount.toLocaleString() }} 条弹幕</small></span>
<b>{{ formatDataSize(data.todayDataBytes) }}</b>
</button>
</div>
</el-card>
</div>
<div class="operations-grid">
<div class="secondary-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading">
<div><h2>存储空间</h2><p>输出目录实际容量与录制保护状态</p></div>
<div class="panel-heading panel-heading--bordered">
<div><h2>存储容量</h2><p>录制目录与保护阈值</p></div>
</div>
<StorageCapacity :status="data.storageStatus" />
</el-card>
<el-card class="surface-card queue-card" shadow="never">
<div class="panel-heading">
<div><h2>处理队列</h2><p>需要系统继续处理的本地文件</p></div>
</div>
<button class="queue-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
<span><strong>待转码</strong><small>FFmpeg 后处理</small></span><b>{{ data.pendingTranscodeCount }}</b>
</button>
<button class="queue-row" type="button" @click="router.push({ name: 'upload-tasks' })">
<span><strong>待上传</strong><small>远端归档</small></span><b>{{ data.pendingUploadCount }}</b>
</button>
<div v-if="data.stalledUploadCount > 0" class="queue-volume">
<span>上传停滞超过 60 分钟</span><strong>{{ data.stalledUploadCount }}</strong>
</div>
<div v-if="data.uploadCleanupFailureCount > 0" class="queue-volume">
<span>本地清理等待重试</span><strong>{{ data.uploadCleanupFailureCount }}</strong>
</div>
<div class="queue-volume"><span>积压数据量</span><strong>{{ formatDataSize(data.queuedDataBytes) }}</strong></div>
</el-card>
</div>
<div class="activity-grid">
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>最近录制</h2><p>最新创建的录制会话</p></div>
<el-button link @click="router.push({ name: 'record-tasks' })">查看全部</el-button>
<div class="panel-heading panel-heading--bordered">
<div><h2>最近动态</h2><p>最新创建的录制会话</p></div>
<el-button link @click="router.push({ name: 'record-tasks' })">全部会话</el-button>
</div>
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
<button
v-for="session in data.recentSessions"
v-for="session in data.recentSessions.slice(0, 4)"
v-else
:key="session.id"
class="activity-row"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<i class="activity-row__dot" :class="{ 'is-error': session.status === 5, 'is-warn': [0, 1, 3, 7].includes(session.status) }" />
<span class="activity-row__main"><strong>{{ session.liveRoomTitle }}</strong><small>{{ formatDate(session.startedAt) }} · {{ session.segmentCount }} 个分片</small></span>
<StatusBadge :label="sessionStatus(session)" :status="session.status" />
</button>
</el-card>
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
<el-button link @click="router.push({ name: 'live-rooms' })">直播间</el-button>
</div>
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
<button v-for="(room, index) in data.topRooms" v-else :key="room.liveRoomId" class="activity-row" type="button" @click="router.push({ name: 'live-rooms' })">
<span class="rank">{{ index + 1 }}</span>
<span class="activity-row__main"><strong>{{ room.title || room.anchorName || room.roomId }}</strong><small>{{ room.platformName }} · {{ room.sessionCount }} 个会话</small></span>
<span class="activity-row__value">{{ formatDuration(room.totalDurationSeconds) }}</span>
</button>
</el-card>
</div>
<el-card class="surface-card ranking-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
<el-button link @click="router.push({ name: 'live-rooms' })">直播间 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
</div>
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
<div v-else class="ranking-list">
<button v-for="(room, index) in data.topRooms" :key="room.liveRoomId" class="ranking-row" type="button" @click="router.push({ name: 'live-rooms' })">
<span class="rank" :class="`rank--${index + 1}`">{{ index + 1 }}</span>
<span class="activity-row__main">
<strong>{{ room.title || room.anchorName || room.roomId }}</strong>
<small><PlatformMark :name="room.platformName" /> · {{ room.sessionCount }} 个会话</small>
</span>
<span class="ranking-row__duration">{{ formatDuration(room.totalDurationSeconds) }}</span>
</button>
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.dashboard-page { gap: 18px; }
.attention-bar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--warning-soft); }
.attention-bar__icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; color: var(--warning); background: var(--surface); }
.attention-bar__copy { display: grid; flex: 1; min-width: 0; gap: 3px; }
.attention-bar__copy strong { font-size: 14px; }
.attention-bar__copy span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, .8fr); gap: 14px; }
.activity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.panel-heading h2 { margin: 0; font-size: 16px; }
.panel-heading p { margin: 5px 0 0; color: var(--text-muted); font-size: 12.5px; }
.queue-card :deep(.el-card__body), .activity-card :deep(.el-card__body) { display: grid; }
.queue-row, .activity-row { display: flex; align-items: center; width: 100%; gap: 12px; padding: 12px; border: 0; border-top: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
.queue-row:hover, .activity-row:hover { background: var(--surface-hover); }
.queue-row span, .activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
.queue-row small, .activity-row small { color: var(--text-muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-row b { font-size: 24px; font-variant-numeric: tabular-nums; }
.queue-volume { display: flex; justify-content: space-between; gap: 12px; padding: 14px 12px 0; color: var(--text-muted); font-size: 12px; }
.queue-volume strong { color: var(--text-primary); font-size: 14px; }
.activity-row__main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; }
.activity-row__value { flex: 0 0 auto; color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.rank { display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 auto; border-radius: 7px; background: var(--surface-muted); color: var(--text-muted); font-weight: 800; }
@media (max-width: 1100px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operations-grid { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .activity-grid { grid-template-columns: 1fr; } .attention-bar { align-items: flex-start; flex-wrap: wrap; } .attention-bar .el-button { width: 100%; } }
@media (max-width: 480px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } }
.dashboard-page { gap: 14px; }
.attention-bar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--warning) 27%, var(--border-subtle)); border-radius: 11px; background: var(--warning-soft); }
.attention-bar__icon { width: 32px; height: 32px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--warning); background: var(--surface); }
.attention-bar__icon .el-icon { display: grid; place-items: center; }
.attention-bar__copy { display: grid; min-width: 0; gap: 2px; }
.attention-bar__copy strong { font-size: 12.5px; }
.attention-bar__copy > span { color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 11.5px; line-height: 1.5; }
.primary-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(290px, .72fr); gap: 14px; }
.secondary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.panel-heading--bordered { margin: -18px -18px 0; padding: 17px 18px; border-bottom: 1px solid var(--border-subtle); }
.panel-heading h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
.panel-heading p { margin: 4px 0 0; color: var(--text-muted); font-size: 11px; }
.live-hero { position: relative; min-height: 282px; overflow: hidden; padding: 20px; border-radius: var(--radius-md); color: #f7f9ff; background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .22), transparent 31%), linear-gradient(135deg, #185ecf, #3f67d8 54%, #6658ce); box-shadow: 0 16px 38px rgba(24, 94, 207, .22); }
:global(html[data-theme="dark"]) .live-hero { background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .16), transparent 31%), linear-gradient(135deg, #17498f, #304f9d 54%, #56469f); box-shadow: 0 16px 38px rgba(0, 0, 0, .28); }
.live-hero::after { content: ""; position: absolute; right: -65px; top: -90px; width: 260px; height: 260px; border: 45px solid rgba(255,255,255,.07); border-radius: 50%; pointer-events: none; }
.live-hero__top { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.live-hero__label { display: flex; align-items: center; gap: 8px; color: #d7e3ff; font-size: 11px; font-weight: 700; }
.live-pulse { width: 8px; height: 8px; border-radius: 50%; background: #ff8b94; box-shadow: 0 0 0 5px rgba(255, 139, 148, .18); }
.live-hero__badge { padding: 4px 9px; border-radius: 99px; color: #ffe8ec; background: rgba(255, 139, 148, .18); font-size: 11px; font-weight: 700; }
.live-hero__count { position: relative; z-index: 1; margin-top: 15px; font-size: 38px; line-height: 1; font-weight: 800; letter-spacing: -.05em; }
.live-hero__count small { margin-left: 7px; color: #d7e3ff; font-size: 12px; font-weight: 500; letter-spacing: 0; }
.active-session-list { position: relative; z-index: 1; display: grid; }
.active-session { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 15px 0; border: 0; border-top: 1px solid rgba(255,255,255,.22); color: inherit; background: transparent; text-align: left; }
.active-session:first-child { margin-top: 18px; }
.active-session:hover strong { text-decoration: underline; text-underline-offset: 3px; }
.active-session__avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #244fa9; background: #e8efff; font-size: 13px; font-weight: 800; }
.active-session__copy { display: grid; min-width: 0; gap: 4px; }
.active-session__copy strong { overflow: hidden; color: #f7f9ff; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.active-session__copy > span { display: flex; align-items: center; color: #d7e3ff; font-size: 10px; }
.active-session__copy :deep(.platform-mark) { color: inherit; }
.active-session time { color: #f7f9ff; font-family: var(--font-mono); font-size: 15px; font-weight: 700; }
.live-hero__empty { position: relative; z-index: 1; margin-top: 32px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.22); color: #d7e3ff; font-size: 12px; }
.process-card :deep(.el-card__body), .activity-card :deep(.el-card__body), .ranking-card :deep(.el-card__body), .storage-card :deep(.el-card__body) { display: grid; }
.process-list { display: grid; }
.process-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 14px 0; border: 0; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); background: transparent; text-align: left; }
.process-row:last-child { border-bottom: 0; }
.process-row__icon { width: 34px; height: 34px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
.process-row:nth-child(2) .process-row__icon { color: var(--purple); background: var(--purple-soft); }
.process-row:nth-child(3) .process-row__icon { color: var(--cyan); background: var(--cyan-soft); }
.process-row__icon .el-icon { display: grid; place-items: center; }
.process-row > span:nth-child(2) { display: grid; min-width: 0; gap: 2px; }
.process-row strong { font-size: 12px; }
.process-row small { overflow: hidden; color: var(--text-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.process-row b { max-width: 96px; overflow: hidden; font-size: 18px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.storage-card :deep(.storage-capacity) { padding-top: 18px; }
.activity-row, .ranking-row { display: flex; align-items: center; width: 100%; gap: 10px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
.activity-row:last-child, .ranking-row:last-child { border-bottom: 0; }
.activity-row:hover, .ranking-row:hover, .process-row:hover { color: var(--accent); }
.activity-row__dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--success); }
.activity-row__dot.is-warn { background: var(--warning); }
.activity-row__dot.is-error { background: var(--danger); }
.activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
.activity-row__main strong { overflow: hidden; color: var(--text-primary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.activity-row__main small { display: flex; align-items: center; min-width: 0; overflow: hidden; color: var(--text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.ranking-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
.rank { width: 26px; height: 26px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 7px; color: var(--text-muted); background: var(--surface-muted); font-size: 11px; font-weight: 800; }
.rank--1 { color: #8a5700; background: #fff1cb; }
.rank--2 { color: var(--accent); background: var(--accent-soft); }
.rank--3 { color: #8b4d2e; background: #f9e7dd; }
.ranking-row__duration { flex: 0 0 auto; color: var(--text-secondary); font-size: 11.5px; font-weight: 700; }
@media (max-width: 1100px) {
.primary-grid { grid-template-columns: 1fr; }
.live-hero { min-height: auto; }
.ranking-list { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.secondary-grid { grid-template-columns: 1fr; }
.attention-bar { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
.attention-bar .el-button { grid-column: 1 / -1; width: 100%; }
.history-note { align-items: flex-start; flex-direction: column; }
.live-hero { padding: 17px; }
.live-hero__count { font-size: 34px; }
.active-session time { font-size: 12px; }
}
@media (max-width: 420px) {
.header-actions { width: 100%; }
.header-actions .el-button { flex: 1; margin-left: 0; }
.active-session { grid-template-columns: auto minmax(0, 1fr); }
.active-session time { grid-column: 2; }
}
</style>
+24 -4
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useRoute } from "vue-router";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import RightDrawer from "@/components/ui/RightDrawer.vue";
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
@@ -25,6 +27,7 @@ import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/ic
const inheritValue = "__inherit__";
const AUTO_REFRESH_INTERVAL_MS = 15000;
const route = useRoute();
const loading = ref(false);
const submitLoading = ref(false);
@@ -687,6 +690,7 @@ function nullableStringFromSelect(value: string | number) {
onMounted(async () => {
await loadRooms();
if (route.query.action === "create") openCreateDialog();
startAutoRefresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
});
@@ -787,7 +791,7 @@ onBeforeUnmount(() => {
:status="row.currentRecordingState"
context="recording"
/>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
<PlatformMark :name="row.platformName" />
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div>
@@ -892,7 +896,7 @@ onBeforeUnmount(() => {
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="config-summary">
<span>{{ row.platformName || "--" }}</span>
<PlatformMark :name="row.platformName" />
<span>{{ row.roomId || "--" }}</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
@@ -1025,7 +1029,7 @@ onBeforeUnmount(() => {
:status="activeRoom.currentRecordingState"
context="recording"
/>
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
<PlatformMark :name="activeRoom.platformName" />
</div>
<el-descriptions :column="1" border class="detail-panel__descriptions">
@@ -1033,7 +1037,7 @@ onBeforeUnmount(() => {
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="平台 + Room ID">
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
<span class="platform-room-id"><PlatformMark :name="activeRoom.platformName" /><span>· {{ activeRoom.roomId || "--" }}</span></span>
</el-descriptions-item>
<el-descriptions-item label="直播状态">
{{ roomAvailabilityLabel(activeRoom) }}
@@ -1453,6 +1457,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.platform-room-id { display: inline-flex; align-items: center; gap: 6px; }
.page-stack {
display: grid;
gap: 24px;
@@ -1691,6 +1696,10 @@ onBeforeUnmount(() => {
background: var(--surface-muted);
}
.detail-panel__hero > div {
min-width: 0;
}
.detail-panel__title {
color: var(--text-primary);
font-size: 18px;
@@ -1922,6 +1931,17 @@ onBeforeUnmount(() => {
}
@media (max-width: 640px) {
.detail-panel__hero {
align-items: flex-start;
gap: 12px;
padding: 12px;
}
.detail-panel__descriptions :deep(.el-descriptions__label) {
width: 104px;
min-width: 104px;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
+98 -101
View File
@@ -2,7 +2,7 @@
import { computed, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Lock, Monitor, Moon, Sunny, User } from "@element-plus/icons-vue";
import { ArrowRight, Lock, Monitor, Moon, Sunny, User, VideoCamera } from "@element-plus/icons-vue";
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useUiPreferences } from "@/composables/useUiPreferences";
@@ -37,7 +37,7 @@ async function handleLogin() {
try {
await authStore.login(form.username, form.password);
ElMessage.success("登录成功");
await router.push({ name: "live-rooms" });
await router.push({ name: "dashboard" });
} catch (error) {
ElMessage.error(
isBackendUnavailableError(error)
@@ -52,14 +52,22 @@ async function handleLogin() {
<template>
<div class="login-screen">
<section class="login-panel surface-card">
<div class="login-panel__header">
<div>
<div class="login-panel__eyebrow">Live Recorder</div>
<h1 class="login-panel__title">登录</h1>
</div>
<section class="login-brand" aria-label="Live Recorder 产品介绍">
<div class="login-brand__logo">
<span><el-icon :size="22"><VideoCamera /></el-icon></span>
<strong>Live Recorder</strong>
</div>
<div class="login-brand__message">
<span class="login-brand__status"><i />本地服务运行正常</span>
<h1>让每一次开播<br>都有迹可循</h1>
<p>集中监控直播状态自动完成录制与归档并在异常发生时第一时间告诉你</p>
</div>
<div class="login-brand__meta">SELF-HOSTED · PRIVATE · RELIABLE</div>
</section>
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
<section class="login-panel">
<div class="login-panel__toolbar">
<el-select v-model="themeMode" size="small" aria-label="界面主题" class="login-panel__theme-select">
<template #prefix>
<el-icon><component :is="currentThemeIcon" /></el-icon>
</template>
@@ -69,41 +77,49 @@ async function handleLogin() {
</el-select>
</div>
<el-alert
v-if="backendUnavailable"
class="login-panel__alert"
type="error"
:closable="false"
show-icon
title="后端服务暂时不可用"
:description="backendMessage"
/>
<div class="login-card surface-card">
<div class="login-card__header">
<div class="login-panel__eyebrow">LIVE RECORDER</div>
<h2 class="login-panel__title">欢迎回来</h2>
<p>登录后进入 Live Recorder 运行中心</p>
</div>
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input
v-model="form.username"
:prefix-icon="User"
autocomplete="username"
spellcheck="false"
/>
</el-form-item>
<el-alert
v-if="backendUnavailable"
class="login-panel__alert"
type="error"
:closable="false"
show-icon
title="后端服务暂时不可用"
:description="backendMessage"
/>
<el-form-item label="密码">
<el-input
v-model="form.password"
:prefix-icon="Lock"
type="password"
autocomplete="current-password"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input
v-model="form.username"
:prefix-icon="User"
autocomplete="username"
spellcheck="false"
/>
</el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录
</el-button>
</el-form>
<el-form-item label="密码">
<el-input
v-model="form.password"
:prefix-icon="Lock"
type="password"
autocomplete="current-password"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</el-form>
</div>
</section>
</div>
</template>
@@ -112,67 +128,48 @@ async function handleLogin() {
.login-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
background: var(--surface);
}
.login-panel {
width: min(100%, 420px);
padding: 24px;
.login-brand { position: relative; display: flex; flex-direction: column; min-height: 100vh; overflow: hidden; padding: clamp(32px, 5vw, 68px); border-right: 1px solid var(--border-subtle); background: radial-gradient(circle at 12% 8%, rgba(86,137,255,.18), transparent 34%), radial-gradient(circle at 92% 88%, rgba(146,119,255,.16), transparent 36%), linear-gradient(145deg, var(--accent-soft-2), var(--surface) 58%, var(--purple-soft)); }
.login-brand::after { content: ""; position: absolute; right: -170px; bottom: -180px; width: 480px; height: 480px; border: 75px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
.login-brand__logo { position: relative; z-index: 1; display: flex; align-items: center; gap: 12px; color: var(--text-primary); font-size: 15px; }
.login-brand__logo > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #fff; background: var(--action); box-shadow: 0 9px 24px color-mix(in srgb, var(--action) 26%, transparent); }
.login-brand__message { position: relative; z-index: 1; width: min(100%, 570px); margin: auto 0; }
.login-brand__status { display: inline-flex; align-items: center; gap: 9px; color: var(--success); font-size: 11px; font-weight: 750; }
.login-brand__status i { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); }
.login-brand__message h1 { margin: 24px 0 0; color: var(--text-primary); font-size: clamp(40px, 5vw, 66px); font-weight: 800; letter-spacing: -.065em; line-height: 1.08; }
.login-brand__message p { max-width: 500px; margin: 24px 0 0; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
.login-brand__meta { position: relative; z-index: 1; color: var(--text-muted); font-size: 9px; font-weight: 750; letter-spacing: .18em; }
.login-panel { position: relative; display: grid; place-items: center; min-height: 100vh; padding: 70px clamp(24px, 7vw, 96px); background: var(--surface); }
.login-panel__toolbar { position: absolute; top: 24px; right: 28px; }
.login-card { width: min(100%, 420px); padding: 30px; box-shadow: var(--shadow-sm); }
.login-card__header { margin-bottom: 24px; }
.login-card__header p { margin: 8px 0 0; color: var(--text-muted); font-size: 12px; }
.login-panel__eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; }
.login-panel__title { margin: 7px 0 0; color: var(--text-primary); font-size: 28px; font-weight: 780; letter-spacing: -.045em; }
.login-panel__theme-select { width: 122px; }
.login-panel__alert { margin-bottom: 18px; }
.login-form__submit { width: 100%; margin-top: 8px; }
:global(html[data-theme="dark"]) .login-brand { background: radial-gradient(circle at 12% 8%, rgba(58,112,220,.22), transparent 34%), radial-gradient(circle at 92% 88%, rgba(119,88,207,.19), transparent 36%), linear-gradient(145deg, #111d30, #172235 58%, #1c223b); }
@media (max-width: 900px) {
.login-screen { grid-template-columns: 1fr; min-height: 100dvh; }
.login-brand { min-height: 240px; padding: 26px 24px 30px; }
.login-brand__message { margin: 38px 0 0; }
.login-brand__message h1 { margin-top: 16px; font-size: 34px; }
.login-brand__message p { margin-top: 13px; font-size: 12px; line-height: 1.65; }
.login-brand__meta { display: none; }
.login-panel { min-height: 0; padding: 70px 18px 28px; }
.login-panel__toolbar { top: 18px; right: 18px; }
}
.login-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.login-panel__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.login-panel__title {
margin: 6px 0 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 750;
letter-spacing: -0.045em;
}
.login-panel__theme-select {
width: 122px;
}
.login-panel__alert {
margin-bottom: 18px;
}
.login-form__submit {
width: 100%;
margin-top: 8px;
}
@media (max-width: 767px) {
.login-screen {
padding: 18px 14px 24px;
}
.login-panel {
padding: 18px;
}
.login-panel__header {
flex-direction: column;
}
.login-panel__theme-select {
width: 100%;
}
@media (max-width: 520px) {
.login-brand { min-height: 218px; padding: 21px 18px 26px; }
.login-brand__logo > span { width: 34px; height: 34px; }
.login-brand__message { margin-top: 30px; }
.login-brand__status { display: none; }
.login-brand__message h1 { margin-top: 0; font-size: 29px; }
.login-brand__message p { display: none; }
.login-panel { padding-inline: 14px; }
.login-card { padding: 22px 18px; }
}
</style>
+130 -3
View File
@@ -4,7 +4,9 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
import { useViewport } from "@/composables/useViewport";
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import type {
RecordArtifactUploadBatchResult,
RecordPreviewTicket,
@@ -28,6 +30,7 @@ const props = defineProps<{
}>();
const router = useRouter();
const { isMobile } = useViewport();
// Danmaku replay dialog
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
@@ -301,14 +304,20 @@ onMounted(loadDetail);
<el-descriptions :column="1" border>
<el-descriptions-item label="会话状态">
<el-tag :type="sessionStatusTagType(detail.session.status)">
{{ sessionStatusLabelMap[detail.session.status] }}
{{ detail.session.isRecovering ? "恢复中" : sessionStatusLabelMap[detail.session.status] }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="detail.session.isRecovering" label="恢复进度">
<span class="recovery-status-copy">{{ detail.session.recoveryReason || "正在等待下一次自动重试" }}</span>
</el-descriptions-item>
<el-descriptions-item label="直播间">
{{ detail.session.liveRoomTitle }}
</el-descriptions-item>
<el-descriptions-item label="主播">
{{ detail.session.anchorName || "未知主播" }}
</el-descriptions-item>
<el-descriptions-item label="平台">
{{ platformLabelMap[detail.session.platform] }}
<PlatformMark :name="platformLabelMap[detail.session.platform]" />
</el-descriptions-item>
<el-descriptions-item label="Room ID">
<span class="monospace">{{ detail.session.roomId }}</span>
@@ -510,7 +519,40 @@ onMounted(loadDetail);
</div>
</div>
<div class="table-scroll-shell">
<div v-if="isMobile" class="segment-card-list">
<article v-for="row in detail.timeline.segments" :key="row.recordTaskId" class="segment-card">
<div class="segment-card__head">
<span>
<strong class="monospace">分片 #{{ row.segmentIndex }}</strong>
<small>{{ formatDuration(row.durationSeconds) }}</small>
</span>
<el-tag :type="sessionStatusTagType(row.status)">{{ taskStatusLabelMap[row.status] }}</el-tag>
</div>
<dl class="segment-card__facts">
<div class="segment-card__file">
<dt>文件</dt>
<dd class="monospace">{{ row.label || "-" }}</dd>
</div>
<div><dt>开始</dt><dd>{{ formatDate(row.startedAt) }}</dd></div>
<div><dt>结束</dt><dd>{{ formatDate(row.endedAt) }}</dd></div>
</dl>
<div class="segment-card__actions">
<el-button size="small" type="primary" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
<el-button
size="small"
plain
:disabled="row.status !== 4 && row.status !== 6"
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
>
弹幕回放
</el-button>
</div>
</article>
</div>
<div v-else 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 }">
@@ -789,6 +831,91 @@ onMounted(loadDetail);
color: var(--text-secondary);
}
.segment-card-list {
display: grid;
gap: 10px;
}
.segment-card {
display: grid;
gap: 13px;
min-width: 0;
padding: 14px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-muted);
}
.segment-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.segment-card__head > span {
display: grid;
min-width: 0;
gap: 4px;
}
.segment-card__head strong {
color: var(--text-primary);
font-size: 13px;
}
.segment-card__head small {
color: var(--text-muted);
font-size: 10px;
}
.segment-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
margin: 0;
}
.segment-card__facts > div {
display: grid;
min-width: 0;
gap: 4px;
}
.segment-card__facts dt {
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
}
.segment-card__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.55;
}
.segment-card__file {
grid-column: 1 / -1;
}
.segment-card__actions {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 8px;
padding-top: 11px;
border-top: 1px solid var(--border-subtle);
}
.segment-card__actions :deep(.el-button) {
flex: 0 0 auto;
margin: 0;
}
.segment-label,
.log-detail {
white-space: pre-wrap;
+75 -4
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
import { ArrowLeft, ArrowRight, Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
import apiClient, {
buildApiUrl,
getApiErrorMessage,
@@ -1104,12 +1104,14 @@ onBeforeUnmount(() => {
<div class="data-card__header">
<div>
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
<div class="session-card__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
<div class="data-card__subtitle monospace">Room ID · {{ session.roomId }}</div>
</div>
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
</div>
<div class="badge-row">
<span v-if="session.isRecovering" class="info-pill">自动重试中</span>
<span class="info-pill">{{ saveModeLabelMap[session.saveMode] }}</span>
<span class="info-pill">{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span class="info-pill">分片 {{ session.segmentCount }}</span>
@@ -1225,6 +1227,7 @@ onBeforeUnmount(() => {
</div>
<div class="session-title__main">
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
<div class="session-title__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
<div class="session-title__meta">
<span class="monospace">{{ session.roomId }}</span>
<span>会话 {{ session.id.slice(0, 8) }}</span>
@@ -1232,7 +1235,7 @@ onBeforeUnmount(() => {
</div>
<div class="session-title__stats">
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span>分片 {{ session.segmentCount }}</span>
@@ -1245,6 +1248,15 @@ onBeforeUnmount(() => {
</template>
<div class="session-body">
<el-alert
v-if="session.isRecovering"
class="session-recovery-alert"
type="warning"
:closable="false"
show-icon
title="录制连接正在自动恢复"
:description="session.recoveryReason || '系统会在直播仍在线时持续重试,并保留当前会话。'"
/>
<div class="session-summary">
<div class="session-summary__item">
<span class="session-summary__label">开始时间</span>
@@ -1370,7 +1382,24 @@ onBeforeUnmount(() => {
</el-collapse>
<div v-if="totalCount > pageSize" class="session-pagination">
<div v-if="isMobile" class="session-pagination__mobile" aria-label="会话分页">
<el-button
class="btn-prev"
:icon="ArrowLeft"
:disabled="currentPage <= 1"
@click="handlePageChange(currentPage - 1)"
>上一页</el-button>
<span class="session-pagination__position" aria-live="polite">
<strong>{{ currentPage }}</strong><span>/ {{ totalPages }}</span>
</span>
<el-button
class="btn-next"
:disabled="currentPage >= totalPages"
@click="handlePageChange(currentPage + 1)"
>下一页 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
</div>
<el-pagination
v-else
background
layout="prev, pager, next"
:current-page="currentPage"
@@ -1612,9 +1641,43 @@ onBeforeUnmount(() => {
.session-pagination {
display: flex;
justify-content: center;
width: 100%;
min-width: 0;
margin-top: 20px;
}
.session-pagination__mobile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.session-pagination__mobile :deep(.el-button) {
width: 100%;
min-width: 0;
margin: 0;
}
.session-pagination__position {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 4px;
min-width: 54px;
color: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.session-pagination__position strong {
color: var(--text-primary);
font-size: 14px;
}
.toolbar-row {
display: flex;
align-items: flex-start;
@@ -1742,6 +1805,14 @@ onBeforeUnmount(() => {
gap: 16px;
}
.session-card__anchor,
.session-title__anchor {
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.session-card__tasks {
display: grid;
gap: 10px;
+361 -8
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
RecoverableLiveRoom,
RecordingFailureItem,
RecordingFailureListResponse,
RecoveryActionResult,
RecoveryOverview
} from "@/types";
@@ -23,11 +26,29 @@ const resumeAllLoading = ref(false);
const runningKey = ref("");
const loadError = ref("");
const overview = ref<RecoveryOverview | null>(null);
const recordingFailures = ref<RecordingFailureItem[]>([]);
const recordingFailureTotal = ref(0);
const recordingFailurePage = ref(1);
const recordingFailureKind = ref("");
const recordingFailureLoading = ref(false);
const { isMobile } = useViewport();
const storage = computed(() => overview.value?.storage ?? null);
const liveRooms = computed(() => overview.value?.liveRooms ?? []);
const finalizations = computed(() => overview.value?.finalizations ?? []);
const mergedArtifacts = computed(() => overview.value?.mergedArtifacts ?? []);
const recordingFailurePageSize = computed(() => isMobile.value ? 8 : 20);
const recordingFailureKinds = [
{ label: "全部原因", value: "" },
{ label: "异常退出分片", value: "ReadableFragment" },
{ label: "短分片", value: "TooShort" },
{ label: "媒体不可读", value: "UnreadableMedia" },
{ label: "封装或收尾失败", value: "FinalizationFailed" },
{ label: "文件缺失", value: "MissingMedia" },
{ label: "直播离线", value: "SourceOffline" },
{ label: "未取得播放地址", value: "StreamUrlUnavailable" },
{ label: "待检测", value: "Unknown" }
];
async function loadOverview() {
loading.value = true;
@@ -46,6 +67,80 @@ async function loadOverview() {
}
}
async function loadRecordingFailures() {
recordingFailureLoading.value = true;
try {
const { data } = await apiClient.get<RecordingFailureListResponse>("/recovery/recording-failures", {
params: {
failureKind: recordingFailureKind.value || undefined,
skip: (recordingFailurePage.value - 1) * recordingFailurePageSize.value,
take: recordingFailurePageSize.value
}
});
recordingFailures.value = data.items;
recordingFailureTotal.value = data.totalCount;
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "录制失败产物加载失败。"));
} finally {
recordingFailureLoading.value = false;
}
}
function handleRecordingFailureFilter() {
recordingFailurePage.value = 1;
void loadRecordingFailures();
}
function handleRecordingFailurePage(page: number) {
recordingFailurePage.value = page;
void loadRecordingFailures();
}
async function acceptRecordingArtifact(item: RecordingFailureItem) {
let confirmShortArtifact = false;
if ((item.durationSeconds ?? 0) < 5) {
try {
await ElMessageBox.confirm(
`该分片只有 ${formatDuration(item.durationSeconds)}。确认后它会转为待上传,但不会自动上传。`,
"确认认领短分片",
{ confirmButtonText: "确认认领", cancelButtonText: "取消", type: "warning" }
);
confirmShortArtifact = true;
} catch {
return;
}
}
runningKey.value = `accept:${item.recordTaskId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(
`/recovery/recording-failures/${item.recordTaskId}/accept`,
{ confirmShortArtifact }
);
showActionResult(data);
await Promise.all([loadOverview(), loadRecordingFailures()]);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "分片校验未通过。"));
} finally {
runningKey.value = "";
}
}
async function repairRecordingArtifact(item: RecordingFailureItem) {
runningKey.value = `repair:${item.recordTaskId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(
`/recovery/recording-failures/${item.recordTaskId}/repair`
);
showActionResult(data);
await loadRecordingFailures();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "无法启动非破坏修复。"));
} finally {
runningKey.value = "";
}
}
async function retryLiveRoom(liveRoomId: string) {
runningKey.value = `retry:${liveRoomId}`;
@@ -114,8 +209,8 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) {
function formatBytes(bytes?: number) {
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) {
return "-";
}
@@ -130,6 +225,13 @@ function formatBytes(bytes: number) {
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
}
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
return "未知时长";
}
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)}` : `${(seconds / 60).toFixed(1)} 分钟`;
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
@@ -171,7 +273,9 @@ function finalizationTitle(item: RecoverableFinalization) {
return item.liveRoomTitle || item.roomId;
}
onMounted(loadOverview);
onMounted(async () => {
await Promise.all([loadOverview(), loadRecordingFailures()]);
});
</script>
<template>
@@ -195,12 +299,200 @@ onMounted(loadOverview);
<div class="stats-grid recovery-metrics" v-loading="loading">
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
<MetricCard label="已收纳短片" :value="mergedArtifacts.length" description="已合并或转存到恢复目录的源分片" :icon="VideoCamera" />
</div>
<el-card v-if="storage" class="surface-card" shadow="never">
<StorageCapacity :status="storage" />
</el-card>
<el-card v-if="mergedArtifacts.length > 0" class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
<h3 class="section-title">短分片合并记录</h3>
<p class="section-subtitle">源文件不会被覆盖恢复目录和清单可用于审计或人工还原</p>
</div>
</div>
<div v-if="isMobile" class="data-card-list">
<article v-for="row in mergedArtifacts" :key="row.sourceRecordTaskId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">源分片 {{ row.sourceRecordTaskId.slice(0, 8) }}</div>
<div class="data-card__subtitle">{{ formatDuration(row.sourceDurationSeconds) }}</div>
</div>
<StatusBadge :label="row.mergedIntoRecordTaskId ? '已合并' : '仅恢复保留'" status="completed" />
</div>
<div class="data-card__grid">
<div style="grid-column: 1 / -1;"><dt>合并结果</dt><dd class="monospace failure-path">{{ row.mergedVideoPath || "未生成独立结果" }}</dd></div>
<div style="grid-column: 1 / -1;"><dt>恢复目录</dt><dd class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</dd></div>
<div style="grid-column: 1 / -1;"><dt>恢复清单</dt><dd class="monospace failure-path">{{ row.manifestPath || "-" }}</dd></div>
</div>
</article>
</div>
<el-table v-else :data="mergedArtifacts" class="premium-table" table-layout="fixed" row-key="sourceRecordTaskId">
<el-table-column label="源分片" min-width="170">
<template #default="{ row }">
<div class="cell-title monospace">{{ row.sourceRecordTaskId.slice(0, 8) }}</div>
<div class="cell-subtitle">{{ formatDuration(row.sourceDurationSeconds) }} · {{ formatDate(row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="合并结果" min-width="280">
<template #default="{ row }"><div class="monospace failure-path">{{ row.mergedVideoPath || "仅恢复保留" }}</div></template>
</el-table-column>
<el-table-column label="恢复目录 / 清单" min-width="320">
<template #default="{ row }">
<div class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</div>
<div class="cell-subtitle monospace failure-path">{{ row.manifestPath || "未找到清单" }}</div>
</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">录制失败产物</h3>
<p class="section-subtitle">先区分可用分片可修复媒体和不可恢复记录所有修复都会保留原文件</p>
</div>
<div class="recovery-filter-actions">
<el-select
v-model="recordingFailureKind"
aria-label="录制失败原因筛选"
@change="handleRecordingFailureFilter"
>
<el-option
v-for="option in recordingFailureKinds"
:key="option.value || 'all'"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :loading="recordingFailureLoading" @click="loadRecordingFailures">刷新</el-button>
</div>
</div>
<EmptyState
v-if="!recordingFailureLoading && recordingFailures.length === 0"
title="没有匹配的录制失败产物"
description="当前分类下没有需要人工处理的文件。"
action-text="刷新列表"
@action="loadRecordingFailures"
/>
<div v-else-if="isMobile" class="data-card-list" v-loading="recordingFailureLoading">
<article v-for="row in recordingFailures" :key="row.recordTaskId" class="data-card failure-card">
<div class="data-card__header">
<div class="failure-card__identity">
<div class="data-card__title">{{ row.liveRoomTitle }}</div>
<div class="data-card__subtitle platform-line">
<PlatformMark :name="row.platformName" /> · 分片 #{{ row.segmentIndex }}
</div>
</div>
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
</div>
<p class="failure-card__action">{{ row.recommendedAction }}</p>
<div class="data-card__grid">
<div><dt>媒体状态</dt><dd>{{ row.fileExists ? "本地文件存在" : "本地文件缺失" }}</dd></div>
<div><dt>时长 / 大小</dt><dd>{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</dd></div>
<div v-if="row.filePath" style="grid-column: 1 / -1;"><dt>文件</dt><dd class="monospace failure-path">{{ row.filePath }}</dd></div>
<div v-if="row.errorMessage" style="grid-column: 1 / -1;"><dt>原始错误</dt><dd class="failure-error">{{ row.errorMessage }}</dd></div>
</div>
<div class="data-card__actions failure-actions">
<el-button
v-if="row.canAccept"
type="primary"
size="small"
:loading="runningKey === `accept:${row.recordTaskId}`"
@click="acceptRecordingArtifact(row)"
>确认有效</el-button>
<el-button
v-if="row.canRepair"
size="small"
:loading="runningKey === `repair:${row.recordTaskId}`"
@click="repairRecordingArtifact(row)"
>非破坏修复</el-button>
<el-button
v-if="row.canRetryRoom"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>重新开录</el-button>
</div>
</article>
</div>
<el-table
v-else
:data="recordingFailures"
v-loading="recordingFailureLoading"
class="premium-table"
table-layout="fixed"
row-key="recordTaskId"
>
<el-table-column label="直播间 / 分片" min-width="210">
<template #default="{ row }">
<div class="cell-title">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle platform-line"><PlatformMark :name="row.platformName" /> · #{{ row.segmentIndex }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="原因与建议" min-width="260">
<template #default="{ row }">
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
<div class="cell-subtitle failure-recommendation">{{ row.recommendedAction }}</div>
<div v-if="row.errorMessage" class="failure-error line-clamp-2">{{ row.errorMessage }}</div>
</template>
</el-table-column>
<el-table-column label="本地产物" min-width="240">
<template #default="{ row }">
<div class="monospace failure-path">{{ row.filePath || "-" }}</div>
<div class="cell-subtitle">{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="250" align="right">
<template #default="{ row }">
<div class="failure-actions failure-actions--desktop">
<el-button
v-if="row.canAccept"
type="primary"
size="small"
:loading="runningKey === `accept:${row.recordTaskId}`"
@click="acceptRecordingArtifact(row)"
>确认有效</el-button>
<el-button
v-if="row.canRepair"
size="small"
:loading="runningKey === `repair:${row.recordTaskId}`"
@click="repairRecordingArtifact(row)"
>修复</el-button>
<el-button
v-if="row.canRetryRoom"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>开录</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="recordingFailureTotal > recordingFailurePageSize" class="pagination-row">
<el-pagination
background
layout="prev, pager, next"
:pager-count="isMobile ? 3 : 7"
:page-size="recordingFailurePageSize"
:total="recordingFailureTotal"
:current-page="recordingFailurePage"
@current-change="handleRecordingFailurePage"
/>
</div>
</el-card>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
@@ -233,7 +525,7 @@ onMounted(loadOverview);
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
<PlatformMark :name="row.platformName" />
</div>
<div class="badge-row">
@@ -291,7 +583,7 @@ onMounted(loadOverview);
<el-table-column label="平台" width="120">
<template #default="{ row }">
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
<PlatformMark :name="row.platformName" />
</template>
</el-table-column>
@@ -364,7 +656,7 @@ onMounted(loadOverview);
<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 class="data-card__subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
</div>
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</div>
@@ -412,7 +704,7 @@ onMounted(loadOverview);
<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-subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
@@ -477,4 +769,65 @@ onMounted(loadOverview);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.recovery-filter-actions,
.failure-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.recovery-filter-actions :deep(.el-select) {
width: 190px;
}
.failure-card__identity {
min-width: 0;
}
.failure-card__action,
.failure-recommendation {
color: var(--text-secondary);
line-height: 1.6;
}
.failure-card__action {
margin: 0;
}
.failure-path,
.failure-error {
overflow-wrap: anywhere;
word-break: break-word;
}
.failure-error {
margin-top: 6px;
color: var(--el-color-danger);
font-size: 12px;
line-height: 1.55;
}
.failure-actions--desktop {
justify-content: flex-end;
}
.line-clamp-2 {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (max-width: 767px) {
.recovery-filter-actions {
width: 100%;
}
.recovery-filter-actions :deep(.el-select) {
flex: 1 1 180px;
width: auto;
}
}
</style>
+124 -46
View File
@@ -1,14 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { RecordArtifactUploadItemResult, UploadTaskItem, UploadTaskListResponse } from "@/types";
import type {
RecordArtifactUploadBatchResult,
RecordArtifactUploadItemResult,
UploadQueueHealth,
UploadTaskItem,
UploadTaskListResponse
} from "@/types";
import {
platformLabelMap,
uploadStatusLabelMap
@@ -28,6 +35,8 @@ const failedCount = ref(0);
const queuedCount = ref(0);
const uploadingCount = ref(0);
const waitingRetryCount = ref(0);
const matchingRetryableCount = ref(0);
const queueHealth = ref<UploadQueueHealth>({ state: "Healthy", isPaused: false });
const uploadStatusFilter = ref<number | null>(null);
const taskSearch = ref("");
const currentPage = ref(1);
@@ -39,6 +48,7 @@ let refreshTimer: number | null = null;
let activeRequest: AbortController | null = null;
let requestSerial = 0;
let appMain: HTMLElement | null = null;
let searchTimer: number | null = null;
const filterOptions = [
{ label: "全部", value: null as number | null },
@@ -51,21 +61,7 @@ const filterOptions = [
];
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
const filteredItems = computed(() => {
const keyword = taskSearch.value.trim().toLowerCase();
if (!keyword) {
return items.value;
}
return items.value.filter((item) => [
item.liveRoomTitle,
item.roomId,
item.filePath,
item.remoteVideoPath,
item.uploadErrorMessage,
item.recordTaskId
].some((value) => String(value || "").toLowerCase().includes(keyword)));
});
const filteredItems = computed(() => items.value);
function mergeItems(nextItems: UploadTaskItem[]) {
const previousById = new Map(items.value.map((item) => [item.recordTaskId, item]));
@@ -120,6 +116,9 @@ async function loadUploadStatus(options: { background?: boolean; cancelPrevious?
if (uploadStatusFilter.value !== null) {
params.uploadStatus = uploadStatusFilter.value;
}
if (taskSearch.value.trim()) {
params.query = taskSearch.value.trim();
}
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", {
params,
@@ -145,6 +144,8 @@ async function loadUploadStatus(options: { background?: boolean; cancelPrevious?
queuedCount.value = data.queuedCount;
uploadingCount.value = data.uploadingCount;
waitingRetryCount.value = data.waitingRetryCount;
matchingRetryableCount.value = data.matchingRetryableCount ?? 0;
queueHealth.value = data.queueHealth ?? { state: "Healthy", isPaused: false };
await nextTick();
if (options.preserveScroll !== false && appMain) {
appMain.scrollTop = scrollTop;
@@ -179,7 +180,11 @@ async function uploadTask(task: UploadTaskItem) {
uploadingTaskId.value = task.recordTaskId;
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.recordTaskId}/upload`);
const retrying = [2, 5].includes(task.uploadStatus);
const endpoint = retrying
? `/record-tasks/${task.recordTaskId}/upload/retry`
: `/record-tasks/${task.recordTaskId}/upload`;
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(endpoint);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} catch (error) {
@@ -190,32 +195,31 @@ async function uploadTask(task: UploadTaskItem) {
}
async function retryAllFailed() {
const failedItems = items.value.filter(item => item.uploadStatus === 2);
if (failedItems.length === 0) {
ElMessage.info("当前没有上传失败的任务。");
const retryableCount = matchingRetryableCount.value;
if (retryableCount === 0) {
ElMessage.info("当前筛选条件下没有可重试任务。");
return;
}
try {
await ElMessageBox.confirm(
`将立即重试全部 ${retryableCount} 个匹配任务,不受当前分页限制。`,
"重试全部匹配任务",
{ confirmButtonText: "确认重试", cancelButtonText: "取消", type: "warning" }
);
} catch {
return;
}
retryingFailed.value = true;
let successCount = 0;
let failCount = 0;
try {
for (const item of failedItems) {
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${item.recordTaskId}/upload`);
if (data.success) {
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
ElMessage[successCount > 0 ? "success" : "warning"](
`重试请求已处理:已受理 ${successCount},失败 ${failCount}`
const status = [2, 5].includes(uploadStatusFilter.value ?? -1) ? uploadStatusFilter.value : null;
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>("/record-tasks/upload/retry", {
uploadStatus: status,
query: taskSearch.value.trim() || null
});
ElMessage[data.successCount > 0 ? "success" : "warning"](
`重试请求已处理:已受理 ${data.successCount},失败 ${data.failedCount}`
);
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} finally {
@@ -262,7 +266,7 @@ function openDetail(task: UploadTaskItem) {
}
function canUpload(task: UploadTaskItem) {
return ![1, 3, 4, 5].includes(task.uploadStatus);
return ![1, 3, 4].includes(task.uploadStatus);
}
function isUploadInProgress(task: UploadTaskItem) {
@@ -307,11 +311,32 @@ function handleVisibilityChange() {
}
}
async function resumeQueue() {
try {
const { data } = await apiClient.post<UploadQueueHealth>("/upload-queue/openlist/resume");
queueHealth.value = data;
ElMessage.success("OpenList 认证成功,上传队列已恢复。");
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "OpenList 仍不可用,请检查账号、密码和限流状态。"));
}
}
watch(isWideDesktop, () => {
currentPage.value = 1;
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
});
watch(taskSearch, () => {
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
searchTimer = window.setTimeout(() => {
currentPage.value = 1;
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
}, 350);
});
onMounted(async () => {
appMain = document.querySelector<HTMLElement>(".app-main");
document.addEventListener("visibilitychange", handleVisibilityChange);
@@ -321,6 +346,9 @@ onMounted(async () => {
onBeforeUnmount(() => {
stopRefreshTimer();
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
activeRequest?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
@@ -345,6 +373,15 @@ onBeforeUnmount(() => {
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div v-if="queueHealth.isPaused" class="queue-health-banner" role="status">
<div>
<strong>{{ queueHealth.state === "RateLimited" ? "OpenList 正在限流" : queueHealth.state === "Disabled" ? "上传队列已暂停" : "OpenList 认证需要处理" }}</strong>
<p>{{ queueHealth.reason || "队列已停止发起新请求,任务重试次数不会继续消耗。" }}</p>
<span v-if="queueHealth.retryAt">预计可重试{{ formatDate(queueHealth.retryAt) }}</span>
</div>
<el-button v-if="queueHealth.state !== 'Disabled'" type="primary" @click="resumeQueue">验证并恢复队列</el-button>
</div>
<div class="stats-grid">
<MetricCard label="全部任务" :value="totalCount" :description="`已完成 ${succeededCount} 个`" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未进入上传队列" :icon="UploadFilled" />
@@ -361,7 +398,7 @@ onBeforeUnmount(() => {
<div class="toolbar-row">
<div>
<h3 class="section-title">上传队列</h3>
<p class="section-subtitle">按状态和文件信息定位任务批量操作仅作用于当前页</p>
<p class="section-subtitle">按状态和文件信息定位任务批量重试会处理全部匹配结果</p>
</div>
<div class="toolbar-row__actions">
<el-button
@@ -378,7 +415,7 @@ onBeforeUnmount(() => {
:loading="retryingFailed"
@click="retryAllFailed"
>
重试本页失败
重试全部匹配
</el-button>
</div>
</div>
@@ -388,7 +425,7 @@ onBeforeUnmount(() => {
<el-select v-model="uploadStatusFilter" placeholder="全部状态" aria-label="上传状态筛选" @change="handleFilterChange">
<el-option v-for="opt in filterOptions" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
</el-select>
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / {{ items.length }}</span>
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / {{ totalCount }}</span>
</div>
<el-skeleton v-if="initialLoading && items.length === 0" :rows="6" animated />
@@ -407,7 +444,7 @@ onBeforeUnmount(() => {
<div class="upload-item-card__header">
<div class="upload-item-card__identity">
<strong>{{ row.liveRoomTitle }}</strong>
<span>{{ platformLabelMap[row.platform] ?? "-" }} · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
<span class="platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
</div>
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
@@ -445,7 +482,7 @@ onBeforeUnmount(() => {
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>{{ row.uploadStatus === 2 ? "重试上传" : "立即上传" }}</el-button>
>{{ [2, 5].includes(row.uploadStatus) ? "立即重试" : "立即上传" }}</el-button>
</div>
</article>
</div>
@@ -461,7 +498,7 @@ onBeforeUnmount(() => {
<template #default="{ row }">
<div>
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle">{{ platformLabelMap[row.platform] ?? "-" }} · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
<div class="cell-subtitle platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
</div>
</template>
</el-table-column>
@@ -513,7 +550,7 @@ onBeforeUnmount(() => {
size="small"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>{{ row.uploadStatus === 2 ? "重试" : "上传" }}</el-button>
>{{ [2, 5].includes(row.uploadStatus) ? "重试" : "上传" }}</el-button>
</div>
</template>
</el-table-column>
@@ -545,6 +582,47 @@ onBeforeUnmount(() => {
border-radius: 14px;
}
.queue-health-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border: 1px solid color-mix(in srgb, var(--el-color-warning) 34%, var(--border-subtle));
border-radius: 14px;
background: color-mix(in srgb, var(--el-color-warning) 9%, var(--surface-card));
}
.queue-health-banner > div {
min-width: 0;
}
.queue-health-banner strong {
display: block;
color: var(--text-primary);
}
.queue-health-banner p {
margin: 4px 0 0;
color: var(--text-secondary);
line-height: 1.55;
overflow-wrap: anywhere;
}
.queue-health-banner span {
display: block;
margin-top: 4px;
color: var(--text-tertiary);
font-size: 12px;
}
@media (max-width: 767px) {
.queue-health-banner {
align-items: stretch;
flex-direction: column;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+304 -10
View File
@@ -63,6 +63,7 @@ const session = {
id: "session-12345678",
liveRoomId: room.id,
liveRoomTitle: room.title,
anchorName: room.anchorName,
platform: 0,
roomId: room.roomId,
status: 4,
@@ -82,6 +83,28 @@ const session = {
tasks: [task]
};
const sessionDetail = {
session,
timeline: {
anchorAt: now,
totalDurationSeconds: 5400,
segments: Array.from({ length: 3 }, (_, index) => ({
recordTaskId: `task-${index + 1}`,
segmentIndex: index + 1,
status: 4,
startedAt: new Date(Date.parse(now) + index * 1_800_000).toISOString(),
endedAt: new Date(Date.parse(now) + (index + 1) * 1_800_000).toISOString(),
offsetSeconds: index * 1800,
durationSeconds: 1800,
label: `/volume1/录制/示例主播/2026-08-02/分片-${String(index + 1).padStart(3, "0")}.mp4`,
detail: "录制完成"
})),
events: [],
heatBuckets: []
},
logs: []
};
const uploadTask = {
recordTaskId: task.id,
recordSessionId: session.id,
@@ -99,6 +122,29 @@ const uploadTask = {
createdAt: now
};
const recordingFailure = {
recordTaskId: "failed-task-1",
recordSessionId: "failed-session-1",
liveRoomId: room.id,
liveRoomTitle: room.title,
roomId: room.roomId,
platformName: room.platformName,
segmentIndex: 7,
failureKind: "ReadableFragment",
failureLabel: "异常退出分片",
recommendedAction: "文件可以读取。确认内容有效后,将它转为待上传任务。",
errorMessage: "FFmpeg 异常退出;原始文件会保留,不会被修复流程覆盖。",
filePath: "/volume1/录制/示例主播/2026-08-02/一个用于验证窄屏换行的很长文件名-007.mp4",
fileSizeBytes: 4_294_967_296,
durationSeconds: 1789,
fileExists: true,
canAccept: true,
canRepair: false,
canRetryRoom: false,
isRepairing: false,
createdAt: now
};
const dashboard = {
activeRecordingCount: 1,
liveRoomCount: 3,
@@ -160,6 +206,7 @@ async function mockApi(page: Page) {
totalTaskCount: 1,
totalDanmakuCount: session.totalDanmakuMessageCount
};
else if (path === `/api/record-sessions/${session.id}`) body = sessionDetail;
else if (path === "/api/record-sessions") body = [session];
else if (path === "/api/record-tasks/upload-status") body = {
items: [uploadTask],
@@ -170,7 +217,18 @@ async function mockApi(page: Page) {
failedCount: 0,
queuedCount: 0,
uploadingCount: 0,
waitingRetryCount: 0
waitingRetryCount: 0,
matchingRetryableCount: 0,
queueHealth: { state: "Healthy", isPaused: false }
};
else if (path === "/api/recovery/recording-failures") body = {
items: [recordingFailure],
totalCount: 1
};
else if (path === "/api/recovery") body = {
storage: dashboard.storageStatus,
liveRooms: [],
finalizations: []
};
else if (path === "/api/settings") body = {};
else if (path.includes("/record-sessions/stream")) {
@@ -204,23 +262,117 @@ test.beforeEach(async ({ page }) => {
await mockApi(page);
});
test("login preserves the product hierarchy without viewport overflow", async ({ page }, testInfo) => {
await page.addInitScript(() => localStorage.clear());
await page.goto("/login");
await expect(page.getByRole("heading", { name: /让每一次开播/ })).toBeVisible();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
await expect(page.getByRole("button", { name: /登录控制台/ })).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "login");
});
test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "仪表盘" })).toBeVisible();
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
const storage = page.getByTestId("storage-capacity");
await expect(storage).toContainText("75.0%");
await expect(storage).toContainText("已使用");
await expect(storage).toContainText("25.0%");
if ((page.viewportSize()?.width ?? 0) <= 768) {
const avatarGeometry = await page.locator(".app-user-btn").evaluate((button) => {
const avatar = button.querySelector<SVGSVGElement>(".app-user-btn__avatar")!;
const circle = avatar.querySelector<SVGCircleElement>("circle")!;
const buttonRect = button.getBoundingClientRect();
const avatarRect = avatar.getBoundingClientRect();
const circleRect = circle.getBoundingClientRect();
return {
tagName: avatar.tagName.toLowerCase(),
buttonDelta: Math.abs(buttonRect.width - buttonRect.height),
avatarDelta: Math.abs(avatarRect.width - avatarRect.height),
circleDelta: Math.abs(circleRect.width - circleRect.height)
};
});
expect(avatarGeometry.tagName).toBe("svg");
expect(avatarGeometry.buttonDelta).toBeLessThanOrEqual(1);
expect(avatarGeometry.avatarDelta).toBeLessThanOrEqual(1);
expect(avatarGeometry.circleDelta).toBeLessThanOrEqual(1);
}
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "dashboard");
});
test("mobile navigation animates route and active state without a tap frame", async ({ page }, testInfo) => {
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile navigation contract");
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
await expect(page.locator(".app-main")).toBeVisible();
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
await page.evaluate(() => {
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = false;
const observer = new MutationObserver((mutations) => {
if (mutations.some((mutation) =>
mutation.target instanceof HTMLElement && mutation.target.className.includes("page-swap-")
)) {
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = true;
observer.disconnect();
}
});
observer.observe(document.querySelector(".app-main")!, {
subtree: true,
attributes: true,
attributeFilter: ["class"]
});
});
const recordNavigation = page.locator(".app-bottom-nav").getByRole("button", { name: "录制", exact: true });
const tapHighlight = await recordNavigation.evaluate((element) =>
getComputedStyle(element).webkitTapHighlightColor
);
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
await recordNavigation.click();
await expect(page).toHaveURL(/\/live-rooms$/);
await expect(recordNavigation).toHaveClass(/is-active/);
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
await expect.poll(() => page.evaluate(() =>
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved
)).toBeTruthy();
const activeIndicator = await recordNavigation.locator(".app-bottom-nav__icon").evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
duration: getComputedStyle(element).transitionDuration
}));
expect(activeIndicator.background).not.toBe("rgba(0, 0, 0, 0)");
expect(activeIndicator.duration).not.toBe("0s");
await capture(page, testInfo, "mobile-navigation-active");
});
test("dark mode teleported overlays inherit dark surfaces and borders", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("live-recorder-ui-theme", "dark"));
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.locator(".app-user-btn").click();
const dropdown = page.locator(".el-dropdown__popper:visible").first();
await expect(dropdown).toBeVisible();
const colors = await dropdown.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
rootBorder: getComputedStyle(document.documentElement).getPropertyValue("--el-border-color-light").trim()
}));
expect(colors.background).toBe("rgb(23, 34, 53)");
expect(colors.border).toBe("rgb(44, 60, 85)");
expect(colors.rootBorder).toBe("#2c3c55");
});
test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => {
await page.goto("/live-rooms");
await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) >= 768) {
if ((page.viewportSize()?.width ?? 0) > 768) {
const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
await expect(actionHeader).toBeVisible();
const box = await actionHeader.boundingBox();
@@ -228,10 +380,38 @@ test("live room table, drawer and dialog retain their final actions", async ({ p
}
await page.getByRole("button", { name: /^查看/ }).first().click();
const drawer = page.locator(".right-drawer");
const drawerFooter = page.locator(".right-drawer__footer");
await expect(drawerFooter).toBeVisible();
if ((page.viewportSize()?.width ?? 0) <= 768) {
const viewportWidth = page.viewportSize()?.width ?? 0;
await expect.poll(async () => {
const box = await drawer.boundingBox();
return Boolean(box && box.x >= -1 && box.x + box.width <= viewportWidth + 1);
}).toBe(true);
if (viewportWidth <= 640) {
const box = await drawer.boundingBox();
expect(Math.round(box?.width ?? 0)).toBe(viewportWidth);
}
const drawerBody = page.locator(".right-drawer__body");
const bodyOverflow = await drawerBody.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowY: getComputedStyle(element).overflowY
}));
expect(bodyOverflow.scrollWidth).toBeLessThanOrEqual(bodyOverflow.clientWidth);
expect(bodyOverflow.overflowY).toBe("auto");
await drawerBody.evaluate((element) => { element.scrollTop = element.scrollHeight; });
const lastDetailRow = page.locator(".detail-panel__descriptions tr").last();
await expect(lastDetailRow).toBeVisible();
const lastRowBox = await lastDetailRow.boundingBox();
const fixedFooterBox = await drawerFooter.boundingBox();
expect(lastRowBox && fixedFooterBox && lastRowBox.y + lastRowBox.height)
.toBeLessThanOrEqual((fixedFooterBox?.y ?? 0) + 1);
}
const footerBox = await drawerFooter.boundingBox();
expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
await capture(page, testInfo, "live-room-drawer");
await page.getByRole("button", { name: "关闭", exact: true }).click();
await expect(drawerFooter).toBeHidden();
@@ -246,7 +426,7 @@ test("live room table, drawer and dialog retain their final actions", async ({ p
});
test("mobile live room list paginates large collections", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract");
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile-only pagination contract");
const manyRooms = Array.from({ length: 25 }, (_, index) => ({
...room,
id: `room-${index + 1}`,
@@ -269,6 +449,13 @@ test("mobile live room list paginates large collections", async ({ page }) => {
test("record task cards and tables expose one primary action", async ({ page }, testInfo) => {
await page.goto("/record-tasks");
await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible();
await expect(page.getByText("主播 · 示例主播", { exact: true })).toBeVisible();
if ((page.viewportSize()?.width ?? 0) <= 768) {
const tapHighlight = await page.getByRole("button", { name: "刷新列表" }).evaluate((element) =>
getComputedStyle(element).webkitTapHighlightColor
);
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
}
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
@@ -276,7 +463,7 @@ test("record task cards and tables expose one primary action", async ({ page },
await capture(page, testInfo, "record-tasks");
});
test("record sessions paginate on the server and bound the rendered page", async ({ page }) => {
test("record sessions paginate on the server and bound the rendered page", async ({ page }, testInfo) => {
const allSessions = Array.from({ length: 200 }, (_, index) => ({
...session,
id: `session-${index + 1}`,
@@ -308,11 +495,11 @@ test("record sessions paginate on the server and bound the rendered page", async
});
await page.goto("/record-tasks");
const expectedPageSize = (page.viewportSize()?.width ?? 0) < 768 ? 12 : 20;
const expectedPageSize = (page.viewportSize()?.width ?? 0) <= 768 ? 12 : 20;
await expect.poll(() => requests.length).toBeGreaterThan(0);
expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize });
await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible();
await expect(page.locator((page.viewportSize()?.width ?? 0) < 768 ? ".session-card" : ".session-panel"))
await expect(page.locator((page.viewportSize()?.width ?? 0) <= 768 ? ".session-card" : ".session-panel"))
.toHaveCount(expectedPageSize);
const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
expect(renderedNodeCount).toBeLessThan(5_000);
@@ -320,10 +507,24 @@ test("record sessions paginate on the server and bound the rendered page", async
await page.locator(".session-pagination .btn-next").click();
await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy();
await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible();
if ((page.viewportSize()?.width ?? 0) <= 768) {
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
const pageCount = Math.ceil(allSessions.length / expectedPageSize);
const middlePage = Math.ceil(pageCount / 2);
for (let pageNumber = 2; pageNumber < middlePage; pageNumber++) {
await page.locator(".session-pagination__mobile .btn-next").click();
await expect(page.getByText(`分页录制会话 ${pageNumber * expectedPageSize + 1}`, { exact: true })).toBeVisible();
}
await expect(page.locator(".session-pagination__position")).toContainText(`${middlePage}/ ${pageCount}`);
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
await expectNoDocumentOverflow(page);
await page.locator(".session-pagination__mobile").scrollIntoViewIfNeeded();
await capture(page, testInfo, "record-session-middle-page");
}
});
test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract");
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile refresh contract");
await page.addInitScript(() => {
class MockEventSource {
@@ -428,8 +629,99 @@ test("upload tasks use cards on mobile and never require table horizontal scroll
await capture(page, testInfo, "upload-tasks");
});
test("paused uploads expose retry controls without overflowing the viewport", async ({ page }, testInfo) => {
await page.route("**/api/record-tasks/upload-status**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [{
...uploadTask,
uploadStatus: 5,
uploadAttemptCount: 3,
uploadErrorMessage: "OpenList 登录请求过多,队列已全局暂停,当前任务次数不会继续消耗。"
}],
totalCount: 1,
notUploadedCount: 0,
failedArtifactCount: 0,
succeededCount: 0,
failedCount: 0,
queuedCount: 0,
uploadingCount: 0,
waitingRetryCount: 1,
matchingRetryableCount: 1,
queueHealth: {
state: "RateLimited",
isPaused: true,
reason: "OpenList 登录请求触发限流。队列暂停期间不会发起新的上传请求,也不会消耗单任务重试额度。",
retryAt: "2026-08-02T12:15:00Z"
}
})
});
});
await page.goto("/upload-tasks");
await expect(page.getByText("OpenList 正在限流", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "验证并恢复队列" })).toBeVisible();
await expect(page.getByRole("button", { name: "重试全部匹配" })).toBeVisible();
const retryLabel = (page.viewportSize()?.width ?? 0) < 1280 ? "立即重试" : "重试";
await expect(page.getByRole("button", { name: retryLabel, exact: true })).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "upload-queue-paused");
});
test("recording failure recovery adapts its actions and long paths to each viewport", async ({ page }, testInfo) => {
await page.goto("/recovery");
await expect(page.getByRole("heading", { name: "恢复中心" })).toBeVisible();
await expect(page.getByRole("heading", { name: "录制失败产物" })).toBeVisible();
await expect(page.getByText(recordingFailure.filePath, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "确认有效" })).toBeVisible();
if ((page.viewportSize()?.width ?? 0) <= 767) {
await expect(page.locator(".failure-card")).toHaveCount(1);
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
} else {
await expect(page.locator(".failure-card")).toHaveCount(0);
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
}
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "recording-failure-recovery");
});
test("mobile session details keep segment actions visible and left aligned", async ({ page }, testInfo) => {
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile session detail contract");
await page.goto("/record-tasks");
const sessionCard = page.locator(".session-card").first();
await expect(sessionCard).toBeVisible();
await sessionCard.locator(":scope > .data-card__actions").getByRole("button", { name: "查看", exact: true }).click();
await expect(page.getByRole("heading", { name: "会话详情" })).toBeVisible();
await expect(page.locator(".segment-card")).toHaveCount(3);
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
const firstCard = page.locator(".segment-card").first();
const firstActions = firstCard.locator(".segment-card__actions");
const cardBox = await firstCard.boundingBox();
const actionsBox = await firstActions.boundingBox();
expect(cardBox).not.toBeNull();
expect(actionsBox).not.toBeNull();
expect(actionsBox!.x - cardBox!.x).toBeLessThanOrEqual(20);
const lastActions = page.locator(".segment-card__actions").last();
await lastActions.scrollIntoViewIfNeeded();
await expect(lastActions.getByRole("button", { name: "查看分片" })).toBeVisible();
const actionsBottom = await lastActions.boundingBox();
const bottomNav = await page.locator(".app-bottom-nav").boundingBox();
expect(actionsBottom).not.toBeNull();
expect(bottomNav).not.toBeNull();
expect(actionsBottom!.y + actionsBottom!.height).toBeLessThanOrEqual(bottomNav!.y + 1);
await capture(page, testInfo, "mobile-session-detail");
});
test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract");
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile polling contract");
const allItems = Array.from({ length: 60 }, (_, index) => ({
...uploadTask,
@@ -466,7 +758,9 @@ test("mobile upload polling keeps cards and scroll position while progress updat
failedCount: 0,
queuedCount: 0,
uploadingCount: allItems.length,
waitingRetryCount: 0
waitingRetryCount: 0,
matchingRetryableCount: 0,
queueHealth: { state: "Healthy", isPaused: false }
})
});
inFlight--;