电商实时监控大屏

一、效果预览


在线预览


二、创建项目


1. 创建项目

1
2
vue create project-eshop-echarts // 默认打开vuex和vue-router
npm init

复制相关资源到assets目录下面

2. 安装依赖

1
2
npm i babel-plugin-transform-remove-console -D // 该插件用于删除项目中的console.log
npm i axios lodash echarts@4.8.0 -S // echarts需要4.8.0版本,最新版本的配置项格式有所改动,会导致主题chalk和westeros无法解析

3. 配置vue.config.js

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
module.export = {
// 配置外网与本地的起始根路径
publicPath: process.env.NODE_ENV === 'production' ? '/project/eshop-echarts' : './',
devServer: {
open: true // 编译后直接打开浏览器
},
configureWebpack: {
resolve: {
alias: { // 路径别名
components: '@/components',
assets: '@/assets',
views: '@/views',
utils: '@/utils'
}
}
},
chainWebpack: config => {
config.when(process.env.NODE_ENV === 'production', config => {
config.entry('app').clear().add('./src/main-prod.js') // 设置入口文件为main-prod.js
config.set('externals', { // 打包时不会将以下库打进包内
vue: 'Vue',
'vue-router': 'VueRouter',
lodash: '_',
axios: 'axios',
echarts: 'echarts'
})
config.plugin('html').tap(args => {
args[0].isProd = true
args[0].title = '电商实时监控大屏'
return args
})
})

config.when(process.env.NODE_ENV === 'development', config => {
config.entry('app').clear().add('./src/main-dev.js')
config.plugin('html').tap(args => {
args[0].isProd = true
args[0].title = 'dev - 电商实时监控大屏'
})
})
}
}

4. main-dev.js和main-prod.js

main-dev.js:

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
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import * as echarts from 'echarts'

import './assets/css/global.css'
import './assets/font/iconfont.css'

import SocketService from './utils/socket_service'

SocketService.Instance.connect()
Vue.prototype.$socket = SocketService.Instance

axios.defaults.baseURL = 'http://localhost:9997/api/'
Vue.prototype.$http = axios

Vue.prototype.$echarts = echarts

import './assets/lib/theme/chalk'
import './assets/lib/theme/westeros'

Vue.config.productionTip = false

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

main-prod.js:

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
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import * as echarts from 'echarts'

import './assets/css/global.scss'
import './assets/font/iconfont.css'

import SocketService from './utils/socket_service'

SocketService.Instance.connect()
Vue.prototype.$socket = SocketService.Instance

axios.defaults.baseURL = 'https://ghobam.com/project/eshop_echarts/api/'
Vue.prototype.$http = axios

Vue.prototype.$echarts = echarts

import './assets/lib/theme/chalk'
import './assets/lib/theme/westeros'

Vue.config.productionTip = false

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

5. 修改index.html

在中间插入下面这段代码,当环境为生产环境时,从外部CDN引入对应的库

1
2
3
4
5
6
7
8
<% if(htmlWebpackPlugin.options.isProd) {%>
<!-- CDN JS -->
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.2.0/vue-router.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/echarts/4.8.0/echarts.min.js"></script>
<% } %>

6. babel.config.js

1
2
3
4
5
6
7
8
9
10
11
const prodPlugins = []
if (process.env.NODE_ENV === 'production') prodPlugins.push('transform-remove-console')

module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
],
plugins: [
...prodPlugins
]
}

7. global.scss

全局样式的定制:

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
# 初始化基础样式
html, body, #app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

# page页面的样式
.com-page {
width: 100%;
height: 100%;
overflow: hidden;
}

# 图标最外层样式
.com-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative
}

# 图表样式
.com-chart {
width: 100%;
height: 100%;
overflow: hidden;
}

# 图标面板样式
canvas {
border-radius: 20px
}

三、后台搭建


后台使用Koa+json来搭建,Koa[1]是一个基于Node.js的Web开发框架,开发者可以通过给Koa添加中间件来实现自己的功能。

1. 创建项目

1
2
mkdir koa-server
npm init

复制data文件夹中的json数据到项目根目录

2. 安装依赖

1
npm i ws koa -S // koa和websocket组件库

3. app.js

主要做两件事:启动Koa和启动WebSocket服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const Koa = require('koa')
const app = new Koa()

// 注册响应时长中间件
const respDurationMiddleware = require('./middleware/koa_response_duration')
app.use(respDurationMiddleware)

// 注册跨域中间件
const respHeaderMiddleware = require('./middleware/koa_response_header')
app.use(respHeaderMiddleware)

// 注册请求处理中间件
const respDataMiddleware = require('./middleware/koa_response_data')
app.use(respDataMiddleware)

// 监听9997端口
app.listen('9997', () => console.log('Server Start Complete: \thttp://localhost:9997\tws://localhost:9998'))

// 启动ws服务
const wsService = require('./services/web_socket_services')
wsService.listen()

4. 添加响应时长中间件

1
2
3
4
5
6
7
8
module.export = async (ctx, next) => { // ctx代表context,包含了request,response等参数,详情可以参考文档
const start = Date.now()
await next() // next会将控制权移交给下一个中间件
const end = Date.now()

const duration = end - start
ctx.set('X-Response-Time', duration + 'ms')
}

5. 添加跨域中间件

1
2
3
4
5
6
7
module.export = async (ctx, next) => {
const contentType = 'application/json; charset=utf-8'
ctx.set('Content-Type', contentType)
ctx.set('Access-Control-Allow-Origin', '*')
ctx.set('Access-Control-Allow-Method', 'GET, POST, PUT, OPTION, DELETE')
await next()
}

