feat: improve recording recovery and upload workflow
This commit is contained in:
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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--;
|
||||
|
||||
Reference in New Issue
Block a user