feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user