BI 仪表盘联动 - ECharts 场景化最佳实践
场景描述: 构建企业级BI(商业智能)仪表盘,实现多图表联动、数据钻取、动态筛选等功能,提升数据分析效率。
📋 目录
场景概述
业务背景
企业BI仪表盘是数据分析和决策支持的核心工具,典型场景包括:
- 销售分析: 按地区、产品、时间维度分析销售趋势
- 用户行为: 分析用户转化漏斗、留存率、活跃度
- 运营监控: 实时监控关键指标(KPI)、异常预警
- 财务分析: 收入、成本、利润的多维度对比
核心挑战
核心需求分析
需求1: 多图表联动
用户故事:
"当我点击某个地区的柱状图时,其他图表应该自动显示该地区的详细数据,而不是手动逐个切换。"
功能拆解:
- 监听图表点击事件
- 提取选中维度(如:地区="华东")
- 广播给其他图表
- 其他图表根据筛选条件更新数据
需求2: 数据钻取
用户故事:
"看到年度数据后,我希望点击某一年,能看到该年的季度数据;再点击季度,能看到月度数据。"
功能拆解:
- 维护多层级数据结构
- 检测双击/单击事件
- 动态切换数据粒度
- 面包屑导航(返回上一级)
需求3: 全局筛选器
用户故事:
"我希望在顶部选择一个时间范围,整个仪表盘的图表都只显示这个时间段的数据。"
功能拆解:
- 创建全局状态管理
- 筛选器变化通知所有图表
- 每个图表根据筛选条件重新查询数据
- 防抖处理,避免频繁请求
需求4: 布局自适应
用户故事:
"在大屏幕上希望看到6个图表并排,在小屏幕上希望自动调整为2列或单列。"
功能拆解:
- 使用CSS Grid或Flexbox布局
- 监听窗口resize事件
- 调用chart.resize()方法
- 保存布局配置到localStorage
技术架构设计
整体架构图
状态管理方案
typescript
// 使用Redux/Zustand或自定义状态管理
interface DashboardState {
// 全局筛选器
filters: {
dateRange: [string, string]; // ['2024-01-01', '2024-12-31']
region?: string; // '华东'
category?: string; // '电子产品'
};
// 钻取状态
drillDown: {
level: 'year' | 'quarter' | 'month' | 'day';
currentValue: string; // '2024-Q1'
history: string[]; // ['2024', '2024-Q1']
};
// 联动状态
linkage: {
activeChartId?: string; // 'region-map'
selectedDimension?: { // { type: 'region', value: '华东' }
type: string;
value: any;
};
};
}
// 状态变更动作
type DashboardAction =
| { type: 'SET_FILTER'; payload: Partial<DashboardState['filters']> }
| { type: 'DRILL_DOWN'; payload: { level: string; value: string } }
| { type: 'DRILL_UP' }
| { type: 'SET_LINKAGE'; payload: Partial<DashboardState['linkage']> }
| { type: 'RESET_LINKAGE' };1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
实现方案
方案1: 多图表联动实现
步骤1: 定义联动管理器
typescript
class ChartLinkageManager {
private charts: Map<string, echarts.ECharts> = new Map();
private listeners: Map<string, Function[]> = new Map();
/**
* 注册图表
*/
register(chartId: string, chart: echarts.ECharts) {
this.charts.set(chartId, chart);
// 绑定点击事件
chart.on('click', (params) => {
this.broadcast(chartId, params);
});
}
/**
* 注册监听器
*/
on(chartId: string, callback: (sourceId: string, params: any) => void) {
if (!this.listeners.has(chartId)) {
this.listeners.set(chartId, []);
}
this.listeners.get(chartId)!.push(callback);
}
/**
* 广播事件
*/
private broadcast(sourceId: string, params: any) {
console.log(`[联动] ${sourceId} 触发联动,选中:`, params);
this.charts.forEach((chart, chartId) => {
if (chartId !== sourceId) {
const callbacks = this.listeners.get(chartId) || [];
callbacks.forEach(cb => cb(sourceId, params));
}
});
}
/**
* 高亮关联数据
*/
highlight(chartId: string, dimension: string, value: any) {
const chart = this.charts.get(chartId);
if (!chart) return;
// 清除之前的高亮
chart.dispatchAction({
type: 'downplay'
});
// 高亮指定数据
chart.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: this.findDataIndex(chart, dimension, value)
});
}
private findDataIndex(chart: echarts.ECharts, dimension: string, value: any): number {
// 根据具体逻辑查找数据索引
// 这里简化处理,实际需要根据option结构查找
return 0;
}
}
// 全局实例
const linkageManager = new ChartLinkageManager();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
步骤2: 初始化图表并注册联动
typescript
// 初始化趋势图
const trendChart = echarts.init(document.getElementById('trend-chart'));
linkageManager.register('trend', trendChart);
trendChart.setOption({
title: { text: '销售趋势' },
xAxis: { type: 'category', data: months },
yAxis: { type: 'value' },
series: [{
name: '销售额',
type: 'line',
data: salesData,
emphasis: {
focus: 'series' // 聚焦当前系列
}
}]
});
// 初始化地图
const mapChart = echarts.init(document.getElementById('map-chart'));
linkageManager.register('map', mapChart);
mapChart.setOption({
title: { text: '地区分布' },
geo: {
map: 'china',
roam: false,
emphasis: {
label: { show: true }
}
},
series: [{
name: '销售额',
type: 'map',
geoIndex: 0,
data: regionData,
emphasis: {
itemStyle: {
areaColor: '#f095ff'
}
}
}]
});
// 设置联动监听
linkageManager.on('trend', (sourceId, params) => {
if (params.componentType === 'series') {
// 趋势图点击,高亮地图对应区域
const month = params.name;
mapChart.dispatchAction({
type: 'highlight',
geoIndex: 0,
name: getTopRegion(month) // 获取该月销售最高的地区
});
}
});
linkageManager.on('map', (sourceId, params) => {
if (params.componentType === 'geo') {
// 地图点击,筛选趋势图数据
const region = params.name;
updateTrendChartByRegion(region);
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
步骤3: 数据更新函数
typescript
function updateTrendChartByRegion(region: string) {
// 根据地区筛选数据
const filteredData = salesDataByRegion[region] || [];
trendChart.setOption({
title: {
subtext: `地区: ${region}` // 显示副标题
},
series: [{
data: filteredData,
animation: true,
animationDuration: 300
}]
});
// 同时更新其他图表
updatePieChartByRegion(region);
updateFunnelChartByRegion(region);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
方案2: 数据钻取实现
步骤1: 准备多层级数据
typescript
// 多级数据结构
const hierarchicalData = {
year: [
{ name: '2022', value: 1200000 },
{ name: '2023', value: 1500000 },
{ name: '2024', value: 1800000 }
],
quarter: {
'2024': [
{ name: 'Q1', value: 400000 },
{ name: 'Q2', value: 450000 },
{ name: 'Q3', value: 500000 },
{ name: 'Q4', value: 450000 }
]
},
month: {
'2024-Q1': [
{ name: '1月', value: 120000 },
{ name: '2月', value: 130000 },
{ name: '3月', value: 150000 }
],
'2024-Q2': [
{ name: '4月', value: 140000 },
{ name: '5月', value: 150000 },
{ name: '6月', value: 160000 }
]
}
};
// 钻取状态管理
let currentLevel: 'year' | 'quarter' | 'month' = 'year';
let drillPath: string[] = []; // 记录钻取路径1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
步骤2: 实现钻取逻辑
typescript
class DrillDownManager {
private chart: echarts.ECharts;
private currentLevel: string;
private history: string[] = [];
constructor(chart: echarts.ECharts, initialLevel: string = 'year') {
this.chart = chart;
this.currentLevel = initialLevel;
}
/**
* 初始化图表
*/
init() {
this.render(this.currentLevel);
// 绑定双击事件进行钻取
this.chart.on('dblclick', (params) => {
this.drillDown(params.name);
});
}
/**
* 钻取到下一级
*/
drillDown(value: string) {
const nextLevel = this.getNextLevel();
if (!nextLevel) {
console.log('已经是最低层级');
return;
}
this.history.push(`${this.currentLevel}:${value}`);
this.currentLevel = nextLevel;
this.render(this.currentLevel, value);
this.updateBreadcrumb();
}
/**
* 钻取到上一级
*/
drillUp() {
if (this.history.length === 0) {
console.log('已经是最高层级');
return;
}
this.history.pop();
const previous = this.history[this.history.length - 1];
if (previous) {
const [level, value] = previous.split(':');
this.currentLevel = level;
this.render(this.currentLevel, value);
} else {
this.currentLevel = 'year';
this.render(this.currentLevel);
}
this.updateBreadcrumb();
}
/**
* 渲染指定层级数据
*/
render(level: string, parentValue?: string) {
let data;
switch (level) {
case 'year':
data = hierarchicalData.year;
break;
case 'quarter':
data = hierarchicalData.quarter[parentValue!] || [];
break;
case 'month':
data = hierarchicalData.month[`${parentValue}`] || [];
break;
}
this.chart.setOption({
title: {
text: this.getTitle(level),
subtext: this.getSubtitle(parentValue)
},
xAxis: {
data: data.map(item => item.name)
},
series: [{
data: data.map(item => item.value),
animation: true,
animationDuration: 500
}]
});
}
/**
* 更新面包屑导航
*/
updateBreadcrumb() {
const breadcrumb = document.getElementById('breadcrumb');
if (!breadcrumb) return;
const levels = ['year', 'quarter', 'month'];
const labels = { year: '年', quarter: '季度', month: '月' };
breadcrumb.innerHTML = this.history.map((path, index) => {
const [level, value] = path.split(':');
const isLast = index === this.history.length - 1;
return isLast
? `<span class="current">${labels[level]}: ${value}</span>`
: `<a href="#" onclick="drillManager.jumpTo(${index})">${labels[level]}: ${value}</a> > `;
}).join('');
}
jumpTo(index: number) {
this.history = this.history.slice(0, index + 1);
const [level, value] = this.history[index].split(':');
this.currentLevel = level;
this.render(this.currentLevel, value);
this.updateBreadcrumb();
}
private getNextLevel(): string | null {
const order = ['year', 'quarter', 'month'];
const currentIndex = order.indexOf(this.currentLevel);
return currentIndex < order.length - 1 ? order[currentIndex + 1] : null;
}
private getTitle(level: string): string {
const titles = {
year: '年度销售数据',
quarter: '季度销售数据',
month: '月度销售数据'
};
return titles[level];
}
private getSubtitle(parentValue?: string): string {
return parentValue ? `上级: ${parentValue}` : '';
}
}
// 使用
const drillManager = new DrillDownManager(trendChart);
drillManager.init();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
步骤3: 添加返回按钮
html
<div class="drill-controls">
<button id="drill-up-btn" onclick="drillManager.drillUp()" disabled>
← 返回上一级
</button>
<div id="breadcrumb"></div>
</div>
<style>
.drill-controls {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
#drill-up-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#breadcrumb {
font-size: 14px;
color: #666;
}
#breadcrumb .current {
color: #1890ff;
font-weight: bold;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
方案3: 全局筛选器实现
步骤1: 创建筛选器组件
typescript
interface GlobalFilters {
dateRange: [string, string];
region?: string;
category?: string;
sortBy?: 'sales' | 'profit' | 'quantity';
}
class GlobalFilterManager {
private filters: GlobalFilters = {
dateRange: ['2024-01-01', '2024-12-31']
};
private subscribers: Array<(filters: GlobalFilters) => void> = [];
/**
* 订阅筛选器变化
*/
subscribe(callback: (filters: GlobalFilters) => void) {
this.subscribers.push(callback);
return () => {
this.subscribers = this.subscribers.filter(cb => cb !== callback);
};
}
/**
* 更新筛选器
*/
update(newFilters: Partial<GlobalFilters>) {
this.filters = { ...this.filters, ...newFilters };
console.log('[筛选器更新]', this.filters);
// 防抖通知所有订阅者
this.notifyDebounce();
}
private notifyDebounce: () => void = this.debounce(() => {
this.subscribers.forEach(cb => cb(this.filters));
}, 300);
private debounce(fn: Function, delay: number): () => void {
let timer: any;
return () => {
clearTimeout(timer);
timer = setTimeout(() => fn(), delay);
};
}
/**
* 获取当前筛选器
*/
getFilters(): GlobalFilters {
return { ...this.filters };
}
/**
* 重置筛选器
*/
reset() {
this.filters = {
dateRange: ['2024-01-01', '2024-12-31']
};
this.notifyDebounce();
}
}
// 全局实例
const filterManager = new GlobalFilterManager();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
步骤2: 创建UI组件
html
<!-- 筛选器面板 -->
<div class="filter-panel">
<div class="filter-item">
<label>时间范围:</label>
<input
type="date"
id="start-date"
value="2024-01-01"
onchange="updateDateRange()"
/>
<span>至</span>
<input
type="date"
id="end-date"
value="2024-12-31"
onchange="updateDateRange()"
/>
</div>
<div class="filter-item">
<label>地区:</label>
<select id="region-select" onchange="updateRegion()">
<option value="">全部地区</option>
<option value="华东">华东</option>
<option value="华南">华南</option>
<option value="华北">华北</option>
<option value="西南">西南</option>
</select>
</div>
<div class="filter-item">
<label>品类:</label>
<select id="category-select" onchange="updateCategory()">
<option value="">全部品类</option>
<option value="电子产品">电子产品</option>
<option value="服装">服装</option>
<option value="食品">食品</option>
</select>
</div>
<div class="filter-item">
<button onclick="resetFilters()">重置筛选</button>
</div>
</div>
<script>
function updateDateRange() {
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
filterManager.update({
dateRange: [startDate, endDate]
});
}
function updateRegion() {
const region = document.getElementById('region-select').value;
filterManager.update({
region: region || undefined
});
}
function updateCategory() {
const category = document.getElementById('category-select').value;
filterManager.update({
category: category || undefined
});
}
function resetFilters() {
filterManager.reset();
// 重置UI
document.getElementById('start-date').value = '2024-01-01';
document.getElementById('end-date').value = '2024-12-31';
document.getElementById('region-select').value = '';
document.getElementById('category-select').value = '';
}
</script>
<style>
.filter-panel {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 24px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-weight: 500;
color: #333;
}
.filter-item input,
.filter-item select {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.filter-item button {
padding: 6px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.filter-item button:hover {
background: #40a9ff;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
步骤3: 图表响应筛选器变化
typescript
// 所有图表订阅筛选器变化
filterManager.subscribe((filters) => {
console.log('收到筛选器更新:', filters);
// 并行更新所有图表
Promise.all([
updateTrendChart(filters),
updateMapChart(filters),
updatePieChart(filters),
updateFunnelChart(filters)
]).then(() => {
console.log('所有图表更新完成');
});
});
// 各图表更新函数
async function updateTrendChart(filters: GlobalFilters) {
const data = await fetchData('/api/sales/trend', filters);
trendChart.setOption({
xAxis: { data: data.dates },
series: [{ data: data.values }]
});
}
async function updateMapChart(filters: GlobalFilters) {
const data = await fetchData('/api/sales/region', filters);
mapChart.setOption({
series: [{ data }]
});
}
async function updatePieChart(filters: GlobalFilters) {
const data = await fetchData('/api/sales/category', filters);
pieChart.setOption({
series: [{ data }]
});
}
async function updateFunnelChart(filters: GlobalFilters) {
const data = await fetchData('/api/sales/funnel', filters);
funnelChart.setOption({
series: [{ data }]
});
}
// API调用函数
async function fetchData(url: string, filters: GlobalFilters) {
const params = new URLSearchParams({
startDate: filters.dateRange[0],
endDate: filters.dateRange[1]
});
if (filters.region) params.append('region', filters.region);
if (filters.category) params.append('category', filters.category);
const response = await fetch(`${url}?${params.toString()}`);
return response.json();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
性能优化
优化1: 虚拟滚动排行榜
问题: 排行榜有1000条数据,一次性渲染卡顿
解决方案:
typescript
// 只渲染可见区域的10条数据
function renderVirtualRanking(data: any[]) {
const visibleCount = 10;
let startIndex = 0;
const container = document.getElementById('ranking-container');
const totalHeight = data.length * 40; // 每条40px
container.style.height = `${totalHeight}px`;
function updateVisibleItems() {
const scrollTop = container.scrollTop;
startIndex = Math.floor(scrollTop / 40);
const endIndex = Math.min(startIndex + visibleCount, data.length);
const visibleData = data.slice(startIndex, endIndex);
rankingChart.setOption({
yAxis: {
data: visibleData.map(item => item.name),
inverse: true
},
series: [{
data: visibleData.map(item => item.value)
}]
});
}
container.addEventListener('scroll', updateVisibleItems);
updateVisibleItems();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
优化2: 图表懒加载
typescript
// 使用IntersectionObserver实现懒加载
const chartObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const chartElement = entry.target;
const chartId = chartElement.id;
// 初始化图表
initChart(chartId);
// 停止观察
chartObserver.unobserve(chartElement);
}
});
});
// 观察所有图表容器
document.querySelectorAll('.chart-container').forEach(el => {
chartObserver.observe(el);
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
优化3: 数据缓存策略
typescript
class DataCache {
private cache = new Map<string, { data: any; timestamp: number }>();
private ttl = 5 * 60 * 1000; // 5分钟过期
/**
* 获取缓存数据
*/
get(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
// 检查是否过期
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return item.data;
}
/**
* 设置缓存
*/
set(key: string, data: any) {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
/**
* 清除缓存
*/
clear() {
this.cache.clear();
}
}
const dataCache = new DataCache();
// 使用缓存
async function fetchDataWithCache(url: string, filters: any) {
const cacheKey = `${url}?${JSON.stringify(filters)}`;
// 尝试从缓存读取
const cached = dataCache.get(cacheKey);
if (cached) {
console.log('[缓存命中]', cacheKey);
return cached;
}
// 从API获取
const data = await fetch(url).then(r => r.json());
// 存入缓存
dataCache.set(cacheKey, data);
return data;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
完整案例
销售分析BI仪表盘
typescript
class SalesDashboard {
private charts: Map<string, echarts.ECharts> = new Map();
private filterManager = new GlobalFilterManager();
private linkageManager = new ChartLinkageManager();
/**
* 初始化仪表盘
*/
async init() {
console.log('初始化销售仪表盘...');
// 1. 初始化所有图表
await this.initCharts();
// 2. 注册联动
this.setupLinkage();
// 3. 订阅筛选器
this.subscribeFilters();
// 4. 加载初始数据
await this.loadInitialData();
console.log('仪表盘初始化完成');
}
private async initCharts() {
// KPI卡片
this.initKPICards();
// 趋势图
const trendChart = echarts.init(document.getElementById('trend-chart'));
this.charts.set('trend', trendChart);
this.linkageManager.register('trend', trendChart);
// 地图
const mapChart = echarts.init(document.getElementById('map-chart'));
this.charts.set('map', mapChart);
this.linkageManager.register('map', mapChart);
// 饼图
const pieChart = echarts.init(document.getElementById('pie-chart'));
this.charts.set('pie', pieChart);
// 漏斗图
const funnelChart = echarts.init(document.getElementById('funnel-chart'));
this.charts.set('funnel', funnelChart);
// 排行榜
const rankingChart = echarts.init(document.getElementById('ranking-chart'));
this.charts.set('ranking', rankingChart);
}
private setupLinkage() {
// 地图点击 → 更新其他图表
this.linkageManager.on('map', (sourceId, params) => {
if (params.componentType === 'geo') {
const region = params.name;
this.filterManager.update({ region });
}
});
// 趋势图点击 → 高亮地图
this.linkageManager.on('trend', (sourceId, params) => {
if (params.componentType === 'series') {
const month = params.name;
const topRegion = this.getTopRegionForMonth(month);
this.linkageManager.highlight('map', 'region', topRegion);
}
});
}
private subscribeFilters() {
this.filterManager.subscribe(async (filters) => {
await Promise.all([
this.updateTrendChart(filters),
this.updateMapChart(filters),
this.updatePieChart(filters),
this.updateFunnelChart(filters),
this.updateRankingChart(filters)
]);
});
}
private async loadInitialData() {
const filters = this.filterManager.getFilters();
await Promise.all([
this.updateTrendChart(filters),
this.updateMapChart(filters),
this.updatePieChart(filters),
this.updateFunnelChart(filters),
this.updateRankingChart(filters)
]);
}
private async updateTrendChart(filters: GlobalFilters) {
const chart = this.charts.get('trend');
if (!chart) return;
const data = await fetchDataWithCache('/api/sales/trend', filters);
chart.setOption({
title: {
text: '销售趋势',
subtext: filters.region ? `地区: ${filters.region}` : ''
},
xAxis: { data: data.dates },
series: [{
type: 'line',
data: data.values,
smooth: true,
areaStyle: { opacity: 0.3 }
}]
});
}
private async updateMapChart(filters: GlobalFilters) {
const chart = this.charts.get('map');
if (!chart) return;
const data = await fetchDataWithCache('/api/sales/region', filters);
chart.setOption({
title: { text: '地区分布' },
visualMap: {
min: data.min,
max: data.max,
calculable: true
},
series: [{
type: 'map',
geoIndex: 0,
data: data.regions,
emphasis: {
itemStyle: { areaColor: '#f095ff' }
}
}]
});
}
private async updatePieChart(filters: GlobalFilters) {
const chart = this.charts.get('pie');
if (!chart) return;
const data = await fetchDataWithCache('/api/sales/category', filters);
chart.setOption({
title: { text: '品类占比' },
series: [{
type: 'pie',
radius: '60%',
data: data.categories,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.5)'
}
}
}]
});
}
private async updateFunnelChart(filters: GlobalFilters) {
const chart = this.charts.get('funnel');
if (!chart) return;
const data = await fetchDataWithCache('/api/sales/funnel', filters);
chart.setOption({
title: { text: '转化漏斗' },
series: [{
type: 'funnel',
data: data.steps,
sort: 'descending',
gap: 2
}]
});
}
private async updateRankingChart(filters: GlobalFilters) {
const chart = this.charts.get('ranking');
if (!chart) return;
const data = await fetchDataWithCache('/api/sales/ranking', filters);
chart.setOption({
title: { text: 'TOP10商品' },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: data.items.map(i => i.name),
inverse: true
},
series: [{
type: 'bar',
data: data.items.map(i => i.value),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#188df0' }
])
}
}]
});
}
private initKPICards() {
// 从API获取KPI数据
fetch('/api/sales/kpi')
.then(r => r.json())
.then(data => {
document.getElementById('kpi-sales').textContent = formatCurrency(data.totalSales);
document.getElementById('kpi-orders').textContent = formatNumber(data.totalOrders);
document.getElementById('kpi-users').textContent = formatNumber(data.totalUsers);
document.getElementById('kpi-conversion').textContent = `${data.conversionRate}%`;
});
}
private getTopRegionForMonth(month: string): string {
// 根据月份获取销售最高的地区
// 实际应该从API或缓存中获取
return '华东';
}
}
// 启动仪表盘
const dashboard = new SalesDashboard();
dashboard.init();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
最佳实践总结
🎯 设计原则
- 单一数据源: 所有图表共享同一个筛选器状态
- 响应式更新: 筛选器变化自动触发图表更新
- 双向联动: 点击图表可以筛选其他图表
- 渐进增强: 先展示核心数据,再加载详细内容
- 性能优先: 大数据量使用虚拟化、缓存、懒加载
📊 技术要点
| 功能 | 关键技术 | 注意事项 |
|---|---|---|
| 图表联动 | event事件广播 | 避免循环触发 |
| 数据钻取 | 多层级数据+面包屑 | 记录钻取历史 |
| 全局筛选 | 状态管理+防抖 | 防止频繁请求 |
| 虚拟滚动 | 只渲染可见区域 | 计算准确的高度 |
| 懒加载 | IntersectionObserver | 及时unobserve |
| 数据缓存 | Map+TTL | 定期清理过期缓存 |
⚡ 性能优化清单
- [ ] 使用
Promise.all并行更新多个图表 - [ ] 筛选器变化使用防抖(300ms)
- [ ] 大数据量开启
progressive渐进式渲染 - [ ] 排行榜使用虚拟滚动
- [ ] 非首屏图表使用懒加载
- [ ] API响应数据缓存5分钟
- [ ] 关闭不必要的动画(
animation: false) - [ ] 使用Web Worker处理大数据
🔧 常见问题
Q1: 多个图表同时更新导致页面卡顿?
A: 使用Promise.all并行更新,并开启progressive渲染:
typescript
await Promise.all([
chart1.setOption(option1),
chart2.setOption(option2)
]);1
2
3
4
2
3
4
Q2: 筛选器快速变化导致频繁请求?
A: 使用防抖:
typescript
const debouncedUpdate = debounce((filters) => {
updateCharts(filters);
}, 300);1
2
3
2
3
Q3: 钻取后如何返回上一级?
A: 维护历史记录栈:
typescript
history.push(currentView);
// 返回时
history.pop();
restorePreviousView();1
2
3
4
2
3
4
延伸阅读
- ECharts 事件系统
- ECharts 性能优化
- ECharts 地图功能
- React状态管理
总结: BI仪表盘的核心是状态管理和响应式更新。通过统一的筛选器状态管理,实现多图表联动;通过数据钻取,提供更深层次的分析能力;通过性能优化,确保大数据量下的流畅体验。记住:好的BI仪表盘应该让用户像探索数据一样自然,而不是像在操作复杂的软件。
