321 lines
19 KiB
Vue
321 lines
19 KiB
Vue
<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>
|