下载vue3脚手架并配置路由 (vue3+vue-router4+vite+eslint+pinia)
创建vite项目 npm init vite@latest my-vue-app -- --template vue
下载路由并配置路由 npm install vue-router@4
import { createRouter,createWebHashHistory } from "vue-router";
import Index from '@/pages/index.vue'
import Login from '@/pages/login.vue'
import NotFound from '@/pages/404.vue'
const routes = [
{ path: '/', component: Index },
{ path: '/login', component: Login },
// 404页面
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router
组件库选用element-plus样式库windi CSS
下载element-plus和windi CSS并引入至main.js
npm install element-plus --save
npm i -D vite-plugin-windicss windicss
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import router from './router/index';
import 'element-plus/dist/index.css'
import 'virtual:windi.css'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.mount('#app')
全局状态管理pinia(替代vuex)
npm i pinia
tip:piana真的太香了~~比起vuex哪种复杂的写法,piana简直不要太好!(都给我用起来!)
附上使用pinia说明(链接),详情看官网~
为什么使用windi CSS?
1、提升开发效率 2、自带兼容 3、功能较为完善
自动导入Element Plus组件、图标 (自动引入太香了~) (链接) unplugin-icons、unplugin-auto-import、unplugin-vue-components
自动导入后使用 icon组件加前缀IEp 例: -> 自动引入图标废除,主页菜单会涉及到 动态渲染菜单图标,自动导入无法根据动态值来正确引入图标(以改为全局引入图标)
v-loading:指令元素loading效果,参数为布尔值
使用ref时,也要在script内声明ref变量。
例如
父组件通过ref使用子组件方法或变量时,子组件必须使用defineExpose暴露方法父组件才能拿到子组件的方法和变量
子组件props与$emit
useDateFormat时间戳转时间
import { useDateFormat } from '@vueuse/core'
const props = defineProps({
info: Object
})
// 付款时间戳转换
const paid_time = computed(() => {
if (props.info.paid_time) {
const s = useDateFormat(props.info.paid_time * 1000, 'YYYY-MM-DD HH:mm:ss')
return s.value
}
return ''
})
4.1、订单列表页
通过注释找到bug
import axios from "axios";
import { getToken } from "@/composables/auth.js";
import { toast } from "@/composables/util.js";
import { mainStore } from '@/store/index.js'
const service = axios.create({
baseURL: '/api'
})
// 请求拦截器
service.interceptors.request.use(function (config) {
// 设置请求头
const token = getToken()
if (token) {
config.headers['token'] = token
}
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
});
// 响应拦截器
service.interceptors.response.use(function (response) {
// 对响应数据做点什么
return response.data.data;
}, function (error) {
// 对响应错误做点什么
const store = mainStore()
const msg = error.response.data.msg || '请求失败'
if (msg === '非法token,请先登录!') {
store.LOGOUT().finally(() => location.reload())
}
toast(msg, 'error')
return Promise.reject(error);
});
export default service
baseUrl
请求拦截器(调接口前给header添加token)
响应拦截器的封装(对返回的数据简化处理,对错误请求进行封装)
一定要对请求错误和响应错误做处理(返回失败的promise)否则会出现逻辑错误
登录逻辑:login接口,存储token,获取用户信息、跳转至首页
存储token:将token存储至cookie中,使用到vueuse库的useCookies方法
封装操作cookie方法
import { useCookies } from '@vueuse/integrations/useCookies'
const cookie = useCookies()
const tokenKey ='admin-token'
export function getToken(){
return cookie.get('admin-token')
}
export function setToken(token){
cookie.set(tokenKey, token)
}
export function removeToken(){
cookie.remove(tokenKey)
}
封装提示消息
export function toast(message,type='success',dangerouslyUseHTMLString=false) {
ElNotification({
message,
type,
dangerouslyUseHTMLString,
duration: 3000
})
}
main.js注册pinia
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index';
import { createPinia } from 'pinia'
// 样式引入
import 'element-plus/dist/index.css'
import 'virtual:windi.css'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
import "./permission.js";
app.mount('#app')
定义store仓库
// 1、定义状态容器
// 2、修改容器中的state
// 3、仓库中的action的使用
import { defineStore } from "pinia";
import { getInfo } from '@/api/manager.js'
// defineStore参数1为仓库id(唯一值)
export const mainStore = defineStore('main', {
state: () => {
return {
// 用户信息
user: {}
}
},
getters: {
},
actions: {
SET_USERINFO (userInfo) {
this.user = userInfo
},
// 获取用户信息 并且 设置用户信息
GET_INFO () {
return new Promise((resolve, reject) => {
getInfo().then(res => {
console.log(res,'res');
this.SET_USERINFO(res)
resolve(res)
}).catch(err => {
reject(err)
})
})
}
}
})
使用pinia,存储用户信息
main.js引入permission.js (代码省略)
前置守卫:对操作进行检查,用户信息持久化
import router from '@/router/index.js'
import { getToken } from "@/composables/auth.js";
import { toast } from "@/composables/util.js";
import { mainStore } from "@/store/index.js";
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const store = mainStore()
const token = getToken()
// 没有登录,强制跳回登录
if (!token && to.path !== '/login') {
toast('请先登录~默认账号密码:admin', 'error')
return next({ path: "/login" })
}
// 防止重复登录检验
if (token && to.path === '/login') {
toast('请勿重复登录', 'error')
return next({ path: from.path || '/' })
}
if(token) {
await store.GET_INFO()
}
next()
})
封装弹框提示
退出登录接口封装
基本逻辑:
// 1、定义状态容器
// 2、修改容器中的state
// 3、仓库中的action的使用
import { defineStore } from "pinia";
import { getInfo } from '@/api/manager.js'
import { removeToken } from '@/composables/auth.js'
import { toast } from '@/composables/util.js'
import { useRouter } from 'vue-router'
import { logout } from '@/api/manager.js'
const router = useRouter()
// defineStore参数1为仓库id(唯一值)
export const mainStore = defineStore('main', {
state: () => {
return {
// 用户信息
user: {}
}
},
getters: {
},
actions: {
SET_USERINFO (userInfo) {
this.user = userInfo
},
// 获取用户信息 并且 设置用户信息
GET_INFO () {
return new Promise((resolve, reject) => {
getInfo().then(res => {
this.SET_USERINFO(res)
resolve(res)
}).catch(err => {
reject(err)
})
})
},
REMOVE_INFO () {
this.user = {}
},
async LOGOUT () {
// 1、调退出登录接口
await logout()
// 2、清除cookie内的token
removeToken()
// 3、清空vuex内user状态
this.REMOVE_INFO()
// 4、提示退出登录成功
toast('退出登录成功')
// 5、跳回登录页
router.push('/login')
}
}
})
npm i nprogress
根据nprogress文档使用 引入相应的css和js封装进工具库
路由前置守卫开启loading 后置守卫关闭loading
效果图:
router.js内配置meta.title
const routes = [
{
path: '/', component: Index,
meta: {
title: '后台首页'
}
},
{
path: '/login', component: Login,
meta: {
title: '登录'
}
},
// 404页面
{
path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound,
meta: {
title: '404'
}
},
]
路由前置守卫获取到to.meta.title,动态修改文档title
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const title = `${to.meta.title || ''}-vue3商城后台管理`
document.title =title
})
el组件搭建 重点**(router-view)**
transition相关:链接
keep-alive相关:链接
配置路由
// 主体框架
{
path: '/',
component: Admin,
// 子路由
children: [
{
path: '/',
component: Index,
meta: {
title: '后台首页'
},
}
]
},
{{
confirmText
}}
取消
代码拆分思路**(重点):**使用组合式api拆分,确定哪些参数为变量,变量部分用接收的参数来替换,确保封装的js代码,可以多组件复用
顶部代码封装(利于后期维护):
// 退出登录
export function useLogout () {
const router = useRouter()
const store = mainStore()
function handleLogout () {
showModel('是否退出', 'warning')
.then(async (res) => {
await logout()
store.LOGOUT()
// 4、提示退出登录成功
toast('退出登录成功')
// 5、跳回登录页
router.push('/login')
})
.catch((err) => {
console.log(err, 'err')
})
}
// 返回函数
return {
handleLogout
}
}
使用:
logo名
{{ store.user.username }}
修改密码
退出登录
子导航多层嵌套:菜单数据主要分为两种情况,一级菜单(没有child),多级菜单(有child,且有可能child后还有child)涉及到循环套用,组件使用也可以套用递归思想(一定要将组件拆分,否则容易报错)
{{ menu.name }}
{{ menu.name }}
先看代码 (此章节资料 vue-router **:**在导航守卫中添加路由、添加嵌套路由)
import { createRouter, createWebHashHistory } from "vue-router";
import Index from '@/pages/index.vue'
import Login from '@/pages/login.vue'
import NotFound from '@/pages/404.vue'
import Admin from '@/layouts/admin.vue'
import GoodsList from '@/pages/goods/list.vue'
import CategoryList from '@/pages/category/list.vue'
// 默认路由所用用户共享
const routes = [
{
path: '/',
// 为什么要加name vue-router规定如果有嵌套路由,父路由必须有name值 (何为嵌套?有子路由)
name: 'admin',
component: Admin
},
{
path: '/login',
component: Login,
meta: {
title: '登录'
}
},
// 404页面
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: {
title: '404'
}
},
]
// 动态路由,用于匹配菜单动态添加路由
const asyncRoutes = [
{
path: '/',
name: '/',
component: Index,
meta: {
title: '后台首页'
},
},
{
path: '/goods/list',
name: '/goods/list',
component: GoodsList,
meta: {
title: '商品管理'
},
},
{
path: '/category/list',
name: '/category/list',
component: CategoryList,
meta: {
title: '分类管理'
},
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes,
})
// 动态添加路由方法
export function addRoutes (menus) {
// 是否有新的路由
let hasNewRoutes = false
// 递归方法 获取用户信息后,触发addRoutes方法,将菜单数据后往默认路由内添加路由,如果已经有了同样名字的路由则跳过,如果有child就再次调该方法递归
const findAndAddRouteByMenus = (arr) => {
arr.forEach(e => {
// 菜单路由数据是否与已有路由的path匹配,匹配返回当前item项,不匹配返回undefined (如果匹配说明路径正确,该组件会被正常渲染,若不匹配则前端更改path路径)
let item = asyncRoutes.find(o => o.path === e.frontpath)
// router.hasRoute():检查是否为注册过的路由
if (item && !router.hasRoute(item.path)) { // 存在且为未注册的路由
router.addRoute('admin', item)
hasNewRoutes = true
}
if (e.child && e.child.length > 0) {
findAndAddRouteByMenus(e.child)
}
})
}
findAndAddRouteByMenus(menus)
console.log(router.getRoutes(),'查看已有路由');
return hasNewRoutes
}
问题1:这样配好后,刷新页面,会返回到404页面,原因:
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const store = mainStore()
const token = getToken()
const title = `${to.meta.title || ''}-vue3商城后台管理`
showFullLoading()
// 没有登录,强制跳回登录
if (!token && to.path !== '/login') {
toast('请先登录~默认账号密码:admin', 'error')
return next({ path: "/login" })
}
// 防止重复登录检验
if (token && to.path === '/login') {
toast('请勿重复登录', 'error')
return next({ path: from.path || '/' })
}
// 检测是否有新的路由
let hasNewRoutes = false
if (token) {
const { menus } = await store.GET_INFO()
hasNewRoutes = addRoutes(menus)
console.log(hasNewRoutes, 'hasNewRoutes');
}
document.title = title
// 用于解决刷新404问题,路由需要手动指向路径 next(to.fullPath)
hasNewRoutes ? next(to.fullPath) : next()
})
tip:菜单会和tab联动(即点击菜单导航后会新增或跳转至相应tab)
菜单和tab联动(即点击菜单,tab组件自动高亮选中项):tbs组件中,主要通过onBeforeRouteUpdate监听路由更新事件,给activeTab赋值
tab和菜单联动(点击tag,菜单自动选中):菜单组件中,通过vuerouter的onBeforeRouteUpdate组件守卫,监听路由更新,并给选中项赋值
封装代码:
import { ref } from 'vue'
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router'
import { mainStore } from '@/store/index'
import { useCookies } from '@vueuse/integrations/useCookies'
export function useTabList (params) {
const store = mainStore()
const route = useRoute()
const router = useRouter()
const cookie = useCookies()
const activeTab = ref(route.path)
const tabList = ref([
{
path: '/',
title: '后台首页'
}
])
// 初始化tabList
function initTabList () {
const tabs = cookie.get('tabList')
if (tabs) {
tabList.value = tabs
}
}
initTabList()
// 监听事件, activeTab 变化触发 实参为activeTab变化后的值
function changeTab (path) {
router.push(path)
}
// 下拉框 关闭tab事件
function handleClose (e) {
// 关闭其他
if (e === 'clearOther') {
tabList.value = tabList.value.filter(
(item) => item.path === '/' || item.path === activeTab.value
)
}
if (e === 'clearAll') {
tabList.value = tabList.value.filter((item) => item.path === '/')
activeTab.value = '/'
}
cookie.set('tabList', tabList.value)
}
// 删除逻辑:判断是否为高亮tab,如果是,则给高亮tab重新赋值(赋值规则:高亮的上一个或高亮的下一个)
function removeTab (t) {
// 声明变量赋值是为了简化代码.value写法会使代码冗余
let tabs = tabList.value
let a = activeTab.value
if (a === t) {
tabs.forEach((item, index) => {
if (item.path === t) {
const nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
a = nextTab.path
}
}
})
}
activeTab.value = a
tabList.value = tabList.value.filter((item) => item.path !== t)
cookie.set('tabList', tabList.value)
}
// tabList添加事件(路由改变添加tbaList)
function addTabs (tab) {
const noTab = tabList.value.findIndex((o) => o.path === tab.path) === -1
if (noTab) {
tabList.value.push(tab)
}
cookie.set('tabList', tabList.value)
}
// 监听路由更新事件(主要通过这个来联动菜单)
onBeforeRouteUpdate((to, from) => {
activeTab.value = to.path
// 获取路由信息
addTabs({ path: to.path, title: to.meta.title })
})
return {
store,
activeTab,
tabList,
changeTab,
removeTab,
handleClose
}
}
// 监听路由变化
onBeforeRouteUpdate((to, from) => {
defaultActive.value = to.path
})
permission 指令对于没有权限的组件、元素进行删除
v-permission :接收一个数组,例:['getStatistics1,GET'], 自定义指令函数内将接收到的实参与sotre内的权限数组做对比,如果存在则返回true,不存在则返回false,并删除使用v-permission的对应组件。
import { mainStore } from '@/store/index.js'
function hasPermission (value, el = false) {
if (!Array.isArray(value)) {
throw new Error('需要配置权限,例如 v-permission="["getStatistics2","GET"]"')
}
const store = mainStore()
// value为数组
// includes 方法可以判断一个数组中是否包含某一个元素, 并且返回true 或者false
// findIndex:获取第一个符合判断条件的值索引,若返回值为布尔,true为0,false为-1
const hasAuth = value.findIndex(v => store.ruleNames.includes(v)) != -1
// hasAuth为false则说明没有权限
// 如果有元素且没有权限,则获取该元素父节点,删除其子节点
if(el && !hasAuth) {
el.parentNode && el.parentNode.removeChild(el)
}
return hasAuth
}
export default {
install (app) {
// 自定义指令 permission
app.directive('permission', {
mounted (el, binding) { // el元素节点 binding.value:v-permission绑定的值
hasPermission(binding.value, el)
}
})
}
}
自定义指令注册(必须在createApp(App)之后)
// 自定义指注册
import permission from "@/directives/permission.js";
app.use(permission)
使用自定义指令 v-permission
{{ item.title }}
{{ item.unit }}
{{ item.subTitle }}
{{ item.value }}
功能:骨架屏(el骨架屏)、数字动画(gsap第三方包**)、echarts图标渲染**
数字动画公共组件封装:
{{ d.num.toFixed(0) }}
首页组件(部分组件做了拆分)
{{ item.title }}
{{ item.unit }}
{{ item.subTitle }}
{{ item.value }}
开发思路:内容组件包括 侧边栏组件+主体组件
功能:表单新增图片分类,上传图片
新增图片分类
上传图片
{{ item.name }}
重命名
删除
新增
修改
删除
**功能:**点击新增或修改,弹出抽屉组件,选择头像弹出选择图片组件
复杂难点:
图片组件封装:复用图库管理业,需将imageAmin组件多加一个复选框(第三个图红框标注)
搜索
重置
新增
// row.avatar没有值,默认展示的图片
{{ row.username }}
ID:{{ row.id }}
{{ row.role ? row.role.name : '-' }}
暂无操作
修改
删除
新增图片分类
上传图片
取消
确认
const getData = async (p = null) => {
if (typeof p == 'number') {
currentPage.value = p
}
loading.value = true
const res = await getImageList(image_class_id.value, currentPage.value)
srcList.value = res.list.map((item) => {
return item.url
})
// 给每个对象加一个checked属性
list.value = res.list.map((item) => {
item.checked = false
return item
})
total.value = res.totalCount
loading.value = false
}
// checked选中的图片
const checkedImage = computed(() => {
return list.value.filter((item) => item.checked)
})
const emit = defineEmits(['choose'])
// 复选框选中图片事件
const handleChooseChange = (item) => {
if (item.checked && checkedImage.value.length > 1) {
item.checked = false
return toast('最多只能选中一张', 'error')
}
// 触发父组件事件 将选中的图片返回给父组件
emit('choose', checkedImage.value)
}
参数拆分:表单、表单ref、抽屉ref、表单规则、修改id、弹框title、当前页码数(点击确定,刷新数据时需要)。
方法拆分:表单的新增、修改、提交
参数拆分:列表数据,分页参数(limit,curretpage、total),搜索参数,
方法拆分:刷新方法、getData、修改状态方法、删除方法
import { ref, reactive, computed } from 'vue'
import { toast } from '@/composables/util.js'
// 页面公共部分(分页+列表+删除+搜索,修改状态)逻辑拆分
// opt参数 必传:getList(获取列表数据的接口)
// 选传:searchForm(搜索参数)、updateStatus(修改状态的接口)、
// delete(删除状态的接口)、onGetListSuccess(获取数据后对数据进行处理的回调)
export function userInitTable (opt = {}) {
const tableData = ref([])
const loading = ref(false)
// 分页参数
const currentPage = ref(1)
const limit = ref(10)
const total = ref(0)
// 搜索
let searchForm = null
let resetSearchForm = null
// 搜索参数可能会有多个,需要使用组件传递对应搜索参数,公共组件动态获取
if (opt.searchForm) {
searchForm = reactive({ ...opt.searchForm })
resetSearchForm = () => {
// opt.searchForm的格式searchForm: {keyword: ''},使用组件传的值必定为空,循环给searchForm初始化值
for (const key in opt.searchForm) {
searchForm[key] = opt.searchForm[key]
}
getData()
}
}
const getData = async (p = null) => {
// p为当前页码数
if (typeof p == 'number') {
currentPage.value = p
}
loading.value = true
const res = await opt.getList(currentPage.value, searchForm)
// 部分组件需要返回特殊的参数,如:每个item中都要返回一个checked属性,那么将执行使用组件传来的逻辑,返回对应参数
if (opt.onGetListSuccess && typeof opt.onGetListSuccess == 'function') {
opt.onGetListSuccess(res)
} else {
tableData.value = res.list
total.value = res.totalCount
}
loading.value = false
}
getData()
// 修改状态
const handleStatusChange = async (status, row) => {
row.statusLoading = true
await opt.updateStatus(row.id, status)
row.statusLoading = false
toast('修改状态成功')
row.status = status
}
// 删除
const handleDelete = async (id) => {
loading.value = true
try {
await opt.delete(id)
toast('删除成功')
getData()
} catch (err) {
loading.value = false
}
}
return {
searchForm,
resetSearchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleStatusChange,
handleDelete
}
}
// 表单(新增+修改+提交)逻辑拆分
// opt参数 必传:form(表单初始值)、
// 可选:title(表单标题)、currentPage(当前页:必须在userInitTable之后)、
// update(修改表单接口)、create(新增表单接口)
export function useInitForm (opt = {}) {
const formDrawerRef = ref(null)
const formRef = ref(null)
// 表单参数
const defaultForm = opt.form
let form = reactive({})
// 表单规则
const rules = opt.rules || {}
// 修改id
const editId = ref(0)
// 弹框title
const drawerTitle = computed(() => {
return editId.value ? '修改' + opt.title : '新增' + opt.title
})
// 当前页码数
const currentPage = ref(1)
currentPage.value = opt.currentPage
// 提交表单
const handleSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
formDrawerRef.value.showLoading()
try {
const Fun = editId.value
? opt.update(editId.value, form)
: opt.create(form)
console.log(defaultForm, 'defaultForm');
const data = await Fun
toast(drawerTitle.value + '成功')
// 修改刷新当前页,新增刷新第一页
opt.getData(editId.value ? currentPage.value : 1)
formDrawerRef.value.close()
} catch (err) {
console.log(err);
}
formDrawerRef.value.hideLoading()
})
}
// 重置表单
const resetForm = (row = {}) => {
if (formRef.value) formRef.value.clearValidate()
// 这里for in defaultForm的原因是,defaultForm为原始值。
// 即:原始值为{title:'xx',content:'xx'}
// 触发编辑事件后 form的值就会为row的值
// 即:{title:'xx',content:'xx',create_time:'2022-12',update_time:'2022-12'}
// 当点击修改后,如果for in的是参数row的话,form的原始数据结构会被改变,会向接口传多余参数,导致报错
// 所以for in defaultForm就是为了点击修改时,给form的key规定好为原始的key值
for (const key in defaultForm) {
form[key] = row[key]
}
}
// 新增
const handleCreate = () => {
editId.value = 0
resetForm(defaultForm)
formDrawerRef.value.open()
}
// 编辑
const handleUpdate = (row) => {
editId.value = row.id
resetForm(row)
formDrawerRef.value.open()
}
return {
formDrawerRef,
formRef,
form,
rules,
editId,
drawerTitle,
handleSubmit,
resetForm,
handleCreate,
handleUpdate
}
}
使用useCommon.js
import { userInitTable } from '@/composables/useCommon.js'
const roles = ref([])
const { searchForm,resetSearchForm, tableData, limit, loading, total, currentPage, getData } =
userInitTable({
searchForm: {
keyword: '',
},
getList: getManagerList,
onGetListSuccess: (res) => {
tableData.value = res.list.map((item) => {
item.statusLoading = false
return item
})
roles.value = res.roles
total.value = res.totalCount
loading.value = false
}
})
6
功能:el-tree构建页面,弹窗复用公共组件FormDrawer,表单新增,表单逻辑复用标题5的公共逻辑
难点:组件配置项太多,容易弄混。复杂的配置项在模板中有标注(el-cascader级联选择器,el-tree)
**核心:**主要是通过addRoute添加动态路由(router.js页)
{{
data.menu ? '菜单' : '权限'
}}
{{ data.name }}
修改
增加
删除
菜单
规则
{{ item }}
功能:列表展示、分页、表单新增,与公共管理类似,多了一个配置权限功能。代码复用标题5的公共逻辑
难点:配置权限弹框的虚拟树渲染,配置项容易出错(模板中已标注)
配置权限
修改
删除
{{ data.menu ? '菜单' : '权限' }}
{{ data.name }}
功能:列表展示、分页、表单新增(复用角色管理页模板,复用公共逻辑)
新增:公共组件新增:**公共组件tagInput.vue,**公共逻辑新增:多选、批量删除
修改
删除
{{ tag }}
+ 添加值
export function useInitTable (opt = {}) {
// 复选框多选选中id
const multiSelectionIds = ref([])
const handleSelectionChange = (e) => {
const ids = e.map(o => { return o.id })
multiSelectionIds.value = ids
}
// 批量删除
const multipleTableRef = ref(null)
const handleMultiDelete = async () => {
if (!multiSelectionIds.value.length) return toast('请选择至少一个选项', 'warning')
try {
await opt.delete(multiSelectionIds.value)
toast('删除成功')
getData()
multipleTableRef.value.clearSelection()
} catch (err) {
console.log(err, 'err');
}
}
return {
handleSelectionChange,
multipleTableRef,
handleMultiDelete
}
}
功能:列表展示、分页、表单新增(复用角色管理页模板,复用公共逻辑)
复杂难点:
优惠券状态判断:在onGetListSuccess回调内调用格式化状态函数。
时间选择器时间戳转换:在公共逻辑提交事件内,声明一个body变量,判断是否有beforeSubmit事件,有该事件,将该事件赋值给body,则调用该函数并将form传给该回调函数,回调函数将时间转换为时间戳后再reture回去,调用提交接口时,将form替换为body
{{ row.name }}
{{ row.start_time }}~{{ row.end_time }}
{{ row.statusText }}
满{{ row.min_price }},打{{Number(row.value).toFixed(0) }}折
满{{ row.min_price }},减{{ row.value }}
修改
删除
失效
满减
折扣
{{ form.type ? "折" : "元" }}
元
// 提交表单
const handleSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
formDrawerRef.value.showLoading()
try {
// 新增start:将form赋值给body,传给回调,处理时间戳
let body ={}
if(opt.beforeSubmit && typeof opt.beforeSubmit =='function' ) {
body =opt.beforeSubmit({...form})
}else {
body = form.value
}
// 新增end
const Fun = editId.value
? opt.update(editId.value, body)
: opt.create(body)
const data = await Fun
toast(drawerTitle.value + '成功')
opt.getData(editId.value ? currentPage.value : 1)
formDrawerRef.value.close()
} catch (err) {
console.log(err);
}
formDrawerRef.value.hideLoading()
})
}
**功能:**下图红框内功能,列表展示、分页、表单新增(复用代码)
复杂难点:功能点多,代码量大
页面渲染:多参数搜索(复用search.vue)、按钮组件(复用ListHeader.vue),table和分页(复用规格管理页)、新增tab切换
提交
商品规格选项弹框主要复杂点在于多规格部分
多规格部分思路:分为两部分,1、规格选项部分(skuCard.vue) 规格选项部分包含(skuCardItem.vue(每一项规格选项)) 2、规格table表格部分(skuTable.vue)
js部分:涉及多组件使用同一值,用传统方法写js的话,值的传递太过复杂,所以使用vue3组合式js,组合式内定义的值可以多组件使用并****保持相同性 将大部分逻辑写在useSku.js内
添加规格
+ 添加选项值
{{ th.name }}
{{ th.name }}
{{ sku.value }}
import { ref, nextTick, computed } from "vue";
import {
createGoodsSkusCard,
updateGoodsSkusCard,
deleteGoodsSkusCard,
sortGoodsSkusCard,
createGoodsSkusCardValue,
updateGoodsSkusCardValue,
deleteGoodsSkusCardValue,
chooseAndSetGoodsSkusCard
} from "@/api/goods.js";
import { useArrayMoveUp, useArrayMoveDown, cartesianProductOf } from "@/composables/util.js";
// 当前商品ID
export const goodsId = ref(0)
// 规格选项列表
export const sku_card_list = ref([])
export const sku_list = ref([])
// 初始化规格选项列表
export function initSkuCardList (d) {
sku_card_list.value = d.goodsSkusCard.map(item => {
item.text = item.name
item.loading = false
item.goodsSkusCardValue.map(v => {
v.text = v.value || "属性值"
return v
})
return item
})
sku_list.value = d.goodsSkus
console.log(sku_list.value, 'sku_list.value');
}
// 添加规格选项
export const btnLoading = ref(false)
export function addSkuCardEvent () {
btnLoading.value = true
createGoodsSkusCard({
"goods_id": goodsId.value,
"name": "规格选项",
"order": 50,
"type": 0
}).then(res => {
sku_card_list.value.push({
...res,
text: res.name,
loading: false,
goodsSkusCardValue: []
})
}).finally(() => {
btnLoading.value = false
})
}
// 修改规格选项
export function handleUpdate (item) {
item.loading = true
updateGoodsSkusCard(item.id, {
"goods_id": item.goods_id,
"name": item.text,
"order": item.order,
"type": 0
}).then(res => {
item.name = item.text
}).catch(err => {
item.text = item.name
}).finally(() => {
item.loading = false
})
}
// 删除规格选项
export function handleDelete (item) {
item.loading = true
deleteGoodsSkusCard(item.id).then(res => {
// 和当前数组内的值做匹配 匹配上了就删除
const i = sku_card_list.value.findIndex(o => o.id == item.id)
if (i != -1) {
sku_card_list.value.splice(i, 1)
}
getTableData()
}).finally(() => {
item.loading = false
})
}
// 排序规格选项
export const bodyLoading = ref(false)
export function sortCard (action, index) {
let oList = JSON.parse(JSON.stringify(sku_card_list.value))
let func = action == 'up' ? useArrayMoveUp : useArrayMoveDown
func(oList, index)
let sortData = oList.map((item, i) => {
return {
id: item.id,
order: i + 1
}
})
bodyLoading.value = true
sortGoodsSkusCard({ sortdata: sortData }).then(res => {
func(sku_card_list.value, index)
getTableData()
}).finally(() => {
bodyLoading.value = false
})
}
// 选择设置规格
export function handleChooseSetGoodsSkusCard (id, data) {
let item = sku_card_list.value.find(o => o.id == id)
item.loading = true
chooseAndSetGoodsSkusCard(id, data).then(res => {
item.name = item.text = res.goods_skus_card.name
console.log(res.goods_skus_card_value, 'res.goods_skus_card_value');
item.goodsSkusCardValue = res.goods_skus_card_value.map(o => {
o.text = o.value || '属性值'
return o
})
getTableData()
}).finally(() => {
item.loading = false
})
}
// 初始化规格值
export function initSkusCardItem (id) {
const item = sku_card_list.value.find(o => o.id == id)
const inputValue = ref('')
const dynamicTags = ref(['Tag 1', 'Tag 2', 'Tag 3'])
const inputVisible = ref(false)
const InputRef = ref()
const loading = ref(false)
const handleClose = (tag) => {
loading.value = true
deleteGoodsSkusCardValue(tag.id).then(res => {
let i = item.goodsSkusCardValue.findIndex(o => o.id == tag.id)
if (i != -1) {
item.goodsSkusCardValue.splice(i, 1)
}
getTableData()
}).finally(() => {
loading.value = false
})
}
const showInput = () => {
inputVisible.value = true
nextTick(() => {
InputRef.value.input.focus()
})
}
// tag添加值
const handleInputConfirm = () => {
loading.value = true
if (!inputValue.value) {
inputVisible.value = false
return
}
createGoodsSkusCardValue({
"goods_skus_card_id": id, //规格ID
"name": item.name, //规格名称
"order": 50, //排序
"value": inputValue.value //规格选项名称
}).then(res => {
item.goodsSkusCardValue.push({
...res,
text: res.value
})
getTableData()
}).finally(() => {
loading.value = false
inputVisible.value = false
inputValue.value = ''
})
}
// tag修改值
const handleChange = (value, tag) => {
loading.value = true
updateGoodsSkusCardValue(tag.id, {
"goods_skus_card_id": id, //规格ID
"name": item.name, //规格名称
"order": tag.order, //排序
"value": value //规格选项名称
}).then(res => {
tag.value = value
getTableData()
}).catch((err) => {
tag.text = tag.value
}).finally(() => {
loading.value = false
})
}
return {
item,
inputValue,
inputVisible,
InputRef,
handleClose,
showInput,
handleInputConfirm,
handleChange,
handleDelete
}
}
// 初始化表格
export function initSkuTable () {
const skuLabels = computed(() => sku_card_list.value.filter(v => v.goodsSkusCardValue.length > 0))
// 获取表头数据
const tableThs = computed(() => {
let length = skuLabels.value.length
return [{
name: '商品规格',
// 表头合并的列数
colspan: length,
width: "",
// 表头合并的行数
rowspan: length > 0 ? 1 : 2
}, {
name: '销售价',
width: "100",
rowspan: 2
}, {
name: '市场价',
width: "100",
rowspan: 2
}, {
name: '成本价',
width: "100",
rowspan: 2
}, {
name: '库存',
width: "100",
rowspan: 2
}, {
name: '体积',
width: "100",
rowspan: 2
}, {
name: '重量',
width: "100",
rowspan: 2
}, {
name: '编码',
width: "100",
rowspan: 2
}]
})
return {
skuLabels,
tableThs,
sku_list
}
}
// 获取规格表格数据
function getTableData () {
if (sku_card_list.value.length == 0) return []
let list = []
sku_card_list.value.forEach(o => {
if (o.goodsSkusCardValue && o.goodsSkusCardValue.length > 0) {
list.push(o.goodsSkusCardValue)
}
})
if (list.length == 0) {
return []
}
let arr = cartesianProductOf(...list)
console.log(arr, 'arr');
sku_list.value = []
sku_list.value = arr.map(o => {
return {
code: "",
cprice: "0.00",
goods_id: goodsId.value,
image: "",
oprice: "0.00",
pprice: "0.00",
skus: o,
stock: 0,
volume: 0,
weight: 0,
}
})
}
规格选项弹框ChooseSku.vue
{{ item.name }}
{{ item }}
取消
确定
开发思路:大部分代码复用权限管理页,弹框内容较少
{{ data.name }}
推荐商品
修改
删除
删除
{{ row.title }}
分类:{{ (row.category && row.category.name) || '未分类' }}
创建时间:{{ row.create_time }}
¥{{ row.min_price }}
¥{{ row.min_oprice }}
取消
确定
开发思路:会员等级模块复用角色管理页
用户管理模块复用管理员管理页
修改
删除
累计消费满
元
设置会员等级所需要的累计消费必须大于等于0,单位:元
累计次数满
次
设置会员等级所需要的购买量必须大于等于0,单位:笔
%
折扣率单位为百分比,如输入90,表示该会员等级的用户可以以商品原价的90%购买
{{ row.username }}
ID:{{ row.id }}
{{ row.user_level ? row.user_level.name : '-' }}
注册时间:{{ row.create_time }}
最后登录:{{ row.last_login_time }}
修改
删除
开发思路:复用管理员管理页
{{ row.user.username }}
{{ row.review_time }}
回复
{{ row.review.data }}
回复
取消
客服
修改
{{ item.data }}
{{ row.id }}
{{ row.goods_item.title ?? '商品已被删除' }}
用户:{{ row.user.username || row.user.nickname }}
{{ row.review_time }}
开发逻辑:复用管理员管理页面
批量删除
订单号:
{{ row.no }}
下单时间:
{{ row.create_time }}
{{ item.goods_item ? item.goods_item.title : '商品已被删除' }}
{{ row.user.nickname || row.user.username }}
(用户ID:{{ row.user.id }})
付款状态:
微信支付
支付宝支付
未支付
发货状态:
{{ row.ship_data ? '已发货'
:
'未发货'
}}
收货状态:
{{
row.ship_status == 'received' ? '已收货' : '未收货'
}}
订单详情
订单发货
同意退款
拒绝退款
导出
导出订单功能 基本逻辑:
触发接口后,会返回一个 对象{size:6405,type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
将这个对象通过new Blob转化为blob格式通过window的方法转为url
通过生成a标签,模拟点击事件 完成excel下载操作
const onSubmit = () => {
if (!form.tab) return toast('订单类型不能为空', 'error')
loading.value = true
let starttime = null
let endtime = null
if (form.time && Array.isArray(form.time)) {
starttime = form.time[0]
endtime = form.time[1]
}
// 接口
exportOrder({
tab: form.tab,
starttime,
endtime
}).then(data => {
console.log(data,'data');
// 导出订单功能:
// 基本逻辑:1、触发接口后,会返回一个 对象{size:6405,type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
// 2、将这个对象通过new Blob转化为blob格式通过window的方法转为url
// 3、通过生成a标签,模拟点击事件 完成excel下载操作
let url = window.URL.createObjectURL(new Blob([data]))
let link = document.createElement('a')
link.style.display = 'none'
link.href = url
let filename = (new Date()).getTime() + '.xlsx'
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
close()
}).finally(() => {
loading.value = false
})
}
订单信息
{{ info.no }}
{{ payment_method }}
{{ paid_time }}
{{ info.create_time }}
发货信息
{{ info.ship_data.express_company }}
{{ info.ship_data.express_no }}
查看物流
{{ ship_time }}
商品信息
{{ item.goods_item?.title ?? '商品已被删除' }}
{{ item.sku }}
¥{{ item.price }}
x{{ item.num }}
¥{{ info.total_price }}
收货信息
{{ info.user.username || info.user.nickname }}
{{ info.address.phone }}
{{ info.address.province }}-{{ info.address.city }}-{{ info.address.district }}-{{ info.address.address }}
退款信息
{{ refund_status }}
{{ info.extra.refund_reason }}
使用el-timeline组件
物流公司:{{ info.typename }}
物流单号:{{ info.number }}
{{ item.status }}
关闭
开启
普通注册
手机注册
数字
小写字母
大写字母
符号
对象存储
请补全http:// 或 https://
是
否
api安全功能开启之后调用前端api需要传输签名串
秘钥设置关系系统中api调用传输签名串的编码规则,以及会员token解析,请慎重设置,注意设置之后对应会员要求重新登录获取token
保存
{{ row.name }}
{{ row.desc }}
配置
分钟后自动关闭
订单下单未付款,n分钟后自动关闭,设置0不自动关闭
天后自动确认收货
如果在期间未确认收货,系统自动完成收货,设置0不自动收货
天内允许申请售后
订单完成后 ,用户在n天内可以发起售后申请,设置0不允许申请售后
保存
点击上传
{{ form.wxpay.cert_client ? form.wxpay.cert_client : '还未配置' }}
例如:apiclient_cert.pem
点击上传
{{ form.wxpay.cert_key ? form.wxpay.cert_key : '还未配置' }}
例如:apiclient_key.pem
用于查询物流信息,接口申请(仅供参考)
保存