481 lines
15 KiB
Vue
481 lines
15 KiB
Vue
<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>
|