Files
live_recorder/frontend/src/views/RecoveryView.vue
T

481 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
RecoverableLiveRoom,
RecoveryActionResult,
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, formatAutoStartDecisionSummary, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera } from "@element-plus/icons-vue";
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 =
axios.isAxiosError(error) && error.response?.status === 404
? "当前后端还没有部署恢复中心接口。请先同步更新并重启后端,再打开这个页面。"
: 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 autoStartDecisionSummary(item: RecoverableLiveRoom) {
return formatAutoStartDecisionSummary(item.lastAutoStartDecisionCode, item.lastAutoStartDecisionSummary);
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
}
if (code === "failed_startup" || code === "poll_failed") {
return "danger";
}
if (
code === "skipped_storage" ||
code === "skipped_active_session" ||
code === "skipped_debounce" ||
code === "poll_failed_transient"
) {
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">恢复处理</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 recovery-metrics" v-loading="loading">
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
</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>
<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>
<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">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</div>
<div class="badge-row">
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<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>{{ autoStartDecisionSummary(row) }}</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 }">
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</template>
</el-table-column>
<el-table-column label="最近决策" min-width="320">
<template #default="{ row }">
<div class="decision-cell">
<div class="decision-cell__head">
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ autoStartDecisionSummary(row) }}</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>
<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">
<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>
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</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 }">
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</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>
.recovery-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.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>