数据可视化大屏-Vue项目
Posted 皮 卡丘
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据可视化大屏-Vue项目相关的知识,希望对你有一定的参考价值。
一、前端项目准备
1.vue-cli 搭建项目
npm install @vue/cli -g (一台电脑只执行一次即可)
vue create 项目名
选 (下键选择 空格确认)
:Manually select features 手动选择某些特性
:Router Vuex CSS
:2.0 x
Use history mode for router?是否使用路由: no
CSS预处理语言 : Less
ESLint 配置模式 - 标准模式:ESLint + Standard config
何时出现ESLint提示- 保存时:Lint on save
配置问件处理:In dedicated config files 单独存放
Save this as a preset for future projects? (y/N) :no
cd 项目名
npm run serve
2.删除无关代码
①App.vue文件中:
<template>
<div id="app"> //待编写 </div>
</template>
<style lang="less">
</style>
②components文件夹、 views文件夹 清空
③router/index.js文件中 剩余内容:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [ ]
const router = new VueRouter(
routes
)
export default router
3.静态资源的引入
图片img、第三方包lib、地图map、主题theme 放在public文件夹下
4.项目的基本配置
根目录下创建vue.config.js文件:
module.exports =
devServer:
port: 8999, //前端 端口号 访问网址(http://localhost:8999/#/具体页面路由)
open: true //自动打开浏览器
;
5.全局echarts对象的挂载
public/index.html文件中:
<script src="echarts.min.js文件的路径地址"></script>
src/main.js文件中:
// 将全局的echarts对象挂载到Vue的原型对象上
// 别的组件中 使用this.$echarts
Vue.prototype.$echarts = window.echarts;
6.axios的封装与挂载
下载axios模块 :npm install axios
src/main.js文件中:
import axios from "axios"; //引入axios // 请求基准路径的配置 接口前缀 axios.defaults.baseURL = "http://127.0.0.1:8888/api/"; // 将axios挂载到Vue的原型对象上 在别的组件中 使用this.$http发起ajax请求 Vue.prototype.$http = axios;
二、单独图表组件的开发
1.横向柱状图 Seller.vue
①组件结构和布局结构的设计
router/index.js文件中:(定义路由)
import SellerPage from '@/views/SellerPage'
const routes = [ path: '/sellerpage', component: SellerPage ] //路由规则
App.vue文件中:
<template>
<div id="app">
<router-view></router-view> //路由的占位符
</div>
</template>
<style lang="less">
</style>
父- views/SellerPage.vue文件中:(创建SellerPage.vue文件)
<!--针对于 /sellerpage 这条路径而显示出来的 在这个组件中, 通过子组件注册的方式, 要显示出Seller.vue这个组件 -->
<template>
<div class="com-page">
<Seller></Seller> ③ //显示子组件
</div>
</template>
<script>
import Seller from '@/components/Seller' ① //引入子组件Seller
export default
data ()
return
,
methods: ,
components:
Seller: Seller ② //注册子组件
</script>
<style lang="less" scoped>
</style>
子- components/Seller.vue文件中:(创建Seller.vue文件,功能主要写在这个文件里面)
<!-- 商家销量统计的横向柱状图 -->
<template>
<div class="com-container">
<div class="com-chart" > 你好 </div>
</div>
</template>
<script></script>
<style lang="less" scoped></style>
assets/css/global.less文件中: 全局样式
html, body, #app
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
.com-page
width: 100%;
height: 100%;
overflow: hidden;
.com-container
width: 100%;
height: 100%;
overflow: hidden;
.com-chart
width: 100%;
height: 900px; //注意
overflow: hidden;
canvas
border-radius: 20px; // 全局样式 圆角
.com-container
position: relative;
main.js文件中:
// 引入全局的样式文件
import "./assets/css/global.less";
public/index.html文件中: 声明主题
<!-- 引入主题的js文件 -->
<script src="static/theme/chalk.js"></script>
<script src="static/theme/vintage.js"></script>
components/Seller.vue文件中:总的代码
<template>
<div class="com-container">
<div class="com-chart" ref="seller_ref"></div> //ref更好的获取dom
</div>
</template>
<script>
export default
data ()
return
chartInstance: null,//图表的数据
allData: null, // 服务器返回的数据
currentPage: 1, // 当前显示的页数
totalPage: 0, // 一共有多少页
timerId: null // 定时器的标识
,
mounted ()
this.initChart() //初始化图表
this.getData() //获取后端的数据
window.addEventListener('resize', this.screenAdapter)
// 在页面加载完成的时候, 主动进行屏幕的适配
this.screenAdapter()
,
destroyed ()
// 在组件销毁的时候, 需要将监听器取消掉
clearInterval(this.timerId)
// 在组件销毁的时候, 需要将监听器取消掉
window.removeEventListener('resize', this.screenAdapter)
,
methods:
initChart ()
this.chartInstance = this.$echarts.init(this.$refs.seller_ref, 'chalk') //chalk使用主题
// start
// 对图表初始化配置的控制
const initOption =
title: //设置标题
text: '▎商家销售统计',
left: 20, //标题位置
top: 20 //标题位置
,
grid: //网格 设置坐标轴位置
top: '20%',
left: '3%',
right: '6%',
bottom: '3%',
containLabel: true // 距离是包含坐标轴上的文字
,
xAxis:
type: 'value' //数值轴
,
yAxis:
type: 'category' //类目轴
,
tooltip:
trigger: 'axis',
axisPointer:
type: 'line',
z: 0,
lineStyle:
color: '#2D3443'
,
series: [ //数值轴对应的数据
type: 'bar', //代表是柱状图
label: //柱状图上的文字
show: true,
position: 'right',
textStyle:
color: 'white'
,
itemStyle: //样式
// 指明颜色渐变的方向
// 指明不同百分比之下颜色的值
color: new this.$echarts.graphic.LinearGradient(0, 0, 1, 0, [
// 百分之0状态之下的颜色值
offset: 0,
color: '#5052EE'
,
// 百分之100状态之下的颜色值
offset: 1,
color: '#AB6EE5'
])
]
this.chartInstance.setOption(initOption)
// end
// 对图表对象进行鼠标事件的监听
this.chartInstance.on('mouseover', () =>
clearInterval(this.timerId)
)
this.chartInstance.on('mouseout', () =>
this.startInterval()
)
,
async getData () //获取后端的数据
// http://127.0.0.1:8888/api/seller
const data: ret = await this.$http.get('seller')
console.log(ret)
this.allData = ret
// 从小到大的排序
this.allData.sort((a, b) =>
return a.value - b.value // 从小到大的排序
)
// 每5个元素显示一页
this.totalPage =
this.allData.length % 5 === 0
? this.allData.length / 5
: this.allData.length / 5 + 1
this.updateChart()
this.startInterval()
,
updateChart ()
const start = (this.currentPage - 1) * 5
const end = this.currentPage * 5
const showData = this.allData.slice(start, end)
const sellerNames = showData.map(item =>
return item.name
)
const sellerValues = showData.map(item =>
return item.value
)
// 获取数据之后的配置option
const dataOption =
yAxis:
data: sellerNames
,
series: [
data: sellerValues
]
this.chartInstance.setOption(dataOption)
,
// 开启定时器
startInterval ()
if (this.timerId)
clearInterval(this.timerId)
this.timerId = setInterval(() =>
this.currentPage++
if (this.currentPage > this.totalPage)
this.currentPage = 1
this.updateChart()
, 3000)
,
// 当浏览器的大小发生变化的时候, 会调用的方法, 来完成屏幕的适配
screenAdapter ()
// console.log(this.$refs.seller_ref.offsetWidth) 屏幕大小
const titleFontSize = (this.$refs.seller_ref.offsetWidth / 100) * 3.6
// 和分辨率大小相关的配置项
const adapterOption =
title:
textStyle:
fontSize: titleFontSize
,
tooltip:
axisPointer:
lineStyle:
width: titleFontSize
,
series: [
barWidth: titleFontSize,
itemStyle:
barBorderRadius: [0, titleFontSize / 2, titleFontSize / 2, 0]
]
this.chartInstance.setOption(adapterOption)
// 手动的调用图表对象的resize 才能产生效果
this.chartInstance.resize()
</script>
<style>
</style>
2.折线图:Trend.vue
main.js文件中:
import './assets/font/iconfont.css' //引入字体icon的样式
router/index.js文件中:
import MapPage from '@/views/MapPage'
const routes = [
path: '/sellerpage', component: SellerPage ,
path: '/trendpage', component: TrendPage ,
]
views/TrendPage.vue文件中:
<template>
<div class="com-page">
<Trend></Trend>
</div>
</template>
<script>
import Trend from '@/components/Trend'
export default
data ()
return
,
methods: ,
components:
Trend: Trend
</script>
<style lang="less" scoped></style>
componens/Trend.vue文件中:总的代码
<template>
<div class="com-container">
<div class="title"
:style="comStyle">
<span> '▎ ' + showTitle </span>
<span class="iconfont title-icon" //使用字体icon的样式
:style="comStyle"
@click="showChoice = !showChoice"></span>
<div class="select-con"
v-show="showChoice"
:style="marginStyle">
<div class="select-item"
v-for="item in selectTypes"
:key="item.key"
@click="handleSelect(item.key)">
item.text
</div>
</div>
</div>
<div class="com-chart"
ref="trend_ref"></div>
</div>
</template>
<script>
import mapState from 'vuex'
// import getThemeValue from '@/utils/theme_utils'
export default
data ()
return
chartInstane: null,
allData: null, // 从服务器中获取的所有数据
showChoice: false, // 是否显示可选项
choiceType: 'map', // 显示的数据类型
titleFontSize: 0 // 指明标题的字体大小
,
// created ()
// // 在组件创建完成之后 进行回调函数的注册
// this.$socket.registerCallBack('trendData', this.getData)
// ,
mounted ()
this.initChart()
this.getData()
// 发送数据给服务器, 告诉服务器, 我现在需要数据
// this.$socket.send(
// action: 'getData',
// socketType: 'trendData',
// chartName: 'trend',
// value: ''
// )
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
,
destroyed ()
window.removeEventListener('resize', this.screenAdapter)
// 在组件销毁的时候, 进行回调函数的取消
// this.$socket.unRegisterCallBack('trendData')
,
computed:
selectTypes ()
if (!this.allData)
return []
else
return this.allData.type.filter(item =>
return item.key !== this.choiceType
)
,
showTitle ()
if (!this.allData)
return ''
else
return this.allData[this.choiceType].title
,
// 设置给标题的样式
comStyle ()
return
fontSize: this.titleFontSize + 'px'
// color: getThemeValue(this.theme).titleColor
,
marginStyle ()
return
marginLeft: this.titleFontSize + 'px'
,
...mapState(['theme'])
,
methods:
initChart ()
this.chartInstane = this.$echarts.init(this.$refs.trend_ref, 'chalk')
const initOption =
grid:
left: '3%',
top: '35%',
right: '4%',
bottom: '1%',
containLabel: true
,
tooltip:
trigger: 'axis'
,
legend:
left: 20,
top: '15%',
icon: 'circle'
,
xAxis:
type: 'category',
boundaryGap: false
,
yAxis:
type: 'value'
this.chartInstane.setOption(initOption)
,
// ret 就是服务端发送给客户端的图表的数据
async getData ()
// await this.$http.get()
// 对allData进行赋值
const data: ret = await this.$http.get('trend')
this.allData = ret
console.log(this.allData)
this.updateChart()
,
updateChart ()
// 半透明的颜色值
const colorArr1 = [
'rgba(11, 168, 44, 0.5)',
'rgba(44, 110, 255, 0.5)',
'rgba(22, 242, 217, 0.5)',
'rgba(254, 33, 30, 0.5)',
'rgba(250, 105, 0, 0.5)'
]
// 全透明的颜色值
const colorArr2 = [
'rgba(11, 168, 44, 0)',
'rgba(44, 110, 255, 0)',
'rgba(22, 242, 217, 0)',
'rgba(254, 33, 30, 0)',
'rgba(250, 105, 0, 0)'
]
// 处理数据
// 类目轴的数据
const timeArr = this.allData.common.month
// y轴的数据 series下的数据
const valueArr = this.allData[this.choiceType].data
const seriesArr = valueArr.map((item, index) =>
return
name: item.name,
type: 'line',
data: item.data,
stack: this.choiceType,
areaStyle:
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
offset: 0,
color: colorArr1[index]
, // %0的颜色值
offset: 1,
color: colorArr2[index]
// 100%的颜色值
])
)
// 图例的数据
const legendArr = valueArr.map(item =>
return item.name
)
const dataOption =
xAxis:
data: timeArr
,
legend:
data: legendArr
,
series: seriesArr
this.chartInstane.setOption(dataOption)
,
screenAdapter ()
this.titleFontSize = this.$refs.trend_ref.offsetWidth / 100 * 3.6
const adapterOption =
legend:
itemWidth: this.titleFontSize,
itemHeight: this.titleFontSize,
itemGap: this.titleFontSize,
textStyle:
fontSize: this.titleFontSize / 2
this.chartInstane.setOption(adapterOption)
this.chartInstane.resize()
,
handleSelect (currentType)
this.choiceType = currentType
this.updateChart()
this.showChoice = false
// watch:
// theme ()
// console.log('主题切换了')
// this.chartInstane.dispose() // 销毁当前的图表
// this.initChart() // 重新以最新的主题名称初始化图表对象
// this.screenAdapter() // 完成屏幕的适配
// this.updateChart() // 更新图表的展示
//
//
</script>
<style lang="less" scoped>
.title
position: absolute;
left: 20px;
top: 20px;
z-index: 10;
color: white;
.title-icon
margin-left: 10px;
cursor: pointer;
.select-con
background-color: #222733;
</style>
3.地图+散点图
router/index.js文件中:
import MapPage from '@/views/MapPage'
const routes = [
path: '/sellerpage', component: SellerPage ,
path: '/trendpage', component: TrendPage ,
path: '/mappage', component: MapPage
]
views/MapPage.vue文件中:
<!-- 针对于 /mappage 这条路径而显示出来的
在这个组件中, 通过子组件注册的方式, 要显示出Map.vue这个组件 -->
<template>
<div class="com-page">
<Map></Map>
</div>
</template>
<script>
import Map from '@/components/Map'
export default
data ()
return
,
methods: ,
components:
Map: Map
</script>
<style lang="less" scoped>
</style>
componens/Map.vue文件中:总的代码
<!-- 商家分布图表 地图+散点图-->
<template>
<div class='com-container' @dblclick="revertMap">
<div class='com-chart' ref='map_ref'></div>
</div>
</template>
<script>
import mapState from 'vuex'
import axios from 'axios'
import getProvinceMapInfo from '@/utils/map_utils'
export default
data ()
return
chartInstance: null,
allData: null,
mapData: // 缓存 所获取的省份的地图矢量数据
,
// created ()
// // 在组件创建完成之后 进行回调函数的注册
// this.$socket.registerCallBack('mapData', this.getData)
// ,
mounted ()
this.initChart()
this.getData()
// this.$socket.send(
// action: 'getData',
// socketType: 'mapData',
// chartName: 'map',
// value: ''
// )
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
,
destroyed ()
window.removeEventListener('resize', this.screenAdapter)
// this.$socket.unRegisterCallBack('mapData')
,
methods:
async initChart ()
this.chartInstance = this.$echarts.init(this.$refs.map_ref, 'chalk')
// this.chartInstance = this.$echarts.init(this.$refs.map_ref, this.theme)
// 获取中国地图的矢量数据
// http://localhost:8999/static/map/china.json
// 由于我们现在获取的地图矢量数据并不是位于KOA2的后台, 所以咱们不能使用this.$http
const ret = await axios.get('http://localhost:8999/static/map/china.json')
this.$echarts.registerMap('china', ret.data)
const initOption =
title:
text: '▎ 商家分布',
left: 20,
top: 20
,
geo:
type: 'map',
map: 'china',
top: '5%',
bottom: '5%',
itemStyle:
areaColor: '#2E72BF',
borderColor: '#333'
,
legend:
left: '5%',
bottom: '5%',
orient: 'vertical'
this.chartInstance.setOption(initOption)
// 点击地图 下钻
this.chartInstance.on('click', async arg =>
console.log(arg)
// arg.name 得到所点击的省份, 这个省份他是中文
const provinceInfo = getProvinceMapInfo(arg.name)
console.log(provinceInfo)
// 需要获取这个省份的地图矢量数据
// 判断当前所点击的这个省份的地图矢量数据在mapData中是否存在
if (!this.mapData[provinceInfo.key])
const ret = await axios.get('http://localhost:8999' + provinceInfo.path)
this.mapData[provinceInfo.key] = ret.data
this.$echarts.registerMap(provinceInfo.key, ret.data)
const changeOption =
geo:
map: provinceInfo.key
this.chartInstance.setOption(changeOption)
)
,
async getData ()
// 获取服务器的数据, 对this.allData进行赋值之后, 调用updateChart方法更新图表
const data: ret = await this.$http.get('map')
this.allData = ret
// console.log(this.allData)
this.updateChart()
,
updateChart ()
// 处理图表需要的数据
// 图例的数据
const legendArr = this.allData.map(item =>
// console.log('item.name', item.name)
return item.name
)
const seriesArr = this.allData.map(item =>
// return的这个对象就代表的是一个类别下的所有散点数据
// 如果想在地图中显示散点的数据, 我们需要给散点的图表增加一个配置, coordinateSystem:geo
return
type: 'effectScatter',
rippleEffect:
scale: 5,
brushType: 'stroke'
,
name: item.name,
data: item.children,
coordinateSystem: 'geo'
)
const dataOption =
legend:
data: legendArr
,
series: seriesArr
this.chartInstance.setOption(dataOption)
,
screenAdapter ()
// console.log('this.$refs.map_ref.offsetWidth', this.$refs.map_ref.offsetWidth)
const titleFontSize = this.$refs.map_ref.offsetWidth / 100 * 3.6
const adapterOption =
title:
textStyle:
fontSize: titleFontSize
,
legend:
itemWidth: titleFontSize / 2,
itemHeight: titleFontSize / 2,
itemGap: titleFontSize / 2,
textStyle:
fontSize: titleFontSize / 2
this.chartInstance.setOption(adapterOption)
this.chartInstance.resize()
,
// 回到中国地图
revertMap ()
const revertOption =
geo:
map: 'china'
this.chartInstance.setOption(revertOption)
,
computed:
...mapState(['theme'])
,
watch:
theme ()
console.log('主题切换了')
this.chartInstance.dispose() // 销毁当前的图表
this.initChart() // 重新以最新的主题名称初始化图表对象
this.screenAdapter() // 完成屏幕的适配
this.updateChart() // 更新图表的展示
</script>
<style lang='less' scoped>
</style>
4.柱状图
router/index.js文件中:
import Vue from 'vue'
import VueRouter from 'vue-router'
import TrendPage from '@/views/TrendPage'
import SellerPage from '@/views/SellerPage'
import MapPage from '@/views/MapPage'
import RankPage from '@/views/RankPage'
Vue.use(VueRouter)
const routes = [
path: '/sellerpage', component: SellerPage ,
path: '/trendpage', component: TrendPage ,
path: '/mappage', component: MapPage ,
path: '/rankpage', component: RankPage
]
const router = new VueRouter(
routes
)
export default router
views/RankPage.vue文件中:
<!--
针对于 /rankpage 这条路径而显示出来的
在这个组件中, 通过子组件注册的方式, 要显示出Rank.vue这个组件
-->
<template>
<div class="com-page">
<Rank></Rank>
</div>
</template>
<script>
import Rank from '@/components/Rank'
export default
data ()
return
,
methods: ,
components:
Rank: Rank
</script>
<style lang="less" scoped>
</style>
componens/Rank.vue文件中:总的代码
<!-- 地区销售排行 柱状图-->
<template>
<div class='com-container'>
<div class='com-chart'
ref='rank_ref'></div>
</div>
</template>
<script>
import mapState from 'vuex'
export default
data ()
return
chartInstance: null,
allData: null,
startValue: 0, // 区域缩放的起点值
endValue: 9, // 区域缩放的终点值
timerId: null // 定时器的标识
,
// created ()
// // 在组件创建完成之后 进行回调函数的注册
// this.$socket.registerCallBack('rankData', this.getData)
// ,
mounted ()
this.initChart()
this.getData()
// this.$socket.send(
// action: 'getData',
// socketType: 'rankData',
// chartName: 'rank',
// value: ''
// )
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
,
destroyed ()
window.removeEventListener('resize', this.screenAdapter)
clearInterval(this.timerId) // 销毁定时器
// this.$socket.unRegisterCallBack('rankData')
,
methods:
initChart ()
// this.chartInstance = this.$echarts.init(this.$refs.rank_ref, this.theme)
this.chartInstance = this.$echarts.init(this.$refs.rank_ref, 'chalk')
const initOption =
title: // 标题
text: '▎ 地区销售排行',
left: 20,
top: 20
,
grid: // 坐标轴位置
top: '40%',
left: '5%',
right: '5%',
bottom: '5%',
containLabel: true
,
tooltip: // 工具提示
show: true
,
xAxis:
type: 'category'
,
yAxis:
type: 'value'
,
series: [
type: 'bar'
]
this.chartInstance.setOption(initOption)
// 鼠标移入 动画效果停止
this.chartInstance.on('mouseover', () =>
clearInterval(this.timerId)
)
// 鼠标移出 动画效果开始
this.chartInstance.on('mouseout', () =>
this.startInterval()
)
,
async getData ()
// 获取服务器的数据, 对this.allData进行赋值之后, 调用updateChart方法更新图表
const data: ret = await this.$http.get('rank')
this.allData = ret
// 对allData里面的每一个元素进行排序, 从大到小进行
this.allData.sort((a, b) =>
return b.value - a.value
)
console.log(this.allData)
this.updateChart()
this.startInterval() // 启动定时器 -开启动画向左移动
,
updateChart ()
const colorArr = [
['#0BA82C', '#4FF778'],
['#2E72BF', '#23E5E5'],
['#5052EE', '#AB6EE5']
]
// 处理图表需要的数据
// 所有省份所形成的数组 x轴数据
const provinceArr = this.allData.map(item =>
return item.name
)
// 所有省份对应的销售金额 y轴数据
const valueArr = this.allData.map(item =>
return item.value
)
const dataOption =
xAxis:
data: provinceArr
,
dataZoom: // 区域缩放 - 使图每隔一段时间向左平移一个
show: false, // 不显示区域缩放组件
startValue: this.startValue, // 区域缩放的起点值
endValue: this.endValue // 区域缩放的终点值
,
series: [
data: valueArr,
itemStyle: // 不同的数据对应不同的颜色 -柱状图的颜色
color: arg =>
let targetColorArr = null
if (arg.value > 300)
targetColorArr = colorArr[0]
else if (arg.value > 200)
targetColorArr = colorArr[1]
else
targetColorArr = colorArr[2]
return new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [ // 颜色渐变
offset: 0,
color: targetColorArr[0]
,
offset: 1,
color: targetColorArr[1]
])
]
this.chartInstance.setOption(dataOption)
,
screenAdapter () // 标题的文字大小、柱的宽度、柱的圆角
const titleFontSize = this.$refs.rank_ref.offsetWidth / 100 * 3.6
const adapterOption =
title:
textStyle:
fontSize: titleFontSize
,
series: [
barWidth: titleFontSize,
itemStyle:
barBorderRadius: [titleFontSize / 2, titleFontSize / 2, 0, 0]
]
this.chartInstance.setOption(adapterOption)
this.chartInstance.resize()
,
startInterval () // 设置定时器 -控制动画的向左移动
if (this.timerId)
clearInterval(this.timerId)
this.timerId = setInterval(() =>
this.startValue++
this.endValue++
if (this.endValue > this.allData.length - 1)
this.startValue = 0
this.endValue = 9
this.updateChart()
, 2000)
,
computed:
...mapState(['theme'])
,
watch:
theme ()
console.log('主题切换了')
this.chartInstance.dispose() // 销毁当前的图表
this.initChart() // 重新以最新的主题名称初始化图表对象
this.screenAdapter() // 完成屏幕的适配
this.updateChart() // 更新图表的展示
</script>
<style lang='less' scoped>
</style>
5.饼图
router/index.js文件中:
import Vue from 'vue'
import VueRouter from 'vue-router'
import TrendPage from '@/views/TrendPage'
import SellerPage from '@/views/SellerPage'
import MapPage from '@/views/MapPage'
import RankPage from '@/views/RankPage'
import HotPage from '@/views/HotPage'
Vue.use(VueRouter)
const routes = [
path: '/sellerpage', component: SellerPage ,
path: '/trendpage', component: TrendPage ,
path: '/mappage', component: MapPage ,
path: '/rankpage', component: RankPage ,
path: '/hotpage', component: HotPage
]
const router = new VueRouter(
routes
)
export default router
views/HotPage.vue文件中:
<!--
针对于 /hotpage 这条路径而显示出来的
在这个组件中, 通过子组件注册的方式, 要显示出Hot.vue这个组件
-->
<template>
<div class="com-page">
<Hot></Hot>
</div>
</template>
<script>
import Hot from '@/components/Hot'
export default
data ()
return
,
methods: ,
components:
Hot: Hot
</script>
<style lang="less" scoped>
</style>
componens/Hot.vue文件中:总的代码
<!-- 热销商品图表 -->
<template>
<div class='com-container'>
<div class='com-chart'
ref='hot_ref'></div>
<span class="iconfont arr-left"
@click="toLeft"
:style="comStyle"></span>
<span class="iconfont arr-right"
@click="toRight"
:style="comStyle"></span>
<span class="cat-name"
:style="comStyle"> catName </span>
</div>
</template>
<script>
import mapState from 'vuex'
// import getThemeValue from '@/utils/theme_utils'
export default
data ()
return
chartInstance: null,
allData: null,
currentIndex: 0, // 当前所展示出的一级分类数据
titleFontSize: 0
,
// created ()
// // 在组件创建完成之后 进行回调函数的注册
// this.$socket.registerCallBack('hotData', this.getData)
// ,
computed:
catName ()
if (!this.allData) // 一级标题的显示
return ''
else
return this.allData[this.currentIndex].name
,
comStyle () //
return
fontSize: this.titleFontSize + 'px'
// color: getThemeValue(this.theme).titleColor
,
...mapState(['theme'])
,
mounted ()
this.initChart()
this.getData()
// this.$socket.send(
// action: 'getData',
// socketType: 'hotData',
// chartName: 'hot',
// value: ''
// )
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
,
destroyed ()
window.removeEventListener('resize', this.screenAdapter)
// this.$socket.unRegisterCallBack('hotData')
,
methods:
initChart ()
// this.chartInstance = this.$echarts.init(this.$refs.hot_ref, this.theme)
this.chartInstance = this.$echarts.init(this.$refs.hot_ref, 'chalk')
const initOption =
title:
text: '▎ 热销商品的占比',
left: 20,
top: 20
,
legend: // 图例图标的位置
top: '15%',
icon: 'circle'
,
tooltip: // 工具提示 - 提示三级占比
show: true,
formatter: arg =>
// console.log(arg)
const thirdCategory = arg.data.children
// 计算出所有三级分类的数值总和
let total = 0
thirdCategory.forEach(item =>
total += item.value
)
let retStr = ''
thirdCategory.forEach(item =>
retStr += `
$item.name:$parseInt(item.value / total * 100) + '%'
<br/>
`
)
return retStr
,
series: [
type: 'pie',
label: // 饼图的图例
show: false
,
emphasis: // 高亮显示 - 饼图的图例
label:
show: true
,
labelLine: // 饼图的图例线条
show: false
]
this.chartInstance.setOption(initOption)
,
async getData ()
// 获取服务器的数据, 对this.allData进行赋值之后, 调用updateChart方法更新图表
const data: ret = await this.$http.get('hotproduct')
this.allData = ret
console.log(this.allData)
this.updateChart()
,
updateChart ()
// 处理图表需要的数据
const legendData = this.allData[this.currentIndex].children.map(item =>
return item.name
)
const seriesData = this.allData[this.currentIndex].children.map(item =>
return
name: item.name,
value: item.value,
children: item.children // 新增加children的原因是为了在tooltip中的formatter的回调函数中,来拿到这个二级分类下的三级分类数据
)
const dataOption =
legend:
data: legendData
,
series: [
data: seriesData
]
this.chartInstance.setOption(dataOption)
,
screenAdapter ()
this.titleFontSize = this.$refs.hot_ref.offsetWidth / 100 * 3.6
const adapterOption =
title: // 标题的文字大小
textStyle:
fontSize: this.titleFontSize
,
legend: // 图例的大小
itemWidth: this.titleFontSize,
itemHeight: this.titleFontSize,
itemGap: this.titleFontSize / 2,
textStyle:
fontSize: this.titleFontSize / 2
,
series: [
radius: this.titleFontSize * 4.5, // 饼图的大小
center: ['50%', '60%']
]
this.chartInstance.setOption(adapterOption)
this.chartInstance.resize()
,
toLeft ()
this.currentIndex--
if (this.currentIndex < 0)
this.currentIndex = this.allData.length - 1
this.updateChart()
,
toRight ()
this.currentIndex++
if (this.currentIndex > this.allData.length - 1)
this.currentIndex = 0
this.updateChart()
,
watch:
theme ()
console.log('主题切换了')
this.chartInstance.dispose() // 销毁当前的图表
this.initChart() // 重新以最新的主题名称初始化图表对象
this.screenAdapter() // 完成屏幕的适配
this.updateChart() // 更新图表的展示
</script>
<style lang='less' scoped>
.arr-left
position: absolute;
left: 10%;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: white;
.arr-right
position: absolute;
right: 10%;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: white;
.cat-name
position: absolute;
left: 80%;
bottom: 20px;
color: white;
</style>
6.循环饼图
router/index.js文件中:
import Vue from 'vue'
import VueRouter from 'vue-router'
import TrendPage from '@/views/TrendPage'
import SellerPage from '@/views/SellerPage'
import MapPage from '@/views/MapPage'
import RankPage from '@/views/RankPage'
import HotPage from '@/views/HotPage'
import StockPage from '@/views/StockPage'
Vue.use(VueRouter)
const routes = [
path: '/sellerpage', component: SellerPage ,
path: '/trendpage', component: TrendPage ,
path: '/mappage', component: MapPage ,
path: '/rankpage', component: RankPage ,
path: '/hotpage', component: HotPage ,
path: '/stockpage', component: StockPage
]
const router = new VueRouter(
routes
)
export default router
views/StockPage.vue文件中:
<!--
针对于 /stockpage 这条路径而显示出来的
在这个组件中, 通过子组件注册的方式, 要显示出Stock.vue这个组件
-->
<template>
<div class="com-page">
<Stock></Stock>
</div>
</template>
<script>
import Stock from '@/components/Stock'
export default
data ()
return
,
methods: ,
components:
Stock: Stock
</script>
<style lang="less" scoped>
</style>
componens/Stock.vue文件中:总的代码
<!-- 库存销量分析 -->
<template>
<div class='com-container'>
<div class='com-chart'
ref='stock_ref'></div>
</div>
</template>
<script>
import mapState from 'vuex'
export default
data ()
return
chartInstance: null,
allData: null,
currentIndex: 0, // 当前显示的数据
timerId: null // 定时器的标识
,
created ()
// 在组件创建完成之后 进行回调函数的注册
this.$socket.registerCallBack('stockData', this.getData)
,
mounted ()
this.initChart()
// this.getData()
this.$socket.send(
action: 'getData',
socketType: 'stockData',
chartName: 'stock',
value: ''
)
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
,
destroyed ()
window.removeEventListener('resize', this.screenAdapter)
clearInterval(this.timerId)
this.$socket.unRegisterCallBack('stockData')
,
methods:
initChart ()
this.chartInstance = this.$echarts.init(this.$refs.stock_ref, this.theme)
const initOption =
title:
text: '▎库存和销量分析',
left: 20,
top: 20
this.chartInstance.setOption(initOption)
this.chartInstance.on('mouseover', () =>
clearInterval(this.timerId)
)
this.chartInstance.on('mouseout', () =>
this.startInterval()
)
,
getData (ret)
// 获取服务器的数据, 对this.allData进行赋值之后, 调用updateChart方法更新图表
// const data: ret = await this.$http.get('stock')
this.allData = ret
console.log(this.allData)
this.updateChart()
this.startInterval()
,
updateChart ()
const centerArr = [
['18%', '40%'],
['50%', '40%'],
['82%', '40%'],
['34%', '75%'],
['66%', '75%']
]
const colorArr = [
['#4FF778', '#0BA82C'],
['#E5DD45', '#E8B11C'],
['#E8821C', '#E55445'],
['#5052EE', '#AB6EE5'],
['#23E5E5', '#2E72BF']
]
// 处理图表需要的数据
const start = this.currentIndex * 5
const end = (this.currentIndex + 1) * 5
const showData = this.allData.slice(start, end)
const seriesArr = showData.map((item, index) =>
return
type: 'pie',
center: centerArr[index],
hoverAnimation: false, // 关闭鼠标移入到饼图时的动画效果
labelLine:
show: false // 隐藏指示线
,
label:
position: 'center',
color: colorArr[index][0]
,
data: [
name: item.name + '\\n\\n' + item.sales,
value: item.sales,
itemStyle:
color: new this.$echarts.graphic.LinearGradient(0, 1, 0, 0, [
offset: 0,
color: colorArr[index][0]
,
offset: 1,
color: colorArr[index][1]
])
,
value: item.stock,
itemStyle:
color: '#333843'
]
)
const dataOption =
series: seriesArr
this.chartInstance.setOption(dataOption)
,
screenAdapter ()
const titleFontSize = this.$refs.stock_ref.offsetWidth / 100 * 3.6
const innerRadius = titleFontSize * 2.8
const outterRadius = innerRadius * 1.125
const adapterOption =
title:
textStyle:
fontSize: titleFontSize
,
series: [
type: 'pie',
radius: [outterRadius, innerRadius],
label:
fontSize: titleFontSize / 2
,
type: 'pie',
radius: [outterRadius, innerRadius],
label:
fontSize: titleFontSize / 2
,
type: 'pie',
radius: [outterRadius, innerRadius],
label:
fontSize: titleFontSize / 2
,
type: 'pie',
radius: [outterRadius, innerRadius],
label:
fontSize: titleFontSize / 2
,
type: 'pie',
radius: [outterRadius, innerRadius],
label:
fontSize: titleFontSize / 2
]
this.chartInstance.setOption(adapterOption)
this.chartInstance.resize()
,
startInterval ()
if (this.timerId)
clearInterval(this.timerId)
this.timerId = setInterval(() =>
this.currentIndex++
if (this.currentIndex > 1)
this.currentIndex = 0
this.updateChart() // 在更改完currentIndex之后 , 需要更新界面
, 5000)
,
computed:
...mapState(['theme'])
,
watch:
theme ()
console.log('主题切换了')
this.chartInstance.dispose() // 销毁当前的图表
this.initChart() // 重新以最新的主题名称初始化图表对象
this.screenAdapter() // 完成屏幕的适配
this.updateChart() // 更新图表的展示
</script>
<style lang='less' scoped>
</style>
三、WebSocket的引入
1.后端 app.js文件中:
const webSocketService = require('./service/web_socket_service')
// 开启服务端的监听, 监听客户端的连接
// 当某一个客户端连接成功之后, 就会对这个客户端进行message事件的监听
webSocketService.listen()
src/utils/file_utils.js文件中:
// 读取文件的工具方法
const fs = require("fs");
module.exports.getFileJsonData = filePath =>
// return "你好";
return new Promise((resolve, reject) =>
fs.readFile(filePath, "utf-8", (error, data) =>
if (error)
reject(error);
else
resolve(data);
);
);
;
src/service/web_socket_service.js文件中:
const path = require('path')
const fileUtils = require('../utils/file_utils')
//WebSocket的引入
//下载插件 npm i ws -S
const WebSocket = require('ws')
// 创建WebSocket服务端的对象, 绑定的端口号是9998
const wss = new WebSocket.Server(
port: 9998
)
// 服务端开启了监听
module.exports.listen = () =>
// 对客户端的连接事件进行connection事件的监听
// client:代表的是客户端的连接socket对象
wss.on('connection', client =>
console.log('有客户端连接成功了...')
// 对客户端的连接对象进行message事件的监听
// msg: 由客户端发给服务端的数据
client.on('message', async msg =>
console.log('客户端发送数据给服务端了: ' + msg)
let payload = JSON.parse(msg)
const action = payload.action
if (action === 'getData')
let filePath = '../data/' + payload.chartName + '.json'
// payload.chartName // trend seller map rank hot stock
filePath = path.join(__dirname, filePath)
const ret = await fileUtils.getFileJsonData(filePath)
// 需要在服务端获取到数据的基础之上, 增加一个data的字段
// data所对应的值,就是某个json文件的内容
payload.data = ret
client.send(JSON.stringify(payload))
else
// 原封不动的将所接收到的数据转发给每一个处于连接状态的客户端
// wss.clients // 所有客户端的连接
wss.clients.forEach(client =>
client.send(msg)
)
// 由服务端往客户端发送数据
// client.send('hello socket from backend')
)
)
2.前端
src/utils/socket_service.js 文件中:export default class SocketService
/**
* 单例
*/
static instance = null
static get Instance ()
if (!this.instance)
this.instance = new SocketService()
return this.instance
// 和服务端连接的socket对象
ws = null
// 存储回调函数
callBackMapping =
// 标识是否连接成功
connected = false
// 记录重试的次数
sendRetryCount = 0
// 重新连接尝试的次数
connectRetryCount = 0
// 定义连接服务器的方法
connect ()
// 连接服务器
if (!window.WebSocket)
return console.log('您的浏览器不支持WebSocket')
this.ws = new WebSocket('ws://localhost:9998')
// 连接成功的事件
this.ws.onopen = () =>
console.log('连接服务端成功了')
this.connected = true
// 重置重新连接的次数
this.connectRetryCount = 0
// 1.连接服务端失败
// 2.当连接成功之后, 服务器关闭的情况
this.ws.onclose = () =>
console.log('连接服务端失败')
this.connected = false
this.connectRetryCount++
setTimeout(() =>
this.connect()
, 500 * this.connectRetryCount)
// 得到服务端发送过来的数据
this.ws.onmessage = msg =>
console.log('从服务端获取到了数据')
// 真正服务端发送过来的原始数据时在msg中的data字段
// console.log(msg.data)
const recvData = JSON.parse(msg.data)
const socketType = recvData.socketType
// 判断回调函数是否存在
if (this.callBackMapping[socketType])
const action = recvData.action
if (action === 'getData')
const realData = JSON.parse(recvData.data)
this.callBackMapping[socketType].call(this, realData)
else if (action === 'fullScreen')
this.callBackMapping[socketType].call(this, recvData)
else if (action === 'themeChange')
this.callBackMapping[socketType].call(this, recvData)
// 回调函数的注册
registerCallBack (socketType, callBack)
this.callBackMapping[socketType] = callBack
// 取消某一个回调函数
unRegisterCallBack (socketType)
this.callBackMapping[socketType] = null
// 发送数据的方法
send (data)
// 判断此时此刻有没有连接成功
if (this.connected)
this.sendRetryCount = 0
this.ws.send(JSON.stringify(data))
else
this.sendRetryCount++
setTimeout(() =>
this.send(data)
, this.sendRetryCount * 500)
src/main.js文件中:
import SocketService from '@/utils/socket_service'
// 对服务端进行websocket的连接
SocketService.Instance.connect()
// 其他的组件 this.$socket
Vue.prototype.$socket = SocketService.Instance
componens/Seller.vue文件中: 其他组件一样
created ()
// 在组件创建完成之后 进行回调函数的注册
this.$socket.registerCallBack('sellerData', this.getData)
,
mounted ()
// this.getData()
this.$socket.send(
action: 'getData',
socketType: 'sellerData',
chartName: 'seller',
value: ''
)
,
destroyed ()
this.$socket.unRegisterCallBack('sellerData')
,
methods:
// 获取服务器的数据
getData (ret)
// const data: ret = await this.$http.get('seller')
this.allData = ret
,
四、细节处置
1.组件合并
src/router/index.js文件中:
import Vue from 'vue'
import VueRouter from 'vue-router'
import ScreenPage from '@/views/ScreenPage'
Vue.use(VueRouter)
const routes = [
path: '/',
redirect: '/screen'
,
path: '/screen',
component: ScreenPage
]
const router = new VueRouter(
routes
)
export default router
src/views/ScreenPage.vue文件中:组件 -导入注册引用
<template>
<div class="screen-container"
:style="containerStyle">
<header class="screen-header">
<div>
<img :src="headerSrc" alt="">
</div>
<span class="title">电商平台实时监控系统</span>
<div class="title-right">
<img :src="themeSrc" class="qiehuan" @click="handleChangeTheme">
<span class="datetime">timeComput</span>
</div>
</header>
<div class="screen-body">
<section class="screen-left">
<div id="left-top" :class="[fullScreenStatus.trend ? 'fullscreen' : '']">
<!-- 销量趋势图表 -->
<Trend ref="trend"></Trend>
<div class="resize">
<span @click="changeSize('trend')"
:class="['iconfont', fullScreenStatus.trend ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
<div id="left-bottom"
:class="[fullScreenStatus.seller ? 'fullscreen' : '']">
<!-- 商家销售金额图表 -->
<Seller ref="seller"></Seller>
<div class="resize">
<!-- icon-compress-alt -->
<span @click="changeSize('seller')"
:class="['iconfont', fullScreenStatus.seller ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
</section>
<section class="screen-middle">
<div id="middle-top"
:class="[fullScreenStatus.map ? 'fullscreen' : '']">
<!-- 商家分布图表 -->
<Map ref="map"></Map>
<div class="resize">
<!-- icon-compress-alt -->
<span @click="changeSize('map')"
:class="['iconfont', fullScreenStatus.map ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
<div id="middle-bottom"
:class="[fullScreenStatus.rank ? 'fullscreen' : '']">
<!-- 地区销量排行图表 -->
<Rank ref="rank"></Rank>
<div class="resize">
<!-- icon-compress-alt -->
<span @click="changeSize('rank')"
:class="['iconfont', fullScreenStatus.rank ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
</section>
<section class="screen-right">
<div id="right-top"
:class="[fullScreenStatus.hot ? 'fullscreen' : '']">
<!-- 热销商品占比图表 -->
<Hot ref="hot"></Hot>
<div class="resize">
<!-- icon-compress-alt -->
<span @click="changeSize('hot')"
:class="['iconfont', fullScreenStatus.hot ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
<div id="right-bottom"
:class="[fullScreenStatus.stock ? 'fullscreen' : '']">
<!-- 库存销量分析图表 -->
<Stock ref="stock"></Stock>
<div class="resize">
<!-- icon-compress-alt -->
<span @click="changeSize('stock')"
:class="['iconfont', fullScreenStatus.stock ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
</section>
</div>
</div>
</template>
<script>
import Hot from '@/components/Hot.vue'
import Map from '@/components/Map.vue'
import Rank from '@/components/Rank.vue'
import Seller from '@/components/Seller.vue'
import Stock from '@/components/Stock.vue'
import Trend from '@/components/Trend.vue'
import mapState from 'vuex'
import getThemeValue from '@/utils/theme_utils'
export default
created ()
// 注册接收到数据的回调函数
this.$socket.registerCallBack('fullScreen', this.recvData)
this.$socket.registerCallBack('themeChange', this.recvThemeChange)
,
mounted ()
this.startInterval()
,
destroyed ()
this.$socket.unRegisterCallBack('fullScreen')
this.$socket.unRegisterCallBack('themeChange')
,
data ()
return
// 定义每一个图表的全屏状态
fullScreenStatus:
trend: false,
seller: false,
map: false,
rank: false,
hot: false,
stock: false
,
timeComput: '',
timer: ''// 定义一个定时器的变量
,
methods:
// 开启定时器
startInterval ()
if (this.timer) // 开启定时器之前先取消定时器,节流防抖
clearInterval(this.timer)
this.timer = setInterval(() =>
this.CurentTime()
, 1000)
,
CurentTime ()
const now = new Date()
const year = now.getFullYear() // 年
const month = now.getMonth() + 1 // 月
const day = now.getDate() // 日
const hh = now.getHours() // 时
const mm = now.getMinutes() // 分
const ss = now.getSeconds() // 秒
let clock = year + '-'
if (month < 10) clock += '0'
clock += month + '-'
if (day < 10) clock += '0'
clock += day + ' '
if (hh < 10) clock += '0'
clock += hh + ':'
if (mm < 10) clock += '0'
clock += mm + ':'
if (ss < 10) clock += '0'
clock += ss
this.timeComput = clock
,
changeSize (chartName)
// 1.改变fullScreenStatus的数据
this.fullScreenStatus[chartName] = !this.fullScreenStatus[chartName]
// 2.需要调用每一个图表组件的screenAdapter的方法
this.$refs[chartName].screenAdapter()
this.$nextTick(() =>
this.$refs[chartName].screenAdapter()
)
// // 全屏联动 -- 将数据发送给服务端
// const targetValue = !this.fullScreenStatus[chartName]
// this.$socket.send(
// action: 'fullScreen',
// socketType: 'fullScreen',
// chartName: chartName,
// value: targetValue
// )
,
// 接收到全屏数据之后的处理
recvData (data)
// 取出是哪一个图表需要进行切换
const chartName = data.chartName
// 取出, 切换成什么状态
const targetValue = data.value
this.fullScreenStatus[chartName] = targetValue
this.$nextTick(() =>
this.$refs[chartName].screenAdapter()
)
,
handleChangeTheme ()
// 修改VueX中数据
this.$store.commit('changeTheme')
// this.$socket.send(
// action: 'themeChange',
// socketType: 'themeChange',
// chartName: '',
// value: ''
// )
,
recvThemeChange ()
this.$store.commit('changeTheme')
基于 SpringBoot + Vue 实现的可视化拖拽编辑的大屏项目
整理:抓哇笔记
简介
大屏设计(AJ-Report)是一个可视化拖拽编辑的全开源项目,直观,酷炫,具有科技感的图表工具。内置的基础功能包括数据源,数据集,报表管理。
多数据源支持,内置mysql、elasticsearch、kudu驱动,支持自定义数据集省去数据接口开发,支持17种大屏组件,不会开发,照着设计稿也可以制作大屏。
三步轻松完成大屏设计:配置数据源—->写SQL配置数据集—->拖拽配置大屏—->保存发布。欢迎体验。
数据流程图
核心技术
后端
Spring Boot2.3.5.RELEASE: Spring Boot是一款开箱即用框架,让我们的Spring应用变的更轻量化、更快的入门。在主程序执行main函数就可以运行。你也可以打包你的应用为jar并通过使用java -jar来运行你的Web应用;
Mybatis-plus3.3.2: MyBatis-plus(简称 MP)是一个 MyBatis (opens new window) 的增强工具。
flyway5.2.1: 主要用于在你的应用版本不断升级的同时,升级你的数据库结构和里面的数据
前端
npm:node.js的包管理工具,用于统一管理我们前端项目中需要用到的包、插件、工具、命令等,便于开发和维护。
webpack:用于现代 JavaScript 应用程序的_静态模块打包工具
ES6:Javascript的新版本,ECMAScript6的简称。利用ES6我们可以简化我们的JS代码,同时利用其提供的强大功能来快速实现JS逻辑。
vue-cli:Vue的脚手架工具,用于自动生成Vue项目的目录及文件。
vue-router:Vue提供的前端路由工具,利用其我们实现页面的路由控制,局部刷新及按需加载,构建单页应用,实现前后端分离。
element-ui:基于MVVM框架Vue开源出来的一套前端ui组件。
avue: 用该组件包裹后可以变成拖拽组件,采用相对于父类绝对定位;用键盘的上下左右也可以控制移动
vue-echarts: vue-echarts是封装后的vue插件,基于 ECharts v4.0.1+ 开发
vue-superslide: Vue-SuperSlide(Github) 是 SuperSlide 的 Vue 封装版本
vuedraggable: 是一款基于Sortable.js实现的vue拖拽插件。
截图
源码:gitee.com/anji-plus/report
模板:gitee.com/52itstyle/visual-large-screen
以上是关于数据可视化大屏-Vue项目的主要内容,如果未能解决你的问题,请参考以下文章