Skip to content

后台管理项目

ts
贾成豪
潘家成 element-ui贡献者 vue-admin-template

后台管理项目介绍

特点

ts
一般是没有注册只有登录[员工的账号与密码,boss:超级管理员]
项目结构一般左右结构
增删改查的业务
用户访问权限[菜单、按钮]

数据大屏

ts
数据大屏项目 政府-

项目模版

使用vue创建项目,在vue项目的基础上再次进行封装,封装成一个项目模版,使用同一类的项目时,直接套用。

shell
Vue2模版:vue-admin-template https://gitee.com/panjiachen/vue-admin-template.git
Vue3模版:vue-admin-template https://gitee.com/jch1011/vue3_admin_project.git
Vue3模版(已完成):vue-admin-template https://gitee.com/jch1011/cd_vue3_project

git clone git@gitee.com:jch1011/cd_vue3_project.git

https://www.bilibili.com/video/BV1Xh411V7b5/?spm_id_from=333.337.search-card.all.click

使用过程

shell
# 首先克隆下来
git clone vue2模版

# 安装依赖
npm install -f (强制安装)
# 单独安装依赖
npm i core-js

阅读代码

ts
从项目的入口文件开始阅读

代码注释

ts
对代码进行注释

API在线文档

ts
代理服务器地址
http://sph-h5-api.atguigu.cn
在线文档API
http://39.98.123.211:8510/swagger-ui.html(业务)
http://39.98.123.211:8170/swagger-ui.html(用户)

商品管理路由

创建商品管理相关路由

新增商品管理路由页面:

  1. attr管理:src/views/product/attr/index.vue
vue
<template>
    <div>
        我是attr
    </div>
</template>

<script setup lang='ts'>
    
</script>

<style scoped lang="less">
</style>
  1. spu管理:src/views/product/spu/index.vue
vue
<template>
    <div>
        我是spu
    </div>
</template>

<script setup lang='ts'>
    
</script>

<style scoped lang="less">
</style>
  1. trademark品牌管理:src/views/product/trademark/index.vue
vue
<template>
    <div>
        我是trademark
    </div>
</template>

<script setup lang='ts'>
    
</script>

<style scoped lang="less">
</style>
  1. sku管理:src/views/product/trademark/index.vue
vue
<template>
    <div>
        我是sku
    </div>
</template>

<script setup lang='ts'>
    
</script>

<style scoped lang="less">
</style>

新增商品管理模块路由:

新增路由页面配置:src/router/router.ts

ts
# 每一项路由 都需要配置 name属性 (首字母大写)
ts
import type { RouteRecordRaw } from 'vue-router';
/**
 * 路由meta对象参数说明
 * meta: {
 *      title:          菜单栏及 tagsView 栏、菜单搜索名称(国际化)
 *      hidden:        是否隐藏此路由
 *      icon:          菜单、tagsView 图标,阿里:加 `iconfont xxx`,fontawesome:加 `fa xxx`
 * }
 */

/**
 * 静态路由(默认路由)
 */
// 一级路由 404 login / 
// 二级路由 /home
 export const staticRoutes: Array<RouteRecordRaw> = [
	{
    // 路径全是小写的
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    // 路由元数据
    meta: {
      hidden: true
    }
  },

  {
    path: '/404',
    name: '404',
    component: () => import('@/views/error/404.vue'),
    meta: {
      hidden: true
    }
  },

  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/home',
    children: [{
      path: 'home',
      name: 'Home',
      component: () => import('@/views/home/index.vue'),
      meta: { 
        title: '首页', 
        icon: 'ele-HomeFilled', 
      }
    }]
  },
  // 商品管理模块,一级路由
  {
    path: '/product', // 一级路由展示 layout组件
    component: () => import('@/layout/index.vue'),
    name:'Product',
    meta:{
      title:'商品管理',
      icon:'ele-Goods'
    },
    children:[
      {
        path:'trademark',
        component:()=>import('@/views/product/trademark/index.vue'),
        name:'Trademark',
        meta:{
          title:'品牌管理',
          icon:'ele-Apple'
        }
      },
      {
        path:'attr',
        component:()=>import('@/views/product/attr/index.vue'),
        name:'Attr',
        meta:{
          title:'属性管理',
          icon:'ele-IceTea'
        }
      },
      {
        path:'spu',
        component:()=>import('@/views/product/spu/index.vue'),
        name:'Spu',
        meta:{
          title:'SPU管理',
          icon:'ele-Burger'
        }
      },
      {
        path:'sku',
        component:()=>import('@/views/product/sku/index.vue'),
        name:'Sku',
        meta:{
          title:'SKU管理',
          icon:'ele-Goblet'
        }
      }
    ]
  },

  /* 匹配任意的路由 必须最后注册 */
  { 
    path: '/:pathMatch(.*)', 
    name: 'Any',
    redirect: '/404', 
    meta: {
      hidden: true 
    }
  }
];


/**
 * 定义动态路由
 */
export const allAsyncRoutes: Array<RouteRecordRaw> = [];

layout组件

用于一级路由页面的布局和渲染

layout是布局组件,是已经封装好的,当你配置了路由,Sidebar就自动的把这些路由渲染到了侧边栏,不需要自己再手动导入到侧边栏,渲染侧边栏。

登陆页面

设置代理跨域