6. 添加处理请求中间件

该组件需要读取本地的json文件,可以先封装一个读取文件的模块file_utils.js:

1
2
3
4
5
6
7
8
9
const fs = require('fs')
module.export.getFileJsonData = filePath => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf-8', (err, data) => {
if (err) reject(err)
resolve(data)
})
})
}

接着引入file_utils.js,完成请求处理的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const path = require('path')
const fileUtils = require('./utils/file_utils.js')
module.export = async (ctx, next) => {
const url = ctx.request.url
let filePath = url.replace('/api', '')
filePath = `../data${filePath}`
filePath = path.join(__dirname, filePath)
try {
const result = await fileUtils.getFileJsonData(filePath)
ctx.response.body = result
}
catch(err) {
const errMsg = {
message: '读取文件内容失败,资源不存在',
status: '404'
}
console.log('请求失败:', errMsg)
ctx.response.body = JSON.stringify(errMsg)
}
await next()
}

7. 添加Websocket模块

选用websocket是借助ws建立连接后一直保持连接的特性,让后台数据可以实时推送给前台。方便后续多端统一全屏和切换主题的功能

web_socket_services.js:

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
const path = require('path')
const WebSocket = require('ws')
const fileUtils = require('./utils/file_utils.js')

const wss = new WebSocker.Server({
port: 9998
})

module.export.listen = () => {
wss.on('connection', client => {
client.on('message', async msg => {
let payload = JSON.parse(msg)
const action = payload.action
// 请求数据的直接从本地读取
if (action === 'getData') {
let filePath = `../data/${payload.chartName}.json`
filePath = path.join(__dirname, filePath)
const res = await fileUtils.getFileJsonData(filePath)
payload.data = res
client.send(JSON.stringify(payload))
}
// 其他操作广播给所有连接到ws的客户端
else {
wss.clients.forEach(client => client.send(JSON.stringify(payload)))
}
})
})
}

四、前端Websocket组件


socket_service.js:

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
export default class SocketService {
static instance = null
static Instance() {
if (!this.instance) this.instance = new SocketService()
return this.instance
}

ws = null
connected = false
sendRetryCount = 0
connectRetryCount = 0
callbackMapping = {} // 用于保存回调函数

connect() {
// 用的浏览器自带的WebSocket组件
if (!window.WebSocket) console.log('该浏览器不支持WebSocket')
if (process.env.NODE_ENV === 'development') {
this.ws = new WebSocket('ws://localhost:9998')
}
else {
// wss相当于https
this.ws = new WebSocket('wss://ghobam.com/project/eshop_echarts/ws')
}

this.ws.onopen = () => {
this.connected = true
this.connectRetryCount = 0
console.log('WS连接成功!')
}

this.ws.onmessage = msg => {
console.log('msg:', msg)
const recvData = JSON.parse(msg.data)
const socketType = recvData.socketType
if (this.callbackMapping[socketType]) {
const action = recvData.action
if (action === 'getData') {
const respData = JSON.parse(respData.data)
this.callbackMapping[socketType].call(this, respData)
}
else {
this.callbackMapping[socketType].call(this, recvData)
}
}
}

this.ws.onerror = () => {
this.connected = false
console.log('连接WS失败!')
}

// 为了保持连接,在意外关闭时,尝试重新连接服务端
this.ws.onclose = () => {
this.connectRetryCount ++
this.connected = false
console.log('连接关闭')
setTimeout(() => {
this.connect()
}, this.connectRetryCount * 500) // 每次尝试的等待时间越来越长
}
}

// 注册回调函数
registerCallback(socketType, callback) {
this.callbackMapping[socketType] = callback
}

// 注销回调函数
unRegisterCallback(socketType, callback) {
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)
}
}
}

五、Home页面


1. 先定一个主题工具类,用于保存不同主题的样式:

theme_utils.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const theme = {
chalk: {
backgroundColor: '#161522',
titleColor: '#FFF',
themeSrc: 'qiehuan_dark.png',
headerBorderSrc: 'header_border_dark.png',
sellerAxisPointerColor: '#2D3443'
},
westeros: {
backgroundColor: '#ddd',
titleColor: '#000',
themeSrc: 'qiehuan_light.png',
headerBorderSrc: 'header_border_light.png',
sellerAxisPointerColor: '#f1f2f6'
}
}

export function getThemeValue(themeName) {
return theme[themeName]
}

