feat: harden recording lifecycle and refresh fnOS UI

This commit is contained in:
2026-08-03 23:45:26 +08:00
parent e5b50ea85c
commit ecc737f0bd
90 changed files with 6995 additions and 1826 deletions
+64
View File
@@ -16,6 +16,7 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.3",
"vite": "^6.2.0",
@@ -1012,6 +1013,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2681,6 +2698,53 @@
}
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -18,6 +19,7 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.3",
"vite": "^6.2.0",
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "@playwright/test";
const viewports = [
{ name: "desktop-1920", width: 1920, height: 1080 },
{ name: "desktop-1366", width: 1366, height: 768 },
{ name: "tablet-1024", width: 1024, height: 768 },
{ name: "tablet-768", width: 768, height: 1024 },
{ name: "mobile-390", width: 390, height: 844 }
];
export default defineConfig({
testDir: "./tests/e2e",
outputDir: "./test-results",
fullyParallel: false,
retries: 0,
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:47173",
colorScheme: "light",
reducedMotion: "reduce",
trace: "retain-on-failure"
},
webServer: {
command: "VITE_DISABLE_DEVTOOLS=1 npm run dev -- --host 127.0.0.1 --port 47173 --strictPort",
url: "http://127.0.0.1:47173",
reuseExistingServer: false,
timeout: 120_000
},
projects: viewports.map(({ name, width, height }) => ({
name,
use: { viewport: { width, height } }
}))
});
-1
View File
@@ -154,7 +154,6 @@ function notifyBackendUnavailable(message: string) {
}
apiClient.interceptors.request.use((config) => {
console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || ""));
const token = localStorage.getItem("live-recorder-token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
+12 -5
View File
@@ -229,7 +229,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
<style scoped>
/* ==================== Shell ==================== */
.app-shell { display: flex; min-height: 100vh; }
.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; }
@@ -287,12 +287,16 @@ html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
.collapsed .app-health { justify-content: center; padding: 10px 0; }
/* ==================== Main column ==================== */
.app-main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; }
.app-main-col {
display: flex; flex: 1; flex-direction: column; height: 100dvh;
min-width: 0; min-height: 0; margin-left: var(--sidebar-w); overflow: hidden;
transition: margin .2s ease;
}
.app-sidebar.collapsed ~ .app-main-col { margin-left: var(--sidebar-w-collapsed); }
/* ==================== Topbar ==================== */
.app-topbar {
position: sticky; top: 0; z-index: 20;
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);
@@ -312,7 +316,10 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
.app-user-btn__name { font-size: 13px; font-weight: 700; }
/* ==================== Main ==================== */
.app-main { padding: 28px 32px; overflow-x: hidden; }
.app-main {
flex: 1; min-width: 0; min-height: 0; padding: 24px 28px 0;
overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable;
}
.backend-alert { width: min(100%, var(--page-max)); margin: 0 auto 18px; border-radius: var(--radius-md); }
/* ==================== Responsive ==================== */
@@ -322,7 +329,7 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
.app-sidebar:not(.mobile-open) .app-brand { display: none; }
.app-sidebar:not(.mobile-open) .app-sidebar__foot { display: none; }
.app-main-col { margin-left: 0 !important; }
.app-main { padding: 12px 12px; }
.app-main { padding: 12px 12px 0; }
.app-topbar { padding: 0 10px; gap: 6px; }
.app-topbar__menu-btn { display: grid; }
.app-breadcrumb { font-size: 11.5px; max-width: 130px; }
+12 -2
View File
@@ -52,12 +52,16 @@ const visible = computed({
<style scoped>
:deep(.right-drawer .el-drawer__body) {
height: 100%;
padding: 0;
overflow: hidden;
}
.right-drawer__shell {
display: flex;
min-height: 100%;
width: 100%;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--surface);
}
@@ -69,6 +73,7 @@ const visible = computed({
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.right-drawer__eyebrow {
@@ -100,9 +105,12 @@ const visible = computed({
}
.right-drawer__body {
flex: 1;
flex: 1 1 auto;
min-height: 0;
padding: 20px 24px 24px;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.right-drawer__footer {
@@ -111,5 +119,7 @@ const visible = computed({
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid var(--border-subtle);
flex: 0 0 auto;
background: var(--surface);
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
const props = withDefaults(defineProps<{
src?: string | null;
alt?: string;
size?: number;
}>(), {
src: "",
alt: "",
size: 48
});
const emit = defineEmits<{
error: [event: Event];
}>();
const failed = ref(false);
const style = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`
}));
watch(() => props.src, () => {
failed.value = false;
});
function handleError(event: Event) {
failed.value = true;
emit("error", event);
}
</script>
<template>
<span class="safe-avatar" :style="style">
<img
v-if="src && !failed"
:src="src"
:alt="alt"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="handleError"
/>
<span v-else class="safe-avatar__fallback"><slot /></span>
</span>
</template>
<style scoped>
.safe-avatar {
display: inline-grid;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 50%;
color: var(--accent-strong);
background: var(--accent-soft);
font-weight: 700;
}
.safe-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.safe-avatar__fallback {
line-height: 1;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from "vue";
interface StorageCapacityStatus {
isEnabled: boolean;
isAvailable: boolean;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
usagePercent: number;
freePercent: number;
tier: string;
message: string;
}
const props = defineProps<{ status: StorageCapacityStatus }>();
const percentage = computed(() => {
const value = Number(props.status.usagePercent);
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
});
const tier = computed(() => props.status.tier?.toLowerCase() || "red");
const statusLabel = computed(() => {
if (!props.status.isAvailable) return "无法读取";
if (!props.status.isEnabled) return "保护未启用";
if (tier.value === "green") return "空间充足";
if (tier.value === "yellow") return "空间偏低";
return "空间紧张";
});
const tagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (!props.status.isAvailable) return "danger";
if (!props.status.isEnabled) return "info";
if (tier.value === "green") return "success";
if (tier.value === "yellow") return "warning";
return "danger";
});
const barColor = computed(() => {
if (!props.status.isAvailable) return "var(--danger)";
if (!props.status.isEnabled) return "var(--text-muted)";
if (tier.value === "green") return "var(--success)";
if (tier.value === "yellow") return "var(--warning)";
return "var(--danger)";
});
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) return "--";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
}
</script>
<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>
<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)}%`"
/>
<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>
</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; }
@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; }
}
</style>
+28 -10
View File
@@ -172,12 +172,12 @@ html[data-theme="dark"] #app {
/* ============================ Base ============================ */
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; }
html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; }
body {
color: var(--text-primary);
background: var(--bg-base);
text-rendering: optimizeLegibility;
overflow-x: hidden;
overflow: hidden;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
@@ -190,7 +190,7 @@ button { font-family: inherit; cursor: pointer; }
::selection { background: var(--accent-soft); }
/* ============================ Page layout ============================ */
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; }
.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); }
@@ -204,7 +204,7 @@ button { font-family: inherit; cursor: pointer; }
border-radius: var(--radius-md); border: 1px solid var(--border-subtle);
background: var(--surface); box-shadow: var(--shadow-xs);
}
.surface-card:hover { box-shadow: var(--shadow-sm); }
.surface-card:hover { border-color: var(--border-base); }
.surface-card .el-card__body { padding: 18px; }
/* stat grid / KPI cards */
@@ -224,9 +224,17 @@ button { font-family: inherit; cursor: pointer; }
.section-subtitle { margin: 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.7; }
.section-header, .toolbar-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.toolbar-row { margin-bottom: 18px; }
.list-filterbar {
display: grid; grid-template-columns: minmax(240px, 1fr) 180px auto; align-items: center;
gap: 10px; margin: 0 0 18px;
}
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* table */
.table-scroll-shell { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-scroll-shell {
width: 100%; min-width: 0; overflow-x: auto; overflow-y: visible;
padding-bottom: 2px; scrollbar-gutter: stable; -webkit-overflow-scrolling: touch;
}
.cell-title { color: var(--text-primary); font-size: 15px; font-weight: 700; line-height: 1.45; }
.cell-subtitle { margin-top: 2px; color: var(--text-muted); font-size: 12px; line-height: 1.6; font-weight: 500; }
.cell-mono, .table-date-text, .monospace { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
@@ -326,21 +334,29 @@ html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; }
.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); }
.premium-table { min-width: 920px; }
.premium-table { width: 100%; }
/* descriptions */
.el-descriptions { --el-descriptions-table-border: 1px solid var(--border-subtle); }
.el-descriptions__body .el-descriptions__table { border-radius: var(--radius-sm); overflow: hidden; }
.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--surface-muted); color: var(--text-muted); font-weight: 600; }
.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { background: var(--surface); color: var(--text-primary); }
.el-descriptions__content { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
/* dialogs */
.el-dialog { border-radius: var(--radius-md); border: 1px solid var(--border-base); background: var(--surface); box-shadow: var(--shadow-float); }
.el-overlay-dialog { display: grid; place-items: center; padding: 16px; overflow: hidden; }
.el-dialog {
display: flex; flex-direction: column; max-width: calc(100vw - 32px);
max-height: calc(100dvh - 32px); margin: 0 !important;
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-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; }
.el-dialog__title { color: var(--text-primary); font-size: 18px; font-weight: 700; letter-spacing: -.03em; }
.el-dialog__body { padding: 12px 24px 6px; }
.el-dialog__body { min-height: 0; padding: 12px 24px 6px; overflow: auto; overscroll-behavior: contain; }
.el-dialog__footer { padding: 12px 24px 20px; }
.el-empty__description, .el-empty__description p, .el-result__subtitle { color: var(--text-secondary); }
@@ -379,11 +395,13 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
.stat-card__value { font-size: 24px; }
.desktop-only { display: none !important; }
.mobile-only { display: block !important; }
.el-dialog { width: min(100vw - 16px, 560px) !important; margin: 3vh auto 0 !important; }
.el-overlay-dialog { padding: 8px; }
.el-dialog { width: min(100vw - 16px, 560px) !important; max-height: calc(100dvh - 16px); margin: 0 !important; }
.el-dialog__header { padding: 16px 16px 8px; }
.el-dialog__body { padding: 10px 16px 4px; }
.el-dialog__footer { padding: 10px 16px 16px; }
.el-table th.el-table__cell { padding: 10px 12px; font-size: 11px; }
.el-table td.el-table__cell { padding: 10px 12px; font-size: 12.5px; line-height: 1.45; }
.list-filterbar { grid-template-columns: 1fr; }
.list-filterbar__count { justify-self: start; }
}
@media (max-width: 400px) { .stats-grid { grid-template-columns: 1fr; } }
+52 -1
View File
@@ -191,6 +191,17 @@ export interface RecordSession {
tasks: RecordTask[];
}
export interface RecordSessionListResponse {
items: RecordSession[];
totalCount: number;
skip: number;
take: number;
totalSessionCount: number;
activeSessionCount: number;
totalTaskCount: number;
totalDanmakuCount: number;
}
export interface SystemLog {
id: string;
level: number;
@@ -301,6 +312,7 @@ export interface UploadTaskListResponse {
items: UploadTaskItem[];
totalCount: number;
notUploadedCount: number;
failedArtifactCount: number;
succeededCount: number;
failedCount: number;
queuedCount: number;
@@ -472,6 +484,7 @@ export interface SystemSettings {
enableRetentionCleanup: boolean;
retentionDays: number;
retentionDeleteFiles: boolean;
retentionRequireUploadSuccess: boolean;
retentionVideoFileCondition: CleanupVideoFileCondition;
retentionTaskStatuses: number[];
enableAutoReconnect: boolean;
@@ -659,10 +672,18 @@ export interface RecoveryOverview {
export interface StorageGuardStatus {
isEnabled: boolean;
isAvailable: boolean;
hasEnoughSpace: boolean;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
requiredBytes: number;
usagePercent: number;
freePercent: number;
greenThresholdPercent: number;
redThresholdPercent: number;
tier: string;
message: string;
}
@@ -745,7 +766,7 @@ export const availabilityLabelMap: Record<number, string> = {
export const currentRecordingStateLabelMap: Record<number, string> = {
0: "未开播",
1: "开播",
1: "开播(未录制)",
2: "录制中"
};
@@ -826,6 +847,22 @@ export const autoStartDecisionLabelMap: Record<string, string> = {
poll_failed: "轮询失败"
};
export const autoStartDecisionSummaryMap: Record<string, string> = {
started: "自动开录已成功启动。",
skipped_disabled: "直播间已禁用,本次自动开录已跳过。",
skipped_storage: "存储空间不满足录制要求,本次自动开录已跳过。",
skipped_active_session: "已有活动录制会话,本次自动开录已跳过。",
skipped_offline: "直播间当前未开播,本次自动开录已跳过。",
skipped_debounce: "仍处于防抖等待时间,本次自动开录已跳过。",
failed_startup: "录制进程启动失败。",
poll_failed_transient: "直播状态巡检暂时失败,系统将自动重试。",
poll_failed: "直播状态巡检失败。"
};
export function formatAutoStartDecisionSummary(code?: string, fallback?: string) {
return (code && autoStartDecisionSummaryMap[code]) || fallback || "暂无自动开录摘要";
}
export const platformLabelMap: Record<number, string> = {
0: "未知",
1: "Douyin",
@@ -899,20 +936,34 @@ export interface DashboardData {
todayDanmakuCount: number;
activeSessionCount: number;
recentErrorCount: number;
currentErrorCount: number;
storageStatus: DashboardStorageStatus;
recentSessions: DashboardRecentSession[];
topRooms: DashboardTopRoom[];
pendingTranscodeCount: number;
pendingUploadCount: number;
queuedDataBytes: number;
oldestTranscodeUpdatedAt?: string | null;
oldestUploadProgressAt?: string | null;
stalledUploadCount: number;
uploadCleanupFailureCount: number;
}
export interface DashboardStorageStatus {
isEnabled: boolean;
isAvailable: boolean;
hasEnoughSpace: boolean;
message: string;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
requiredBytes: number;
tier: string;
usagePercent: number;
freePercent: number;
greenThresholdPercent: number;
redThresholdPercent: number;
}
export interface DashboardRecentSession {
+140 -188
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
import { Clock, House, Refresh, Upload, VideoCamera, 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 type { DashboardData } from "@/types";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import type { DashboardData, DashboardRecentSession } from "@/types";
import { sessionStatusLabelMap } from "@/types";
const router = useRouter();
@@ -12,45 +15,36 @@ const loading = ref(false);
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
)
));
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
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} 分钟`;
}
function formatDataSize(bytes?: number) {
if (typeof bytes !== "number" || bytes <= 0) return "-";
if (typeof bytes !== "number" || bytes < 0) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
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`;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
return value ? new Date(value).toLocaleString() : "--";
}
function sessionStatusTagType(status: number): "" | "success" | "warning" | "danger" | "info" {
if (status === 2) return "success";
if (status === 5) return "danger";
if (status === 4 || status === 6) return "info";
return "warning";
}
function storageTierTagType(): "success" | "warning" | "danger" {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "success";
if (tier === "Yellow") return "warning";
return "danger";
}
function storageTierLabel(): string {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "正常";
if (tier === "Yellow") return "警告";
return "紧急";
function sessionStatus(session: DashboardRecentSession) {
return sessionStatusLabelMap[session.status] ?? "未知";
}
async function loadData() {
@@ -70,179 +64,137 @@ onMounted(loadData);
</script>
<template>
<div class="page-stack">
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">系统概览</div>
<div class="page-kicker">运行中心</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">系统运行状态一览包含直播间录制会话弹幕和存储概况</p>
<p class="page-subtitle">先处理异常与积压再查看录制产出和最近活动</p>
</div>
<div class="header-actions">
<el-button @click="loadData" :loading="loading">刷新</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !data" animated :rows="6" />
<el-skeleton v-if="loading && !data" animated :rows="8" />
<template v-else-if="data">
<!-- KPI -->
<div class="stats-grid">
<MetricCard label="正在录制" :value="data.activeRecordingCount">
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
</MetricCard>
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" description="共接入">
<template #icon><el-icon :size="18"><House /></el-icon></template>
</MetricCard>
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
</MetricCard>
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
</MetricCard>
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
</MetricCard>
<MetricCard
label="24h 异常"
:value="data.recentErrorCount"
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
>
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
</MetricCard>
<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>
最近 30 分钟 {{ data.currentErrorCount }} 个异常
{{ queueTotal }} 个处理任务等待完成存储状态为 {{ data.storageStatus.tier === "Green" ? "正常" : "受限" }}
</span>
</div>
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
</section>
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
24 小时共记录 {{ data.recentErrorCount }} 个历史异常最近 30 分钟为 {{ data.currentErrorCount }} 历史记录不代表系统当前仍有故障
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<!-- storage + queue -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div style="display:flex;align-items:center;gap:28px;margin-top:18px">
<div class="ring-wrap" :style="{ background: `conic-gradient(var(--success) ${Number(data.storageStatus.usagePercent.toFixed(1))}%, var(--surface-hover) 0)` }">
<div class="ring-inner">
<div class="ring-num">{{ data.storageStatus.usagePercent.toFixed(1) }}%</div>
<div class="ring-cap">已使用</div>
</div>
</div>
<div style="flex:1;display:grid;gap:14px">
<div>
<div style="font-size:12px;color:var(--text-muted)">可用空间</div>
<div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">
{{ formatDataSize(data.storageStatus.availableBytes) }}
<span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 总计</span>
</div>
</div>
<div style="display:flex;gap:24px">
<div><div style="font-size:12px;color:var(--text-muted)">水位线</div><el-tag :type="storageTierTagType()" size="small">{{ storageTierLabel() }}</el-tag></div>
<div><div style="font-size:12px;color:var(--text-muted)">说明</div><div style="font-size:13px;font-weight:500;color:var(--text-secondary)">{{ data.storageStatus.message || "-" }}</div></div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">处理队列</h3>
<p class="section-subtitle">待转码和待上传的文件积压情况</p>
<div style="display:grid;gap:14px;margin-top:18px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待转码</div><div style="font-size:12px;color:var(--text-muted)">FFmpeg 后处理队列</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingTranscodeCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待上传</div><div style="font-size:12px;color:var(--text-muted)">WebDAV / S3 / OpenList</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingUploadCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">积压数据量</div><div style="font-size:12px;color:var(--text-muted)">等待归档的本地文件总量</div></div>
<div style="font-size:20px;font-weight:700;font-variant-numeric:tabular-nums">{{ formatDataSize(data.queuedDataBytes) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<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>
<!-- recent sessions + top rooms -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">最近会话</h3>
<p class="section-subtitle">最近创建的录制会话点击可跳转至详情</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.recentSessions" class="premium-table" size="small">
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="sessionStatusTagType(row.status)" size="small">
{{ sessionStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分片" width="60" prop="segmentCount" />
<el-table-column label="开始时间" width="160">
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">今日热门直播间</h3>
<p class="section-subtitle">今日录制时长最长的直播间Top 5</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.topRooms" class="premium-table" size="small">
<el-table-column label="直播间" min-width="130">
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
</el-table-column>
<el-table-column label="平台" width="90" prop="platformName" />
<el-table-column label="会话数" width="70" prop="sessionCount" />
<el-table-column label="录制时长" width="100">
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<div class="operations-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading">
<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>
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
<button
v-for="session in data.recentSessions"
v-else
:key="session.id"
class="activity-row"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<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>
</template>
</div>
</template>
<style scoped>
.page-stack { display: grid; gap: var(--page-gap); }
.header-actions { align-self: center; }
.page-error-alert { border-radius: var(--radius-md); }
.ring-wrap {
--p: 32;
width: 92px; height: 92px; flex-shrink: 0;
border-radius: 99px; display: grid; place-items: center;
}
.ring-inner {
width: 70px; height: 70px; border-radius: 99px;
background: var(--surface); display: grid; place-items: center; text-align: center;
}
.ring-num { font-size: 20px; font-weight: 800; line-height: 1; }
.ring-cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
@media (max-width: 768px) {
.header-actions { width: 100%; }
.header-actions :deep(.el-button) { flex: 1; }
.ring-wrap { width: 68px; height: 68px; }
.ring-inner { width: 50px; height: 50px; }
.ring-num { font-size: 16px; }
}
.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; } }
</style>
+139 -281
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import RightDrawer from "@/components/ui/RightDrawer.vue";
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
autoStartDecisionLabelMap,
formatAutoStartDecisionSummary,
formatQualityLabel,
availabilityLabelMap,
currentRecordingStateLabelMap,
@@ -47,10 +49,11 @@ const loadError = ref("");
const roomDetailVisible = ref(false);
const activeRoom = ref<LiveRoom | null>(null);
const { isMobile } = useViewport();
const roomsTableShellRef = ref<HTMLElement | null>(null);
const roomsTableProxyRef = ref<HTMLElement | null>(null);
const showRoomsTableProxyScroll = ref(false);
const roomsTableProxyInnerWidth = ref(0);
const roomSearch = ref("");
const roomState = ref("all");
const mobileRoomPage = ref(1);
const mobileRoomPageSize = 12;
const failedAvatarUrls = ref<Set<string>>(new Set());
const createForm = reactive({
url: "",
@@ -113,12 +116,36 @@ const selectedRoomIds = computed(() => selectedRooms.value.map((item) => item.id
const selectedRoomCount = computed(() => selectedRooms.value.length);
const hasSelectedRooms = computed(() => selectedRoomCount.value > 0);
const pendingDeleteCount = computed(() => pendingDeleteRoom.value ? 1 : pendingDeleteRoomIds.value.length);
const totalRooms = computed(() => rooms.value.length);
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
const filteredRooms = computed(() => {
const keyword = roomSearch.value.trim().toLowerCase();
return rooms.value.filter((room) => {
const matchesKeyword = !keyword || [room.title, room.anchorName, room.alias, room.roomId, room.platformName]
.some((value) => String(value || "").toLowerCase().includes(keyword));
const matchesState = roomState.value === "all" ||
(roomState.value === "live" && room.availabilityStatus === 2) ||
(roomState.value === "recording" && room.currentRecordingState === 2) ||
(roomState.value === "enabled" && room.isEnabled) ||
(roomState.value === "disabled" && !room.isEnabled);
return matchesKeyword && matchesState;
});
});
const mobileRoomPageCount = computed(() =>
Math.max(1, Math.ceil(filteredRooms.value.length / mobileRoomPageSize))
);
const paginatedMobileRooms = computed(() => {
const start = (mobileRoomPage.value - 1) * mobileRoomPageSize;
return filteredRooms.value.slice(start, start + mobileRoomPageSize);
});
watch([roomSearch, roomState], () => {
mobileRoomPage.value = 1;
});
watch(mobileRoomPageCount, (pageCount) => {
mobileRoomPage.value = Math.min(mobileRoomPage.value, pageCount);
});
const activeRoomSubtitle = computed(() => {
if (!activeRoom.value) {
return "";
@@ -126,182 +153,16 @@ const activeRoomSubtitle = computed(() => {
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
});
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
function clearRoomFilters() {
roomSearch.value = "";
roomState.value = "all";
}
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const recordDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 440px)" : "440px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 760px)" : "840px"));
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
let autoRefreshTimer: number | null = null;
let roomsTableScrollWrap: HTMLElement | null = null;
let roomsTableResizeObserver: ResizeObserver | null = null;
let observedRoomsTableShell: HTMLElement | null = null;
let observedRoomsTableWrap: HTMLElement | null = null;
let syncingRoomsTableProxy = false;
let syncingRoomsTableBody = false;
function cleanupRoomsTableScrollSync() {
if (roomsTableScrollWrap) {
roomsTableScrollWrap.removeEventListener("scroll", handleRoomsTableBodyScroll);
roomsTableScrollWrap = null;
}
roomsTableResizeObserver?.disconnect();
roomsTableResizeObserver = null;
observedRoomsTableShell = null;
observedRoomsTableWrap = null;
}
function getRoomsTableScrollWrap() {
const shell = roomsTableShellRef.value;
if (!shell) {
return null;
}
return shell.querySelector<HTMLElement>(".el-table__body-wrapper .el-scrollbar__wrap") ??
shell.querySelector<HTMLElement>(".el-scrollbar__wrap") ??
shell.querySelector<HTMLElement>(".el-table__body-wrapper");
}
function getRoomsTableContentWidth(wrap: HTMLElement) {
const shell = roomsTableShellRef.value;
if (!shell) {
return wrap.scrollWidth;
}
const widths = [
wrap.scrollWidth,
wrap.firstElementChild?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__body table")?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__header table")?.scrollWidth ?? 0
];
const wrapRect = wrap.getBoundingClientRect();
let maxRight = 0;
shell.querySelectorAll<HTMLElement>(
".el-table__header th, .el-table__body td, .room-actions-cell, .config-summary, .auto-start-cell"
).forEach((element) => {
const rect = element.getBoundingClientRect();
maxRight = Math.max(maxRight, rect.right - wrapRect.left + wrap.scrollLeft);
});
widths.push(Math.ceil(maxRight) + 24);
return Math.max(...widths);
}
function handleRoomsTableBodyScroll() {
if (syncingRoomsTableProxy) {
return;
}
const proxy = roomsTableProxyRef.value;
if (!proxy || !roomsTableScrollWrap) {
return;
}
syncingRoomsTableBody = true;
proxy.scrollLeft = roomsTableScrollWrap.scrollLeft;
window.requestAnimationFrame(() => {
syncingRoomsTableBody = false;
});
}
function handleRoomsTableProxyScroll() {
if (syncingRoomsTableBody || !roomsTableScrollWrap) {
return;
}
const proxy = roomsTableProxyRef.value;
if (!proxy) {
return;
}
syncingRoomsTableProxy = true;
roomsTableScrollWrap.scrollLeft = proxy.scrollLeft;
window.requestAnimationFrame(() => {
syncingRoomsTableProxy = false;
});
}
function ensureRoomsTableResizeObserver() {
if (typeof ResizeObserver === "undefined") {
return;
}
const shell = roomsTableShellRef.value;
const wrap = roomsTableScrollWrap;
if (roomsTableResizeObserver && observedRoomsTableShell === shell && observedRoomsTableWrap === wrap) {
return;
}
roomsTableResizeObserver?.disconnect();
roomsTableResizeObserver = new ResizeObserver(() => {
void syncRoomsTableProxyScroll();
});
if (shell) {
roomsTableResizeObserver.observe(shell);
}
if (wrap) {
roomsTableResizeObserver.observe(wrap);
}
observedRoomsTableShell = shell;
observedRoomsTableWrap = wrap;
}
async function syncRoomsTableProxyScroll() {
await nextTick();
if (isMobile.value) {
showRoomsTableProxyScroll.value = false;
roomsTableProxyInnerWidth.value = 0;
cleanupRoomsTableScrollSync();
return;
}
const wrap = getRoomsTableScrollWrap();
if (roomsTableScrollWrap !== wrap) {
roomsTableScrollWrap?.removeEventListener("scroll", handleRoomsTableBodyScroll);
roomsTableScrollWrap = wrap;
roomsTableScrollWrap?.addEventListener("scroll", handleRoomsTableBodyScroll, { passive: true });
}
const proxy = roomsTableProxyRef.value;
if (!wrap) {
showRoomsTableProxyScroll.value = false;
roomsTableProxyInnerWidth.value = 0;
ensureRoomsTableResizeObserver();
return;
}
const scrollWidth = getRoomsTableContentWidth(wrap);
const clientWidth = wrap.clientWidth;
const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth;
const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth;
const canScrollHorizontally = scrollWidth > clientWidth + 1;
const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth);
roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0;
showRoomsTableProxyScroll.value = canScrollHorizontally;
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
proxy.scrollLeft = wrap.scrollLeft;
}
ensureRoomsTableResizeObserver();
}
async function loadRooms() {
loading.value = true;
loadError.value = "";
@@ -309,7 +170,6 @@ async function loadRooms() {
try {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
void syncRoomsTableProxyScroll();
} catch (error) {
loadError.value = getApiErrorMessage(error, "直播间列表加载失败,请稍后重试。");
} finally {
@@ -344,7 +204,6 @@ async function autoRefreshRooms() {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
loadError.value = "";
void syncRoomsTableProxyScroll();
} catch {
// Keep the current view stable; the background poller will try again.
}
@@ -711,12 +570,26 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase();
}
function getRoomAvatarUrl(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
return url && !failedAvatarUrls.value.has(url) ? url : "";
}
function markRoomAvatarFailed(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
if (!url || failedAvatarUrls.value.has(url)) {
return;
}
failedAvatarUrls.value = new Set([...failedAvatarUrls.value, url]);
}
function roomAvailabilityLabel(room: LiveRoom) {
return availabilityLabelMap[room.availabilityStatus] ?? "--";
}
function latestEventLabel(room: LiveRoom) {
return room.lastAutoStartDecisionSummary || "暂无事件";
return formatAutoStartDecisionSummary(room.lastAutoStartDecisionCode, room.lastAutoStartDecisionSummary || "暂无事件");
}
function getQualityLabel(quality?: string | null) {
@@ -814,17 +687,13 @@ function nullableStringFromSelect(value: string | number) {
onMounted(async () => {
await loadRooms();
void syncRoomsTableProxyScroll();
startAutoRefresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("resize", syncRoomsTableProxyScroll);
});
onBeforeUnmount(() => {
stopAutoRefresh();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("resize", syncRoomsTableProxyScroll);
cleanupRoomsTableScrollSync();
});
</script>
@@ -857,36 +726,13 @@ onBeforeUnmount(() => {
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
</div>
<el-card class="surface-card focus-card" shadow="never">
<div class="focus-card__header">
<div>
<div class="focus-card__eyebrow">监控概览</div>
<h3 class="section-title">直播采集态势</h3>
</div>
<StatusBadge
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
:status="recordingRooms > 0"
context="boolean"
/>
</div>
<p class="focus-card__description">
当前共接入 {{ totalRooms }} 个直播间其中 {{ enabledRooms }} 个处于自动巡检范围{{ disabledRooms }} 个处于保留但停用状态
</p>
<div class="focus-card__chips">
<span class="info-pill">自动开录复用现有后端策略</span>
<span class="info-pill">单房间配置优先于全局设置</span>
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
</div>
</el-card>
</div>
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
<div class="toolbar-row">
<div>
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">继续使用真实接口数据统一强化为监控控制台视图支持查看详情手动录制和单房间配置覆盖</p>
<p class="section-subtitle">搜索主播或房间并按运行状态快速筛选</p>
</div>
<el-space v-if="!isMobile" wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
@@ -898,20 +744,32 @@ onBeforeUnmount(() => {
</el-space>
</div>
<div class="list-filterbar">
<el-input v-model="roomSearch" clearable placeholder="搜索主播、标题、平台或 Room ID" />
<el-select v-model="roomState" aria-label="直播间状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="正在直播" value="live" />
<el-option label="正在录制" value="recording" />
<el-option label="已启用" value="enabled" />
<el-option label="已停用" value="disabled" />
</el-select>
<span class="list-filterbar__count">{{ filteredRooms.length }} / {{ rooms.length }}</span>
</div>
<EmptyState
v-if="!loading && rooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadRooms"
v-if="!loading && filteredRooms.length === 0"
:title="rooms.length === 0 ? '尚未添加直播间' : '没有匹配的直播间'"
:description="rooms.length === 0 ? '添加直播间后即可开始自动巡检和录制' : '请调整搜索词或状态筛选'"
:action-text="rooms.length === 0 ? '刷新列表' : '清除筛选'"
@action="rooms.length === 0 ? loadRooms() : clearRoomFilters()"
/>
<div v-else-if="isMobile" class="data-card-list room-card-list">
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
<article v-for="row in paginatedMobileRooms" :key="row.id" class="data-card room-card">
<div class="data-card__header">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</el-avatar>
</SafeAvatar>
<div>
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
@@ -987,32 +845,36 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
录制
</el-button>
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看详情</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</article>
<el-pagination
v-model:current-page="mobileRoomPage"
class="mobile-room-pagination"
background
layout="prev, pager, next"
:page-size="mobileRoomPageSize"
:pager-count="5"
:total="filteredRooms.length"
hide-on-single-page
/>
</div>
<div v-else ref="roomsTableShellRef" class="table-scroll-shell rooms-table-shell">
<div
v-if="showRoomsTableProxyScroll"
ref="roomsTableProxyRef"
class="rooms-table-proxy-scroll"
@scroll.passive="handleRoomsTableProxyScroll"
>
<div class="rooms-table-proxy-scroll__inner" :style="{ width: `${roomsTableProxyInnerWidth}px` }"></div>
</div>
<div v-else class="table-scroll-shell rooms-table-shell" data-testid="rooms-table-scroll">
<el-table
:data="rooms"
:height="tableHeight"
:data="filteredRooms"
class="premium-table rooms-table"
table-layout="fixed"
row-key="id"
@@ -1023,9 +885,9 @@ onBeforeUnmount(() => {
<el-table-column label="直播间" min-width="340">
<template #default="{ row }">
<div class="room-summary-cell">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</el-avatar>
</SafeAvatar>
<div class="room-summary-cell__copy">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
@@ -1112,34 +974,27 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="监控字段" width="168">
<template #default="{ row }">
<div class="table-placeholder-grid">
<span>在线人数 --</span>
<span>码率 --</span>
<span>采集账号 --</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近检测" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="380">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="room-actions-cell">
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
开始录制
</el-button>
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -1154,9 +1009,9 @@ onBeforeUnmount(() => {
>
<div v-if="activeRoom" class="detail-panel">
<div class="detail-panel__hero">
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(activeRoom)" :alt="activeRoom.anchorName || activeRoom.title" :size="64" class="room-avatar" @error="markRoomAvatarFailed(activeRoom)">
{{ getRoomAvatarText(activeRoom) }}
</el-avatar>
</SafeAvatar>
<div>
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
@@ -1674,28 +1529,18 @@ onBeforeUnmount(() => {
font-size: 13px;
}
.list-filterbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 180px auto;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rooms-table-shell {
overflow: hidden;
}
.rooms-table-proxy-scroll {
position: sticky;
top: 0;
z-index: 5;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 10px;
padding-bottom: 6px;
background: var(--surface);
scrollbar-width: thin;
}
.rooms-table-proxy-scroll__inner {
height: 1px;
}
.rooms-table {
min-width: 1920px;
}
.rooms-table :deep(.el-table__cell) {
@@ -1724,6 +1569,11 @@ onBeforeUnmount(() => {
gap: 16px;
}
.mobile-room-pagination {
justify-content: center;
padding-top: 4px;
}
.room-card__toggle {
display: flex;
align-items: center;
@@ -2040,6 +1890,9 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.list-filterbar { grid-template-columns: 1fr 160px; }
.list-filterbar__count { grid-column: 1 / -1; }
.batch-actions {
justify-content: flex-start;
}
@@ -2063,6 +1916,11 @@ onBeforeUnmount(() => {
}
}
@media (max-width: 600px) {
.list-filterbar { grid-template-columns: 1fr; }
.list-filterbar__count { grid-column: auto; }
}
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
+27 -12
View File
@@ -7,7 +7,9 @@ import { logLevelLabelMap } from "@/types";
const AUTO_REFRESH_INTERVAL_MS = 15000;
const loading = ref(false);
const initialLoading = ref(false);
const refreshing = ref(false);
const requestInFlight = ref(false);
const logs = ref<SystemLog[]>([]);
const loadError = ref("");
const { isMobile } = useViewport();
@@ -31,13 +33,25 @@ const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].c
let autoRefreshTimer: number | null = null;
async function loadLogs() {
if (loading.value) {
function mergeLogs(nextLogs: SystemLog[]) {
const previousById = new Map(logs.value.map((item) => [item.id, item]));
return nextLogs.map((item) => {
const previous = previousById.get(item.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
async function loadLogs(background = false) {
if (requestInFlight.value) {
return;
}
loading.value = true;
loadError.value = "";
requestInFlight.value = true;
initialLoading.value = !background && logs.value.length === 0;
refreshing.value = !initialLoading.value;
if (!background) {
loadError.value = "";
}
try {
const { data } = await apiClient.get<SystemLog[]>("/logs", {
@@ -50,11 +64,13 @@ async function loadLogs() {
}
});
logs.value = data;
logs.value = mergeLogs(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
} finally {
loading.value = false;
requestInFlight.value = false;
initialLoading.value = false;
refreshing.value = false;
}
}
@@ -63,7 +79,7 @@ async function autoRefreshLogs() {
return;
}
await loadLogs();
await loadLogs(true);
}
function startAutoRefresh() {
@@ -126,7 +142,7 @@ onBeforeUnmount(() => {
</div>
<div class="page-toolbar">
<el-button @click="loadLogs">刷新日志</el-button>
<el-button :loading="refreshing" @click="loadLogs(false)">刷新日志</el-button>
</div>
</div>
@@ -187,7 +203,7 @@ onBeforeUnmount(() => {
<el-input-number v-model="filters.take" :min="1" :max="500" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadLogs">应用筛选</el-button>
<el-button type="primary" @click="loadLogs(false)">应用筛选</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -230,8 +246,7 @@ onBeforeUnmount(() => {
<div v-else class="table-scroll-shell logs-table-shell">
<el-table
:data="logs"
v-loading="loading"
:height="720"
v-loading="initialLoading"
class="premium-table logs-table"
table-layout="auto"
>
+56 -29
View File
@@ -14,15 +14,36 @@ const browser = ref<MediaBrowserResponse | null>(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
const mediaSearch = ref("");
const mediaType = ref("all");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
const filteredItems = computed(() => {
const keyword = mediaSearch.value.trim().toLowerCase();
return (browser.value?.items ?? []).filter((item) => {
const matchesKeyword = !keyword || [item.name, item.relativePath].some((value) => value.toLowerCase().includes(keyword));
const matchesType = mediaType.value === "all" ||
(mediaType.value === "directory" && item.type === "directory") ||
(mediaType.value === "video" && (item.type === "mp4" || item.type === "ts")) ||
(mediaType.value === "xml" && item.type === "xml") ||
(mediaType.value === "other" && !["directory", "mp4", "ts", "xml"].includes(item.type));
return matchesKeyword && matchesType;
});
});
function clearFilters() {
mediaSearch.value = "";
mediaType.value = "all";
}
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
mediaSearch.value = "";
mediaType.value = "all";
try {
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
@@ -181,18 +202,30 @@ onMounted(() => {
</el-button>
</div>
<div class="list-filterbar media-filterbar">
<el-input v-model="mediaSearch" clearable placeholder="搜索文件名或相对路径" />
<el-select v-model="mediaType" aria-label="文件类型筛选">
<el-option label="全部类型" value="all" />
<el-option label="目录" value="directory" />
<el-option label="视频" value="video" />
<el-option label="XML" value="xml" />
<el-option label="其他" value="other" />
</el-select>
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ browser?.items.length ?? 0 }}</span>
</div>
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<EmptyState
v-else-if="browser && browser.items.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新目录"
@action="refreshCurrentDirectory"
v-else-if="browser && filteredItems.length === 0"
:title="browser.items.length === 0 ? '目录为空' : '没有匹配的文件'"
:description="browser.items.length === 0 ? '当前目录下没有可展示内容' : '请调整搜索词或文件类型'"
:action-text="browser.items.length === 0 ? '刷新目录' : '清除筛选'"
@action="browser.items.length === 0 ? refreshCurrentDirectory() : clearFilters()"
/>
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
<div v-else-if="browser" class="table-scroll-shell" data-testid="media-table-scroll">
<el-table :data="filteredItems" class="premium-table media-table" table-layout="auto">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="file-cell">
@@ -219,30 +252,24 @@ onMounted(() => {
{{ formatDate(row.modifiedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
<el-button
v-if="row.type !== 'directory'"
size="small"
plain
@click="openFile(row, true)"
>
下载
</el-button>
<el-button
v-if="row.canTranscode"
size="small"
type="primary"
plain
:loading="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>
转码为 MP4
</el-button>
<el-button v-if="row.type === 'directory'" size="small" type="primary" @click="openDirectory(row.relativePath)">进入</el-button>
<el-button v-else-if="row.type === 'mp4'" size="small" type="primary" @click="previewVideo(row)">预览</el-button>
<el-button v-else-if="row.type === 'xml'" size="small" type="primary" @click="openFile(row)">查看</el-button>
<el-button v-else size="small" type="primary" @click="openFile(row, true)">下载</el-button>
<el-dropdown v-if="row.type !== 'directory'" trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openFile(row, true)">下载文件</el-dropdown-item>
<el-dropdown-item
v-if="row.canTranscode"
:disabled="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>转码为 MP4</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -2,7 +2,6 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
@@ -29,7 +28,6 @@ const props = defineProps<{
}>();
const router = useRouter();
const { isMobile } = useViewport();
// Danmaku replay dialog
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
@@ -77,7 +75,6 @@ const uploadLoading = ref(false);
const loadError = ref("");
const detail = ref<RecordSessionDetail | null>(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
const timelineDurationSeconds = computed(() => {
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
@@ -283,7 +280,7 @@ onMounted(loadDetail);
</p>
</div>
<el-space class="header-actions">
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetail">刷新</el-button>
<el-button :loading="uploadLoading" @click="uploadSessionArtifacts">上传会话</el-button>
@@ -547,7 +544,7 @@ onMounted(loadDetail);
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="200">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
<el-button
@@ -574,7 +571,7 @@ onMounted(loadDetail);
</div>
<div class="table-scroll-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table" table-layout="auto">
<el-table :data="detail.logs" class="premium-table detail-logs-table" table-layout="auto">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -792,6 +789,12 @@ onMounted(loadDetail);
color: var(--text-secondary);
}
.segment-label,
.log-detail {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.preview-empty {
display: grid;
place-items: center;
+2 -7
View File
@@ -61,7 +61,6 @@ async function toggleDanmaku() {
showDanmaku.value = true;
}
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
const activeTaskStatuses = new Set([1, 2, 3, 7]);
const canManualTranscode = computed(() => {
const task = detail.value?.task;
@@ -282,7 +281,7 @@ onMounted(loadDetailAndPreview);
</p>
</div>
<el-space class="header-actions">
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetailAndPreview">刷新</el-button>
<el-button
@@ -466,7 +465,7 @@ onMounted(loadDetailAndPreview);
<p class="section-subtitle">日志已带上会话与任务关联可看到 ffmpeg巡检和弹幕采集的上下文</p>
<div class="table-scroll-shell detail-logs-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table detail-logs-table">
<el-table :data="detail.logs" class="premium-table detail-logs-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -513,10 +512,6 @@ onMounted(loadDetailAndPreview);
padding-inline: 4px;
}
.detail-logs-table {
min-width: 940px;
}
.detail-card :deep(.el-card__body),
.preview-card :deep(.el-card__body),
.logs-card :deep(.el-card__body) {
+233 -150
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
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";
@@ -22,6 +22,7 @@ import type {
RecordArtifactUploadBatchResult,
RecordArtifactUploadItemResult,
RecordSession,
RecordSessionListResponse,
RecordTask
} from "@/types";
import {
@@ -68,14 +69,26 @@ const selectedTaskMap = ref<Record<string, RecordTask>>({});
const realtimeConnected = ref(false);
const realtimeError = ref("");
const { isMobile } = useViewport();
const sessionSearch = ref("");
const sessionState = ref("all");
const currentPage = ref(1);
const pageSize = computed(() => isMobile.value ? 12 : 20);
const totalCount = ref(0);
const totalSessionCount = ref(0);
const activeSessionCount = ref(0);
const totalTaskCount = ref(0);
const totalDanmakuCount = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
let sessionsEventSource: EventSource | null = null;
let cleanupPollTimer: number | null = null;
let searchTimer: number | null = null;
let activeRequest: AbortController | null = null;
let requestSerial = 0;
let appMain: HTMLElement | null = null;
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const filteredSessions = computed(() => sessions.value);
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
const mixedSelectionLabel = computed(() => {
const parts = [];
@@ -285,23 +298,28 @@ function applySessionsSnapshot(
resetSelection?: boolean;
}
) {
const nextSessionIds = new Set(data.map((item) => item.id));
const previousById = new Map(sessions.value.map((item) => [item.id, item]));
const mergedData = data.map((item) => {
const previous = previousById.get(item.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
const nextSessionIds = new Set(mergedData.map((item) => item.id));
const nextTaskMap = new Map<string, RecordTask>();
data.forEach((session) => {
mergedData.forEach((session) => {
session.tasks.forEach((task) => {
nextTaskMap.set(task.id, task);
});
});
sessions.value = data;
sessions.value = mergedData;
if (options?.resetPanels || activeSessionPanels.value.length === 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
} else {
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => nextSessionIds.has(sessionId));
if (activeSessionPanels.value.length === 0 && data.length > 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
if (activeSessionPanels.value.length === 0 && mergedData.length > 0) {
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
}
}
@@ -323,30 +341,109 @@ function applySessionsSnapshot(
);
}
async function loadSessions(options?: { resetPanels?: boolean; resetSelection?: boolean }) {
loading.value = true;
loadError.value = "";
async function loadSessions(options: {
resetPanels?: boolean;
resetSelection?: boolean;
background?: boolean;
preserveScroll?: boolean;
cancelPrevious?: boolean;
} = {}) {
if (activeRequest && !options.cancelPrevious) {
return;
}
activeRequest?.abort();
const controller = new AbortController();
activeRequest = controller;
const serial = ++requestSerial;
const background = options.background === true;
const scrollTop = appMain?.scrollTop ?? 0;
if (!background) {
loading.value = true;
loadError.value = "";
}
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
applySessionsSnapshot(data, {
const params: Record<string, string | number> = {
skip: (currentPage.value - 1) * pageSize.value,
take: pageSize.value,
state: sessionState.value
};
const search = sessionSearch.value.trim();
if (search) {
params.search = search;
}
const { data } = await apiClient.get<RecordSessionListResponse>("/record-sessions/page", {
params,
signal: controller.signal
});
if (serial !== requestSerial) {
return;
}
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
queueMicrotask(() => void loadSessions({ ...options, cancelPrevious: true }));
return;
}
applySessionsSnapshot(data.items, {
resetPanels: options?.resetPanels ?? true,
resetSelection: options?.resetSelection ?? true
});
totalCount.value = data.totalCount;
totalSessionCount.value = data.totalSessionCount;
activeSessionCount.value = data.activeSessionCount;
totalTaskCount.value = data.totalTaskCount;
totalDanmakuCount.value = data.totalDanmakuCount;
markBackendAvailable();
await nextTick();
if (options.preserveScroll && appMain) {
appMain.scrollTop = scrollTop;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
loadError.value = getApiErrorMessage(error, "录制会话加载失败,请稍后重试。");
} finally {
loading.value = false;
if (serial === requestSerial) {
activeRequest = null;
loading.value = false;
}
}
}
function handlePageChange(page: number) {
currentPage.value = page;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true }).then(() => {
document.querySelector(".sessions-card")?.scrollIntoView({ block: "start" });
});
}
function scheduleSearchReload() {
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
searchTimer = window.setTimeout(() => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
}, 300);
}
function connectRealtimeUpdates() {
if (typeof window === "undefined" || typeof EventSource === "undefined") {
realtimeError.value = "当前浏览器不支持实时更新,仍可手动刷新列表。";
return;
}
if (document.visibilityState !== "visible") {
return;
}
const token = localStorage.getItem("live-recorder-token");
if (!token) {
realtimeError.value = "未检测到登录凭证,实时更新未启用。";
@@ -368,18 +465,13 @@ function connectRealtimeUpdates() {
markBackendAvailable();
};
sessionsEventSource.addEventListener("sessions", (event) => {
try {
const nextSessions = JSON.parse((event as MessageEvent<string>).data) as RecordSession[];
applySessionsSnapshot(nextSessions, { resetPanels: false, resetSelection: false });
realtimeConnected.value = true;
realtimeError.value = "";
loadError.value = "";
markBackendAvailable();
} catch {
realtimeConnected.value = false;
realtimeError.value = "实时更新数据解析失败,已保留手动刷新入口。";
}
sessionsEventSource.addEventListener("refresh", () => {
void loadSessions({
background: true,
preserveScroll: true,
resetPanels: false,
resetSelection: false
});
});
sessionsEventSource.onerror = () => {
@@ -398,6 +490,20 @@ function closeRealtimeUpdates() {
sessionsEventSource = null;
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void loadSessions({
background: true,
preserveScroll: true,
resetPanels: false,
resetSelection: false
}).finally(connectRealtimeUpdates);
return;
}
closeRealtimeUpdates();
}
function persistCleanupOperationId(id: string | null) {
if (typeof window === "undefined") {
return;
@@ -840,8 +946,20 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
watch(sessionSearch, scheduleSearchReload);
watch(sessionState, () => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
});
watch(pageSize, () => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
});
onMounted(async () => {
await loadSessions();
appMain = document.querySelector<HTMLElement>(".app-main");
document.addEventListener("visibilitychange", handleVisibilityChange);
await loadSessions({ cancelPrevious: true });
await restoreCleanupTracking();
connectRealtimeUpdates();
});
@@ -849,6 +967,11 @@ onMounted(async () => {
onBeforeUnmount(() => {
closeRealtimeUpdates();
stopCleanupPolling();
activeRequest?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
});
</script>
@@ -918,7 +1041,7 @@ onBeforeUnmount(() => {
<div class="record-ops-grid">
<div class="stats-grid">
<MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<MetricCard label="录制会话" :value="totalSessionCount" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
<MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
@@ -945,28 +1068,39 @@ onBeforeUnmount(() => {
>
删除已选会话{{ selectedSessionCount > 0 ? `${selectedSessionCount}` : "" }}
</el-button>
<el-button plain :loading="deleting" @click="openConditionalDeleteDialog">
按条件清理
</el-button>
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
清理无分片会话
</el-button>
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
清理无文件分片
</el-button>
<el-dropdown trigger="click">
<el-button plain :loading="deleting">清理工具</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openConditionalDeleteDialog">按条件清理</el-dropdown-item>
<el-dropdown-item @click="openDeleteEmptySessionsDialog">清理无分片会话</el-dropdown-item>
<el-dropdown-item @click="openDeleteMissingFileTasksDialog">清理无文件分片</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</div>
<div class="list-filterbar">
<el-input v-model="sessionSearch" clearable placeholder="搜索直播间、Room ID、会话、任务或文件路径" />
<el-select v-model="sessionState" aria-label="录制会话状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="进行中" value="active" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
<el-option label="已停止" value="stopped" />
</el-select>
<span class="list-filterbar__count">当前页 {{ filteredSessions.length }} / {{ totalCount }}</span>
</div>
<EmptyState
v-if="!loading && sessions.length === 0"
title="暂无录制任务"
description="当前筛选条件下没有可展示内容"
v-if="!loading && filteredSessions.length === 0"
:title="totalSessionCount === 0 ? '暂无录制任务' : '没有匹配的录制会话'"
:description="totalSessionCount === 0 ? '录制开始后,会话会显示在这里。' : '请调整搜索词或状态筛选。'"
action-text="刷新列表"
@action="loadSessions()"
/>
<div v-else-if="isMobile" class="data-card-list session-card-list">
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
<article v-for="session in filteredSessions" :key="session.id" class="data-card session-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
@@ -1002,23 +1136,15 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-button size="small" :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click.stop="stopSession(session)"
>
停止会话
</el-button>
<el-button size="small" type="danger" plain :loading="deleting" @click.stop="openDeleteSessionDialog([session.id])">
删除会话
</el-button>
<el-button size="small" type="primary" @click.stop="openSessionDetail(session)">查看</el-button>
<el-dropdown trigger="click" @click.stop>
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
<div class="session-card__tasks">
@@ -1067,33 +1193,15 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click="openDetail(task)">详情</el-button>
<el-button
v-if="canTriggerSegmentCompleted(task)"
size="small"
:loading="triggeringSegmentCompletedTaskId === task.id"
@click="triggerSegmentCompleted(task)"
>
触发完成事件
</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
:loading="uploadingTaskId === task.id"
@click="uploadTask(task)"
>
上传
</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([task.id])"
>
删除
</el-button>
<el-button size="small" type="primary" @click="openDetail(task)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="canTriggerSegmentCompleted(task)" :disabled="triggeringSegmentCompletedTaskId === task.id" @click="triggerSegmentCompleted(task)">触发完成事件</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(task)" :disabled="uploadingTaskId === task.id" @click="uploadTask(task)">上传</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(task)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([task.id])">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</article>
</div>
@@ -1102,7 +1210,7 @@ onBeforeUnmount(() => {
<el-collapse v-else v-model="activeSessionPanels" class="session-collapse">
<el-collapse-item
v-for="session in sessions"
v-for="session in filteredSessions"
:key="session.id"
:name="session.id"
class="session-panel"
@@ -1157,29 +1265,15 @@ onBeforeUnmount(() => {
</div>
<div class="session-actions">
<el-button @click.stop="openSessionDetail(session)">
查看会话
</el-button>
<el-button :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click.stop="stopSession(session)"
>
停止会话
</el-button>
<el-button
type="danger"
plain
:loading="deleting"
@click.stop="openDeleteSessionDialog([session.id])"
>
删除会话
</el-button>
<el-button type="primary" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-dropdown trigger="click" @click.stop>
<el-button>更多操作</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
<div class="tasks-table-shell">
@@ -1253,46 +1347,19 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canTriggerSegmentCompleted(row)"
size="small"
:loading="triggeringSegmentCompletedTaskId === row.id"
@click="triggerSegmentCompleted(row)"
>
触发事件
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
:loading="uploadingTaskId === row.id"
@click="uploadTask(row)"
>
上传
</el-button>
<el-button
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click="stopSession(session)"
>
停止
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([row.id])"
>
删除
</el-button>
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="canTriggerSegmentCompleted(row)" :disabled="triggeringSegmentCompletedTaskId === row.id" @click="triggerSegmentCompleted(row)">触发完成事件</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(row)" :disabled="uploadingTaskId === row.id" @click="uploadTask(row)">上传</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(row)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([row.id])">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -1301,6 +1368,17 @@ onBeforeUnmount(() => {
</div>
</el-collapse-item>
</el-collapse>
<div v-if="totalCount > pageSize" class="session-pagination">
<el-pagination
background
layout="prev, pager, next"
:current-page="currentPage"
:page-size="pageSize"
:page-count="totalPages"
@current-change="handlePageChange"
/>
</div>
</el-card>
<el-dialog
@@ -1531,6 +1609,12 @@ onBeforeUnmount(() => {
padding-top: 18px;
}
.session-pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.toolbar-row {
display: flex;
align-items: flex-start;
@@ -1693,7 +1777,6 @@ onBeforeUnmount(() => {
.nested-table {
border-radius: 12px;
min-width: 1220px;
}
.nested-table :deep(.el-table__cell) {
+13 -15
View File
@@ -6,6 +6,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
@@ -13,8 +14,8 @@ import type {
RecoveryActionResult,
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
import { autoStartDecisionLabelMap, formatAutoStartDecisionSummary, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera } from "@element-plus/icons-vue";
const loading = ref(false);
const retryAllLoading = ref(false);
@@ -137,6 +138,10 @@ function autoStartDecisionLabel(code?: string) {
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionSummary(item: RecoverableLiveRoom) {
return formatAutoStartDecisionSummary(item.lastAutoStartDecisionCode, item.lastAutoStartDecisionSummary);
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
@@ -188,21 +193,14 @@ onMounted(loadOverview);
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid recovery-metrics" v-loading="loading">
<MetricCard
label="存储保护"
:value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
:description="storage?.message || '暂无存储数据'"
:icon="WarningFilled"
/>
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
<MetricCard
label="剩余空间"
:value="storage ? formatBytes(storage.availableBytes) : '--'"
:description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
/>
</div>
<el-card v-if="storage" class="surface-card" shadow="never">
<StorageCapacity :status="storage" />
</el-card>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
@@ -254,7 +252,7 @@ onMounted(loadOverview);
</div>
<div>
<dt>最近决策</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无摘要" }}</dd>
<dd>{{ autoStartDecisionSummary(row) }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.lastAutoStartDecisionDetail">
<dt>原因详情</dt>
@@ -308,7 +306,7 @@ onMounted(loadOverview);
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<div class="cell-subtitle">{{ autoStartDecisionSummary(row) }}</div>
<div v-if="row.lastAutoStartDecisionDetail" class="decision-cell__detail">
{{ row.lastAutoStartDecisionDetail }}
</div>
+27 -11
View File
@@ -43,6 +43,7 @@ const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
const showAdvancedSettings = ref(false);
const { isMobile } = useViewport();
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
@@ -129,6 +130,7 @@ const form = reactive<SettingsFormModel>({
enableRetentionCleanup: false,
retentionDays: 30,
retentionDeleteFiles: false,
retentionRequireUploadSuccess: false,
retentionVideoFileCondition: "any",
retentionTaskStatuses: [],
enableAutoReconnect: true,
@@ -1128,6 +1130,10 @@ onBeforeRouteLeave(async () => {
<span>折叠侧栏</span>
<el-switch v-model="sidebarCollapsed" />
</label>
<label class="settings-quickbar__switch">
<span>高级设置</span>
<el-switch v-model="showAdvancedSettings" />
</label>
</section>
<div class="settings-grid" v-loading="loading">
@@ -1277,7 +1283,7 @@ onBeforeRouteLeave(async () => {
<el-form-item label="绿色水位线:剩余空间高于 (%)">
<el-input-number
v-model="form.storageGreenThresholdPercent"
:min="5"
:min="10"
:max="90"
:step="5"
:disabled="!form.enableStorageGuard"
@@ -1289,44 +1295,49 @@ onBeforeRouteLeave(async () => {
<el-form-item label="红色水位线:剩余空间低于 (%)">
<el-input-number
v-model="form.storageRedThresholdPercent"
:min="1"
:max="85"
:min="5"
:max="Math.max(5, form.storageGreenThresholdPercent - 5)"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">低于此比例时暂停所有录制和转码仅保留上传</div>
<div class="field-hint">低于此比例时暂停录制空间满足安全余量时仍允许 MP4 收尾上传继续</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动MP4 转码会额外占用中间 TS 文件空间<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停所有录制转码上传清盘)
建议绿色水位线至少 30%红色水位线至少 10%系统最低允许 10% / 5%,并强制保留 5% 的黄色缓冲区。恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停录制有安全余量时继续 MP4 收尾上传继续清盘)
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<el-card v-show="showAdvancedSettings" class="surface-card settings-card settings-card--advanced" shadow="never">
<h3 class="section-title">保留清理</h3>
<p class="section-subtitle">按保留天数清理不活跃的会话任务结果和日志可选删除磁盘文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-col :span="6">
<el-form-item label="启用自动清理">
<el-switch v-model="form.enableRetentionCleanup" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="6">
<el-form-item label="保留天数">
<el-input-number v-model="form.retentionDays" :min="1" :max="3650" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="6">
<el-form-item label="删除磁盘文件">
<el-switch v-model="form.retentionDeleteFiles" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="仅清理已上传任务">
<el-switch v-model="form.retentionRequireUploadSuccess" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="视频文件条件">
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
@@ -1424,7 +1435,7 @@ onBeforeRouteLeave(async () => {
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<el-card v-show="showAdvancedSettings" class="surface-card settings-card settings-card--advanced settings-grid__full" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">目录和文件名模板均支持变量分片目录结构完全由模板控制</p>
@@ -2493,6 +2504,7 @@ onBeforeRouteLeave(async () => {
.settings-quickbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
padding: 12px 16px;
}
@@ -2628,6 +2640,10 @@ onBeforeRouteLeave(async () => {
padding-top: 20px;
}
.settings-card--advanced {
border-style: dashed;
}
.settings-card :deep(.el-form) {
max-width: 1120px;
}
+82 -28
View File
@@ -5,34 +5,75 @@ import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { TranscodeTaskItem } from "@/types";
import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types";
import { useViewport } from "@/composables/useViewport";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const initialLoading = ref(false);
const refreshing = ref(false);
const requestInFlight = ref(false);
const transcodeStartingTaskId = ref<string | null>(null);
const loadError = ref("");
const items = ref<TranscodeTaskItem[]>([]);
const tableHeight = computed(() => (isMobile.value ? undefined : 560));
const taskSearch = ref("");
const taskState = ref("all");
let refreshTimer: number | null = null;
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
const filteredItems = computed(() => {
const keyword = taskSearch.value.trim().toLowerCase();
return items.value.filter((item) => {
const matchesKeyword = !keyword || [
item.task.liveRoomTitle,
item.task.recordSessionId,
item.task.id,
item.sourceFilePath,
item.result?.filePath,
item.task.outputFilePath
].some((value) => String(value || "").toLowerCase().includes(keyword));
const matchesState = taskState.value === "all" ||
(taskState.value === "processing" && Boolean(item.task.postProcessStage)) ||
(taskState.value === "manual" && item.canManualTranscode) ||
(taskState.value === "completed" && !item.task.postProcessStage && !item.canManualTranscode);
return matchesKeyword && matchesState;
});
});
async function loadItems() {
loading.value = true;
loadError.value = "";
function clearFilters() {
taskSearch.value = "";
taskState.value = "all";
}
function mergeItems(nextItems: TranscodeTaskItem[]) {
const previousById = new Map(items.value.map((item) => [item.task.id, item]));
return nextItems.map((item) => {
const previous = previousById.get(item.task.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
async function loadItems(background = false) {
if (requestInFlight.value) {
return;
}
requestInFlight.value = true;
initialLoading.value = !background && items.value.length === 0;
refreshing.value = !initialLoading.value;
if (!background) {
loadError.value = "";
}
try {
const { data } = await apiClient.get<TranscodeTaskItem[]>("/transcode-tasks");
items.value = data;
items.value = mergeItems(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
} finally {
loading.value = false;
requestInFlight.value = false;
initialLoading.value = false;
refreshing.value = false;
}
}
@@ -42,7 +83,7 @@ async function startManualTranscode(taskId: string) {
try {
await apiClient.post(`/record-tasks/${taskId}/transcode`);
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
await loadItems();
await loadItems(true);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
} finally {
@@ -110,7 +151,7 @@ function setupAutoRefresh() {
refreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadItems();
void loadItems(true);
}
}, 15000);
}
@@ -141,7 +182,7 @@ onBeforeUnmount(() => {
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回录制任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadItems">刷新列表</el-button>
<el-button :loading="refreshing" @click="loadItems(false)">刷新列表</el-button>
</el-space>
</div>
@@ -178,10 +219,23 @@ onBeforeUnmount(() => {
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="当前没有需要关注的转码任务" />
<div class="list-filterbar">
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、会话、任务或文件路径" />
<el-select v-model="taskState" aria-label="转码状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="处理中" value="processing" />
<el-option label="可手动转码" value="manual" />
<el-option label="已结束" value="completed" />
</el-select>
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ items.length }}</span>
</div>
<el-empty v-if="!initialLoading && filteredItems.length === 0" :description="items.length === 0 ? '当前没有需要关注的转码任务' : '没有匹配的转码任务'">
<el-button v-if="items.length > 0" type="primary" @click="clearFilters">清除筛选</el-button>
</el-empty>
<div v-else class="table-scroll-shell">
<el-table :data="items" :height="tableHeight" class="premium-table" table-layout="auto">
<el-table :data="filteredItems" class="premium-table transcode-table" table-layout="auto">
<el-table-column label="直播间 / 分片" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.task.liveRoomTitle }}</div>
@@ -222,21 +276,21 @@ onBeforeUnmount(() => {
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openTask(row.task.id)">查看分片</el-button>
<el-button size="small" @click="openSession(row.task.recordSessionId)">查看会话</el-button>
<el-button
v-if="row.canManualTranscode"
size="small"
type="primary"
plain
:loading="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>
手动转码
</el-button>
<el-button size="small" type="primary" @click="openTask(row.task.id)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openSession(row.task.recordSessionId)">查看会话</el-dropdown-item>
<el-dropdown-item
v-if="row.canManualTranscode"
:disabled="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>手动转码</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
+334 -123
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
@@ -7,6 +7,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { RecordArtifactUploadItemResult, UploadTaskItem, UploadTaskListResponse } from "@/types";
import {
platformLabelMap,
@@ -14,23 +15,30 @@ import {
} from "@/types";
const router = useRouter();
const loading = ref(false);
const { isWideDesktop } = useViewport();
const initialLoading = ref(false);
const refreshing = ref(false);
const loadError = ref("");
const items = ref<UploadTaskItem[]>([]);
const totalCount = ref(0);
const notUploadedCount = ref(0);
const failedArtifactCount = ref(0);
const succeededCount = ref(0);
const failedCount = ref(0);
const queuedCount = ref(0);
const uploadingCount = ref(0);
const waitingRetryCount = ref(0);
const uploadStatusFilter = ref<number | null>(null);
const taskSearch = ref("");
const currentPage = ref(1);
const pageSize = 50;
const pageSize = computed(() => isWideDesktop.value ? 50 : 12);
const uploadingTaskId = ref<string | null>(null);
const retryingFailed = ref(false);
const uploadingAllPending = ref(false);
let refreshTimer: number | null = null;
let activeRequest: AbortController | null = null;
let requestSerial = 0;
let appMain: HTMLElement | null = null;
const filterOptions = [
{ label: "全部", value: null as number | null },
@@ -42,45 +50,129 @@ const filterOptions = [
{ label: "等待重试", value: 5 }
];
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
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;
}
async function loadUploadStatus() {
loading.value = true;
loadError.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)));
});
function mergeItems(nextItems: UploadTaskItem[]) {
const previousById = new Map(items.value.map((item) => [item.recordTaskId, item]));
return nextItems.map((item) => {
const previous = previousById.get(item.recordTaskId);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
function stopRefreshTimer() {
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
}
function scheduleRefresh() {
stopRefreshTimer();
if (document.visibilityState !== "visible") {
return;
}
const hasActiveJobs = queuedCount.value + uploadingCount.value + waitingRetryCount.value > 0;
refreshTimer = window.setTimeout(async () => {
await loadUploadStatus({ background: true, preserveScroll: true });
scheduleRefresh();
}, hasActiveJobs ? 5000 : 15000);
}
async function loadUploadStatus(options: { background?: boolean; cancelPrevious?: boolean; preserveScroll?: boolean } = {}) {
if (activeRequest && !options.cancelPrevious) {
return;
}
activeRequest?.abort();
const controller = new AbortController();
activeRequest = controller;
const serial = ++requestSerial;
const isInitial = items.value.length === 0 && !options.background;
initialLoading.value = isInitial;
refreshing.value = !isInitial;
if (!options.background) {
loadError.value = "";
}
const scrollTop = appMain?.scrollTop ?? 0;
try {
const params: Record<string, string | number> = {
skip: (currentPage.value - 1) * pageSize,
take: pageSize
skip: (currentPage.value - 1) * pageSize.value,
take: pageSize.value
};
if (uploadStatusFilter.value !== null) {
params.uploadStatus = uploadStatusFilter.value;
}
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", { params });
items.value = data.items;
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", {
params,
signal: controller.signal
});
if (serial !== requestSerial) {
return;
}
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
queueMicrotask(() => void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }));
return;
}
items.value = mergeItems(data.items);
totalCount.value = data.totalCount;
notUploadedCount.value = data.notUploadedCount;
failedArtifactCount.value = data.failedArtifactCount;
succeededCount.value = data.succeededCount;
failedCount.value = data.failedCount;
queuedCount.value = data.queuedCount;
uploadingCount.value = data.uploadingCount;
waitingRetryCount.value = data.waitingRetryCount;
await nextTick();
if (options.preserveScroll !== false && appMain) {
appMain.scrollTop = scrollTop;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
} finally {
loading.value = false;
if (serial === requestSerial) {
activeRequest = null;
initialLoading.value = false;
refreshing.value = false;
}
}
}
function handleFilterChange() {
currentPage.value = 1;
loadUploadStatus();
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
}
function handlePageChange(page: number) {
currentPage.value = page;
loadUploadStatus();
void loadUploadStatus({ cancelPrevious: true, preserveScroll: false }).then(() => {
document.querySelector(".upload-card")?.scrollIntoView({ block: "start" });
});
}
async function uploadTask(task: UploadTaskItem) {
@@ -89,7 +181,7 @@ async function uploadTask(task: UploadTaskItem) {
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.recordTaskId}/upload`);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "上传失败,请稍后重试。"));
} finally {
@@ -125,7 +217,7 @@ async function retryAllFailed() {
ElMessage[successCount > 0 ? "success" : "warning"](
`重试请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} finally {
retryingFailed.value = false;
}
@@ -159,7 +251,7 @@ async function uploadAllPending() {
ElMessage[successCount > 0 ? "success" : "warning"](
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} finally {
uploadingAllPending.value = false;
}
@@ -169,6 +261,14 @@ function openDetail(task: UploadTaskItem) {
router.push({ name: "record-task-detail", params: { id: task.recordTaskId } });
}
function canUpload(task: UploadTaskItem) {
return ![1, 3, 4, 5].includes(task.uploadStatus);
}
function isUploadInProgress(task: UploadTaskItem) {
return [3, 4, 5].includes(task.uploadStatus);
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
@@ -199,19 +299,30 @@ function formatProgress(value?: number) {
: "-";
}
onMounted(() => {
void loadUploadStatus();
refreshTimer = window.setInterval(() => {
if (!loading.value && !uploadingTaskId.value && !retryingFailed.value && !uploadingAllPending.value) {
void loadUploadStatus();
}
}, 5000);
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void loadUploadStatus({ background: true, preserveScroll: true }).finally(scheduleRefresh);
} else {
stopRefreshTimer();
}
}
watch(isWideDesktop, () => {
currentPage.value = 1;
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
});
onMounted(async () => {
appMain = document.querySelector<HTMLElement>(".app-main");
document.addEventListener("visibilitychange", handleVisibilityChange);
await loadUploadStatus();
scheduleRefresh();
});
onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
}
stopRefreshTimer();
activeRequest?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
</script>
@@ -228,38 +339,29 @@ onBeforeUnmount(() => {
<div class="page-toolbar">
<el-button @click="router.push({ name: 'record-tasks' })">录制任务</el-button>
<el-button @click="loadUploadStatus">刷新列表</el-button>
<el-button :loading="refreshing" @click="loadUploadStatus({ cancelPrevious: true, preserveScroll: true })">刷新列表</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<MetricCard label="总计" :value="totalCount" description="所有有录制结果的分片" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" description="排队或正在由 OpenList 复制" :icon="UploadFilled" />
<MetricCard label="等待重试" :value="waitingRetryCount" description="按退避策略等待下一次尝试" :icon="Bell" />
<MetricCard label="全部任务" :value="totalCount" :description="`已完成 ${succeededCount} 个`" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未进入上传队列" :icon="UploadFilled" />
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" :description="`排队 ${queuedCount} · 上传中 ${uploadingCount}`" :icon="CircleCheck" />
<MetricCard
label="需要处理"
:value="failedCount + failedArtifactCount + waitingRetryCount"
:description="`上传失败 ${failedCount} · 失败残片 ${failedArtifactCount} · 等待重试 ${waitingRetryCount}`"
:icon="CircleClose"
/>
</div>
<el-card class="surface-card upload-card" shadow="never">
<div class="toolbar-row">
<div class="toolbar-row__filter">
<span class="filter-label">上传状态</span>
<el-radio-group
v-model="uploadStatusFilter"
size="small"
@change="handleFilterChange"
>
<el-radio-button
v-for="opt in filterOptions"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
<div>
<h3 class="section-title">上传队列</h3>
<p class="section-subtitle">按状态和文件信息定位任务批量操作仅作用于当前页</p>
</div>
<div class="toolbar-row__actions">
<el-button
@@ -281,65 +383,104 @@ onBeforeUnmount(() => {
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<div class="list-filterbar">
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、任务、文件或远端路径" />
<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>
</div>
<el-skeleton v-if="initialLoading && items.length === 0" :rows="6" animated />
<EmptyState
v-else-if="items.length === 0"
title="暂无上传任务"
description="当前筛选条件下没有可展示的上传记录。"
v-else-if="filteredItems.length === 0"
:title="items.length === 0 ? '暂无上传任务' : '没有匹配的上传任务'"
:description="items.length === 0 ? '当前状态下没有可展示的上传记录。' : '请调整搜索关键词。'"
action-text="刷新列表"
@action="loadUploadStatus"
/>
<template v-else>
<div class="table-scroll-shell">
<div v-if="!isWideDesktop" class="upload-card-list">
<article v-for="row in filteredItems" :key="row.recordTaskId" class="upload-item-card">
<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>
</div>
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
:status="row.uploadStatus"
context="upload"
/>
</div>
<div class="upload-item-card__section">
<span class="upload-item-card__label">本地文件</span>
<span class="monospace path-text">{{ row.filePath || "-" }}</span>
<span class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</span>
</div>
<div v-if="isUploadInProgress(row)" class="upload-item-card__progress">
<el-progress :percentage="Math.round(row.uploadProgressPercent || 0)" :stroke-width="7" :show-text="false" />
<span>{{ formatProgress(row.uploadProgressPercent) }} · {{ row.uploadAttemptCount || 0 }} </span>
</div>
<dl class="upload-item-card__facts">
<div><dt>上传方式</dt><dd>{{ row.lastUploadProvider || "-" }}</dd></div>
<div><dt>上传时间</dt><dd>{{ formatDate(row.lastUploadedAt) }}</dd></div>
</dl>
<div v-if="row.remoteVideoPath" class="upload-item-card__section">
<span class="upload-item-card__label">远端路径</span>
<span class="monospace path-text">{{ row.remoteVideoPath }}</span>
</div>
<div v-if="row.uploadErrorMessage" class="upload-item-card__error">{{ row.uploadErrorMessage }}</div>
<div class="upload-item-card__actions">
<el-button type="primary" plain @click="openDetail(row)">查看详情</el-button>
<el-button
v-if="canUpload(row)"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>{{ row.uploadStatus === 2 ? "重试上传" : "立即上传" }}</el-button>
</div>
</article>
</div>
<div v-else class="upload-table-shell">
<el-table
:data="items"
:data="filteredItems"
class="premium-table upload-table"
table-layout="auto"
table-layout="fixed"
row-key="recordTaskId"
>
<el-table-column label="直播间" min-width="160">
<el-table-column label="直播间" min-width="170">
<template #default="{ row }">
<div>
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle monospace">{{ row.roomId }}</div>
<div class="cell-subtitle">{{ platformLabelMap[row.platform] ?? "-" }} · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="{ row }">
{{ platformLabelMap[row.platform] ?? "-" }}
</template>
</el-table-column>
<el-table-column label="分片" width="80">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="文件" min-width="260">
<el-table-column label="文件" min-width="220">
<template #default="{ row }">
<div class="monospace path-text">{{ row.filePath || "-" }}</div>
<div class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</div>
</template>
</el-table-column>
<el-table-column label="上传状态" width="120">
<el-table-column label="状态与进度" min-width="170">
<template #default="{ row }">
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
:status="row.uploadStatus"
context="upload"
/>
</template>
</el-table-column>
<el-table-column label="进度 / 重试" width="150">
<template #default="{ row }">
<div v-if="row.uploadStatus === 3 || row.uploadStatus === 4 || row.uploadStatus === 5">
<div v-if="isUploadInProgress(row)" class="table-progress">
<el-progress
:percentage="Math.round(row.uploadProgressPercent || 0)"
:stroke-width="6"
@@ -352,47 +493,27 @@ onBeforeUnmount(() => {
下次{{ formatDate(row.nextUploadAttemptAt) }}
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="上传方式" width="100">
<template #default="{ row }">
{{ row.lastUploadProvider || "-" }}
</template>
</el-table-column>
<el-table-column label="远端路径" min-width="220">
<el-table-column label="远端结果" min-width="220">
<template #default="{ row }">
<div class="monospace path-text">{{ row.remoteVideoPath || "-" }}</div>
<div class="cell-subtitle">{{ row.lastUploadProvider || "-" }} · {{ formatDate(row.lastUploadedAt) }}</div>
<div v-if="row.uploadErrorMessage" class="error-text">{{ row.uploadErrorMessage }}</div>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ formatDate(row.lastUploadedAt) }}
</template>
</el-table-column>
<el-table-column label="上传错误" min-width="160">
<template #default="{ row }">
<span class="error-text">{{ row.uploadErrorMessage || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="160" align="right">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
<el-button
v-if="row.uploadStatus !== 1 && row.uploadStatus !== 3 && row.uploadStatus !== 4 && row.uploadStatus !== 5"
v-if="canUpload(row)"
size="small"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>
{{ row.uploadStatus === 2 ? "重试" : "上传" }}
</el-button>
>{{ row.uploadStatus === 2 ? "重试" : "上传" }}</el-button>
</div>
</template>
</el-table-column>
@@ -445,32 +566,20 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
}
.toolbar-row__filter {
display: flex;
align-items: center;
gap: 12px;
}
.filter-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.toolbar-row__actions {
display: flex;
align-items: center;
gap: 10px;
}
.table-scroll-shell {
overflow-x: auto;
.upload-table-shell {
min-width: 0;
}
.upload-table {
min-width: 1200px;
border-radius: 12px;
.upload-table { border-radius: 12px; }
.table-progress {
margin-top: 9px;
}
.pagination-row {
@@ -497,6 +606,108 @@ onBeforeUnmount(() => {
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
justify-content: flex-end;
}
.upload-card-list {
display: grid;
gap: 12px;
}
.upload-item-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: 14px;
background: var(--surface-raised);
}
.upload-item-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.upload-item-card__identity {
display: grid;
min-width: 0;
gap: 4px;
}
.upload-item-card__identity span,
.upload-item-card__label,
.upload-item-card__progress span,
.upload-item-card__facts dt {
color: var(--text-secondary);
font-size: 12px;
}
.upload-item-card__section {
display: grid;
min-width: 0;
gap: 3px;
margin-top: 14px;
}
.upload-item-card__progress {
display: grid;
gap: 5px;
margin-top: 14px;
}
.upload-item-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 14px 0 0;
}
.upload-item-card__facts div {
min-width: 0;
padding: 10px;
border-radius: 10px;
background: var(--surface-muted);
}
.upload-item-card__facts dt,
.upload-item-card__facts dd {
margin: 0;
}
.upload-item-card__facts dd {
margin-top: 4px;
overflow-wrap: anywhere;
}
.upload-item-card__error {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
color: var(--danger);
background: color-mix(in srgb, var(--danger) 9%, transparent);
overflow-wrap: anywhere;
}
.upload-item-card__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.upload-item-card__actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.upload-item-card__actions :deep(.el-button:only-child) {
grid-column: 1 / -1;
}
.path-text {
overflow-wrap: anywhere;
word-break: break-word;
}
.task-actions-cell :deep(.el-button) {
+502
View File
@@ -0,0 +1,502 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
const now = "2026-08-02T12:00:00Z";
const room = {
id: "room-1",
platform: 0,
platformName: "哔哩哔哩",
sourceUrl: "https://live.example/123",
originalLiveRoomUrl: "https://live.example/123",
normalizedUrl: "https://live.example/123",
roomId: "123456",
title: "夏日音乐直播间",
anchorName: "示例主播",
isPinned: true,
isPriority: false,
overrides: {},
effectiveSettings: {
preferredQuality: "origin",
outputFormat: 0,
saveMode: 1,
recordingTemplate: 0,
segmentDurationMinutes: 30,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 60,
readWriteTimeoutMilliseconds: 30000,
enableDanmakuRecording: true,
danmakuIncludeNonChatEvents: false,
danmakuMinPollIntervalMilliseconds: 1000,
danmakuRetryDelayMaxSeconds: 30
},
isEnabled: true,
availabilityStatus: 2,
currentRecordingState: 2,
lastAutoStartDecisionCode: "started",
lastAutoStartDecisionSummary: "已自动开始录制",
lastAutoStartDecisionDetail: "直播状态确认后创建录制会话。",
lastAutoStartDecisionAt: now,
lastCheckedAt: now,
createdAt: now,
updatedAt: now
};
const task = {
id: "task-1",
liveRoomId: room.id,
recordSessionId: "session-12345678",
segmentIndex: 1,
liveRoomTitle: room.title,
platform: 0,
roomId: room.roomId,
status: 4,
preferredQuality: "origin",
outputFormat: 0,
outputFilePath: "/volume1/录制/示例主播/2026-08-02/分片-001.mp4",
createdAt: now,
startedAt: now,
endedAt: now,
durationSeconds: 1800,
uploadStatus: 0
};
const session = {
id: "session-12345678",
liveRoomId: room.id,
liveRoomTitle: room.title,
platform: 0,
roomId: room.roomId,
status: 4,
preferredQuality: "origin",
outputFormat: 0,
saveMode: 1,
activeSegmentIndex: 0,
segmentCount: 1,
createdAt: now,
startedAt: now,
endedAt: now,
totalFileSizeBytes: 8_589_934_592,
totalDanmakuMessageCount: 2680,
uploadedSegmentCount: 0,
failedUploadSegmentCount: 0,
uploadingSegmentCount: 0,
tasks: [task]
};
const uploadTask = {
recordTaskId: task.id,
recordSessionId: session.id,
liveRoomId: room.id,
liveRoomTitle: room.title,
platform: room.platform,
roomId: room.roomId,
segmentIndex: 1,
outputFormat: 0,
filePath: task.outputFilePath,
fileSizeBytes: 8_589_934_592,
uploadStatus: 0,
uploadProgressPercent: 0,
uploadAttemptCount: 0,
createdAt: now
};
const dashboard = {
activeRecordingCount: 1,
liveRoomCount: 3,
offlineRoomCount: 5,
totalRoomCount: 8,
todayRecordingSeconds: 12600,
todayDataBytes: 32_212_254_720,
todayDanmakuCount: 12860,
activeSessionCount: 1,
recentErrorCount: 2,
currentErrorCount: 0,
storageStatus: {
isEnabled: true,
isAvailable: true,
hasEnoughSpace: true,
message: "空间充足",
checkedPath: "/volume1/录制/示例主播/2026-08-02",
totalBytes: 1_000_000_000_000,
usedBytes: 750_000_000_000,
availableBytes: 250_000_000_000,
requiredBytes: 100_000_000_000,
tier: "Green",
usagePercent: 75,
freePercent: 25,
greenThresholdPercent: 30,
redThresholdPercent: 10
},
recentSessions: [session],
topRooms: [{ liveRoomId: room.id, title: room.title, anchorName: room.anchorName, platformName: room.platformName, roomId: room.roomId, sessionCount: 1, totalDurationSeconds: 12600 }],
pendingTranscodeCount: 2,
pendingUploadCount: 3,
queuedDataBytes: 4_294_967_296
};
async function mockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem("live-recorder-token", "e2e-token");
localStorage.setItem("live-recorder-user", JSON.stringify({
userId: "e2e-user",
username: "tester",
displayName: "界面测试"
}));
});
await page.route(/^http:\/\/127\.0\.0\.1:47173\/api\//, async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
let body: unknown = {};
if (path === "/api/dashboard") body = dashboard;
else if (path === "/api/live-rooms") body = [room];
else if (path === "/api/record-sessions/page") body = {
items: [session],
totalCount: 1,
skip: 0,
take: 20,
totalSessionCount: 1,
activeSessionCount: 0,
totalTaskCount: 1,
totalDanmakuCount: session.totalDanmakuMessageCount
};
else if (path === "/api/record-sessions") body = [session];
else if (path === "/api/record-tasks/upload-status") body = {
items: [uploadTask],
totalCount: 1,
notUploadedCount: 1,
failedArtifactCount: 0,
succeededCount: 0,
failedCount: 0,
queuedCount: 0,
uploadingCount: 0,
waitingRetryCount: 0
};
else if (path === "/api/settings") body = {};
else if (path.includes("/record-sessions/stream")) {
await route.fulfill({ status: 200, contentType: "text/event-stream", body: "" });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
});
}
async function expectNoDocumentOverflow(page: Page) {
await expect.poll(() => page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}))).toEqual(await page.evaluate(() => ({
scrollWidth: document.documentElement.clientWidth,
clientWidth: document.documentElement.clientWidth
})));
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
}
test.beforeEach(async ({ page }) => {
page.on("pageerror", (error) => console.error("[browser pageerror]", error.message));
page.on("console", (message) => {
if (message.type() === "error") console.error("[browser console]", message.text());
});
await mockApi(page);
});
test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
await page.goto("/");
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%");
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "dashboard");
});
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) {
const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
await expect(actionHeader).toBeVisible();
const box = await actionHeader.boundingBox();
expect(box && box.x + box.width).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
}
await page.getByRole("button", { name: /^查看/ }).first().click();
const drawerFooter = page.locator(".right-drawer__footer");
await expect(drawerFooter).toBeVisible();
const footerBox = await drawerFooter.boundingBox();
expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
await page.getByRole("button", { name: "关闭", exact: true }).click();
await expect(drawerFooter).toBeHidden();
await page.getByRole("button", { name: "新增直播间" }).click();
const dialogFooter = page.locator(".el-dialog__footer");
await expect(dialogFooter).toBeVisible();
const dialogFooterBox = await dialogFooter.boundingBox();
expect(dialogFooterBox && dialogFooterBox.y + dialogFooterBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
await page.getByRole("button", { name: "取消", exact: true }).click();
await expect(dialogFooter).toBeHidden();
await capture(page, testInfo, "live-rooms");
});
test("mobile live room list paginates large collections", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract");
const manyRooms = Array.from({ length: 25 }, (_, index) => ({
...room,
id: `room-${index + 1}`,
roomId: String(100000 + index + 1),
title: `分页直播间 ${index + 1}`,
anchorName: `分页主播 ${index + 1}`
}));
await page.route("**/api/live-rooms", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manyRooms) });
});
await page.goto("/live-rooms");
await expect(page.locator(".room-card")).toHaveCount(12);
await expect(page.getByText("分页直播间 1", { exact: true })).toBeVisible();
await page.locator(".mobile-room-pagination .btn-next").click();
await expect(page.getByText("分页直播间 13", { exact: true })).toBeVisible();
await expect(page.locator(".room-card")).toHaveCount(12);
});
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.getByPlaceholder(/搜索直播间/)).toBeVisible();
await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "record-tasks");
});
test("record sessions paginate on the server and bound the rendered page", async ({ page }) => {
const allSessions = Array.from({ length: 200 }, (_, index) => ({
...session,
id: `session-${index + 1}`,
liveRoomTitle: `分页录制会话 ${index + 1}`,
roomId: String(200000 + index + 1),
tasks: [{ ...task, id: `task-${index + 1}`, recordSessionId: `session-${index + 1}` }]
}));
const requests: Array<{ skip: number; take: number }> = [];
await page.route("**/api/record-sessions/page**", async (route) => {
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 20);
requests.push({ skip, take });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allSessions.slice(skip, skip + take),
totalCount: allSessions.length,
skip,
take,
totalSessionCount: allSessions.length,
activeSessionCount: 0,
totalTaskCount: allSessions.length,
totalDanmakuCount: allSessions.length * session.totalDanmakuMessageCount
})
});
});
await page.goto("/record-tasks");
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"))
.toHaveCount(expectedPageSize);
const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
expect(renderedNodeCount).toBeLessThan(5_000);
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();
});
test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract");
await page.addInitScript(() => {
class MockEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
private listeners = new Map<string, Array<(event: Event) => void>>();
constructor() {
(window as any).__recordSessionEventSource = this;
window.setTimeout(() => this.onopen?.(new Event("open")), 0);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const callback = typeof listener === "function"
? listener
: (event: Event) => listener.handleEvent(event);
this.listeners.set(type, [...(this.listeners.get(type) ?? []), callback]);
}
emit(type: string) {
this.listeners.get(type)?.forEach((listener) => listener(new MessageEvent(type, { data: "{}" })));
}
close() {}
}
Object.defineProperty(window, "EventSource", { configurable: true, value: MockEventSource });
(window as any).__emitRecordSessionRefresh = () =>
(window as any).__recordSessionEventSource?.emit("refresh");
});
const allSessions = Array.from({ length: 60 }, (_, index) => ({
...session,
id: `refresh-session-${index + 1}`,
liveRoomTitle: `刷新录制会话 ${index + 1}`,
tasks: [{ ...task, id: `refresh-task-${index + 1}`, recordSessionId: `refresh-session-${index + 1}` }]
}));
let requestCount = 0;
await page.route("**/api/record-sessions/page**", async (route) => {
requestCount++;
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 12);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allSessions.slice(skip, skip + take),
totalCount: allSessions.length,
skip,
take,
totalSessionCount: allSessions.length,
activeSessionCount: 0,
totalTaskCount: allSessions.length,
totalDanmakuCount: 0
})
});
});
await page.goto("/record-tasks");
await expect(page.locator(".session-card")).toHaveCount(12);
const main = page.locator(".app-main");
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
expect(scrollTopBefore).toBeGreaterThan(500);
await page.evaluate(() => (window as any).__emitRecordSessionRefresh());
await expect.poll(() => requestCount).toBeGreaterThan(1);
await expect(page.locator(".session-card")).toHaveCount(12);
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
});
test("upload tasks use cards on mobile and never require table horizontal scrolling", async ({ page }, testInfo) => {
await page.goto("/upload-tasks");
await expect(page.getByRole("heading", { name: "上传任务" })).toBeVisible();
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
await expect(page.getByText(task.outputFilePath, { exact: true })).toBeVisible();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) < 1280) {
await expect(page.locator(".upload-item-card")).toBeVisible();
await expect(page.locator(".upload-table")).toHaveCount(0);
await expect(page.getByRole("button", { name: "查看详情" })).toBeVisible();
await expect(page.getByRole("button", { name: "立即上传" })).toBeVisible();
} else {
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
const tableScroll = page.locator(".upload-table .el-scrollbar__wrap");
await expect(tableScroll).toBeVisible();
const dimensions = await tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
const uploadButton = page.getByRole("button", { name: "上传", exact: true });
await expect(uploadButton).toBeVisible();
const uploadButtonBox = await uploadButton.boundingBox();
expect(uploadButtonBox && uploadButtonBox.x + uploadButtonBox.width)
.toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
}
await capture(page, testInfo, "upload-tasks");
});
test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract");
const allItems = Array.from({ length: 60 }, (_, index) => ({
...uploadTask,
recordTaskId: `upload-task-${index + 1}`,
segmentIndex: index + 1,
liveRoomTitle: `轮询直播间 ${index + 1}`,
uploadStatus: 3,
uploadProgressPercent: 10
}));
let requestCount = 0;
let inFlight = 0;
let maxInFlight = 0;
await page.route("**/api/record-tasks/upload-status**", async (route) => {
requestCount++;
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 50);
if (requestCount > 1) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
const progress = requestCount > 1 ? 42 : 10;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allItems.slice(skip, skip + take).map((item) => ({ ...item, uploadProgressPercent: progress })),
totalCount: allItems.length,
notUploadedCount: 0,
failedArtifactCount: 0,
succeededCount: 0,
failedCount: 0,
queuedCount: 0,
uploadingCount: allItems.length,
waitingRetryCount: 0
})
});
inFlight--;
});
await page.goto("/upload-tasks");
await expect(page.locator(".upload-item-card")).toHaveCount(12);
await expect(page.getByText("10.0% · 第 0 次").first()).toBeVisible();
const main = page.locator(".app-main");
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
expect(scrollTopBefore).toBeGreaterThan(500);
await expect.poll(() => requestCount, { timeout: 10_000 }).toBeGreaterThan(1);
await expect(page.locator(".upload-item-card")).toHaveCount(12);
await expect(page.locator(".el-skeleton")).toHaveCount(0);
await expect(page.getByText("42.0% · 第 0 次").first()).toBeVisible();
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
expect(maxInFlight).toBe(1);
});
test("settings layer advanced controls without clipping the quick bar", async ({ page }, testInfo) => {
await page.goto("/settings/recording");
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await expect(page.getByRole("heading", { name: "保留清理" })).toBeHidden();
await page.getByText("高级设置", { exact: true }).click();
await expect(page.getByRole("heading", { name: "保留清理" })).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "settings");
});
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
export default defineConfig(({ command }) => ({
plugins: [
...(command === "serve" ? [VueDevTools()] : []),
...(command === "serve" && process.env.VITE_DISABLE_DEVTOOLS !== "1" ? [VueDevTools()] : []),
vue()
],
resolve: {