设置vite构建工具的配置 vite.config.ts

ts
https://cn.vitejs.dev/config/server-options.html
ts
export default defineConfig((mode: ConfigEnv) => {
  const env = loadEnv(mode.mode, process.cwd());
  return {
    plugins: [vue(), vueJsx()],
    resolve: {}
    css: {},
    server:{
      proxy:{
        '/app-dev': {
        target: 'http://sph-h5-api.atguigu.cn',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/app-dev/, ''),
      },
      }
    }
  };
});
ts
重启服务
npm run dev

表单验证步骤

ts
form组件表单表单校验
1 form组件添加model属性->告诉form组件表单的数据收集在那个对象的身上
2 form组件添加rules属性->书写表单校验的规则
3 el-form-item表单相组件添加prop属性,属性值即为校验字段的名字

新增路由组件

登陆组件:src/views/login.vue

vue
<template>
  <div class="login-container">
    <el-form ref="formRef" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">
      <div class="title-container">
        <h3 class="title">尚品汇后台管理</h3>
      </div>
      <el-form-item prop="username">
        <span class="svg-container">
          <svg-icon name="ele-UserFilled" />
        </span>
        <el-input ref="username" v-model="loginForm.username" placeholder="Username" name="username" type="text" tabindex="1" auto-complete="on" />
      </el-form-item>
      <el-form-item prop="password">
        <span class="svg-container">
          <svg-icon name="ele-Lock" />
        </span>
        <el-input :key="passwordType" ref="passwordRef" v-model="loginForm.password" :type="passwordType" placeholder="Password" name="password" tabindex="2" auto-complete="on" @keyup.enter.native="handleLogin" />
        <span class="show-pwd" @click="showPwd">
          <svg-icon :name="passwordType === 'password' ? 'ele-Hide' : 'ele-View'" />
        </span>
      </el-form-item>
      <el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;height: 40px;" @click.native.prevent="handleLogin">登 陆</el-button>
    </el-form>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Login'
}
</script>

<script lang="ts" setup>
import { useUserInfoStore } from '@/stores/userInfo'
import type { FormInstance } from 'element-plus'
import { nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

// 获取用户相关的小仓库
const userInfoStore = useUserInfoStore()
const route = useRoute()
const router = useRouter()
const loginForm = ref({
  username: 'admin',
  password: '111111'
})
const loading = ref(false)
// 密码表单元素type属性需要响应式数据:默认密码
const passwordType = ref('password')
const redirect = ref('')
// 获取密码选择框的实例
const passwordRef = ref<HTMLInputElement>()
const formRef = ref<FormInstance>()

const validateUsername = (rule: any, value: any, callback: any) => {
  if (value.length < 4) {
    // 提示信息并放行
    callback(new Error('用户名长度不能小于4位'))
  } else {
    // 放行
    callback()
  }
}
const validatePassword = (rule: any, value: any, callback: any) => {
  if (value.length < 6) {
    callback(new Error('密码长度不能小于6位'))
  } else {
    callback()
  }
}

const loginRules = {
  username: [{ required: true, validator: validateUsername }],
  password: [{ required: true, trigger: 'blur', validator: validatePassword }]
}

watch(route,() => {
    redirect.value = route.query && (route.query.redirect as string)
  },
  { immediate: true }
)

/* 
切换密码的显示/隐藏
*/
const showPwd = () => {
  if (passwordType.value === 'password') {
    passwordType.value = 'text'
  } else {
    passwordType.value = 'password'
  }
  // 数据改变时dom立即更新
  nextTick(() => {
    // ? ts中的语法,当passwordRef.value存在时,执行passwordRef.value.focus(),不存在时不执行
    passwordRef.value?.focus()
  })
}

/* 
点击登陆的回调
*/
const handleLogin = async () => {
  // form表单验证通过之后再发送请求
  await formRef.value?.validate()
  loading.value = true
  const { username, password } = loginForm.value
  try {
    // 用户小仓库执行login方法登陆
    await userInfoStore.login(username, password)
    // 编程式跳转路由
    router.push({ path: redirect.value || '/' })
  } finally {
    loading.value = false
  }
}
</script>

定义数据类型

src/api/user/type/index.ts

ts
// 登陆接口 参数类型
export interface LoginParams {
    password: string,
    username: string
}
// 登陆接口 响应类型
export interface LoginResponseData{
    token: string
}
// 用户信息 响应类型
export interface UserInfoResponseData{
    name:string,
    avatar:string,
    roles:string[],
    routes:string[],
    buttons:string[]
}

封装api接口

src/api/user/index.ts

ts
import request from '@/utils/request'
import type { LoginParams,LoginResponseData, UserInfoResponseData } from './type'

// 接口地址:枚举enum
enum API{
    // 登陆URL
    LOGIN_URL = "/admin/acl/index/login",
    // 用户信息
    USERINFO_URL = "/admin/acl/index/info",
    // 退出登陆
    USERLOGOUT_URL = "/admin/acl/index/logout"
}

// api接口
// 登陆
export const reqUserLogin = (data:LoginParams)=>request.post<any,LoginResponseData>(API.LOGIN_URL,data)

// 获取用户信息
export const getUserInfo = ()=>request.get<any,UserInfoResponseData>(API.USERINFO_URL)

// 退出登陆
export const reqUserlogout = () => {
    return request.post<any,any>(API.USERLOGOUT_URL)
}

调用api接口

在pinia的user小仓库中调用登陆api请求

src/stores/userInfo.ts

ts
// 引入定义小仓库方法
import { defineStore } from 'pinia';
// 本地存储操作token
import { getToken, removeToken, setToken } from '../utils/token-utils';
// state类型的数据类型
import type { UserInfoState } from './interface';
// 消息提示
import { ElMessage } from 'element-plus'
// 静态路由
import { staticRoutes } from '@/router/routes'
// 导入用户相关的API
import { getUserInfo, reqUserLogin, reqUserlogout } from '@/api/user';
// 引入类型
import type {LoginResponseData, UserInfoResponseData} from '@/api/user/type/index'
/**
 * 用户信息
 * @methods setUserInfos 设置用户信息
 */
export const useUserInfoStore = defineStore('userInfo', {

  state: (): UserInfoState => ({
    token: getToken() as string,
    name: '',
    avatar: '',
    menuRoutes: []
  }),

  actions: {
    // 登陆
    async login(username: string, password: string) {
      // 定义请求体
      const data = {
        username,
        password
      }
      // 获取响应数据
      const result:LoginResponseData = await reqUserLogin(data)
      // 存储token数据(pinia)
      this.token = result.token
      // 存储token数据(localstore)
      setToken(result.token)
      
    },
    // 获取用户信息(路由鉴权那里进行调用)
    async getInfo() {
      const result:UserInfoResponseData = await getUserInfo()
      // console.log("result ",result)
      // 设置小仓库中的数据状态
      this.name = result.name
      this.avatar = result.avatar
      // 设置用户的路由
      this.menuRoutes = staticRoutes
    },
    // 退出登陆
    async reset() {
      // 发送请求
      await reqUserlogout()
      // 删除local中保存的token
      removeToken()
      // 提交重置用户信息的mutation
      this.token = ''
      this.name = ''
      this.avatar = ''
    },
  },
});

在组件中调用小仓库中的login方法

ts
<template>
  <div class="login-container">
    <el-form ref="formRef" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">
      <div class="title-container">
        <h3 class="title">尚品汇后台管理</h3>
      </div>
      <el-form-item prop="username">
        <span class="svg-container">
          <svg-icon name="ele-UserFilled" />
        </span>
        <el-input ref="username" v-model="loginForm.username" placeholder="Username" name="username" type="text" tabindex="1" auto-complete="on" />
      </el-form-item>
      <el-form-item prop="password">
        <span class="svg-container">
          <svg-icon name="ele-Lock" />
        </span>
        <el-input :key="passwordType" ref="passwordRef" v-model="loginForm.password" :type="passwordType" placeholder="Password" name="password" tabindex="2" auto-complete="on" @keyup.enter.native="handleLogin" />
        <span class="show-pwd" @click="showPwd">
          <svg-icon :name="passwordType === 'password' ? 'ele-Hide' : 'ele-View'" />
        </span>
      </el-form-item>
      <el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;height: 40px;" @click.native.prevent="handleLogin">登 陆</el-button>
    </el-form>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Login'
}
</script>

