在线体验链接:http://amv3admin.top/#/login
项目地址:https://gitee.com/xiao-ming-1999/am-vue3.git
vscode开发vue3插件:volar(使用此插件时,把vetur禁用,vetur是vue2插件)
1、开发依赖
下载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、功能较为完善
2、vite使用到的包
自动导入Element Plus组件、图标 (自动引入太香了~) (链接) unplugin-icons、unplugin-auto-import、unplugin-vue-components
自动导入后使用 icon组件加前缀IEp 例: -> 自动引入图标废除,主页菜单会涉及到 动态渲染菜单图标,自动导入无法根据动态值来正确引入图标(以改为全局引入图标)
3、 VUE3新特性
1、指令
v-loading:指令元素loading效果,参数为布尔值
2、vue3使用需要注意的点
使用ref时,也要在script内声明ref变量。
例如
<template>
<div ref="divDom"></div>
</template>
<script setup>
import { ref } from 'vue'
// divDom与上面ref绑定的字符串相对应
const divDom =ref(null)
console.log(divDom) // <div ref="divDom"></div>
</script>
父组件通过ref使用子组件方法或变量时,子组件必须使用defineExpose暴露方法父组件才能拿到子组件的方法和变量
<script setup>
const count = ()=> console.log(11)
const a = 1
// 暴露方法、变量
defineExpose({
count,
a
})
</script>
子组件props与$emit
<template>
<div>
<!-- $emit('edit') 触发父组件的emit绑定事件 -->
<el-button
class="ml-auto px-1"
text
type="primary"
size="small"
@click="$emit('edit')"
><el-icon><Edit /></el-icon
></el-button>
<el-button
class="px-1"
text
type="primary"
size="small"
@click="$emit('close')"
><el-icon><Close /></el-icon
></el-button>
</div>
</template>
<script setup>
// 接收父组件参数
defineProps({
active: {
type: Boolean,
default: false
}
})
// 向外暴露方法
defineEmits(['edit','close'])
</script>
3、vue-use提供的方法
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、bug记录
4.1、订单列表页
通过注释找到bug
·全局配置、公共组件封装、框架初始化(重点)
1、axios配置
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)否则会出现逻辑错误
2、登录页
登录逻辑:login接口,存储token,获取用户信息、跳转至首页
<script setup>
import { ref, reactive } from 'vue'
import { login } from '@/api/manager.js'
import { useRouter } from 'vue-router'
import { useCookies } from '@vueuse/integrations/useCookies'
// do not use same name with ref
const form = reactive({
username: '',
password: ''
})
const rules = reactive({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
})
// 奇怪的写法,为什么会和上面的formRef关联呢?vue2中 模板里的ref应该绑定的是字符串,vue3绑定的却是script内的变量
const formRef = ref(null)
const router = useRouter()
const loading = ref(false)
const onSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
loading.value = true
const res = await login(form.username, form.password)
loading.value = false
if (res.token) {
ElNotification({
message: '登录成功',
type: 'success',
duration: 3000
})
// 存储token至cookie中
const cookie = useCookies()
cookie.set('admin-token', res.token)
router.push('/')
}
})
}
</script>
存储token:将token存储至cookie中,使用到vueuse库的useCookies方法
3、封装工具库配置
封装操作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
})
}
4、全局状态管理pinia配置
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,存储用户信息
<script setup>
import { mainStore } from '@/store/index'
const store = mainStore()
const onSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
loading.value = true
const res = await login(form.username, form.password)
loading.value = false
if (res.token) {
toast('登录成功')
// 存储token至cookie中
setToken(res.token)
const userInfo =await getInfo()
// pinia存储用户信息
store.SET_USERINFO(userInfo)
router.push('/')
}
})
}
</script>
5、路由前置守卫配置
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()
})
6、退出登录页
封装弹框提示
退出登录接口封装
基本逻辑:
// 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')
}
}
})
7、配置全局loading效果
npm i nprogress
根据nprogress文档使用 引入相应的css和js封装进工具库
路由前置守卫开启loading 后置守卫关闭loading
效果图:
8、动态修改网站title
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
})
9、首页框架搭建
el组件搭建 重点**(router-view)**
transition相关:链接
keep-alive相关:链接
<template>
<el-container>
<el-header>
<f-header />
</el-header>
<el-container>
<el-aside :style="`width:${store.asideWidth}`">
<f-menu />
</el-aside>
<el-main>
<f-tag-list />
<!-- 子路由显示 -->
<router-view v-slot="{ Component }">
<!-- 如果缓存的实例数量即将超过10个,则最久没有被访问的缓存实例将被销毁(再次加载组件会重新调用接口获取数据),以便为新的实例腾出空间。-->
<transition name="fade">
<KeepAlive :max="10">
<component :is="Component"></component>
</KeepAlive>
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import FHeader from './components/FHeader.vue'
import FMenu from './components/FMenu.vue'
import FTagList from './components/FTagList.vue'
import { mainStore } from '@/store/index.js'
const store = mainStore()
</script>
<style lang="scss" scoped>
.el-aside {
transition: all 0.2s;
}
// 加载前
.fade-enter-from {
opacity: 0;
-webkit-transform: translate3d(0, -100%, 0);
transform: translate3d(0, -100%, 0);
}
// 加载后
.fade-enter-to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
// 离开前
.fade-leave-from {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
// 离开后
.fade-leave-to {
opacity: 0;
-webkit-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
}
// 淡入激活
.fade-enter-active {
// 延迟0.3s加载
transition-delay: 0.3s;
}
// 淡出激活
.fade-leave-active {
transition: all 0.3s;
}
</style>
配置路由
// 主体框架
{
path: '/',
component: Admin,
// 子路由
children: [
{
path: '/',
component: Index,
meta: {
title: '后台首页'
},
}
]
},
10、封装el弹框组件
<template>
<el-drawer
v-model="showDrawer"
:size="size"
:title="title"
:destroy-on-close="destroyOnClose"
:close-on-click-modal="false"
>
<div class="form-drawer">
<div class="body">
<slot></slot>
</div>
<div class="actions">
<el-button type="primary" @click="submit" :loading="loading">{{
confirmText
}}</el-button>
<el-button type="default" @click="close">取消</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup>
import { ref } from 'vue'
const showDrawer = ref(false)
const loading = ref(false)
// 接收父组件传递参数 与vue2类似
const props = defineProps({
title: String,
size: {
type: String,
default: '45%'
},
// 控制是否在关闭 Drawer 之后将子元素全部销毁
destroyOnClose: {
type: Boolean,
default: false
},
confirmText: {
type: String,
default: '提交'
}
})
// 弹框状态
const open = () => (showDrawer.value = true)
const close = () => (showDrawer.value = false)
// loading状态
const showLoading = () => (loading.value = true)
const hideLoading = () => (loading.value = false)
// 向父组件暴露以下方法(vue3新增特性)
defineExpose({
open,
close,
showLoading,
hideLoading
})
// 接收父组件传递的'submit'事件
const emit = defineEmits(['submit'])
// 触发父组件事件
const submit = () => emit('submit')
</script>
<style lang="scss" scoped>
.form-drawer {
width: 100%;
height: 100%;
position: relative;
@apply flex flex-col;
.body {
overflow-y: auto;
@apply flex-1;
}
.actions {
height: 50px;
@apply flex items-center;
}
}
</style>
11、首页顶部header
代码拆分思路**(重点):**使用组合式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
}
}
使用:
<!-- 主页头部布局 -->
<template>
<div class="f-header">
<span class="logo">
<el-icon class="mr-1"><elemeFilled /></el-icon>
logo名
</span>
<el-icon class="icon-btn"><fold /></el-icon>
<el-icon class="icon-btn" @click="handleRefresh"><refresh /></el-icon>
<div class="ml-auto flex items-center">
<el-icon class="icon-btn" @click="toggle">
<fullScreen v-if="!isFullscreen" />
<aim v-else />
</el-icon>
<el-dropdown class="dropdown" @command="handleCommand">
<span class="el-dropdown-link flex items-center text-light-50">
<el-avatar class="mr-2" :size="25" :src="store.user.avatar" />
{{ store.user.username }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="handlePassword"
>修改密码</el-dropdown-item
>
<el-dropdown-item command="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 自定义抽屉组件 -->
<form-drawer
ref="formDrawerRef"
title="修改密码"
destroyOnClose
@submit="onSubmit"
>
<el-form
:rules="rules"
:model="form"
ref="formRef"
label-width="80px"
size="small"
>
<el-form-item prop="oldpassword" label="旧密码">
<el-input v-model="form.oldpassword" placeholder="请输入原密码">
</el-input>
</el-form-item>
<el-form-item prop="password" label="新密码">
<el-input
type="password"
v-model="form.password"
placeholder="请输入新密码"
show-password
>
</el-input>
</el-form-item>
<el-form-item prop="repassword" label="确认密码">
<el-input
type="password"
v-model="form.repassword"
placeholder="请确认密码"
show-password
@keydown.enter="onSubmit()"
>
</el-input>
</el-form-item>
</el-form>
</form-drawer>
</template>
<script setup>
// 全局状态pinia
import { mainStore } from '@/store/index.js'
// 全屏
import { useFullscreen } from '@vueuse/core'
import FormDrawer from '@/components/FormDrawer.vue'
import { useRePassword, useLogout } from '@/composables/useManager.js'
/* 变量部分 */
const {
// 是否全屏状态
isFullscreen,
// 切换全屏状态
toggle
} = useFullscreen()
const { form, rules, formRef, formDrawerRef, onSubmit, openRePasswordForm } =
useRePassword()
const { handleLogout } = useLogout
const store = mainStore()
// 下拉事件
function handleCommand(key) {
switch (key) {
// 修改密码
case 'handlePassword':
openRePasswordForm()
break
// 退出登录
case 'handleLogout':
handleLogout()
break
default:
break
}
}
// 全屏
function handleFullScreen() {
isFullscreen = true
}
// 刷新
const handleRefresh = () => location.reload()
</script>
<style lang="scss" scoped>
.f-header {
@apply flex bg-indigo-700 text-light-50 fixed top-0 left-0 right-0;
height: 64px;
.logo {
width: 250px;
@apply flex justify-center items-center text-xl font-thin;
}
.dropdown {
height: 64px;
cursor: pointer;
@apply flex justify-center items-center mx-5;
}
}
.icon-btn {
@apply flex justify-center items-center;
width: 42px;
height: 64px;
cursor: pointer;
&:hover {
@apply bg-indigo-600;
}
}
</style>
12、首页左侧菜单 重点!!(子导航多层嵌套)
子导航多层嵌套:菜单数据主要分为两种情况,一级菜单(没有child),多级菜单(有child,且有可能child后还有child)涉及到循环套用,组件使用也可以套用递归思想(一定要将组件拆分,否则容易报错)
<!-- 主页菜单布局 -->
<template>
<div class="f-menu" :style="`width:${store.asideWidth}`">
<el-menu
:collapse="isCollapse"
class="border-0"
@select="handleSelect"
:collapse-transition="false"
unique-opened
:default-active="defaultActive"
>
<subMenu v-for="menu in asideMenus" :key="menu.name" :menu="menu" />
</el-menu>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { mainStore } from '@/store/index.js'
import { computed, ref } from 'vue'
import subMenu from '../libs/subMenu.vue'
const router = useRouter()
const store = mainStore()
let asideMenus = [
{
name: '后台面板',
icon: 'help',
child: [
{
name: '主控台1',
frontpath: '/',
icon: 'home-filled'
}
]
},
{
name: '商城管理1',
icon: 'shopping-bag',
child: [
{
name: '商城管理2',
frontpath: '/goods/list',
icon: 'shopping-cart-full',
child: [
{
name: '商城管理',
frontpath: '/goods/list',
icon: 'shopping-cart-full2-1'
}
]
},
{
name: '商城管理3',
frontpath: '/goods/list3',
icon: 'shopping-cart-full3',
}
]
}
]
const isCollapse = computed(() => {
return !(store.asideWidth === '250px')
})
const route = useRoute()
const defaultActive = ref(route.path)
function handleSelect(e) {
// console.log(e, 'e')
router.push(e)
}
</script>
<style lang="scss" scoped>
.f-menu {
position: fixed;
top: 64px;
left: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
@apply shadow-md bg-light-50;
transition: all 0.2s;
}
</style>
<template>
<!-- 多级菜单 -->
<el-sub-menu :index="menu.name" v-if="menu.child && menu.child.length > 0">
<!-- 第一级 -->
<template #title>
<el-icon><component :is="menu.icon"></component></el-icon>
<span>{{ menu.name }}</span>
</template>
<!-- 多级嵌套菜单渲染 -->
<subMenu :menu="menuItem" v-for="(menuItem,index) in menu.child" :key="index"/>
<!-- 剪切部分 -->
</el-sub-menu>
<!-- 一级菜单 -->
<el-menu-item v-else :index="menu.frontpath">
<el-icon><component :is="menu.icon"></component></el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
<script setup>
const props = defineProps({
menu: {
type: Object,
default: {}
}
})
const menus = props.asideMenus
</script>
13、动态路由添加 (重点!)
先看代码 (此章节资料 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()
})
14、首页tabs封装
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
}
}
<template>
<div>
<div class="f-tag-list" :style="`left:${store.asideWidth}`">
<!-- tip:style="min-width:100px;" 必须设置,否则无法显示左右滚动按钮(el组件的bug) -->
<el-tabs
v-model="activeTab"
type="card"
class="flex-1"
style="min-width: 100px"
@tab-change="changeTab"
@tab-remove="removeTab"
>
<el-tab-pane
v-for="item in tabList"
:key="item.path"
:label="item.title"
:name="item.path"
:closable="item.path !== '/'"
>
</el-tab-pane>
</el-tabs>
<span class="tag-btn">
<el-dropdown @command="handleClose">
<span class="el-dropdown-link">
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="clearOther">关闭其他</el-dropdown-item>
<el-dropdown-item command="clearAll">全部关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
<div style="height: 44px"></div>
</div>
</template>
<script setup>
import { useTabList } from '@/composables/useTabList.js'
const { store, activeTab, tabList, changeTab, removeTab, handleClose } =
useTabList()
</script>
<style lang="scss" scoped>
.f-tag-list {
@apply fixed bg-gray-100 flex items-center px-2;
top: 64px;
right: 0;
height: 44px;
z-index: 100;
// 下拉框样式
.tag-btn {
@apply bg-white rounded ml-auto flex items-center justify-center px-2;
height: 32px;
}
}
:deep(.el-tabs__header) {
@apply mb-0 flex items-center;
border: 0 !important;
}
:deep(.el-tabs__nav) {
border: 0 !important;
}
:deep(.el-tabs__item) {
border: 0 !important;
height: 32px;
line-height: 32px;
@apply bg-white mx-1 rounded;
}
:deep(.el-tabs__nav-next),
:deep(.el-tabs__nav-prev) {
height: 32px;
line-height: 32px;
}
:deep(.is-disabled) {
cursor: not-allowed;
@apply text-gray-300;
}
</style>
// 监听路由变化
onBeforeRouteUpdate((to, from) => {
defaultActive.value = to.path
})
15、自定义指令(v-permission)
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
<template>
<div>
<el-row :gutter="20" v-permission="['getStatistics1,GET']">
<!-- 骨架屏 -->
<template v-if="panels.length === 0">
<el-col :span="6" :offset="0" v-for="i in 4" :key="i">
<el-skeleton animated loading>
<template #template>
<el-card shadow="hover">
<template #header>
<div class="flex justify-between">
<el-skeleton-item variant="text" style="width: 50%" />
<el-skeleton-item variant="text" style="width: 10%" />
</div>
</template>
<span class="text-3xl font-bold text-gray-500">
<el-skeleton-item variant="text" style="width: 30%" />
<el-divider />
<div class="flex justify-between text-sm to-gray-500">
<el-skeleton-item variant="text" style="width: 30%" />
<el-skeleton-item variant="text" style="width: 30%" />
</div>
</span>
</el-card>
</template>
</el-skeleton>
</el-col>
</template>
<el-col
:span="6"
:offset="0"
v-for="(item, index) in panels"
:key="index"
>
<el-card shadow="hover">
<template #header>
<div class="flex justify-between">
<span>{{ item.title }}</span>
<el-tag :type="item.unitColor" class="mx-1" effect="plain">
{{ item.unit }}
</el-tag>
</div>
</template>
<span class="text-3xl font-bold text-gray-500">
<countTo :value="item.value" />
<el-divider />
<div class="flex justify-between text-sm to-gray-500">
<span>{{ item.subTitle }}</span>
<span>{{ item.value }}</span>
</div>
</span>
</el-card>
</el-col>
</el-row>
<!-- 分类组件 -->
<IndexNavs />
<el-row :gutter="20" class="mt-5">
<el-col :span="12" :offset="0">
<IndexChart v-permission="['getStatistics3,GET']" />
</el-col>
<el-col :span="12" :offset="0" v-permission="['getStatistics2,GET']">
<IndexCard title="店铺及商品提示" tip="店铺及商品提示" :btns="goods" />
<IndexCard
title="交易提示"
tip="需要立即处理的交易订单"
:btns="order"
class="mt-3"
/>
</el-col>
</el-row>
</div>
</template>
功能模块开发、组件封装
1、首页开发
功能:骨架屏(el骨架屏)、数字动画(gsap第三方包**)、echarts图标渲染**
数字动画公共组件封装:
<template>
<div>
{{ d.num.toFixed(0) }}
</div>
</template>
<script setup>
import gsap from 'gsap'
import { reactive, watch } from 'vue'
const props = defineProps({
value: {
type: Number,
default: 0
}
})
const d = reactive({
num: 0
})
function AnimateToValue() {
gsap.to(d, {
duration: 0.5,
num: props.value
})
}
AnimateToValue()
watch(
() => props.value,
() => AnimateToValue()
)
</script>
首页组件(部分组件做了拆分)
<template>
<div>
<!-- v-permission自定义指令,对于没有权限的组件或元素进行删除处理 -->
<el-row :gutter="20" v-permission="['getStatistics1,GET']">
<!-- 骨架屏 -->
<template v-if="panels.length === 0">
<el-col :span="6" :offset="0" v-for="i in 4" :key="i">
<el-skeleton animated loading>
<template #template>
<el-card shadow="hover">
<template #header>
<div class="flex justify-between">
<el-skeleton-item variant="text" style="width: 50%" />
<el-skeleton-item variant="text" style="width: 10%" />
</div>
</template>
<span class="text-3xl font-bold text-gray-500">
<el-skeleton-item variant="text" style="width: 30%" />
<el-divider />
<div class="flex justify-between text-sm to-gray-500">
<el-skeleton-item variant="text" style="width: 30%" />
<el-skeleton-item variant="text" style="width: 30%" />
</div>
</span>
</el-card>
</template>
</el-skeleton>
</el-col>
</template>
<el-col
:span="6"
:offset="0"
v-for="(item, index) in panels"
:key="index"
>
<el-card shadow="hover">
<template #header>
<div class="flex justify-between">
<span>{{ item.title }}</span>
<el-tag :type="item.unitColor" class="mx-1" effect="plain">
{{ item.unit }}
</el-tag>
</div>
</template>
<span class="text-3xl font-bold text-gray-500">
<countTo :value="item.value" />
<el-divider />
<div class="flex justify-between text-sm to-gray-500">
<span>{{ item.subTitle }}</span>
<span>{{ item.value }}</span>
</div>
</span>
</el-card>
</el-col>
</el-row>
<!-- 分类组件 -->
<IndexNavs />
<el-row :gutter="20" class="mt-5">
<el-col :span="12" :offset="0">
<IndexChart v-permission="['getStatistics3,GET']" />
</el-col>
<el-col :span="12" :offset="0" v-permission="['getStatistics2,GET']">
<IndexCard title="店铺及商品提示" tip="店铺及商品提示" :btns="goods" />
<IndexCard
title="交易提示"
tip="需要立即处理的交易订单"
:btns="order"
class="mt-3"
/>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { getStatistics1, getStatistics2 } from '@/api/index.js'
import { ref } from 'vue'
import countTo from '@/components/countTo.vue'
import IndexNavs from '@/components/IndexNavs.vue'
import IndexChart from '@/components/IndexChart.vue'
import IndexCard from '@/components/IndexCard.vue'
const panels = ref([])
const goods = ref([])
const order = ref([])
const getData = async () => {
const res = await getStatistics1()
panels.value = res.panels
const data = await getStatistics2()
goods.value = data.goods
order.value = data.order
}
getData()
</script>
2、图库管理
开发思路:内容组件包括 侧边栏组件+主体组件
功能:表单新增图片分类,上传图片
<template>
<el-container class="bg-white rounded" :style="{ height: h + 'px' }">
<el-header class="image-header">
<el-button
type="primary"
size="small"
@click="imageAsideRef.handleCreate()"
>新增图片分类</el-button
>
<el-button
type="warning"
size="small"
@click="imageMaineRef.OpenUploadDrawer()"
>上传图片</el-button
>
</el-header>
<el-container>
<ImageAside ref="imageAsideRef" @change="handleAsideChange" />
<ImageMain ref="imageMaineRef" />
</el-container>
</el-container>
</template>
<script setup>
import ImageAside from '@/components/ImageAside.vue'
import ImageMain from '@/components/ImageMain.vue'
import { ref } from 'vue'
// 根据显示器高度 给父容器赋值
const windowHeight = window.innerHeight | document.body.clientHeight
const h = windowHeight - 64 - 44
const imageAsideRef = ref(null)
const imageMaineRef = ref(null)
const handleAsideChange = (id) => {
imageMaineRef.value.loadData(id)
}
</script>
<style lang="scss" scoped>
.image-header {
border-bottom: 1px solid #eeeeee;
@apply flex items-center;
}
.image-aside,
.image-main {
position: relative;
.top {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-y: auto;
}
.bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
@apply flex items-center justify-center;
height: 50px;
}
}
.image-aside {
border-right: 1px solid #eeeeee;
}
</style>
<template>
<el-aside width="220px" class="image-aside" v-loading="loading">
<div class="top">
<Aside
v-for="(item, index) in list"
:key="index"
@edit="handleEdit(item)"
@close="handleClose(item.id)"
:active="activeId === item.id"
@click="handleChangeActiveId(item.id)"
>{{ item.name }}
</Aside>
</div>
<div class="bottom">
<el-pagination
background
layout="prev, next"
:total="total"
:page-size="limit"
:current-page="currentPage"
@current-change="getData"
/>
</div>
</el-aside>
<FormDrawer
:title="drawerTitle"
ref="forDrawerRef"
@submit="handleSubmit"
:destroyOnClose="true"
>
<el-form :model="form" ref="formRef" :rules="rules" :inline="false">
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="排序" prop="order">
<el-input-number v-model="form.order" :min="0" :max="1000" />
</el-form-item>
</el-form>
</FormDrawer>
</template>
<script setup>
import Aside from './Aside.vue'
import {
getImageClassList,
createImageClassList,
updateImageClassList,
deleteImageClassList
} from '@/api/image_class.js'
import { ref, reactive, computed } from 'vue'
import { toast, showModel } from '@/composables/util.js'
import FormDrawer from '@/components/FormDrawer.vue'
// 加载动画
let loading = ref(false)
const list = ref([])
const activeId = ref(0)
const forDrawerRef = ref(null)
const formRef = ref(null)
// 分页参数
let currentPage = ref(1)
let limit = ref(10)
let total = ref(0)
// 表单
let form = reactive({
name: '',
order: 50
})
const rules = reactive({
name: [{ required: true, message: '图库分类名称不能为空', trigger: 'blur' }],
order: [{ required: true, message: '图库排序不能为空', trigger: 'blur' }]
})
const editId = ref(0)
const drawerTitle = computed(() => {
return editId.value ? '修改' : '新增'
})
const getData = async (p = null) => {
// p为当前页码数
if (typeof p == 'number') {
currentPage.value = p
}
loading.value = true
const res = await getImageClassList(currentPage.value)
list.value = res.list
total.value = res.totalCount
let item = res.list[0]
handleChangeActiveId(item.id)
loading.value = false
}
getData()
// 新增图库
const handleCreate = () => {
editId.value = 0
form.name = ''
form.order = 50
forDrawerRef.value.open()
}
// 编辑图库
const handleEdit = (row) => {
editId.value = row.id
form.name = row.name
form.order = row.order
forDrawerRef.value.open()
}
// 删除图库
const handleClose = async (id) => {
const data = await deleteImageClassList(id)
toast('删除成功')
getData()
}
// 提交表单
const handleSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
forDrawerRef.value.showLoading()
const Fun = editId.value
? updateImageClassList(editId.value, form)
: createImageClassList(form)
const data = await Fun
console.log(data, 'data')
forDrawerRef.value.hideLoading()
toast(drawerTitle.value + '成功')
getData(editId.value ? currentPage.value : 1)
forDrawerRef.value.close()
form.name = ''
form.order = 50
})
}
// 选中图库分类id 向父组件传递一个change方法
const emit = defineEmits(['change'])
const handleChangeActiveId = (id) => {
activeId.value = id
// 触发父组件change方法
emit('change', id)
}
defineExpose({
handleCreate
})
</script>
<style lang="scss" scoped>
.image-aside {
position: relative;
border-right: 1px solid #eeeeee;
.top {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-y: auto;
}
.bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
@apply flex items-center justify-center;
height: 50px;
}
}
.image-aside {
border-right: 1px solid #eeeeee;
}
</style>
<template>
<el-main class="image-main" v-loading="loading">
<div class="top p-3">
<!-- <div v-for="(item,index) in list" :key="index">{{ item.name }}</div> -->
<el-row :gutter="10">
<el-col
v-for="(item, index) in list"
:key="index"
:span="6"
:offset="0"
>
<el-card
shadow="hover"
class="relative mb-3"
:body-style="{ padding: 0 }"
>
<el-image
:src="item.url"
fit="cover"
class="w-[100%] h-[150px]"
:preview-src-list="srcList"
:initial-index="currentImage"
@click="handleImage(index)"
></el-image>
<div class="image-title" :title="item.name">{{ item.name }}</div>
<div class="flex items-center justify-center p-2">
<el-button
type="primary"
size="small"
text
@click="handleRename(item)"
>
重命名
</el-button>
<el-popconfirm
title="是否删除该图片?"
@confirm="handleDeleteImage(item.id)"
confirm-button-text="确认"
cancel-button-text="取消"
>
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="bottom">
<div class="bottom">
<el-pagination
background
layout="prev,pager,next"
:total="total"
:page-size="limit"
:current-page="currentPage"
@current-change="getData"
/>
</div>
</div>
</el-main>
<!-- 上传图片弹框 -->
<el-drawer title="上传图片" v-model="drawer" direction="rtl">
<UploadFile :data="{image_class_id}" @successUpload="handleUploadSuccess"/>
</el-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getImageList, updateImage, deleteImage } from '@/api/image.js'
import { showPrompt, toast } from '@/composables/util.js'
import UploadFile from '@/components/UploadFile.vue'
// 分页参数
const currentPage = ref(1)
const limit = ref(12)
const total = ref(0)
const loading = ref(false)
const list = ref([])
const image_class_id = ref(0)
const srcList = ref([])
const currentImage = ref(0)
// 上传图片
const drawer = ref(false)
const getData = async (p = null) => {
// p为当前页码数
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
})
list.value = res.list
total.value = res.totalCount
loading.value = false
}
// 重命名
const handleRename = async (item) => {
try {
const { value: name } = await showPrompt('重命名', item.name)
loading.value = true
await updateImage(item.id, name)
loading.value = false
toast('修改成功')
getData()
} catch (err) {
console.log(err, 'err')
}
}
// 删除
const handleDeleteImage = async (id) => {
loading.value = true
await deleteImage([id])
loading.value = false
toast('删除成功')
getData()
}
// 选中图片触发
const handleImage = (index) => {
currentImage.value = index
}
// 根据分类id重新加载图片列表
const loadData = (id) => {
currentPage.value = 1
image_class_id.value = id
getData()
}
const OpenUploadDrawer = () => {
drawer.value = true
}
// 上传图片成功钩子
const handleUploadSuccess =()=> {
getData(1)
}
defineExpose({
loadData,
OpenUploadDrawer
})
</script>
<style lang="scss" scoped>
.image-main {
position: relative;
.top {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-x: hidden;
overflow-y: auto;
.image-title {
position: absolute;
top: 122px;
left: -1px;
right: -1px;
@apply text-sm truncate text-gray-100 bg-opacity-50 bg-gray-800 px-2 py-1;
}
}
.bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
@apply flex items-center justify-center;
height: 50px;
}
}
</style>
3、公告管理页
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 新增|刷新 -->
<div class="flex items-center justify-between mb-4">
<el-button type="primary" size="small" @click="handleCreate"
>新增</el-button
>
<el-tooltip effect="dark" content="刷新数据" placement="top">
<el-button text @click="getData()">
<el-icon :size="20"><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column prop="title" label="公告标题" />
<el-table-column prop="create_time" label="发布时间" width="380" />
<el-table-column label="操作" width="180" align="center">
<template #default="scope">
<el-button
type="primary"
size="small"
text
@click="handleUpdate(scope.row)"
>修改</el-button
>
<el-popconfirm
title="是否删除该记录?"
@confirm="handleDelete(scope.row.id)"
confirm-button-text="确认"
cancel-button-text="取消"
>
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination
background
layout="prev,pager,next"
:total="total"
:page-size="limit"
:current-page="currentPage"
@current-change="getData"
/>
</div>
<!-- 抽屉 -->
<FormDrawer
:title="drawerTitle"
ref="forDrawerRef"
@submit="handleSubmit"
:destroyOnClose="true"
>
<el-form :model="form" ref="formRef" :rules="rules" :inline="false">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="form.title"
placeholder="请填写公告标题"
></el-input>
</el-form-item>
<el-form-item label="公告内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
rows="5"
placeholder="请填写公告内容"
/>
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import {
getNoticeList,
createNotice,
updateNotice,
deleteNotice
} from '@/api/notice.js'
import { showPrompt, toast } from '@/composables/util.js'
import FormDrawer from '@/components/FormDrawer.vue'
import { userInitTable } from '@/composables/useCommon.js'
const { searchForm, tableData, limit, loading, total, currentPage, getData } =
userInitTable({
getList: getNoticeList
})
// 表单参数
const forDrawerRef = ref(null)
const formRef = ref(null)
let form = reactive({
title: '',
content: ''
})
const rules = reactive({
title: [{ required: true, message: '公告标题不能为空', trigger: 'blur' }],
content: [{ required: true, message: '公告内容不能为空', trigger: 'blur' }]
})
const editId = ref(0)
const drawerTitle = computed(() => {
return editId.value ? '修改公告' : '新增公告'
})
// const getData = async (p = null) => {
// // p为当前页码数
// if (typeof p == 'number') {
// currentPage.value = p
// }
// loading.value = true
// const res = await getNoticeList(currentPage.value)
// tableData.value = res.list
// total.value = res.totalCount
// loading.value = false
// }
getData()
// 提交表单
const handleSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
forDrawerRef.value.showLoading()
const Fun = editId.value
? updateNotice(editId.value, form)
: createNotice(form)
const data = await Fun
forDrawerRef.value.hideLoading()
toast(drawerTitle.value + '成功')
// 修改刷新当前页,新增刷新第一页
getData(editId.value ? currentPage.value : 1)
forDrawerRef.value.close()
form.title = ''
form.content = ''
})
}
// 新增公告
const handleCreate = () => {
// editId.value = 0
form.title = ''
form.content = ''
forDrawerRef.value.open()
}
// 更新公告
const handleUpdate = (row) => {
editId.value = row.id
form.title = row.title
form.content = row.content
forDrawerRef.value.open()
}
// 删除
const handleDelete = async (id) => {
loading.value = true
await deleteNotice(id)
loading.value = false
toast('删除成功')
getData()
}
</script>
<style lang="scss" scoped>
/* v-loading 样式权重默认为2000,会覆盖header影响体验*/
:deep(.el-loading-mask) {
z-index: 999;
}
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
4、管理员管理页
**功能:**点击新增或修改,弹出抽屉组件,选择头像弹出选择图片组件
复杂难点:
图片组件封装:复用图库管理业,需将imageAmin组件多加一个复选框(第三个图红框标注)
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 搜索 -->
<el-form
:model="searchForm"
:rules="rules"
label-width="80px"
class="mb-3"
size="small"
>
<el-row :gutter="20">
<el-col :span="8" :offset="0">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="管理员昵称"
clearable
@keydown.enter="getData"
/>
</el-form-item>
</el-col>
<el-col :span="8" :offset="8">
<div class="flex items-center justify-end">
<el-button type="primary" @click="getData">搜索</el-button>
<el-button @click="resetSearchForm">重置</el-button>
</div>
</el-col>
</el-row>
</el-form>
<!-- 新增|刷新 -->
<div class="flex items-center justify-between mb-4">
<el-button type="primary" size="small" @click="handleCreate"
>新增</el-button
>
<el-tooltip effect="dark" content="刷新数据" placement="top">
<el-button text @click="getData()">
<el-icon :size="20"><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column label="管理员" width="200">
<template #default="{ row }">
<div class="flex items-center">
<el-avatar :size="40" :src="row.avatar">
// row.avatar没有值,默认展示的图片
<img
src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png"
/>
</el-avatar>
<div class="ml-3">
<h6>{{ row.username }}</h6>
<small>ID:{{ row.id }}</small>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="所属角色" align="center">
<template #default="{ row }">
{{ row.role ? row.role.name : '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-switch
:loading="row.statusLoading"
:modelValue="row.status"
:active-value="1"
:inactive-value="0"
:disabled="row.super === 1"
@change="handleStatusChange($event, row)"
>
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<small v-if="row.super === 1" class="text-sm text-gray-500"
>暂无操作</small
>
<div v-else>
<el-button
type="primary"
size="small"
text
@click="handleUpdate(row)"
>修改</el-button
>
<el-popconfirm
title="是否删除该管理员?"
@confirm="handleDelete(row.id)"
confirm-button-text="确认"
cancel-button-text="取消"
>
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination
background
layout="prev,pager,next"
:total="total"
:page-size="limit"
:current-page="currentPage"
@current-change="getData"
/>
</div>
<!-- 抽屉弹框 -->
<FormDrawer
:title="drawerTitle"
ref="formDrawerRef"
@submit="handleSubmit"
:destroyOnClose="true"
>
<el-form
:model="form"
ref="formRef"
:rules="rules"
label-width="80px"
:inline="false"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="用户名"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" placeholder="密码"></el-input>
</el-form-item>
<el-form-item label="头像" prop="avatar">
<ChooseImage v-model="form.avatar" />
</el-form-item>
<el-form-item label="所属角色" prop="role_id">
<el-select v-model="form.role_id" placeholder="选择所属角色">
<el-option
v-for="item in roles"
:key="item.id"
:label="item.name"
:value="item.id"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="content">
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
>
</el-switch>
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import {
getManagerList,
updateManagerStatus,
createManager,
updateManager,
deleteManager
} from '@/api/manager.js'
import { showPrompt, toast } from '@/composables/util.js'
import { userInitTable } from '@/composables/useCommon.js'
import FormDrawer from '@/components/FormDrawer.vue'
import chooseImage from '@/components/chooseImage.vue'
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
}
})
// 表单参数
const formDrawerRef = ref(null)
const formRef = ref(null)
let form = reactive({
username: '',
password: '',
role_id: null,
status: 1,
avatar: ''
})
const rules = {
username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
password: [{ required: true, message: '密码不能为空', trigger: 'blur' }],
role_id: [{ required: true, message: '所属角色不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
// avatar: [{ required: true, message: '头像不能为空', trigger: 'blur' }]
}
const editId = ref(0)
const drawerTitle = computed(() => {
return editId.value ? '修改管理员' : '新增管理员'
})
// 修改状态
const handleStatusChange = async (status, row) => {
row.statusLoading = true
await updateManagerStatus(row.id, status)
row.statusLoading = false
toast('修改状态成功')
row.status = status
}
// 提交表单
const handleSubmit = () => {
formRef.value.validate(async (valid) => {
if (!valid) return false
formDrawerRef.value.showLoading()
const Fun = editId.value
? updateManager(editId.value, form)
: createManager(form)
const data = await Fun
formDrawerRef.value.hideLoading()
toast(drawerTitle.value + '成功')
// 修改刷新当前页,新增刷新第一页
getData(editId.value ? currentPage.value : 1)
formDrawerRef.value.close()
form.title = ''
form.content = ''
})
}
// 重置表单
function resetForm(row = false) {
if (formRef.value) formRef.value.clearValidate()
if (row) {
for (const key in form) {
form[key] = row[key]
}
}
}
// 新增公告
const handleCreate = () => {
editId.value = 0
resetForm({
username: '',
password: '',
role_id: null,
status: 1,
avatar: ''
})
formDrawerRef.value.open()
}
// 更新公告
const handleUpdate = (row) => {
editId.value = row.id
form.username = row.username
form.password = row.password
form.role_id = row.role_id
form.status = row.status
form.avatar = row.avatar
formDrawerRef.value.open()
}
// 删除
const handleDelete = async (id) => {
loading.value = true
try {
await deleteManager(id)
toast('删除成功')
getData()
} catch (err) {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
/* v-loading 样式权重默认为2000,会覆盖header影响体验*/
:deep(.el-loading-mask) {
z-index: 999;
}
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
<template>
<!-- 图片展示 -->
<div v-if="modelValue" class="flex items-center">
<el-image
:src="modelValue"
fit="cover"
class="current-image w-[100px] h-[100px] rounded border mr-2"
></el-image>
</div>
<!-- 按钮 -->
<div class="choose-image-btn" @click="open()">
<el-icon :size="25" class="text-gray-500"><Plus /></el-icon>
</div>
<!-- 弹框组件 -->
<el-dialog title="选择图片" v-model="dialogVisible" width="80%" top="5vh">
<el-container class="bg-white rounded" style="height: 70vh">
<el-header class="image-header">
<el-button
type="primary"
size="small"
@click="imageAsideRef.handleCreate()"
>新增图片分类</el-button
>
<el-button
type="warning"
size="small"
@click="imageMaineRef.OpenUploadDrawer()"
>上传图片</el-button
>
</el-header>
<el-container>
<ImageAside ref="imageAsideRef" @change="handleAsideChange" />
<ImageMain
ref="imageMaineRef"
@choose="handleChoose"
:openChoose="true"
/>
</el-container>
</el-container>
<template #footer>
<span>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="submit">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import ImageAside from '@/components/ImageAside.vue'
import ImageMain from '@/components/ImageMain.vue'
const dialogVisible = ref(false)
const open = () => {
dialogVisible.value = true
}
const close = () => {
dialogVisible.value = false
}
const imageAsideRef = ref(null)
const imageMaineRef = ref(null)
const handleAsideChange = (id) => {
imageMaineRef.value.loadData(id)
}
const props = defineProps({
modelValue: [String, Array]
})
const emit = defineEmits(['update:modelValue'])
let urls = []
const handleChoose = (e) => {
urls = e.map((o) => o.url)
}
const submit = () => {
if (urls.length) {
emit('update:modelValue', urls[0])
close()
}
}
</script>
<style lang="scss" scoped>
.choose-image-btn {
@apply w-[100px] h-[100px] rounded border flex items-center justify-center cursor-pointer hover:(bg-gray-100);
}
// 弹框样式
.image-header {
border-bottom: 1px solid #eeeeee;
@apply flex items-center;
}
.image-aside,
.image-main {
position: relative;
.top {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-y: auto;
}
.bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
@apply flex items-center justify-center;
height: 50px;
}
}
.image-aside {
border-right: 1px solid #eeeeee;
}
</style>
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)
}
5、公共逻辑拆分(页面公共逻辑(获取数据,分页)+表单逻辑)(重点++)
5.1、表单逻辑拆分useInitForm
参数拆分:表单、表单ref、抽屉ref、表单规则、修改id、弹框title、当前页码数(点击确定,刷新数据时需要)。
方法拆分:表单的新增、修改、提交
5.2、页面公共部分拆分userInitTable
参数拆分:列表数据,分页参数(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
6、权限管理页(菜单、接口配置)
功能:el-tree构建页面,弹窗复用公共组件FormDrawer,表单新增,表单逻辑复用标题5的公共逻辑
难点:组件配置项太多,容易弄混。复杂的配置项在模板中有标注(el-cascader级联选择器,el-tree)
**核心:**主要是通过addRoute添加动态路由(router.js页)
<template>
<el-card shadow="never" class="border-0">
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 树 -->
<!--default-expanded-keys :默认展开的一维数组 -->
<el-tree :data="tableData" :props="{ label: 'name', children: 'child' }" v-loading="loading" node-key="id"
:default-expanded-keys="defaultExpandedKeys">
<template #default="{ node, data }">
<div class="custom-tree-node">
<el-tag :type="data.menu ? '' : 'info'" size="small">{{
data.menu ? '菜单' : '权限'
}}</el-tag>
<el-icon v-if="data.icon" :size="16" class="ml-2">
<component :is="data.icon"></component>
</el-icon>
<span> {{ data.name }} </span>
<div class="ml-auto">
<el-switch :modelValue="data.status" :active-value="1" :inactive-value="0" @change="handleStatusChange($event, data)"/>
<el-button type="primary" size="small" text @click.stop="handleEdit(data)">修改</el-button>
<el-button type="primary" size="small" text @click.stop="addChild(data.id)">增加</el-button>
<el-popconfirm title="是否要删除该记录?" @confirm="handleDelete(data.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</div>
</div>
</template>
</el-tree>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" label-width="80px" :inline="false">
<el-form-item label="上级菜单">
<!-- el-cascader级联选择器
props配置:
checkStrictly:遵守父子节点不互相关联
emitPath:选中后是否返回子节点整个对象,若为false则只返回该节点的值(id)
value:指定选项的值为选项对象的某个属性值 (即为options 某项的id)-->
<el-cascader v-model="form.rule_id" :options="options" :props="{
value: 'id',
label: 'name',
children: 'child',
checkStrictly: true,
emitPath: false
}" clearable placeholder="请选择上级菜单" />
</el-form-item>
<el-form-item label="菜单/规则">
<el-radio-group v-model="form.menu">
<el-radio :label="1" border>菜单</el-radio>
<el-radio :label="0" border>规则</el-radio>
</el-radio-group>
<!-- <el-input v-model="form.menu" placeholder="用户名"></el-input> -->
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="名称" style="width: 30%"></el-input>
</el-form-item>
<el-form-item label="菜单图标" v-if="form.menu == 1">
<IconSelect v-model="form.icon" />
<!-- <el-input v-model="form.icon" placeholder="菜单图标"></el-input> -->
</el-form-item>
<el-form-item label="前端路由" v-if="form.menu == 1 && form.rule_id > 0">
<el-input v-model="form.frontpath" placeholder="前端路由"></el-input>
</el-form-item>
<el-form-item label="后端规则" v-if="form.menu == 0">
<el-input v-model="form.condition" placeholder="后端规则"></el-input>
</el-form-item>
<el-form-item label="请求方式" v-if="form.menu == 0">
<el-select v-model="form.method" placeholder="请选择请求方式" size="large">
<el-option v-for="item in methodList" :key="item" :label="item" :value="item" />
</el-select>
<!-- <el-input v-model="form.method" placeholder="用户名"></el-input> -->
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.order" :min="0" :max="1000" />
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import { getRuleList, createRule, updateRule, deleteRule, updateRuleStatus } from '@/api/rule.js'
import ListHeader from '@/components/ListHeader.vue'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import FormDrawer from '@/components/FormDrawer.vue'
import IconSelect from '@/components/IconSelect.vue'
let defaultExpandedKeys = ref([])
const options = ref([])
const methodList = ['GET', 'POST', 'PUT', 'DELETE']
let { loading, tableData, getData, handleDelete,handleStatusChange } = useInitTable({
getList: getRuleList,
onGetListSuccess: (res) => {
options.value = res.rules
tableData.value = res.list
// 默认展开的一维数组,必须包含唯一值id
defaultExpandedKeys.value = res.list.filter((item) => item.id)
},
delete: deleteRule,
updateStatus: updateRuleStatus,
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
title: '菜单权限',
form: {
rule_id: 0,
menu: 1,
name: '',
condition: '',
method: 'GET',
status: 0,
order: 50,
icon: '',
frontpath: ''
},
rules: {
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
// icon: [{ required: true, message: '图标不能为空', trigger: 'blur' }]
},
getData,
update: updateRule,
create: createRule,
})
// 增加子菜单
const addChild = (id) => {
handleCreate()
form.rule_id = id
}
</script>
<style lang="scss" scoped>
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
padding: 20px 0;
}
.custom-tree-node {
flex: 1;
@apply flex items-center justify-center;
font-size: 16px;
padding-right: 8px;
}
</style>
<template>
<el-select :modelValue="modelValue" placeholder="请选择图标" filterable @change="handleChange">
<template #prefix v-if="modelValue">
<el-icon :size="20">
<component :is="modelValue"></component>
</el-icon>
</template>
<el-option v-for="item in icons" :key="item" :label="item" :value="item">
<div class="flex items-center justify-between">
<el-icon :size="20">
<component :is="item"></component>
</el-icon>
<span class="text-gray-500">{{ item }}</span>
</div>
</el-option>
</el-select>
</template>
<script setup>
import { ref } from 'vue'
import * as iconList from '@element-plus/icons-vue'
defineProps({
modelValue: String
})
const icons = ref([])
// 获取所有图标的key
icons.value = Object.keys(iconList)
// update:modelValue 直接修改父组件的值
const emits = defineEmits(['update:modelValue'])
const handleChange = (icon) => {
emits('update:modelValue', icon)
}
</script>
<style lang='scss' scoped>
</style>
7、角色管理页(角色权限配置)
功能:列表展示、分页、表单新增,与公共管理类似,多了一个配置权限功能。代码复用标题5的公共逻辑
难点:配置权限弹框的虚拟树渲染,配置项容易出错(模板中已标注)
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column prop="name" label="角色名称" />
<el-table-column prop="desc" label="角色描述" />
<el-table-column prop="status" label="状态" width="300" align="center">
<template #default="scope">
<el-switch :modelValue="scope.row.status" :active-value="1" :inactive-value="0"
@change="handleStatusChange($event, scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button type="primary" size="small" text @click="openSetRule(scope.row)">配置权限</el-button>
<el-button type="primary" size="small" text @click="handleEdit(scope.row)">修改</el-button>
<el-popconfirm title="是否删除该记录?" @confirm="handleDelete(scope.row.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" :inline="false" label-width="80px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" placeholder="请填写角色名称"></el-input>
</el-form-item>
<el-form-item label="角色描述" prop="desc">
<el-input v-model="form.desc" type="textarea" rows="5" placeholder="请填写角色描述" />
</el-form-item>
<el-form-item label="角色状态" prop="status">
<el-switch :modelValue="form.status" :active-value="1" :inactive-value="0">
</el-switch> </el-form-item>
</el-form>
</FormDrawer>
<!-- 权限配置抽屉 -->
<FormDrawer :title="ruleDrawerTitle" ref="setRuleFormDrawerRef" @submit="handleSetRuleSubmit"
:destroyOnClose="true">
<!--
check-strictly配置项:在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为 false
如果使用默认选项即check-strictly:false,获取已选数据后,父子节点会关联,即父节点下的子节点全都会选中
所以需要做动态处理
-->
<el-tree-v2 ref="elTreeRef" :data="ruleList" :props="{
value: 'id',
label: 'name',
children: 'child'
}" show-checkbox node-key="id" :height="treeHeight" :check-strictly="checkStrictly"
:default-expanded-keys="defaultExpandedKeys" @check="handleTreeCheck">
<template #default="{ node, data }">
<el-tag :type="data.menu ? '' : 'info'" size="small" effect="light">
{{ data.menu ? '菜单' : '权限' }}
</el-tag>
<span class="ml-2 text-sm">{{ data.name }}</span>
</template>
</el-tree-v2>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import {
getRoleList,
createRole,
updateRole,
deleteRole,
updateRoleStatus,
setRoleRules
} from '@/api/role.js'
import { getRuleList } from '@/api/rule.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import { toast } from "@/composables/util.js";
import ListHeader from '@/components/ListHeader.vue'
import FormDrawer from '@/components/FormDrawer.vue'
const {
searchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange
} = useInitTable({
getList: getRoleList,
delete: deleteRole,
updateStatus: updateRoleStatus
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
currentPage,
title: '角色',
form: {
name: '',
desc: '',
status: 1
},
rules: {
name: [{ required: true, message: '角色名不能为空', trigger: 'blur' }],
},
getData,
update: updateRole,
create: createRole
})
// 权限抽屉配置
const ruleDrawerTitle = '权限配置'
const setRuleFormDrawerRef = ref(null)
const ruleList = ref([])
const treeHeight = ref(0)
// 默认展开数组
let defaultExpandedKeys = ref([])
const elTreeRef = ref(null)
// 当前项id
const roleId = ref(0)
// 当前角色拥有的权限id
let ruleIds = ref([])
// 是否严格的遵循父子不互相关联
const checkStrictly = ref(false)
// 权限抽屉提交按钮
const handleSetRuleSubmit = async () => {
setRuleFormDrawerRef.value.showLoading()
try {
const res = await setRoleRules(roleId.value, ruleIds.value)
toast('修改成功')
} catch (err) {
console.log(err, 'err');
}
setRuleFormDrawerRef.value.hideLoading()
setRuleFormDrawerRef.value.close()
getData()
}
// 打开权限抽屉
const openSetRule = async (row) => {
roleId.value = row.id
// 树容器高度
treeHeight.value = window.innerHeight - 180
checkStrictly.value = true
setRuleFormDrawerRef.value.showLoading()
const res = await getRuleList(1)
setRuleFormDrawerRef.value.hideLoading()
// 数列表
ruleList.value = res.list
// 默认展开的一维数组,必须包含唯一值id
defaultExpandedKeys.value = res.list.map((item) => item.id)
// 权限抽屉
setRuleFormDrawerRef.value.open()
// 当前角色拥有的权限id
ruleIds.value = row.rules.map(o => o.id)
// 这里的定时器执行是因为 必须要等树模板渲染完后才能拿到树绑定的ref方法
setTimeout(() => {
elTreeRef.value.setCheckedKeys(ruleIds.value)
checkStrictly.value = false
}, 150)
}
const handleTreeCheck = (...e) => {
const { checkedKeys, halfCheckedKeys } = e[1]
ruleIds.value = [...checkedKeys, ...halfCheckedKeys]
}
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
height: 30px;
}
</style>
8、规格管理页(商品默认规格配置)
功能:列表展示、分页、表单新增(复用角色管理页模板,复用公共逻辑)
新增:公共组件新增:**公共组件tagInput.vue,**公共逻辑新增:多选、批量删除
<template>
<!-- 商品规格管理 -->
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 新增|刷新 -->
<ListHeader layout="create,refresh,delete" @create="handleCreate" @refresh="getData" @delete="handleMultiDelete" />
<!-- 表格 -->
<el-table ref="multipleTableRef" :data="tableData" stripe style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="规格名称" />
<el-table-column prop="default" label="规格值" />
<el-table-column prop="order" label="排序" />
<el-table-column prop="status" label="状态" width="300" align="center">
<template #default="scope">
<el-switch :modelValue="scope.row.status" :active-value="1" :inactive-value="0"
@change="handleStatusChange($event, scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button type="primary" size="small" text @click="handleEdit(scope.row)">修改</el-button>
<el-popconfirm title="是否删除该规格?" @confirm="handleDelete(scope.row.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" :inline="false" label-width="80px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" placeholder="请填写规格名称"></el-input>
</el-form-item>
<el-form-item label="排序" prop="order">
<el-input-number v-model="form.order" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch :modelValue="form.status" :active-value="1" :inactive-value="0">
</el-switch>
</el-form-item>
<el-form-item label="规格" prop="default">
<TagInput v-model="form.default" />
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import {
getSkusList,
createSkus,
updateSkus,
deleteSkus,
updateSkusStatus
} from '@/api/skus.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import { toast } from "@/composables/util.js";
import ListHeader from '@/components/ListHeader.vue'
import FormDrawer from '@/components/FormDrawer.vue'
import TagInput from '@/components/TagInput.vue'
const {
searchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange,
handleSelectionChange,
multipleTableRef,
handleMultiDelete
} = useInitTable({
getList: getSkusList,
delete: deleteSkus,
updateStatus: updateSkusStatus
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
currentPage,
title: '角色',
form: {
name: '',
default: '',
order: 1,
status: 0
},
rules: {
name: [{ required: true, message: '角色名不能为空', trigger: 'blur' }], name: [{ required: true, message: '角色名不能为空', trigger: 'blur' }],
default: [{ required: true, message: '规格值必填', trigger: 'blur' }], name: [{ required: true, message: '角色名不能为空', trigger: 'blur' }],
},
getData,
update: updateSkus,
create: createSkus
})
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
height: 30px;
}
</style>
<template>
<el-tag v-for="tag in dynamicTags" :key="tag" class="mx-1" closable :disable-transitions="false"
@close="handleClose(tag)">
{{ tag }}
</el-tag>
<el-input v-if="inputVisible" ref="InputRef" v-model="inputValue" class="ml-1 w-20" size="small"
@keyup.enter="handleInputConfirm" @blur="handleInputConfirm" />
<el-button v-else class="button-new-tag ml-1" size="small" @click="showInput">
+ 添加值
</el-button>
</template>
<script setup>
import { nextTick, ref } from 'vue'
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
const inputValue = ref('')
const dynamicTags = ref(props.modelValue ? props.modelValue.split(',') : [])
const inputVisible = ref(false)
const InputRef = ref()
const handleClose = (tag) => {
dynamicTags.value.splice(dynamicTags.value.indexOf(tag), 1)
emit('update:modelValue',dynamicTags.value.join(','))
}
const showInput = () => {
inputVisible.value = true
nextTick(() => {
InputRef.value.input.focus()
})
}
const handleInputConfirm = () => {
if (inputValue.value) {
dynamicTags.value.push(inputValue.value)
console.log(dynamicTags.value,'dynamicTags.value');
emit('update:modelValue',dynamicTags.value.join(','))
}
inputVisible.value = false
inputValue.value = ''
}
</script>
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
}
}
9、优惠券列表
功能:列表展示、分页、表单新增(复用角色管理页模板,复用公共逻辑)
复杂难点:
优惠券状态判断:在onGetListSuccess回调内调用格式化状态函数。
时间选择器时间戳转换:在公共逻辑提交事件内,声明一个body变量,判断是否有beforeSubmit事件,有该事件,将该事件赋值给body,则调用该函数并将form传给该回调函数,回调函数将时间转换为时间戳后再reture回去,调用提交接口时,将form替换为body
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column prop="name" label="优惠券名称" width="350">
<template #default="{ row }">
<div class="border border-dashed py-2 px-4 rounded bg-light-300"
:class="row.statusText === '领取中' ? 'active' : 'inactive'">
<h5 class="text-md font-bold">{{ row.name }}</h5>
<small>{{ row.start_time }}~{{ row.end_time }}</small>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
{{ row.statusText }}
</template>
</el-table-column>
<el-table-column prop="status" label="优惠">
<template #default="{ row }">
<div v-if="row.type === 1">满{{ row.min_price }},打{{Number(row.value).toFixed(0) }}折 </div>
<div v-else>满{{ row.min_price }},减{{ row.value }}</div>
</template>
</el-table-column>
<el-table-column prop="total" label="发放数量" />
<el-table-column prop="used" label="已使用" />
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button type="primary" size="small" text @click="handleEdit(scope.row)" v-if="scope.row.statusText == '未开始'">修改</el-button>
<el-popconfirm title="是否删除该优惠券?" @confirm="handleDelete(scope.row.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none" v-if="scope.row.statusText != '领取中'">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
<el-popconfirm v-if="scope.row.statusText == '领取中'" title="是否让该优惠券失效?" @confirm="handleStatusChange(0,scope.row)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="danger" size="small"> 失效 </el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" :inline="false" label-width="100px">
<el-form-item label="优惠券名称" prop="name">
<el-input v-model="form.name" placeholder="请填写优惠券名称"></el-input>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-radio-group v-model="form.type">
<el-radio :label="1" border>满减</el-radio>
<el-radio :label="0" border>折扣</el-radio>
</el-radio-group> </el-form-item>
<el-form-item label="面值" prop="value">
<el-input v-model="form.value" placeholder="面值" style="width: 50%;">
<template #append>{{ form.type ? "折" : "元" }}</template>
</el-input>
</el-form-item>
<el-form-item label="发行量" prop="total">
<el-input-number v-model="form.total" :min="0" :max="1000" />
</el-form-item>
<el-form-item label="最低使用价格" prop="min_price">
<el-input v-model="form.min_price" placeholder="面值" style="width: 50%;">
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="排序" prop="order">
<el-input-number v-model="form.order" :min="0" :max="1000" />
</el-form-item>
<el-form-item label="活动时间">
<el-config-provider :locale="locale">
<el-date-picker v-model="timeRange" :editable="false" value-format="YYYY-MM-DD HH:mm:ss"
type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-config-provider>
</el-form-item>
<el-form-item label="描述" prop="desc">
<el-input v-model="form.desc" type="textarea" rows="4" placeholder="请填写描述" />
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { computed } from 'vue'
import {
getCouponList,
createCoupon,
updateCoupon,
deleteCoupon,
updateCouponStatus,
} from '@/api/coupon.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import ListHeader from '@/components/ListHeader.vue'
import FormDrawer from '@/components/FormDrawer.vue'
// 日期选择器改为中文版
import zhCn from "element-plus/lib/locale/lang/zh-cn";
const {
searchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange
} = useInitTable({
getList: getCouponList,
delete: deleteCoupon,
onGetListSuccess: (res) => {
tableData.value = res.list.map(o => {
o.statusText = formatStatus(o)
return o
})
total.value = res.totalCount
},
updateStatus: updateCouponStatus,
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
currentPage,
title: '优惠券',
form: {
name: '',
type: 0,
value: 0,
total: 100,
min_price: 0,
start_time: '',
end_time: '',
order: 50,
desc: ''
},
rules: {
name: [{ required: true, message: '优惠券名称不能为空', trigger: 'blur' }],
},
getData,
update: updateCoupon,
create: createCoupon,
beforeSubmit: (f) => {
if (typeof form.start_time != 'number') {
f.start_time = (new Date(form.start_time)).getTime()
}
if (typeof form.end_time != 'number') {
f.end_time = (new Date(form.end_time)).getTime()
}
return f
}
})
// 格式化 优惠券状态
const formatStatus = (row) => {
let s = '领取中'
let start_time = (new Date((row.start_time))).getTime()
let end_time = (new Date((row.end_time))).getTime()
// 当前时间戳
let now = (new Date()).getTime()
if (start_time > now) {
s = '未开始'
} else if (end_time < now) {
s = '已过期'
} else if (row.status == 0) {
s = '已失效'
}
return s
}
// 表单时间值
const timeRange = computed({
get () {
return form.start_time && form.end_time ? [form.start_time, form.end_time] : []
},
set (val) {
if (val) {
form.start_time = val[0]
form.end_time = val[1]
}
}
})
let locale = zhCn;
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
// 优惠券可用样式
.active {
@apply border-rose-200 bg-rose-50 text-red-400;
}
.inactive {
@apply border-gray-200 bg-gray-50 text-gray-400;
}
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
height: 30px;
}
</style>
// 提交表单
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()
})
}
10、商品管理页(较复杂)
**功能:**下图红框内功能,列表展示、分页、表单新增(复用代码)
复杂难点:功能点多,代码量大
10.1、页面渲染:将页面分为四部分(见上图红框)
页面渲染:多参数搜索(复用search.vue)、按钮组件(复用ListHeader.vue),table和分页(复用规格管理页)、新增tab切换
10.2、设置轮播图弹框:新增banner弹框组件,banner组件内复用chooseImage.vue
<template>
<!-- 修改轮播图组件 -->
<el-drawer title="设置轮播图" v-model="dialogVisible" size="50%" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="轮播图">
<ChooseImage v-model="form.banners" :limit="9" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit" :loading="loading">提交</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { toast } from '@/composables/util.js'
import {
readGoods, setGoodsBanner
} from '@/api/goods.js'
import ChooseImage from "@/components/ChooseImage.vue";
const dialogVisible = ref(false)
const form = reactive({ banners: [] })
const goosId = ref(0)
const open = async (row) => {
goosId.value = row.id
row.bannersLoading =true
const res = await readGoods(goosId.value)
form.banners = res.goodsBanner.map(item => {
return item.url
})
dialogVisible.value = true
row.bannersLoading =false
}
const emit =defineEmits(['reloadData'])
const loading = ref(false)
const submit = async () => {
loading.value = true
try {
await setGoodsBanner(goosId.value, form.banners)
toast('设置轮播图成功')
emit('reloadData')
dialogVisible.value = false
} catch (err) {
toast('设置轮播图失败', 'error')
}
loading.value = true
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
</style>
10.3、商品规格设置(动态表格渲染)
商品规格选项弹框主要复杂点在于多规格部分
多规格部分思路:分为两部分,1、规格选项部分(skuCard.vue) 规格选项部分包含(skuCardItem.vue(每一项规格选项)) 2、规格table表格部分(skuTable.vue)
js部分:涉及多组件使用同一值,用传统方法写js的话,值的传递太过复杂,所以使用vue3组合式js,组合式内定义的值可以多组件使用并****保持相同性 将大部分逻辑写在useSku.js内
<template>
<el-form-item label="规格选项" v-loading="bodyLoading">
<el-card shadow="never" class="w-full mb-3" v-for="(item, index) in sku_card_list" :key="item.id"
v-loading="item.loading">
<template #header>
<div class="flex items-center">
<!-- 规格名 -->
<el-input v-model="item.text" placeholder="规格名称" style="width:200px" @change="handleUpdate(item)">
<template #append>
<el-icon class="cursor-pointer" @click="handleChooseSku(item)">
<more />
</el-icon>
</template>
</el-input>
<!-- 上箭头 -->
<el-button class="ml-auto" size="small" @click="sortCard('up', index)" :disabled="index === 0"><el-icon>
<Top />
</el-icon></el-button>
<!-- 下箭头 -->
<el-button size="small" @click="sortCard('down', index)"
:disabled="index === sku_card_list.length - 1"><el-icon>
<Bottom />
</el-icon></el-button>
<el-popconfirm title="是否删除该选项?" @confirm="handleDelete(item)" confirm-button-text="确认" cancel-button-text="取消"
width="none">
<template #reference>
<el-button size="small">
<el-icon>
<Delete />
</el-icon>
</el-button>
</template>
</el-popconfirm>
</div>
</template>
<!-- card body -->
<SkuCardItem :skuCardId="item.id" />
</el-card>
<el-button type="success" size="small" :loading="btnLoading" @click="addSkuCardEvent">添加规格</el-button>
</el-form-item>
<ChooseSku ref="ChooseSkuRef" />
</template>
<script setup>
import { ref } from 'vue'
import SkuCardItem from './SkuCardItem.vue'
import ChooseSku from '@/components/ChooseSku.vue'
import {
sku_card_list,
addSkuCardEvent,
handleUpdate,
handleDelete,
sortCard,
btnLoading,
bodyLoading,
handleChooseSetGoodsSkusCard
} from "@/composables/useSku.js";
const ChooseSkuRef = ref(null)
const handleChooseSku = (item) => {
ChooseSkuRef.value.open((value) => {
handleChooseSetGoodsSkusCard(item.id, {
name: value.name,
value: value.list
})
})
}
</script>
<style lang='scss' scoped>
:deep(.el-card__header) {
@apply bg-gray-50;
padding: 0.5rem !important;
}
</style>
<template>
<div v-loading="loading">
<el-tag v-for="(tag, index) in item.goodsSkusCardValue" :key="index" class="mx-1" closable
:disable-transitions="false" @close="handleClose(tag)" effect="plain">
<el-input v-model="tag.text" placeholder="选项值" size="small" class="ml-[-10px] w-20" @change="handleChange($event, tag)">
</el-input>
</el-tag>
<el-input v-if="inputVisible" ref="InputRef" v-model="inputValue" class="ml-1 w-20" size="small"
@keyup.enter="handleInputConfirm" @blur="handleInputConfirm" />
<el-button v-else class="button-new-tag ml-1" size="small" @click="showInput">
+ 添加选项值
</el-button>
</div>
</template>
<script setup>
import { initSkusCardItem } from "@/composables/useSku.js";
const props = defineProps({
skuCardId: [Number, String]
})
const {
item,
inputValue,
inputVisible,
InputRef,
handleClose,
showInput,
handleInputConfirm,
loading,
handleChange,
handleDelete
} = initSkusCardItem(props.skuCardId)
</script>
<style lang='scss' scoped>
</style>
<template>
<el-form-item label="规格设置">
<table>
<thead class="border">
<tr>
<th v-for="(th, thi) in tableThs" :key="thi" class="border" :width="th.width" :rowspan="th.rowspan"
:colspan="th.colspan">
{{ th.name }}
</th>
</tr>
<tr>
<th v-for="(th, thi) in skuLabels" :key="thi" class="border">
{{ th.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in sku_list" :key="index">
<td width="100" class="border text-center" v-for="(sku, skuI) in item.skus" :key="skuI">
{{ sku.value }}
</td>
<td class="border">
<el-input v-model="item.pprice" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.oprice" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.cprice" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.stock" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.volume" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.weight" size="small" type="number"></el-input>
</td>
<td class="border">
<el-input v-model="item.code" size="small"></el-input>
</td>
</tr>
</tbody>
</table>
</el-form-item>
</template>
<script setup>
import {
initSkuTable
} from "@/composables/useSku.js";
const {
skuLabels,
tableThs,
sku_list
} = initSkuTable()
// console.log(sku_list.value,'sku_list');
</script>
<style lang='scss' scoped>
</style>
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
<template>
<el-dialog title="规格选择" v-model="dialogVisible" width="80%" top="5vh">
<el-container style="height: 65vh;">
<el-aside width="220px" class="image-aside">
<!-- Aside content -->
<div class="top">
<div class="sku-list" :class="{ 'active': (activeId == item.id) }" @click="handleChangeActiveId(item.id)"
v-for="(item, index) in tableData" :key="index">
{{ item.name }}
</div>
</div>
<div class="bottom">
<el-pagination background layout="prev, next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
</el-aside>
<el-main>
<el-checkbox-group v-model="form.list">
<el-checkbox v-for="item in list" :key="item" :label="item" border>
{{ item }}
</el-checkbox>
</el-checkbox-group>
</el-main>
</el-container>
<template #footer>
<span>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { getSkusList } from '@/api/skus.js'
import { useInitTable } from '@/composables/useCommon.js'
const dialogVisible = ref(false)
const activeId = ref(0)
const {
loading,
currentPage,
limit,
total,
tableData,
getData
} = useInitTable({
getList: getSkusList,
onGetListSuccess: (res) => {
tableData.value = res.list
total.value = res.totalCount
if (tableData.value.length > 0) {
handleChangeActiveId(tableData.value[0].id)
}
}
})
const callBackFunction =ref(null)
const open = (callBack =null) => {
callBackFunction.value =callBack
getData(1)
dialogVisible.value = true
}
const list = ref([])
const form = reactive({
name: '',
list: []
})
function handleChangeActiveId (id) {
activeId.value = id
list.value = []
let item = tableData.value.find(o => o.id == id)
if (item) {
list.value = item.default.split(',')
form.name = item.name
}
}
const submit = () => {
if(typeof callBackFunction.value === 'function') {
callBackFunction.value(form)
}
dialogVisible.value =false
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
.image-aside {
position: relative;
border-right: 1px solid #eeeeee;
.top {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
overflow-y: auto;
}
.bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
@apply flex items-center justify-center;
height: 50px;
}
}
.sku-list {
border-bottom: 1px solid #f4f4f4;
@apply p-3 text-sm text-gray-600 flex items-center cursor-pointer;
&:hover,
.active {
@apply bg-blue-50;
}
}
</style>
11、商品分类模块开发(较简单)
开发思路:大部分代码复用权限管理页,弹框内容较少
<template>
<el-card shadow="never" class="border-0">
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 树 -->
<!--default-expanded-keys :默认展开的一维数组 -->
<!-- :default-expanded-keys="defaultExpandedKeys" -->
<el-tree :data="tableData" :props="{ label: 'name', children: 'child' }" v-loading="loading" node-key="id">
<template #default="{ node, data }">
<div class="custom-tree-node">
<span>{{ data.name }}</span>
<div class="ml-auto">
<el-button text type="primary" size="small" @click="openGoodsDrawer(data)" :loading="data.goodsDrawerLoading">推荐商品</el-button>
<el-switch :modelValue="data.status" :active-value="1" :inactive-value="0"
@change="handleStatusChange($event, data)" />
<el-button text type="primary" size="small" @click.stop="handleEdit(data)">修改</el-button>
<el-popconfirm title="是否要删除该记录?" confirmButtonText="确认" cancelButtonText="取消"
@confirm="handleDelete(data.id)">
<template #reference>
<el-button text type="primary" size="small">删除</el-button>
</template>
</el-popconfirm>
</div>
</div>
</template>
</el-tree>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" label-width="80px" :inline="false">
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" placeholder="分类名称"></el-input>
</el-form-item>
</el-form>
</FormDrawer>
<GoodsDrawer ref="GoodsDrawerRef" />
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import { getCategoryList, createCategory, updateCategory, deleteCategory, updateCategoryStatus } from '@/api/category.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import ListHeader from '@/components/ListHeader.vue'
import FormDrawer from '@/components/FormDrawer.vue'
import GoodsDrawer from './components/GoodsDrawer.vue'
const GoodsDrawerRef = ref(null)
let defaultExpandedKeys = ref([])
const methodList = ['GET', 'POST', 'PUT', 'DELETE']
let { loading, tableData, getData, handleDelete, handleStatusChange } = useInitTable({
getList: getCategoryList,
onGetListSuccess: (res) => {
tableData.value = res.map(o => {
o.goodsDrawerLoading = false
return o
})
// 默认展开的一维数组,必须包含唯一值id
// defaultExpandedKeys.value = res.list.filter((item) => item.id)
},
delete: deleteCategory,
updateStatus: updateCategoryStatus,
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
title: '菜单权限',
form: {
name: ''
},
rules: {
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
// icon: [{ required: true, message: '图标不能为空', trigger: 'blur' }]
},
getData,
update: updateCategory,
create: createCategory,
})
const openGoodsDrawer = (data) => {
GoodsDrawerRef.value.open(data)
}
</script>
<style lang="scss" scoped>
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
padding: 20px 0;
}
.custom-tree-node {
flex: 1;
@apply flex items-center justify-center;
font-size: 16px;
padding-right: 8px;
}
</style>
<template>
<FormDrawer ref="FormDrawerRef" title="推荐商品" @submit="handleConnect" confirmText="关联">
<el-table :data="tableData" border stripe style="width:100%">
<el-table-column prop="goods_id" label="ID" width="60" />
<el-table-column label="商品封面" width="180">
<template #default="{ row }">
<el-image :src="row.cover" fit="fill" :lazy="true" style="width: 64px;height:64px"></el-image>
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" width="180" />
<el-table-column label="操作">
<template #default="{ row }">
<el-popconfirm title="是否要删除该记录?" confirmButtonText="确认" cancelButtonText="取消" @confirm="handleDelete(row)">
<template #reference>
<el-button text type="primary" size="small" :loading="row.loading">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</FormDrawer>
<!-- 商品弹框组件 -->
<ChooseGoods ref="ChooseGoodsRef" />
</template>
<script setup>
import { ref } from 'vue'
import { getCategoryGoods, deleteCategoryGoods, connectCategoryGoods } from '@/api/category.js'
import { toast } from '@/composables/util.js'
import FormDrawer from '@/components/FormDrawer.vue'
import ChooseGoods from '@/components/ChooseGoods.vue'
const ChooseGoodsRef = ref(null)
const FormDrawerRef = ref(null)
const category_id = ref(0)
const tableData = ref([])
const open = (item) => {
category_id.value = item.id
item.goodsDrawerLoading = true
getData().then(res => FormDrawerRef.value.open()).finally(() => {
item.goodsDrawerLoading = false
})
}
// 删除
const handleDelete = (row) => {
row.loading = true
deleteCategoryGoods(row.id).then(res => {
toast('删除关联成功')
getData()
}).finally(() => {
row.loading = false
})
}
const handleConnect = () => {
ChooseGoodsRef.value.open((goods_ids) => {
FormDrawerRef.value.showLoading()
console.log(goods_ids,'goods_ids');
connectCategoryGoods({ category_id: category_id.value, goods_ids }).then(res => {
toast('关联成功')
getData()
}).finally(()=> {
FormDrawerRef.value.hideLoading()
})
})
}
function getData () {
return getCategoryGoods(category_id.value).then(res => {
tableData.value = res.map(o => {
o.loading = false
return o
})
})
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
</style>
<template>
<!-- 商品选择弹框 -->
<el-dialog title="商品选择" v-model="dialogVisible" width="80%" destroy-on-close>
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%;" ref="multipleTableRef"
@selection-change="handleSelectionChange" v-loading="loading" height="300px">
<el-table-column type="selection" width="55" />
<el-table-column label="商品">
<template #default="{ row }">
<div class="flex items-center">
<el-avatar :size="50" :src="row.cover" shape="square" fit="cover">
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
</el-avatar>
<div class="ml-3 flex flex-col">
<p>{{ row.title }}</p>
<p class="text-xs mb-1 text-gray-400">分类:{{ (row.category && row.category.name) || '未分类' }}</p>
<p class="text-xs text-gray-400">创建时间:{{ row.create_time }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="总库存" prop="stock" width="100" align="center" />
<el-table-column label="价格(元)" width="150" align="center">
<template #default="{ row }">
<span class="text-rose-500">¥{{ row.min_price }}</span>
<el-divider direction="vertical"></el-divider>
<span class="text-gray-500 text-xs">¥{{ row.min_oprice }}</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
<!-- 底部确定按钮 -->
<template #footer>
<span>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { getGoodsList } from '@/api/goods.js'
import { useInitTable } from '@/composables/useCommon.js'
const {
multipleTableRef,
handleSelectionChange,
searchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
// 复选框多选选中ids
multiSelectionIds
} = useInitTable({
searchForm: {
title: '',
tab: 'all',
category_id: null,
},
getList: getGoodsList,
onGetListSuccess: (res) => {
tableData.value = res.list
total.value = res.totalCount
loading.value = false
}
})
const dialogVisible = ref(false)
// 打开弹框时接收一个回调函数
const callbackFunction =ref(null)
const open = (callback=null) => {
callbackFunction.value =callback
dialogVisible.value = true
}
// 确定
const submit = () => {
if(typeof callbackFunction.value ==='function') {
callbackFunction.value(multiSelectionIds.value)
}
close()
}
// 取消
function close () {
dialogVisible.value = false
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
12、会员等级模块和用户管理模块开发(较简单)
开发思路:会员等级模块复用角色管理页
用户管理模块复用管理员管理页
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column prop="name" label="会员等级" width="250" align="center" />
<el-table-column prop="discount" label="折扣率(%)" align="center" />
<el-table-column prop="level" label="等级序号" align="center" />
<el-table-column prop="status" label="状态" align="center">
<template #default="scope">
<el-switch :modelValue="scope.row.status" :active-value="1" :inactive-value="0"
@change="handleStatusChange($event, scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button type="primary" size="small" text @click="handleEdit(scope.row)">修改</el-button>
<el-popconfirm title="是否删除该记录?" @confirm="handleDelete(scope.row.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit" :current-page="currentPage"
@current-change="getData" />
</div>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" :inline="false" label-width="80px">
<el-form-item label="等级名称" prop="name">
<el-input v-model="form.name" placeholder="请填写角色名称"></el-input>
</el-form-item>
<el-form-item label="等级权重" prop="level" style="width: 50%;">
<el-input v-model="form.level" type="number" rows="5" placeholder="等级权重" />
</el-form-item>
<el-form-item label="是否启用" prop="status">
<el-switch :modelValue="form.status" :active-value="1" :inactive-value="0" @change="handleChangeSwitch">
</el-switch> </el-form-item>
<el-form-item label="升级条件">
<div>
<small class="text-xs mr-2">
累计消费满
</small>
<el-input v-model="form.max_price" type="number" rows="5" style="width: 50%;">
<template #append>元</template>
</el-input>
<small class="text-gray-400 flex">
设置会员等级所需要的累计消费必须大于等于0,单位:元
</small>
</div>
<div>
<small class="text-xs mr-2">
累计次数满
</small>
<el-input v-model="form.max_times" type="number" rows="5" style="width: 50%;">
<template #append>次</template>
</el-input>
<small class="text-gray-400 flex">
设置会员等级所需要的购买量必须大于等于0,单位:笔
</small>
</div>
</el-form-item>
<el-form-item label="折扣率(%)" prop="discount" >
<el-input v-model="form.discount" type="number" rows="5" placeholder="折扣率(%)" style="width: 50%;">
<template #append>%</template>
</el-input>
<small class="text-gray-400">
折扣率单位为百分比,如输入90,表示该会员等级的用户可以以商品原价的90%购买
</small>
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import {
getUserLevelList,
createUserLevel,
updateUserLevel,
deleteUserLevel,
updateUserLevelStatus
} from '@/api/level.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import ListHeader from '@/components/ListHeader.vue'
import FormDrawer from '@/components/FormDrawer.vue'
const {
searchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange
} = useInitTable({
getList: getUserLevelList,
delete: deleteUserLevel,
updateStatus: updateUserLevelStatus
})
console.log(tableData.value);
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit
} = useInitForm({
currentPage,
title: '会员等级',
form: {
name: '',
// 等级权重
level: 100,
status: 1,
// 折扣率
discount: 0,
// 累计消费金额
max_price: 0,
// 累计消费次数
max_times: 0
},
rules: {
name: [{ required: true, message: '会员等级名称不能为空', trigger: 'blur' }],
},
getData,
update: updateUserLevel,
create: createUserLevel
})
const handleChangeSwitch =(e)=> {
form.status =e
}
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
:deep(.el-tree-node__label) {
flex: 1;
}
:deep(.el-tree-node__content) {
height: 30px;
}
</style>
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 搜索 -->
<Search @search="getData" @reset="resetSearchForm" showSearch :model="searchForm">
<SearchItem label="关键词">
<el-input v-model="searchForm.keyword" placeholder="手机号/邮箱/会员昵称" clearable @keydown.enter="getData" />
</SearchItem>
<template #show>
<SearchItem label="会员等级">
<el-select v-model="searchForm.user_level_id" placeholder="请选择会员等级" clearable filterable>
<el-option v-for="item in user_level" :key="item.id" :label="item.name" :value="item.id">
</el-option>
</el-select>
</SearchItem>
</template>
</Search>
<!-- 新增|刷新 -->
<ListHeader @create="handleCreate" @refresh="getData" />
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column label="会员" width="200">
<template #default="{ row }">
<div class="flex items-center">
<el-avatar :size="40" :src="row.avatar">
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
</el-avatar>
<div class="ml-3">
<h6>{{ row.username }}</h6>
<small>ID:{{ row.id }}</small>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="会员等级" align="center">
<template #default="{ row }">
{{ row.user_level ? row.user_level.name : '-' }}
</template>
</el-table-column>
<el-table-column label="登录注册" align="center">
<template #default="{ row }">
<p>注册时间:{{ row.create_time }}</p>
<p v-if="row.last_login_time">最后登录:{{ row.last_login_time }}</p>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-switch :loading="row.statusLoading" :modelValue="row.status" :active-value="1"
:inactive-value="0" :disabled="row.super === 1" @change="handleStatusChange($event, row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<div>
<el-button type="primary" size="small" text @click="handleEdit(row)">修改</el-button>
<el-popconfirm title="是否删除该用户?" @confirm="handleDelete(row.id)" confirm-button-text="确认"
cancel-button-text="取消" width="none">
<template #reference>
<el-button type="primary" size="small" text> 删除 </el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit"
:current-page="currentPage" @current-change="getData" />
</div>
<!-- 抽屉 -->
<FormDrawer :title="drawerTitle" ref="formDrawerRef" @submit="handleSubmit" :destroyOnClose="true">
<el-form :model="form" ref="formRef" :rules="rules" label-width="80px" :inline="false">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="用户名"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" placeholder="密码"></el-input>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="昵称"></el-input>
</el-form-item>
<el-form-item label="头像" prop="avatar">
<ChooseImage v-model="form.avatar" />
</el-form-item>
<el-form-item label="会员等级" prop="user_level_id">
<el-select v-model="form.user_level_id" placeholder="请选择会员等级">
<el-option v-for="item in user_level" :key="item.id" :label="item.name" :value="item.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="手机" prop="phone">
<el-input v-model="form.phone" placeholder="手机"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="邮箱"></el-input>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0">
</el-switch>
</el-form-item>
</el-form>
</FormDrawer>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import {
getUserList,
updateUserStatus,
createUser,
updateUser,
deleteUser
} from '@/api/user.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import FormDrawer from '@/components/FormDrawer.vue'
import ChooseImage from '@/components/ChooseImage.vue'
import ListHeader from '@/components/ListHeader.vue'
import Search from '@/components/Search.vue'
import SearchItem from '@/components/SearchItem.vue'
const roles = ref([])
const user_level = ref([])
const {
searchForm,
resetSearchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange
} = useInitTable({
searchForm: {
keyword: '',
user_level_id: ''
},
getList: getUserList,
onGetListSuccess: (res) => {
tableData.value = res.list.map((item) => {
item.statusLoading = false
return item
})
user_level.value = res.user_level
total.value = res.totalCount
loading.value = false
},
updateStatus: updateUserStatus,
delete: deleteUser
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit,
} = useInitForm({
title: '管理员',
form: {
username: '',
password: '',
status: 1,
nickname: '',
phone: '',
email: '',
avatar: '',
user_level_id: ''
},
rules: {
username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
password: [{ required: true, message: '密码不能为空', trigger: 'blur' }],
user_level_id: [{ required: true, message: '会员等级不能为空', trigger: 'blur' }]
},
getData,
update: updateUser,
create: createUser,
})
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
13、商品评论列表模块(较简单)
开发思路:复用管理员管理页
<template>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 搜索 -->
<Search @search="getData" @reset="resetSearchForm" showSearch :model="searchForm">
<SearchItem label="商品标题">
<el-input v-model="searchForm.title" placeholder="请输入商品标题" clearable @keydown.enter="getData" />
</SearchItem>
</Search>
<!-- 表格 -->
<el-table default-expand-all :data="tableData" stripe style="width: 100%" v-loading="loading">
<!-- 展开行 -->
<el-table-column type="expand">
<template #default="{ row }">
<div class="flex pl-18">
<el-avatar :size="50" :src="row.user.avatar" class="mr-3" />
<div class="flex flex-col flex-1">
<div class="flex items-center">
<span>{{ row.user.username }}</span>
<span class="text-gray-400 ml-2 text-xs">{{ row.review_time }}</span>
<el-button size="small" class="ml-auto" @click="openTextarea(row)"
v-if="row.textareaEdit == false && !row.extra">回复</el-button>
</div>
<p>{{ row.review.data }}</p>
<div class="flex py-2">
<el-image v-for="(item, index) in row.review.image" :key="index" :src="item" fit="cover"
style="width: 100px;height:100px;" class="rounded"></el-image>
</div>
<div v-if="row.textareaEdit">
<el-input v-model="textarea" placeholder="请输入评价内容" clearable type="textarea"
:rows="2"></el-input>
<div class="py-2">
<el-button type="primary" size="small" @click="review(row)">回复</el-button>
<el-button size="small" class="ml-2" @click="row.textareaEdit = false">取消</el-button>
</div>
</div>
<template v-else>
<div class="mt-3 bg-gray-100 p-3 rounded" v-for="(item, index) in row.extra"
:key="index">
<span class="flex">
<span class="text-md font-bold">客服</span>
<el-button type="info" size="small" class="ml-auto"
@click="openTextarea(row, item.data)">修改</el-button>
</span>
<p>{{ item.data }}</p>
</div>
</template>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="ID" width="70" align="center">
<template #default="{ row }">
<h2>{{ row.id }}</h2>
</template>
</el-table-column>
<el-table-column label="商品">
<template #default="{ row }">
<div class="flex items-center">
<el-image :src="row.goods_item ? row.goods_item.cover : ''" fit="fill"
style="width: 50px;height:50px;"></el-image>
<p class="ml-2">{{ row.goods_item.title ?? '商品已被删除' }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="评价信息" width="200">
<template #default="{ row }">
<div class="flex flex-col items-start">
<span>用户:{{ row.user.username || row.user.nickname }}</span>
<el-rate v-model="row.rating" disabled show-score text-color="#ff9900"
score-template="{value}" />
</div>
</template>
</el-table-column>
<el-table-column label="评价时间" align="center">
<template #default="{ row }">
{{ row.review_time }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-switch :loading="row.statusLoading" :modelValue="row.status" :active-value="1"
:inactive-value="0" :disabled="row.super === 1" @change="handleStatusChange($event, row)">
</el-switch>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit"
:current-page="currentPage" @current-change="getData" />
</div>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import {
getGoodsCommentList,
updateGoodsCommentStatus,
reviewGoodsComment
} from '@/api/goods_comment.js'
import { useInitTable, useInitForm } from '@/composables/useCommon.js'
import { toast } from '@/composables/util.js'
import Search from '@/components/Search.vue'
import SearchItem from '@/components/SearchItem.vue'
const roles = ref([])
const {
searchForm,
resetSearchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
handleStatusChange
} = useInitTable({
searchForm: {
title: ''
},
getList: getGoodsCommentList,
onGetListSuccess: (res) => {
tableData.value = res.list.map((item) => {
item.statusLoading = false
item.textareaEdit = false
return item
})
roles.value = res.roles
total.value = res.totalCount
loading.value = false
},
updateStatus: updateGoodsCommentStatus
})
const {
formDrawerRef,
formRef,
form,
rules,
drawerTitle,
handleSubmit,
handleCreate,
handleEdit,
} = useInitForm({
title: '管理员',
form: {
username: '',
password: '',
role_id: null,
status: 1,
avatar: ''
},
rules: {
username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
password: [{ required: true, message: '密码不能为空', trigger: 'blur' }],
role_id: [{ required: true, message: '所属角色不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
// avatar: [{ required: true, message: '头像不能为空', trigger: 'blur' }]
},
getData,
// update: reviewGoodsComment
})
const textarea = ref('')
const openTextarea = (row, data = '') => {
textarea.value = data
row.textareaEdit = true
}
// 回复按钮事件
const review =(row) => {
if(textarea.value == '') {
return toast('回复内容不能为空','error')
}
reviewGoodsComment(row.id,textarea.value).then(res => {
row.textareaEdit =false
toast('回复成功')
getData()
})
}
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
14、订单模块(excel导出(重点))
14.1、订单列表
开发逻辑:复用管理员管理页面
<template>
<div>
<!-- 分类切换 -->
<el-tabs v-model="searchForm.tab" class="demo-tabs" @tab-Change="getData">
<el-tab-pane v-for="(item, index) in tabList" :key="index" :label="item.name" :name="item.key" />
</el-tabs>
<el-card shadow="never" class="border-0 relative" v-loading="loading">
<!-- 搜索 -->
<Search @search="getData" @reset="resetSearchForm" showSearch :model="searchForm">
<SearchItem label="订单编号">
<el-input v-model="searchForm.no" placeholder="订单编号" clearable @keydown.enter="getData" />
</SearchItem>
<template #show>
<SearchItem label="收货人">
<el-input v-model="searchForm.name" placeholder="收货人" clearable></el-input>
</SearchItem>
<SearchItem label="手机号">
<el-input v-model="searchForm.phone" placeholder="手机号" clearable></el-input>
</SearchItem>
<SearchItem label="开始时间">
<el-date-picker v-model="searchForm.starttime" type="date" placeholder="开始日期"
style="width: 90%;" value-format="YYYY-MM-DD" />
</SearchItem>
<SearchItem label="结束时间">
<el-date-picker v-model="searchForm.endtime" type="date" placeholder="结束日期" style="width: 90%;"
value-format="YYYY-MM-DD" />
</SearchItem>
</template>
</Search>
<!-- 新增|刷新 -->
<ListHeader layout="refresh,download" @refresh="getData" @download="handleExportExcel">
<el-button size="small" type="danger" @click="handleMultiDelete">批量删除</el-button>
</ListHeader>
<!-- 表格 -->
<el-table :data="tableData" stripe style="width: 100%" ref="multipleTableRef"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column label="商品" width="300">
<template #default="{ row }">
<div class="flex text-sm">
<div class="flex-1">
<p>订单号:</p>
<small>{{ row.no }}</small>
</div>
<div>
<p>下单时间:</p>
<small>{{ row.create_time }}</small>
</div>
</div>
<div class="flex" v-for="(item, index) in row.order_items" :key="index">
<el-image style="width:30px;height:30px;"
:src="item.goods_item ? item.goods_item.cover : ''" fit="cover" :lazy="true"
class="mt-1"></el-image>
<p class="text-blue-500 ml-2">
{{ item.goods_item ? item.goods_item.title : '商品已被删除' }}
</p>
</div>
</template>
</el-table-column>
<el-table-column label="实付款" prop="total_price" width="120" align="center" />
<el-table-column label="买家" width="120" align="center">
<template #default="{ row }">
<p>{{ row.user.nickname || row.user.username }}</p>
<small>(用户ID:{{ row.user.id }})</small>
</template>
</el-table-column>
<el-table-column label="交易状态" width="180" align="center">
<template #default="{ row }">
<div class="flex items-start flex-col">
<div>
付款状态:
<el-tag v-if="row.payment_method == 'wechat'" type="success" size="small">微信支付</el-tag>
<el-tag v-else-if="row.payment_method == 'alipay'" size="small">支付宝支付</el-tag>
<el-tag v-else type="info" size="small">未支付</el-tag>
</div>
<div>
发货状态:
<el-tag :type="row.ship_data ? 'success' : 'info'" size="small">{{ row.ship_data ? '已发货'
:
'未发货'
}}</el-tag>
</div>
<div>
收货状态:
<el-tag :type="row.ship_status == 'received' ? 'success' : 'info'" size="small">{{
row.ship_status == 'received' ? '已收货' : '未收货'
}}</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="{ row }">
<el-button type="primary" class="px-1" text size="small">订单详情</el-button>
<el-button v-if="searchForm.tab == 'noship'" type="primary" class="px-1" text
size="small">订单发货</el-button>
<el-button v-if="searchForm.tab == 'refunding'" type="primary" class="px-1" text
size="small">同意退款</el-button>
<el-button v-if="searchForm.tab == 'refunding'" type="primary" class="px-1" text
size="small">拒绝退款</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="bottom">
<el-pagination background layout="prev,pager,next" :total="total" :page-size="limit"
:current-page="currentPage" @current-change="getData" />
</div>
</el-card>
<ExportExcel :tabs="tabList" ref="ExportExcelRef"/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import {
getOrderList,
deleteOrder,
} from '@/api/order.js'
import { useInitTable } from '@/composables/useCommon.js'
import ListHeader from '@/components/ListHeader.vue'
import Search from '@/components/Search.vue'
import SearchItem from '@/components/SearchItem.vue'
import ExportExcel from './components/ExportExcel.vue'
const ExportExcelRef =ref(null)
const {
multipleTableRef,
handleSelectionChange,
handleMultiDelete,
handleMultiStatusChange,
searchForm,
resetSearchForm,
tableData,
limit,
loading,
total,
currentPage,
getData,
handleDelete,
// 复选框多选选中ids
multiSelectionIds
} = useInitTable({
searchForm: {
tab: 'all',
no: '',
starttime: '',
endtime: '',
name: '',
phone: ''
},
getList: getOrderList,
onGetListSuccess: (res) => {
tableData.value = res.list.map((item) => {
item.bannersLoading = false
item.contentLoading = false
item.skusLoading = false
return item
})
total.value = res.totalCount
loading.value = false
},
delete: deleteOrder
})
// tab选项数据
const tabList = [
{ name: '全部', key: 'all' },
{ name: '待发货', key: 'noship' },
{ name: '待收货', key: 'shiped' },
{ name: '已收货', key: 'received' },
{ name: '已完成', key: 'finish' },
{ name: '已关闭', key: 'closed' },
{ name: '退款中', key: 'refunding' }
]
// 导数excel按钮事件
const handleExportExcel =()=> {
ExportExcelRef.value.open()
}
</script>
<style lang="scss" scoped>
.bottom {
@apply flex items-center justify-center mt-5;
}
</style>
<template>
<el-drawer title="导出订单" v-model="dialogVisible" size="40%">
<el-form :model="form" label-width="80px">
<el-form-item label="订单类型">
<el-select v-model="form.tab" placeholder="请选择">
<el-option v-for="item in tabs" :key="item.key" :label="item.name" :value="item.key">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-config-provider :locale="locale">
<el-date-picker v-model="form.time" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" value-format="YYYY-MM-DD" />
</el-config-provider>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="loading">导出</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { exportOrder } from '@/api/order.js'
import { toast } from '@/composables/util.js';
// 日期选择器改为中文版
import zhCn from "element-plus/lib/locale/lang/zh-cn";
let locale = zhCn;
defineProps({
tabs: Array
})
const form = reactive({
tab: null,
time: ''
})
const dialogVisible = ref(false)
const open = () => {
dialogVisible.value = true
}
const close = () => {
dialogVisible.value = false
}
// 导出按钮事件
const loading = ref(false)
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
})
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
</style>
14.1、excel导出功能逻辑
导出订单功能 基本逻辑:
触发接口后,会返回一个 对象{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
})
}
14.2、订单详情
<template>
<el-drawer title="订单详情" v-model="dialogVisible" size="50%">
<!-- 订单信息卡片 -->
<el-card shadow="never" class="mb-3">
<template #header>
<b class="text-sm">订单信息</b>
</template>
<el-form label-width="80px">
<el-form-item label="订单号">
{{ info.no }}
</el-form-item>
<el-form-item label="付款方式">
{{ payment_method }}
</el-form-item>
<el-form-item label="付款时间">
{{ paid_time }}
</el-form-item>
<el-form-item label="创建时间">
{{ info.create_time }}
</el-form-item>
</el-form>
</el-card>
<!-- 发货信息卡片 -->
<el-card v-if="info.ship_data" shadow="never" class="mb-3">
<template #header>
<b class="text-sm">发货信息</b>
</template>
<el-form label-width="80px">
<el-form-item label="物流公司">
{{ info.ship_data.express_company }}
</el-form-item>
<el-form-item label="运单号">
{{ info.ship_data.express_no }}
<el-button type="primary" text @click="openShipInfoModel(info.id)" class="ml-3"
:loading="loading">查看物流</el-button>
</el-form-item>
<el-form-item label="发货时间">
{{ ship_time }}
</el-form-item>
</el-form>
</el-card>
<!-- 商品信息卡片 -->
<el-card shadow="never" class="mb-3">
<template #header>
<b class="text-sm">商品信息</b>
</template>
<div class="flex mb-2" v-for="(item, index) in info.order_items" :key="index">
<el-image :src="item.goods_item ? item.goods_item.cover : ''" fit="cover" class="rounded"
style="width: 60px;height:60px;" />
<div class="ml-2 text-sm">
<p>{{ item.goods_item?.title ?? '商品已被删除' }}</p>
<p v-if="item.sku" class="mt-1">
<el-tag type="info" size="small">
{{ item.sku }}
</el-tag>
</p>
<p>
<b class="text-rose-500 mr-2">¥{{ item.price }}</b>
<span class="text-xs text-gray-500">x{{ item.num }}</span>
</p>
</div>
</div>
<el-form label-width="80px" label-position="left">
<el-form-item label="成交价">
<span class="text-rose-500 font-bold">¥{{ info.total_price }}</span>
</el-form-item>
</el-form>
</el-card>
<!-- 收货信息卡片 -->
<el-card shadow="never" class="mb-3" v-if="info.address">
<template #header>
<b class="text-sm">收货信息</b>
</template>
<el-form label-width="80px">
<el-form-item label="收货人">
{{ info.user.username || info.user.nickname }}
</el-form-item>
<el-form-item label="联系方式">
{{ info.address.phone }}
</el-form-item>
<el-form-item label="收货地址">
{{ info.address.province }}-{{ info.address.city }}-{{ info.address.district }}-{{ info.address.address }}
</el-form-item>
</el-form>
</el-card>
<!-- 退款信息卡片 -->
<el-card shadow="never" class="mb-3" v-if="info.refund_status != 'pending'">
<template #header>
<b class="text-sm">退款信息</b>
<el-button text disabled style="float: right;">{{ refund_status }}</el-button>
</template>
<el-form label-width="90px">
<el-form-item label="退款理由:">
{{ info.extra.refund_reason }}
</el-form-item>
</el-form>
</el-card>
<ShipInfoModel ref="ShipInfoModelRef" />
</el-drawer>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useDateFormat } from '@vueuse/core'
import ShipInfoModel from './ShipInfoModel.vue'
const props = defineProps({
info: Object
})
// 支付方式
const payment_method = computed(() => {
let s = '未支付'
if (props.info.payment_method == 'wechat') {
s = '微信支付'
} else if (props.info.payment_method == 'alipay') {
s = '支付宝支付'
}
return s
})
// 付款时间戳转换
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 ''
})
// 发货时间戳转换
const ship_time = computed(() => {
if (props.info.ship_data.express_time) {
const s = useDateFormat(props.info.ship_data.express_time * 1000, 'YYYY-MM-DD HH:mm:ss')
return s.value
}
return ''
})
// 退款状态转换为中文
const refund_status = computed(() => {
const opt = {
pending: "未退款",
applied: "已申请,等待审核",
processing: "退款中",
success: "退款成功",
failed: "退款失败"
}
return props.info.refund_status ? opt[props.info.refund_status] : ""
})
const dialogVisible = ref(false)
const open = () => {
dialogVisible.value = true
}
const close = () => {
dialogVisible.value = false
}
const ShipInfoModelRef = ref(null)
const loading = ref(false)
const openShipInfoModel = (id) => {
loading.value = true
ShipInfoModelRef.value.open(id).finally(() => {
loading.value = false
})
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
</style>
14.3、物流详情
使用el-timeline组件
<template>
<el-drawer title="物流详情" v-model="dialogVisible" size="40%">
<el-card shadow="never" class="border-0 mb-3">
<div class="flex items-center">
<el-image :src="info.logo" fit="fill" :lazy="true" style="width: 60px;height:60px;"
class="rounded border"></el-image>
<div class="ml-3">
<p>物流公司:{{ info.typename }}</p>
<p>物流单号:{{ info.number }}</p>
</div>
</div>
</el-card>
<el-card shadow="never" class="border-0 border-t">
<el-timeline class="ml-[-40px]">
<el-timeline-item :timestamp="item.time" placement="top" v-for="(item,index) in info.list" :key="index">
{{ item.status }}
</el-timeline-item>
</el-timeline>
</el-card>
</el-drawer>
</template>
<script setup>
import { ref } from 'vue'
import { getShipInfo } from '@/api/order.js'
import { toast } from '@/composables/util.js';
const dialogVisible = ref(false)
const info = ref({})
const open = (id) => {
return getShipInfo(id).then(res => {
if (res.status != 0) {
return toast(res.msg, 'error')
}
info.value = res.result
console.log(res.result, 'res.result');
dialogVisible.value = true
})
}
const close = () => {
dialogVisible.value = false
}
defineExpose({
open
})
</script>
<style lang='scss' scoped>
</style>
15、基础、物流和交易设置页开发(较简单)
<template>
<div v-loading="loading" class="bg-white p-4 rounded">
<el-form :model="form" label-width="160px">
<el-tabs v-model="activeName">
<el-tab-pane label="注册与访问" name="first">
<el-form-item label="是否允许注册会员">
<el-radio-group v-model="form.open_reg">
<el-radio border :label="0">
关闭
</el-radio>
<el-radio border :label="1">
开启
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="注册类型">
<el-radio-group v-model="form.reg_method">
<el-radio border label="username">
普通注册
</el-radio>
<el-radio border label="phone">
手机注册
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="密码最小长度类型">
<el-input v-model="form.password_min" placeholder="密码最小长度类型" style="width: 30%;" type="number"></el-input>
</el-form-item>
<el-form-item label="强制密码复杂度">
<el-checkbox-group v-model="form.password_encrypt">
<el-checkbox label="0" border>
数字
</el-checkbox>
<el-checkbox label="1" border>
小写字母
</el-checkbox>
<el-checkbox label="2" border>
大写字母
</el-checkbox>
<el-checkbox label="3" border>
符号
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="上传设置" name="second">
<el-form-item label="默认上传方式">
<el-radio-group v-model="form.upload_method">
<el-radio border label="oss">
对象存储
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Bucket">
<el-input v-model="form.upload_config.Bucket" placeholder="Bucket" style="width: 30%;"
type="number"></el-input>
</el-form-item>
<el-form-item label="ACCESS_KEY">
<el-input v-model="form.upload_config.ACCESS_KEY" placeholder="ACCESS_KEY" style="width: 30%;"
type="number"></el-input>
</el-form-item>
<el-form-item label="SECRET_KEY">
<el-input v-model="form.upload_config.SECRET_KEY" placeholder="SECRET_KEY" style="width: 30%;"
type="number"></el-input>
</el-form-item>
<el-form-item label="空间域名">
<el-input v-model="form.upload_config.http" placeholder="http" style="width: 30%;" type="number"></el-input>
<small class="ml-2 text-gray-500">请补全http:// 或 https://</small>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="Api安全" name="third">
<el-form-item label="是否开启API安全">
<el-radio-group v-model="form.api_safe">
<el-radio border :label="1">
是
</el-radio>
<el-radio border :label="0">
否
</el-radio>
</el-radio-group>
<small class="ml-2 text-gray-500">api安全功能开启之后调用前端api需要传输签名串</small>
</el-form-item>
<el-form-item label="秘钥">
<el-input v-model="form.api_secret" placeholder="秘钥" type="number"></el-input>
<div class="text-xs mt-2 text-gray-500">秘钥设置关系系统中api调用传输签名串的编码规则,以及会员token解析,请慎重设置,注意设置之后对应会员要求重新登录获取token
</div>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { getSysconfigList, setSysconfig } from '@/api/sysconfig.js'
import { toast } from "@/composables/util.js";
const activeName = ref('first')
const form = reactive({
"open_reg": 1, //开启注册,0关闭1开启
"reg_method": "username", //注册方式,username普通phone手机
"password_min": 7, //密码最小长度
"password_encrypt": [], //密码复杂度,0数字、1小写字母、2大写字母、3符号;例如0,1,2
"upload_method": "oss", //上传方式,oss对象存储
"upload_config": {
"Bucket": "",
"ACCESS_KEY": "",
"SECRET_KEY": "",
"http": ""
}, //上传配置 { Bucket:"", ACCESS_KEY:"", SECRET_KEY:"", http:""}
"api_safe": 1, //api安全,0关闭1开启
"api_secret": "", //秘钥
})
const loading = ref(false)
function getData () {
loading.value = true
getSysconfigList().then(res => {
for (const k in form) {
form[k] = res[k]
}
form.password_encrypt = form.password_encrypt.split(',')
}).finally(() => {
loading.value = false
})
}
getData()
// 保存
const submit = () => {
loading.value = true
form.password_encrypt = form.password_encrypt.join(',')
setSysconfig(form).then(res => {
toast('修改成功')
}).finally(() => {
getData()
loading.value = false
})
}
</script>
<style lang='scss' scoped>
</style>
<template>
<div v-loading="loading" class="bg-white p-4 rounded">
<el-form :model="form" label-width="160px">
<el-tabs v-model="activeName">
<el-tab-pane label="支付设置" name="first">
<el-table :data="tableData" border stripe>
<el-table-column label="支付方式" align="left">
<template #default="{ row }">
<div class="flex items-center">
<el-image :src="row.src" fit="fill" :lazy="true" style="width:40px;height:40px;"
class="rounded mr-2"></el-image>
<div>
<h6>{{ row.name }}</h6>
<small class="flex text-gray-500 mt-1">{{ row.desc }}</small>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150">
<template #default="{ row }">
<el-button type="primary" size="small" text @click="open(row)">配置</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="购物设置" name="second">
<el-form-item label="未支付订单">
<div class="flex flex-col">
<el-input v-model="form.close_order_minute" placeholder="未支付订单" style="width: 70%;" type="number">
<template #append>
分钟后自动关闭
</template>
</el-input>
<small class="text-gray-500">订单下单未付款,n分钟后自动关闭,设置0不自动关闭</small>
</div>
</el-form-item>
<el-form-item label="已发货订单">
<div class="flex flex-col">
<el-input v-model="form.auto_received_day" placeholder="已发货订单" style="width: 70%;" type="number">
<template #append>
天后自动确认收货
</template>
</el-input>
<small class="text-gray-500">如果在期间未确认收货,系统自动完成收货,设置0不自动收货</small>
</div>
</el-form-item>
<el-form-item label="已完成订单">
<div class="flex flex-col">
<el-input v-model="form.after_sale_day" placeholder="已完成订单" style="width: 70%;" type="number">
<template #append>
天内允许申请售后
</template>
</el-input>
<small class="text-gray-500">订单完成后 ,用户在n天内可以发起售后申请,设置0不允许申请售后</small>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
<FormDrawer ref="FormDrawerRef" title="配置" @submit="submit">
<el-form :model="form" label-width="80" v-if="drawerType == 'alipay'" label-position="right">
<el-form-item label="app_id">
<el-input v-model="form.alipay.app_id" placeholder="app_id" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="ali_public_key">
<el-input v-model="form.alipay.ali_public_key" placeholder="ali_public_key" style="width: 90%;"
type="textarea" rows="4"></el-input>
</el-form-item>
<el-form-item label="private_key">
<el-input v-model="form.alipay.private_key" placeholder="private_key" style="width: 90%;" type="textarea"
rows="4"></el-input>
</el-form-item>
</el-form>
<el-form :model="form" label-width="100" v-else label-position="right">
<el-form-item label="公众号 APPID">
<el-input v-model="form.wxpay.app_id" placeholder="公众号 APPID" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="小程序 APPID">
<el-input v-model="form.wxpay.miniapp_id" placeholder="小程序 APPID" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="小程序 secret">
<el-input v-model="form.wxpay.secret" placeholder="小程序 secret" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="appid">
<el-input v-model="form.wxpay.appid" placeholder="appid" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="商户号">
<el-input v-model="form.wxpay.mch_id" placeholder="商户号" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="API 密钥">
<el-input v-model="form.wxpay.key" placeholder="API 密钥" style="width: 90%;"></el-input>
</el-form-item>
<el-form-item label="cert_client">
<el-upload :action="uploadAction" :limit="1" :headers="{ token }" accept=".pem"
:on-success="uploadClientSuccess">
<el-button type="primary">点击上传</el-button>
<template #tip>
<p class="text-rose-500">{{ form.wxpay.cert_client ? form.wxpay.cert_client : '还未配置' }}</p>
<div class="text-gray-500 text-xs">
例如:apiclient_cert.pem
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="cert_key">
<el-upload :action="uploadAction" :limit="1" :headers="{ token }" accept=".pem"
:on-success="uploadKeySuccess">
<el-button type="primary">点击上传</el-button>
<template #tip>
<p class="text-rose-500">{{ form.wxpay.cert_key ? form.wxpay.cert_key : '还未配置' }}</p>
<div class="text-gray-500 text-xs">
例如:apiclient_key.pem
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</FormDrawer>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { getSysconfigList, setSysconfig, uploadAction } from '@/api/sysconfig.js'
import { toast } from "@/composables/util.js";
import { getToken } from '@/composables/auth.js'
import FormDrawer from "@/components/FormDrawer.vue";
const token = getToken()
const activeName = ref('first')
const tableData = [{
name: '支付宝支付',
desc: '该系统支持即时到账接口',
src: '/alipay.png',
key: 'alipay'
}, {
name: '微信支付',
desc: '该系统支持微信网页支付和扫码支付',
src: '/wepay.png',
key: 'wepay'
}]
const form = reactive({
"close_order_minute": 30,
"auto_received_day": 7,
"after_sale_day": 23,
"alipay": {
"app_id": "****已配置****",
"ali_public_key": "****已配置****",
"private_key": "****已配置****"
},
"wxpay": {
"app_id": "****已配置****",
"miniapp_id": "****已配置****",
"secret": "****已配置****",
"appid": "****已配置****",
"mch_id": "****已配置****",
"key": "****已配置****",
"cert_client": "****已配置****.pem",
"cert_key": "****已配置****.pem"
},
"ship": "****已配置****"
})
const loading = ref(false)
function getData () {
loading.value = true
getSysconfigList().then(res => {
for (const k in form) {
form[k] = res[k]
}
}).finally(() => {
loading.value = false
})
}
getData()
// 保存
const submit = () => {
loading.value = true
setSysconfig(form).then(res => {
toast('修改成功')
}).finally(() => {
getData()
loading.value = false
})
}
const drawerType = ref('alipay')
const FormDrawerRef = ref(null)
const open = (row) => {
drawerType.value = row.key
FormDrawerRef.value.open()
}
// cert_client上传文件成功钩子
function uploadClientSuccess (response, uploadFile, uploadFiles) {
console.log(response, 'cert_client');
form.wxpay.cert_client = response.data
}
// cert_key上传文件成功钩子
function uploadKeySuccess (response, uploadFile, uploadFiles) {
console.log(response, 'cert_key');
form.wxpay.cert_key = response.data
}
</script>
<style lang='scss' scoped>
</style>
<template>
<el-card shadow="never" class="p-2 bg-white rounded">
<el-form :model="form" label-width="120" label-position="right">
<el-form-item label="物流查询 key">
<div class="flex flex-col">
<el-input v-model="form.ship" placeholder="物流查询 key" style="width: 100%;"></el-input>
<small class="text-gray-500">用于查询物流信息,接口申请(仅供参考)</small>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { getSysconfigList, setSysconfig, uploadAction } from '@/api/sysconfig.js'
import { toast } from "@/composables/util.js";
const form = reactive({
"ship": "****已配置****"
})
const loading = ref(false)
function getData () {
loading.value = true
getSysconfigList().then(res => {
for (const k in form) {
form[k] = res[k]
}
}).finally(() => {
loading.value = false
})
}
// 保存
const submit = () => {
loading.value = true
setSysconfig(form).then(res => {
toast('修改成功')
}).finally(() => {
getData()
loading.value = false
})
}
getData()
</script>
<style lang='scss' scoped>
</style>