feat: improve recording recovery and upload workflow

This commit is contained in:
2026-08-10 11:14:34 +08:00
parent e0d3969e46
commit 19d358b312
58 changed files with 5979 additions and 586 deletions
+218 -98
View File
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Clock, House, Refresh, Upload, VideoCamera, Warning } from "@element-plus/icons-vue";
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 MetricCard from "@/components/ui/MetricCard.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";
@@ -16,19 +24,29 @@ 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
)
));
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);
return hours > 0 ? `${hours} 小时 ${minutes}` : `${minutes} 分钟`;
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) {
@@ -36,17 +54,31 @@ function formatDataSize(bytes?: number) {
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(2)} GB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "--";
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 = "";
@@ -54,7 +86,7 @@ async function loadData() {
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
data.value = result;
} catch (error) {
loadError.value = getApiErrorMessage(error, "仪表盘数据加载失败。");
loadError.value = getApiErrorMessage(error, "运行中心数据加载失败。");
} finally {
loading.value = false;
}
@@ -67,12 +99,13 @@ onMounted(loadData);
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">运行中心</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">先处理异常积压查看录制产出和最近活动</p>
<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 :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>
@@ -81,120 +114,207 @@ onMounted(loadData);
<template v-else-if="data">
<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 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" ? "正常" : "受限" }}
最近 30 分钟 {{ data.currentErrorCount }} 个异常{{ queueTotal }} 个处理任务等待完成
存储状态{{ data.storageStatus.tier === "Green" ? "正常" : "已触发预警" }}
</span>
</div>
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
</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">
24 小时共记录 {{ data.recentErrorCount }} 个历史异常最近 30 分钟为 {{ data.currentErrorCount }} 历史记录不代表系统当前仍有故障
<span> 24 小时共记录 {{ data.recentErrorCount }} 个历史异常当前状态以最近 30 分钟的 {{ data.currentErrorCount }} 个异常为准</span>
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<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 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="operations-grid">
<div class="secondary-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading">
<div><h2>存储空间</h2><p>输出目录实际容量与录制保护状态</p></div>
<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 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 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"
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>
<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>
<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: 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; } }
.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>