2 天以前 dc1d067c566bbde1c7170186960a8bd27f210a47
src/views/bi/dashboard/index.vue
@@ -40,21 +40,40 @@
  }
}
// ======== 分类 ========
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}`,
  };
}
// ======== 图表缩放 ========
@@ -140,6 +159,9 @@
// ======== 看板主题色 ========
const dashboardAccentColors: Record<string, string> = {
  'decision-overview': '#00E5FF',
  energy: '#FFC107',
  'sales-customer': '#00FF88',
  'purchase-sales': '#6366f1',
  'production-equipment': '#f59e0b',
  quality: '#f43f5e',
@@ -160,6 +182,8 @@
  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';
}
@@ -167,13 +191,17 @@
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>
@@ -227,87 +255,79 @@
      <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>
@@ -333,14 +353,17 @@
  --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;
}
/* 背景光晕 + 网格 */
@@ -375,6 +398,7 @@
.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);
}
@@ -451,16 +475,35 @@
}
.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);
@@ -469,8 +512,6 @@
  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;
}
@@ -491,8 +532,8 @@
.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;
@@ -500,17 +541,18 @@
}
.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);
}
@@ -520,32 +562,19 @@
  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: '';
@@ -582,7 +611,8 @@
}
.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);
@@ -643,8 +673,7 @@
.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; }
/* ======== 加载态/空状态 ======== */
@@ -657,9 +686,6 @@
  .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>