vue3 admin后台管理开发笔记

在线体验链接:http://amv3admin.top/#/login

项目地址:https://gitee.com/xiao-ming-1999/am-vue3.git

vscode开发vue3插件:volar(使用此插件时,把vetur禁用,vetur是vue2插件)

1、开发依赖

  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
  1. 组件库选用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')
  1. 全局状态管理pinia(替代vuex)

  • npm i pinia

tip:piana真的太香了~~比起vuex哪种复杂的写法,piana简直不要太好!(都给我用起来!)

附上使用pinia说明(链接),详情看官网~

为什么使用windi CSS?

1、提升开发效率 2、自带兼容 3、功能较为完善

2、vite使用到的包

  1. 自动导入Element Plus组件、图标 (自动引入太香了~) (链接) unplugin-icons、unplugin-auto-import、unplugin-vue-components

自动导入后使用 icon组件加前缀IEp 例: -> 自动引入图标废除,主页菜单会涉及到 动态渲染菜单图标,自动导入无法根据动态值来正确引入图标(以改为全局引入图标)

3、 VUE3新特性

1、指令

v-loading:指令元素loading效果,参数为布尔值

2、vue3使用需要注意的点

  1. 使用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>
  1. 父组件通过ref使用子组件方法或变量时,子组件必须使用defineExpose暴露方法父组件才能拿到子组件的方法和变量

<script setup>
  const count = ()=> console.log(11)
  const a = 1
  // 暴露方法、变量
  defineExpose({
  count,
  a
})
</script>
  1. 子组件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配置

  1. 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')
  1. 定义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)
        })
      })
    }
  }
})
  1. 使用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、路由前置守卫配置

  1. main.js引入permission.js (代码省略)

  1. 前置守卫:对操作进行检查,用户信息持久化

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. 封装弹框提示

  1. 退出登录接口封装

  1. 基本逻辑:

// 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效果

  1. npm i nprogress

  1. 根据nprogress文档使用 引入相应的css和js封装进工具库

  1. 路由前置守卫开启loading 后置守卫关闭loading

  1. 效果图:

8、动态修改网站title

  1. 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'
    }
  },
]
  1. 路由前置守卫获取到to.meta.title,动态修改文档title

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  const title = `${to.meta.title || ''}-vue3商城后台管理`
  document.title =title
})

9、首页框架搭建

  • el组件搭建 重点**(router-view)**

<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、优惠券列表

功能:列表展示、分页、表单新增(复用角色管理页模板,复用公共逻辑)

复杂难点:

  1. 优惠券状态判断:在onGetListSuccess回调内调用格式化状态函数。

  1. 时间选择器时间戳转换:在公共逻辑提交事件内,声明一个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导出功能逻辑

导出订单功能 基本逻辑:

  1. 触发接口后,会返回一个 对象{size:6405,type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}

  1. 将这个对象通过new Blob转化为blob格式通过window的方法转为url

  1. 通过生成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>
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值