1. 引言:多层次数据表的可视化需求
在数据分析中,多层次数据表(如树形结构、嵌套分类)广泛存在于组织架构、产品分类等场景。这类数据的核心挑战在于如何直观展示层级关系,同时避免信息过载。传统表格难以胜任,而可视化工具如 ECharts 能通过动态交互和空间编码,将复杂层级转化为可探索的视觉形式。例如,通过树图逐层展开部门结构,或通过旭日图呈现电商商品的多级分类占比,帮助用户快速定位关键信息。
2. ECharts 核心功能与多层次数据适配性
ECharts 支持 JSON 和 树形数据结构,提供多种图表类型满足不同层级需求:
- 树图(Tree):以父子节点连线明确层级路径,适合展示垂直结构。
- 旭日图(Sunburst):通过环形分层显示占比,适合多维度聚合分析。
- 矩形树图(Treemap):利用面积和颜色编码数据,空间利用率高。
- 桑基图(Sankey):描述层级间流量或关系,如用户行为路径。
此外,ECharts 的 组件化配置(如dataZoom、tooltip)和 交互动画(点击下钻、高亮)能显著提升用户体验。
3. 数据准备与格式处理
数据结构设计
ECharts 的树形数据需包含 name、value 和 children 字段。例如:
{
"name": "总部",
"value": 1000,
"children": [
{ "name": "技术部", "value": 400, "children": [...] }
]
}
数据转换
若原始数据为扁平结构(如数据库表),可通过工具转换为嵌套格式:
import { nest } from 'lodash';
const nestedData = nest()
.key(d => d.department)
.key(d => d.team)
.entries(flatData);
或使用递归函数动态构建层级。
4. 使用 ECharts 展示层级数据的方法
树图(Tree)的实现与配置
树图是展示层级关系最直观的方式,特别适合组织架构、分类系统等场景:
option = {
tooltip: {
trigger: 'item',
triggerOn: 'mousemove'
},
series: [
{
type: 'tree',
data: [treeData], // 树形数据
top: '1%',
left: '7%',
bottom: '1%',
right: '20%',
symbolSize: 7,
label: {
position: 'left',
verticalAlign: 'middle',
align: 'right',
fontSize: 12
},
leaves: {
label: {
position: 'right',
verticalAlign: 'middle',
align: 'left'
}
},
emphasis: {
focus: 'descendant'
},
expandAndCollapse: true,
animationDuration: 550,
animationDurationUpdate: 750
}
]
};
桑基图(Sankey)展示数据流向
桑基图特别适合展示数据流向和转化关系,如用户路径分析、能源流向等:
option = {
series: {
type: 'sankey',
layout: 'none',
emphasis: {
focus: 'adjacency'
},
data: nodes,
links: links
}
};
旭日图(Sunburst)表达层级占比
旭日图通过同心环展示层级数据,非常适合展示层级占比关系:
option = {
series: {
type: 'sunburst',
data: hierarchicalData,
radius: [0, '90%'],
label: {
rotate: 'radial'
}
}
};
矩形树图(Treemap)的应用
矩形树图使用嵌套的矩形表示层级关系,矩形面积表示数值大小:
option = {
series: [{
type: 'treemap',
data: treeData,
levels: [
{ itemStyle: { borderWidth: 0, gapWidth: 5 } },
{ itemStyle: { borderColor: '#999', borderWidth: 1, gapWidth: 1 } },
{ itemStyle: { borderColorSaturation: 0.6 } }
]
}]
};
5. 高级技巧:复杂多层次数据的可视化
数据下钻(Drill-down)功能实现
数据下钻允许用户从高层次视图深入到更详细的数据层级:
chart.on('click', function(params) {
if (params.data.children && params.data.children.length > 0) {
option.series[0].data = [params.data];
chart.setOption(option);
}
});
// 添加返回上一级的按钮
document.getElementById('back').addEventListener('click', function() {
option.series[0].data = [rootData];
chart.setOption(option);
});
动态切换不同层级视图
通过控制显示的层级深度,可以实现视图的动态调整:
function updateDepth(depth) {
option.series[0].levels = generateLevels(depth);
chart.setOption(option);
}
// 层级控制滑块
document.getElementById('depth-slider').addEventListener('input', function(e) {
updateDepth(parseInt(e.target.value));
});
多维数据的交互式筛选
结合ECharts的事件系统,可以实现数据的交互式筛选:
chart.on('legendselectchanged', function(params) {
const filteredData = originalData.filter(item =>
params.selected[item.category]
);
updateChart(filteredData);
});
组合图表展示多层次数据的不同维度
通过组合多个图表,可以从不同角度展示多层次数据:
option = {
grid: [
{left: '7%', top: '7%', width: '38%', height: '38%'},
{right: '7%', top: '7%', width: '38%', height: '38%'},
{left: '7%', bottom: '7%', width: '38%', height: '38%'},
{right: '7%', bottom: '7%', width: '38%', height: '38%'}
],
xAxis: [{gridIndex: 0}, {gridIndex: 1}, {gridIndex: 2}, {gridIndex: 3}],
yAxis: [{gridIndex: 0}, {gridIndex: 1}, {gridIndex: 2}, {gridIndex: 3}],
series: [
{
type: 'treemap',
data: hierarchicalData,
// 配置treemap...
},
{
type: 'pie',
data: aggregatedData,
center: ['25%', '25%'],
radius: ['40%', '70%']
// 配置pie...
},
// 其他图表...
]
};
6. 数据预处理与转换
扁平数据转换为层级结构
实际应用中,数据往往以扁平表格形式存储,需要转换为层级结构:
function convertToTree(flatData, idField, parentField, rootId = null) {
const nodeMap = {};
const result = [];
// 创建所有节点的映射
flatData.forEach(item => {
nodeMap[item[idField]] = { ...item, children: [] };
});
// 建立父子关系
flatData.forEach(item => {
const parentId = item[parentField];
if (parentId === rootId) {
// 根节点
result.push(nodeMap[item[idField]]);
} else if (nodeMap[parentId]) {
// 添加到父节点的children
nodeMap[parentId].children.push(nodeMap[item[idField]]);
}
});
return result;
}
// 使用示例
const treeData = convertToTree(
flatTableData,
'id',
'parentId',
null
);
使用 JavaScript 处理复杂数据结构
对于复杂数据,可能需要更高级的处理:
function processHierarchicalData(data, options = {}) {
const {
valueField = 'value',
aggregateFunction = (children) =>
children.reduce((sum, child) => sum + (child[valueField] || 0), 0)
} = options;
function processNode(node) {
if (node.children && node.children.length) {
// 递归处理子节点
node.children.forEach(processNode);
// 计算聚合值
if (!node[valueField]) {
node[valueField] = aggregateFunction(node.children);
}
}
return node;
}
return data.map(processNode);
}
// 使用示例
const processedData = processHierarchicalData(treeData, {
valueField: 'sales',
aggregateFunction: (children) => {
// 自定义聚合逻辑
return children.reduce((sum, child) => sum + (child.sales || 0), 0);
}
});
服务端数据处理与前端展示的配合
对于大规模数据,可以采用服务端处理与前端逐级加载的策略:
async function loadNodeChildren(nodeId) {
try {
const response = await fetch(`/api/hierarchical-data/${nodeId}/children`);
const children = await response.json();
return children;
} catch (error) {
console.error('Failed to load children:', error);
return [];
}
}
chart.on('click', async function(params) {
if (params.data.isLeaf === false && !params.data.children) {
// 显示加载状态
chart.showLoading();
// 加载子节点数据
const children = await loadNodeChildren(params.data.id);
// 更新节点数据
params.data.children = children;
// 重新渲染图表
chart.hideLoading();
chart.setOption(option);
}
});
7. 实战案例分析
案例一:组织架构图的实现
option = {
tooltip: { trigger: 'item' },
series: [{
type: 'tree',
data: [orgData],
orient: 'vertical',
layout: 'radial',
symbol: 'emptyCircle',
symbolSize: 10,
initialTreeDepth: 3,
label: {
position: 'radial',
rotate: 0,
verticalAlign: 'middle',
align: 'center'
},
leaves: {
label: {
position: 'radial',
rotate: 0,
verticalAlign: 'middle',
align: 'center'
}
},
animationDurationUpdate: 750,
emphasis: {
focus: 'descendant'
}
}]
};
在这个案例中,我们使用了径向布局的树图来展示组织架构。这种布局方式适合展示规模较大的组织结构,能够在有限的空间内展示更多的层级关系。通过设置initialTreeDepth,我们可以控制初始展开的层级深度,避免初始视图过于复杂。
此外,我们还可以添加自定义的交互功能,例如点击部门显示详细信息:
chart.on('click', function(params) {
if (params.data) {
document.getElementById('department-detail').innerHTML = `
<h3>${params.data.name}</h3>
<p>人数: ${params.data.value || '未知'}</p>
<p>负责人: ${params.data.manager || '未指定'}</p>
`;
}
});
案例二:多层级销售数据分析看板
销售数据通常包含多个维度,如地区、产品类别、时间等,非常适合使用多层次可视化:
// 准备数据
const salesData = {
name: '总销售',
value: 10000000,
children: [
{
name: '华北区',
value: 3500000,
children: [
{ name: '北京', value: 1800000 },
{ name: '天津', value: 900000 },
{ name: '河北', value: 800000 }
]
},
{
name: '华东区',
value: 4200000,
children: [
{ name: '上海', value: 2100000 },
{ name: '江苏', value: 1200000 },
{ name: '浙江', value: 900000 }
]
},
{
name: '华南区',
value: 2300000,
children: [
{ name: '广东', value: 1400000 },
{ name: '福建', value: 900000 }
]
}
]
};
// 创建销售数据看板
option = {
title: {
text: '区域销售数据分析',
left: 'center'
},
tooltip: {
formatter: function(params) {
const value = params.data.value;
const formattedValue = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0
}).format(value);
return `${params.data.name}: ${formattedValue}`;
}
},
series: [
{
type: 'treemap',
data: salesData.children,
levels: [
{ itemStyle: { borderWidth: 0, gapWidth: 5 } },
{ itemStyle: { borderColor: '#999', borderWidth: 1, gapWidth: 1 } }
],
breadcrumb: {
show: true
},
label: {
show: true,
formatter: '{b}\n{c}'
},
upperLabel: {
show: true,
height: 30
}
}
]
};
这个矩形树图可以直观地展示各区域销售额的占比关系。为了增强分析能力,我们可以添加下钻功能和时间维度的切换:
// 添加时间筛选
const timeRanges = ['2023年Q1', '2023年Q2', '2023年Q3', '2023年Q4'];
const timeSelector = document.getElementById('time-selector');
timeSelector.addEventListener('change', async function() {
const selectedTime = this.value;
chart.showLoading();
// 从服务器获取对应时间段的数据
const response = await fetch(`/api/sales-data?time=${selectedTime}`);
const newData = await response.json();
option.series[0].data = newData.children;
chart.hideLoading();
chart.setOption(option);
});
// 实现下钻功能
chart.on('click', function(params) {
if (params.data.children) {
option.series[0].data = params.data.children;
chart.setOption(option);
// 更新面包屑导航
updateBreadcrumb(params.data.name);
}
});
function updateBreadcrumb(name) {
const breadcrumb = document.getElementById('custom-breadcrumb');
const item = document.createElement('span');
item.textContent = name + ' > ';
item.onclick = function() {
// 返回到对应层级
navigateToLevel(name);
};
breadcrumb.appendChild(item);
}
案例三:网站访问路径分析
网站访问路径分析是一个典型的多层次数据应用场景,可以使用桑基图来展示用户的浏览路径和转化漏斗:
// 用户访问路径数据
const pathData = {
nodes: [
{ name: '首页' },
{ name: '产品列表' },
{ name: '产品详情' },
{ name: '购物车' },
{ name: '结算页' },
{ name: '支付成功' },
{ name: '搜索结果' },
{ name: '用户中心' },
{ name: '离开网站' }
],
links: [
{ source: '首页', target: '产品列表', value: 3721 },
{ source: '首页', target: '搜索结果', value: 2138 },
{ source: '首页', target: '用户中心', value: 1568 },
{ source: '首页', target: '离开网站', value: 2103 },
{ source: '产品列表', target: '产品详情', value: 2256 },
{ source: '产品列表', target: '离开网站', value: 1465 },
{ source: '搜索结果', target: '产品详情', value: 1832 },
{ source: '搜索结果', target: '离开网站', value: 306 },
{ source: '产品详情', target: '购物车', value: 1286 },
{ source: '产品详情', target: '离开网站', value: 2802 },
{ source: '购物车', target: '结算页', value: 865 },
{ source: '购物车', target: '离开网站', value: 421 },
{ source: '结算页', target: '支付成功', value: 762 },
{ source: '结算页', target: '离开网站', value: 103 },
{ source: '用户中心', target: '离开网站', value: 1568 }
]
};
option = {
title: {
text: '网站用户访问路径分析',
left: 'center'
},
tooltip: {
trigger: 'item',
triggerOn: 'mousemove'
},
series: {
type: 'sankey',
layout: 'none',
emphasis: {
focus: 'adjacency'
},
data: pathData.nodes,
links: pathData.links,
lineStyle: {
color: 'gradient',
curveness: 0.5
}
}
};
为了增强分析能力,我们可以添加转化率计算和路径高亮功能:
// 计算转化率
function calculateConversionRate(links, startNode, endNode) {
const startFlow = links.filter(link => link.source === startNode)
.reduce((sum, link) => sum + link.value, 0);
// 找到从起点到终点的所有可能路径
const paths = findAllPaths(links, startNode, endNode);
// 计算到达终点的总流量
const endFlow = paths.reduce((sum, path) => {
// 找到路径上的最小流量,即实际能到达终点的流量
const pathFlow = Math.min(...path.map(step => {
const link = links.find(l => l.source === step.source && l.target === step.target);
return link ? link.value : Infinity;
}));
return sum + pathFlow;
}, 0);
return (endFlow / startFlow * 100).toFixed(2) + '%';
}
// 添加转化率显示
document.getElementById('conversion-rate').textContent =
`首页到支付成功的转化率: ${calculateConversionRate(pathData.links, '首页', '支付成功')}`;
// 路径高亮功能
document.getElementById('highlight-path').addEventListener('click', function() {
const startNode = document.getElementById('start-node').value;
const endNode = document.getElementById('end-node').value;
// 找到从起点到终点的最优路径
const bestPath = findBestPath(pathData.links, startNode, endNode);
// 高亮显示该路径
const highlightedLinks = pathData.links.map(link => {
const isOnPath = bestPath.some(step =>
step.source === link.source && step.target === link.target
);
return {
...link,
lineStyle: {
opacity: isOnPath ? 1 : 0.1,
width: isOnPath ? 5 : 1,
color: isOnPath ? '#ff5500' : undefined
}
};
});
option.series.links = highlightedLinks;
chart.setOption(option);
});
8. 性能优化与最佳实践
大数据量下的渲染优化
当处理大规模多层次数据时,性能是一个关键问题:
// 数据采样与聚合
function sampleHierarchicalData(data, maxNodesPerLevel = 20) {
function processLevel(nodes) {
if (nodes.length <= maxNodesPerLevel) {
return nodes.map(node => {
if (node.children && node.children.length > 0) {
return {
...node,
children: processLevel(node.children)
};
}
return node;
});
}
// 对节点进行排序(例如按值大小)
const sortedNodes = [...nodes].sort((a, b) => (b.value || 0) - (a.value || 0));
// 保留最重要的节点
const keepNodes = sortedNodes.slice(0, maxNodesPerLevel - 1);
// 将其余节点合并为"其他"
const otherNodes = sortedNodes.slice(maxNodesPerLevel - 1);
const otherValue = otherNodes.reduce((sum, node) => sum + (node.value || 0), 0);
const result = [
...keepNodes.map(node => {
if (node.children && node.children.length > 0) {
return {
...node,
children: processLevel(node.children)
};
}
return node;
}),
{ name: '其他', value: otherValue }
];
return result;
}
return processLevel(data);
}
// 使用采样后的数据
const sampledData = sampleHierarchicalData(originalData, 15);
option.series[0].data = sampledData;
按需加载与延迟渲染
对于非常大的数据集,可以实现按需加载策略:
// 初始只加载第一级数据
let currentData = await fetchTopLevelData();
option.series[0].data = currentData;
// 实现展开时动态加载
chart.on('click', async function(params) {
if (params.data && !params.data.children && params.data.hasChildren) {
// 显示加载状态
chart.showLoading({ text: '加载中...' });
try {
// 异步加载子节点数据
const children = await fetchChildrenData(params.data.id);
// 更新当前节点的children属性
params.data.children = children;
// 更新图表
chart.setOption(option);
} catch (error) {
console.error('Failed to load children:', error);
} finally {
chart.hideLoading();
}
}
});
交互响应优化
优化交互响应对于提升用户体验至关重要:
// 使用节流函数优化高频事件
function throttle(fn, delay) {
let timer = null;
return function(...args) {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
// 优化缩放和平移操作
const throttledUpdateView = throttle(function(params) {
// 更新视图的代码
option.series[0].zoom = params.zoom;
option.series[0].center = params.center;
chart.setOption(option);
}, 100);
// 使用增量更新而非完全重绘
function updatePartialData(newData, path) {
let target = option.series[0].data;
const pathArray = path.split('.');
// 导航到目标节点的父节点
for (let i = 0; i < pathArray.length - 1; i++) {
const index = parseInt(pathArray[i]);
target = target[index].children;
}
// 更新目标节点
const lastIndex = parseInt(pathArray[pathArray.length - 1]);
target[lastIndex] = newData;
// 只更新变化的部分
chart.setOption(option, {notMerge: false, lazyUpdate: true});
}
9. 常见问题与解决方案
数据格式不兼容问题
在实际项目中,数据格式不兼容是一个常见的挑战,尤其是当数据来自不同系统或API时:
// 适配器模式:转换不同来源的数据到统一格式
function dataAdapter(sourceData, sourceType) {
switch(sourceType) {
case 'api1':
// API1返回的数据格式转换
return sourceData.items.map(item => ({
name: item.title,
value: item.count,
children: item.subItems ? dataAdapter(item.subItems, 'api1-sub') : undefined
}));
case 'api1-sub':
// API1子项数据格式转换
return sourceData.map(subItem => ({
name: subItem.name,
value: subItem.value
}));
case 'api2':
// API2返回的数据格式转换
return sourceData.data.categories.map(category => ({
name: category.categoryName,
value: category.total,
children: category.subcategories ?
category.subcategories.map(sub => ({
name: sub.name,
value: sub.amount
})) : undefined
}));
case 'csv':
// CSV导入的扁平数据转换为层级结构
return convertFlatToHierarchical(sourceData, 'id', 'parentId');
default:
console.error('Unknown source type:', sourceType);
return [];
}
}
// 使用适配器
async function loadAndDisplayData(source, type) {
const rawData = await fetchData(source);
const adaptedData = dataAdapter(rawData, type);
option.series[0].data = adaptedData;
chart.setOption(option);
}
此外,还可以创建数据验证函数,确保数据符合ECharts的要求:
function validateHierarchicalData(data) {
const errors = [];
function validateNode(node, path = '') {
if (!node.name) {
errors.push(`节点缺少name属性: ${path}`);
}
if (node.children) {
if (!Array.isArray(node.children)) {
errors.push(`children必须是数组: ${path}`);
} else {
node.children.forEach((child, index) => {
validateNode(child, `${path}.children[${index}]`);
});
}
}
}
data.forEach((node, index) => {
validateNode(node, `[${index}]`);
});
return { valid: errors.length === 0, errors };
}
// 使用验证
const validation = validateHierarchicalData(data);
if (!validation.valid) {
console.error('数据格式有误:', validation.errors);
// 显示错误信息或进行修正
}
复杂层级展示时的视觉优化
当层级结构过于复杂时,视觉表现可能变得混乱,影响用户理解:
// 颜色编码优化:使用渐变色表示层级深度
function generateLevelColors(levels) {
const colorMap = {};
const baseColor = echarts.color.parse('#5470c6');
for (let i = 0; i < levels; i++) {
// 随着层级深入,颜色逐渐变深
const factor = 0.8 - (i * 0.1);
const color = baseColor.clone();
// 调整颜色亮度
color.r = Math.floor(color.r * factor);
color.g = Math.floor(color.g * factor);
color.b = Math.floor(color.b * factor);
colorMap[i] = color.toHex();
}
return colorMap;
}
// 应用层级颜色
function applyLevelColors(data, colorMap, level = 0) {
return data.map(node => {
const newNode = {
...node,
itemStyle: {
color: colorMap[level]
}
};
if (node.children && node.children.length) {
newNode.children = applyLevelColors(node.children, colorMap, level + 1);
}
return newNode;
});
}
// 使用层级颜色
const levelColorMap = generateLevelColors(5); // 支持5个层级
const coloredData = applyLevelColors(treeData, levelColorMap);
option.series[0].data = coloredData;
另一个常见问题是标签重叠,可以通过以下方式优化:
// 标签布局优化
option.series[0].label = {
show: true,
formatter: function(params) {
// 根据节点值大小调整标签显示内容
const value = params.value || 0;
if (value < threshold) {
// 对于小节点,只显示简化信息或不显示
return params.name.length > 4 ? params.name.substring(0, 4) + '...' : params.name;
}
return params.name + '\n' + value;
},
// 自动旋转标签避免重叠
rotate: function(params) {
// 根据节点位置动态计算旋转角度
return params.data.depth % 2 === 0 ? 0 : 90;
}
};
// 对于树图,可以调整节点间距
option.series[0].layout = 'orthogonal';
option.series[0].orient = 'LR';
option.series[0].initialTreeDepth = 3;
option.series[0].symbolSize = function(value) {
// 根据数值动态调整节点大小
return Math.max(5, Math.min(20, Math.sqrt(value) / 10));
};
移动端适配与响应式设计
随着移动设备使用的增加,多层次数据可视化的移动端适配变得尤为重要:
// 响应式配置
function createResponsiveOption(baseOption) {
// 检测设备类型
const isMobile = window.innerWidth < 768;
// 移动端特定配置
if (isMobile) {
// 简化图表,减少显示的层级
baseOption.series[0].initialTreeDepth = 2;
// 调整字体大小
baseOption.textStyle = {
fontSize: 12
};
// 调整图例位置
baseOption.legend = {
...baseOption.legend,
orient: 'horizontal',
top: 'bottom',
padding: [5, 5, 5, 5]
};
// 对于树图,切换到垂直布局
if (baseOption.series[0].type === 'tree') {
baseOption.series[0].orient = 'TB';
baseOption.series[0].layout = 'orthogonal';
}
// 对于矩形树图,简化标签
if (baseOption.series[0].type === 'treemap') {
baseOption.series[0].label.show = false;
baseOption.series[0].upperLabel.show = true;
baseOption.series[0].upperLabel.height = 20;
}
}
return baseOption;
}
// 监听窗口大小变化,动态调整图表
window.addEventListener('resize', function() {
const responsiveOption = createResponsiveOption(baseOption);
chart.setOption(responsiveOption, true);
});
// 初始化时应用响应式配置
const responsiveOption = createResponsiveOption(option);
chart.setOption(responsiveOption);
针对触摸交互的优化:
// 优化触摸交互
if ('ontouchstart' in window) {
// 增大交互元素的响应区域
option.series[0].symbolSize = function(value) {
// 在移动设备上增大节点尺寸
return Math.max(15, Math.min(30, Math.sqrt(value) / 8));
};
// 简化提示框内容
option.tooltip = {
...option.tooltip,
triggerOn: 'click', // 改为点击触发
enterable: true, // 允许鼠标进入提示框
confine: true, // 限制在图表区域内
formatter: function(params) {
// 简化的提示内容
return `<div style="padding: 10px;">
<h4 style="margin:0">${params.name}</h4>
<p style="margin:5px 0 0">值: ${params.value}</p>
</div>`;
}
};
}
10. 总结
ECharts 在多层次数据可视化中的优势与局限:
优势:
- 丰富的图表类型,特别适合层级数据展示
- 强大的交互能力,支持复杂的用户交互场景
- 良好的性能,能处理较大规模的数据
- 完善的文档和活跃的社区支持
- 高度可定制性,几乎所有视觉元素都可配置
局限:
- 对于超大规模数据(万级节点),仍需特别优化
- 某些复杂的多维分析场景可能需要结合其他工具
- 学习曲线较陡,完全掌握需要一定时间
- 某些特殊图表类型可能需要扩展或自定义实现
最后,记住可视化的终极目标不仅是展示数据,更是通过直观的方式帮助人们理解数据,发现洞见,并做出更好的决策。在这个过程中,技术只是工具,真正的价值在于你如何利用这些工具讲述数据背后的故事。