feat: redesign live recorder control console ui

This commit is contained in:
2026-05-13 18:51:05 +08:00
parent d69c7d015e
commit b81ead700d
12 changed files with 2120 additions and 806 deletions
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Box } from "@element-plus/icons-vue";
withDefaults(
defineProps<{
title?: string;
description?: string;
actionText?: string;
}>(),
{
title: "暂无数据",
description: "当前筛选条件下没有可展示内容",
actionText: ""
}
);
defineEmits<{
(event: "action"): void;
}>();
</script>
<template>
<div class="empty-state">
<div class="empty-state__icon">
<el-icon><Box /></el-icon>
</div>
<div class="empty-state__title">{{ title }}</div>
<div class="empty-state__description">{{ description }}</div>
<el-button v-if="actionText" type="primary" plain @click="$emit('action')">
{{ actionText }}
</el-button>
</div>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
gap: 14px;
padding: 42px 20px;
text-align: center;
}
.empty-state__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 18px;
background: rgba(37, 99, 235, 0.08);
color: var(--accent);
font-size: 28px;
}
.empty-state__title {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
.empty-state__description {
max-width: 36ch;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Component } from "vue";
withDefaults(
defineProps<{
label: string;
value: string | number;
description?: string;
icon?: Component | null;
}>(),
{
description: "",
icon: null
}
);
</script>
<template>
<article class="metric-card">
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
<span v-if="icon" class="metric-card__icon">
<el-icon><component :is="icon" /></el-icon>
</span>
</div>
<div class="metric-card__value">{{ value }}</div>
<div v-if="description" class="metric-card__description">{{ description }}</div>
</article>
</template>
<style scoped>
.metric-card {
display: grid;
gap: 12px;
min-height: 164px;
padding: 20px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
.metric-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-card__label {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.metric-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 12px;
background: rgba(37, 99, 235, 0.1);
color: var(--accent);
font-size: 18px;
}
.metric-card__value {
color: var(--text-primary);
font-size: clamp(28px, 2vw, 40px);
font-weight: 760;
letter-spacing: -0.06em;
line-height: 1;
}
.metric-card__description {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from "vue";
import { Close } from "@element-plus/icons-vue";
const props = withDefaults(
defineProps<{
modelValue: boolean;
title: string;
subtitle?: string;
size?: string | number;
}>(),
{
subtitle: "",
size: "520px"
}
);
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value)
});
</script>
<template>
<el-drawer v-model="visible" class="right-drawer" direction="rtl" :size="size" :with-header="false">
<div class="right-drawer__shell">
<header class="right-drawer__header">
<div class="right-drawer__copy">
<div class="right-drawer__eyebrow">详情面板</div>
<h3 class="right-drawer__title">{{ title }}</h3>
<p v-if="subtitle" class="right-drawer__subtitle">{{ subtitle }}</p>
</div>
<el-button class="right-drawer__close" text circle @click="visible = false">
<el-icon><Close /></el-icon>
</el-button>
</header>
<div class="right-drawer__body">
<slot />
</div>
<footer v-if="$slots.footer" class="right-drawer__footer">
<slot name="footer" />
</footer>
</div>
</el-drawer>
</template>
<style scoped>
:deep(.right-drawer .el-drawer__body) {
padding: 0;
}
.right-drawer__shell {
display: flex;
min-height: 100%;
flex-direction: column;
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
}
.right-drawer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border-subtle);
}
.right-drawer__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.right-drawer__title {
margin: 0;
color: var(--text-primary);
font-size: 22px;
font-weight: 760;
letter-spacing: -0.04em;
}
.right-drawer__subtitle {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.right-drawer__close {
color: var(--text-muted);
}
.right-drawer__body {
flex: 1;
padding: 20px 24px 24px;
overflow: auto;
}
.right-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid var(--border-subtle);
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed } from "vue";
type BadgeContext =
| "generic"
| "availability"
| "recording"
| "task"
| "session"
| "cleanup"
| "upload"
| "boolean";
const props = withDefaults(
defineProps<{
label?: string;
status?: string | number | boolean | null;
context?: BadgeContext;
size?: "sm" | "md";
}>(),
{
label: "",
status: null,
context: "generic",
size: "md"
}
);
type Tone = "gray" | "green" | "blue" | "yellow" | "red" | "orange" | "indigo";
function normalizeValue(value: string | number | boolean | null) {
if (typeof value === "string") {
return value.trim().toLowerCase();
}
return value;
}
function resolveToneByNumber(value: number, context: BadgeContext): Tone {
if (context === "availability") {
if (value === 2) {
return "green";
}
return "gray";
}
if (context === "recording") {
if (value === 2) {
return "blue";
}
if (value === 1) {
return "green";
}
return "gray";
}
if (context === "task" || context === "session") {
if (value === 2) {
return "blue";
}
if (value === 1 || value === 7) {
return "indigo";
}
if (value === 0) {
return "yellow";
}
if (value === 3) {
return "orange";
}
if (value === 4) {
return "green";
}
if (value === 5) {
return "red";
}
return "gray";
}
if (context === "upload") {
if (value === 1) {
return "green";
}
if (value === 2) {
return "red";
}
return "gray";
}
if (context === "boolean") {
return value ? "green" : "gray";
}
return "gray";
}
function resolveToneByKeyword(value: string): Tone {
if (
value.includes("live") ||
value.includes("online") ||
value.includes("living") ||
value.includes("开播") ||
value.includes("直播中") ||
value === "started" ||
value === "completed" ||
value.includes("归档") ||
value.includes("healthy")
) {
return "green";
}
if (value.includes("recording") || value.includes("录制中") || value.includes("uploading")) {
return "blue";
}
if (
value.includes("pending") ||
value.includes("queued") ||
value.includes("待") ||
value.includes("waiting") ||
value.includes("storage")
) {
return "yellow";
}
if (value.includes("retry") || value.includes("stopping") || value.includes("停止中")) {
return "orange";
}
if (value.includes("process") || value.includes("transcod")) {
return "indigo";
}
if (
value.includes("error") ||
value.includes("fail") ||
value.includes("异常") ||
value.includes("错误") ||
value.includes("离线") ||
value === "poll_failed"
) {
return "red";
}
return "gray";
}
const tone = computed<Tone>(() => {
const normalized = normalizeValue(props.status);
if (typeof normalized === "number") {
return resolveToneByNumber(normalized, props.context);
}
if (typeof normalized === "boolean") {
return normalized ? "green" : "gray";
}
if (typeof normalized === "string" && normalized.length > 0) {
return resolveToneByKeyword(normalized);
}
return "gray";
});
const displayLabel = computed(() => {
if (props.label) {
return props.label;
}
if (props.status === null || props.status === undefined || props.status === "") {
return "--";
}
return String(props.status);
});
</script>
<template>
<span class="status-badge" :class="[`status-badge--${tone}`, `status-badge--${size}`]">
<span class="status-badge__dot"></span>
<span>{{ displayLabel }}</span>
</span>
</template>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 600;
white-space: nowrap;
}
.status-badge--sm {
min-height: 28px;
padding: 0 10px;
font-size: 12px;
}
.status-badge--md {
min-height: 30px;
padding: 0 12px;
font-size: 12px;
}
.status-badge__dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
background: currentColor;
}
.status-badge--gray {
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
.status-badge--green {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.18);
color: #15803d;
}
.status-badge--blue {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.16);
color: #2563eb;
}
.status-badge--yellow {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.18);
color: #b45309;
}
.status-badge--red {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.16);
color: #dc2626;
}
.status-badge--orange {
background: rgba(249, 115, 22, 0.12);
border-color: rgba(249, 115, 22, 0.18);
color: #c2410c;
}
.status-badge--indigo {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.18);
color: #4f46e5;
}
:global(html[data-theme="dark"]) .status-badge--gray {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
:global(html[data-theme="dark"]) .status-badge--green {
color: #86efac;
}
:global(html[data-theme="dark"]) .status-badge--blue {
color: #93c5fd;
}
:global(html[data-theme="dark"]) .status-badge--yellow {
color: #fcd34d;
}
:global(html[data-theme="dark"]) .status-badge--red {
color: #fca5a5;
}
:global(html[data-theme="dark"]) .status-badge--orange {
color: #fdba74;
}
:global(html[data-theme="dark"]) .status-badge--indigo {
color: #a5b4fc;
}
</style>
+126 -87
View File
@@ -1,54 +1,55 @@
:root { :root {
color-scheme: light; color-scheme: light;
font-family: "Inter", "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
--bg-base: #f3f7fb; --bg-base: #f6f8fb;
--bg-subtle: #eef3f8; --bg-subtle: #f1f5f9;
--bg-emphasis: #e6eef7; --bg-emphasis: #e8eef7;
--surface: #ffffff; --surface: #ffffff;
--surface-raised: #fbfdff; --surface-raised: #ffffff;
--surface-muted: #f4f8fc; --surface-muted: #f8fafc;
--surface-strong: #edf4fb; --surface-strong: #eff6ff;
--border-subtle: #d7e1eb; --border-subtle: #e2e8f0;
--border-base: #c7d3e1; --border-base: #cbd5e1;
--border-strong: #a7b7cb; --border-strong: #94a3b8;
--text-primary: #0d1726; --text-primary: #0f172a;
--text-secondary: #425167; --text-secondary: #334155;
--text-muted: #6b7c91; --text-muted: #64748b;
--text-soft: #8f9cb0; --text-soft: #94a3b8;
--text-inverse: #eff5fb; --text-inverse: #eff6ff;
--accent: #2f6fb4; --accent: #2563eb;
--accent-strong: #245a92; --accent-strong: #1d4ed8;
--accent-soft: rgba(47, 111, 180, 0.12); --accent-soft: rgba(37, 99, 235, 0.1);
--info: #2f6fb4; --info: #2563eb;
--success: #1f8a63; --success: #16a34a;
--warning: #ba7b1f; --warning: #d97706;
--danger: #c75151; --danger: #dc2626;
--focus-ring: rgba(47, 111, 180, 0.2); --focus-ring: rgba(37, 99, 235, 0.18);
--shadow-soft: 0 10px 30px rgba(15, 23, 38, 0.06); --shadow-soft: 0 12px 30px rgba(15, 23, 42, 0.06);
--shadow-card: 0 18px 42px rgba(15, 23, 38, 0.08); --shadow-card: 0 18px 44px rgba(15, 23, 42, 0.08);
--shadow-float: 0 24px 60px rgba(15, 23, 38, 0.12); --shadow-float: 0 24px 60px rgba(15, 23, 42, 0.14);
--sidebar-shell-bg: --sidebar-shell-bg:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)), linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.92)),
var(--bg-emphasis); var(--surface);
--topbar-shell-bg: rgba(255, 255, 255, 0.56); --topbar-shell-bg: rgba(255, 255, 255, 0.9);
--radius-sm: 6px; --radius-sm: 10px;
--radius-md: 8px; --radius-md: 16px;
--radius-lg: 10px; --radius-lg: 18px;
--page-gap: 24px; --page-gap: 24px;
--content-padding: 28px; --page-max-width: 1280px;
--content-padding: 24px;
--content-padding-mobile: 16px; --content-padding-mobile: 16px;
--control-height: 40px; --control-height: 40px;
--control-height-sm: 34px; --control-height-sm: 34px;
--table-row-padding: 16px; --table-row-padding: 16px;
--header-row-height: 68px; --header-row-height: 64px;
--el-color-primary: var(--accent); --el-color-primary: var(--accent);
--el-color-primary-light-3: #5d90ca; --el-color-primary-light-3: #5b8cf0;
--el-color-primary-light-5: #84adde; --el-color-primary-light-5: #7ba4f5;
--el-color-primary-light-7: #bad2ec; --el-color-primary-light-7: #a8c3fa;
--el-color-primary-light-8: #d4e4f3; --el-color-primary-light-8: #c7dbfd;
--el-color-primary-light-9: #ebf3fa; --el-color-primary-light-9: #e8f0ff;
--el-color-primary-dark-2: var(--accent-strong); --el-color-primary-dark-2: var(--accent-strong);
--el-color-success: var(--success); --el-color-success: var(--success);
--el-color-warning: var(--warning); --el-color-warning: var(--warning);
@@ -58,17 +59,17 @@
--el-text-color-secondary: var(--text-muted); --el-text-color-secondary: var(--text-muted);
--el-border-color: var(--border-base); --el-border-color: var(--border-base);
--el-border-color-light: var(--border-subtle); --el-border-color-light: var(--border-subtle);
--el-border-radius-base: var(--radius-md); --el-border-radius-base: var(--radius-sm);
--el-bg-color: transparent; --el-bg-color: transparent;
--el-fill-color-blank: var(--surface); --el-fill-color-blank: var(--surface);
--el-fill-color-light: var(--surface-muted); --el-fill-color-light: var(--surface-muted);
--el-fill-color-lighter: var(--surface-strong); --el-fill-color-lighter: var(--surface-strong);
--el-mask-color: rgba(7, 13, 22, 0.58); --el-mask-color: rgba(15, 23, 42, 0.54);
} }
html[data-density="compact"] { html[data-density="compact"] {
--page-gap: 20px; --page-gap: 20px;
--content-padding: 22px; --content-padding: 20px;
--content-padding-mobile: 14px; --content-padding-mobile: 14px;
--control-height: 36px; --control-height: 36px;
--control-height-sm: 30px; --control-height-sm: 30px;
@@ -79,43 +80,43 @@ html[data-density="compact"] {
html[data-theme="dark"] { html[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--bg-base: #07111b; --bg-base: #08111d;
--bg-subtle: #0d1825; --bg-subtle: #0f172a;
--bg-emphasis: #102131; --bg-emphasis: #132033;
--surface: #0f1b29; --surface: #0f1b2d;
--surface-raised: #132233; --surface-raised: #132236;
--surface-muted: #17283a; --surface-muted: #17293f;
--surface-strong: #1b2f45; --surface-strong: #1d3653;
--border-subtle: #22364d; --border-subtle: #22334d;
--border-base: #314862; --border-base: #334a68;
--border-strong: #4a6686; --border-strong: #4b6486;
--text-primary: #eaf2fb; --text-primary: #eaf2fb;
--text-secondary: #bdd0e3; --text-secondary: #bfd0e6;
--text-muted: #8ca0b6; --text-muted: #8ea3bc;
--text-soft: #6f8399; --text-soft: #6f849d;
--text-inverse: #08111a; --text-inverse: #08111a;
--accent: #5ba8ff; --accent: #60a5fa;
--accent-strong: #3f8de0; --accent-strong: #3b82f6;
--accent-soft: rgba(91, 168, 255, 0.16); --accent-soft: rgba(96, 165, 250, 0.18);
--info: #5ba8ff; --info: #60a5fa;
--success: #31c48d; --success: #31c48d;
--warning: #f0a23c; --warning: #f59e0b;
--danger: #ef6666; --danger: #f87171;
--focus-ring: rgba(91, 168, 255, 0.24); --focus-ring: rgba(96, 165, 250, 0.24);
--shadow-soft: 0 12px 30px rgba(2, 6, 12, 0.32); --shadow-soft: 0 12px 30px rgba(2, 6, 12, 0.32);
--shadow-card: 0 18px 40px rgba(2, 6, 12, 0.4); --shadow-card: 0 18px 40px rgba(2, 6, 12, 0.4);
--shadow-float: 0 24px 60px rgba(2, 6, 12, 0.52); --shadow-float: 0 24px 60px rgba(2, 6, 12, 0.52);
--sidebar-shell-bg: --sidebar-shell-bg:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)), linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
var(--bg-subtle); var(--bg-subtle);
--topbar-shell-bg: rgba(15, 27, 41, 0.78); --topbar-shell-bg: rgba(15, 23, 42, 0.84);
--el-color-primary: var(--accent); --el-color-primary: var(--accent);
--el-color-primary-light-3: #7db9ff; --el-color-primary-light-3: #93c5fd;
--el-color-primary-light-5: #9ac8ff; --el-color-primary-light-5: #bfdbfe;
--el-color-primary-light-7: #bfdcff; --el-color-primary-light-7: #dbeafe;
--el-color-primary-light-8: #d8e9ff; --el-color-primary-light-8: #e5f0ff;
--el-color-primary-light-9: #eef6ff; --el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: var(--accent-strong); --el-color-primary-dark-2: var(--accent-strong);
--el-text-color-primary: var(--text-primary); --el-text-color-primary: var(--text-primary);
--el-text-color-regular: var(--text-secondary); --el-text-color-regular: var(--text-secondary);
@@ -147,14 +148,36 @@ body,
body { body {
color: var(--text-primary); color: var(--text-primary);
background: background:
radial-gradient(circle at top left, rgba(91, 168, 255, 0.06), transparent 24%), radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 24%),
radial-gradient(circle at top right, rgba(79, 201, 168, 0.05), transparent 22%), radial-gradient(circle at top right, rgba(14, 165, 233, 0.06), transparent 22%),
linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent 18%),
linear-gradient(180deg, var(--bg-base) 0%, var(--bg-subtle) 100%); linear-gradient(180deg, var(--bg-base) 0%, var(--bg-subtle) 100%);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-x: hidden; overflow-x: hidden;
} }
* {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: rgba(148, 163, 184, 0.52);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
body, body,
button, button,
input, input,
@@ -178,13 +201,16 @@ select:focus-visible {
.page-stack { .page-stack {
display: grid; display: grid;
gap: var(--page-gap); gap: var(--page-gap);
width: min(100%, var(--page-max-width));
margin: 0 auto;
} }
.page-header { .page-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 24px;
padding: 4px 0;
} }
.page-header > div:first-child { .page-header > div:first-child {
@@ -194,16 +220,16 @@ select:focus-visible {
.page-title { .page-title {
margin: 0; margin: 0;
font-size: clamp(28px, 2vw, 40px); font-size: clamp(30px, 2vw, 42px);
font-weight: 750; font-weight: 780;
letter-spacing: -0.045em; letter-spacing: -0.045em;
line-height: 1.02; line-height: 1;
color: var(--text-primary); color: var(--text-primary);
} }
.page-subtitle { .page-subtitle {
max-width: 76ch; max-width: 76ch;
margin: 12px 0 0; margin: 14px 0 0;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 14px; font-size: 14px;
line-height: 1.75; line-height: 1.75;
@@ -231,7 +257,9 @@ select:focus-visible {
overflow: hidden; overflow: hidden;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
background: linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%); background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
transition: transition:
transform 0.2s ease, transform 0.2s ease,
@@ -252,20 +280,20 @@ select:focus-visible {
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.stat-card { .stat-card {
grid-column: span 3;
display: grid; display: grid;
gap: 10px; gap: 12px;
padding: 18px; min-height: 154px;
padding: 20px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42)), linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface-muted); var(--surface);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -285,7 +313,7 @@ html[data-theme="dark"] .stat-card {
.stat-card__value { .stat-card__value {
color: var(--text-primary); color: var(--text-primary);
font-size: clamp(26px, 2vw, 36px); font-size: clamp(30px, 2vw, 40px);
font-weight: 760; font-weight: 760;
letter-spacing: -0.05em; letter-spacing: -0.05em;
line-height: 1; line-height: 1;
@@ -768,7 +796,7 @@ html[data-theme="dark"] .stat-card {
} }
.el-dialog { .el-dialog {
border-radius: 12px; border-radius: 18px;
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
background: linear-gradient(180deg, var(--surface-raised), var(--surface)); background: linear-gradient(180deg, var(--surface-raised), var(--surface));
box-shadow: var(--shadow-float); box-shadow: var(--shadow-float);
@@ -800,9 +828,20 @@ html[data-theme="dark"] .stat-card {
color: var(--text-secondary); color: var(--text-secondary);
} }
.el-badge__content.is-fixed.is-dot {
top: 10px;
right: 10px;
}
.el-dropdown-menu {
border-radius: 16px;
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-card);
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.stat-card { .stats-grid {
grid-column: span 6; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.highlight-grid { .highlight-grid {
+18 -34
View File
@@ -4,12 +4,15 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import axios from "axios"; import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client"; import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import { useViewport } from "@/composables/useViewport"; import { useViewport } from "@/composables/useViewport";
import type { import type {
DailyReviewPushResult, DailyReviewPushResult,
DailyReviewReport, DailyReviewReport,
PushDailyReviewRequest PushDailyReviewRequest
} from "@/types"; } from "@/types";
import { Bell, Calendar, Clock, DataAnalysis, VideoCamera } from "@element-plus/icons-vue";
const router = useRouter(); const router = useRouter();
const { isMobile } = useViewport(); const { isMobile } = useViewport();
@@ -137,6 +140,7 @@ onMounted(loadReport);
<div class="page-stack"> <div class="page-stack">
<div class="page-header"> <div class="page-header">
<div> <div>
<div class="page-kicker">回顾分析</div>
<h1 class="page-title">回顾日报</h1> <h1 class="page-title">回顾日报</h1>
<p class="page-subtitle"> <p class="page-subtitle">
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件 按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件
@@ -163,37 +167,13 @@ onMounted(loadReport);
<el-skeleton v-if="loading && !report" animated :rows="10" /> <el-skeleton v-if="loading && !report" animated :rows="10" />
<template v-else-if="report"> <template v-else-if="report">
<div class="stats-grid"> <div class="stats-grid daily-review-metrics">
<div class="stat-card"> <MetricCard label="活跃直播间" :value="report.summary.activeLiveRoomCount" :description="`${report.date} 当天有录制重叠的房间数`" :icon="Calendar" />
<div class="stat-card__label">活跃直播间</div> <MetricCard label="录制会话" :value="report.summary.sessionCount" description="按整场直播会话聚合统计" :icon="VideoCamera" />
<div class="stat-card__value">{{ report.summary.activeLiveRoomCount }}</div> <MetricCard label="分片总数" :value="report.summary.segmentCount" description="只统计与当天有时间重叠的分片" :icon="DataAnalysis" />
<div class="stat-card__hint">{{ report.date }} 当天有录制重叠的房间数</div> <MetricCard label="录制时长" :value="formatDuration(report.summary.totalDurationSeconds)" description="跨天会话按日报窗口裁剪" :icon="Clock" />
</div> <MetricCard label="警告 / 错误" :value="`${report.summary.warningCount} / ${report.summary.errorCount}`" description="来自当天警告和错误系统日志" :icon="Bell" />
<div class="stat-card"> <MetricCard label="弹幕事件" :value="report.summary.totalDanmakuCount" description="优先按弹幕 XML 分钟桶统计" :icon="Bell" />
<div class="stat-card__label">录制会话</div>
<div class="stat-card__value">{{ report.summary.sessionCount }}</div>
<div class="stat-card__hint">按整场直播会话聚合统计</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片总数</div>
<div class="stat-card__value">{{ report.summary.segmentCount }}</div>
<div class="stat-card__hint">只统计与当天有时间重叠的分片</div>
</div>
<div class="stat-card">
<div class="stat-card__label">录制时长</div>
<div class="stat-card__value">{{ formatDuration(report.summary.totalDurationSeconds) }}</div>
<div class="stat-card__hint">跨天会话按日报窗口裁剪</div>
</div>
<div class="stat-card">
<div class="stat-card__label">警告 / 错误</div>
<div class="stat-card__value">{{ report.summary.warningCount }} / {{ report.summary.errorCount }}</div>
<div class="stat-card__hint">来自当天警告 / 错误系统日志</div>
</div>
<div class="stat-card">
<div class="stat-card__label">弹幕事件</div>
<div class="stat-card__value">{{ report.summary.totalDanmakuCount }}</div>
<div class="stat-card__hint">优先按弹幕 XML 分钟桶统计</div>
</div>
</div> </div>
<el-card class="surface-card" shadow="never"> <el-card class="surface-card" shadow="never">
@@ -204,7 +184,7 @@ onMounted(loadReport);
</div> </div>
</div> </div>
<el-empty v-if="report.rooms.length === 0" description="当天没有录制数据" /> <EmptyState v-if="report.rooms.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell"> <div v-else class="table-scroll-shell">
<el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto"> <el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto">
@@ -240,7 +220,7 @@ onMounted(loadReport);
</div> </div>
</div> </div>
<el-empty v-if="report.highlights.length === 0" description="当天没有可展示的会话亮点" /> <EmptyState v-if="report.highlights.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="highlight-grid"> <div v-else class="highlight-grid">
<section <section
@@ -278,7 +258,7 @@ onMounted(loadReport);
</div> </div>
</div> </div>
<el-empty v-if="report.moments.length === 0" description="当天没有可用的弹幕热度数据" /> <EmptyState v-if="report.moments.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell"> <div v-else class="table-scroll-shell">
<el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto"> <el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto">
@@ -342,6 +322,10 @@ onMounted(loadReport);
border-radius: 14px; border-radius: 14px;
} }
.daily-review-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.highlight-grid { .highlight-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
+312 -74
View File
@@ -2,6 +2,10 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client"; 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 StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport"; import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types"; import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import { import {
@@ -10,10 +14,12 @@ import {
availabilityLabelMap, availabilityLabelMap,
currentRecordingStateLabelMap, currentRecordingStateLabelMap,
outputFormatLabelMap, outputFormatLabelMap,
platformOptionList,
qualityOptionList, qualityOptionList,
recordingTemplateLabelMap, recordingTemplateLabelMap,
saveModeLabelMap saveModeLabelMap
} from "@/types"; } from "@/types";
import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/icons-vue";
const inheritValue = "__inherit__"; const inheritValue = "__inherit__";
const AUTO_REFRESH_INTERVAL_MS = 15000; const AUTO_REFRESH_INTERVAL_MS = 15000;
@@ -38,6 +44,8 @@ const settingsRoom = ref<LiveRoom | null>(null);
const rooms = ref<LiveRoom[]>([]); const rooms = ref<LiveRoom[]>([]);
const selectedRooms = ref<LiveRoom[]>([]); const selectedRooms = ref<LiveRoom[]>([]);
const loadError = ref(""); const loadError = ref("");
const roomDetailVisible = ref(false);
const activeRoom = ref<LiveRoom | null>(null);
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const roomsTableShellRef = ref<HTMLElement | null>(null); const roomsTableShellRef = ref<HTMLElement | null>(null);
const roomsTableProxyRef = ref<HTMLElement | null>(null); const roomsTableProxyRef = ref<HTMLElement | null>(null);
@@ -85,14 +93,15 @@ const settingsForm = reactive({
}); });
const platformOptions = [ const platformOptions = [
{ label: "自动识别", value: null }, { label: "自动识别", value: null as number | null },
{ label: "抖音", value: 1 }, ...platformOptionList.map((option) => ({
{ label: "Bilibili", value: 2 }, label: option.label,
{ label: "虎牙", value: 3 } value: option.value
}))
]; ];
const qualityOptions = qualityOptionList; const qualityOptions = qualityOptionList;
const qualitySupportHint = "抖音和 Bilibili 支持按画质选流;虎牙暂未实现。若目标档位不可用,平台会自动回退到最接近的可用画质。"; const qualitySupportHint = "不同平台的可选画质不同;如果目标档位不可用,系统会自动回退到当前平台最接近的可用。";
const booleanOverrideOptions = [ const booleanOverrideOptions = [
{ label: "跟随全局", value: inheritValue }, { label: "跟随全局", value: inheritValue },
{ label: "开启", value: "true" }, { label: "开启", value: "true" },
@@ -108,6 +117,15 @@ const totalRooms = computed(() => rooms.value.length);
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length); const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const disabledRooms = 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 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 activeRoomSubtitle = computed(() => {
if (!activeRoom.value) {
return "";
}
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
});
const tableHeight = computed(() => (isMobile.value ? undefined : 700)); const tableHeight = computed(() => (isMobile.value ? undefined : 700));
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px")); const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px")); const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
@@ -501,6 +519,11 @@ function openRecordDialog(room: LiveRoom) {
recordDialogVisible.value = true; recordDialogVisible.value = true;
} }
function openRoomDetails(room: LiveRoom) {
activeRoom.value = room;
roomDetailVisible.value = true;
}
async function copyRoomLink(room: LiveRoom) { async function copyRoomLink(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl; const url = room.originalLiveRoomUrl || room.sourceUrl;
@@ -688,6 +711,14 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase(); return source.slice(0, 1).toUpperCase();
} }
function roomAvailabilityLabel(room: LiveRoom) {
return availabilityLabelMap[room.availabilityStatus] ?? "--";
}
function latestEventLabel(room: LiveRoom) {
return room.lastAutoStartDecisionSummary || "暂无事件";
}
function getQualityLabel(quality?: string | null) { function getQualityLabel(quality?: string | null) {
return formatQualityLabel(quality); return formatQualityLabel(quality);
} }
@@ -802,14 +833,14 @@ onBeforeUnmount(() => {
<div class="page-header"> <div class="page-header">
<div> <div>
<div class="page-kicker">直播监控</div> <div class="page-kicker">直播监控</div>
<h1 class="page-title">直播间管理</h1> <h1 class="page-title">直播间监控录制控制台</h1>
<p class="page-subtitle"> <p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置 聚合直播状态自动开录决策和单房间录制配置面向开播检测异常巡检与手动介入场景
</p> </p>
</div> </div>
<div class="page-toolbar"> <div class="page-toolbar">
<el-button @click="loadRooms">刷新列表</el-button> <el-button @click="loadRooms">刷新状态</el-button>
<el-button @click="openImportDialog">批量导入</el-button> <el-button @click="openImportDialog">批量导入</el-button>
<el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button> <el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button>
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button> <el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
@@ -818,34 +849,44 @@ onBeforeUnmount(() => {
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" /> <el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid"> <div class="live-console-grid">
<div class="stat-card"> <div class="stats-grid">
<div class="stat-card__label">直播间总数</div> <MetricCard label="在线直播间" :value="liveRooms" description="基于最近一次巡检识别为开播中的房间" :icon="House" />
<div class="stat-card__value">{{ totalRooms }}</div> <MetricCard label="录制中任务" :value="recordingRooms" description="当前处于录制状态的直播间数量" :icon="VideoCamera" />
<div class="stat-card__hint">当前已接入系统的直播间数量</div> <MetricCard label="启用房间" :value="enabledRooms" description="参与自动检测与自动录制的直播间" :icon="SwitchButton" />
</div> <MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
<div class="stat-card">
<div class="stat-card__label">启用中</div>
<div class="stat-card__value">{{ enabledRooms }}</div>
<div class="stat-card__hint">参与后台巡检与自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">已停用</div>
<div class="stat-card__value">{{ disabledRooms }}</div>
<div class="stat-card__hint">保留解析结果但不再自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">当前开播</div>
<div class="stat-card__value">{{ liveRooms }}</div>
<div class="stat-card__hint">基于最近一次状态检测</div>
</div> </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> </div>
<el-card class="surface-card table-card" shadow="never"> <el-card class="surface-card table-card" shadow="never" v-loading="loading">
<div class="toolbar-row"> <div class="toolbar-row">
<div> <div>
<h3 class="section-title">直播间列表</h3> <h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">支持刷新状态手动启动录制单房间配置覆盖以及按需删除直播间</p> <p class="section-subtitle">继续使用真实接口数据统一强化为监控控制台视图支持查看详情手动录制和单房间配置覆盖</p>
</div> </div>
<el-space v-if="!isMobile" wrap class="batch-actions"> <el-space v-if="!isMobile" wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span> <span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
@@ -854,10 +895,18 @@ onBeforeUnmount(() => {
<el-button type="danger" plain :disabled="!hasSelectedRooms || batchActionLoading" @click="openBatchDeleteDialog"> <el-button type="danger" plain :disabled="!hasSelectedRooms || batchActionLoading" @click="openBatchDeleteDialog">
批量删除 批量删除
</el-button> </el-button>
</el-space> </el-space>
</div> </div>
<div v-if="isMobile" class="data-card-list room-card-list"> <EmptyState
v-if="!loading && rooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadRooms"
/>
<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 rooms" :key="row.id" class="data-card room-card">
<div class="data-card__header"> <div class="data-card__header">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar"> <el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
@@ -874,30 +923,42 @@ onBeforeUnmount(() => {
</div> </div>
<div class="badge-row"> <div class="badge-row">
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)"> <StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
{{ currentRecordingStateLabelMap[row.currentRecordingState] }} <StatusBadge
</el-tag> :label="currentRecordingStateLabelMap[row.currentRecordingState]"
<el-tag effect="plain">{{ row.platformName }}</el-tag> :status="row.currentRecordingState"
<el-tag v-if="row.isPinned" size="small" effect="plain">置顶</el-tag> context="recording"
<el-tag v-if="row.isPriority" size="small" effect="plain" type="danger">重点</el-tag> />
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)"> <StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }} <StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
</el-tag> <StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div> </div>
<div class="data-card__grid"> <div class="data-card__grid">
<div> <div>
<dt>自动开录</dt> <dt>最近事件</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</dd> <dd>{{ latestEventLabel(row) }}</dd>
</div> </div>
<div> <div>
<dt>最近巡检</dt> <dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd> <dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div> </div>
<div>
<dt>在线人数</dt>
<dd>--</dd>
</div>
<div>
<dt>码率</dt>
<dd>--</dd>
</div>
<div> <div>
<dt>检测间隔</dt> <dt>检测间隔</dt>
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd> <dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
</div> </div>
<div>
<dt>采集账号</dt>
<dd>--</dd>
</div>
<div style="grid-column: 1 / -1;"> <div style="grid-column: 1 / -1;">
<dt>房间配置</dt> <dt>房间配置</dt>
<dd> <dd>
@@ -926,6 +987,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="data-card__actions"> <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="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(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="copyRoomLink(row)">复制链接</el-button>
@@ -950,7 +1012,6 @@ onBeforeUnmount(() => {
<el-table <el-table
:data="rooms" :data="rooms"
v-loading="loading"
:height="tableHeight" :height="tableHeight"
class="premium-table rooms-table" class="premium-table rooms-table"
table-layout="fixed" table-layout="fixed"
@@ -959,53 +1020,59 @@ onBeforeUnmount(() => {
> >
<el-table-column type="selection" width="48" /> <el-table-column type="selection" width="48" />
<el-table-column label="头像" width="88"> <el-table-column label="直播间" min-width="340">
<template #default="{ row }"> <template #default="{ row }">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="56" class="room-avatar"> <div class="room-summary-cell">
{{ getRoomAvatarText(row) }} <el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
</el-avatar> {{ getRoomAvatarText(row) }}
</template> </el-avatar>
</el-table-column> <div class="room-summary-cell__copy">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<el-table-column label="直播间" min-width="320"> <div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<template #default="{ row }"> <div class="config-summary">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div> <span>{{ row.platformName || "--" }}</span>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div> <span>{{ row.roomId || "--" }}</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
</div>
</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div> <div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div> <div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="config-summary"> <div class="badge-row">
<span v-if="row.isPinned">置顶</span> <StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<span v-if="row.isPriority">重点</span> <StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div> </div>
<div class="monospace cell-mono">{{ row.roomId }}</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="平台" width="110"> <el-table-column label="直播状态" width="128">
<template #default="{ row }"> <template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag> <StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="直播状态" width="120"> <el-table-column label="录制状态" width="128">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)"> <StatusBadge
{{ currentRecordingStateLabelMap[row.currentRecordingState] }} :label="currentRecordingStateLabelMap[row.currentRecordingState]"
</el-tag> :status="row.currentRecordingState"
context="recording"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="自动开录" min-width="280"> <el-table-column label="最近事件" min-width="300">
<template #default="{ row }"> <template #default="{ row }">
<div class="auto-start-cell"> <div class="auto-start-cell">
<div class="auto-start-cell__head"> <div class="auto-start-cell__head">
<el-tag size="small" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)" effect="plain"> <StatusBadge
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }} size="sm"
</el-tag> :label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span> <span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div> </div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div> <div class="cell-subtitle">{{ latestEventLabel(row) }}</div>
<el-popover <el-popover
v-if="row.lastAutoStartDecisionDetail" v-if="row.lastAutoStartDecisionDetail"
trigger="hover" trigger="hover"
@@ -1032,7 +1099,7 @@ onBeforeUnmount(() => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="录制开关" width="128"> <el-table-column label="录制开关" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-switch <el-switch
:model-value="row.isEnabled" :model-value="row.isEnabled"
@@ -1045,6 +1112,16 @@ onBeforeUnmount(() => {
</template> </template>
</el-table-column> </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"> <el-table-column label="最近检测" width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div> <div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
@@ -1054,6 +1131,7 @@ onBeforeUnmount(() => {
<el-table-column label="操作" width="380"> <el-table-column label="操作" width="380">
<template #default="{ row }"> <template #default="{ row }">
<div class="room-actions-cell"> <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="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(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="copyRoomLink(row)">复制链接</el-button>
@@ -1069,6 +1147,76 @@ onBeforeUnmount(() => {
</div> </div>
</el-card> </el-card>
<RightDrawer
v-model="roomDetailVisible"
:title="activeRoom?.title || activeRoom?.anchorName || activeRoom?.roomId || '直播间详情'"
:subtitle="activeRoomSubtitle"
>
<div v-if="activeRoom" class="detail-panel">
<div class="detail-panel__hero">
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
{{ getRoomAvatarText(activeRoom) }}
</el-avatar>
<div>
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
</div>
</div>
<div class="badge-row">
<StatusBadge :label="roomAvailabilityLabel(activeRoom)" :status="activeRoom.availabilityStatus" context="availability" />
<StatusBadge
:label="currentRecordingStateLabelMap[activeRoom.currentRecordingState]"
:status="activeRoom.currentRecordingState"
context="recording"
/>
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
</div>
<el-descriptions :column="1" border class="detail-panel__descriptions">
<el-descriptions-item label="直播间名称">
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="平台 + Room ID">
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="直播状态">
{{ roomAvailabilityLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="录制状态">
{{ currentRecordingStateLabelMap[activeRoom.currentRecordingState] || "--" }}
</el-descriptions-item>
<el-descriptions-item label="在线人数">--</el-descriptions-item>
<el-descriptions-item label="码率">--</el-descriptions-item>
<el-descriptions-item label="录制时长">--</el-descriptions-item>
<el-descriptions-item label="采集账号">--</el-descriptions-item>
<el-descriptions-item label="最近事件">
{{ latestEventLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="最近巡检">
{{ formatDate(activeRoom.lastCheckedAt) }}
</el-descriptions-item>
<el-descriptions-item label="自动开录详情">
{{ activeRoom.lastAutoStartDecisionDetail || "--" }}
</el-descriptions-item>
<el-descriptions-item label="房间配置">
{{ getQualityLabel(activeRoom.effectiveSettings.preferredQuality) }} ·
{{ outputFormatLabelMap[activeRoom.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[activeRoom.effectiveSettings.saveMode] }} ·
{{ activeRoom.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="roomDetailVisible = false">关闭</el-button>
<el-button @click="activeRoom && openSettingsDialog(activeRoom)">配置</el-button>
<el-button type="primary" :disabled="!activeRoom?.isEnabled" @click="activeRoom && openRecordDialog(activeRoom)">
开始录制
</el-button>
</template>
</RightDrawer>
<el-dialog <el-dialog
v-model="createDialogVisible" v-model="createDialogVisible"
class="form-dialog" class="form-dialog"
@@ -1083,7 +1231,7 @@ onBeforeUnmount(() => {
v-model="createForm.url" v-model="createForm.url"
type="textarea" type="textarea"
:rows="5" :rows="5"
placeholder="粘贴抖音 / Bilibili / 虎牙直播间链接,或直接输入 roomId" placeholder="粘贴 Douyin / Bilibili / Huya / Douyu / Kuaishou / TikTok / YouTube / Twitch 等直播间链接"
/> />
</el-form-item> </el-form-item>
@@ -1129,7 +1277,7 @@ onBeforeUnmount(() => {
v-model="importForm.content" v-model="importForm.content"
type="textarea" type="textarea"
:rows="12" :rows="12"
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://live.douyin.com/262011082654" placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://www.twitch.tv/example_channel&#10;https://www.youtube.com/watch?v=example12345"
/> />
</el-form-item> </el-form-item>
@@ -1455,6 +1603,46 @@ onBeforeUnmount(() => {
gap: 24px; gap: 24px;
} }
.live-console-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
gap: 18px;
}
.focus-card :deep(.el-card__body) {
display: grid;
gap: 18px;
}
.focus-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.focus-card__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.focus-card__description {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
.focus-card__chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.header-actions { .header-actions {
align-self: center; align-self: center;
} }
@@ -1518,6 +1706,16 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
} }
.room-summary-cell {
display: flex;
align-items: flex-start;
gap: 14px;
}
.room-summary-cell__copy {
min-width: 0;
}
.room-avatar { .room-avatar {
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14); box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
} }
@@ -1617,10 +1815,46 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
} }
.table-placeholder-grid {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.room-actions-cell :deep(.el-button) { .room-actions-cell :deep(.el-button) {
margin: 0; margin: 0;
} }
.detail-panel {
display: grid;
gap: 18px;
}
.detail-panel__hero {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.detail-panel__title {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
}
.detail-panel__meta {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
word-break: break-all;
}
.field-help { .field-help {
margin-top: -8px; margin-top: -8px;
margin-bottom: 14px; margin-bottom: 14px;
@@ -1798,6 +2032,10 @@ onBeforeUnmount(() => {
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.live-console-grid {
grid-template-columns: 1fr;
}
.toolbar-row { .toolbar-row {
flex-direction: column; flex-direction: column;
} }
+26 -1
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue"; import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client"; import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types"; import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
const loading = ref(false); const loading = ref(false);
@@ -14,6 +16,9 @@ const previewTitle = ref("");
const previewUrl = ref(""); const previewUrl = ref("");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录"); 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);
async function loadDirectory(path = "") { async function loadDirectory(path = "") {
loading.value = true; loading.value = true;
@@ -140,6 +145,13 @@ onMounted(() => {
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" /> <el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid media-browser-metrics">
<MetricCard label="当前目录" :value="currentPathLabel" description="录制归档目录仅在后端允许的路径范围内浏览" />
<MetricCard label="子目录数" :value="directoryCount" description="当前目录下的可进入目录数量" :icon="FolderOpened" />
<MetricCard label="媒体文件" :value="mediaFileCount" description="当前目录下可直接预览或下载的文件数" :icon="Document" />
<MetricCard label="待转码" :value="transcodeReadyCount" description="当前目录下支持补转码为 MP4 的文件数量" :icon="VideoPlay" />
</div>
<el-card class="surface-card" shadow="never"> <el-card class="surface-card" shadow="never">
<div class="section-header"> <div class="section-header">
<div> <div>
@@ -171,7 +183,13 @@ onMounted(() => {
<el-skeleton v-if="loading && !browser" animated :rows="8" /> <el-skeleton v-if="loading && !browser" animated :rows="8" />
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" /> <EmptyState
v-else-if="browser && browser.items.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新目录"
@action="refreshCurrentDirectory"
/>
<div v-else-if="browser" class="table-scroll-shell"> <div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto"> <el-table :data="browser.items" class="premium-table" table-layout="auto">
@@ -252,6 +270,13 @@ onMounted(() => {
border-radius: 14px; border-radius: 14px;
} }
.media-browser-metrics :deep(.metric-card__value) {
overflow: hidden;
font-size: clamp(22px, 1.8vw, 30px);
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-row { .breadcrumb-row {
display: flex; display: flex;
align-items: center; align-items: center;
+88 -32
View File
@@ -2,11 +2,15 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus"; import { ElMessage, ElNotification } from "element-plus";
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
import apiClient, { import apiClient, {
buildApiUrl, buildApiUrl,
getApiErrorMessage, getApiErrorMessage,
getBackendUnavailableMessage getBackendUnavailableMessage
} from "@/api/client"; } 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 { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus"; import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport"; import { useViewport } from "@/composables/useViewport";
import type { import type {
@@ -818,7 +822,7 @@ onBeforeUnmount(() => {
<div class="page-kicker">录制工作台</div> <div class="page-kicker">录制工作台</div>
<h1 class="page-title">录制任务</h1> <h1 class="page-title">录制任务</h1>
<p class="page-subtitle"> <p class="page-subtitle">
按直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变保留手动刷新入口用于兜底 按直播会话聚合展示真实录制任务自动接收状态转码进度和分片变适合观察开播检测到归档上传的完整链路
</p> </p>
</div> </div>
@@ -856,7 +860,7 @@ onBeforeUnmount(() => {
<div class="cleanup-status-card__title">当前任务状态{{ cleanupOperationStatusLabel }}</div> <div class="cleanup-status-card__title">当前任务状态{{ cleanupOperationStatusLabel }}</div>
</div> </div>
<div class="cleanup-status-card__actions"> <div class="cleanup-status-card__actions">
<el-tag :type="cleanupOperationTagType">{{ cleanupOperationStatusLabel }}</el-tag> <StatusBadge :label="cleanupOperationStatusLabel" :status="cleanupOperation?.status" context="cleanup" />
<el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button> <el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button>
</div> </div>
</div> </div>
@@ -875,26 +879,30 @@ onBeforeUnmount(() => {
</ul> </ul>
</el-card> </el-card>
<div class="stats-grid"> <div class="record-ops-grid">
<div class="stat-card"> <div class="stats-grid">
<div class="stat-card__label">录制会话</div> <MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<div class="stat-card__value">{{ sessions.length }}</div> <MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
<div class="stat-card__hint">按整场直播聚合分片任务</div> <MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
</div> </div>
<div class="stat-card">
<div class="stat-card__label">活跃会话</div> <div class="record-feature-strip">
<div class="stat-card__value">{{ activeSessionCount }}</div> <article class="record-feature-card">
<div class="stat-card__hint">Starting / Running / Stopping / Processing</div> <div class="record-feature-card__eyebrow">能力说明</div>
</div> <div class="record-feature-card__title">开播自动录制</div>
<div class="stat-card"> <p class="record-feature-card__description">继续复用后端轮询自动开录和活动会话保护逻辑不新增任何前端假状态</p>
<div class="stat-card__label">分片任务</div> </article>
<div class="stat-card__value">{{ totalTaskCount }}</div> <article class="record-feature-card">
<div class="stat-card__hint">包含单文件模式下的唯一任务</div> <div class="record-feature-card__eyebrow">能力说明</div>
</div> <div class="record-feature-card__title">分片后处理</div>
<div class="stat-card"> <p class="record-feature-card__description">保留现有转码分片完成事件和实时进度展示聚焦运维可读性</p>
<div class="stat-card__label">弹幕事件</div> </article>
<div class="stat-card__value">{{ totalDanmakuCount }}</div> <article class="record-feature-card">
<div class="stat-card__hint">累计写入 XML 的事件总数</div> <div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">上传归档</div>
<p class="record-feature-card__description">继续调用真实上传接口空数据时显示空状态而不是伪造归档数量</p>
</article>
</div> </div>
</div> </div>
@@ -926,7 +934,13 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" /> <EmptyState
v-if="!loading && sessions.length === 0"
title="暂无录制任务"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadSessions()"
/>
<div v-else-if="isMobile" class="data-card-list session-card-list"> <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 sessions" :key="session.id" class="data-card session-card">
@@ -935,9 +949,7 @@ onBeforeUnmount(() => {
<div class="data-card__title">{{ session.liveRoomTitle }}</div> <div class="data-card__title">{{ session.liveRoomTitle }}</div>
<div class="data-card__subtitle monospace">{{ session.roomId }}</div> <div class="data-card__subtitle monospace">{{ session.roomId }}</div>
</div> </div>
<el-tag :type="sessionTagType(session.status)"> <StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
{{ sessionStatusLabelMap[session.status] }}
</el-tag>
</div> </div>
<div class="badge-row"> <div class="badge-row">
@@ -993,7 +1005,7 @@ onBeforeUnmount(() => {
<strong class="monospace">#{{ task.segmentIndex }}</strong> <strong class="monospace">#{{ task.segmentIndex }}</strong>
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div> <div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
</div> </div>
<el-tag :type="taskTagType(task.status)">{{ taskStatusLabelMap[task.status] }}</el-tag> <StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
</div> </div>
<div v-if="hasPostProcess(task)" class="session-task-card__progress"> <div v-if="hasPostProcess(task)" class="session-task-card__progress">
@@ -1081,9 +1093,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="session-title__stats"> <div class="session-title__stats">
<el-tag :type="sessionTagType(session.status)"> <StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
{{ sessionStatusLabelMap[session.status] }}
</el-tag>
<span>{{ saveModeLabelMap[session.saveMode] }}</span> <span>{{ saveModeLabelMap[session.saveMode] }}</span>
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span> <span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span>分片 {{ session.segmentCount }}</span> <span>分片 {{ session.segmentCount }}</span>
@@ -1157,9 +1167,7 @@ onBeforeUnmount(() => {
<el-table-column label="状态" width="220"> <el-table-column label="状态" width="220">
<template #default="{ row }"> <template #default="{ row }">
<div class="task-status-cell"> <div class="task-status-cell">
<el-tag :type="taskTagType(row.status)"> <StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
{{ taskStatusLabelMap[row.status] }}
</el-tag>
<template v-if="hasPostProcess(row)"> <template v-if="hasPostProcess(row)">
<div class="task-status-cell__stage">{{ row.postProcessStage }}</div> <div class="task-status-cell__stage">{{ row.postProcessStage }}</div>
<el-progress <el-progress
@@ -1360,6 +1368,50 @@ onBeforeUnmount(() => {
gap: 24px; gap: 24px;
} }
.record-ops-grid {
display: grid;
gap: 18px;
}
.record-feature-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.record-feature-card {
display: grid;
gap: 10px;
padding: 18px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
.record-feature-card__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.record-feature-card__title {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
.record-feature-card__description {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.75;
}
.header-actions { .header-actions {
align-self: center; align-self: center;
} }
@@ -1722,6 +1774,10 @@ onBeforeUnmount(() => {
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.record-feature-strip {
grid-template-columns: 1fr;
}
.header-actions { .header-actions {
align-self: stretch; align-self: stretch;
justify-content: stretch; justify-content: stretch;
+54 -33
View File
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import axios from "axios"; import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client"; 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 { useViewport } from "@/composables/useViewport";
import type { import type {
RecoverableFinalization, RecoverableFinalization,
@@ -11,6 +14,7 @@ import type {
RecoveryOverview RecoveryOverview
} from "@/types"; } from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types"; import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
const loading = ref(false); const loading = ref(false);
const retryAllLoading = ref(false); const retryAllLoading = ref(false);
@@ -183,27 +187,20 @@ onMounted(loadOverview);
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" /> <el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid" v-loading="loading"> <div class="stats-grid recovery-metrics" v-loading="loading">
<div class="stat-card"> <MetricCard
<div class="stat-card__label">存储保护</div> label="存储保护"
<div class="stat-card__value">{{ storage?.hasEnoughSpace ? "可恢复" : "受限" }}</div> :value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
<div class="stat-card__hint">{{ storage?.message || "暂无数据" }}</div> :description="storage?.message || '暂无存储数据'"
</div> :icon="WarningFilled"
<div class="stat-card"> />
<div class="stat-card__label">可重试开录</div> <MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<div class="stat-card__value">{{ liveRooms.length }}</div> <MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
<div class="stat-card__hint">在线启用中且当前没有活动会话的直播间</div> <MetricCard
</div> label="剩余空间"
<div class="stat-card"> :value="storage ? formatBytes(storage.availableBytes) : '--'"
<div class="stat-card__label">可恢复转码</div> :description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
<div class="stat-card__value">{{ finalizations.length }}</div> />
<div class="stat-card__hint">等待继续或可手动补转码的 MP4 任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">剩余空间</div>
<div class="stat-card__value">{{ storage ? formatBytes(storage.availableBytes) : "-" }}</div>
<div class="stat-card__hint">恢复阈值 {{ storage ? formatBytes(storage.requiredBytes) : "-" }}</div>
</div>
</div> </div>
<el-card class="surface-card table-card" shadow="never"> <el-card class="surface-card table-card" shadow="never">
@@ -223,20 +220,30 @@ onMounted(loadOverview);
</el-button> </el-button>
</div> </div>
<div v-if="isMobile" class="data-card-list"> <EmptyState
v-if="!loading && liveRooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新总览"
@action="loadOverview"
/>
<div v-else-if="isMobile" class="data-card-list">
<article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card"> <article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card">
<div class="data-card__header"> <div class="data-card__header">
<div> <div>
<div class="data-card__title">{{ liveRoomTitle(row) }}</div> <div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div> <div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div> </div>
<el-tag effect="plain">{{ row.platformName }}</el-tag> <StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</div> </div>
<div class="badge-row"> <div class="badge-row">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)"> <StatusBadge
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }} size="sm"
</el-tag> :label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span> <span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span>
</div> </div>
@@ -286,7 +293,7 @@ onMounted(loadOverview);
<el-table-column label="平台" width="120"> <el-table-column label="平台" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag> <StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</template> </template>
</el-table-column> </el-table-column>
@@ -294,9 +301,11 @@ onMounted(loadOverview);
<template #default="{ row }"> <template #default="{ row }">
<div class="decision-cell"> <div class="decision-cell">
<div class="decision-cell__head"> <div class="decision-cell__head">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)"> <StatusBadge
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }} size="sm"
</el-tag> :label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span> <span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div> </div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div> <div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
@@ -344,14 +353,22 @@ onMounted(loadOverview);
</el-button> </el-button>
</div> </div>
<div v-if="isMobile" class="data-card-list"> <EmptyState
v-if="!loading && finalizations.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新总览"
@action="loadOverview"
/>
<div v-else-if="isMobile" class="data-card-list">
<article v-for="row in finalizations" :key="row.recordTaskId" class="data-card"> <article v-for="row in finalizations" :key="row.recordTaskId" class="data-card">
<div class="data-card__header"> <div class="data-card__header">
<div> <div>
<div class="data-card__title">{{ finalizationTitle(row) }}</div> <div class="data-card__title">{{ finalizationTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div> <div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
</div> </div>
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag> <StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</div> </div>
<div class="data-card__grid"> <div class="data-card__grid">
@@ -404,7 +421,7 @@ onMounted(loadOverview);
<el-table-column label="状态" width="120"> <el-table-column label="状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag> <StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</template> </template>
</el-table-column> </el-table-column>
@@ -439,6 +456,10 @@ onMounted(loadOverview);
</template> </template>
<style scoped> <style scoped>
.recovery-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.decision-cell { .decision-cell {
display: grid; display: grid;
gap: 6px; gap: 6px;
File diff suppressed because it is too large Load Diff