feat: redesign responsive control room ui

This commit is contained in:
2026-04-26 02:18:03 +08:00
parent ff3e1fae70
commit 94f6e08dea
16 changed files with 4189 additions and 926 deletions
+452
View File
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
RecoverableLiveRoom,
RecoveryActionResult,
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
const loading = ref(false);
const retryAllLoading = ref(false);
const resumeAllLoading = ref(false);
const runningKey = ref("");
const loadError = ref("");
const overview = ref<RecoveryOverview | null>(null);
const { isMobile } = useViewport();
const storage = computed(() => overview.value?.storage ?? null);
const liveRooms = computed(() => overview.value?.liveRooms ?? []);
const finalizations = computed(() => overview.value?.finalizations ?? []);
async function loadOverview() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecoveryOverview>("/recovery");
overview.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "恢复中心加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
async function retryLiveRoom(liveRoomId: string) {
runningKey.value = `retry:${liveRoomId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(`/recovery/live-rooms/${liveRoomId}/retry`);
showActionResult(data);
await loadOverview();
} finally {
runningKey.value = "";
}
}
async function retryAllLiveRooms() {
retryAllLoading.value = true;
try {
const { data } = await apiClient.post<RecoveryActionResult>("/recovery/live-rooms/retry-all");
showActionResult(data);
await loadOverview();
} finally {
retryAllLoading.value = false;
}
}
async function resumeFinalization(recordTaskId: string) {
runningKey.value = `resume:${recordTaskId}`;
try {
const { data } = await apiClient.post<RecoveryActionResult>(`/recovery/finalizations/${recordTaskId}/resume`);
showActionResult(data);
await loadOverview();
} finally {
runningKey.value = "";
}
}
async function resumeAllFinalizations() {
resumeAllLoading.value = true;
try {
const { data } = await apiClient.post<RecoveryActionResult>("/recovery/finalizations/resume-all");
showActionResult(data);
await loadOverview();
} finally {
resumeAllLoading.value = false;
}
}
function showActionResult(result: RecoveryActionResult) {
const summary = result.messages[0] ?? `成功 ${result.successCount},失败 ${result.failedCount}`;
if (result.failedCount > 0 && result.successCount === 0) {
ElMessage.warning(summary);
return;
}
if (result.failedCount > 0) {
ElMessage.warning(`成功 ${result.successCount} 项,失败 ${result.failedCount}`);
return;
}
ElMessage.success(summary);
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) {
return "-";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
}
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
}
if (code === "failed_startup") {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
return "warning";
}
return "info";
}
function liveRoomTitle(item: RecoverableLiveRoom) {
return item.title || item.anchorName || item.roomId;
}
function finalizationTitle(item: RecoverableFinalization) {
return item.liveRoomTitle || item.roomId;
}
onMounted(loadOverview);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Recovery</div>
<h1 class="page-title">恢复中心</h1>
<p class="page-subtitle">
这里集中处理没有自动拉起的直播间和可继续的 MP4 转码任务上方的存储状态直接复用当前输出目录的保护检查结果
</p>
</div>
<div class="page-toolbar">
<el-button :loading="loading" @click="loadOverview">刷新总览</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid" v-loading="loading">
<div class="stat-card">
<div class="stat-card__label">存储保护</div>
<div class="stat-card__value">{{ storage?.hasEnoughSpace ? "可恢复" : "受限" }}</div>
<div class="stat-card__hint">{{ storage?.message || "暂无数据" }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可重试开录</div>
<div class="stat-card__value">{{ liveRooms.length }}</div>
<div class="stat-card__hint">在线启用中且当前没有活动会话的直播间</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可恢复转码</div>
<div class="stat-card__value">{{ finalizations.length }}</div>
<div class="stat-card__hint">等待继续或可手动补转码的 MP4 任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">剩余空间</div>
<div class="stat-card__value">{{ storage ? formatBytes(storage.availableBytes) : "-" }}</div>
<div class="stat-card__hint">恢复阈值 {{ storage ? formatBytes(storage.requiredBytes) : "-" }}</div>
</div>
</div>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
<h3 class="section-title">可重试开录的直播间</h3>
<p class="section-subtitle">优先展示最近一次自动开录为什么没起帮助我们先判断再重试</p>
</div>
<el-button
type="primary"
:loading="retryAllLoading"
:disabled="liveRooms.length === 0"
@click="retryAllLiveRooms"
>
重试全部直播间
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
</div>
<div class="badge-row">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span>
</div>
<div class="data-card__grid">
<div>
<dt>房间 ID</dt>
<dd class="monospace">{{ row.roomId }}</dd>
</div>
<div>
<dt>最近决策</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无摘要" }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.lastAutoStartDecisionDetail">
<dt>原因详情</dt>
<dd>{{ row.lastAutoStartDecisionDetail }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button
type="primary"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>
立即重试
</el-button>
</div>
</article>
</div>
<el-table
v-else
:data="liveRooms"
v-loading="loading"
class="premium-table"
table-layout="auto"
row-key="liveRoomId"
>
<el-table-column label="直播间" min-width="280">
<template #default="{ row }">
<div class="cell-title">{{ liveRoomTitle(row) }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近决策" min-width="320">
<template #default="{ row }">
<div class="decision-cell">
<div class="decision-cell__head">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<div v-if="row.lastAutoStartDecisionDetail" class="decision-cell__detail">
{{ row.lastAutoStartDecisionDetail }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="最近巡检" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="runningKey === `retry:${row.liveRoomId}`"
@click="retryLiveRoom(row.liveRoomId)"
>
立即重试
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
<h3 class="section-title">可恢复的 MP4 转码任务</h3>
<p class="section-subtitle">这里列出已暂停重启中断或仍保留中间文件可继续补转码的任务</p>
</div>
<el-button
type="primary"
:loading="resumeAllLoading"
:disabled="finalizations.length === 0"
@click="resumeAllFinalizations"
>
恢复全部转码
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<article v-for="row in finalizations" :key="row.recordTaskId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
</div>
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
</div>
<div class="data-card__grid">
<div>
<dt>房间 ID</dt>
<dd class="monospace">{{ row.roomId }}</dd>
</div>
<div>
<dt>结束时间</dt>
<dd>{{ formatDate(row.endedAt || row.createdAt) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>原因</dt>
<dd>{{ row.reason || "可继续恢复转码" }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.outputFilePath">
<dt>输出路径</dt>
<dd class="monospace">{{ row.outputFilePath }}</dd>
</div>
</div>
<div class="data-card__actions">
<el-button
type="primary"
size="small"
:loading="runningKey === `resume:${row.recordTaskId}`"
@click="resumeFinalization(row.recordTaskId)"
>
恢复转码
</el-button>
</div>
</article>
</div>
<el-table
v-else
:data="finalizations"
v-loading="loading"
class="premium-table"
table-layout="auto"
row-key="recordTaskId"
>
<el-table-column label="任务" min-width="280">
<template #default="{ row }">
<div class="cell-title">{{ finalizationTitle(row) }}</div>
<div class="cell-subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
<div class="cell-mono monospace">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="300">
<template #default="{ row }">
<div class="cell-subtitle">{{ row.reason || "可继续恢复转码" }}</div>
<div v-if="row.outputFilePath" class="cell-mono monospace">{{ row.outputFilePath }}</div>
</template>
</el-table-column>
<el-table-column label="结束时间" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.endedAt || row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="runningKey === `resume:${row.recordTaskId}`"
@click="resumeFinalization(row.recordTaskId)"
>
恢复转码
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.decision-cell {
display: grid;
gap: 6px;
}
.decision-cell__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.decision-cell__detail {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>