<script lang="ts" setup>
|
import type { BiDashboardApi } from '#/api/bi/dashboard';
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { useRoute } from 'vue-router';
|
|
import { IconifyIcon } from '@vben/icons';
|
|
import { Empty, Spin } from 'ant-design-vue';
|
|
import { getDashboardData } from '#/api/bi/dashboard';
|
|
import ChartCard from './modules/ChartCard.vue';
|
import ClockWidget from './modules/ClockWidget.vue';
|
import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data';
|
|
defineOptions({ name: 'BiDashboard' });
|
|
const route = useRoute();
|
const dashboardCode = computed(() => getDashboardCode(route.path));
|
const meta = computed(() => DASHBOARD_META[dashboardCode.value]);
|
|
const charts = ref<BiDashboardApi.ChartItem[]>([]);
|
const loading = ref(true);
|
const lastUpdateTime = ref('');
|
|
async function loadData() {
|
if (!meta.value) return;
|
try {
|
loading.value = true;
|
const data = await getDashboardData(meta.value.code);
|
charts.value = data.length > 0 ? data : getDefaultCharts();
|
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
} catch {
|
charts.value = getDefaultCharts();
|
} finally {
|
loading.value = false;
|
await nextTick();
|
resizeAllCharts();
|
}
|
}
|
|
// ======== 分类 ========
|
const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number'));
|
const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table'));
|
const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
|
|
// ======== 图表 Grid 列跨度 ========
|
function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
|
const w = chart.position?.w;
|
if (w && w >= 24) return true;
|
return chart.chartType === 'table';
|
}
|
|
function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
|
if (isFullWidth(chart)) return { 'grid-column': '1 / -1' };
|
return {};
|
}
|
|
// ======== 图表缩放 ========
|
const dashboardRef = ref<HTMLElement>();
|
let resizeObserver: ResizeObserver | null = null;
|
|
function resizeAllCharts() {
|
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
|
}
|
|
function setupResizeObserver() {
|
if (!dashboardRef.value) return;
|
resizeObserver = new ResizeObserver(() => resizeAllCharts());
|
resizeObserver.observe(dashboardRef.value);
|
}
|
|
// ======== 全屏 ========
|
const isFullscreen = ref(false);
|
async function toggleFullscreen() {
|
if (!document.fullscreenElement) {
|
await dashboardRef.value?.requestFullscreen();
|
isFullscreen.value = true;
|
} else {
|
await document.exitFullscreen();
|
isFullscreen.value = false;
|
}
|
setTimeout(resizeAllCharts, 400);
|
}
|
function onFullscreenChange() {
|
isFullscreen.value = !!document.fullscreenElement;
|
setTimeout(resizeAllCharts, 300);
|
}
|
|
// ======== KPI 数字动画 ========
|
const animatedIds = new Set<number>();
|
|
function animateValue(el: HTMLElement, end: number, decimals = 0) {
|
if (end === 0) {
|
el.textContent = '0';
|
return;
|
}
|
const duration = 1800;
|
const startTime = performance.now();
|
function update(currentTime: number) {
|
const progress = Math.min((currentTime - startTime) / duration, 1);
|
const eased = 1 - (1 - progress) ** 3;
|
el.textContent = (end * eased).toFixed(decimals);
|
if (progress < 1) requestAnimationFrame(update);
|
}
|
requestAnimationFrame(update);
|
}
|
|
function maybeAnimate(el: HTMLElement, cardId: number, value: number) {
|
if (animatedIds.has(cardId)) return;
|
animatedIds.add(cardId);
|
animateValue(el, value, 0);
|
}
|
|
watch(() => charts.value, () => {
|
animatedIds.clear();
|
});
|
|
function formatNumber(num: number): string {
|
if (num >= 1_0000_0000) return `${(num / 1_0000_0000).toFixed(1)}亿`;
|
if (num >= 1_0000) return `${(num / 1_0000).toFixed(1)}万`;
|
if (Number.isInteger(num)) return num.toLocaleString();
|
return num.toFixed(2);
|
}
|
|
function extractNumberValue(record: Record<string, unknown>): number {
|
return (Object.values(record).find((v): v is number => typeof v === 'number') || 0) as number;
|
}
|
|
function getCardDisplayValue(card: BiDashboardApi.ChartItem): string {
|
if (!card.data?.[0]) return '--';
|
const v = extractNumberValue(card.data[0] as Record<string, unknown>);
|
return formatNumber(v);
|
}
|
|
function getCellValue(row: unknown, col: string): unknown {
|
return (row as Record<string, unknown>)[col];
|
}
|
|
// ======== 看板主题色 ========
|
const dashboardAccentColors: Record<string, string> = {
|
'purchase-sales': '#6366f1',
|
'production-equipment': '#f59e0b',
|
quality: '#f43f5e',
|
'hr-oa': '#8b5cf6',
|
};
|
const accentColor = computed(() => dashboardAccentColors[dashboardCode.value] || '#00E5FF');
|
|
// ======== KPI 图标/颜色 ========
|
function getKpiIcon(name: string): string {
|
if (name.includes('采购')) return 'lucide:shopping-cart';
|
if (name.includes('销售')) return 'lucide:trending-up';
|
if (name.includes('售后')) return 'lucide:headphones';
|
if (name.includes('生产')) return 'lucide:factory';
|
if (name.includes('设备')) return 'lucide:cpu';
|
if (name.includes('质量') || name.includes('检验') || name.includes('质检')) return 'lucide:shield-check';
|
if (name.includes('人力') || name.includes('员工') || name.includes('人事')) return 'lucide:users';
|
if (name.includes('考勤') || name.includes('打卡')) return 'lucide:clock';
|
if (name.includes('合同') || name.includes('协同')) return 'lucide:file-text';
|
if (name.includes('订单')) return 'lucide:clipboard-list';
|
if (name.includes('库存') || name.includes('仓库')) return 'lucide:package';
|
return 'lucide:bar-chart-4';
|
}
|
|
const kpiColors = ['#00E5FF', '#00FF88', '#FFC107', '#FF3860', '#8B5CF6', '#6366f1'];
|
function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; }
|
|
watch(dashboardCode, () => loadData());
|
onMounted(() => {
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
setupResizeObserver();
|
loadData();
|
});
|
onBeforeUnmount(() => {
|
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
resizeObserver?.disconnect();
|
});
|
</script>
|
|
<template>
|
<div
|
ref="dashboardRef"
|
class="bi-dashboard"
|
:class="{ 'is-fullscreen': isFullscreen }"
|
:style="{ '--bi-accent': accentColor }"
|
>
|
<!-- ======== 头部 ======== -->
|
<header v-if="meta" class="dash-header">
|
<div class="dash-header-left">
|
<div class="dash-logo">
|
<IconifyIcon :icon="meta.icon" class="text-xl" />
|
</div>
|
<div class="dash-title-group">
|
<h1>{{ meta.title }}</h1>
|
<p>{{ meta.subtitle }}</p>
|
</div>
|
</div>
|
<div class="dash-header-divider" />
|
<div class="dash-header-right">
|
<div class="dash-status-tags">
|
<span class="dash-status-tag">
|
<i class="dash-status-dot online" /> 系统正常
|
</span>
|
<span class="dash-status-tag">
|
<i class="dash-status-dot" /> 数据更新
|
</span>
|
</div>
|
<ClockWidget />
|
<div class="dash-live-badge">
|
<span class="dash-live-dot" />
|
<span>实时</span>
|
<span v-if="lastUpdateTime" class="dash-live-time">· {{ lastUpdateTime }}</span>
|
</div>
|
<button
|
class="dash-fs-btn"
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
@click="toggleFullscreen"
|
>
|
<IconifyIcon :icon="isFullscreen ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
</button>
|
</div>
|
</header>
|
|
<!-- ======== 加载 ======== -->
|
<Spin v-if="loading && charts.length === 0" :spinning="true" tip="加载仪表盘中...">
|
<div style="height: 400px" />
|
</Spin>
|
|
<!-- ======== 仪表盘内容 ======== -->
|
<template v-else>
|
<!-- KPI 卡片行 -->
|
<div v-if="numberCards.length > 0" class="kpi-row">
|
<div
|
v-for="(card, i) in numberCards"
|
:key="card.id"
|
class="kpi-card"
|
:style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }"
|
>
|
<div class="kpi-icon">
|
<IconifyIcon :icon="getKpiIcon(card.name)" />
|
</div>
|
<div class="kpi-body">
|
<span class="kpi-label">{{ card.name }}</span>
|
<span
|
class="kpi-value"
|
:ref="(el: unknown) => {
|
if (el && card.data?.[0]) {
|
const v = extractNumberValue(card.data[0] as Record<string, unknown>);
|
maybeAnimate(el as HTMLElement, card.id!, v);
|
}
|
}"
|
>{{ getCardDisplayValue(card) }}</span>
|
</div>
|
</div>
|
</div>
|
|
<!-- 图表 Grid -->
|
<div v-if="echartsCharts.length > 0" class="chart-row">
|
<div
|
v-for="(chart, i) in echartsCharts"
|
:key="chart.id"
|
:style="{ ...getGridStyle(chart), animationDelay: `${i * 0.08}s` }"
|
>
|
<ChartCard :chart="chart" :accent-color="accentColor" />
|
</div>
|
</div>
|
|
<!-- 数据表格 -->
|
<template v-if="tableCharts.length > 0">
|
<div
|
v-for="chart in tableCharts"
|
:key="chart.id"
|
class="table-card"
|
>
|
<div class="table-header">
|
<span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
|
<span class="table-header-title">{{ chart.name }}</span>
|
<span class="table-badge">实时滚动</span>
|
</div>
|
<div v-if="chart.data && chart.data.length > 0" class="table-wrap">
|
<table class="data-table">
|
<thead>
|
<tr>
|
<th
|
v-for="key in Object.keys(chart.data[0] || {})"
|
:key="key"
|
>{{ key }}</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr
|
v-for="(row, ri) in chart.data"
|
:key="ri"
|
:style="{ animationDelay: `${ri * 50}ms` }"
|
>
|
<td
|
v-for="key in Object.keys(chart.data[0] || {})"
|
:key="key"
|
>{{ getCellValue(row, key) }}</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
<Empty v-else description="暂无数据" />
|
</div>
|
</template>
|
|
<!-- 空状态 -->
|
<div v-if="charts.length === 0" class="empty-state">
|
<IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" />
|
<p>暂未配置图表数据</p>
|
<p class="empty-sub">请在配置管理中为当前仪表盘添加图表</p>
|
</div>
|
</template>
|
</div>
|
</template>
|
|
<style scoped>
|
/* ======== CSS 变量 ======== */
|
.bi-dashboard {
|
--bg-deep: #020817;
|
--bg-card: rgba(10, 24, 52, 0.55);
|
--bg-card-hover: rgba(16, 34, 68, 0.72);
|
--border: rgba(0, 229, 255, 0.2);
|
--border-hover: rgba(0, 229, 255, 0.4);
|
--cyan: #00E5FF;
|
--green: #00FF88;
|
--yellow: #FFC107;
|
--red: #FF3860;
|
--text-primary: rgba(235, 240, 252, 0.95);
|
--text-secondary: rgba(185, 196, 220, 0.72);
|
--text-muted: rgba(145, 158, 185, 0.5);
|
|
position: relative;
|
background:
|
radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%);
|
color: var(--text-primary);
|
padding: 16px 20px 24px;
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
overflow-x: hidden;
|
overflow-y: auto;
|
max-height: calc(100vh - 104px);
|
}
|
|
/* 背景光晕 + 网格 */
|
.bi-dashboard::before {
|
content: '';
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
background:
|
radial-gradient(ellipse 50% 45% at 50% 5%, rgba(0, 229, 255, 0.12) 0%, transparent 50%),
|
radial-gradient(ellipse 35% 30% at 20% 75%, rgba(0, 255, 136, 0.07) 0%, transparent 55%),
|
radial-gradient(ellipse 30% 30% at 80% 65%, rgba(139, 92, 246, 0.08) 0%, transparent 55%),
|
radial-gradient(ellipse 25% 25% at 50% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 60%);
|
animation: bg-breathe 10s ease-in-out infinite alternate;
|
}
|
|
@keyframes bg-breathe {
|
0% { opacity: 0.7; }
|
100% { opacity: 1; }
|
}
|
|
.bi-dashboard::after {
|
content: '';
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
background-image:
|
linear-gradient(rgba(0, 229, 255, 0.03) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(0, 229, 255, 0.03) 1px, transparent 1px);
|
background-size: 64px 64px;
|
mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%);
|
-webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%);
|
}
|
|
/* ======== 头部 ======== */
|
.dash-header {
|
position: relative; z-index: 1;
|
display: flex; align-items: center; gap: 16px;
|
padding-bottom: 14px; margin-bottom: 16px;
|
border-bottom: 1px solid rgba(0,229,255,0.08);
|
}
|
|
.dash-header-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
|
.dash-logo {
|
display: flex; align-items: center; justify-content: center;
|
width: 42px; height: 42px;
|
background: linear-gradient(135deg, rgba(0,229,255,0.25), rgba(0,229,255,0.08));
|
border: 1px solid rgba(0,229,255,0.25);
|
border-radius: 10px; color: var(--cyan);
|
box-shadow: 0 0 24px rgba(0,229,255,0.18), inset 0 1px 0 rgba(255,255,255,0.05);
|
}
|
|
.dash-title-group h1 {
|
font-size: 18px; font-weight: 700; letter-spacing: 1px;
|
background: linear-gradient(90deg, #fff, var(--cyan));
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
background-clip: text;
|
}
|
|
.dash-title-group p {
|
font-size: 11px; color: var(--text-secondary); margin-top: 2px; letter-spacing: 0.5px;
|
}
|
|
.dash-header-divider {
|
flex: 1; height: 1px;
|
background: linear-gradient(90deg, transparent, rgba(0,229,255,0.15), transparent);
|
}
|
|
.dash-header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
|
.dash-status-tags { display: flex; gap: 8px; }
|
|
.dash-status-tag {
|
display: flex; align-items: center; gap: 5px;
|
font-size: 11px; color: var(--text-secondary);
|
padding: 3px 10px; background: rgba(0,229,255,0.06);
|
border-radius: 12px; border: 1px solid rgba(0,229,255,0.1);
|
}
|
|
.dash-status-dot {
|
width: 5px; height: 5px; border-radius: 50%; background: var(--text-muted);
|
}
|
.dash-status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
.dash-live-badge {
|
display: flex; align-items: center; gap: 5px;
|
font-size: 11px; color: var(--text-secondary);
|
padding: 3px 10px; background: rgba(0,229,255,0.06);
|
border-radius: 12px; border: 1px solid rgba(0,229,255,0.1);
|
}
|
|
.dash-live-dot {
|
width: 5px; height: 5px; border-radius: 50%;
|
background: #22c55e; box-shadow: 0 0 6px #22c55e;
|
animation: live-pulse 2s ease-in-out infinite;
|
}
|
@keyframes live-pulse {
|
0%, 100% { opacity: 1; }
|
50% { opacity: 0.3; }
|
}
|
|
.dash-live-time { color: var(--text-muted); }
|
|
.dash-fs-btn {
|
display: flex; align-items: center; justify-content: center;
|
width: 32px; height: 32px; border-radius: 6px;
|
border: 1px solid rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.02);
|
color: var(--text-secondary); cursor: pointer;
|
transition: all 0.2s;
|
}
|
.dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); }
|
|
/* ======== KPI 行 ======== */
|
.kpi-row {
|
position: relative; z-index: 1;
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
gap: 12px; margin-bottom: 16px;
|
}
|
|
.kpi-card {
|
display: flex; align-items: center; gap: 12px;
|
padding: 14px 16px;
|
background: var(--bg-card);
|
backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
border: 1px solid var(--border);
|
border-radius: 10px;
|
cursor: default;
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both;
|
min-width: 0;
|
position: relative;
|
overflow: hidden;
|
}
|
.kpi-card::after {
|
content: '';
|
position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
background: linear-gradient(90deg, transparent, var(--kpi-color, var(--cyan)), transparent);
|
opacity: 0.35;
|
}
|
.kpi-card:hover {
|
border-color: var(--kpi-color, var(--border-hover));
|
box-shadow:
|
0 0 20px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent),
|
0 0 40px color-mix(in srgb, var(--kpi-color, var(--cyan)) 8%, transparent),
|
0 4px 20px rgba(0, 0, 0, 0.35);
|
transform: translateY(-2px);
|
}
|
|
.kpi-icon {
|
display: flex; align-items: center; justify-content: center;
|
width: 40px; height: 40px; flex-shrink: 0;
|
border-radius: 8px;
|
background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent);
|
color: var(--kpi-color, var(--cyan));
|
font-size: 18px;
|
box-shadow: 0 0 12px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent);
|
}
|
|
.kpi-body {
|
flex: 1; min-width: 0;
|
display: flex; flex-direction: column; gap: 4px;
|
}
|
|
.kpi-label {
|
font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
}
|
|
.kpi-value {
|
font-size: 24px; font-weight: 800; line-height: 1;
|
font-variant-numeric: tabular-nums;
|
color: var(--text-primary);
|
}
|
|
@keyframes kpi-fade-up {
|
from { opacity: 0; transform: translateY(16px); }
|
to { opacity: 1; transform: translateY(0); }
|
}
|
|
/* ======== 图表行 ======== */
|
.chart-row {
|
position: relative; z-index: 1;
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
gap: 12px;
|
margin-bottom: 16px;
|
}
|
|
.chart-row > div {
|
min-width: 0;
|
animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
|
}
|
|
/* ======== 表格卡片 ======== */
|
.table-card {
|
position: relative; z-index: 1;
|
background: var(--bg-card);
|
backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
border: 1px solid var(--border);
|
border-radius: 10px;
|
padding: 14px;
|
margin-bottom: 16px;
|
transition: all 0.35s;
|
animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
|
}
|
.table-card::before {
|
content: '';
|
position: absolute; inset: 0; border-radius: 10px; pointer-events: none;
|
background: linear-gradient(135deg, rgba(0, 229, 255, 0.04) 0%, transparent 50%, rgba(0, 255, 136, 0.03) 100%);
|
}
|
.table-card:hover {
|
border-color: var(--border-hover);
|
box-shadow: 0 0 24px rgba(0, 229, 255, 0.06), 0 0 48px rgba(0, 229, 255, 0.03);
|
}
|
|
.table-header {
|
display: flex; align-items: center; gap: 8px;
|
margin-bottom: 10px;
|
}
|
|
.table-header-dot {
|
width: 6px; height: 6px; border-radius: 50%;
|
flex-shrink: 0;
|
}
|
|
.table-header-title {
|
font-size: 13px; font-weight: 600;
|
color: var(--text-primary);
|
letter-spacing: 0.5px;
|
}
|
|
.table-badge {
|
margin-left: auto;
|
font-size: 10px; font-weight: 400;
|
padding: 2px 8px; border-radius: 10px;
|
background: rgba(0,255,136,0.08); color: var(--green);
|
border: 1px solid rgba(0,255,136,0.15);
|
}
|
|
.table-wrap {
|
overflow-x: auto; border-radius: 8px;
|
background: rgba(4, 14, 36, 0.55);
|
backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
border: 1px solid rgba(0, 229, 255, 0.08);
|
}
|
|
.data-table {
|
width: 100%; border-collapse: collapse; font-size: 12px;
|
}
|
|
.data-table thead th {
|
background: rgba(6, 18, 42, 0.8);
|
color: var(--text-secondary);
|
font-weight: 600; font-size: 10px;
|
text-transform: uppercase; letter-spacing: 0.8px;
|
padding: 10px 14px; text-align: left;
|
border-bottom: 1px solid rgba(0, 229, 255, 0.12);
|
white-space: nowrap;
|
}
|
|
.data-table tbody td {
|
padding: 9px 14px; color: var(--text-primary);
|
border-bottom: 1px solid rgba(0, 229, 255, 0.04);
|
white-space: nowrap;
|
}
|
|
.data-table tbody tr {
|
transition: all 0.25s;
|
opacity: 0;
|
animation: row-fade-in 0.45s ease forwards;
|
}
|
|
.data-table tbody tr:hover {
|
background: rgba(0, 229, 255, 0.06);
|
box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03);
|
}
|
|
@keyframes row-fade-in {
|
from { opacity: 0; transform: translateY(6px); }
|
to { opacity: 1; transform: translateY(0); }
|
}
|
|
/* ======== 空状态 ======== */
|
.empty-state {
|
position: relative; z-index: 1;
|
display: flex; flex-direction: column; align-items: center;
|
justify-content: center; padding: 60px 0;
|
color: var(--text-muted);
|
}
|
.empty-state p { margin-top: 12px; }
|
.empty-sub { font-size: 12px; color: rgba(140, 152, 180, 0.35); }
|
|
/* ======== 全屏 ======== */
|
.is-fullscreen {
|
max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto;
|
}
|
.is-fullscreen::before,
|
.is-fullscreen::after {
|
position: fixed;
|
}
|
.is-fullscreen .kpi-row { gap: 16px; }
|
.is-fullscreen .chart-row { gap: 16px; }
|
.is-fullscreen .dash-header { margin-bottom: 24px; }
|
|
/* ======== 加载态/空状态 ======== */
|
:deep(.ant-spin-text) { color: var(--text-secondary); }
|
:deep(.ant-empty) { color: var(--text-muted); }
|
:deep(.ant-empty-description) { color: var(--text-muted); }
|
|
/* ======== 响应式 ======== */
|
@media (max-width: 768px) {
|
.bi-dashboard { padding: 10px; }
|
.dash-header { flex-wrap: wrap; }
|
.dash-header-divider { display: none; }
|
.kpi-row { grid-template-columns: 1fr 1fr; }
|
.chart-row { grid-template-columns: 1fr; }
|
.chart-row > div { grid-column: 1 / -1 !important; }
|
.kpi-value { font-size: 20px; }
|
}
|
</style>
|