<script lang="ts" setup>
import { useUserInfoStore } from '@/stores/userInfo'
import type { FormInstance } from 'element-plus'
import { nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

// 获取用户相关的小仓库
const userInfoStore = useUserInfoStore()
const route = useRoute()
const router = useRouter()
const loginForm = ref({
  username: 'admin',
  password: '111111'
})
const loading = ref(false)
// 密码表单元素type属性需要响应式数据:默认密码
const passwordType = ref('password')
const redirect = ref('')
// 获取密码选择框的实例
const passwordRef = ref<HTMLInputElement>()
const formRef = ref<FormInstance>()

const validateUsername = (rule: any, value: any, callback: any) => {
  if (value.length < 4) {
    // 提示信息并放行
    callback(new Error('用户名长度不能小于4位'))
  } else {
    // 放行
    callback()
  }
}
const validatePassword = (rule: any, value: any, callback: any) => {
  if (value.length < 6) {
    callback(new Error('密码长度不能小于6位'))
  } else {
    callback()
  }
}

const loginRules = {
  username: [{ required: true, validator: validateUsername }],
  password: [{ required: true, trigger: 'blur', validator: validatePassword }]
}

watch(route,() => {
    redirect.value = route.query && (route.query.redirect as string)
  },
  { immediate: true }
)

/* 
切换密码的显示/隐藏
*/
const showPwd = () => {
  if (passwordType.value === 'password') {
    passwordType.value = 'text'
  } else {
    passwordType.value = 'password'
  }
  // 数据改变时dom立即更新
  nextTick(() => {
    // ? ts中的语法,当passwordRef.value存在时,执行passwordRef.value.focus(),不存在时不执行
    passwordRef.value?.focus()
  })
}

/* 
点击登陆的回调
*/
const handleLogin = async () => {
  // form表单验证通过之后再发送请求
  await formRef.value?.validate()
  loading.value = true
  const { username, password } = loginForm.value
  try {
    // 用户小仓库执行login方法登陆
    await userInfoStore.login(username, password)
    // 编程式跳转路由
    router.push({ path: redirect.value || '/' })
  } finally {
    loading.value = false
  }
}
</script>

品牌管理页面

静态页面布局

src/views/product/trademark/index.vue

vue
<template>
    <el-card shadow="hover">
        <!-- 顶部按钮 -->
        <el-button type="primary" :icon="Plus" @click="addTradeMark">添加品牌</el-button>
        <el-button type="primary" :icon="Download" >导出数据</el-button>
        <!-- 表格 -->
        <el-table :data="tradeMarkList" style="width: 100%;margin:10px 0px" border>
            <el-table-column label="序号" width="90" align="center" type="index"/>
            <el-table-column label="品牌名称" prop="tmName"/>
            <el-table-column label="品牌LOGO" prop="logoUrl">
                <template #="{row,$index}">
                    <img :src="row.logoUrl" alt="" style="width: 100px;height:100px">
                </template>
            </el-table-column>
            <el-table-column label="品牌操作">
                <template #="scope">
                    <el-button type="warning" size="small" :icon="Edit" circle />
                    <el-button type="danger" size="small" :icon="Delete" circle />
                </template>
            </el-table-column>
        </el-table>
        <!-- 对话框 -->
        <el-dialog 
            v-model="showDialog" 
            style="margin-top: 100px"
            title="新增品牌" >
            <el-form 
                label-width="100px"
                style="width: 80%"
                >
                <el-form-item label="品牌名称">
                    <el-input type="text" placeholder="请输入品牌名称"/>
                </el-form-item>
                <el-form-item label="品牌LOGO">
                    <el-upload
                        class="avatar-uploader"
                        action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
                        :show-file-list="false"
                        :on-success="handleAvatarSuccess"
                        :before-upload="beforeAvatarUpload"
                    >
                        <img v-if="imageUrl" :src="imageUrl" class="avatar" />
                        <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
                    </el-upload>
                </el-form-item>
            </el-form>
            <template #footer>
                <!-- 具名插槽:放置确定与取消按钮 -->
                <el-button size="default" @click="handleCloseDialog">取消</el-button>
                <el-button type="primary" size="default" >确定</el-button>
            </template>
        </el-dialog>
        <!-- 分页 layout中的->:把右侧的放在最右侧 -->
        <el-pagination
            v-model:current-page="pageNo"
            v-model:page-size="pageSize"
            :page-sizes="[3, 5, 7]"
            :size="size"
            :background="true"
            layout="prev, pager, next, jumper,->,sizes,total"
            :total="total"
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
        />
    </el-card>
</template>

<script setup lang='ts'>
import { Plus,Download,Edit,Delete } from '@element-plus/icons-vue'
import { onMounted, ref } from 'vue'
import type { ComponentSize } from 'element-plus'
import { reqTradeMark } from '@/api/product/trademark'
import type {TradeMarkResponseData,Records} from '@/api/product/type/trademark'
import type { UploadProps } from 'element-plus'
import { ElMessage } from 'element-plus'

// 表格相关
const tradeMarkList = ref<Records>([])
// 分页相关
const pageNo = ref<number>(1)
const pageSize = ref(3)
const size = ref<ComponentSize>('default')
const total = ref<number>(0)
// 表单相关
const showDialog = ref<boolean>(false)
// 上传相关
const imageUrl = ref('')
const handleAvatarSuccess: UploadProps['onSuccess'] = (
  response,
  uploadFile
) => {
  imageUrl.value = URL.createObjectURL(uploadFile.raw!)
}
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.type !== 'image/jpeg') {
    ElMessage.error('Avatar picture must be JPG format!')
    return false
  } else if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('Avatar picture size can not exceed 2MB!')
    return false
  }
  return true
}

