云
7 天以前 c2e8526f8f857be5e3a8c6f2b39c5efd424de33e
src/views/bi/dashboard/index.vue
@@ -12,6 +12,7 @@
import ChartCard from './modules/ChartCard.vue';
import ClockWidget from './modules/ClockWidget.vue';
import RealtimeTable from './modules/RealtimeTable.vue';
import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data';
defineOptions({ name: 'BiDashboard' });
@@ -23,27 +24,53 @@
const charts = ref<BiDashboardApi.ChartItem[]>([]);
const loading = ref(true);
const lastUpdateTime = ref('');
let dataRefreshTimer: ReturnType<typeof setInterval> | undefined;
let requestSequence = 0;
let fetching = false;
let disposed = false;
async function loadData() {
  if (!meta.value) return;
async function loadData(background = false) {
  if (!meta.value || disposed || (background && fetching)) return;
  const sequence = ++requestSequence;
  const code = meta.value.code;
  fetching = true;
  if (!background && dataRefreshTimer) clearInterval(dataRefreshTimer);
  try {
    loading.value = true;
    const data = await getDashboardData(meta.value.code);
    if (!background) loading.value = true;
    const data = await getDashboardData(code);
    if (disposed || sequence !== requestSequence) return;
    charts.value = data.length > 0 ? data : getDefaultCharts();
    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
  } catch {
    charts.value = getDefaultCharts();
    if (!background && !disposed && sequence === requestSequence) charts.value = getDefaultCharts();
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    if (!disposed && sequence === requestSequence) {
      fetching = false;
      loading.value = false;
      await nextTick();
      if (!disposed && sequence === requestSequence) {
        resizeAllCharts();
        if (!background) {
          const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0);
          if (intervals.length) dataRefreshTimer = setInterval(() => {
            if (!loading.value && document.visibilityState === 'visible') loadData(true);
          }, Math.max(15, Math.min(...intervals)) * 1000);
        }
      }
    }
  }
}
// ======== 分类 ========
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)));
const displayCharts = computed(() => {
  if (dashboardCode.value !== 'purchase-sales') return charts.value;
  return [...charts.value].sort((a, b) =>
    Number(b.chartOptions?.dashboardSection === 'crm') - Number(a.chartOptions?.dashboardSection === 'crm'),
  );
});
const numberCards = computed(() => displayCharts.value.filter((c) => c.chartType === 'number'));
const tableCharts = computed(() => displayCharts.value.filter((c) => c.chartType === 'table'));
const echartsCharts = computed(() => displayCharts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
// ======== 图表 Grid 列跨度 ========
function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
@@ -102,6 +129,7 @@
    const progress = Math.min((currentTime - startTime) / duration, 1);
    const eased = 1 - (1 - progress) ** 3;
    el.textContent = (end * eased).toFixed(decimals);
    if (progress === 1) el.textContent = formatNumber(end);
    if (progress < 1) requestAnimationFrame(update);
  }
  requestAnimationFrame(update);
@@ -110,7 +138,7 @@
function maybeAnimate(el: HTMLElement, cardId: number, value: number) {
  if (animatedIds.has(cardId)) return;
  animatedIds.add(cardId);
  animateValue(el, value, 0);
  animateValue(el, value, Number.isInteger(value) ? 0 : 2);
}
watch(() => charts.value, () => {
@@ -134,8 +162,16 @@
  return formatNumber(v);
}
// 大屏表格里的日期列只展示到天(形如 2026-09-01 10:23:45 的值切成 2026-09-01),
// 图表 SQL 里的 order_time / receipt_date / create_time / hire_date 都是带时分秒的
const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
function getCellValue(row: unknown, col: string): unknown {
  return (row as Record<string, unknown>)[col];
  const val = (row as Record<string, unknown>)[col];
  if (typeof val === 'string' && DATE_TIME_RE.test(val)) {
    return val.slice(0, 10);
  }
  return val;
}
// ======== 看板主题色 ========
@@ -149,6 +185,10 @@
// ======== KPI 图标/颜色 ========
function getKpiIcon(name: string): string {
  if (name.includes('客户')) return 'lucide:users';
  if (name.includes('商机')) return 'lucide:target';
  if (name.includes('回款')) return 'lucide:wallet';
  if (name.includes('报价')) return 'lucide:receipt-text';
  if (name.includes('采购')) return 'lucide:shopping-cart';
  if (name.includes('销售')) return 'lucide:trending-up';
  if (name.includes('售后')) return 'lucide:headphones';
@@ -173,6 +213,9 @@
  loadData();
});
onBeforeUnmount(() => {
  disposed = true;
  requestSequence++;
  if (dataRefreshTimer) clearInterval(dataRefreshTimer);
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
});
@@ -241,7 +284,7 @@
            <IconifyIcon :icon="getKpiIcon(card.name)" />
          </div>
          <div class="kpi-body">
            <span class="kpi-label">{{ card.name }}</span>
            <span class="kpi-label" :title="card.name">{{ card.name }}</span>
            <span
              class="kpi-value"
              :ref="(el: unknown) => {
@@ -278,30 +321,11 @@
            <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>
          <RealtimeTable
            v-if="chart.data && chart.data.length > 0"
            :rows="chart.data"
            :format-cell="getCellValue"
          />
          <Empty v-else description="暂无数据" />
        </div>
      </template>
@@ -506,7 +530,7 @@
.kpi-label {
  font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  white-space: normal; overflow-wrap: anywhere; line-height: 1.4;
}
.kpi-value {
@@ -579,50 +603,6 @@
  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); }
}
/* ======== 空状态 ======== */