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

321 lines
19 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 { useRouter } from "vue-router";
import {
ArrowRight,
Film,
FolderOpened,
Plus,
Refresh,
Upload,
Warning
} from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
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();
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 attentionCount = computed(() => {
if (!data.value) return 0;
return Number(data.value.currentErrorCount > 0)
+ Number(data.value.storageStatus.tier !== "Green")
+ Number(queueTotal.value > 0);
});
const hasAttention = computed(() => attentionCount.value > 0);
const activeSessions = computed(() => data.value?.recentSessions.filter((item) => [1, 2, 3, 7].includes(item.status)).slice(0, 2) ?? []);
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours} 小时 ${minutes} 分`;
return `${Math.max(1, minutes)} 分钟`;
}
function formatTimer(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return "--:--:--";
const hours = Math.floor(seconds / 3600).toString().padStart(2, "0");
const minutes = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0");
const remainder = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${hours}:${minutes}:${remainder}`;
}
function formatDataSize(bytes?: number) {
if (typeof bytes !== "number" || bytes < 0) return "--";
if (bytes < 1024) return `${bytes} B`;
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(1)} GB`;
}
function formatDate(value?: string) {
if (!value) return "--";
return new Date(value).toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function formatRelativeHint(value?: string | null) {
if (!value) return "暂无等待中的任务";
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
const minutes = Math.floor(elapsed / 60_000);
if (minutes < 1) return "最早任务刚刚进入队列";
if (minutes < 60) return `最早任务已等待 ${minutes} 分钟`;
return `最早任务已等待 ${Math.floor(minutes / 60)} 小时`;
}
function sessionStatus(session: DashboardRecentSession) {
return sessionStatusLabelMap[session.status] ?? "未知";
}
function roomInitial(session: DashboardRecentSession) {
return session.liveRoomTitle.trim().slice(0, 1) || "播";
}
async function loadData() {
loading.value = true;
loadError.value = "";
try {
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
data.value = result;
} catch (error) {
loadError.value = getApiErrorMessage(error, "运行中心数据加载失败。");
} finally {
loading.value = false;
}
}
onMounted(loadData);
</script>
<template>
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">OPERATIONS</div>
<h1 class="page-title">运行中心</h1>
<p class="page-subtitle">优先处理异常和积压再关注正在进行的录制与今日产出</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新状态</el-button>
<el-button type="primary" :icon="Plus" @click="router.push({ name: 'live-rooms', query: { action: 'create' } })">添加直播间</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="8" />
<template v-else-if="data">
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
<span class="attention-bar__icon"><el-icon><Warning /></el-icon></span>
<span class="attention-bar__copy">
<strong> {{ attentionCount }} 项需要关注</strong>
<span>
最近 30 分钟 {{ data.currentErrorCount }} 个异常{{ queueTotal }} 个处理任务等待完成
存储状态{{ data.storageStatus.tier === "Green" ? "正常" : "已触发预警" }}
</span>
</span>
<el-button size="small" @click="router.push({ name: queueTotal > 0 ? 'upload-tasks' : 'logs' })">立即处理</el-button>
</section>
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
<span> 24 小时共记录 {{ data.recentErrorCount }} 个历史异常当前状态以最近 30 分钟的 {{ data.currentErrorCount }} 个异常为准</span>
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<div class="primary-grid">
<section class="live-hero" aria-label="当前录制概况">
<div class="live-hero__top">
<span class="live-hero__label"><i class="live-pulse" />正在录制</span>
<span class="live-hero__badge">{{ data.activeRecordingCount }} 路活跃</span>
</div>
<div class="live-hero__count">{{ String(data.activeRecordingCount).padStart(2, "0") }}<small>当前录制会话</small></div>
<div v-if="activeSessions.length" class="active-session-list">
<button
v-for="session in activeSessions"
:key="session.id"
class="active-session"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<span class="active-session__avatar">{{ roomInitial(session) }}</span>
<span class="active-session__copy">
<strong>{{ session.liveRoomTitle }}</strong>
<span><PlatformMark :name="session.platformName" /> · {{ session.segmentCount }} 个分片</span>
</span>
<time>{{ formatTimer(session.durationSeconds) }}</time>
</button>
</div>
<div v-else class="live-hero__empty">
{{ data.activeRecordingCount > 0 ? "活动会话将在下一次状态刷新后显示" : "当前没有正在录制的直播间" }}
</div>
</section>
<el-card class="surface-card process-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<div><h2>处理概况</h2><p>录制后的自动化流水线</p></div>
<el-button link @click="router.push({ name: 'transcode-tasks' })">查看队列</el-button>
</div>
<div class="process-list">
<button class="process-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
<span class="process-row__icon"><el-icon><Film /></el-icon></span>
<span><strong>等待转码</strong><small>{{ formatRelativeHint(data.oldestTranscodeUpdatedAt) }}</small></span>
<b>{{ data.pendingTranscodeCount }}</b>
</button>
<button class="process-row" type="button" @click="router.push({ name: 'upload-tasks' })">
<span class="process-row__icon"><el-icon><Upload /></el-icon></span>
<span><strong>等待上传</strong><small>{{ data.stalledUploadCount ?? 0 }} 个停滞 · {{ data.uploadCleanupFailureCount ?? 0 }} 个待清理</small></span>
<b>{{ data.pendingUploadCount }}</b>
</button>
<button class="process-row" type="button" @click="router.push({ name: 'media-browser' })">
<span class="process-row__icon"><el-icon><FolderOpened /></el-icon></span>
<span><strong>今日产出</strong><small>{{ formatDuration(data.todayRecordingSeconds) }} · {{ data.todayDanmakuCount.toLocaleString() }} 条弹幕</small></span>
<b>{{ formatDataSize(data.todayDataBytes) }}</b>
</button>
</div>
</el-card>
</div>
<div class="secondary-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<div><h2>存储容量</h2><p>录制目录与保护阈值</p></div>
</div>
<StorageCapacity :status="data.storageStatus" />
</el-card>
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<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.slice(0, 4)"
v-else
:key="session.id"
class="activity-row"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<i class="activity-row__dot" :class="{ 'is-error': session.status === 5, 'is-warn': [0, 1, 3, 7].includes(session.status) }" />
<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>
</div>
<el-card class="surface-card ranking-card" shadow="never">
<div class="panel-heading panel-heading--bordered">
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
<el-button link @click="router.push({ name: 'live-rooms' })">直播间 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
</div>
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
<div v-else class="ranking-list">
<button v-for="(room, index) in data.topRooms" :key="room.liveRoomId" class="ranking-row" type="button" @click="router.push({ name: 'live-rooms' })">
<span class="rank" :class="`rank--${index + 1}`">{{ index + 1 }}</span>
<span class="activity-row__main">
<strong>{{ room.title || room.anchorName || room.roomId }}</strong>
<small><PlatformMark :name="room.platformName" /> · {{ room.sessionCount }} 个会话</small>
</span>
<span class="ranking-row__duration">{{ formatDuration(room.totalDurationSeconds) }}</span>
</button>
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.dashboard-page { gap: 14px; }
.attention-bar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--warning) 27%, var(--border-subtle)); border-radius: 11px; background: var(--warning-soft); }
.attention-bar__icon { width: 32px; height: 32px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--warning); background: var(--surface); }
.attention-bar__icon .el-icon { display: grid; place-items: center; }
.attention-bar__copy { display: grid; min-width: 0; gap: 2px; }
.attention-bar__copy strong { font-size: 12.5px; }
.attention-bar__copy > span { color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
.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: 11.5px; line-height: 1.5; }
.primary-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(290px, .72fr); gap: 14px; }
.secondary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.panel-heading--bordered { margin: -18px -18px 0; padding: 17px 18px; border-bottom: 1px solid var(--border-subtle); }
.panel-heading h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
.panel-heading p { margin: 4px 0 0; color: var(--text-muted); font-size: 11px; }
.live-hero { position: relative; min-height: 282px; overflow: hidden; padding: 20px; border-radius: var(--radius-md); color: #f7f9ff; background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .22), transparent 31%), linear-gradient(135deg, #185ecf, #3f67d8 54%, #6658ce); box-shadow: 0 16px 38px rgba(24, 94, 207, .22); }
:global(html[data-theme="dark"]) .live-hero { background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .16), transparent 31%), linear-gradient(135deg, #17498f, #304f9d 54%, #56469f); box-shadow: 0 16px 38px rgba(0, 0, 0, .28); }
.live-hero::after { content: ""; position: absolute; right: -65px; top: -90px; width: 260px; height: 260px; border: 45px solid rgba(255,255,255,.07); border-radius: 50%; pointer-events: none; }
.live-hero__top { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.live-hero__label { display: flex; align-items: center; gap: 8px; color: #d7e3ff; font-size: 11px; font-weight: 700; }
.live-pulse { width: 8px; height: 8px; border-radius: 50%; background: #ff8b94; box-shadow: 0 0 0 5px rgba(255, 139, 148, .18); }
.live-hero__badge { padding: 4px 9px; border-radius: 99px; color: #ffe8ec; background: rgba(255, 139, 148, .18); font-size: 11px; font-weight: 700; }
.live-hero__count { position: relative; z-index: 1; margin-top: 15px; font-size: 38px; line-height: 1; font-weight: 800; letter-spacing: -.05em; }
.live-hero__count small { margin-left: 7px; color: #d7e3ff; font-size: 12px; font-weight: 500; letter-spacing: 0; }
.active-session-list { position: relative; z-index: 1; display: grid; }
.active-session { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 15px 0; border: 0; border-top: 1px solid rgba(255,255,255,.22); color: inherit; background: transparent; text-align: left; }
.active-session:first-child { margin-top: 18px; }
.active-session:hover strong { text-decoration: underline; text-underline-offset: 3px; }
.active-session__avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #244fa9; background: #e8efff; font-size: 13px; font-weight: 800; }
.active-session__copy { display: grid; min-width: 0; gap: 4px; }
.active-session__copy strong { overflow: hidden; color: #f7f9ff; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.active-session__copy > span { display: flex; align-items: center; color: #d7e3ff; font-size: 10px; }
.active-session__copy :deep(.platform-mark) { color: inherit; }
.active-session time { color: #f7f9ff; font-family: var(--font-mono); font-size: 15px; font-weight: 700; }
.live-hero__empty { position: relative; z-index: 1; margin-top: 32px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.22); color: #d7e3ff; font-size: 12px; }
.process-card :deep(.el-card__body), .activity-card :deep(.el-card__body), .ranking-card :deep(.el-card__body), .storage-card :deep(.el-card__body) { display: grid; }
.process-list { display: grid; }
.process-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 14px 0; border: 0; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); background: transparent; text-align: left; }
.process-row:last-child { border-bottom: 0; }
.process-row__icon { width: 34px; height: 34px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
.process-row:nth-child(2) .process-row__icon { color: var(--purple); background: var(--purple-soft); }
.process-row:nth-child(3) .process-row__icon { color: var(--cyan); background: var(--cyan-soft); }
.process-row__icon .el-icon { display: grid; place-items: center; }
.process-row > span:nth-child(2) { display: grid; min-width: 0; gap: 2px; }
.process-row strong { font-size: 12px; }
.process-row small { overflow: hidden; color: var(--text-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.process-row b { max-width: 96px; overflow: hidden; font-size: 18px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.storage-card :deep(.storage-capacity) { padding-top: 18px; }
.activity-row, .ranking-row { display: flex; align-items: center; width: 100%; gap: 10px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
.activity-row:last-child, .ranking-row:last-child { border-bottom: 0; }
.activity-row:hover, .ranking-row:hover, .process-row:hover { color: var(--accent); }
.activity-row__dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--success); }
.activity-row__dot.is-warn { background: var(--warning); }
.activity-row__dot.is-error { background: var(--danger); }
.activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
.activity-row__main strong { overflow: hidden; color: var(--text-primary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.activity-row__main small { display: flex; align-items: center; min-width: 0; overflow: hidden; color: var(--text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.ranking-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
.rank { width: 26px; height: 26px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 7px; color: var(--text-muted); background: var(--surface-muted); font-size: 11px; font-weight: 800; }
.rank--1 { color: #8a5700; background: #fff1cb; }
.rank--2 { color: var(--accent); background: var(--accent-soft); }
.rank--3 { color: #8b4d2e; background: #f9e7dd; }
.ranking-row__duration { flex: 0 0 auto; color: var(--text-secondary); font-size: 11.5px; font-weight: 700; }
@media (max-width: 1100px) {
.primary-grid { grid-template-columns: 1fr; }
.live-hero { min-height: auto; }
.ranking-list { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.secondary-grid { grid-template-columns: 1fr; }
.attention-bar { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
.attention-bar .el-button { grid-column: 1 / -1; width: 100%; }
.history-note { align-items: flex-start; flex-direction: column; }
.live-hero { padding: 17px; }
.live-hero__count { font-size: 34px; }
.active-session time { font-size: 12px; }
}
@media (max-width: 420px) {
.header-actions { width: 100%; }
.header-actions .el-button { flex: 1; margin-left: 0; }
.active-session { grid-template-columns: auto minmax(0, 1fr); }
.active-session time { grid-column: 2; }
}
</style>