云
12 小时以前 973574fd62a1a130a1c229f812e70cfe3763b48e
feat(bi): 增加CRM数据展示功能并优化图表组件

- 在采购销售看板中集成CRM相关数据统计功能
- 新增水平柱状图选项支持,增强图表展示灵活性
- 添加多个CRM关键指标图表包括客户、商机、合同回款等
- 实现数据定时刷新机制以保持信息实时性
- 优化数字动画效果显示精度控制
- 改进图表排序逻辑按CRM与供应链分类
- 增加空数据状态提示提升用户体验
- 扩展KPI图标库匹配更多业务场景
- 添加SQL脚本实现CRM数据表结构初始化
已修改4个文件
61 ■■■■■ 文件已修改
src/views/bi/dashboard/chart-options.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/ChartCard.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/chart-options.ts
@@ -40,13 +40,17 @@
  xData: string[],
  series: Array<{ name: string; data: number[] }>,
  colors: string[] = [C.cyan, C.blue, C.purple],
  horizontal = false,
): EChartsOption {
  return {
    grid: { bottom: 40, left: 48, right: 16, top: 16 },
    grid: { bottom: 40, left: horizontal ? 140 : 48, right: 16, top: 16 },
    legend: { ...darkLegend, bottom: 0 },
    tooltip: { ...darkTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' },
    xAxis: { ...darkAxis, data: xData, type: 'category' },
    yAxis: { ...darkAxis, type: 'value' },
    xAxis: horizontal ? { ...darkAxis, type: 'value' } : { ...darkAxis, data: xData, type: 'category' },
    yAxis: horizontal ? {
      ...darkAxis, data: xData, type: 'category', inverse: true,
      axisLabel: { ...darkAxis.axisLabel, width: 125, overflow: 'truncate', interval: 0 },
    } : { ...darkAxis, type: 'value' },
    series: series.map((s, i) => ({
      barGap: '30%',
      barMaxWidth: 36,
src/views/bi/dashboard/data.ts
@@ -13,7 +13,7 @@
  'purchase-sales': {
    code: 'purchase_sales',
    title: '采购 · 销售 · 售后',
    subtitle: '供应链全链路经营决策看板',
    subtitle: '客户经营 · 商机跟进 · 合同回款 · 采购售后',
    gradient: 'from-indigo-600 via-blue-600 to-cyan-500',
    icon: 'lucide:shopping-cart',
  },
src/views/bi/dashboard/index.vue
@@ -23,27 +23,41 @@
const charts = ref<BiDashboardApi.ChartItem[]>([]);
const loading = ref(true);
const lastUpdateTime = ref('');
let dataRefreshTimer: ReturnType<typeof setInterval> | undefined;
async function loadData() {
async function loadData(background = false) {
  if (!meta.value) return;
  if (!background && dataRefreshTimer) clearInterval(dataRefreshTimer);
  try {
    loading.value = true;
    if (!background) 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();
    if (!background) charts.value = getDefaultCharts();
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    if (!background && dashboardCode.value === 'purchase-sales') {
      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 +116,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 +125,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, () => {
@@ -157,6 +172,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';
@@ -181,6 +200,7 @@
  loadData();
});
onBeforeUnmount(() => {
  if (dataRefreshTimer) clearInterval(dataRefreshTimer);
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
});
@@ -249,7 +269,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) => {
@@ -514,7 +534,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 {
src/views/bi/dashboard/modules/ChartCard.vue
@@ -41,12 +41,16 @@
  const valueKey = keys[1] || 'value';
  const labels = chart.data.map((d: Record<string, unknown>) => String(d[labelKey] || ''));
  const values = chart.data.map((d: Record<string, unknown>) => Number(d[valueKey]) || 0);
  const series = keys.slice(1).map((key) => ({
    name: keys.length > 2 ? key : chart.name,
    data: chart.data!.map((row) => Number(row[key]) || 0),
  }));
  switch (chart.chartType) {
    case 'bar':
      return getBarChartOptions(labels, [{ name: chart.name, data: values }]);
      return getBarChartOptions(labels, series, undefined, chart.chartOptions?.horizontal === true);
    case 'line':
      return getAreaLineChartOptions(labels, [{ name: chart.name, data: values }]);
      return getAreaLineChartOptions(labels, series);
    case 'pie':
      return getDonutPieChartOptions(labels.map((l, i) => ({ name: l, value: values[i] || 0 })));
    case 'gauge': {
@@ -125,6 +129,9 @@
    <div v-if="chart.error" class="flex h-[240px] items-center justify-center">
      <Empty :description="chart.error" />
    </div>
    <div v-else-if="!chart.data?.length" class="flex h-[240px] items-center justify-center">
      <Empty description="暂无业务数据" />
    </div>
    <EchartsUI
      v-else
      ref="chartRef"