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