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

453 lines
14 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 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>