这是一个基于Vue2 + Element-ui + Echarts的PC端小项目,功能模块有:
登录、可视化数据展示、分页展示、修改用户信息、订单管理、规格管理等。整个项目功能不多,但是我也从中学习到很多,比如封装复用组件、使用Echarts可视化图表、更加熟练使用axios发请求和Element-ui组件库等等。
温故而知新,想对完成的项目做一个简单的总结和回顾,目前只写到了商品管理模块,后面还会补充更新的。
项目地址
在线演示:chyeonl.github.io/vue-system/
在线演示只能展示页面布局和所有数据,无法使用功能
因为后端是我自己在本地搭建的node服务器,为了可以在线展示我mock了数据但是没办法进行增删改查的操作
github:github.com/chyeonL/vue…
可以下载仓库中的master分支在本地运行
项目准备
reset.css 引入公共的初始化样式
3种引入方式:
- main.js 入口文件 import
- index.html 标签引入
- 在组件的标签中引入
iconfont引入
引入方式:(每次有新的图标都要重新生成在线代码/下载、重新引入)
- 在线代码
- 放在reset.css里一起引入吧,比较好(就只用引入一次)
- main.js 入口文件 import
- 下载到本地
scss 预处理器使用
// 安装依赖
npm i sass
npm i sass-loader@10
<style scoped lang="scss">
// 样式
</style>
路由分析、配置(兄弟路由,子路由,多级路由)
-
先分析有什么页面,然后注册路由组件
-
写了结构,再写功能和样式
-
一级路由:登录login 和 布局layout(兄弟路由) layout就是首页界面,里面还有
-
二级路由:layout的侧边栏(子路由)
layout分为左边和右边,都封装为单独的组件
myNav组件左边导航侧边栏
myContent组件右边内容区(又分为上下)
-
三级路由:侧边栏导航的子菜单
注意路由注册的时候,子路由的写法,前面连 斜杠 都不要加
-
其他技术点
路由搭建:
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
Vue.use(VueRouter)
const router = new VueRouter({
// 路由模块化 注册 数组
routes,
// 路由配置模式,默认是hash
mode:history
// 路由的其他可配置项,如:滚动行为
})
路由懒加载 ()=>import('@/views/Home')
路由重定向 redirect
路由元信息 meta:{ }
路由模块化 routes = [{},{},......]
在main.js中引入,并注册 (Don't forget!)
页面布局Layout(左固定 右自适应)
左侧和右侧顶部固定,右侧内容区滑动
右边顶部固定 下边内容区显示不同路由 实现:
- Layout盒子 flex布局 100vw 100vh
- 左边固定具体宽度 高度100%
- 右边flex:1 高度100% overflow:auto(超出高度的时候显示滚动条,不然不设置滚动时左边没办法占到底)
-
右边flex+相对定位
-
顶部绝对定位,宽度100%高度固定,top 0 left 0
-
内容区flex:1, overflow:auto,margin-top顶部的高度
-
axios二次封装
import Axios from "axios";
import Nprogress from 'nprogress'
import 'nprogress/nprogress.css' // Progress 添加进度条样式
// 默认配置
Axios.defaults.baseURL = "/api";
Axios.defaults.headers.post["Content-Type"] = "application/x-www-form-urlencoded";
Axios.defaults.timeout = 5000;
// 请求拦截器
Axios.interceptors.request.use(
(config) => {
Nprogress.start() // 进度条开始
// 携带token
if(JSON.parse(localStorage.getItem('userInfo'))){
let token = JSON.parse(localStorage.getItem('userInfo')).token
Axios.defaults.headers.common['Authorization'] = token
}
return config; //很重要,没有这句直接gg
},
(error) => {}
);
// 响应拦截器
Axios.interceptors.response.use(
(response) => {
// console.log(response);
Nprogress.done()
if (response.status == 200) return response.data;
},
(error) => {}
);
export default Axios;
跨域问题
配置代理服务器
Vue.config.js -- devserver -- proxy
proxy写成对象形式,可以设置多个代理(以前缀区分)
统一管理接口
新建api.js文件,引入刚封装的axios实例,分别向外暴露每一个接口
// 统一封装接口 并 分别暴露
import request from "./request";
// 举例接口
// 登录
export const login = (data)=>request.post('/login',data)
export const modifyPwd = (data)=>request.post('/modifyPwd',data)
在需要发请求的地方引入使用
// 可以引入整个api文件,也可以解构赋值引用其中的接口方法
import { login,modifyPwd } from "@/api";
还可以在main.js引入,然后挂在组件身上。
注意:但是只有在组件里才能使用,其他文件比如vuex就不行(因为vuex中的this并不是指向Vue实例,而是Vuex.store)
Vuex
Vuex:是Vue专门用于集中式状态管理的一个插件,适用于任何组件间的通信。
- 新建store文件夹,index.js文件
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 模块化
import user from './user'
import params from './params'
export default new Vuex.Store({
modules:{
user,params
}
})
在main.js中引入,并注册 (Don't forget!)
系统初始化
初次进入系统,默认进入是“home首页”
加载App组件时,在mounted生命周期函数中进行初始化——判断vuex仓库中 isLogin 的状态(isLogin默认读取本地存储),若无登录,会跳转Login登陆页面
登录拦截(全局路由守卫)
beforeEach() 判断当前使用的功能是否需要登录,若需要,则再次判断登录状态
import {Message} from 'element-ui'
import store from '@/store'
router.beforeEach((to,from,next)=>{
// 只要有一个匹配到的路由有requireAuth,就返回true
let requireAuth = to.matched.some(item=>item.meta.requireAuth)
// 看看登录没
let isLogin = store.state.user.isLogin
// console.log(isLogin);
// 需要登录
if(requireAuth){
// 已经登陆
if(isLogin) next()
else{
Message({
message: '需要先进行登录',
type: 'warning'
});
next({
path:'/login',
query:{
redirect:to.fullPath
}
})
}
}else{
next()
}
})
注:所有功能都需要登录后才能使用
添加路由元信息meta:{ },因为所有功能模块都是Layout路由的子路由,所以只要给父路由加就好了,所有的子路由都会进行拦截
{
path:'',
name:'Layout',
component:Layout,
redirect:'/home', // 路由重定向
meta:{
requireAuth:true // 需要登陆权限
},
children:[{...},{...},...]
}
登录的逻辑
前端验证(表单验证)
用了 Element-ui 的表单样式和验证规则,也可以自定义规则
后端验证(发请求)
这里我没有直接发请求,而是dispatch请求,然后在vuex处理数据
验证结果
成功:
- 保存返回的用户信息(vuex+本地存储)
- 弹窗提醒
- 路由跳转
保存之前要去的功能页到路由query参数的redirect,登陆成功后判断有无query参数,有则跳转
// 表单结构HTML
<el-form
:model="ruleForm"
:rules="rules"
ref="ruleForm"
label-width="50px"
class="demo-ruleForm"
>
<h2 style="font-weight: normal; font-size: 18px">登录</h2>
<el-form-item label="账号" prop="username">
<el-input
type="text"
v-model="ruleForm.username"
@keyup.enter.native="submitForm()"
></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
type="password"
v-model="ruleForm.password"
@keyup.enter.native="submitForm()"
></el-input>
</el-form-item>
<el-form-item class="buttons">
<el-button @click="resetForm()">重置</el-button>
<el-button type="primary" @click="submitForm()" class="goLogin">
登录
</el-button>
</el-form-item>
</el-form>
// 表单项 以及 验证规则
data() {
var validateUsername = (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入帐户"));
} else {
callback();
}
};
var validatePassword = (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
} else {
callback();
}
};
return {
// 用户对象
ruleForm: {
username: "administrator",
password: "password",
},
// 验证方法
rules: {
username: [{ validator: validateUsername, trigger: "blur" }],
password: [{ validator: validatePassword, trigger: "blur" }],
},
};
},
// 登录的事件回调
submitForm() {
this.$refs["ruleForm"].validate((valid) => {
// 表单验证
if (valid) {
// console.log(this.ruleForm);
// 后台验证 发请求
this.$store.dispatch("goLogin", this.ruleForm).then((res) => {
// 弹窗提示
if (res) {
this.$message({
message: "登陆成功",
type: "success",
});
// 跳转到登录前要去的页面,如果有的话
if (this.$route.query.redirect) {
this.$router.replace(`/${this.$route.query.redirect}`);
} else this.$router.replace("/");
// 刷新页面
this.$router.go(0);
}
});
}
// 前端验证失败
else {
this.$message.error("请完整输入");
return false;
}
});
},
// 重置
resetForm() {
this.ruleForm = {
username: "",
password: "",
};
}
失败: 弹窗提示
注:
- 自动填充密码和账号
- 给组件原生绑定回调事件需要加上 .native 修饰符
侧边栏的折叠与展开
layout父组件 折叠与展开改变的是myNav的宽度,动态类名控制样式,默认展开(collapse:false)
-
自定义事件(改变collapse)传给myContent 点击图标触发
// 定义自定义事件 changeIsCollapse() { this.isCollapse = !this.isCollapse; }, // 触发自定义事件 change() { this.$emit("changeIsCollapse"); } -
collapse值传给myNav 导航栏组件有内置属性值
深度选择器 ::v-deep
当 标签有 scoped 属性时,它的 CSS 只作用于当前组件中的元素,父组件的样式将不会渗透到子组件。 如果你希望 scoped 样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用深度选择器。
例:Element-ui的badge组件
同时还有牵涉到css选择器的问题,像这样多个类名之间没有空格隔开写在一起,是代表并集选择器。如果用空格分开,则代表后代选择器。
这里不用考虑优先级的问题,所以只写一个类名就ok了。
很容易混淆,要注意!
Echarts可视化(封装复用组件)
官网使用手册:echarts.apache.org/handbook/zh…
// 安装依赖
npm i echart -S
// main.js引入
import * as echarts from 'echarts';
-
初始化时,挂载在一个DOM元素上,配置对象option{ }
option一些重要配置:
-
grid:{ }控制图标在DOM元素中的位置
-
legend:{} 图例设置
-
series:{ } 设置 type图表类型,name图例,data数据
-
-
查看示例选择适合的图表样式进行修改
- Home组件props数据给Chart组件,包括图表数据和配置
分页展示商品
页面展示:flex布局
- 进入所有商品组件,created生命周期中发请求获取数据(默认页码第1页,页面大小4条数据),处理返回数据,props传给分页器。(没有用vuex)
-
与分页器通信:
- 传过去 总页数、当前页码、页面大小
- 自定义事件:修改页码、修改页面大小
-
用Element-ui的表格组件展示数据
分页展示搜索结果
在搜索框输入关键字,敲下回车或点击搜索就会发请求获取搜索结果,并分页展示(共用一个分页器)
注:发请求将一次性返回所有搜索结果,所以修改页码或修改页面大小并没有发请求,而是对之前的结果进行切割展示
-
定义一个type变量用于区分不同功能:all--展示所有商品,search--搜索商品
-
自定义事件里,加一层if-else逻辑,判断当前type值是否all,是就调用获取商品数据接口,反之做搜索处理
添加商品
有两种方式添加商品:
- 页面添加:点击跳转路由(没有真正实现页面添加的功能)
- 弹窗添加:点击显示弹窗,可进行添加
弹窗添加(封装弹窗组件)
点击按钮“弹窗添加”,触发事件:显示弹窗可见(全局事件总线),控制弹窗可见的变量定义在弹窗组件中
- 使用Element-ui的dialog对话框和form表单
<el-dialog
:title="type == 'add' ? '商品添加' : '商品编辑'"
:visible.sync="dialogFormVisible"
center
width="70%"
>
<el-form
:model="goodsForm"
:rules="rules"
label-width="80px"
ref="goodsForm"
>
<el-form-item label="类目选择" prop="category">
<el-button type="primary" @click="innerVisible = true"
>选择类别</el-button
>
{{ goodsForm.category }}
</el-form-item>
<el-form-item label="商品名称" prop="name">
<el-input v-model="goodsForm.name"></el-input>
</el-form-item>
<el-form-item label="商品价格" prop="price">
<el-input v-model="goodsForm.price"></el-input>
</el-form-item>
<el-form-item label="商品数量" prop="num">
<el-input v-model="goodsForm.num"></el-input>
</el-form-item>
<el-form-item label="发布时间">
<el-col :span="11">
<el-date-picker
type="date"
placeholder="选择日期"
v-model="goodsForm.date1"
style="width: 100%"
prop="date1"
></el-date-picker>
</el-col>
<el-col class="line" :span="2" style="text-align: center">-</el-col>
<el-col :span="11">
<el-time-picker
placeholder="选择时间"
v-model="goodsForm.date2"
style="width: 100%"
prop="date2"
></el-time-picker>
</el-col>
</el-form-item>
<el-form-item label="商品卖点" prop="sellPoint">
<el-input v-model="goodsForm.sellPoint"></el-input>
</el-form-item>
<el-form-item label="商品图片" prop="imgUrl" class="imgItem">
<el-button type="primary" @click="innerIMGVisible = true">
上传图片
<i class="el-icon-upload el-icon--right"></i>
</el-button>
<img :src="goodsForm.imgUrl" class="imgUpload" />
</el-form-item>
<el-form-item label="商品描述" prop="desc">
<!-- 富文本编辑器 -->
<WangEditor ref="desc" :desc='goodsForm.desc'/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="resetForm()">重置 </el-button>
<el-button type="primary" @click="submitForm()">确 定</el-button>
</div>
<!-- 内层嵌套的dialog 类目选择 -->
<el-dialog
width="30%"
title="类目选择"
:visible.sync="innerVisible"
append-to-body
>
<Tree />
<div slot="footer" class="dialog-footer">
<el-button @click="innerVisible = false">取消 </el-button>
<el-button type="primary" @click="chooseCategory()">确 定</el-button>
</div>
</el-dialog>
<!-- 内层嵌套的dialog 上传图片 -->
<el-dialog
width="30%"
title="上传图片"
:visible.sync="innerIMGVisible"
append-to-body
>
<Upload />
<div slot="footer" class="dialog-footer">
<el-button @click="innerIMGVisible = false">取消</el-button>
<el-button type="primary" @click="uploadIMG()">确 定</el-button>
</div>
</el-dialog>
</el-dialog>
// 全局事件总线 绑定开关弹窗事件
this.$bus.$on("openDialog", (type, goods = {}) => {
this.dialogFormVisible = !this.dialogFormVisible;
// 关闭弹窗的方法有两个:1、改变这个变量 2、确定添加商品
// 所以弹窗右上角的关闭 和 取消 都是 绑定了这个变量
// 之所以没有将这个变量定义在父组件:
// 1、props过来的参数,不允许直接改变,
// (所以还要另外定义一个变量接收props,然后改变这个变量)
// (或者再弄自定义事件,父组件绑定,子组件触发) both 麻烦
// 2、这是一个独立封装的组件了,当然还是自己收着参数更好啦
this.type = type;
if (this.type == "edit") this.goodsForm = goods;
});
-
点击按钮“确定”,收集表单数据发请求添加商品
-
点击右上角的按钮“x”或弹窗外任意地方,可关闭弹窗
-
点击按钮“重置”,可清空所有表单数据
嵌套弹窗
用于类目选择和上传图片
嵌套弹窗直接写在了Dialog组件里,没有封装
-
类目选择,还使用了树形控件Tree
-
点击按钮“选择类别”时,显示弹窗
-
封装为Tree组件,组件加载时发请求获取所有类目
-
懒加载,点击类别再加载下一层
-
- 上传图片,还用了上传组件upload
// 选择类目
chooseCategory() {
this.innerVisible = false;
this.goodsForm.category = this.TreeData.name;
this.goodsForm.cid = this.TreeData.cid;
},
// 上传图片
uploadIMG() {
this.innerIMGVisible = false;
this.goodsForm.imgUrl = this.img;
},
-
内嵌弹窗中,点击确定,触发自定义事件,父组件获取Tree组件中选择的类目(Upload组件中上传的图片url),并渲染到页面上
// 获取类目 getCategory(data, node, ele) { this.$bus.$emit("getCategory", data); }, // 图片 getCategory(data, node, ele) { this.$bus.$emit("getCategory", data); }
写在最后
会有小伙伴看到这里吗?如果有,谢谢你的耐心,欢迎指正!
暂时先写到这里啦,未完待续。