注意:前端领域当中,开发后台管理系统项目,并非是java、php等后台语言项目。
在前面课程当中,我们已经开发了一个项目【尚品汇电商平台项目】,这个项目主要针对的是用户(游客),可以让用户在平台当中购买产品。
但是你需要想明白一件事情,用户购买产品信息从何而来呀?
比如:前台项目当中的数据来源于卖家(公司),但是需要注意的时候,卖家它不会数据库操作。对于卖家而言,需要把产品的信息上传于服务器,写入数据库。
卖家并非程序员,不会数据库操作(增删改查)。导致卖家,找了一个程序员,开发一个产品,可以让我进行可视化操作数据库(增伤改查)
卖家(公司):组成,老板、员工。
老板:开发这个项目,对于老板而言,什么都可以操作。【产品的上架、产品的下架、查看员工的个人业绩、其他等等】
员工:可能就是查看个人业绩
后台管理系统:可以让用户通过一个可视化工具,可以实现对于数据库进行增删改查的操作。
而且需要注意,根据不同的角色(老板、员工),看到的、操作内容是不同的。
对于后台管理系统项目,一般而言,是不需要注册的。
简洁版: https://github.com/PanJiaChen/vue-admin-template 我们用这个
加强版: https://github.com/PanJiaChen/vue-element-admin
------ index.js webpack配置文件【很少修改这个文件】
------ mock数据文件夹 【模拟一些假数据mockjs实现】,本案例使用真实数据
------ 项目依赖
------ 静态资源,打包时不会编译这个文件夹
api------ 接口请求相关
assets------ 静态资源,打包时不会编译
components ------ 一般放置非路由组件或者全局组件
icons ------ 放置了一些svg矢量图
layout ------ 放置一些组件与混入,后续组件都是在layout组件中
router ------ 路由相关
store------ vuex
style------ 样式相关
utils ------ 封装一些需要的方法比如request.js axios二次封装,以及token的存储和移出操作
views------ 放置路由组件
App.vue:根组件
main.js:入口文件
permission.js:与导航守卫先关、
settings:项目配置项文件
.env.development
.env.producation
因为我们使用的是真实接口需要替换原模板的接口
接口地址:http://39.98.123.211:8170/swagger-ui.html#/
这里将其他接口一起进行了替换
//引入axios(axios进行了二次封装)
import request from '@/utils/request'
//对外暴露的登录接口函数
export function login(data) {
return request({
url: '/admin/acl/index/login',
method: 'post',
data
})
}
//对外暴露的用户信息接口函数
export function getInfo(token) {
return request({
url: '/admin/acl/index/info',
method: 'get',
params: { token }
})
}
//对外暴露的退出登录接口函数
export function logout() {
return request({
url: '/admin/acl/index/logout',
method: 'post'
})
}
修改axios的二次封装,真实接口成功的code=200,我们真实接口需要携带token字段,而不是提供的X-Token
service.interceptors.request.use(
config => {
// do something before request is sent
if (store.getters.token) {
// let each request carry token
// ['X-Token'] is a custom headers key
// please modify it according to the actual situation
// config.headers['X-Token'] = getToken()
config.headers['token'] = getToken()//修改后
}
return config
},
error => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
if (res.code !== 20000 && res.code != 200) {
Message({
message: res.message || 'Error',
type: 'error',
duration: 5 * 1000
})
因为使用了真实接口所以需要在vue.config.js配置代理跨域
devServer: {
port: port,
open: true,
overlay: {
warnings: false,
errors: true
},
//配置代理跨域
proxy: {
'/dev-api': {
target: 'http://39.98.123.211:8170',
pathRewrite: { '^/dev-api': '' },
},
},
},
组件内
handleLogin() {
//验证表单元素是否符合规则
this.$refs.loginForm.validate((valid) => {
if (valid) {
//按钮的loading效果
this.loading = true
//派发一个action:user/login,带着用户名和密码
this.$store
.dispatch('user/login', this.loginForm)
//登录成功
.then(() => {
//路由跳转
this.$router.push({ path: this.redirect || '/' })
//loading结束
this.loading = false
})
.catch(() => {
this.loading = false
})
} else {
console.log('error submit!!')
return false
}
})
},
vuex仓库中
actions,由于之前没有async和await,模板使用的.then与.catch
login({ commit }, userInfo) {
//解构用户名与密码
const { username, password } = userInfo
return new Promise((resolve, reject) => {
//向服务器请求数据
login({ username: username.trim(), password: password })
//请求成功
.then(response => {
// console.log(response);
//将response解构赋值给data
const { data } = response
//派发mutations业务
commit('SET_TOKEN', data.token)
//本地保存token
setToken(data.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
修改为async和await
async login({ commit }, userInfo) {
//解构用户名与密码
const { username, password } = userInfo
let result = await login({ username: username.trim(), password: password })
if (result.code == 20000) {
commit('SET_TOKEN', result.data.token)
setToken(result.data.token)
return 'ok'
} else {
return Promise.reject(new Error('faile'))
}
},
剩余工作就是将英文文本替换成中文,较为简单不做演示,表单验证放在后面做
登录页最终结果
由于api在上面已经做了修改,所以这里只是修改文本不做演示,退出登录组件名为Navbar
首页(一级路由)
权限管理(一级)
商品管理(一级)
---------品牌管理(二级)
---------平台属性管理(二级)
---------Spu管理(二级)
---------Sku管理(二级)
我们的一级路由都是在Layout这个骨架下搭建的,所以一级路由的component都写Layout
{
path: '/',
component: Layout,
//重定向至dashboard组件,即首页
redirect: '/dashboard',
children: [{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index'),
meta: { title: '首页', icon: 'dashboard' }
}]
},
{
path: '/product',
component: Layout,
name: 'Product',
meta: { title: '商品管理', icon: 'el-icon-goods' },
children: [
{
path: '/tradeMark',
name: 'TradeMark',
component: () => import('@/views/product/tradeMark'),
meta: { title: '品牌管理' }
},
{
path: '/attr',
name: 'Attr',
component: () => import('@/views/product/Attr'),
meta: { title: '平台属性管理' }
},
{
path: '/spu',
name: 'Spu',
component: () => import('@/views/product/Spu'),
meta: { title: 'Spu管理' }
},
{
path: '/sku',
name: 'Sku',
component: () => import('@/views/product/Sku'),
meta: { title: 'Sku管理' }
},
]
},
meta中的title为菜单栏以及面包屑显示的名字
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jKbvRwv9-1663551760268)(C:\Users\15363\AppData\Roaming\Typora\typora-user-images\image-20220916091415769.png)]
这里主要是利用element-UI制作静态页面,模板的element-UI默认英文版
// set ElementUI lang to EN
// Vue.use(ElementUI, { locale })
// 如果想要中文版 element-ui,按如下方式声明
Vue.use(ElementUI)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-piNMFlod-1663551760269)(D:\桌面\屏幕截图 2022-09-16 092103.png)]
添加
el-table属性:
data:表格组件将来需要展示的数据【数组类型】
border:是给表格带上边框
el-table-column属性:
label:显示的标题
width:对应列的宽度
align:对齐方式
.pagination {
margin-top: 20px;
text-align: center;
}
原地址端口更改,代理跨域发生改变
proxy: {
'/dev-api': {
target: 'http://gmall-h5-api.atguigu.cn',
pathRewrite: { '^/dev-api': '' },
},
},
后续就是写接口,写仓库
在api文件夹创建product文件夹统一管理商品管理的接口
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ShFN3MJJ-1663551760269)(C:\Users\15363\AppData\Roaming\Typora\typora-user-images\image-20220916165311726.png)]
在index中统一暴露
//统一暴露
import *as tradeMark from './product/tradeMark'
import *as attr from './product/attr'
import *as sku from './product/sku'
import *as spu from './product/spu'
export default {
tradeMark,
attr,
sku,
spu,
}
//获取品牌信息
export const reqTradeMarkList = (page, limit) =>
request({ url: `/admin/product/baseTrademark/${page}/${limit}`, method: 'get' })
这里不需要共享数据所以没有使用vuex仓库,而是将api添加到Vue原型上,其他组件可以直接调用
import API from '@/api'
Vue.prototype.$API = API
mounted() {
this.getPageList()
},
methods: {
//获取商品列表
async getPageList() {
const { page, limit } = this
let result = await this.$API.tradeMark.reqTradeMarkList(page, limit)
if (result.code == 200) {
this.total = result.data.total
this.list = result.data.records
}
},
//点击分页器跳转页码
handleCurrentChange(pager) {
this.page = pager
this.getPageList()
},
//展示数据条数切换
handleSizeChange(sizes) {
this.limit = sizes
this.getPageList()
},
elementUI当中的table组件,展示的数据是以一列一列进行展示数据的
如果需要显示索引,可以增加一列el-table-column,设置type属性为index即可显示从 1 开始的索引号。
prop:对应列内容的字段名,也可以使用 property 属性【字符串类型】
el-tabel :data="list"
表示这个表格展示的是哪里面的数据
第一列 我们要展示的是序列号,可以用 type="index"
表示从1 开始展示索引号
第二列 我们展示的是品牌类型,在list 里面的tmName,我们可以用prop
:对应列内容的字段名
修改
删除
第三列 展示的是品牌logo,是个图片,我们有地址,我们可以用【作用域插槽】来展示图片
slot-scope="{ row, $index }"
代表的是子组件回传过来的数据,也就是list
然后我们进行动态展示图片:src
当点击页数的时候,修改data里面的page,然后再次发请求
@current-change="handleCurrentChange"
当你点击其他页数时触发
// 点击页码进行切换
handleCurrentChange(pager) {
// pager你点击的页数
// 修改参数,然后再发请求
this.page = pager ;
this.getPageList();
},
// 点击页码进行切换
handleCurrentChange(pager) {
// pager你点击的页数
// 修改参数,然后再发请求
this.page = pager ;
this.getPageList();
},
@size-change="handleSizeChange"
当pageSize【展示数据条数】改变时触发
// 当分页器某一页需要展示数据的条数发生变化时触发
handleSizeChange(limit){
// 修改数据,再次发请求
this.limit = limit ;
this.getPageList();
},
当你点击品牌管理页面 左上角【添加】的时候,会弹出一个类似遮罩层的页面
当你点击【修改】,也会弹出一个类似遮罩层的页面-----------显示对话框
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pdAQvCLP-1663551760270)(C:\Users\15363\AppData\Roaming\Typora\typora-user-images\image-20220916172746949.png)]
我们这里用elementui的 dialog 对话框
上传图片用elementui的Upload上传
只能上传jpg/png文件,且不超过2M
粘贴elementui里面相关的数据和方法与样式
data(){
return{
dialogFormVisible: false, // 对话框显示与隐藏的控制属性
imageUrl: "", // 上传图片使用的属性
}
},
methods:{
// 上传图片相关的回调
handleAvatarSuccess(res, file) {
this.imageUrl = URL.createObjectURL(file.raw);
},
beforeAvatarUpload(file) {
const isJPG = file.type === "image/jpeg";
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJPG) {
this.$message.error("上传头像图片只能是 JPG 格式!");
}
if (!isLt2M) {
this.$message.error("上传头像图片大小不能超过 2MB!");
}
return isJPG && isLt2M;
},
}
//添加或修改品牌
export const reqAddOrUpdateTradeMark = (tradeMark) => {
//修改品牌信息时需要获得品牌的id,我们通过是否存在品牌id,判断需要发送什么请求
if (tradeMark.id) {
//修改
return request({ url: '/admin/product/baseTrademark/update', method: 'put', data: tradeMark })
} else {
//添加
return request({ url: '/admin/product/baseTrademark/save', method: 'post', data: tradeMark })
}
}
//删除品牌
export const reqDeleteTradeMark = (id) => request({
url: `/admin/product/baseTrademark/remove/${id}`, method: 'delete'
})
要收集到elementui里面的表单数据,就要在表单el-form上面添加 model
属性,在item上添加v-model将来表单验证也需要这个属性
<el-form style="width: 80%" :model="tmForm">
<el-form-item label="品牌名称" label-width="100px" >
<el-input autocomplete="off" v-model="tmForm.tmName"></el-input>
</el-form-item>
收集LOGO图片
这里收集数据不能使用v-model,因为不是表单元素,我们这里用了另一个el-upload
action:设置图片上传的地址
on-success 可以检测到图片上传成功,图片上传成功会执行一次
before-upload :可以在上传图片之前,会执行一次
只能上传jpg/png文件,且不超过2M
//图片上传成功
handleAvatarSuccess(res, file) {
//res:上传成功之后返回的前端数据
//file:同上
//收集品牌图片数据
this.tmForm.logoUrl = res.data
},
//图片上传之前
beforeAvatarUpload(file) {
const isJPG = file.type === 'image/jpeg'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
this.$message.error('上传头像图片只能是 JPG 格式!')
}
if (!isLt2M) {
this.$message.error('上传头像图片大小不能超过 2MB!')
}
return isJPG && isLt2M
},
当点击【添加品牌】里的【确定】按钮时
确 定
笔记是在中午和晚上总结的,所以步骤会有点混乱,老师是先发请求,完成功能后再完成的表单验证,这里我提前表单验证
Form 组件提供了表单验证的功能,只需要通过rules
属性传入约定的验证规则,并将 Form-Item 的 prop
属性设置为需校验的字段名即可。校验规则参见 async-validator(element-ui原话)
data(){
return{
//表单验证规则
rules: {
tmName: [
//required:必须验证的字段,message:提示信息,trigger:用户行为,blur失焦,change发生改变
{ required: true, message: '请输入品牌名称', trigger: 'blur' },
{
min: 2, //字符长度
max: 10,
message: '长度在 2 到 10 个字符',
trigger: 'change',
},
],
//图片格式、大小不在这里验证,因为他不是表单元素, 在beforeAvatarUpload方法中有校验规则
logoUrl: [{ required: true, message: '请上传品牌logo' }],
},
}
}
发请求
addOrUpdateTradeMark() {
//当全部字段验证通过在执行操作
this.$refs.ruleForm.validate(async (valid) => {
if (valid) {
this.dialogFormVisible = false
//发请求
let result = await this.$API.tradeMark.reqAddOrUpdateTradeMark(
this.tmForm
)
if (result.code == 200) {
//element-ui的消息提示
this.$message({
message: this.tmForm.id ? '修改品牌成功' : '添加品牌成功',
type: 'success',
})
//判断是修改还是添加操作,修改则留在当前页,添加返回第一页
this.handleCurrentChange(this.tmForm.id ? this.page : 1)
this.getPageList()
}
} else {
this.$message.error('信息未填写完整')
return false
}
})
},
完善效果,点击不同按钮显示不同
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vpQNW8Hi-1663551760271)(C:\Users\15363\AppData\Roaming\Typora\typora-user-images\image-20220919094028254.png)]
当点击【删除】按钮时,会弹出一个弹框【确定要删除吗】,然后有【确定】和【取消】两个按钮
点击【删除】按钮,给【删除】绑定点击事件deleteTradeMark
,要传参数(你点击该品牌的信息—row)
弹出一个弹框,里面是【确定删除】还是【取消】
确定后,发请求删除该品牌,然后再发请求获取品牌列表数据;
deleteTradeMark(row) {
this.$confirm(`确定删除${row.tmName}, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
//点击确定触发
.then(async () => {
let result = await this.$API.tradeMark.reqDeleteTradeMark(row.id)
if (result.code == 200) {
this.$message({
type: 'success',
message: '删除成功!',
})
this.handleCurrentChange(
this.list.length > 1 ? this.page : this.page - 1
)
this.getPageList()
}
})
//点击取消触发
.catch(() => {
this.$message({
type: 'info',
message: '已取消删除',
})
})
},
】绑定点击事件deleteTradeMark
,要传参数(你点击该品牌的信息—row)
弹出一个弹框,里面是【确定删除】还是【取消】
确定后,发请求删除该品牌,然后再发请求获取品牌列表数据;
deleteTradeMark(row) {
this.$confirm(`确定删除${row.tmName}, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
//点击确定触发
.then(async () => {
let result = await this.$API.tradeMark.reqDeleteTradeMark(row.id)
if (result.code == 200) {
this.$message({
type: 'success',
message: '删除成功!',
})
this.handleCurrentChange(
this.list.length > 1 ? this.page : this.page - 1
)
this.getPageList()
}
})
//点击取消触发
.catch(() => {
this.$message({
type: 'info',
message: '已取消删除',
})
})
},
后续不想写了,主要是没那么多时间整理,只能写在代码注释里,抱歉。
完成后的GitHub源码地址
码云地址