// 生命周期
onMounted(()=>{
    getTradeMarkList()
})

// 获取品牌列表数据方法
const getTradeMarkList = async(val:number=1) =>{
    pageNo.value = val
    const result:TradeMarkResponseData = await reqTradeMark(pageNo.value,pageSize.value)
    // 修改品牌列表数据
    tradeMarkList.value = result.records
    total.value = result.total
}

// 添加品牌
const addTradeMark = () =>{
    showDialog.value = true;
}
const handleCloseDialog = () =>{
    showDialog.value = false;
}
const handleSizeChange = (val: number) => {
    pageSize.value = val
    getTradeMarkList()
}
const handleCurrentChange = (val: number) => {
    pageNo.value = val
    getTradeMarkList(pageNo.value)
}

</script>

<style scoped>
    .avatar-uploader .avatar {
    width: 178px;
    height: 178px;
    display: block;
    }
</style>

<style>

    .avatar-uploader .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
    }

    .avatar-uploader .el-upload:hover {
    border-color: var(--el-color-primary);
    }

    .el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 178px;
    height: 178px;
    text-align: center;
    }
</style>

封装获取品牌接口

获取品牌列表数据:src/api/product/trademark.ts

ts
import request from '@/utils/request'
import type { TradeMarkResponseData } from './type/trademark'

// 枚举地址
enum API {
    GETTRADEMARK_URL = '/admin/product/baseTrademark/'
}

// 获取品牌列表数据 
export const reqTradeMark = (page:number,limit:number) => request.get<any,TradeMarkResponseData>(API.GETTRADEMARK_URL+`${page}/${limit}`)

定义品牌api类型

定义品牌列表数据类型:src/api/product/type/trademark.ts

