背景

自己做了一点点的小尝试:基于前馈神经网络 LSTM 的个体出行目的地预测模型,基于个体历史出行数据,模型可以实现出行目的地的实时动态预测功能。

模型其实具有实际应用功能,为了对其应用场景进行探索,拟开发一个全栈的项目,在Web客户端实现用户出行的动态预测效果,同时能够提供数据可视分析等功能。

项目地址

可视化效果

前端结构设计

下图是整个项目的结构,也就是采用Vue-cli脚手架搭建的前端项目结构

buile 文件夹存放项目构建脚本。
config 文件夹存放项目的配置信息,包括webpack配置及端口转发等。
dist/docs 文件夹存放的是项目构建后的内容,即编译出的项目代码。
node_modules 这个目录存放的是项目的所有依赖,即 npm install 命令下载下来的文件。
server 文件夹存放的是服务器相关代码与数据。
src 存放前端项目的源码。
static 存放项目的静态资源。
index.html 项目的入口页,也是唯一的HTML页面。
package.json 定义了项目的所有依赖,包括开发时依赖和发布时依赖。

其中前端开发的大多工作是在 src 文件夹下进行的,它的目录结构如下:

assets 文件夹存放资产文件。
components 文件夹存放项目公共的组件(.vue文件)。
lib 文件夹存放的是第三方库暴露出来的接口。
router 存放前端界面的路由逻辑js文件。
store 文件夹存放的是全局共享的变量。
utils 存放辅助函数脚本。
view 存放项目各独立界面(.vue文件)。
App.vue 一个vue组件,是第一个vue组件。
main.js 定义了项目启动的入口。
permission.js 界面初始化工作,包括动态界面的加载。

界面模版

前端界面模版是基于 vue-admin-template修改的,去除了登录功能,精简了界面逻辑。

界面模版最大的特点可以实现菜单栏的个性定制。

