feat: harden recording lifecycle and refresh fnOS UI

This commit is contained in:
2026-08-03 23:45:26 +08:00
parent e5b50ea85c
commit ecc737f0bd
90 changed files with 6995 additions and 1826 deletions
+140 -188
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
import { Clock, House, Refresh, Upload, VideoCamera, Warning } from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { DashboardData } from "@/types";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import type { DashboardData, DashboardRecentSession } from "@/types";
import { sessionStatusLabelMap } from "@/types";
const router = useRouter();
@@ -12,45 +15,36 @@ const loading = ref(false);
const loadError = ref("");
const data = ref<DashboardData | null>(null);
const queueTotal = computed(() => (data.value?.pendingTranscodeCount ?? 0) + (data.value?.pendingUploadCount ?? 0));
const hasAttention = computed(() => Boolean(
data.value && (
data.value.currentErrorCount > 0 ||
data.value.storageStatus.tier !== "Green" ||
queueTotal.value > 0
)
));
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours} 小时 ${minutes}` : `${minutes} 分钟`;
}
function formatDataSize(bytes?: number) {
if (typeof bytes !== "number" || bytes <= 0) return "-";
if (typeof bytes !== "number" || bytes < 0) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
return value ? new Date(value).toLocaleString() : "--";
}
function sessionStatusTagType(status: number): "" | "success" | "warning" | "danger" | "info" {
if (status === 2) return "success";
if (status === 5) return "danger";
if (status === 4 || status === 6) return "info";
return "warning";
}
function storageTierTagType(): "success" | "warning" | "danger" {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "success";
if (tier === "Yellow") return "warning";
return "danger";
}
function storageTierLabel(): string {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "正常";
if (tier === "Yellow") return "警告";
return "紧急";
function sessionStatus(session: DashboardRecentSession) {
return sessionStatusLabelMap[session.status] ?? "未知";
}
async function loadData() {
@@ -70,179 +64,137 @@ onMounted(loadData);
</script>
<template>
<div class="page-stack">
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">系统概览</div>
<div class="page-kicker">运行中心</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">系统运行状态一览包含直播间录制会话弹幕和存储概况</p>
<p class="page-subtitle">先处理异常与积压再查看录制产出和最近活动</p>
</div>
<div class="header-actions">
<el-button @click="loadData" :loading="loading">刷新</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !data" animated :rows="6" />
<el-skeleton v-if="loading && !data" animated :rows="8" />
<template v-else-if="data">
<!-- KPI -->
<div class="stats-grid">
<MetricCard label="正在录制" :value="data.activeRecordingCount">
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
</MetricCard>
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" description="共接入">
<template #icon><el-icon :size="18"><House /></el-icon></template>
</MetricCard>
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
</MetricCard>
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
</MetricCard>
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
</MetricCard>
<MetricCard
label="24h 异常"
:value="data.recentErrorCount"
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
>
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
</MetricCard>
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
<div class="attention-bar__icon"><el-icon><Warning /></el-icon></div>
<div class="attention-bar__copy">
<strong>有需要关注的运行状态</strong>
<span>
最近 30 分钟 {{ data.currentErrorCount }} 个异常
{{ queueTotal }} 个处理任务等待完成存储状态为 {{ data.storageStatus.tier === "Green" ? "正常" : "受限" }}
</span>
</div>
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
</section>
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
24 小时共记录 {{ data.recentErrorCount }} 个历史异常最近 30 分钟为 {{ data.currentErrorCount }} 历史记录不代表系统当前仍有故障
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<!-- storage + queue -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div style="display:flex;align-items:center;gap:28px;margin-top:18px">
<div class="ring-wrap" :style="{ background: `conic-gradient(var(--success) ${Number(data.storageStatus.usagePercent.toFixed(1))}%, var(--surface-hover) 0)` }">
<div class="ring-inner">
<div class="ring-num">{{ data.storageStatus.usagePercent.toFixed(1) }}%</div>
<div class="ring-cap">已使用</div>
</div>
</div>
<div style="flex:1;display:grid;gap:14px">
<div>
<div style="font-size:12px;color:var(--text-muted)">可用空间</div>
<div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">
{{ formatDataSize(data.storageStatus.availableBytes) }}
<span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 总计</span>
</div>
</div>
<div style="display:flex;gap:24px">
<div><div style="font-size:12px;color:var(--text-muted)">水位线</div><el-tag :type="storageTierTagType()" size="small">{{ storageTierLabel() }}</el-tag></div>
<div><div style="font-size:12px;color:var(--text-muted)">说明</div><div style="font-size:13px;font-weight:500;color:var(--text-secondary)">{{ data.storageStatus.message || "-" }}</div></div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">处理队列</h3>
<p class="section-subtitle">待转码和待上传的文件积压情况</p>
<div style="display:grid;gap:14px;margin-top:18px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待转码</div><div style="font-size:12px;color:var(--text-muted)">FFmpeg 后处理队列</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingTranscodeCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待上传</div><div style="font-size:12px;color:var(--text-muted)">WebDAV / S3 / OpenList</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingUploadCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">积压数据量</div><div style="font-size:12px;color:var(--text-muted)">等待归档的本地文件总量</div></div>
<div style="font-size:20px;font-weight:700;font-variant-numeric:tabular-nums">{{ formatDataSize(data.queuedDataBytes) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="dashboard-metrics">
<MetricCard label="正在录制" :value="data.activeRecordingCount" description="当前活动录制会话" :icon="VideoCamera" />
<MetricCard label="在线直播间" :value="`${data.liveRoomCount} / ${data.totalRoomCount}`" description="在线 / 已接入" :icon="House" />
<MetricCard label="今日录制" :value="formatDuration(data.todayRecordingSeconds)" :description="`${formatDataSize(data.todayDataBytes)} · ${data.todayDanmakuCount.toLocaleString()} 条弹幕`" :icon="Clock" />
<MetricCard label="待处理" :value="queueTotal" :description="`${data.pendingTranscodeCount} 转码 · ${data.pendingUploadCount} 上传`" :icon="Upload" />
</div>
<!-- recent sessions + top rooms -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">最近会话</h3>
<p class="section-subtitle">最近创建的录制会话点击可跳转至详情</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.recentSessions" class="premium-table" size="small">
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="sessionStatusTagType(row.status)" size="small">
{{ sessionStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分片" width="60" prop="segmentCount" />
<el-table-column label="开始时间" width="160">
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">今日热门直播间</h3>
<p class="section-subtitle">今日录制时长最长的直播间Top 5</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.topRooms" class="premium-table" size="small">
<el-table-column label="直播间" min-width="130">
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
</el-table-column>
<el-table-column label="平台" width="90" prop="platformName" />
<el-table-column label="会话数" width="70" prop="sessionCount" />
<el-table-column label="录制时长" width="100">
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<div class="operations-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading">
<div><h2>存储空间</h2><p>输出目录实际容量与录制保护状态</p></div>
</div>
<StorageCapacity :status="data.storageStatus" />
</el-card>
<el-card class="surface-card queue-card" shadow="never">
<div class="panel-heading">
<div><h2>处理队列</h2><p>需要系统继续处理的本地文件</p></div>
</div>
<button class="queue-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
<span><strong>待转码</strong><small>FFmpeg 后处理</small></span><b>{{ data.pendingTranscodeCount }}</b>
</button>
<button class="queue-row" type="button" @click="router.push({ name: 'upload-tasks' })">
<span><strong>待上传</strong><small>远端归档</small></span><b>{{ data.pendingUploadCount }}</b>
</button>
<div v-if="data.stalledUploadCount > 0" class="queue-volume">
<span>上传停滞超过 60 分钟</span><strong>{{ data.stalledUploadCount }}</strong>
</div>
<div v-if="data.uploadCleanupFailureCount > 0" class="queue-volume">
<span>本地清理等待重试</span><strong>{{ data.uploadCleanupFailureCount }}</strong>
</div>
<div class="queue-volume"><span>积压数据量</span><strong>{{ formatDataSize(data.queuedDataBytes) }}</strong></div>
</el-card>
</div>
<div class="activity-grid">
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>最近录制</h2><p>最新创建的录制会话</p></div>
<el-button link @click="router.push({ name: 'record-tasks' })">查看全部</el-button>
</div>
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
<button
v-for="session in data.recentSessions"
v-else
:key="session.id"
class="activity-row"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<span class="activity-row__main"><strong>{{ session.liveRoomTitle }}</strong><small>{{ formatDate(session.startedAt) }} · {{ session.segmentCount }} 个分片</small></span>
<StatusBadge :label="sessionStatus(session)" :status="session.status" />
</button>
</el-card>
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
<el-button link @click="router.push({ name: 'live-rooms' })">直播间</el-button>
</div>
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
<button v-for="(room, index) in data.topRooms" v-else :key="room.liveRoomId" class="activity-row" type="button" @click="router.push({ name: 'live-rooms' })">
<span class="rank">{{ index + 1 }}</span>
<span class="activity-row__main"><strong>{{ room.title || room.anchorName || room.roomId }}</strong><small>{{ room.platformName }} · {{ room.sessionCount }} 个会话</small></span>
<span class="activity-row__value">{{ formatDuration(room.totalDurationSeconds) }}</span>
</button>
</el-card>
</div>
</template>
</div>
</template>
<style scoped>
.page-stack { display: grid; gap: var(--page-gap); }
.header-actions { align-self: center; }
.page-error-alert { border-radius: var(--radius-md); }
.ring-wrap {
--p: 32;
width: 92px; height: 92px; flex-shrink: 0;
border-radius: 99px; display: grid; place-items: center;
}
.ring-inner {
width: 70px; height: 70px; border-radius: 99px;
background: var(--surface); display: grid; place-items: center; text-align: center;
}
.ring-num { font-size: 20px; font-weight: 800; line-height: 1; }
.ring-cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
@media (max-width: 768px) {
.header-actions { width: 100%; }
.header-actions :deep(.el-button) { flex: 1; }
.ring-wrap { width: 68px; height: 68px; }
.ring-inner { width: 50px; height: 50px; }
.ring-num { font-size: 16px; }
}
.dashboard-page { gap: 18px; }
.attention-bar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--warning-soft); }
.attention-bar__icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; color: var(--warning); background: var(--surface); }
.attention-bar__copy { display: grid; flex: 1; min-width: 0; gap: 3px; }
.attention-bar__copy strong { font-size: 14px; }
.attention-bar__copy span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, .8fr); gap: 14px; }
.activity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.panel-heading h2 { margin: 0; font-size: 16px; }
.panel-heading p { margin: 5px 0 0; color: var(--text-muted); font-size: 12.5px; }
.queue-card :deep(.el-card__body), .activity-card :deep(.el-card__body) { display: grid; }
.queue-row, .activity-row { display: flex; align-items: center; width: 100%; gap: 12px; padding: 12px; border: 0; border-top: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
.queue-row:hover, .activity-row:hover { background: var(--surface-hover); }
.queue-row span, .activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
.queue-row small, .activity-row small { color: var(--text-muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-row b { font-size: 24px; font-variant-numeric: tabular-nums; }
.queue-volume { display: flex; justify-content: space-between; gap: 12px; padding: 14px 12px 0; color: var(--text-muted); font-size: 12px; }
.queue-volume strong { color: var(--text-primary); font-size: 14px; }
.activity-row__main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; }
.activity-row__value { flex: 0 0 auto; color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.rank { display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 auto; border-radius: 7px; background: var(--surface-muted); color: var(--text-muted); font-weight: 800; }
@media (max-width: 1100px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operations-grid { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .activity-grid { grid-template-columns: 1fr; } .attention-bar { align-items: flex-start; flex-wrap: wrap; } .attention-bar .el-button { width: 100%; } }
@media (max-width: 480px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } }
</style>