ts
// 品牌item对象类型
export interface TradeMark{
    id?:number,
    tmName:string,
    logoUrl:string
}
// 品牌数组
export type Records = TradeMark[]

// 品牌列表响应类型
export interface TradeMarkResponseData{
    records:Records,
    total:number,
    size:number,
    current:number,
    searchCount:boolean,
    pages:number
}

调用接口渲染数据

在组件中调用:src/views/product/trademark/index.vue

vue
<template>
    <el-card shadow="hover">
        <!-- 顶部按钮 -->
        <el-button type="primary" :icon="Plus" @click="addTradeMark">添加品牌</el-button>
        <el-button type="primary" :icon="Download" >导出数据</el-button>
        <!-- 表格 -->
        <el-table :data="tradeMarkList" style="width: 100%;margin:10px 0px" border>
            <el-table-column label="序号" width="90" align="center" type="index"/>
            <el-table-column label="品牌名称" prop="tmName"/>
            <el-table-column label="品牌LOGO" prop="logoUrl">
                <template #="{row,$index}">
                    <img :src="row.logoUrl" alt="" style="width: 100px;height:100px">
                </template>
            </el-table-column>
            <el-table-column label="品牌操作">
                <template #="scope">
                    <el-button type="warning" size="small" :icon="Edit" circle />
                    <el-button type="danger" size="small" :icon="Delete" circle />
                </template>
            </el-table-column>
        </el-table>
        <!-- 对话框 -->
        <el-dialog 
            v-model="showDialog" 
            style="margin-top: 100px"
            title="新增品牌" >
            <el-form 
                label-width="100px"
                style="width: 80%"
                >
                <el-form-item label="品牌名称">
                    <el-input type="text" placeholder="请输入品牌名称"/>
                </el-form-item>
                <el-form-item label="品牌LOGO">
                    <el-upload
                        class="avatar-uploader"
                        action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
                        :show-file-list="false"
                        :on-success="handleAvatarSuccess"
                        :before-upload="beforeAvatarUpload"
                    >
                        <img v-if="imageUrl" :src="imageUrl" class="avatar" />
                        <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
                    </el-upload>
                </el-form-item>
            </el-form>
            <template #footer>
                <!-- 具名插槽:放置确定与取消按钮 -->
                <el-button size="default" @click="handleCloseDialog">取消</el-button>
                <el-button type="primary" size="default" >确定</el-button>
            </template>
        </el-dialog>
        <!-- 分页 layout中的->:把右侧的放在最右侧 -->
        <el-pagination
            v-model:current-page="pageNo"
            v-model:page-size="pageSize"
            :page-sizes="[3, 5, 7]"
            :size="size"
            :background="true"
            layout="prev, pager, next, jumper,->,sizes,total"
            :total="total"
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
        />
    </el-card>
</template>

<script setup lang='ts'>
import { Plus,Download,Edit,Delete } from '@element-plus/icons-vue'
import { onMounted, ref } from 'vue'
import type { ComponentSize } from 'element-plus'
import { reqTradeMark } from '@/api/product/trademark'
import type {TradeMarkResponseData,Records} from '@/api/product/type/trademark'
import type { UploadProps } from 'element-plus'
import { ElMessage } from 'element-plus'

// 表格相关
const tradeMarkList = ref<Records>([])
// 分页相关
const pageNo = ref<number>(1)
const pageSize = ref(3)
const size = ref<ComponentSize>('default')
const total = ref<number>(0)
// 表单相关
const showDialog = ref<boolean>(false)
// 上传相关
const imageUrl = ref('')
const handleAvatarSuccess: UploadProps['onSuccess'] = (
  response,
  uploadFile
) => {
  imageUrl.value = URL.createObjectURL(uploadFile.raw!)
}
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.type !== 'image/jpeg') {
    ElMessage.error('Avatar picture must be JPG format!')
    return false
  } else if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('Avatar picture size can not exceed 2MB!')
    return false
  }
  return true
}

// 生命周期
onMounted(()=>{
    getTradeMarkList()
})

// 获取品牌列表数据方法
const getTradeMarkList = async(val:number=1) =>{
    pageNo.value = val
    const result:TradeMarkResponseData = await reqTradeMark(pageNo.value,pageSize.value)
    // 修改品牌列表数据
    tradeMarkList.value = result.records
    total.value = result.total
}

// 添加品牌
const addTradeMark = () =>{
    showDialog.value = true;
}
const handleCloseDialog = () =>{
    showDialog.value = false;
}
const handleSizeChange = (val: number) => {
    pageSize.value = val
    getTradeMarkList()
}
const handleCurrentChange = (val: number) => {
    pageNo.value = val
    getTradeMarkList(pageNo.value)
}

</script>

<style scoped>
    .avatar-uploader .avatar {
    width: 178px;
    height: 178px;
    display: block;
    }
</style>

<style>

    .avatar-uploader .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
    }

    .avatar-uploader .el-upload:hover {
    border-color: var(--el-color-primary);
    }

    .el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 178px;
    height: 178px;
    text-align: center;
    }
</style>

Dialog对话框

静态布局

src/views/product/trademark/index.vue