2. 完成store/index.js中的数据共享逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
state: {
theme: 'chalk'
},
mutations: {
changeTheme(state) {
if (state.theme === 'chalk') state.theme = 'westeros'
else state.theme = 'chalk'
}
}
})
  1. 完善Home.vue页面
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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
<template>
<!-- 设置背景颜色跟文字颜色 -->
<div class="screen-container" :class="containerStyle">
<header class="screen-header">
<div>
<!-- 标题的背景框 -->
<img :src="headerSrc" />
</div>
<span class="title">电商实时监控大屏</span>
<div class="title-right">
<img :src="themeSrc" class="qiehuan" @click="onThemeChange" alt="切换主题" title="切换主题" />
<div class="datetime">{{ systemDateTime }}</div>
</div>
</header>
<div class="screen-body">
<section class="screen-left">
<!-- 使用fullScreenStatus来控制图标的全屏状态 -->
<div id="left-top" :class="{ fullscreen: fullScreenStatus.trend }">
<trend ref="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="{ fullscreen: fullScreenStatus.seller }">
<seller ref="seller" />
<div class="resize">
<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="{ fullscreen: fullScreenStatus.map }">
<single-map ref="map" />
<div class="resize">
<span @click="changeSize('map')" :class="['iconfont', fullScreenStatus.map ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
<div id="middle-bottom" :class="{ fullscreen: fullScreenStatus.rank }">
<rank ref="rank" />
<div class="resize">
<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="{ fullscreen: fullScreenStatus.hot }">
<hot ref="hot" />
<div class="resize">
<span @click="changeSize('hot')" :class="['iconfont', fullScreenStatus.hot ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
<div id="right-bottom" :class="{ fullscreen: fullScreenStatus.stock }">
<stock ref="stock" />
<div class="resize">
<span @click="changeSize('stock')" :class="['iconfont', fullScreenStatus.stock ? 'icon-compress-alt' : 'icon-expand-alt']"></span>
</div>
</div>
</section>
</div>
</div>
</template>

<script>
import { mapState } from 'vuex'
import { getThemeValue } from 'utils/theme_utils'

import { Trend } from 'components/Trend.vue'
import { Seller } from 'components/Seller.vue'
import { Map } from 'components/Map.vue'
import { Rank } from 'components/Rank.vue'
import { Hot } from 'components/Hot.vue'
import { Stock } from 'components/Stock.vue'

export default {
name: "Home",
components: {
Trend, Seller, 'single-map': Map, Rank, Hot, Stock
},
data() {
return {
systemDateTime: null,
timeId: null,
fullScreenStatus: {
trend: false,
seller: false,
map: false,
rank: false,
hot: false,
stock: false
}
}
},
created() {
this.currentTime()
// 注册切换主题的回调
this.$socket.registerCallback('themeChange', this.recvThemeChange)
// 注册全屏回调
this.$socket.registerCallback('fullScreen', this.recvFullScreen)
},
methods: {
onThemeChange() {
// 通过socket连接向后台发送切换主题的请求,后台会将请求广播给每一个客户端
this.$socket.send({
action: 'themeChange',
socketType: 'themeChange',
chartName: '',
value: ''
})
},
currentTime() {
// 获得本时区的时间
this.systemDateTime = new Date().toLocaleString()
// 先清除掉残留的计时器
this.timeId && clearInterval(this.timeId)
// 注册计时器,每秒更新一次时间
this.timeId = setInterval(() => {
this.systemDateTime = new Date().toLocaleString()
}, 1000)
},
recvThemeChange() {
// 通知Vuex该切换主题了
this.$store.commit('changeTheme')
},
changeSize(chartName) {
const targetValue = !this.fullScreenStatus[chartName]
this.$socket.send({
action: 'fullScreen',
socketType: 'fullScreen',
chartName: chartName,
value: targetValue
})
},
recvFullScreen(data) {
const chartName = data.chartName
const targetValue = data.value

// 修改全屏状态
this.fullScreenStatus[chartName] = targetValue

// 在下一帧调整图表的尺寸
this.$nextTick(() => {
this.$refs[chartName].screenAdapter()
})
}
},
computed: {
...mapState(['theme']),
//
containerStyle() {
return {
backgroundColor: getThemeValue(this.theme).backgroundColor
color: getThemeValue(this.theme).titleColor
}
},
headerSrc() {
// 这里必须带上require,否则编译后的相对路径不会自动转换成正确的路径
return require('assets/images/' + getThemeValue(this.theme).headerBorderSrc)
},
// 切换按钮的图片
themeSrc() {
return require('assets/images/' + getThemeValue(this.theme).themeSrc)
}
}
}
</script>

<style lang="scss" scoped>
.screen-container {
width: 100%;
height: 100%;
padding: 0 20px;
background-color: #161522;
color: #fff;
box-sizing: border-box;

.screen-header {
width: 100%;
height: 64px;
font-size: 20px;
position: relative;
> div [
img {
width: 100%;
}
]

.title {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
}

.title-right {
display: flex;
align-items: center
position: absolute;
right: 0;
top: 50%;
transfrom: translateY(-80%);

.qiehuan {
width: 28px;
height: 21px;
cursor: pointer;
}

.datetime {
font-size: 15px;
margin-left: 10px;
}
}
}

.screen-body {
width: 100%;
height: 100%;
margin-top: 10px;
display: flex;
.screen-left {
height: 100%;
width: 27.6%;
#left-top {
height: 53%;
position: relative;
}
#left-bottom {
height: 31%;
position: relative;
margin-top: 25px;
}
}
.screen-middle {
height: 100%;
width: 41.5%;
margin-left: 1.6%;
margin-right: 1.6%;
#middle-top {
height: 100%;
width: 56%;
position: relative;
}
#middle-bottom {
margin-top: 25px;
height: 100%;
height: 28%;
position: relative;
}
}
.screen-right {
height: 100%;
width: 27.6%;
#right-top {
height: 46%;
position: relative;
}
#right-bottom {
height: 38%;
margin-top: 25px;
position: relative;
}
}
}
}

.resize {
position: absolute;
right: 20px;
top: 20px;
cursor: pointer;
}

.fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
z-index: 999 !important;
}
</style>

六、Trend页面