首先在 src/components/Index.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
<template>
<div class="index-vue">
<!-- 侧边栏 -->
<aside :class="asideClassName">
<!-- logo -->
<div class="logo-c pointer" @click="isShrinkAside" title="收缩/展开">
<img src="../assets/imgs/logo.png" alt="logo" class="logo">
<span v-show="isShowAsideTitle">驾驶分析系统</span>
</div>
<!-- 菜单栏 -->
<Menu class="menu" ref="asideMenu" theme="dark" width="100%" @on-select="gotoPage"
accordion :open-names="openMenus" :active-name="currentPage" @on-open-change="menuChange">
<!-- 动态菜单 -->
<div v-for="(item, index) in menuItems" :key="index">
<Submenu v-if="item.children" :name="index">
<template slot="title">
<Icon :size="item.size" :type="item.type"/>
<span v-show="isShowAsideTitle">{{item.text}}</span>
</template>
<div v-for="(subItem, i) in item.children" :key="index + i">
<Submenu v-if="subItem.children" :name="index + '-' + i">
<template slot="title">
<Icon :size="subItem.size" :type="subItem.type"/>
<span v-show="isShowAsideTitle">{{subItem.text}}</span>
</template>
<MenuItem class="menu-level-3" v-for="(threeItem, k) in subItem.children"
v-if="!threeItem.hidden" :name="threeItem.name" :key="index + i + k">
<Icon :size="threeItem.size" :type="threeItem.type"/>
<span v-show="isShowAsideTitle">{{threeItem.text}}</span>
</MenuItem>
</Submenu>
<MenuItem v-else-if="!subItem.hidden" :name="subItem.name">
<Icon :size="subItem.size" :type="subItem.type"/>
<span v-show="isShowAsideTitle">{{subItem.text}}</span>
</MenuItem>
</div>
</Submenu>
<MenuItem v-else-if="!item.hidden" :name="item.name">
<Icon :size="item.size" :type="item.type"/>
<span v-show="isShowAsideTitle">{{item.text}}</span>
</MenuItem>
</div>
</Menu>
</aside>
</div>
</template>
<script>
export default {
name: 'index',
data () {
return {
// 用于储存页面路径
paths: {},
// 当前显示页面
currentPage: '',
openMenus: [], // 要打开的菜单名字 name属性
menuCache: [], // 缓存已经打开的菜单
showLoading: false, // 是否显示loading
isShowRouter: true,
isShowAsideTitle: true, // 是否展示侧边栏内容
main: null, // 页面主要内容区域
asideClassName: 'aside-big', // 控制侧边栏宽度变化
asideArrowIcons: [], // 缓存侧边栏箭头图标 收缩时用
};
},
created () {
// 已经为ajax请求设置了loading 请求前自动调用 请求完成自动结束
// 添加请求拦截器
this.$axios.interceptors.request.use(config => {
this.showLoading = false;
// 在发送请求之前做些什么
return config;
}, error => {
this.showLoading = false;
// 对请求错误做些什么
return Promise.reject(error);
});
// 添加响应拦截器
this.$axios.interceptors.response.use(response => {
// 可以在这里对返回的数据进行错误处理 如果返回的 code 不对 直接报错或退出登陆
// 就可以省去在业务代码里重复判断
// 例子
// if (res.code != 0) {
// this.$Message.error(res.msg)
// return Promise.reject()
// }
this.showLoading = false;
const res = response.data;
return res;
}, error => {
this.showLoading = false;
// 对响应错误做点什么
return Promise.reject(error);
});
},
mounted () {
// 第一个标签
const name = this.$route.name;
this.currentPage = name;

// 根据路由打开对应的菜单栏
this.openMenus = this.getMenus(name);
this.$nextTick(() => {
this.$refs.asideMenu.updateOpened();
});

this.main = document.querySelector('.sec-right');
this.asideArrowIcons = document.querySelectorAll('aside .ivu-icon-ios-arrow-down');

// 监听窗口大小 自动收缩侧边栏
this.monitorWindowSize();
},
watch: {
$route (to) {
const name = to.name;
this.currentPage = name;
}
},
computed: {
// 菜单栏
menuItems () {
return this.$store.state.menuItems;
},

// 由于iView的导航菜单比较坑 只能设定一个name参数
// 所以需要在这定义组件名称和标签栏标题的映射表 有多少个页面就有多少个映射条数
nameToTitle () {
const obj = {};
this.menuItems.forEach(e => {
this.processNameToTitle(obj, e);
});

return obj;
},
},
methods: {
getMenus (name) {
let menus;
const tagTitle = this.nameToTitle[name];
for (let i = 0, l = this.menuItems.length; i < l; i++) {
const item = this.menuItems[i];
menus = [];
menus[0] = i;
if (item.text == tagTitle) {
return menus;
}

if (item.children) {
for (let j = 0, ll = item.children.length; j < ll; j++) {
const child = item.children[j];
menus[1] = i + '-' + j;
menus.length = 2;
if (child.text == tagTitle) {
return menus;
}

if (child.children) {
for (let k = 0, lll = child.children.length; k < lll; k++) {
const grandson = child.children[k];
menus[2] = i + '-' + j + '-' + k;
if (grandson.text == tagTitle) {
return menus;
}
}
}
}
}
}
},

monitorWindowSize () {
let w = document.documentElement.clientWidth || document.body.clientWidth;
if (w < 1300) {
this.shrinkAside();
}

window.onresize = () => {
// 可视窗口宽度太小 自动收缩侧边栏
if (w < 1300 && this.isShowAsideTitle
&& w > (document.documentElement.clientWidth || document.body.clientWidth)) {
this.shrinkAside();
}

w = document.documentElement.clientWidth || document.body.clientWidth;
};
},

// 判断当前标签页是否激活状态
isActive (name) {
return this.$route.name === name;
},

// 判断
isShrinkAside () {
this.isShowAsideTitle ? this.shrinkAside() : this.expandAside();
},
// 收缩
shrinkAside () {
this.asideArrowIcons.forEach(e => {
e.style.display = 'none';
});

this.isShowAsideTitle = false;
this.openMenus = [];
this.$nextTick(() => {
this.$refs.asideMenu.updateOpened();
});

setTimeout(() => {
this.asideClassName = '';
this.main.style.width = 'calc(100% - 80px)';
}, 0);
},
// 展开
expandAside () {
setTimeout(() => {
this.isShowAsideTitle = true;
this.asideArrowIcons.forEach(e => {
e.style.display = 'block';
});

this.openMenus = this.menuCache;
this.$nextTick(() => {
this.$refs.asideMenu.updateOpened();
});
}, 200);
this.asideClassName = 'aside-big';
this.main.style.width = 'calc(100% - 220px)';
},

// 菜单栏改变事件
menuChange (data) {
this.menuCache = data;
},
processNameToTitle (obj, data, text) {
if (data.name) {
obj[data.name] = data.text;
this.paths[data.name] = text ? `${text} / ${data.text}` : data.text;
}
if (data.children) {
data.children.forEach(e => {
this.processNameToTitle(obj, e, text ? `${text} / ${data.text}` : data.text);
});
}
}
}
};
</script>