ts
...
<el-dialog 
    v-model="showDialog" 
    style="margin-top: 100px"
    title="新增品牌" >
    <el-form 
        label-width="100px"
        style="width: 80%"
        >
        <el-form-item label="品牌名称">
            <el-input type="text" placeholder="请输入品牌名称"/>
        </el-form-item>
        <el-form-item label="品牌LOGO">
            <el-upload
                class="avatar-uploader"
                action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
                :show-file-list="false"
                :on-success="handleAvatarSuccess"
                :before-upload="beforeAvatarUpload"
            >
                <img v-if="imageUrl" :src="imageUrl" class="avatar" />
                <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
        </el-form-item>
    </el-form>
    <template #footer>
        <!-- 具名插槽:放置确定与取消按钮 -->
        <el-button size="default" @click="handleCloseDialog">取消</el-button>
        <el-button type="primary" size="default" >确定</el-button>
    </template>
</el-dialog>
...
import type { UploadProps } from 'element-plus'
import { ElMessage } from 'element-plus'
// 表单相关
const showDialog = ref<boolean>(false)
// 上传相关
const imageUrl = ref('')
const handleAvatarSuccess: UploadProps['onSuccess'] = (
  response,
  uploadFile
) => {
  imageUrl.value = URL.createObjectURL(uploadFile.raw!)
}
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.type !== 'image/jpeg') {
    ElMessage.error('Avatar picture must be JPG format!')
    return false
  } else if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('Avatar picture size can not exceed 2MB!')
    return false
  }
    //发送请求
  return true
}

<style scoped>
    .avatar-uploader .avatar {
    width: 178px;
    height: 178px;
    display: block;
    }
</style>

<style>

    .avatar-uploader .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
    }

    .avatar-uploader .el-upload:hover {
    border-color: var(--el-color-primary);
    }

    .el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 178px;
    height: 178px;
    text-align: center;
    }
</style>

deep选择器

ts
可以使用 deep 选择器(通常是 ::v-deep)来为 ElDialog 等组件内部元素应用样式。deep 选择器允许您在 scoped 样式中嵌套地选择并覆盖第三方组件的内部样式,非常适用于这种情况。

注意:
::v-deep 是 Vue 3 中的写法,Vue 2 使用 /deep/>>>。请确认您的 Vue 版本是否支持此选择器。
::v-deep 选择器可以帮助您在不直接修改组件的情况下覆盖组件库的样式,但由于其跨作用域的特性,可能会增加样式复杂度,需谨慎使用。
这样,您可以在保持 scoped 的同时,自定义第三方组件的样式而不会影响其他组件。

使用案例:

ts
<template>
  <ElDialog :model-value="false" @update:model-value="fn">
    <!-- 组件内容 -->
  </ElDialog>
</template>

<style scoped>
/* 使用 ::v-deep 选择器 */
::v-deep .el-dialog {
  margin-top: 100px;
}
</style>

项目代码:

ts
<style scoped>
/* 使用 ::v-deep 选择器 */
::v-deep .el-dialog {
  margin-top: 100px;
}
</style>

封装API接口(新增和更新)

定义数据类型 src/api/product/type/trademark.ts

ts
// 品牌item对象类型
export interface TradeMark{
    id?:number,
    tmName:string,
    logoUrl:string
}
// 品牌数组
export type Records = TradeMark[]

// 品牌列表响应类型
export interface TradeMarkResponseData{
    records:Records,
    total:number,
    size:number,
    current:number,
    searchCount:boolean,
    pages:number
}

封装新增和修改的品牌接口:src/api/product/trademark.ts

ts
import type { TradeMarkResponseData,TradeMark } from './type/trademark'

// 枚举地址
enum API {
    //获取已有的品牌
    GETTRADEMARK_URL = '/admin/product/baseTrademark/',
    //添加新增的品牌接口:post  {tmName:'xxx',logoUrl:'xxx'}
    ADDTRADEMARK_URL = "/admin/product/baseTrademark/save",
    //更新已有的品牌  post  {id:'zzz',tmName:'xxx',logoUrl:'xxx'}
    UPDATETRADEMARK_URL = "/admin/product/baseTrademark/update",
    //删除已有的品牌数据
    DLETETRADENARK_URL = "/admin/product/baseTrademark/remove/"

}

//添加与更新品牌的函数
export const reqAddOrUpdateTradeMark = (data: TradeMark) => {
    //更新已有的品牌
    if (data.id) {
        return request.put<any, any>(API.UPDATETRADEMARK_URL, data);
    } else {
        //新增品牌
        return request.post<any, any>(API.ADDTRADEMARK_URL, data);
    }
}

调用上传接口

获取from表单数据并配置上传的接口:src/views/product/trademark/index.vue

ts
<el-dialog 
    v-model="showDialog" 
    style="margin-top: 100px"
    title="新增品牌" >
    <el-form 
        ref="formRef"
        :model="tradeMark"
        label-width="100px"
        style="width: 80%"
        >
        <el-form-item label="品牌名称">
            <el-input type="text" placeholder="请输入品牌名称" v-model="tradeMark.tmName"/>
        </el-form-item>
        <el-form-item label="品牌LOGO">
            <!-- action:上传图片的请求地址 上传图片post 需要代理跨域 app-dev  -->
            <el-upload
                class="avatar-uploader"
                action="/app-dev/admin/product/fileUpload"
                :show-file-list="false"
                :on-success="handleAvatarSuccess"
                :before-upload="beforeAvatarUpload"
            >
                <img v-if="tradeMark.logoUrl" :src="tradeMark.logoUrl" class="avatar" />
                <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
            </el-upload>
        </el-form-item>
    </el-form>
    <template #footer>
        <!-- 具名插槽:放置确定与取消按钮 -->
        <el-button size="default" @click="handleCloseDialog">取消</el-button>
        <el-button type="primary" size="default" >确定</el-button>
    </template>