地区销量趋势折线图Trend.vue

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
<template>
<div class="com-container">
<div class="title" @click="showMenu = !showMenu" : style="comStyle">
<span class="before-icon"></span>
<span>{{ showTitle }}</span>
<!-- 下拉框的箭头图标 -->
<span class="iconfont title-icon" :style="comStyle">&#xe6eb;</span>
<!-- 下拉选择面板 -->
<div class="select-con">
<div class="select-item" v-show="showMenu" @click.prevent="handleSelect(item.key)" v-for="item in selectTypes" :key="item.key">
{{ item.text }}
</div>
</div>
</div>
<div class="com-chart" ref="trendRef"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getThemeValue } from 'utils/theme_utils'

export default {
name: 'Trend',
data() {
return {
showMenu: false,
allData: null,
activeName: 'map',
chartIns: null,
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)'],
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)'],
titleFontSize: 0
}
},
mounted() {
// 不在created中注册是因为切换页面的时候,注册函数会在destoryed中被销毁
this.$socket.registerCallback('trendData', this.getData)
// 发送获取trend数据的请求
this.$socket.send({
action: 'getData',
socketType: 'trendData',
chartName: 'trend',
value: ''
})
this.initChart()
// 图表自适应
this.screenAdapter()
window.addEventListener('resize', this.screenAdapter)
},
destroyed() {
window.removeEventListener('resize', this.screenAdapter)
this.$socket.unRegisterCallback('treanData')
},
methods: {
// 初始化图表
initChart() {
this.chartIns = this.$echarts.init(this.$refs.trendRef, this.theme)
const initOpt = {
gird: {
left: '3%',
top: '35%',
right: '4%',
bottom: '1%',
containLabel: true
},
tooltip: {
trigger: 'axis'
},
legend: {
left: 'center',
top: '18%',
icon: 'circle'
},
xAxis: {
type: 'category',
boundaryGap: false
},
yAxis: {
type: 'value'
}
}
this.chartIns.setOption(initOpt)
},
// 刷新图表
updateChart() {
const month = this.allData.common.month
const valueArr = this.allData[this.activeName].data
const seriesArr = valueArr.map((item, index) => {
return {
name: item.name,
type: 'line',
data: item.data,
// 同一组数据的折线图会堆叠展示
stack: this.activeName,
areaStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: this.colorArr1[index]
},
{
offser: 1,
color: this.colorArr2[index]
}
])
}
}
})

const legendArr = valueArr.map(item => item.name)
const dataOption = {
xAxis: {
data: month
},
yAxis: {
data: legendArr
},
series: seriesArr
}

this.chartIns.setOption(seriesArr)
},
getData(res) {
this.allData = res
this.updateChart()
},
// 更新当前选中面板
onSelect(curType) {
this.activeName = curType
this.updateChart()
},
screenAdapter() {
this.titleFontSize = (this.$refs.trendRef.offsetWidth / 100) * 3.6

const adapterOption = {
legend: {
itemWidth: this.titleFontSize,
itemHeight: this.titleFontSize,
// item之间的间隔
itemGap: this.titleFontSize,
textStyle: {
fontSize: this.titleFontSize / 1.3
}
}
}

this.chartIns.setOption(adapterOption)
this.chartIns.resize()
}
},
computed: {
...mapState(['theme']),
// 标题的文字大小和文字颜色
comStyle() {
return {
fontSize: this.titleFontSize + 'px',
color: getThemeValue(this.theme).titleColor
}
},
showTitle() {
if (!this.allData) return ''
// 通过修改activeName来达到切换title的效果
return this.allData[this.activeName].title
},
selectTypes() {
if (!this.allData) return []
// 过滤掉当前展示的图表名称
return this.allData.type.filter(item => item.key !== this.activeName)
}
},
watch: {
// 当主题切换时,执行以下操作
theme() {
// 重新绘制图表
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>
<style lang="scss" scoped>
.title {
position: absolute;
top: 20px;
left: 50px;
z-index: 999;
color: white;
cursor: pointer;
.before-icon {
left: -20px;
position: absolute;
}
.title-icon {
margin-left: 10px;
}
}
</style>

七、Seller页面


商家销售统计横向柱形图Seller.vue

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
<template>
<div class="com-container">
<div class="com-chart" ref="sellerRef"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getThemeValue } from 'utils/theme_utils'

export default {
name: 'Seller',
data() {
return {
chartIns: null,
allData: null,
timerId: null,
totalPage: 0,
currentPage: 1
}
},
mounted() {
this.$socket.registerCallback('sellerData', this.getData)
this.initChart()
this.$socket.send({
action: 'getData',
socketType: 'sellerData',
chartName: 'seller',
value: ''
})
this.screenAdapter()
window.addEventListener('resize', this.screenAdapter)
},
destoryed() {
window.removeEventListener('resize', this.screenAdapter)
this.$socket.unRegisterCallback('sellerData')
this.timerId && clearInterval(this.timerId)
},
methods: {
getData(res) {
this.allData = res
this.allData.sort((a, b) => b.value - a.value)
this.totalPage = Math.ceil(this.allData.length / 5)

this.updateChart()
this.startInterval()
},
initChart() {
this.chartIns = this.$echarts.init(this.$refs.sellerRef, this.theme)
const initOpt = {
title: {
text: '▎商家销售统计',
top: 20,
left: 20
},
grid: {
top: '20%',
left: '3%',
right: '6%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value'
},
yAxis: {
type: 'category'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: {
color: this.axisPointerColor
},
z: 0
}
},
series: [
{
type: 'bar',
label: {
show: true,
position: 'right',
textStyle: {
color: 'white'
}
},
itemStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#5052EE' },
{ offset: 1, color: '#AB6EE5' }
])
}
}
]
}

