# ConanLee-vue3-0823 **Repository Path**: conanleey/conan-lee-vue3-0823 ## Basic Information - **Project Name**: ConanLee-vue3-0823 - **Description**: vue3+ts后台项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2022-08-23 - **Last Updated**: 2022-10-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 说明 * 基于Vue3的电商中台管理项目 * 技术栈: TS + Vue3 + VueRouter4 + Pinia + ElementPlus * 当前为完成版 * 学习参见 文档 文件夹下的MD文件 赵立耿哈哈哈 ## day1 #### 分析源码目录结构 拿到项目,先浏览项目文件,看看项目的入口文件(找到路由,通过路由找到对应的vue文件),package.json(使用的技术栈); ~~~ |-node_modules 依赖包 |-public 包含会被自动打包到项目根路径的文件夹 |-favicon.ico 页面标题图标 |-static/logo.png 应用Logo图片 |-src |-assets 组件中需要使用的公用资源 |-404_images 404页面的图片 |-bg.jpg 登陆背景图片 |-components 公共非路由组件 |-Breadcrumb 面包屑组件(头部水平方向的层级组件) |-Hamburger 用来点击切换左侧菜单导航的图标组件 |-SvgIcon svg图标组件 |-hooks 自定义hook模块 |-useResize.ts 处理应用在不同屏幕下的适应问题 |-layout 管理界面整体布局(一级路由) |-components 组成整体布局的一些子组件 |-index.vue 后台管理的整体界面布局组件 |-router |-index.ts 路由器 |-routes.ts 路由表 |-stores |-interface/index.ts state数据接口 |-app.js 管理应用相关数据 |-settings.js 管理设置相关数据 |-userInfo.js 管理后台登陆用户相关数据 |-index.js pinia的store |-styles |-xxx.scss 项目组件需要使用的一些样式(使用scss) |-utils 一些工具函数 |-get-page-title.js 得到要显示的网页title |-token-utils.js 操作登陆用户的token cookie |-request.js axios 二次封装的模块 |-validate.js 检验相关工具函数 |-views 路由组件文件夹 |-error/404.vue 404页面 |-home 首页 |-login 登陆 |-App.vue 应用根组件 |-main.ts 入口js |-permission.ts 使用全局守卫实现路由权限控制的模块 |-settings.ts 包含应用设置信息的模块 |-.env 通用的环境配置 |-.env.development 指定了开发环境的代理服务器前缀路径 |-.env.production 指定了生产环境的代理服务器前缀路径 |-.eslintrc.cjs eslint的检查配置 |-.gitignore git的忽略配置 |-env.d.ts 让TS认知Vue的配置 |-index.html 唯一的页面 |-package-lock.json 当前项目依赖的第三方包的精确信息 |-package.json 当前项目包信息 |-README.md git仓库的md文档 |-shims.d.ts 告诉TS, vue 文件是这种类型 |-tsconfig.config.json TS的配置文件 |-tsconfig.json TS的配置文件 |-vite.config.ts vite相关配置(如: 代理服务器等) ~~~ ~~~~ # 相对重要的部分 src    assets # 包含一些静态资源, 如图片   components # 包含一些通用的非路由组件   layout # 管理界面整体布局   router # 路由相关   store # pinia相关 userInfo.ts # 管理后台登陆用户相关数据 index.ts # pinia的store   styles # 包含一些scss样式模块   utils # 包含一些工具模块     token-utils.ts # 存储token的模块     request.ts # axios二次封装的模块   views # 包含一些路由组件     login/index.vue # 用户登陆路由组件   App.vue # 应用根组件   main.ts # 入口js   permission.ts # 使用全局守卫做权限控制的模块 .env.development  # 配置开发环境的变量   代理前缀路径 .env.production # 配置生产环境的变量 代理前缀路径 tsconfig.json # 用于引入模块路径时, 加@后还可以提示 package-lock.json  # 保存了下载的依赖包的准确详细信息 vite.config.ts # vite配置   配置代理等 ~~~~ #### 查看接口文档说明 在线接口文档swagger,由Java后台提供,可以在解接口中直接测试,能否使用。 - 权限管理: http://39.98.123.211:8170/swagger-ui.html - 商品管理:http://39.98.123.211:8510/swagger-ui.html #### 关联git 远程仓库 本地仓库: ~~~ git init git status git add . git commit -m ~~~ ``` 在当前文件目录下执行一下操作: 1、初始化一个本地Git仓库 git init   执行以上命令,我们能够发现在当前目录下多了一个.git的目录,这个目录是Git来跟踪管理版本库的,千万不要手动修改这个目录里面的文件,不然改乱了,就把Git仓库给破坏了。   注意:Git会自动为我们创建唯一一个master分支 2、将本地仓库与远程仓库进行关联   git remote add origin git@github.com:YZ/helloTest.git   git@github.com:YZ/helloTest.git是我们远程仓库的路径 3、先将关联后的github仓库中的代码pull下来 git pull origin master 4、将最新的修改推送到远程仓库 将本地仓库的文件推送到远程仓库 git push -u origin master   第一次使用加上了-u参数,是推送内容并关联分支。   推送成功后就可以看到远程和本地的内容一模一样,下次只要本地作了提交,就可以通过命令:git push origin master 把最新内容推送到Github上关联的远程仓库中去 ``` #### 修改小功能 ##### 一、修改默认边距 ​ 登录页有上下滑动的边距 styles=>index.scss ``` css * { margin: 0; padding: 0; } ``` ##### 二、修改'登录'按钮、修改顶部导航'退出登陆'这个'陆' ##### 三、修改登录界面背景图 views=>login=>index.vue 找到style样式 ~~~ background-image: url(../../assets/bg.webp); ~~~ #### 分析页面结构 ##### Layou页面分为三部分 * sidebar - 侧边栏,了解数据从哪来 - userInfoStore.menuRoutes * navbar - 顶部导航 * appMain - 主要渲染我们之后写的页面 ```
``` #### 登录login分析 ##### 一、登录逻辑 */*** *登录的逻辑:* *登录页点击登录,发送网络请求,返回token.(注意:只要有token的返回证明登录成功)* *登录之后要跳转到目标页面,在跳转的过程中,获取用户信息(在全局前置守卫发请求,请求头携带token,拿的用户信息) - token存在localStroage中* *如果可以拿到用户信息,跳转目标路径* *拿不到用户信息,代表token过期了,跳转登录页面* *------* *假设已经登录了- 退出登录* *需要发送请求,让后端作废掉当前token,等待请求完毕,清除本地存的token,同时清除store中的 token 和 用户信息* > 按照这个逻辑走,发现store当中登录,获取用户信息、退出登录都是假的,需要重写 > 登录之后路由的前置守卫是真的,可以直接使用; ##### 二、限制接口 写接口的时候,我们期望得到的数据是有格式的,需要对axios二次封装进行TS类型限制 1. axios中请求拦截器 - 要加token 2. axios中相应拦截器 - 一定要注意返回的数据(data)是哪一层,决定了写接口函数的时候 request.get() xxx 返回数据的类型 3. request.get() 这个地方要限制类型,xxx 类型写接口返回数据的格式 ###### 1.在请求拦截中限制,拿到token放到请求头中 utils=>request ~~~ ts import axios, { type AxiosResponse, type AxiosRequestConfig, type AxiosRequestHeaders } from 'axios'; /* 定义response对象的data接口 */ interface ResponseData { code: number; data: T; message: string; } // 添加请求拦截器 ===>修改token service.interceptors.request.use( (config) => { // 如果有token,应该携带token const storeUserInfo = useUserInfoStore(); // 拿到store const token = storeUserInfo.token; if (token) { (config.headers as AxiosRequestHeaders).token = token; } return config; } ); // 添加响应拦截器 service.interceptors.response.use( /* 约束一下response */ async (response: AxiosResponse>) => { // 对响应数据做点什么 const res = response.data; // 拿到的是响应体数据 if (res.code !== 20000 && res.code !== 200) { /* 成功数据的code值为20000/200 */ // 统一的错误提示 .... // `token` 过期或者账号已在别处登录 if (response.status===401) { ... } else { return res.data; /* 返回成功响应数据中的data属性数据 */ ======》注意此处的返回值 } }, (error) => { // 对响应错误做点什么 ... return Promise.reject(error); } ); ~~~ ###### 2.写接口 api=>userInfo.ts ~~~ ts import request from '@/utils/request' // GET /admin/acl/index/info // POST /admin/acl/index/login // POST /admin/acl/index/logout interface LoginParamsModel { username: string, password: string } interface TokenModel { token: string } export interface UserInfoModel { name: string, avatar: string, buttons: string[], roles: string[], routes: string[] } // 期望,我们拿到的数据直接是可以设置类型,这个类型根据接口的返回数据的样子来设置 export default { login(loginParams: LoginParamsModel) { return request.post('/admin/acl/index/login', loginParams); // 第一个any是用来占位的, 第二个才是我们真正用的 }, info() { return request.get('/admin/acl/index/info') }, logout() { return request.post('/admin/acl/index/logout') } } ~~~ ###### 3.配置代理 ```` 在vite官网去找,代理的配置和webpack配置几乎一样 ```js server: { proxy: { // 选项写法 '/app-dev': { target: 'http://gmall-h5-api.atguigu.cn', changeOrigin: true, rewrite: (path) => path.replace(/^\/app-dev/, '') }, } } ``` ```` ###### 4.测试接口 ===>main.ts ~~~ ts // 测试接口 login // import userApi from '@/api/userinfo' // import { setToken } from '@/utils/token-utils' // async function fn () { // let result = await userApi.login({ username: 'admin', password: '111111' }) //在这里把token 存到localstoage中 // setToken(result.token) // // { // // token: 'xxxx' // // } // } // fn() //测试info // async function fnInfo () { // let result = await userApi.info(); // console.log(result); // } // fnInfo(); ~~~ 测试成功,开始写具体内容! ###### 5.分析路由前置守卫,无需修改 src=>permission.ts ~~~ts // 路由加载前 router.beforeEach(async (to, from, next) => { // 在显示进度条 NProgress.start() // 设置整个页面的标题 document.title = getPageTitle(to.meta.title as string) const token = userInfoStore.token // 如果token存在(已经登陆或前面登陆过) if (token) { // 如果请求的是登陆路由 if (to.path === '/login') { // 直接跳转到根路由, 并完成进度条 next({ path: '/' }) NProgress.done() } else { // 请求的不是登陆路由 // 是否已经登陆 const hasLogin = !!userInfoStore.userinfo.name // 如果已经登陆直接放行 if (hasLogin) { next() } else { // 如果还没有登陆 try { // 异步请求获取用户信息(包含权限数据) ==> 动态注册用户的权限路由 => 当次跳转不可见 await userInfoStore.getInfo() next(to) // 重新跳转去目标路由, 能看到动态添加的异步路由, 且不会丢失参数 NProgress.done() // 结束进度条 } catch (error: any) { // 如果请求处理过程中出错 // 重置用户信息 await userInfoStore.reset() // 提示错误信息 // ElMessage.error(error.message || 'Has Error') // axios拦截器中已经有提示了 // 跳转到登陆页面, 并携带原本要跳转的路由路径, 用于登陆成功后跳转 next(`/login?redirect=${to.path}`) // 完成进度条 NProgress.done() } } } } else { // 没有token // 如果目标路径在白名单中(是不需要token的路径) if (whiteList.indexOf(to.path) !== -1) { // 放行 next() } else { // 如果没在白名单中, 跳转到登陆路由携带原目标路径 next(`/login?redirect=${to.path}`) // 完成进度条 当次跳转中断了, 要进行一个新的跳转了 NProgress.done() } } }) ~~~ ##### 三、登录页面、退出登录 ​ 1、逻辑 ​ 点击`退出登录`按钮,需要调用接口,告诉后端当前这个token失效了,等待接口调用成功、清除我们store中的 token、 userInfo、同时清除 localStorage 中 token ​ 2、具体实现 ​ store=>userInfo.ts ​ 修改 登录、退出的方法 ``` ts import { defineStore } from 'pinia'; import { getToken, removeToken, setToken } from '../utils/token-utils'; // import type { UserInfoState } from './interface'; import {ElMessage} from 'element-plus' import {staticRoutes} from '@/router/routes' import userinfoApi from '@/api/userinfo' import type { RouteRecordRaw } from "vue-router"; import type { UserInfoModel } from '@/api/userinfo' // 用户信息包括权限数据 export interface UserInfoState { token: string; userinfo: UserInfoModel menuRoutes: RouteRecordRaw[] // 用于生成导航菜单的路由列表 } // 创建一个空的用户信息,用来初始化store.userinfo和reset const resetUserinfo = () => ({ name: '', avatar: '', buttons: [], roles: [], routes: [] }) /** * 用户信息 * @methods setUserInfos 设置用户信息 */ export const useUserInfoStore = defineStore('userInfo', { state: (): UserInfoState => ({ token: getToken() as string, // 用户信息改了,所有涉及到获取用户信息的地方都需要改 // 1. router.beforeEach 通过获取用户名判断登录 // 2. 首页 hello, admin -> 用户名获取方式改变 // 3. navBar 用户名和头像的展示 userinfo: resetUserinfo(), menuRoutes: [] // 该数据用于渲染侧边栏 }), actions: { async login(username: string, password: string) { try { let result = await userinfoApi.login({ username, password }) this.token = result.token; // 给store存 setToken(result.token); // 给localStroage存 } catch (error) { ElMessage.error('用户名密码错误'); return Promise.reject(error) } }, // login (username: string, password: string) { // return new Promise((resolve, reject) => { // setTimeout(() => { // if (username==='admin' && password==='111111') { // const token = 'token-atguigu' // setToken(token) // this.token = token // resolve(token) // } else { // reject(new Error('用户名或密码错误!')) // ElMessage.error('用户名或密码错误!') // } // }, 1000) // }) // }, async getInfo() { try { // 这里result的类型是有 request.post() 中的xxx类型来决定的 let result = await userinfoApi.info(); // 请求头携带的 this.userinfo = result; // 侧边栏的数据,现在是假的,是静态的 // 后期我们每个用户可以看到的侧边栏都不一样,通过用户信息可以拿到侧边栏数据 this.menuRoutes = staticRoutes; } catch (error) { ElMessage.error('获取用户信息失败') return Promise.reject(error); } }, // getInfo () { // return new Promise((resolve, reject) => { // setTimeout(() => { // this.name = 'admin' // this.avatar = 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif' // this.menuRoutes = staticRoutes // resolve({name: this.name, avatar: this.avatar, token: this.token}) // }, 1000) // }) // }, async reset() { try { await userinfoApi.logout(); // 请求头携带token,告诉后端token作废 removeToken(); // 删除local中保存的token // 清除store的token this.token = ""; // 提交重置用户信息 this.userinfo = resetUserinfo(); } catch (error) { ElMessage.error('退出登录错误') return Promise.reject(error); } }, // 退出登录 // reset () { // // 删除local中保存的token // removeToken() // // 提交重置用户信息的mutation // this.token = '' // this.name = '' // this.avatar = '' // }, }, }); ``` 注意: 用户信息改了,所有涉及到获取用户信息的地方都需要改 `userInfoStore.name 变成了userInfoStore.userInfo.name` ​ // 1. router.beforeEach 通过获取用户名判断登录 permission.ts ​ // 2. 首页 hello, admin -> 用户名获取方式改变 view/home.vue ​ // 3. navBar 用户名和头像的展示 src/layout/components/Navbar.vue #### 侧边栏的展示 */*** *当一级路由只有一个子集路由的时候* *侧边栏 sideBar 不会显示一级菜单,直接显示这个子集菜单* **/* src=>router/routes.ts ~~~ ts { 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', component: () => import('@/layout/index.vue'), meta: { title: '商品管理', icon: 'ele-GoodsFilled' }, children: [ { // name: '', // name放一放,按下不表 path: 'trademark/list', component: () => import('@/views/product/trademark/index.vue'), meta: { title: '品牌管理' } }, { path: 'attr/list', component: () => import('@/views/product/attr/index.vue'), meta: { title: '平台属性管理' } }, { path: 'spu/list', component: () => import('@/views/product/spu/index.vue'), meta: { title: 'SPU管理' } }, { path: 'sku/list', component: () => import('@/views/product/sku/index.vue'), meta: { title: 'SKU管理' } }, ] }, ~~~ 侧边栏渲染实际上是递归组件=》逻辑见:src=>layout=>components=>siderbar=>siderbarItem 小图标的显示: `icon: 'ele-GoodsFilled'`实际上是简化代码,把element-plus的icon组件简化成该写法 ​ 逻辑见:src=>layout=>components=>svgIcon=>Elsvg.ts #### 品牌管理模块 ##### 一、步骤 *1. 静态页面搭建* *2. 数据展示* *2.1 准备接口 - 根据接口文档拿到返回的数据类型,给api用TS限制类型* *2.2 页面请求数据,展示* *3. 交互* *3.1 翻页* *3.2 新增* *3.2.1 弹框的展示,注意里面放的是form表单,收集输入的数据* *收集数据使用 tmForm 这个对象收集数据,这个数据是符合 定义的品牌的interface(接口),有两个值 tmName 和 logoUrl* *首先需要有 tmForm 对象* *3.2.1.1 收集品牌名称* ​ *将 tmForm.tmName 绑定到 input 标签上,使用v-model收集* *3.2.1.2 收集图片 - 文件上传* ​ *图片上传 使用的是 el-upload 组件,点击上传,选择图片后走 :before-upload , 校验图片是否是我们想要的格式和大小* ​ *校验通过会上传至 actions="" 配置的地址* ​ *上传成功之后,返回数据之后会走 :on-success 回调* ​ *在回调当中 收集返回的 url* *3.2.2 点击弹框保存之前应该校验表单* *校验数据需要两个条件* ​ *1.配置表单的 rules 属性* ​ *2.配置 form-item 的prop属性* *此时表单校验只有变化(失焦 变化)的时候会校验(只有在变化的时候才会触发规则)* *点击保存这个按钮的时候 也 需要触发校验规则去校验* *3.2.3 按理来说,现在该点击保存按钮发请求,但是我们发现编辑也使用的这个弹框,也使用的这套校验规则,所以先把编辑做完(在保存之前)* *----------------------------------------------------------------------------------------------------------------* *3.2.4 点击编辑按钮,弹出弹框,回显表格这一条数据* *当再次点击保存,又走了一个校验规则* *规则通过应该发送请求,更新这一条数据* *3.2.5* *发请求,此时新增应该调用新增的接口,修改应该调用修改的接口,如何区分是新增还是修改?* *新增是没有id的,而修改是有id的,根据id的存在与否来调用不同的接口* *3.3 编辑(略,在新增中)* *3.4 删除* *-->* ### day02 #### 一、平台属性 分析: *1. 三级联动 - 用组件去做(全局公用组件)* *1.1 静态搭建* *1.2 接口做好 - 去查看接口返回的数据,使用TS限制数据格式* *1.3 数据展示 - 交互* *1.4 当选择完3id的时候(如何让attr组件知道,3id有值了) - 有两个交互* *按钮可用* *获取主体内容列表展示数据* *2. 主体内容* *2.1 把编辑和列表展示切换搞定 - 三级分类的禁用* *---------------------------------------------* *2.2 准备api* *2.2 列表展示页* *静态展示* *调用接口拿数据,展示数据* *2.3 新增/编辑页面* *静态展示* *新增 - 收集数据 - 声明一个变量来收集数据 - 做新增* *收集属性名,v-model直接绑定收集即可* *添加属性值禁用状态 -> 当属性名有值的时候,才可以点击 '添加属性值'* *点击'添加属性值'交互 -> 点击添加完属性值,表格多一行(这个行的数据要先关注,至于交互 放一放)* *删除属性值 -> 点击删除这一行的时候,表格删除掉这行的数据* *保存按钮禁用状态 -> 保存按钮-只有属性名和属性值列表都有值,才能点* *编辑回显* *深拷贝数据 给form表单即可* *调用接口,保存数据* *一定要校验数据的合法性,不能让空值保存了* *2.4 删除* *删除主界面的数据* **/ #### 拓展知识:深拷贝总结* ~~~ js 原始数据 const obj = { name: '张三', age: 33, fun: { // 这个地址不会改变 run: '跑的快', eat: '吃得多' } } 1. Object.assign({}, obj) 只能拷贝第一层,第二层的对象没有办法拷贝 const obj1 = Object.assign({}, obj); 2. 扩展运算符 - 只能拷贝第一层,第二层的对象没有办法拷贝 const obj1 = { ...obj } 3. 使用json拷贝 - 数组和对象都没问题,函数不行 const obj1 = JSON.parse(JSON.stringify(obj)); 4. 使用第三方(lodash - cloneDeep) - 或者自己写递归去拷贝(面试的时候可能会手写 - 面试精讲准本) ~~~ ### day3 #### 一、平台属性 1、逻辑-》新增、编辑 新增/编辑 需求:点击属性值表格中属性值名称列要切换状态,并且可以输入内容 1. 需要一个控制切换 input 和 div 的一个值 - boolean 2. 再输入input的时候一个值去记录输入的内容 - 将会展示到只读状态的显示 这个值我们已经有了,在添加属性值表格的时候,默认我们给了个值 ---- ​ 这里需要做的事情还有很多(需求):**表格input切换显示** ​ 1. 默认新增需要展示input,不应该展示div - 且input要聚焦 ​ 2. 在输入内容的时候,不能有重复属性值 ​ 3. 让页面中始终最多只有一个input ​ (--按下不表 -- 因为这里我们只使用了一个变量 inputRef 来收集这个组件实例 - 这里永远获取到的是当前显示的那一个input) ​ 步骤: ​ 1. 添加 inputVisible 这个数据(在添加属性值列表的时候,同时加上这个数据) - ts限制类型中也需加 ​ 2. 通过模板中点击div和input失焦 来 切换 inputVisible 的值 ​ 3. 在input失焦的时候 inputVisible为false input隐藏的时候,做了限制条件 ​ 3.1 当输入内容为空的时候,把之前的添加的新数据干掉 ​ 3.2 当输入内容和已存在内容重复的时候,,把之前的添加的新数据干掉 ​ 3.3 然后让 inputVisible变为false ​ 4. 在聚焦的时候 - 点击div的时候,新增数据的时候也应该自动聚焦 ​ 使用 nextTick 等待DOM更新完毕之后,才能获取到input框 然后调用聚焦 #### 二、spu 步骤: 1. 分析页面 2. 三个界面切换 - 枚举 - vue3的v-model 把SpuList和SpuForm的切换先做出来了(禁用状态: 1.spuList新增按钮 2.CategorySelector禁用) 3. spuList的静态搭建 4. spu所有涉及到的接口 - ts类型限制(花了很长时间) 5. spuList 调接口,展示数据 6. spuFomr的静态展示 7. spuForm请求接口拿数据 新增拿两个接口数据 修改拿4个接口数据 使用:父子组件间的传参 *// 修改showStatus可选的方式* *// 1. props 子 -> defineProps* *// 2. 自定义事件 -> defineEmits* *// 3. 子组件直接修改父组件数据 -> 模板中$parent 传给函数 通过传的这个东西调用 修改父组件数据 父组件需要 defineExpose 将可修改的数据说明* *// 4. 父子组件之间的数据同步 - v-model* *// 4.1. 子组件要展示这个数据 需要在子组件中使用 defineProps(['modelValue'])* *// 4.2. 修改父组件数据需要在子组件中 defineEmits(['update:modelValue'])* ### day4 #### 一、Spu管理列表(收集数据) 目标: 新增什么? ​ spu - 新增了一个符合spuModel接口(interface)的数据,调用的保存接口 ​ 看一下保存的时候的数据格式,才能知道你要收集什么数据格式 ~~~ json //数据格式 // 保存spu数据 { "category3Id": 61, "description": "性价比高", "spuImageList": [ { "imgName": "00.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/06/B6/rBHu8mMMFYGAfup5AABD9OLYmoU597.jpg" } ], "spuName": "笔记本", "spuSaleAttrList": [ { "baseSaleAttrId": 1, "saleAttrName": "颜色", "spuSaleAttrValueList": [ { "baseSaleAttrId": 1, "saleAttrValueName": "red" }, { "baseSaleAttrId": 1, "saleAttrValueName": "blue" } ] }, { "baseSaleAttrId": 2, "saleAttrName": "版本", "spuSaleAttrValueList": [ { "baseSaleAttrId": 2, "saleAttrValueName": "64G" }, { "baseSaleAttrId": 2, "saleAttrValueName": "32G" } ] } ], "tmId": 24 } ~~~ 8.1 创建一个 spuForm 这个数据用来收集数据 ~~~~ vue const initSpuForm = () => ({ category3Id: undefined, // 保存之前再赋值 spuName: '', // spu名称 description: '', // 描述 tmId: undefined, // 品牌 spuSaleAttrList: [], // 销售属性列表 spuImageList: [] // 图片列表 }) const spuForm = ref( initSpuForm() ); // 新增-初始化数据 ==>SpuModel export interface SpuModel { id?: number, spuName: string, // spu名称 description: string, // spu描述 category3Id: number | undefined, // 三级分类的id tmId: number | undefined, // 品牌id spuSaleAttrList: SpuSaleAttrListModel, // spu销售属性列表 spuImageList: SpuImageListModel // spu图片列表 } export type SpuListModel = SpuModel[] ~~~~ 收集数据分为直接收集 和 间接收集 直接收集的数据: spuName、tmId、description (使用v-model) ``` ``` 间接收集的数据: spuSaleAttrList、spuImageList ##### (一)上传图片数据收集(spuImageList) spuForm 中的上传图片: ~~~ vue //上传图片 //预览图片 ~~~ 同时针对图片数据的变化,修改ts类型==>spu.ts ~~~ ts // 图片列表 export interface SpuImageModel { id?: number, // 保存没有 spuId?: number, // 保存没有 imgName: string, // 图片名称 imgUrl: string, // 图片url // ------- name?: string, // 前端交互使用 url?: string, // 前端交互使用 response?: any, // 前端交互使用 } export type SpuImageListModel = SpuImageModel[] ~~~ ##### (二)销售属性数据的收集(spuSaleAttrList) 1、思路 收集销售属性 (1)收集销售属性下拉框数据的时候,即需要收集到id,还需要收集到name(只能通过value获取) ```vue ``` 下拉框选择到内容的时候,attrIdAttrVal就收集到了`id:name` ``` vue 添加销售属性 ``` (2)当点击添加按钮的时候,应该把当前收集的这个数据,拆开创建一个 销售属性的对象 添加到 spuForm.spuSaleAttrList 拿着添加好的值去展示 ~~~ vue ~~~ (3)当将销售属性添加到 表格中的时候,此时下拉框的的数据将要发生变化 已经添加到表格的销售属性不能在下拉框中去显示 下拉框不能写死,要么使用computed 要么使用watch 同时需要清除掉刚刚选中的值 attrIdAttrVal - 点击按钮的时候 ~~~ js // 下拉框真实展示的数据,需要过滤掉表格中以存在的数据 // baseSaleAttrList -> 原始数据 // saleAttrList 过滤后的数据 const saleAttrList = computed(() => { // filter 满足条件的会被过滤留下,不满足条件的会被过滤掉 return baseSaleAttrList.value?.filter(item => { // spuForm.value.spuSaleAttrList - 表格数据 const isRepate = spuForm.value.spuSaleAttrList.some(row => { return row.saleAttrName == item.name }) return !isRepate }) }) const addSaleAttr = () => { if (!attrIdAttrVal.value) return const [baseSaleAttrId, saleAttrName] = attrIdAttrVal.value.split(':'); // 创建 销售属性对象 spuForm.value.spuSaleAttrList.push({ baseSaleAttrId: Number(baseSaleAttrId), // 类型不同,需要强转 saleAttrName, spuSaleAttrValueList: [] // 属性值列表也比有 }) attrIdAttrVal.value = '';// 清除已选择的值 } ~~~ (4)收集属性值列表数据 - 需要两个值 1. 控制显示隐藏 2. 记录输入内容 把这两个数据放到当前 row 当中 ​ ==》点击添加属性按钮时,需要显示input框,同时聚焦 ​ ==>点击新增按钮时,先判断输入框内有没有内容, ​ =》没有内容直接情况输入框,同时隐藏Input框 ​ =》有内容时,需要判断新增的内容(row.inputValue)是否已经存在, ​ =》 当存在时,清空Input内容,隐藏Input输入框,弹出错误提示(已经重复); =》不存在,将它添加到属性值列表中(添加进去自动v-for循环就会显示); ​ =》最后清空内容,隐藏输入框!! ~~~ ts // 销售属性 //不能单独使用变量 需要定义在内部 // const inputVisible=ref(false) // const inputValue=ref('') export interface SpuSaleAttrModel { id?: number, spuId?: number, baseSaleAttrId: number, saleAttrName: string, spuSaleAttrValueList: SpuSaleAttrValueListModel, *inputVisible?: boolean, *inputValue?: string, } ~~~ ```vue //属性值列表 //销售属性值的展示与添加 //点击输入框 const handleInputConfirm=(row:SpuSaleAttrModel)=>{ // 判断有没有输入的值 if (!row.inputValue) { row.inputVisible = false; return; } else { // 添加之前要去重 const isRepate = row.spuSaleAttrValueList.map(item => item.saleAttrValueName).includes(row.inputValue); if (isRepate) { // 有重复 row.inputValue = ""; // 请内容 row.inputVisible = false; // 切显示 ElMessage.error('输入属性值重复,请重试') return; } // 将值添加到属性值列表中(添加进去自动v-for循环就会显示) row.spuSaleAttrValueList.push({ baseSaleAttrId: row.baseSaleAttrId, saleAttrValueName: row.inputValue, }) // 清空输入的内容 row.inputValue = ""; // 切换显示 row.inputVisible = false; } } //点击新增按钮 const InputRef=ref() const showInput=(row:SpuSaleAttrModel)=>{ row.inputVisible=true; //隐藏输入框 nextTick(()=>{ InputRef.value?.focus(); //聚焦 }) } ``` (5)完成删除该条信息,以及添加的该属性值 ~~~ vue ~~~ #### (三)新增与回显 1、分析数据回显格式 ~~~ json // 编辑回显点击保存 { "id": 3, "spuName": "Apple iPhone 12", "description": "Apple iPhone 12", "category3Id": 61, "tmId": 2, "spuSaleAttrList": [ { "id": 5, "spuId": 3, "baseSaleAttrId": 1, "saleAttrName": "颜色", "spuSaleAttrValueList": [ { "id": 9, "spuId": 3, "baseSaleAttrId": 1, "saleAttrValueName": "黑色", "saleAttrName": "颜色", "isChecked": null }, { "id": 10, "spuId": 3, "baseSaleAttrId": 1, "saleAttrValueName": "红色", "saleAttrName": "颜色", "isChecked": null }, { "id": 11, "spuId": 3, "baseSaleAttrId": 1, "saleAttrValueName": "蓝色", "saleAttrName": "颜色", "isChecked": null }, { "id": 12, "spuId": 3, "baseSaleAttrId": 1, "saleAttrValueName": "白色", "saleAttrName": "颜色", "isChecked": null } ] }, { "baseSaleAttrId": 3, "saleAttrName": "尺码", "spuSaleAttrValueList": [ { "baseSaleAttrId": 3, "saleAttrValueName": "6.4" }, { "baseSaleAttrId": 3, "saleAttrValueName": "5.8" } ] } ], "spuImageList": [ { "imgName": "7155bba4c363065f.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWAVRWzAABUiOmA0ic932.jpg" }, { "imgName": "2689bc534d570eaf.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWAO2oYAAEw9kY2VKk982.jpg" }, { "imgName": "6ef342197c8095b6.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWAJllcAAEY0AkXL8M782.jpg" }, { "imgName": "34c390fe3ab2bab5.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWAbqkuAAENKBtJukQ551.jpg" }, { "imgName": "7ae59d1d962f0965.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWAFeQLAAEt9MLZnho584.jpg" }, { "imgName": "de33680f921e5838.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWASR1YAADsOUYB-2g312.jpg" }, { "imgName": "f73bfe30f5ec641a.jpg", "imgUrl": "http://47.93.148.192:8080/group1/M00/00/02/rBHu8l-rgfWABhwlAAEjBwwVkrI735.jpg" } ] } ~~~ 2、获取此行数据,传给组件 ~~~ vue //spu.vue const spuInfo = ref( initSpuInfo() ) const changeSpuInfo = (row: SpuModel) => { if (row) { spuInfo.value = row; // 设置编辑要回显的数据 } else { spuInfo.value = initSpuInfo(); // 重置为空模板 } } ~~~ ``` //spuList const editSpu = (row: SpuModel) => { emits('spuInfo', row); // 把数据给父组件 emits('update:modelValue', SPUSTATUS.SPUFORM) // 切换界面的显示 } ``` ~~~ vue //spuForm // 编辑,编辑回显数据是回显的父组件传过来的spuInfo这个数据 // 当前组件被销毁的时候,父组件没有把它内部的 spuInfo 给重置了 // 当再次点击'新增'进来的时候,会重新把父组件的 spuInfo 这个数据传过来,又有了 // 在点击保存和取消的时候,都需要告诉父组件把父组件内部的 spuInfo 给重置了 const initData = async () => { if (props.spuInfo.id) { // 重新给spuForm赋值 spuForm.value = cloneDeep(props.spuInfo); // 深拷贝,不能影响主界面的值 await getSpuImageList(); // 获取当前spu的图片列表 await getSpuSaleAttrList(); // 获取当前spu的销售属性列表 } getTrademarkList(); // 品牌下拉数据 getBaseSaleAttrList(); // 获取销售属性下拉 } const handlerSuccess: UploadProps['onSuccess'] = (response, uploadFile, uploadFiles) => { // 注意: 这是新增的逻辑 const tempImgList: SpuImageListModel = []; // 临时的中间变量 uploadFiles.forEach(item => { // 这里需要注意: // 只有新上传的图片有response这个属性,编辑的时候,返回的图片列表中是没有response这个属性的 if (item.response) { // 新上传的图片 tempImgList.push({ imgName: item.name, imgUrl: (item.response as any).data, // 注意:这里upload组件预览图片的时候是需要url和name这两个属性的 name: item.name, url: (item.response as any).data, response: item.response // 为了第二次循环的时候,第一张图片还有response这个属性 }) } else { tempImgList.push(item as any); } }) spuImageList.value = tempImgList; } // 获取当前spu的图片列表 const spuImageList = ref() const getSpuImageList = async () => { try { let result = await spuApi.getSpuImageList(spuForm.value.id as number); spuImageList.value = result.map(item => { return { ...item, name: item.imgName, // 图片upload组件展示需要 url: item.imgUrl // 图片upload组件展示需要 } }); } catch (error) { console.error(error); ElMessage.error('获取SPU的图片列表失败'); } } // 保存 const onSave = async () => { // 组装数据 - 自动收集的数据(v-model收集的)不需要管 - 需要处理间接收集的数据 spuForm.value.spuImageList = (spuImageList.value as SpuImageListModel).map(item => { // 判断是新上传的图片还是之前就有的图片 if (item.id) { // 老图片,编辑回显的图片,直接拿就行 return item } else { return { imgName: item.imgName as string, imgUrl: item.imgUrl as string } } }) // 清除数据(这个可以没有,因为对后端不会造成影响) spuForm.value.spuSaleAttrList.forEach(item => { delete item.inputVisible delete item.inputValue }) if (!spuForm.value.category3Id) { spuForm.value.category3Id = categoryStore.category3Id; } // 简单校验 const { spuName, tmId, category3Id, spuImageList: imgList, spuSaleAttrList } = spuForm.value; if ( !(spuName && tmId && category3Id && imgList.length && spuSaleAttrList.length) ) { ElMessage.error('名称、品牌、图片列表、销售属性列表为必填项,请检查') return } // 发送请求 try { await spuApi.saveSpu(spuForm.value); ElMessage.success('保存成功'); cancelSave(); } catch (error) { console.error(error) ElMessage.success('保存失败'); } } // 取消保存 const cancelSave = () => { emits('update:modelValue', SPUSTATUS.SPULIST) emits('spuInfo'); } ~~~ ##### (四)删除 1、写接口 api=>spu ~~~ // 删除spu DELETE /admin/product/deleteSpu/${ spuId } delete(spuId: number) { return request.delete(`/admin/product/deleteSpu/${ spuId }`) } ~~~ 2、添加事件=>spuList ~~~vue // 删除spu const deleteSpu = async (row: SpuModel) => { try { await spuApi.delete(row.id as number); ElMessage.success('删除成功') if (spuList.value?.length == 1 && page.value != 1) { page.value -= 1; } getPage(); } catch (error) { console.error(error); ElMessage.success('删除失败') } } ~~~ #### 二、SKU静态页面的搭建 ~~~ //SKUFORM ~~~ ``` //spuList // 添加sku const addSku = (row: SpuModel) => { emits('update:modelValue', SPUSTATUS.SKUFORM); emits('spuInfo', row); // 把数据给父组件 } ```