| | |
| | | } |
| | | } |
| | | |
| | | // ======== 分类 ======== |
| | | 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))); |
| | | // ======== 12 列栅格自适应 ======== |
| | | const COMPACT_BREAKPOINT = 1180; |
| | | const isCompact = ref(false); |
| | | function updateCompact() { |
| | | isCompact.value = window.innerWidth < COMPACT_BREAKPOINT; |
| | | } |
| | | |
| | | // ======== 图表 Grid 列跨度 ======== |
| | | function isFullWidth(chart: BiDashboardApi.ChartItem): boolean { |
| | | const w = chart.position?.w; |
| | | if (w && w >= 24) return true; |
| | | function isNumberCard(chart: BiDashboardApi.ChartItem): boolean { |
| | | return chart.chartType === 'number'; |
| | | } |
| | | |
| | | function isTableCard(chart: BiDashboardApi.ChartItem): boolean { |
| | | return chart.chartType === 'table'; |
| | | } |
| | | |
| | | function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> { |
| | | if (isFullWidth(chart)) return { 'grid-column': '1 / -1' }; |
| | | return {}; |
| | | function chartGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> { |
| | | const pos = chart.position; |
| | | if (isCompact.value) { |
| | | // 窄屏:指标卡半宽两列排布,图表/表格全宽堆叠,避免挤压 |
| | | return isNumberCard(chart) || chart.chartType === 'gauge' |
| | | ? { gridColumn: 'auto / span 6' } |
| | | : { gridColumn: '1 / -1' }; |
| | | } |
| | | if (!pos) { |
| | | return { gridColumn: '1 / -1' }; |
| | | } |
| | | const colStart = Math.max(1, (pos.x || 0) + 1); |
| | | const colSpan = Math.min(12, Math.max(1, pos.w || 12)); |
| | | const rowStart = Math.max(1, (pos.y || 0) + 1); |
| | | const rowSpan = Math.max(1, pos.h || 1); |
| | | return { |
| | | gridColumn: `${colStart} / span ${colSpan}`, |
| | | gridRow: `${rowStart} / span ${rowSpan}`, |
| | | }; |
| | | } |
| | | |
| | | // ======== 图表缩放 ======== |
| | |
| | | |
| | | // ======== 看板主题色 ======== |
| | | const dashboardAccentColors: Record<string, string> = { |
| | | 'decision-overview': '#00E5FF', |
| | | energy: '#FFC107', |
| | | 'sales-customer': '#00FF88', |
| | | 'purchase-sales': '#6366f1', |
| | | 'production-equipment': '#f59e0b', |
| | | quality: '#f43f5e', |
| | |
| | | if (name.includes('合同') || name.includes('协同')) return 'lucide:file-text'; |
| | | if (name.includes('订单')) return 'lucide:clipboard-list'; |
| | | if (name.includes('库存') || name.includes('仓库')) return 'lucide:package'; |
| | | if (name.includes('能耗') || name.includes('电量') || name.includes('功率') || name.includes('用能')) return 'lucide:zap'; |
| | | if (name.includes('客户')) return 'lucide:users'; |
| | | return 'lucide:bar-chart-4'; |
| | | } |
| | | |
| | |
| | | function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; } |
| | | |
| | | watch(dashboardCode, () => loadData()); |
| | | watch(isCompact, () => setTimeout(resizeAllCharts, 120)); |
| | | onMounted(() => { |
| | | document.addEventListener('fullscreenchange', onFullscreenChange); |
| | | window.addEventListener('resize', updateCompact); |
| | | updateCompact(); |
| | | setupResizeObserver(); |
| | | loadData(); |
| | | }); |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | window.removeEventListener('resize', updateCompact); |
| | | resizeObserver?.disconnect(); |
| | | }); |
| | | </script> |
| | |
| | | <div style="height: 400px" /> |
| | | </Spin> |
| | | |
| | | <!-- ======== 仪表盘内容 ======== --> |
| | | <!-- ======== 12 列栅格布局 ======== --> |
| | | <template v-else> |
| | | <!-- KPI 卡片行 --> |
| | | <div v-if="numberCards.length > 0" class="kpi-row"> |
| | | <div v-if="charts.length > 0" class="bi-grid"> |
| | | <div |
| | | v-for="(card, i) in numberCards" |
| | | :key="card.id" |
| | | class="kpi-card" |
| | | :style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }" |
| | | v-for="(chart, i) in charts" |
| | | :key="chart.id" |
| | | class="bi-cell" |
| | | :style="chartGridStyle(chart)" |
| | | > |
| | | <div class="kpi-icon"> |
| | | <IconifyIcon :icon="getKpiIcon(card.name)" /> |
| | | <!-- 数字指标卡 --> |
| | | <div |
| | | v-if="isNumberCard(chart)" |
| | | class="kpi-card" |
| | | :style="{ '--kpi-color': getKpiColor(i) }" |
| | | > |
| | | <div class="kpi-icon"> |
| | | <IconifyIcon :icon="getKpiIcon(chart.name)" /> |
| | | </div> |
| | | <div class="kpi-body"> |
| | | <span class="kpi-label">{{ chart.name }}</span> |
| | | <span |
| | | class="kpi-value" |
| | | :ref="(el: unknown) => { |
| | | if (el && chart.data?.[0]) { |
| | | const v = extractNumberValue(chart.data[0] as Record<string, unknown>); |
| | | maybeAnimate(el as HTMLElement, chart.id!, v); |
| | | } |
| | | }" |
| | | >{{ getCardDisplayValue(chart) }}</span> |
| | | </div> |
| | | </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 v-else-if="isTableCard(chart)" 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> |
| | | |
| | | <!-- ECharts 图表 --> |
| | | <ChartCard v-else :chart="chart" :accent-color="accentColor" /> |
| | | </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"> |
| | | <div v-else class="empty-state"> |
| | | <IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" /> |
| | | <p>暂未配置图表数据</p> |
| | | <p class="empty-sub">请在配置管理中为当前仪表盘添加图表</p> |
| | |
| | | --text-muted: rgba(145, 158, 185, 0.5); |
| | | |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: |
| | | radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%); |
| | | color: var(--text-primary); |
| | | padding: 16px 20px 24px; |
| | | padding: 16px 20px 20px; |
| | | font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; |
| | | overflow-x: hidden; |
| | | overflow-y: auto; |
| | | max-height: calc(100vh - 104px); |
| | | height: calc(100vh - 104px); |
| | | min-height: 320px; |
| | | } |
| | | |
| | | /* 背景光晕 + 网格 */ |
| | |
| | | .dash-header { |
| | | position: relative; z-index: 1; |
| | | display: flex; align-items: center; gap: 16px; |
| | | flex-shrink: 0; |
| | | padding-bottom: 14px; margin-bottom: 16px; |
| | | border-bottom: 1px solid rgba(0,229,255,0.08); |
| | | } |
| | |
| | | } |
| | | .dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); } |
| | | |
| | | /* ======== KPI 行 ======== */ |
| | | .kpi-row { |
| | | /* ======== 12 列栅格 ======== */ |
| | | .bi-grid { |
| | | position: relative; z-index: 1; |
| | | flex: 1; |
| | | min-height: 0; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); |
| | | gap: 12px; margin-bottom: 16px; |
| | | grid-template-columns: repeat(12, minmax(0, 1fr)); |
| | | grid-auto-rows: minmax(36px, 1fr); |
| | | gap: 12px; |
| | | } |
| | | |
| | | .bi-cell { |
| | | min-width: 0; |
| | | min-height: 0; |
| | | animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | } |
| | | |
| | | .bi-cell > * { |
| | | height: 100%; |
| | | } |
| | | |
| | | /* ======== KPI 指标卡 ======== */ |
| | | .kpi-card { |
| | | display: flex; align-items: center; gap: 12px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 8px; |
| | | text-align: center; |
| | | padding: 14px 16px; |
| | | background: var(--bg-card); |
| | | backdrop-filter: blur(10px); |
| | |
| | | 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-icon { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 40px; height: 40px; flex-shrink: 0; |
| | | border-radius: 8px; |
| | | width: 42px; height: 42px; flex-shrink: 0; |
| | | border-radius: 10px; |
| | | background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent); |
| | | color: var(--kpi-color, var(--cyan)); |
| | | font-size: 18px; |
| | |
| | | } |
| | | |
| | | .kpi-body { |
| | | flex: 1; min-width: 0; |
| | | display: flex; flex-direction: column; gap: 4px; |
| | | display: flex; flex-direction: column; align-items: center; gap: 4px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .kpi-label { |
| | | font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px; |
| | | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .kpi-value { |
| | | font-size: 24px; font-weight: 800; line-height: 1; |
| | | font-size: 28px; font-weight: 800; line-height: 1.1; |
| | | font-variant-numeric: tabular-nums; |
| | | color: var(--text-primary); |
| | | } |
| | |
| | | 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; |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | transition: all 0.35s; |
| | | animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | position: relative; |
| | | } |
| | | .table-card::before { |
| | | content: ''; |
| | |
| | | } |
| | | |
| | | .table-wrap { |
| | | overflow-x: auto; border-radius: 8px; |
| | | flex: 1; |
| | | overflow-x: auto; overflow-y: auto; border-radius: 8px; |
| | | background: rgba(4, 14, 36, 0.55); |
| | | backdrop-filter: blur(6px); |
| | | -webkit-backdrop-filter: blur(6px); |
| | |
| | | .is-fullscreen::after { |
| | | position: fixed; |
| | | } |
| | | .is-fullscreen .kpi-row { gap: 16px; } |
| | | .is-fullscreen .chart-row { gap: 16px; } |
| | | .is-fullscreen .bi-grid { gap: 16px; } |
| | | .is-fullscreen .dash-header { margin-bottom: 24px; } |
| | | |
| | | /* ======== 加载态/空状态 ======== */ |
| | |
| | | .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; } |
| | | .kpi-value { font-size: 24px; } |
| | | } |
| | | </style> |
| | | </style> |