this.chartIns.setOption(initOpt)

this.chartIns.on('mouseover', () => {
this.timerId && clearInterval(this.timerId)
})
this.chartIns.on('mouseout', () => {
this.startInterval()
})
},
startInterval() {
this.timeId && clearInterval(this.timerId)

// 每三秒根据page切换一次数据
this.timerId = setInterval(() => {
this.currentPage ++
if (this.currentPage > this.totalPage) this.currentPage = 1
this.updateChart()
}, 3000)
},
updateChart() {
// 截取
const start = (this.currentPage - 1) * 5
const end = this.currentPage * 5
const showData = this.allData.slice(start, end)

const sellerNames = showData.map(item => item.name)
const sellerValue = showData.map(item => item.value)

const dataOpt = {
yAxis: {
data: sellerNames
},
series: [
{
data: sellerValue
}
]
}

this.chartIns.setOption(dataOpt)
},
screenAdapter() {
const titleFontSize = (this.$refs.sellerRef.offsetWidth / 100) * 3.6

const adapterOpt = {
title: {
textStyle: {
fontSize: titleFontSize
}
},
tooltip: {
axisPointer: {
lineStyle: {
width: titleFontSize
}
}
},
series: [
{
// 柱形图宽度
barWidth: titleFontSize,
itemStyle: {
// 柱形图圆角效果
barBorderRadius: [0, titleFontSize / 2, titleFontSize / 2, 0]
}
}
]
}

this.chartIns.setOption(adapterOpt)
this.chartIns.resize()
}
},
computed: {
...mapState(['theme']),
axisPointerColor() {
return getThemeValue(this.theme).sellerAxisPointerColor
}
},
watch: {
theme() {
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>

八、Map页面


首先创建一个地图工具类map_utils.js,用来获取各省份的信息:

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
各省份对应的表格名称
const nameChange = {
安徽: 'anhui',
陕西: 'shanxi1',
澳门: 'aomen',
北京: 'beijing',
重庆: 'chongqing',
福建: 'fujian',
甘肃: 'gansu',
广东: 'guangdong',
广西: 'guangxi',
贵州: 'guizhou',
海南: 'hainan',
河北: 'hebei',
黑龙江: 'heilongjiang',
河南: 'henan',
湖北: 'hubei',
湖南: 'hunan',
江苏: 'jiangsu',
江西: 'jiangxi',
吉林: 'jilin',
辽宁: 'liaoning',
内蒙古: 'neimenggu',
宁夏: 'ningxia',
青海: 'qinghai',
山东: 'shandong',
上海: 'shanghai',
山西: 'shanxi',
四川: 'sichuan',
台湾: 'taiwan',
天津: 'tianjin',
香港: 'xianggang',
新疆: 'xinjiang',
西藏: 'xizang',
云南: 'yunnan',
浙江: 'zhejiang'
}

export function getProvinceMaoInfo(arg) {
const path = `map/province/${nameChange[arg]}.json`
return {
key: nameChange[arg],
path: path
}
}

国内会员分布图Map.vue

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
<template>
<div class="com-container" @dbclick="chinaMap">
<div class="com-chart" ref="mapRef"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getProvinceMapInfo } from 'utils/map_utils.js'

export default {
name: 'Map',
data() {
return {
chartIns: null,
allData: null,
chinaMapData: null,
cityMapData: {}
}
},
mounted() {
this.$socket.registerCallback('mapData', this.getData)
this.$socket.send({
action: 'getData',
socketType: 'mapData',
chartData: 'map',
value: ''
})
this.initChart()
window.addEventListener('resize', this.screenAdapter)
this.screenAdapter()
},
destoryed() {
window.removeEventListener('resize', this.screenAdapter)
this.$socket.unRegisterCallback('mapData')
},
methods: {
async initChart() {
// 主题指的是assest/lib/theme中的chalk.js和westeros,这俩在最初main-dev.js引入时就被注册到echarts中
this.chartIns = this.$echarts.init(this.$refs.mapRef, this.theme)

// 通过axios获取中国地图的数据
if (!this.chinaMapData) {
const { data: res } = await this.$http.get('map/china.json')
this.chinaMapData = res
}

// 注册中国地图
this.$echarts.registerMap('china', this.chinaMapData)

const initOpt = {
title: {
text: '▎商家分布',
top: 20,
left: 20
},
geo: {
type: 'map',
map: 'china',
left: 'center',
top: 'center',
// 支持拖拽和缩放
roam: true,
zoom: 1.1,
itemStyle: {
areaStyle: '#2E72BF',
borderColor: '#333'
},
label: {
show: true,
color: 'white',
formatter: '{a}'
}
}
}

this.chartIns.setOption(initOpt)

// 点击省份时切换成省份的大图
this.chartIns.on('click', async e => {
const provinceInfo = getProvinceMapInfo(e.name)

// 将省份的地图数据缓存起来
if (!this.cityMapData[provinceInfo.key]) {
const { data: res } = await this.$http.get(provinceInfo.path)
this.cityMapData[provinceInfo.key] = res
this.$echarts.registerMap(provinceInfo.key, res)
}

const changeOpt = {
geo: {
map: provinceInfo.key,
// 每次切换地图时需要重新设置下颜色,否则颜色会丢失
itemStyle: {
areaColor: '#2E72BF',
borderColor: '#333'
},
label: {
show: true,
color: 'white',
formatter: '{a}'
}
}
}

this.chartIns.setOption(changeOpt)
})
},
getData(res) {
this.allData = res
this.updateChart()
},
updateChart() {
const legendArr = this.allData.map(item => item.name)
const seriesArr = this.allData.map(item => {
return {
type: 'effectScatter',
name: item.name,
data: item.children,
// 指定坐标系统为经纬度
coordinationSystem: 'geo',
// 涟漪效果
rippleEffect: {
scale: 10,
brushType: 'stroke'
}
}
})

const dataOpt = {
legend: {
left: '2%',
bottom: '5%',
oriend: 'verticle',
data: legendArr.reverse()
},
series: seriesArr
}

this.chartIns.setOption(dataOpt)
},
screenAdapter() {
const titleFontSize = (this.$refs.mapRef.offsetWidth / 100) * 3.6

const adapterOpt = {
title: {
textStyle: {
fontSize: titleFontSize
}
},
legend: {
itemWidth: titleFontSize / 2,
itemHeight: titleFontSize / 2,
itemGap: titleFontSize / 2,
textStyle: {
fontSize: titleFontSize / 2
}
}
}

this.chartIns.setOption(adapterOpt)
this.chartIns.resize()
},
// 双击变回中国地图
chinaMap() {
const chinaMapOpt = {
geo: {
map: 'china',
itemStyle: {
areaStyle: '#2E72BF',
borderStyle: '#333'
},
label: {
show: true,
color: 'white',
formatter: '{a}'
}
}
}

this.chartIns.setOption(chinaMapOpt)
}
},
computed: {
...mapState(['theme'])
},
watch: {
theme() {
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>

九、Rank页面


地区销量排行柱形图Rank.vue

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
<template>
<div class="com-container">
<div class="com-chart" ref="rankRef"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'

export default {
name: 'Rank',
data() {
return {
chartIns: null,
allData: null,
timerId: null,
startVal: 0,
endVal: 9,
colorArr: [
['#0BA82C', '#4FF778'],
['#2E72BF', '#23E5E5'],
['#5052EE', '#AB6EE5']
]
}
},
mounted() {
this.$socket.registerCallback('rankData', this.getData)
this.$socket.send({
action: 'getData',
socketType: 'rankData',
chartName: 'rank',
value: ''
})
this.initChart()
this.screenAdapter()
window.addEventListener('resize', this.screenAdapter)
},
destoryed() {
window.removeEventListener('resize', this.screenAdapter)
this.$socket.unRegisterCallback('rankData')
this.timerId && clearInterval(this.timerId)
},
methods: {
initChart() {
this.chartIns = this.$echarts.init(this.$refs.rankRef, this.theme)

const initOpt = {
title: {
text: '▎地区销售排行',
left: 20,
top: 20
},
tooltip: {
show: true
},
grid: {
top: '40%',
left: '5%',
right: '5%',
bottom: '5%',
containLabel: true
},
xAxis: {
type: 'category'
},
yAxis: {
type: 'value'
},
series: [
{
type: 'bar',
label: {
position: 'top',
show: true,
color: 'white',
rotate: 30
}
}
]
}

this.chartIns.setOption(initOpt)

this.chartIns.on('mouseover', () => this.timerId && clearInterval(this.timerId))
this.chartIns.on('mouseout', () => this.startInterval())
},
getData(res) {
this.allData = res
this.allData.sort((a, b) => b.value - a.value)
this.updateChart()
this.startInterval()
},
updateChart() {
const provinceInfo = this.allData.map(item => item.name)
const valueArr = this.allData.map(item => item.value)

const dataOpt = {
xAxis: {
data: provinceInfo
},
// 只显示指定区域的部分数据
dataZoom: {
// 是否显示大纲组件
show: false,
// 只显示十个数据
startValue: this.startVal,
endValue: this.endVal
},
series: [
{
data: valueArr,
itemStyle: {
color: arg => {
let targetColorArr = null
if (arg > 300) {
targetColorArr = this.colorArr[0]
}
else if (arg > 200) {
targetColorArr = this.colorArr[1]
}
else {
targetColorArr = this.colorArr[2]
}

return new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: targetColorArr[0] },
{ offset: 1, color: targetColorArr[1] }
])
}
}
}
]
}

this.chartIns.setOption(dataOpt)
},
screenAdapter() {
const titleFontSize = (this.$refs.rankRef.offsetWidth / 100) * 3.6

const adapterOpt = {
title: {
textStyle: {
fontSize: titleFontSize
}
},
series: [
{
barWidth: titleFontSize,
itemStyle: {
barBorderRadius: [0, titleFontSize / 2, titleFontSize / 2, 0]
}
}
]
}

this.chartIns.setOption(adapterOpt)
this.chartIns.resize()
},
startInterval() {
this.timerId && clearInterval(this.timerId)

this.timerId = setInterval(() => {
this.startVal ++
this.endVal ++
if (this.endVal >= this.allData.length) {
this.startVal = 0
this.endVal = 9
}
this.updateChart()
}, 2000)
}
},
computed: {
...mapState(['theme'])
},
watch: {
theme() {
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>

十、Hot页面


热销商品占比饼图 Hot.vue

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
<template>
<div class="com-container">
<div class="com-chart" ref="hotRef"></div>
<!-- 增加左右箭头,点击时切换显示不同商品类别的饼图 -->
<i class="iconfont icon-left" @click="toLeft" :style="themeStyle">&#xe6ef;</i>
<i class="iconfont icon-right" @click="toRight" :style="themeStyle">&#xe6ed;</i>
<span class="cate-name" :style="themeStyle">{{ cateName }}</span>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getThemeValue } from 'utils/theme_utils.js'
import _ from 'lodash'

export default {
name: 'Hot',
data() {
return {
chartIns: null,
allData: null,
curIndex: 0,
titleFontSize: 0
}
},
mounted() {
this.$socket.registerCallback('hotData', this.getData)
this.$socket.send({
action: 'getData',
socketType: 'hotData',
chartName: 'hotproduct',
value: ''
})

this.initChart()

this.screenAdapter()
window.addEventListener('resize', this.screenAdapter)
},
destoryed() {
this.$socket.unRegisterCallback('hostData')
window.removeEventListener('resize', this.screenAdapter)
},
methods: {
initChart() {
this.chartIns = this.$echarts.init(this.$refs.hotRef, this.theme)

const initOpt = {
title: {
text: '▎热销商品占比',
top: 20,
left: 20
},
legend: {
top: '15%',
// 说明文字圆点图表
icon: 'circle'
},
tooltip: {
show: true,
formatter: arg => {
const thirdCategory = arg.data.children

let total = thirdCategory.reduce((a, b) => a.value + b.value)

let showStr = ''
// 以"商品:占比%"的形式展示提示框
thirdCategory.forEach(item => showStr += `${item.name}: ${_.round((item.value / total) * 100, 2)}% <br/>`)
return showStr
}
},
series: [
{
type: 'pie',
label: {
show: true,
formatter: '{b}{d}%'
},
emphasis: {
labelLine: {
show: true
}
}
}
]
}

this.chartIns.setOption(initOpt)
},
getData(res) {
this.allData = res
this.updateChart()
},
updateChart() {
const legendDataArr = this.allData[this.curIndex].children.map(item => item.name)
const seriesDataArr = this.allData[this.curIndex].children.map(item => {
name: item.name,
value: item.value,
children: item.children
})

const dataOpt = {
legend: {
data: legendDataArr
},
series: [
{
data: seriesDataArr
}
]
}

this.chartIns.setOption(dataOpt)
},
screenAdapter() {
this.titleFontSize = (this.$refs.hotRef.offsetWidth / 100) * 3.6

const adapterOpt = {
title: {
textStyle: {
fontSize: this.titleFontSize
}
},
legend: {
itemWidth: this.titleFontSize,
itemHeight: this.titleFontSize,
itemGap: this.titleFontSize / 2,
textStyle: {
fontSize: this.titleFontSize / 1.2
}
},
series: [
{
radius: this.titleFontSize * 4.5,
// 圆心的x和y坐标
center: ['50%', '60%'],
label: {
fontSize: this.titleFontSize * .8
}
}
]
}
this.chartIns.setOption(adapterOpt)
this.chartIns.resize()
},
toLeft() {
this.curIndex --
if (this.curIndex < 0) this.curIndex = this.allData.length - 1
this.updateChart()
},
toRight() {
this.curIndex ++
if (this.curIndex >= this.allData.length) this.curIndex = 0
this.updateChart()
}
},
computed: {
...mapState(['theme']),
cateName() {
if (!this.allData) return ''
return this.allData[this.curIndex].name
},
themeStyle() {
let tStyle = { color: getThemeValue(this.theme).titleColor }
if (this.titleFontSize) tStyle['fontSize'] = this.titleFontSize + 'px'
return tStyle
}
},
watch: {
theme() {
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>
<style lang="scss" scoped>
i {
z-index: 999;
top: 50%;
transform: translateY(-50%);
position: absolute;
cursor: pointer

&.icon-left {
left: 5%;
}
&.icon-right {
right: 5%;
}
}
.cate-name {
position: absolute;
right: 10%;
bottom: 20px;
z-index: 999;
}
</style>

十一、Stock页面


库存和销量分析圆环图 Stock.vue

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
<template>
<div class="com-container">
<div class="com-chart" ref="stockRef"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'

export default {
name: 'Stock',
data() {
return {
chartIns: null,
allData: null,
timerId: null,
curIndex: 1,
// 五个圆环的位置
centerArr: [
['18%', '40%'],
['50%', '40%'],
['82%', '40%'],
['34%', '75%'],
['66%', '75%'],
],
// 五个圆环的颜色
colorArr: [
['#4FF778', '#0BA82C'],
['#E5DD45', '#E8B11C'],
['#E8821C', '#E55445'],
['#5052EE', '#AB6EE5'],
['#23E5E5', '#2E72BF'],
],
}
},
mounted() {
this.$socket.registerCallback('stockData', this.getData)
this.$socket.send({
action: 'getData',
socketType: 'stockData',
chartName: 'stock',
value: ''
})

this.initChart()

this.screenAdapter()
window.addEventListener('resize', this.screenAdapter)
},
destoryed() {
this.$socket.unRegisterCallback('stockData')
window.removeEventListener('resize', this.screenAdapter)
this.timerId && clearInterval(this.timerId)
},
methods: {
initChart() {
this.chartIns = this.$echarts.init(this.$refs.stockRef, this.theme)

const initOpt = {
title: {
text: '▎库存和销量分析',
top: 20,
left: 20
}
}

this.chartIns.setOption(initOpt)
this.chartIns.on('mouseover', () => this.timerId && clearInterval(this.timerId))
this.chartIns.on('mouseout', () => this.startInterval())
},
getData(res) {
this.allData = res
this.updateChart()
},
updateChart() {
const start = (this.curIndex - 1) * 5
const end = start + 5

const showData = this.allData.slice(start, end)

let seriesArr = showData.map((item, index) => {
return {
type: 'pie',
center: this.centerArr[index],
hoverAnimation: false,
labelLine: {
show: false
},
label: {
position: 'center',
color: this.colorArr[index][0]
},
data: [
// 圆环的右半,即销量
{
name: item.name + '\n\n' + item.sales,
value: item.sales,
itemStyle: {
color: new this.$echart.graphic.LinearGradien(0, 0, 0, 1, [
{ offset: 0, color: this.colorArr[index][0] },
{ offset: 1, color: this.colorArr[index][1] }
])
},
tooltip: {
formatter: `${item.name} <br/>销量:{c} <br/>占比:{d}%`
}
},
// 圆环的左半,即库存
{
value: item.stock,
itemStyle: {
color: '#bbb'
},
tooltip: {
formatter: `${item.name} <br/>库存:{c} <br/>占比:{d}%`
}
}
]
}
})

const dataOpt = {
tooltip: {
trigger: 'item'
},
series: seriesArr
}

this.chartIns.setOption(dataOpt)
this.startInterval()
},
screenAdapter() {
const titleFontSize = (this.$refs.stockRef.offsetWidth / 100) * 3.6
// 内环的半径
const innerRadius = titleFontSize * 2.8
// 外环的半径
const outerRadius = innerRadius * 1.2

const adapterOpt = {
title: {
textStyle: {
fontSize: titleFontSize
}
},
// 五个圆环
series: [
{
type: 'pie',
radius: [outerRadius, innerRadius],
label: {
fontSize: titleFontSize / 1.2
}
},
{
type: 'pie',
radius: [outerRadius, innerRadius],
label: {
fontSize: titleFontSize / 1.2
}
},
{
type: 'pie',
radius: [outerRadius, innerRadius],
label: {
fontSize: titleFontSize / 1.2
}
},
{
type: 'pie',
radius: [outerRadius, innerRadius],
label: {
fontSize: titleFontSize / 1.2
}
},
{
type: 'pie',
radius: [outerRadius, innerRadius],
label: {
fontSize: titleFontSize / 1.2
}
}
]
}

this.chartIns.setOption(adapterOpt)
this.chartIns.resize()
},
startInterval() {
this.timerId && clearInterval(this.timerId)

this.timerId = setInterval(() => {
this.curIndex ++
if (this.curIndex > Math.ceil(this.allData / 5)) this.curIndex = 1
this.updateChart()
}, 5000)
}
},
computed: {
...mapState(['theme'])
},
watch: {
theme() {
this.chartIns.dispose()
this.initChart()
this.updateChart()
this.screenAdapter()
}
}
}
</script>

十二、注册路由


在home路径下总览所有图表时,也希望可以单独访问对应的路径来观察单一全屏展示的图表。
要实现功能,就要给每个图表都创建单独的page,例如HotPage:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div class="com-page">
<hot />
</div>
</template>
<script>
import Hot from 'components/Hot'
export default {
name: 'HotPage',
components: { Hot }
}
</script>

以此类推,创建完所有Page之后,再注册到路由里面,就可实现访问单一图表的功能

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
import Vue from 'vue'
import VueRouter from 'vue-router'

import Home from 'views/Home'
import HotPage from 'views/HotPage'
import TrendPage from 'views/TrendPage'
import MapPage from 'views/MapPage'
import SellerPage from 'views/SellerPage'
import RankPage from 'views/RankPage'
import StockPage from 'views/StockPage'

Vue.use(VueRouter)

const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
component: Home
},
{
path: '/trend',
component: TrendPage
},
{
path: '/seller',
component: SellerPage
},
{
path: '/map',
component: MapPage
},
{
path: '/rank',
component: RankPage
},
{
path: '/hot',
component: HotPage
},
{
path: '/stock',
component: StockPage
}
]

const router = new VueRouter({
routes
})

export default router

十三、项目部署


1. 前端项目部署

直接使用之前写的部署脚本,
先在servers.json中指定远程路径,
接着在package.json中添加deploy属性即可:

1
2
3
"scripts": {
"deploy": "npm run build && node ../deploy.js --proj=eshop_echarts"
}

2. 后端项目部署

在服务器上通过git拉取项目之后,利用pm2将后台项目做成一个服务。
此时会在本地开放一个https的9997接口,以及wss的9998接口

https接口通过在nginx中添加规则转发请求:

1
2
3
4
5
6
7
8
location /project/eshop_echarts/api/ {
proxy_redirect off;
proxy_pass http://127.0.0.1:9997/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forward-Proto $scheme;
}

而wss接口的转发规则跟https相似,最大的不同是需要升级转成Socket协议,规则如下:

1
2
3
4
5
6
7
location /project/eshop_echarts/ws {
proxy_redirect off;
proxy_pass http://127.0.0.1:9998/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

这样发起请求的时候,nginx会识别对应的wss请求,并给wss请求添加切换协议的请求头,再转发给本地9998端口。



参考
视频教程
素材
前端项目
后台项目
where to find or how to set htmlWebpackPlugin.options.title in project created with vue cli 3?
echarts主题
vue img中的src使用变量引用
node.js监听文件变化
lodash文档
WebSocket 结合 Nginx 实现域名及 WSS 协议访问


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!