任务栏是根据 this.$store.state.menuItems 定义的内容循环更新,包括子任务栏,其定义在src/store/index.js文件内,需利用Vuex状态管理插件。

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
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
state: {
// 左侧菜单栏数据
menuItems: [
{
name: 'home', // 要跳转的路由名称 不是路径
size: 22, // icon大小
type: 'md-home', // icon类型
text: '主页', // 文本内容
}
{
text: '数据视图',
type: 'md-globe',
size: 22,
children: [
{
text: '可视化',
type: 'ios-eye',
size: 20,
children: [
{
type: 'logo-steam',
name: 'trajectory',
text: '轨迹'
}
]
}
]
}
],
},
mutations: {
setMenus (state, items) {
state.menuItems = [...items];
}
}
});

export default store;

然后在src/router/index.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
import Vue from 'vue';
import Router from 'vue-router';

Vue.use(Router);

export const commonRouters = [
{
path: '/',
redirect: 'home'
}
];
// 需要动态定制的任务栏,包括子任务栏
export const asyncRouters = {
'home': {
path: 'home',
name: 'home',
component: () => import('@/views/Home.vue')
},
'trajectory': {
path: 'trajectory',
name: 'trajectory',
component: () => import('@/views/Trajectory.vue')
}
};

const createRouter = () => new Router({
routes: commonRouters
});

export function resetRouter () {
const newRouter = createRouter();
router.matcher = newRouter.matcher;
}

const router = createRouter();

export default router;

然后在 src/views 文件夹下定义对应路口的界面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
// Home.vue
<template>
...
</template>

<script>
export default {
name: 'home'
};
</script>

<style scoped>

</style>

// Trajectory.vue
<template>
...
</template>

<script>
export default {
name: 'home'
};
</script>

<style scoped>

</style>

之后,在src/permission.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
import router from '@/router'
import store from '@/store'
import {menusToRouters} from '@/utils';
import {LoadingBar} from 'iview'

let hasMenus = false;
router.beforeEach(async (to, from, next)=>{
LoadingBar.start();
if(hasMenus){
next()
}else{
try{
const routers = menusToRouters(store.state.menuItems);
router.addRoutes(routers);
hasMenus = true;
next({path: to.path || '/'});
}catch (e) {
console.log(e.toString());
}
}
});

router.afterEach(()=>{
LoadingBar.finish()
});

其中 menusToRouters 方法是将定义的任务栏转化为对应的router对象:

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
import {asyncRouters} from '@/router';

export function menusToRouters (data) {
const res = [];
const children = [];

res.push({
path: '/',
component: () => import('@/components/Index.vue'),
children,
});

data.forEach(item => {
generateRouters(children, item);
});

children.push({
path: 'error',
name: 'error',
component: () => import('@/components/Error.vue')
});

return res;
}

function generateRouters (children, item) {
if (item.name) {
children.push(asyncRouters[item.name]);
} else if (item.children) {
item.children.forEach(e => {
generateRouters(children, e);
});
}
}

最后,在入口文件 src/main.js 文件中导入permission.js文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue';
import App from '@/App';
import router from '@/router';
import iView from 'iview';
import axios from 'axios';
import store from '@/store';
import 'iview/dist/styles/iview.css'
import '@/permission';

// 全局注册
Vue.config.productionTip = false;
Vue.prototype.$axios = axios;
Vue.use(iView);

/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
render: h => h(App) // 相当于 components: { App } vue1.0的写法
});

初始化的菜单栏就定义完成了。