feat(bi): 增加CRM数据展示功能并优化图表组件
- 在采购销售看板中集成CRM相关数据统计功能
- 新增水平柱状图选项支持,增强图表展示灵活性
- 添加多个CRM关键指标图表包括客户、商机、合同回款等
- 实现数据定时刷新机制以保持信息实时性
- 优化数字动画效果显示精度控制
- 改进图表排序逻辑按CRM与供应链分类
- 增加空数据状态提示提升用户体验
- 扩展KPI图标库匹配更多业务场景
- 添加SQL脚本实现CRM数据表结构初始化
| | |
| | | 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, |
| | |
| | | 'purchase-sales': { |
| | | code: 'purchase_sales', |
| | | title: '采购 · 销售 · 售后', |
| | | subtitle: '供应链全链路经营决策看板', |
| | | subtitle: '客户经营 · 商机跟进 · 合同回款 · 采购售后', |
| | | gradient: 'from-indigo-600 via-blue-600 to-cyan-500', |
| | | icon: 'lucide:shopping-cart', |
| | | }, |
| | |
| | | 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 { |
| | |
| | | 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); |
| | |
| | | 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, () => { |
| | |
| | | |
| | | // ======== 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'; |
| | |
| | | loadData(); |
| | | }); |
| | | onBeforeUnmount(() => { |
| | | if (dataRefreshTimer) clearInterval(dataRefreshTimer); |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | | }); |
| | |
| | | <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) => { |
| | |
| | | |
| | | .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 { |
| | |
| | | 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': { |
| | |
| | | <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" |