</el-dialog>

...

let tradeMark = reactive<TradeMark>({
    tmName: "", //收集新增品牌名称
    logoUrl: "", //收集新增品牌图片地址
})

...

配置上传前的钩子函数

ts
# 配置上传图片前的钩子函数:

参数是文件对象,文件对象可以获取上传文件的类型,大小,名字;因此可以限制上传文件的类型,大小,名字。
返回值:
return true 发送请求
return false 不发送请求

项目代码:src/views/product/trademark/index.vue

ts
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.type !== 'image/jpeg') {
    ElMessage.error('Avatar picture must be JPG format!')
    return false
  } else if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('Avatar picture size can not exceed 2MB!')
    return false
  }
    //发送请求
  return true
}

配置上传后的钩子函数

ts
配置上传后的钩子函数 用于获取成功后的服务器响应,把响应的图片地址收集到from表单中

//图片上传成功的钩子
const handleAvatarSuccess = (response: any, file: any) => {
  //response:即为上传图片post 请求返回的数据
  //file:即为上传图片对象
  tradeMark.logoUrl = response.data;
  //图片上传成功清除校验结果
  formRef.value.clearValidate("logoUrl");
};

点击确定按钮发送请求

ts
<el-button type="primary" size="default" @click="confirm" >确定</el-button>
...
const confirm = ()=>{
    //收集参数
    // 校验数据
    //发请求
    // 提示消息
}

表单校验

文本类表单校验函数,和上传类表单校验函数都是来自Element-plus官网的文档中。

表单校验

原理

这类表单校验是:文本类,checkbox,这种具有 change blur 事件的校验

ts
校验函数 validator()
函数的第一个参数是 rule对象
第二个参数是 表单内容
第三个参数是 放行函数 通过校验时触发调用
const validatorTmName = (rule,value,callback) =>{
    
    if(){
        //放行
        callback();
    }else{
        // 不符合条件时,在放行函数中 传入一个 Error对象
    	callback(new Error("不符合校验"));
    }
    
}

项目代码

ts
// 配置校验表单
<el-form 
    ref="formRef"
    :model="tradeMark"
    label-width="100px"
    style="width: 80%"
    :rules="rules"
    >
	<el-form-item label="品牌名称" prop="tmName">
	<el-form-item label="品牌LOGO" prop="logoUrl"> 
</el-form>
// 设置校验规则
const rules = {
    tmName:[{
        required:true,
        trigger:"change",
        validator:validatorTmName
    }],
    logoUrl:[{
        required:true,
        trigger:"change",
        validator:validatorTmName
    }]
}
// 配置自定义校验函数
const validatorTmName = (rule,value,callback) =>{
    
    if(){
        //放行
        callback();
    }else{
        // 不符合条件时,在放行函数中 传入一个 Error对象
    	callback(new Error("不符合校验"));
    }
    
}

非文本表单校验

这类表单校验是:文本类,checkbox,这种没有 change blur 事件的校验

原理

使用表单form对象方法validate()校验表单数据

参数:无

作用:校验表单内容

返回值:会返回一个Promise对象,全部表单校验成功即成功,只要有一个表单校验失败就失败

ts
// 在元素上绑定ref属性
<el-form ref="formRef"></el-form>
// 定义ref对象
const formRef = ref<any>()
// 定义校验的rules
const rules = {
    tmName:[{
        required:true,
        trigger:"blur",
        validator:validatorTmName
    }],
    logoUrl:[{
        required:true,
        validator:validatorlogoUrl
    }]
}
// 校验的方法
const validatorlogoUrl = (rule,value,callback)=>{
    console.log("123")
}

// 使用from对象的validate方法调用(在提交的时候触发)
formRef.value.validate()

项目代码

ts


// 自定义校验规则
const validatorTmName = (rule: any, value: any, callBack: any) =>{
    //规则对象没有实际用途 rule
    //表单内容  value
    //放行函数 callBack
    if (/^[\u4e00-\u9fa5]{2,}$/.test(value)) { // 如果前两位数是汉字
        callBack();
    } else {
        //不符合条件
        callBack(new Error("品牌名称至少两位汉字"));
    }
}
const validatorLogoUrl = (rule: any, value: any, callBack:any)=>{
    //value即为上传图片地址:如果有放行
    if (value) {
        callBack();
    } else {
        //value即为上传图片地址:如果没有返回错误信息
        callBack(new Error("请上传图片LOGO"));
    }
}
// 设置校验规则
const rules = {
    tmName:[{
        required:true,
        trigger:"blur",
        validator:validatorTmName
    }],
    logoUrl:[{
        required:true,
        validator:validatorLogoUrl
    }]
}

清空表单校验结果

ts
// 方式1 使用nextTick()

// 方式2 使用 ts 语法 ? 
// ? 的语法 当?前的对象是undefined时,不执行方法,?前的对象存在时,执行方法
formRef.value?.clearValidate()

// 清除upload表单校验结果
在上传后的钩子函数中清除 upload校验结果

