feat: improve recording recovery and upload workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user