nextTick

ts
作用:当响应式数据发生变化后,获取更新后的DOM
nextTick(()=>{
    代码
}),是一个异步函数,需要DOM更新完毕后,执行里边的代码

项目代码

ts
<el-form 
    ref="formRef"
    :model="tradeMark"
    label-width="100px"
    style="width: 80%"
    :rules="rules"
    >
...
const formRef = ref()
...

// 打开添加品牌
const handleAddDialog = () =>{
    showDialog.value = true
    //清空表单校验的结果
    resetFiledResult()
}

// 重置表单数据
const resetFromData = ()=>{
    tradeMark.id = 0
    tradeMark.tmName = ""
    tradeMark.logoUrl = ""
}

//清空表单校验的结果
const resetFiledResult = () => {
    formRef.value?.clearValidate("tmName")
    formRef.value?.clearValidate("logoUrl")
}
// 在上传后的钩子函数
const handleAvatarSuccess: UploadProps['onSuccess'] = (response,uploadFile) => {
    //response:即为上传图片post 请求返回的数据
    //file:即为上传图片对象
    tradeMark.logoUrl = response.data;
    //图片上传成功清除校验结果
    formRef.value.clearValidate("logoUrl");
}

确认提交

ts
<el-button type="primary" size="default" @click="confirm" >确定</el-button>

const confirm = async()=>{
    //收集参数
    // 校验数据 调用 formRef对象的 validate() 方法
    //通过form组件实例的validate方法进行全部的表单校验
    //validate方法执行:会返回一个Promise->成功(全部表单相校验成功)、失败(只要有一个表单相校验失败)
    await formRef.value.validate();
    //发请求
    try {
        //添加品牌成功||修改品牌成功
        await reqAddOrUpdateTradeMark(tradeMark);
        //关闭对话框
        showDialog.value = false
        //提示
        ElMessage({
        type: "success",
        message: tradeMark.id ? "更新成功" : "添加成功",
        });
        //再次获取全部已有的品牌
        //更新留在当前页、添加留在第一页
        getTradeMarkList(tradeMark.id ? pageNo.value : 1)
    } catch (error) {
        ElMessage({
            type: "error",
            message: tradeMark.id ? "更新失败" : "添加失败",
        })
    }
}

更新品牌

ts
<el-table-column label="品牌操作">
<template #="scope">
    <el-button type="warning" size="small" :icon="Edit" @click="updateTradeMark(scope.row)" circle />
    <el-button type="danger" size="small" :icon="Delete" circle />
</template>
</el-table-column>

...
// 编辑品牌
const updateTradeMark = (row:TradeMark) =>{
    //将已有的品牌数据赋值给tradeMark
    tradeMark.id = row.id;
    tradeMark.tmName = row.tmName;
    tradeMark.logoUrl = row.logoUrl;
    //显示对话框
    showDialog.value = true;
}

删除品牌

封装删除品牌的接口 src/api/product/trademark.ts

ts
// 删除品牌的接口
export const reqDeleteById = (id: number) => request.delete<any, any>(API.DLETETRADENARK_URL + id);

组件中调用 src/views/product/trademark/index.vue

ts
<el-popconfirm
    :title="`你确定要删除${scope.row.tmName}?`"
    icon-color="red"
    :icon="Delete"
    width="250px"
    @confirm="deleteTradeMark(scope.row.id)"
>
    <template #reference>
        <el-button type="danger" size="small" :icon="Delete" circle />
    </template>
</el-popconfirm>
...
// 删除品牌
const deleteTradeMark = async (id:number) =>{
    try {
        await reqDeleteById(id);
        //提示
        ElMessage({
            type: "success",
            message: "删除成功",
        });
        //再次获取全部已有品牌数据(当前页)
        getTradeMarkList(pageNo.value);
    } catch (error) {
        ElMessage({
            type: "error",
            message: "删除失败",
        });
    }
}

导出表格

使用插件 xlsx 格式

ts
# 详细请查阅 npm xlsx 插件的 readme (搜索 append 关键字)

// 安装插件:
npm install xlsx
https://www.npmjs.com/package/xlsx
// 使用插件:

// 导入 全部引入 并且设置别名
import * as XLSX from 'xlsx'

/* generate worksheet and workbook */
// 创建json数据转Excel需要的格式
const worksheet = XLSX.utils.json_to_sheet(rows);
// 给Excel创建一个容器
const workbook = XLSX.utils.book_new();
// 把数据追加到Excel容器中
XLSX.utils.book_append_sheet(workbook, worksheet, "Dates");
// 导出数据:第二个参数是文件名
XLSX.writeFile(workbook, "Presidents.xlsx");

项目代码

ts
<el-button type="primary" :icon="Download" @click="downLoad">导出数据</el-button>

...
import * as XLSX from 'xlsx'
// 导出数据
const downLoad = () => {
    // 创建json数据转Excel需要的格式
    const worksheet = XLSX.utils.json_to_sheet(tradeMarkList.value);
    // 给Excel创建一个容器
    const workbook = XLSX.utils.book_new();
    // 把数据追加到Excel容器中
    XLSX.utils.book_append_sheet(workbook, worksheet, "Dates");
    // 导出数据:第二个参数是文件名
    XLSX.writeFile(workbook, "Presidents.xlsx")

}

富文本

shell