# gshop-admin_0613 **Repository Path**: newsegmentfault/gshop-admin_0613 ## Basic Information - **Project Name**: gshop-admin_0613 - **Description**: gshop-admin_0613.git 仓库 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2022-11-30 - **Last Updated**: 2022-12-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 说明 * 基于Vue3的电商中台管理项目 * 技术栈: TS + Vue3 + VueRouter4 + Pinia + ElementPlus * 当前为完成版 * 学习参见 文档 文件夹下的MD文件 # day01 - day02 ### 项目介绍 ![](assets/01.项目介绍.png) 我们的项目只有"权限管理" 和 "商品管理" ,在真实的后台管理项中模块不可能只有两个,我们只是挑出两个模块去说一说,其他的模块可能不知道里面的内容,但是要能够想到有这些模块,举一反三(真实的业务有固定套路)。 ### 项目准备 为什么我们的项目不是从零到一自己搭建? 在公司中,不可能等你入职之后再开始做项目,进到公司之后,拿到手里的一定是一个已经开发了一下内容的项目,我们直接拿一个模板(半成品)进行开发,这个模板是 "潘家诚" 写的,尚硅谷改的 潘家诚:写了一套后台管理系统的模板,基于 vue2 写的,很多公司都在使用这个模板 模板地址:https://github.com/PanJiaChen/vue-element-admin 1. vue-admin-template: 这是一个非常流行的,极简的**后台管理模板项目** 技术栈: **js + vue2 + vue-router3 + vuex + element-ui** 2. 我们的模板项目是此项目的升级版本(而我们使用的是 vue3 ,尚硅谷把这个 vue2 的模板改成了 vue3 模板) 技术栈: **ts + vue3 + vue-router4 + pinia + element-plus +axios** #### 安装依赖 进入项目后,执行 `npm i` 进行依赖安装 #### 关联到git仓库 1. gitee 上创建仓库 2. 初始化本地仓库 ```js 项目目录当中 git init ``` 注意:在推代码之前一样要先有一个 commit 节点,否则会出问题 3. 本地执行如下指令 ```js // 将远端地址添到本地取别名叫 origin git remote add origin https://gitee.com/newsegmentfault/gshop-admin_0613.git // 将本地代码推动到远端 git push -u origin "master" ``` 4. 此时远端就已经有了项目的代码了 把项目设置成开源 "项目" -> "管理" -> "基础管理" -> '"开源" -> "保存" #### 关于地址 前台项目:http://101.43.227.123/home 前台项目 gitee:https://gitee.com/tianyucoder/220613_sph 后台项目:http://101.43.227.123:5555/home 后台项目 gitee:https://gitee.com/newsegmentfault/gshop-admin_0613 ### 项目目录介绍 ```js |-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相关配置(如: 代理服务器等) ``` ```js # 相对重要的部分 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配置   配置代理等 ``` ### 接口文档介绍 - 权限管理:http://39.98.123.211:8170/swagger-ui.html - 商品管理:http://39.98.123.211:8510/swagger-ui.html -------------- ## 分析 发现:当我们拿到这个项目,启动了之后,发现默认展示的是登录页,当登录之后进入的是首页,这里没有登录的时候刷新永远是登录页,登录之后目前只有首页,刷新是首页 我们不熟悉这个项目,把项目从两个角度来分析,1. 登录的角度(首页) 2. 未登录的角度(登录页) 来分析页面 从哪里开始分析? 从入口文件开始(main.ts),让我们开始分析 ### 页面结构(登录的角度 - 首页分析) 每个文件引入到 main.ts 中,需要注意的文件有: ```js import App from './App.vue' // 根组件 import router from './router' // 路由 import './permission' // 路由守卫 ``` 为什么是这几个文件? 我们现在登录的状态下渲染的首页,从 `App.vue` 和 `路由` 一步一步往下走,看看首页是怎么渲染出来的,其他文件目前不涉及页面的渲染 **src/App.vue(根组件)** ```html
``` 发现跟组件中只有 `router-view` ,说明整个页面都是使用路由渲染的,再往下走应该看一下路由怎么配置的 **src/router/index.ts(路由器)** ```js import { staticRoutes } from '@/router/routes'; const router = createRouter({ history: createWebHistory(), routes: staticRoutes, // -----------------------> routes配置的是staticRoutes scrollBehavior() { return { top: 0, left: 0} }, }) ``` 查看 router 文件的时候发现,配置的路由选项在另一个文件中,查看另一个 `routes.ts` 文件 **src/router/routes.ts(路由配置文件)** ```js export const staticRoutes: Array = [ { path: '/login', ... }, { path: '/404', ... }, { path: '/', component: () => import('@/layout/index.vue'), // 一级路由在App.vue渲染 redirect: '/home', children: [ { path: 'home', name: 'Home', component: () => import('@/views/home/index.vue'), // 二级路由在AppMain.vue渲染 meta: { title: '首页', icon: 'ele-HomeFilled', } } ] }, /* 匹配任意的路由 必须最后注册 */ { path: '/:pathMatch(.*)', redirect: '/404' } ]; ``` 通过查看 `routes.ts` 得到的结论:App.vue 根组件中的 `router-view` 目前渲染了三个页面,`/login` 、 `/404` 和 `/` ,注意的是 `/` 根路径进行了重定向,重定向到了它下面的二级路由,那么二级路由在哪里渲染? 二级路由一定是在已经渲染出来的一级路由页面中进行渲染的,所以需要查看 `/` 根路径渲染的组件中去找二级路由的 `router-view` **二级路由渲染** `@/layout/index.vue` ----->这是`/` 根路径的渲染组件的路径,在 App.vue根组件的 `router-view` 渲染 `@/views/home/index.vue` ----->这是二级路由 `/home` 渲染组件的路径,就是 `/` 路径重定向的路径,在哪里渲染目前不知道,只知道在 `@/layout/index.vue` 这个文件中渲染 **页面(Layout)的结构:** layout 的结构就是整个页面的结构,打开 `@/layout/index.vue` 发现 layout 由三个组件构成了页面:`SideBar`、`NavBar`、`AppMain`,关于这三个组件: ```js SideBar组件: 侧边菜单栏渲染,它里面有一个递归组件(sideBar-item),渲染侧边栏每一项,侧边栏数据来源于 userinfoStore 中 menuRoutes 注意: 这个组件知道这么多即可,其他的不要深究,深究整个项目就无法进行了 NavBar: 顶部导航 AppMain:主体渲染内容,组件中有 `router-view`, 这个 `router-view` 就是用来渲染二级路由的 ``` 整个页面的结构出来了,总结: App.vue 作为根组件渲染了所有的一级路由 layout 组件作为整个项目的结构,所有相关的二级路由都在 layout 组件中的 AppMain 组件渲染 **遇到的问题:** 1. router-view 在管理这页面的渲染,当我们输入 `/` 路径之后,会走 routes 去匹配我们的路由,当匹配到路由之后使用 router-view 渲染即可,并不是必须使用 router-link 才能渲染。 这里我们相当于先使用 `routes` 匹配到 `/`路径,然后直接找到 `App.vue` 根组件下的 `router-view` 进行一级路由的渲染,渲染的是 `layout` 组件,此时在 `/` 根路径的路由中,进行了重定向,重定向到 `/home`,此时会再次拿着 `/home` 路径在 `routes` 中进行匹配,匹配到之后进行渲染,在**一级路由组件(Layout)**中进行二级路由的渲染,所以要查看 `layout` 组件中的结构,找到二级路由渲染的 `router-view` 即可 2. **在vue3当中,【不能】直接使用 $router 和 $route 来获取路由器对象 和 当前的路由对象** 因为我们使用的是 vue3 + vueRouter4 ,获取发生发生了变化 获取路由器对象和路由对象的方法发生了改变,在组件中以如下方式获取: ```js import { useRouter, useRoute } from 'vue-router' const router = useRouter(); // ----> 获取router路由器对象 const route = useRoute(); // ----> 获取route当前的路由对象 ``` * router 获取的 router 对象可以进行跳转,例如: ```js router.push('/home') ``` * route 获取的 route 对象可以拿到路由的传参,例如: ```js route.params.xxx route.query.xxx ``` ### 登录 - 整个流程(未登录的角度 - 分析) 当我们拿到页面之后,未登录的状态下渲染的是登录页,此时我们的项目应该从 main.ts 分析 当我们默认打开页面的时候,输入的路径是 `/` ,在匹配到 `/` 这个路径的时候,重定向到 `/home` 页面,那么此时应该去渲染 `/home` 页面,但是渲染出来的是 `/login` 页面,为什么? 因为走了路由守卫,需要我们结合路由守卫去看一下整个页面的跳转过程。 注意:之前页用到了路由守卫,只是我们没有考虑路由守卫的问题,但是现在必须要走路由守卫了,因为页面的渲染发生了变化 步骤: 我们先把路由守卫的逻辑走一遍,再从未登录的时候,一步一步退出所有的代码走向 #### 路由守卫 -- `permission.ts` beforeEach 全局前置守卫,在全局前置守卫中获取 token 获取 token * 有 token 判断是不是去登录页 * 如果是去登录页,不允许,因为token已经存在,已经登录过了 注意:只要登陆过之后,就有token * 不是去登陆页 判断有没有用户信息 * 如果有用户信息,直接放行 * 如果没有用户信息 调用 store 中 actions,actions调用接口获取用户信息 这里获取用户信息可能成功,也可能失败 * 获取用户信息成功,放行 * 获取用户信息失败 > 什么情况下会获取用户信息失败? > > 1. 网络错误 (很少发生) > 2. token 过期 清除 token ,调用store来清除,清除 store 和 loaclStorage 中的 token 清除之后,跳转 login 页面,进行登录 * 没有 token 查看跳转的路径是否在白名单当中(白名单中只有登录页) * 如果在白名单中,放行 * 如果不在白名单中,跳转登录页,并记录想去而没有去的页面 遇到的问题: 1. next(to) 和 next() 有什么区别 next() 直接调用相当于是直接放行,不重新走路由守卫,直接去渲染页面了 next(to) 这个不是直接放行,相当于给了一个路由,让代码重新进行一次跳转,从头开始再走一次路由守卫 怎么不写next(to.path) ? 可以写,next() 中是支持直接写路径 和 路由的,两个都可以直接写 **使用 to.path 如果有参数可能会丢失,没有参数直接使用就行** 2. 从根路径重定向到 home 之间走路由守卫吗? 通过打印看到 走了路由守卫 结论:只有跳转路径发生了变化,都会走路由守卫 3. 路由跳转记录参数不理解 ```js next(`/login?redirect=${to.path}`) next(`/login?redirect=${to.fullPath}`) ``` 什么情况下会走到这一行? 当没有token,切不在白名单中的径路会走到这一行,走到这一行之后,next('/login') 相当于让我们当前跳转登录页去,问号之后的 redirect 的 query 参数记录了当前想要去的页面为 '/home',这里参数`redirect`仅仅就是为了记录一下想要去的页面是哪个 【后续】在登录之后可以拿到这个参数,登录之后直接跳转到这个页面 > `to.path` 和 `to.fullPath` 的区别 > > `fullPath` 带 query 参数 > > `path` 不带 query 参数 > > 我们这里"退出登录"的时候使用 path 和 fullpath 不影响路径跳转 4. `!!userInfoStore.userinfo.name` 这句是为了讲值转化成布尔值,然后放到 if 跳转中进行判断 如果不加 `!!` 在 if 条件的小括号内,不是一个布尔值,只是进行了隐式转化转成了布尔值进行判断的 代码不是越精简越好吗? 在TS中,类型越清晰越好,只要看到 `!!` 就应该想到是把表达式转成一个布尔值 注意: 什么代表登录? 只要有token就代表登录,我们登录只获取 token,其他所有的内容不获取 在登录之后要跳转到首页,首页是需要展示用户信息的,用户信息的获取是在路由守卫中,所以需要在路由守卫中判断是不是有用户信息,如果没有用户信息就需要去获取,如果有用户信息进行放行 #### 登录流程 ##### 登录流程开始 首先在浏览器的url中输入项目启动的地址,访问的是 `/` 根路径,访问根路径的时候进行了重定向,重定向到 `/home` 页(重定向是走了路由守卫了,这个过程不分析了),去 `/home` 页也要走路由守卫,此时是没有登录的,没有token,然后判断去的页面在不在白名单当中,如果在直接放行(白名单当中目前只有 `/login`) ,如果不在白名单当中 跳转 `/login` ```js next(`/login?redirect=${to.path}`) ``` 同时记录了想要去的页面在 query 参数中,query 参数中 `redirect` 记录了 `/home`,此时跳转 `/login`,我们需要在 `routes` 中找到 `login` 的路由,发现渲染的组件在 `src/view/login/index.vue` ,在 App.vue 中的 `router-view` 渲染(渲染的是一级路由,在根组件的 `router-view` 中渲染) 此时应该查看 `src/view/login/index.vue` ##### 查看login页面 **`src/view/login/index.vue`** 在登录页面,点击登录按钮,进行登录 当点击登录按钮的时候,通过 `userinfoStore` 调用了 actions 中的 `login` 方法,在 actions 中的 `login` 方法当中【应该】去调用登录的接口,而项目中写的是一个 Promise 在模拟登录,并没有调用登录接口,后期这个 actions 需要我们自己手动去写 *当调用登录接口成功的时候(这里是Promise模拟的),需要将 token 存入到 store 和 localStorage 中* 此时就有了 token,代表登录成功,查看 login 组件中登录按钮的回调函数,发现 `router.push({ path: redirect.value || '/' })` , 发现了调用 actions 中的 login 成功之后,使用 touter 进行跳转,跳转至 当前路由 query 中 redirect 携带的参数,如果有这个参数就跳转到这个参数记录的页面,如果没有跳转到 `/` 根路径 此时我们 redirect 记录的 `/home`,那么此时应该跳转至 `/home` 又涉及到了跳转,走【路由守卫】 ##### 点击登录按钮跳转 - 登录成功跳转 - 获取个人信息 此时进入路由守卫中,判断是否有 token ,如果有 token 代表登录了,此时我们有 token 在有 token 的基础上,判断有没有用户信息,用户信息存放在 store 当中,从 store 中拿到用户名,如果有用户名,代表有用户信息,直接放行即可,如果没有用户名,代表没有用户信息,调用 actions 中的 `getInfo` 方法来获取用户信息 我们这里没有用户名,没有用户信息,需要调用 actions 中的 `getInfo` 方法来获取用户信息,【应该】去调用获取用户信息的接口,拿用户信息(这里使用的是 promise 模拟的),拿到用户信息之后,需要把用户信息存到 store 当中,**同时【应该】对路由的权限做处理**,这里我们不考虑处理权限,先写死,后期讲到权限的时候,再去做权限 `this.menuRoutes = staticRoutes` 先把这行写死,这个数据我们见过,在分析页面结构的时候,在layout组件中的 sidebar 组件中见过,sideBar 组件是侧边栏的展示,使用的数据就是这个数据,目前写死就是所有的路由都在侧边栏展示,并没有进行权限的处理,如果进行权限的处理,每个用户看到的侧边栏应该是不一样的 此时获取用户信息成功,进行放行,放行跳转至刚刚 query 中 redirect 记录的页面,也就是 `/home` 此时才走了我们之前登录之后分析的流程 - **登录的角度-首页分析** ,走路由匹配,走 layout 渲染 等一系列之前分析的步骤,展示首页 #### 退出登录 在首页的 NavBar 组件上,有退出登录的按钮,当点击"退出登录"按钮的时候,走退出登录的逻辑 退出登录的逻辑: 点击退出登录之后,也需要调用接口,告诉后端当前的 token 失效 当点击"退出登录"按钮,此时应该调用一个 actions 中的 退出登录方法,退出登录方法没有,需要自己写,在这个方法中调用 退出登录(logout) 接口,**当退出登录接口调用成功之后,此时应该将前端存储的 token 和 用户信息 进行清除** 调用 actions 中的 reset 方法进行清除 清除 state 中存储的 token 和 localStorage 中存的 token 清除 store 中存的用户信息 -------------------- 以上都是分析内容,熟悉页面的结构和写法的 ## 代码的书写 ### 需要修改的地方: 在分析页面的过程中发现了一些问题需要我们去修改,发现问题如下: * 滚动条 发现 body 标签有 8px的默认边距,需要解决: 在全局的css文件中,加上去除默认边距的css即可 ---- `src/styles/index.scss` ```css * { margin: 0; padding: 0; } ``` * 登录页 "登陆" 按钮的 "陆"不对, 改成"登录" - 根据路由找到文件`src/views/login/index.vue` * 修改一下登录按钮的文本 * 修改一下背景图片,在.vue文件的css样式中,把背景图替换成自己想要的图片 点击登录按钮,`userinfo` 的 store 调用 login 方法,进行登录,发现 actions 中的 login 方法是假的,没有调用接口 * 顶部导航 - "退出登陆" "陆"不对, 改成"退出登录" ### 登录 - api 和 actions 书写 `src/api/userinfo.ts` ```js import request from '@/utils/request' // info GET /admin/acl/index/info // login POST /admin/acl/index/login // logout POST /admin/acl/index/logout interface loginModel { username: string, password: string } export default { login(data: loginModel) { return request.post(`/admin/acl/index/login`, data) } } ``` `src/store/userinfo.ts` ```ts import userinfoApi from '@/api/userinfo'; actions: { async login (username: string, password: string) { try { let result = await userinfoApi.login({ username, password }); console.log('登录返回的数据', result); this.token = result.token; // token存到store中 setToken(result.token) // token 存到 localStorage } catch (error) { ElMessage.error('登录失败,请重试'); // 提示信息 return Promise.reject(error); // 将错误信息继续往外抛 } }, } ``` ### vite 配置 服务器代理 `vite.config.ts` ```js server: { proxy: { // 选项写法 '/app-dev': { target: 'http://sph-h5-api.atguigu.cn', // 代理的目标路径 changeOrigin: true, // 允许跨域 rewrite: (path) => path.replace(/^\/app-dev/, '') // 路径重写 }, } } ``` > 注意:配置文件的改变需要重启项目 > 注意: > > 需要把 "标识" 写对,还有 rewrite 选项中的标识写对 代理配置的时候,需要url中的"标识",进行替换,url中的标识在哪(这个问题就是在问:为什么写 `/app-dev`)? 发请求的时候,使用的是 `src/utils/request.ts` 中对 axios 二次封装的实例,在二次封装的时候,写的有 `baseUrl` 基础路径,这个基础路径就是"标识" ```js baseURL: import.meta.env.VITE_API_URL import.meta 可以拿到当前项目的元信息,了解即可,这是新出的一个JS模块化的API import.meta.env 拿到的是环境变量 -- 当是开发环境的时候,拿到的是 .env.development 文件中的信息 -- 当时生产环境的时候,拿到的是 .env.production 文件中的信息 可以理解成当前项目的环境变量 import.meta.env.VITE_API_URL -- 当前运行的是开发环境,拿到的就是 .env.development 文件中的变量 VITE_API_URL = '/app-dev' 所以这个 baseUrl 中放的是 '/app-dev' ``` 写道这个位置,就可以调通接口,拿到token并且存起来了,此时应该在 "登录" 按钮的回调中进行跳转页面了,执行:`router.push({ path: redirect.value || '/' })` 这里目前要跳转的是首页,跳转首页走路由守卫,守卫中获取用户个人信息 ```js 题外话(不理解也没事 - 知道有这么一回事就行) 在package.json中 script: { "dev": "vite --host 0.0.0.0", } 加上 --host 0.0.0.0 这个选项,启动项目的时候就会有 Netwrok 这个地址(是一个局域网的地址ip + 端口) 不加 --host 0.0.0.0 这个选项,启动项目的时候就只有 local: localhost:3000 这个地址 ``` ### 获取个人用户信息 在 `src/permission.ts` 文件中有路由守卫,此时我们已经登录,有了token,但是没有用户信息,在守卫中判断有没有个人用户信息,没有的话获取个人用户信息,调用 userinfoStore 中的 actions 下的 getInfo() 方法获取的 `await userInfoStore.getInfo()` 我们发现 userinfoStore 中的 actions 下的 getInfo 这个方法是假的,使用Promise模拟的,我们需要调用真是的接口 **api 准备 - `src/api/userinfo.ts`** ```js export default { ... // 获取用户信息的时候,通过请求头携带token获取用户信息 info() { return request.get(`/admin/acl/index/info`) } } ``` **注意: 获取用户信息的时候,通过请求头携带token获取用户信息** **请求拦截器中添加 token - `src/utils/request.ts`** ```js import axios, { type AxiosRequestConfig, type AxiosRequestHeaders } from 'axios'; service.interceptors.request.use( (config: AxiosRequestConfig) => { // 请求头携带token const userInfoStore = useUserInfoStore(); if (userInfoStore.token) { (config.headers as AxiosRequestHeaders).token = userInfoStore.token; } return config; } ); ``` 注意:这里直接给请求头添加 token 信息的时候,`config.header.token` 会飘红,飘红是因为类型问题,需要给 `config.header` 设置类型,这个类型不知道,怎么办? 鼠标放至参数 `condig` 上可以看到 `config` 类型,将 `config` 的类型引入进来,给参数 `config` 设置上类型,再使用 `ctrl + 鼠标左键` 点击这个类型,在 `config` 的类型声明文件中可以找到 `configheader` 的类型,引入 `config.header` 的类型,加上断言即可 **修改 store 中获取用户信息 actions - `src/store/userinfo.ts`** ```js // import type { UserInfoState } from './interface'; interface userinfoModel { // 这个类型是 userinfo 接口返回数据类型,存到state中 avatar: string, name: string, buttons: string[], roles: string[], routes: string[] } interface UserInfoState { // 这个类型是 state 的类型,之前是引入外部的 token: string, userinfo: userinfoModel, menuRoutes: RouteRecordRaw[] } const initUserinfo = (): userinfoModel => ({ avatar: '', name: '', buttons: [], roles: [], routes: [] }) state: (): UserInfoState => ({ token: getToken() as string, // userinfo 有了关于当前用户的所有信息,那么初始化的时候,也应该初始化成存储的结构 userinfo: initUserinfo(), menuRoutes: [] }), actions: { async getInfo () { try { let result = await userinfoApi.info(); // 获取个人用户信息 console.log('获取到的个人信息', result); this.userinfo = result; // 将返回的个人用户信息存储起来(飘红原因,因为这里不知道result的类型) // 用于侧边栏展示的,这里先写死,最终需要通过个人信息返回的权限信息进行修改 this.menuRoutes = staticRoutes } catch (error) { ElMessage.error('获取个人用户信息失败,请重试') return Promise.reject(error); // 错误信息继续往外抛 } }, ... } ``` **当修改了 store 中 userinfo 存储的位置之后,页面中只要用到用户信息的地方都需要修改** 1. 在路由守卫中,跳转页面的时候,当token存在的时候,此时获取用户信息需要判断,这里需要修改用户信息的获取 ```js // const hasLogin = !!userInfoStore.name // 错误的,获取方式需要改变 const hasLogin = !!userInfoStore.userinfo.name ``` 2. 首页中展示用户信息的地方需要修改 ```js // Hello, {{userInfoStore.name}} // 错误的,获取方式需要改变 Hello, {{userInfoStore.userinfo.name}} ``` 3. 在 `NavBar.vue` 组件中展示了用户名和头像,这两个地方也需要修改 ```js // 错误的 // userInfoStore.name // userInfoStore.avatar 正确的 userInfoStore.userinfo.name userInfoStore.userinfo.avatar ``` 4. 清除store用户信息也需要修改 - 这里还没有做到,做到之后再修改 获取完用户信息可以成功的跳转到 `/home` ### 退出登录 在 Navbar 组件上有 "退出登录" 按钮,当点击这个按钮的时候,要调用 actions 中的 退出登录方法(这个方法没有,需要自己写),actions 中的 退出登录的方法需要调用退出登录的接口,当接口调用成功的时候,此时要清除掉 token(localStorage 和 state) 和 用户信息 准备 api 函数 ```js logout() { return request.post(`/admin/acl/index/logout`) } ``` userinfoStore ```js actions: { async logout () { try { let result = await userinfoApi.logout(); this.reset(); // 调用actions中的reset函数,清除token和用户信息 } catch (error) { ElMessage.error('退出登录失败,请重试') return Promise.reject(error); } }, reset () { // 删除local中保存的token removeToken() // 清除store中的token和用户信息 this.token = '' // 初始化用户信息 this.userinfo = initUserinfo(); // -- 这就是初始化 userinfo的时候为什么要写成函数 }, } ``` NavBar 组件 ```js const logout = async () => { // await userInfoStore.reset(); await userInfoStore.logout(); // 需要调用接口退出登录 router.push(`/login?redirect=${route.fullPath}`); // 跳转登录页 } ``` 目前为止整个登录的流程结束了,但是我们没有加 TS 的类型限制,来限制我们的数据,那么接下来就给我们发请求拿到的数据加 TS 类型 > 这里加 TS 类型限制,第一次理解整个过程,后续的操作将变成统一的套路 ## 加 TS 类型限制 目前飘红的为止有哪些? 1. userinfoStore 中 actions 里 login 返回返回的数据 token 赋值的时候飘红 2. userinfoStore 中 actions 里 getInfo 中,返回的个人信息 赋值给 state 的时候飘红 3. 其实还有一个地方需要加类型限制,logout 接口返回数据也要加 TS 类型限制 > 总结: > > 只要调用接口的地方都应该去加TS类型限制,限制接口返回的数据类型,让我们在使用数据赋值的时候,不再飘红 在哪加TS类型限制?加什么TS类型限制? 在 api 文件中,调用接口的位置 加TS类型限制 ```js request.post<这里写TS类型>(`/admin/acl/index/login) ``` 当调用 request 调用 post 方法的时候,返回的是一个 promise 对象,这个promise对象会在数据回来之后,得到数据,`ctrl + 鼠标左键` 点进 post 方法看一下,它的参数类型 ![](assets/02.Post请求类型.png) 结论:尖括号中的 R 这个泛型是什么类型,我们返回值 Promise 出来的类型就是什么类型 当调用 post 函数的时候,我们使用了 `await` 等待 post 这个方法的返回值(本质上是在等 post 中的 promise 出结果),接口返回的数据就相当于是 Promise 返回的结果,这个类型可以在 调用post 函数的时候确定下来,怎么写? ```js interface TokenModel { token: string } login(data: loginModel) { return request.post('xxxxxxx', data); } ``` 总结: 只要在 api 文件中调用接口方法的时候(get、post、delete...),给这些方法加上第二个类型,我们 await 返回的数据就具有了这个类型,这个类型一般通过我们查看接口返回的数据结构来定义,尖括号中的第一个类型仅仅用来占位,目的是为了写第二个参数 `src/api/userinfo.ts` 文件 ```js export interface userinfoModel { // 这个类型是 userinfo 接口返回数据类型,存到state中 avatar: string, name: string, buttons: string[], roles: string[], routes: string[] } interface tokenModel { token: string } interface loginModel { username: string, password: string } export default { login(data: loginModel) { return request.post(`/admin/acl/index/login`, data) }, // 获取用户信息的时候,通过请求头携带token获取用户信息 info() { return request.get(`/admin/acl/index/info`) }, // 退出登录没有参数,使用请求头携带的token退出,让当前 token 失效 logout() { return request.post(`/admin/acl/index/logout`) } } ``` ### 关于 axios 加TS类型 #### 返回的数据说明 接下的内容知道即可,不需要深究: ![](assets/03.axios返回数据差异.png) 后台项目和前台项目返回数据在控制台打印的时候不一样,后台项目做了什么事情? 看axios的二次封装中的响应拦截器 ![](assets/04.png) 调用接口返回数据说明: 响应体中的内容就是我们接口返回的数据,但是在控制台答应的数据不是响应体数据,响应体的数据有 message,code ,data... 等数据,而真正在 store 中调用接口 await 返回的数据是 `响应体.data` 这个数据,相当于数据多脱了一层(相较于前台项目而言) #### 关于TS ![](assets/05.1.png) 同一张图 ![](assets/05.2.png) 结论: 在 store 中调用接口返回的数据有类型是两个地方作用的:1. request.post() 方法调用的时候,限制了 promise 结果的数据格式 2. 在响应拦截器中我们返回的数据 是 `response.data.data`(response是响应报文,response.data 是响应体),这个返回的数据才是我们在 store 中真正拿到的数据 总结 - 套路: 以后在写 api 函数的时候,看一下接口返回的数据(可以使用 swagger 或者 看生产环境接口返回数据),直接定义好类型即可 # day03 ### 侧边栏的展示 侧边栏在哪渲染?侧边栏的数据从哪来? 侧边栏的渲染是 `SideBar` 组件 ![](assets/06.png) 侧边栏的数据从 `userInfoStore.menuRoutes` 来,这个值在哪赋值的?在 `userInfoStore` 的 `actions` 下的 `getInfo()` 方法赋值的,目前写死了,写的是 `staticRoutes` ![](assets/07.png) 现在想让侧边栏显示商品管理的所有侧边栏,怎么办? 1. 我们的 token 存在 store 和 localStorage 中,当刷新页面的时候,store中的数据没有了,刷新页面的时候,此时 token 从 localStorage 中取出来放到store中 2. 刷新页面一定经过路由守卫,此时 store 中是有token,但是没有用户信息,调用接口获取用户信息 得到用户信息之后,目前把 `staticRoutes` 赋值给 `userinfoStore.menuRoutes`,目前是写死的 3. 只要我们修改 staticRoutes, 侧边栏就应该展示了 ![](assets/08.png) #### 修改 staticRoutes 模仿首页的路由渲染来写我们当前的路由(说白了就是抄,抄的时候稍微改改) ```js { path: '/product', component: () => import('@/layout/index.vue'), meta: { title: '商品管理', icon: 'ele-GoodsFilled', // icon相当于拿到这个字符串进行拆分解析了 }, children: [ { path: 'trademark/list', name: 'Trademark', // 必须和我写的一样,按下不表,作用后面说 component: () => import('@/views/product/trademark/index.vue'), meta: { title: '品牌管理' } }, { path: 'attr/list', name: 'Attr', component: () => import('@/views/product/attr/index.vue'), meta: { title: '平台属性管理' } }, { path: 'spu/list', name: 'Spu', component: () => import('@/views/product/spu/index.vue'), meta: { title: 'SPU管理' } }, { path: 'sku/list', name: 'Sku', component: () => import('@/views/product/sku/index.vue'), meta: { title: 'SKU管理' } }, ] }, ``` #### 关于侧边栏展示问题 首页是二级路由,怎么会展示在一级路由的位置呢? 结论:当二级路由中只有一个路由的时候,此时这个二级路由会被当成一级路由去展示 为什么会发生这种现象?需要查看 `SideBar` 是如何渲染侧边栏的 ![](assets/09.png) ![](assets/10.png) 分析 SideBar 组件展示 --- 了解即可 ![](assets/11.png) 再往后走,都是套路 ## 品牌管理 后台管理项目就是在做 增、删、改、查 1. 静态页面搭建 2. 初始化数据展示 查看列表展示数据 3. 交互 新增 修改 删除 ### 静态页面搭建 静态页面搭建使用的是 element plus 中的组件,把页面中相关的组件拿过用即可 ```html ``` ### 初始化数据展示 页面 `` 展示的数据是一个数组,数组中放的对象会在表格展示,数组直接绑定到 `` 上,通过 `:data=""` 页面中获取数据需要发请求调用接口来拿数据,需要查看接口文档(也可以看线上已完成的项目来拿接口),先准备好 api 函数,准备好之后,在页面初始化的时候调用接口,拿到数据绑定到 `` 上进行展示 **api函数 - `src/api/trademark.ts`** ```js import request from '@/utils/request' // 从接口文档中粘贴过来 // 删除BaseTrademark DELETE /admin/product/baseTrademark/remove/{id} // 新增BaseTrademark POST /admin/product/baseTrademark/save // 修改BaseTrademark PUT /admin/product/baseTrademark/update // 分页列表 GET /admin/product/baseTrademark/{page}/{limit} export interface TrademarkModel { id: number, tmName: string, logoUrl: string } export type TrademarkList = TrademarkModel[] export interface TrademarkPageModel { current: number, pages: number, records: TrademarkList, // 一会写 searchCount: boolean, size: number, total: number } export default { // 查询分页 getPage(page: number, limit: number) { return request.get(`/admin/product/baseTrademark/${ page }/${ limit }`) }, } ``` **组件中展示** ```html // table展示的数据 const trademarkList = ref([]) // 获取分页列表数据 const getPage = async () => { try { let result = await trademarkApi.getPage(page.value, limit.value) console.log('品牌分页的数据', result); trademarkList.value = result.records; total.value = result.total; // 分页器需要有总条数才能算出总页码 } catch (error) { ElMessage.error('获取品牌分页数据失败,请重试') } } // 挂载页面的时候初始化数据 onMounted(() => { getPage(); }) ``` ### 交互 #### 新增 ##### 展示dialog 1. 给"添加"按钮绑定点击事件,当点击这个按钮的时候,需要弹出弹框 ```js 添加 const dialogVisible = ref(false); // 控制dialog的显示和隐藏的 // 展示新增弹框 const showDialog = () => { dialogVisible.value = true; } ``` 2. 弹框使用 `el-dialog` 组件,通过一个布尔值控制弹框的显示和隐藏 ```js 随便写点内容 ``` 3. 弹框中要展示 form 表单 ```html const handleAvatarSuccess: UploadProps['onSuccess'] = ( response, uploadFile ) => { const imageUrl = URL.createObjectURL(uploadFile.raw!) } // 上传之前的回调,要进行拦截,不符合要求的图片拦截住 const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => { if (rawFile.type !== 'image/jpeg') { ElMessage.error('图片必须是jpg格式') return false } else if (rawFile.size / 1024 / 1024 > 2) { ElMessage.error('图片大小不能超过2MB!') return false } return true } ``` form 表单自己写,upload组件可以粘贴,然后将样式进行微调,调整成想要的效果 ##### 收集dialog弹框表单数据 收集表单中的数据 品牌名称 和 品牌LOGO(图片的url) ,当点击保存的时候,需要调用接口进行保存 1. 创建一个 数据进行收集 收集 品牌名称的时候,使用 v-model 直接收集 input 输入的内容即可 收集 品牌LOGO(图片的url) 数据,需要注意上传流程 2. 上传流程 点击"upload"组件,会弹出一个选择文件的对话框,然后选择图片,选择图片之后点击确认会将图片上传到服务器上,此时服务器会返回一个图片的 url ,把这个url 收集起来即可 > 说白了,其实就是先把图片上传成功之后,获取到图片的url再收集起来 ```js // 上传的url // 开发环境应该用开发的url `/app-dev/admin/product/upload` // 生产环境应该用生产的url `/app-prod/admin/product/upload` // 前缀需要改变 使用 import.meta.env.VITE_API_URL // 注意: // 这个上传的url是没有走 axios 的二次封装的,所以需要我们手动拼接前缀 // 这个url也走了代理了 const uploadAction = `${ import.meta.env.VITE_API_URL }/admin/product/upload` // 创建一个表单收集的数据 - 收集创建一个品牌的数据 const tmForm = ref({ tmName: '', logoUrl: '' }) // 图片上传的回调 // 上传图片成功的回调 const handleAvatarSuccess: UploadProps['onSuccess'] = ( response, uploadFile ) => { tmForm.value.logoUrl = response.data; } ``` > 注意: > > 上传的url > 开发环境应该用开发的url `/app-dev/admin/product/upload` > 生产环境应该用生产的url `/app-prod/admin/product/upload` > 前缀需要改变 使用 import.meta.env.VITE_API_URL > 注意: > 这个上传的url是没有走 axios 的二次封装的,所以需要我们手动拼接前缀 > 这个url也走了代理了 > const uploadAction = `${ import.meta.env.VITE_API_URL }/admin/product/upload` ##### 点击保存 点击保存按钮的时候,需要调用 api 接口进行保存,调用保存接口成功之后,关闭弹出,给个提示,重新发请求刷新页面数据 **准备api函数** ```js save(data: TrademarkModel) { return request.post(`/admin/product/baseTrademark/save`, data) } ``` **组件** ```js // 保存的回调,需要调用接口,隐藏弹框,重新请求数据 const onSave = async () => { try { await trademarkApi.save(tmForm.value); ElMessage.success('保存成功'); // 给个提示 cancelSave(); // 隐藏弹框 重置表单数据 getPage(); // 刷新页面数据 } catch (error) { ElMessage.error('保存失败,请重试') } } // 取消保存的回调 const cancelSave = () => { dialogVisible.value = false; // 隐藏弹框 tmForm.value = { tmName: '', logoUrl: '' } // 重置表单数据 } ``` #### 翻页 当保存完数据之后,查看保存的数据需要翻页,翻页怎么做? 组装数据 - 组装 页码 和 每页条数的数据 发送请求 - 重新发请求获取数据 # day04 #### 修改 修改的时候,点击编辑,弹出弹框,弹框就是新增时候的弹框,但是内容不一样 * 标题 - 新增的时候是 `添加品牌` 修改的时候是 `修改品牌` * 回显数据 - 编辑的时候,需要把当前的这一条数据,在 `form` 表单中回显出来 * 点击保存调用接口 - 点击保存的时候调用的接口不一样 步骤: api 准备 ```js update(data: TrademarkModel) { return request.put(`/admin/product/baseTrademark/update`, data) }, ``` 1. 点击"编辑"按钮,获取到当前点击的数据,讲数据回显(当前的数据放到 tmForm 当中即可),且展示弹框 ```js 编辑 const handleEdit = (row: TrademarkModel, index: number) => { // 回显数据 // 回显数据的时候,拿着表格中的数据进行回显的,给的是一个地址,是表格中数据对象的地址 // 此时tmForm这个ref对象,地址和表格中的对象的地址一样,所以一旦数据有变动,表格展示就会改变 // 如何解决? // 使用深拷贝拷贝一份独立的数据就解决了 tmForm.value = cloneDeep(row); // 问题出现在这一行 // 展示弹框 dialogVisible.value = true; } ``` 2. 修改标题,修改展示的标题 3. 点击保存的时候,调用的api不一样,书写api,在保存的回调中进行判断 如果 tmForm 有id,代表是编辑,走编辑的接口 如果 tmForm 没有id,代表是新增,走新增的接口 ```js const onSave = async () => { try { if (tmForm.value.id) { // 编辑保存 await trademarkApi.update(tmForm.value); } else { // 新增保存 await trademarkApi.save(tmForm.value); } } ........ } ``` > 注意: > > 编辑回显数据的时候,需要讲数据进行**深拷贝** #### 删除 在项目中只要遇到删除,都需要做 `double confirm` 双重确认,一定要让用户双重确认之后,才能删除这个数据 > 这条规则用于删除的时候会修改数据库的数据的时候 > > 当不是直接调用接口修改数据库数据的时候,`double confirm` 双重确认可加可不加 做法: 点击删除按钮,弹出一个提示框,提示用户即将删除这条数据,当用户点击`确认` 的时候,此时才要调用接口删除数据 步骤: 1. 给"删除"按钮绑定点击事件,点击"删除"按钮,弹出弹框(MessageBox) 2. 当点击"确认",此时才要调用接口,删除数据 api 准备 ```js delete(id: number) { return request.delete(`/admin/product/baseTrademark/remove/${ id }`) } ``` 组件 ```js const handleDelete = (row: TrademarkModel, index: number) => { ElMessageBox.confirm( `确认需要删除[${ row.tmName }]吗?`, '警告', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', } ) .then(async () => { // 当点击确认的时候,会走.then方法,点击取消的时候会走.catch方法 try { await trademarkApi.delete(row.id as number) ElMessage.success('删除成功') getPage(); } catch (error) { ElMessage.error('删除失败,请重试') } }) .catch(() => {}) } ``` 按理来说删除已经写完了,但是有问题 当页面中只有一条数据的时候,此时删除了这条数据,重新获取数据,应该没有这一页的数据,应该查看前一页的数据 这里 element plus 给我们做了优化,当获取到的数数据没有这一页的数据的时候,它自动的重新走了一次请求,element 怎么做的 ? 当删除页面中最后一条数据之后,删除成功之后立马重新发请求,获取到新的 total 值,这个值在分页组件内部计算出来了总页码,此时当前页码比总页码小,element plus 自动触发了翻页的函数,然后重新发请求,拿了前一页的数据 #### bug: 编辑弹框直接关闭表单的数据没有清除 解决: 给 `dialog` 组件绑定 close 事件,走取消按钮的回调即可 ### 表单校验 通过看官网 element plus 表单校验得到的信息: ```html 将数据给了form表单*** :rules="rules" ------------> 设置校验规则*** ... > prop 设置校验数据中的哪一个字段*** // 表单校验 const rules = reactive({ tmName: [ { required: true, message: '请输入品牌名称', trigger: 'blur' }, { min: 2, max: 10, message: '品牌名称最少2个字符最多10个字符', trigger: 'blur' }, ], logoUrl: [ { required: true, message: '请输入品牌LOGO', trigger: 'change'}, ], }) ``` 以上三个值(:model、:rules、prop)都,只是添加了校验规则,和触发校验规则的方式 并没有在保存的回调当中对数据进行校验,保存的时候也需要校验,如果校验不通过,那么不能保存 ![](assets/12.png) 保存之前校验需要获取到当前 `el-form` 组件实例,通过组件实例调用校验的方法 `formEl.validate` 和 `formEl.resetFields` 这两个方法可以对 `表单校验` 和 `表单校验重置 ` ```js const ruleFormRef = ref() // 拿到组件实例 // 保存的回调,需要调用接口,隐藏弹框,重新请求数据 const onSave = async (formEl: FormInstance | undefined) => { if (!formEl) return formEl.validate(async (valid, fields) => { // valid 布尔值,代表校验成功或失败,true代表成功 // fields 校验的字段 if (valid) { 之前调用接口方法 } }) } // 取消保存的回调 const cancelSave = (formEl: FormInstance | undefined) => { if (!formEl) return formEl.resetFields(); // 重置表单校验 之前代码不动 } ``` 步骤总结: 1. 先把 form 表单的 model、rules 和 form-item 的 prop 配置上,此时就已经有了校验效果了 2. 获取表单实例,在保存之前需要触发一次校验规则,集体做法如代码所示 取消按钮中需要有 重置表单校验 > 注意:取消保存按钮回调中的参数传递 ### 总结: 套路: 1. 静态页面搭建 先不要考虑数据,把样子先写出来(除了分页,分页可以直接把数据和回调粘过来) 2. 初始化数据展示 查询 - 列表展示 先看先测api,准备好api,在写api函数的时候,直接把类型搞定,同时把TS类型 `export` 在页面中,初始化页面的时候,发请求,拿数据,展示即可 3. 交互 把页面中的所有功能先列出来(在真实的工作中看原型图,产品开会会说),根据功能来实现页面,我们这里直接模仿线上已完成项目的功能 1. 新增 2. 编辑 新增和编辑往往用的一套模板,只是在数据传递的过程中,编辑有id,保存没有id(只要数据在数据库中存储过就会有id ---> 唯一标识) 做新增和编辑的时候,有的时候就需要直接考虑到编辑可能出现的情况(作为新手而言,可以先写新增,再写编辑,如果是一个熟练工的话,可以新增编辑一块考虑) 写代码的时候也是先准备好api(同时把TS的类型加上),为什么永远是先准备api,我们前端页面的操作都是在改最终的数据,准备api就是知道调用后端提供的接口的时候,知道需要传的参数,需要接的参数 > 题外话: > > 前端是用户和公司之间的第一接触位置,界面要漂亮,交互要丝滑,要让用户的体验好 > > 同时数据才是公司最重要的东西,保证数据不能出错 > > 一般做后台管理项目都是 toB 的,界面和交互优先级没有数据高 > > 如果项目是 toC 的,界面和交互的优先级 与数据同样高 再接着根据需要的数据,在页面中收集起来发送即可,收集数据的过程就是写页面交互的过程 > 注意: > > 编辑回显数据的时候需要深拷贝 > > 收集表单数据的时候,一般可能会做表单校验,我们需要使用 element plus 中 form 提供的表单校验来做 3. 删除 删除只要改变数据库的数据,就是要 `double confirm` 双重确认,不直接改数据库数据的可以加可以不加 先主备api,再调用,通过页面触发事件来调用函数,函数中调用接口 ### 拓展: 深拷贝 - 看public下的代码 ## 平台属性管理 分析页面,发现页面由两大块组成,上面是三级分类,下面是主体内容,把这两块拆开来做 * 三级分类 三级分类单独当成一个组件来做,封装成一个组件,项目中没有其他地方使用 pinia ,这个组件封装的时候使用一下 pinia,练习一下 1. 静态界面展示 2. 准备 api 函数,准备 actions,初始化 一级分类的数据 3. 交互 1. 当一级分类选中内容之后,触发 actions 发请求拿二级分类的数据 2. 当二级分类选择内容之后,触发 actions 发请求拿三级分类的数据 > 注意: > > 一级分类变化,需要重置 二级分类 和 三级分类 相关数据 > > 二级分类变化,需要重置 三级分类 相关数据 * 主体内容 1. 静态页面搭建 2. 初始化数据展示 查询 - 展示列表,准备api(添加TS类型)页面上当由三级分类数据的时候,调用api函数,获得数据进行展示 3. 交互 * 新增 准备 保存 api 函数 当点击新增的时候,切换界面,收集页面数据,调用api接口,进行保存,保存之后刷新界面展示数据,切换回主界面 * 编辑 准备 保存 api 函数 点击编辑切换界面,回显数据,进行展示,当再次点击保存的时候,调用保存接口,保存之后刷新界面展示数据,切换回主界面 * 删除 准备 api 函数 双重确认,确认删除之后,调用 api 函数,删除成功之后,刷新页面数据,切换回主界面 ### 三级分类 - 公用组件 #### 静态也界面搭建 * 定义 创建 `src/components/CategorySelector/index.vue` 搭建界面 ```html ``` * 注册 全局注册 - main.ts ```js // 全局注册三级分类组件 import CategorySelector from '@/components/CategorySelector/index.vue' app.component('CategorySelector', CategorySelector); ``` * 使用 在平台属性管理界面直接使用即可 #### 获取数据的准备工作 src/api/category.ts ```js import request from '@/utils/request' // getCategory1 GET /admin/product/getCategory1 // getCategory2 GET /admin/product/getCategory2/{category1Id} // getCategory3 GET /admin/product/getCategory3/{category2Id} export interface CategoryModel { id: number, name: string, category1Id?: number, category2Id?: number } export default { getCategory1List() { return request.get(`/admin/product/getCategory1`) }, getCategory2List(category1Id: number) { return request.get(`/admin/product/getCategory2/${category1Id}`) }, getCategory3List(category2Id: number) { return request.get(`/admin/product/getCategory3/${category2Id}`) } } ``` src/store/category.ts ```js import { defineStore } from 'pinia' import categoryApi, { type CategoryModel } from '@/api/category' import { ElMessage } from 'element-plus' // 限定State的类型 interface StateModel { category1List: CategoryModel[], category2List: CategoryModel[], category3List: CategoryModel[], } // 初始化state的函数 const initState = (): StateModel => ({ category1List: [], category2List: [], category3List: [], }) const useCategoryStore = defineStore('category', { state() { return initState(); }, actions: { async getCategory1List() { try { let result = await categoryApi.getCategory1List() this.category1List = result; } catch (error) { ElMessage.error('获取一级分类数据失败') } }, async getCategory2List() { try { let category1Id = -1; // 写个假的,一会改 let result = await categoryApi.getCategory2List(category1Id); this.category2List = result; } catch (error) { ElMessage.error('获取二级分类数据失败'); } }, async getCategory3List() { try { let category2Id = -1; // 写个假的,一会改 let result = await categoryApi.getCategory3List(category2Id); this.category3List = result; } catch (error) { ElMessage.error('获取三级分类数据失败'); } }, }, getters: {} }) export default useCategoryStore ``` #### 交互 1. 默认展示组件的时候,需要调用 actions 获取一级分类的数据 在组件的 `onMounted` 钩子函数中触发获取一级分类数据的 actions 2. 当选择一级分类数据的时候,此时 v-model 应该收集到数据,可以通过 `` 的 change 事件,来调用获取二级分类的数据 3. 当选择二级分类数据的时候,此时 v-model 应该收集到数据,可以通过 `` 的 change 事件,来调用获取三级分类的数据 4. 当选择三级分类数据的时候,页面的主体内容应该展示(后面说) > 注意问题: > > 每次当一级分类数据发生变化的时候,都需要重置 二级分类拉下 和 三级分类下拉 和 二级分类id 和 三级分类id,然后重新获取二级分类下拉数据 > > 每次当二级分类数据发生变化的时候,都需要重置 三级分类下拉 和 三级分类id,然后重新获取三级分类下拉数据 # day05 ### 主体内容 #### 静态页面搭建 主体内容静态搭建的时候,发现有两个状态,列表展示状态 和 新增编辑状态,需要分开用 v-if 或 v-show 进行切换展示,先搭架子,后续加上交互 ```html ``` #### 初始化数据展示 api 准备,api准备好之后,页面要调用,什么情况下调用?三级分类的数据中第三级的数据存在之后,应该发请求调用接口,拿到数据存储到变量中,在页面 `el-table` 进行展示即可 1. 准备api ```js import request from '@/utils/request' // 平台属性列表attrInfoList GET /admin/product/attrInfoList/{category1Id}/{category2Id}/{category3Id} // 删除平台属性deleteAttr DELETE /admin/product/deleteAttr/{attrId} // 保存平台属性saveAttrInfo POST /admin/product/saveAttrInfo interface AttrValueModel { id: number, valueName: string, attrId: number, } interface AttrModel { id: number, attrName: string, categoryId: number, categoryLevel: number, attrValueList: AttrValueModel[], // 属性值列表 } export default { attrInfoList(category1Id: number, category2Id: number, category3Id: number) { return request.get(`/admin/product/attrInfoList/${category1Id}/${category2Id}/${category3Id}`) } } ``` 2. 使用 watch 监听,监听三级分类的数据,当第三级id有了的时候,发请求,拿数据展示 只要第三级id没有,需要重置页面的数据 ```js // 列表展示数据 const attrList = ref([]) // 初始化数据的请求 const getList = async () => { try { const { category1Id, category2Id, category3Id } = categoryStore; let result = await attrApi.attrInfoList(category1Id as number, category2Id as number, category3Id as number); attrList.value = result; } catch (error) { ElMessage.error('获取列表数据失败,请重试'); } } // 只要三级id有,那么就去获取数据 watch(() => categoryStore.category3Id, (nval) => { if (nval) { getList() } else { // 重置页面数据 } }, { immediate: true }) ``` 3. 在页面中展示请求回来的数据 ```html ``` > 注意: > > el-tabl 组件只有表格帮我们展示数据的时候,需要给列加 prop 属性 > > 我们自己展示数据的时候,不需要加 prop 属性 > > 列的 prop 属性就是告诉列组件你要展示数据中的哪个字段 #### 交互 ##### 新增 新增、编辑界面,当点击"添加属性"按钮的时候,切换界面展示,展示出编辑的界面 ```js 添加属性 const addAttr = () => { isEdit.value = true } ``` 搭建静态,这里没有初始化数据展示,只有交互,收集界面数据,点击保存调用接口 步骤: 1. 搭建静态 - 在编辑展示的界面中搭建静态 ```html
添加属性值 取消
保存 取消
``` 2. 新增界面没有初始化数据展示 - 准备保存调用的api函数 点击"保存"按钮要调用接口,准备 api 函数(看看接口文档或线上),加上TS,准备好 api 函数就知道了我们收集页面数据需要收集哪些,然后根据需要收集的数据去页面上写交互 ![](assets/13.png) 了解到数据的来源和结构之后,开始写 api 函数 ```js saveAttr(data: AttrModel) { return request.post(`/admin/product/saveAttrInfo`, data) } ``` > 注意: > > TS中所有涉及到 id 的都是可选的类型参数,加问号: 属性id 和 属性值id都加问号 3. 保存 * 创建一个收集新增界面的数据,并且初始化这个数据(加TS) * 属性名 `attrName` 使用 v-model 收集即可,属性值列表需要点击添加按钮来添加 1. 点击"添加属性值"按钮的时候,`attrForm.attrValueList` 要添加一条数据 2. 同时这个数据需要在 el-table 中进行展示,展示的时候展示的是 `attrForm.attrValueList` 这个数组中的对象,每个对象中的 `valueName`,需要给 `el-table-column` 加 `prop` 属性 > 注意:在写第一个小步骤的时候,添加的属性值先写死,目前先死 `valueName: '后期修改'`,关于这里的交互后续再说 * 点击"保存"按钮,在保存按钮的回调中,收集到 categoryId 这个数据,这个数据就是 store 中的 category3Id 然后调用接口保存即可,保存成功需要做的事: 给个提示、展示主列表页面、重置表单数据、刷新主列表数据 * "取消"按钮,需要 展示主列表页面、重置表单数据 ```js // 初始化 attrForm const initAttrForm = (): AttrModel => ({ attrName: '', attrValueList: [], categoryId: undefined, // 点击保存按钮执行回调的时候再去赋这个值 categoryLevel: 3 // 写死 }) // 准备好即将要收集的数据 const attrForm = ref(initAttrForm()); // 添加属性值 const addAttrVal = () => { attrForm.value.attrValueList.push({ valueName: '随便写内容,后期回来再改' // 目前随便写一个值,不要为空即可,后期回来在改 }) console.log(attrForm.value) } // 保存 const onSave = async () => { // 收集categoryId的值 - 组装数据 attrForm.value.categoryId = categoryStore.category3Id; // 调用接口 try { await attrApi.saveAttr(attrForm.value) ElMessage.success('保存成功') // 给个提示 // isEdit.value = false; // 展示列表页 // attrForm.value = initAttrForm() // 重置表单数据 cancelSave() // 取消的回调 getList(); // 刷新主列表的数据 } catch (error) { ElMessage.error('保存失败,请重试') } } // 取消保存 const cancelSave = () => { isEdit.value = false; // 切换主界面显示 attrForm.value = initAttrForm(); // 重置表单数据 } ``` #### 增加限制条件 1. 当选择完三级分类的时候,页面中才会有数据,只要第三级的 categoryId 没有值,那么此时页面应该不展示内容 ```js watch(() => categoryStore.category3Id, (nval) => { if (nval) { getList() } else { attrList.value = []; // 1. 第一个限制条件 } }, { immediate: true }) ``` 2. 列表界面"添加属性"按钮必须要有 第三级的 categoryId ,如果没有禁止点击 ```html 禁用状态是限制条件 添加属性 ``` 3. 当进入新增页面的时候,此时 `CategorySelector` 组件应该事禁用状态 CategorySelector 组件 ```js defineProps<{ disabled: boolean }>() ``` 平台属性组件 ```html ``` > 只要在编辑页面就是禁用 4. 新增界面的"添加属性值"按钮默认是禁用状态,当属性名有内容的时候,这个按钮才能点击 ```html 添加属性值 ``` > 属性名没有就禁用(禁用就是true) 5. 新增界面,保存按钮,当有属性名和属性值列表不为空的时候才可以点击 ```html 保存 ``` ##### 编辑 在主列表页面,点击"编辑"按钮(绑定点击事件),切换界面到编辑界面,同时回显数据 1. 点击"编辑"回调 ```js const editAttr = (row: AttrModel) => { isEdit.value = true; // 切换编辑界面 attrForm.value = cloneDeep(row); // 回显数据 } ``` 2. 在编辑界面,新增的时候我们没有做删除属性值的操作,现在做上 给编辑界面的"删除"属性值的按钮绑定点击事件,写删除的回调 (这里没有直接修改数据库的数据,所以不加双重校验) ```js const deleteAttrVal = (index: number) => { // attrForm.value.attrValueList 属性值列表删除数据 attrForm.value.attrValueList.splice(index, 1); // 删除一个属性值 } ``` 注意: 编辑逻辑的保存和新增时候的保存用的同一套逻辑,接口并没有发生变化,只要回显了数据,点击保存即可 ##### 删除 主列表点击"删除"按钮,要双重确认,这里删除是要调用接口的 准备api ```js deleteAttr(attrId: number) { return request.delete(`/admin/product/deleteAttr/${ attrId }`) } ``` 组件 - 书写删除的回调 ```js // 删除平台属性 const deleteAttr = async (row: AttrModel) => { try { await attrApi.deleteAttr(row.id as number) ElMessage.success('删除成功') getList(); // 重新获取数据进行展示 } catch (error) { ElMessage.error('删除失败,请重试') } } ``` ##### 编辑界面 - 属性值 input 切换 注意: 1. 表格属性值列切换input框 和 自动聚焦是两码事 2. 切换input 不能全局声明一个变量来控制 input 的切换,因为一个变量无法知道是哪一个切换成了 input 给表格中每一条数据绑定一个布尔值,用每一个数据的布尔值来控制 input 的切换 `inputVisible:true` 3. 自动聚焦 在页面中的时候,始终保持只有一个 input 框在展示,当失焦的时候,应该切换成div状态,当点击div或者新增数据(属性值)的时候,此时 `inputVisible:true` 进行展示,自动聚焦 自动聚焦:获取 input 实例调用 聚焦 方法即可 只保证页面中有一个 input 在展示的时候,只需要一个变量就可以拿到这个input的实例 **切换input** 给每一个表格中的数据加一个属性(布尔值)来切换 input 显示 ![](assets/14.png) ```js const addAttrVal = () => { attrForm.value.attrValueList.push({ valueName: '随便写内容,后期回来再改', // 目前随便写一个值,不要为空即可,后期回来在改 inputVisible: true // 用这个数据来控制当前这个对象在表格中切换input的显示状态 }) } 同时需要在TS类型中加上 inputVisible 这个值 - src/api/attr.ts 文件 export interface AttrValueModel { ...... // 这个数据只在前端交互使用,后端不用,传给后端不会解析,不影响结果 inputVisible?: boolean } ``` **聚焦和失焦** 1. 点击div,此时给div绑定点击事件,首先切换input的显示状态,让input显示,然后获取到input的组件实例,调用聚焦的方法进行聚焦 ```js const inputRef = ref(); // 拿当前input的实例 // input聚焦事件 const inputFocus = (row: AttrValueModel) => { row.inputVisible = true; // 切换input的展示 // 只能等展示出来input才能聚焦,否则获取不到组件实例 nextTick(() => { inputRef.value?.focus() // 拿到实例进行聚焦 }) } ``` > nextTick: > > Vue中DOM的更新是异步的,当数据发生变化的时候,此时DOM还没有更新 > 需要等待DOM更新完毕之后,才能获取到DOM元素 > nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。 2. 添加属性值的时候也是一样的,新增的属性值默认 `inputVisible: true`,此时如果想要自动聚焦需要等待DOM的更新完毕,再获取组件实例,获取到组件实例之后,调用聚焦方法进行自动聚焦 ```js // 添加属性值回调 const addAttrVal = () => { attrForm.value.attrValueList.push({ valueName: '', // 目前随便写一个值,不要为空即可,后期回来在改 inputVisible: true // 用这个数据来控制当前这个对象在表格中切换input的显示状态 }) nextTick(() => { inputRef.value?.focus(); // 等待DOM更新,拿到组件实例进行自动聚焦 }) } ``` 3. 失焦 失焦的时候,需要切换成 div 展示 `inputVisible: false`,通过代码的逻辑来控制页面中始终只有一个input是展示状态,那么 inputRef 这个变量永远获取的就是这个展示中 input 的实例 失焦还需要做的事情,就是对输入内容的校验 1. 输入的内容不能为空,为空的话,这条数据不能存在,需要删除 2. 输入的内容不能重复,重复的话这条数据也需要删除 ```js input绑定失焦事件 const inputBlur = (row: AttrValueModel, index: number) => { row.inputVisible = false // 判断输入的内容不能为空 if (!row.valueName.trim()) { attrForm.value.attrValueList.splice(index, 1); // 删除掉这条空数据 ElMessage.error('输入的内容不能为空') return } // 判断输入的内容不能重复 // attrForm.value.attrValueList 属性值列表 // row 当前属性值 // 在属性值列表中,一定有当前自己的这个属性值(row),判断的时候,需要把自己在数组中的位置排除掉之后,再进行判断有没有相同的 const isRepeat = attrForm.value.attrValueList.some((item, idx) => { if (index == idx) { // 把自己排除掉 return false } else { return item.valueName == row.valueName } }) if (isRepeat) { attrForm.value.attrValueList.splice(index, 1); ElMessage.error('输入的内容重复,请重新输入') return } } ``` > #### 拓展: vue3 和 vue2 响应式原理使用差异 > > 在 input 切换的过程中,新增一个属性值的时候,同时新增了一个 inputVisible,这是新增,如果是编辑呢? > > 编辑的时候,回显数据中,每一个属性值是没有 `inputVisible` 的,相当于初始化 attrForm 的时候就没有 `inputVisible ` 这个值,那么在点击 div 进行 input 切换的时候,为什么可以直接切换? > > 因为在点击 div 的时候,直接给当前的 row(也就是当前的属性值数据) 直接添加上了 `inputVisible` 这个属性,为什么这个数据具有响应式? > > 这就是 vue3 的好处,这里的数据是给一个 ref 对象赋值的,而这个数据在赋值的时候会交给 reactive 处理,reactive 处理是将原生对象变成一个代理对象,代理对象使用的是 new Proxy() > > ```js > new Proxy(obj, { > get(target, key) { > return Reflect.get(target, key) > }, > set(target, key, val) { > return Reflect.set(target, key, val) > } > }) > ``` > > proxy 是给整个对象设置的拦截,只要是这个对象中的属性发生变化,不管是删除还是新增属性都可以拦截到,所以直接 给对象添加 `inputVisible` 是可以拦截到的 > > ---- > > 如果 这是vue2 项目的话,就不能直接添加数据,因为Vue2使用的是 Object.defineProperty() 数据劫持做的响应式,数据劫持是对 data 配置项中数据的所有属性进行递归遍历,把每一个属性都走了一遍 Object.defineProperty() > > 此时如果直接给vue2中的数据对象添加属性没有走 Object.defineProperty() 数据劫持 ,所以不会有响应式,需要使用 $set 来设置,让添加的属性走一遍 Object.defineProperty() 数据劫持 具有响应式才可以 ## SPU管理 什么是SPU?什么是SKU? SPU一般指商品的品名,SKU一般指具体的某一个商品 ```js 举例说明: 去手机店,和店员的场景 顾客: 给我来一部 iphone 14 ----- 这里的iphone14可以理解为SPU 店员: 您是要 iphone14呢,还是iphone14 Plus,还是iphone14 pro,还是iphone14 pro max ? ----- 这里的 iphone14、phone14 Plus、iphone14 pro、iphone14 pro max ----- 这4款商品可以理解为 SKU ``` **分析页面** 分为上下两打块 上面是三级分类模块 - 三级分类模块使用已经写好的三级分类组件即可 下面是主体内容 1. 整个主体内容有三个界面,Spu列表展示(主列表展示)、新增Spu界面(和编辑Spu界面用的一套)、新增Sku界面 搭建整体页面结构,分三个模块去 2. SpuList 主界面展示 * 静态页面搭建 * 初始化数据展示 - 展示列表 - 分页 * 交互 新增Spu === 单独作为一个模块去做 编辑Spu === 单独作为一个模块去做 新增Sku === 单独作为一个模块去做 弹框查看Sku列表 删除Spu 3. 新增 Spu 界面 === 静态页面搭建 没有初始化数据展示 书写api,查看需要传递的参数 和 接口返回的数据,加TS类型,从而知道收集页面的什么数据,进行页面交互收集数据 点击保存进行数据更新,切换回主列表页 4. 编辑 Spu 界面 回显数据,点击保存进行数据更新,切换回主列表页 5. 新增 Sku 界面 静态页面的搭建 初始化数据展示没有 书写api,查看需要传递的参数 和 接口返回的数据,加TS类型,从而知道收集页面的什么数据,进行页面交互收集数据 6. Sku列表的弹窗展示 7. 删除Spu ### Spu页面架子搭建 再 spu 下面创建三个组件 SpuList、SpuForm、SkuForm,这三个组件不会同时显示,需要根据参数(showStatus)来进行展示,同时这三个组件创建之后,需要写按钮模拟切换界面 ```js ``` # day06 SPU 页面的架子第二版 ```js ``` 这里使用了 v-model,子组件接收事件要做处理 - 所有的事件变为 `update:modelValue` ```js const emit = defineEmits<{ (e: 'update:modelValue', status: number): void }>() ``` ### SpuList 主列表 1. 静态页面搭建 可以安装一个vscode的插件来提升静态搭建页面的效率 ![](assets/15.png) > tips: > > 关于 element plus 组件上的选项,需要了解的组件有 : table 和 form 表单 > > 这两个组件下去把所有的配置项通读一遍即可 ```html
添加SPU
``` 2. 初始化数据展示 准备 api - 加TS ```js export interface SpuModel { id: number, spuName: string, description: string, category3Id: number, tmId: number, spuSaleAttrList: undefined, spuImageList: undefined, } export interface SpuPageModel { records: SpuModel[], total: number, size: number, current: number, searchCount: boolean, pages: number, } export default { getPage(page: number, limit: number, category3Id: number) { return request.get(`/admin/product/${page}/${limit}?category3Id=${ category3Id }`) } } ``` 页面调用 api 获取数据 ```js const spuList = ref([]) const getPage = async () => { try { let result = await spuApi.getPage(page.value, limit.value, categoryStore.category3Id as number); spuList.value = result.records; } catch (error) { ElMessage.error('获取Spu列表数据失败,请重试') } } watch(() => categoryStore.category3Id, (nval) => { if (nval) { getPage(); } else { // 重置表格数据 spuList.value = []; } }) ``` table 表格展示 ```html ... ``` 3. 交互 新增spu交互,给按钮绑定点击事件切换界面即可,跳转到SpuForm ```html 添加SPU ``` ### SpuForm #### 静态页面搭建 先将所有的界面搭建完毕之后,把图片上传空下,当所有的样式没有问题了之后,再返回来去 element plus 官网去粘贴"照片墙"中的相关内容,哪里飘红就粘那块 ```html ``` #### 初始化数据展示 当打开新增页面的时候,发送了两个请求,获取品牌下拉 和 销售属性下拉的数据 步骤: 1. 准备 api - 准备这两个下拉的 api 函数 `src/api/trademark.ts` - 品牌下拉api准备 ```js // 获取所有的品牌数据 getTrademarkList() { return request.get(`/admin/product/baseTrademark/getTrademarkList`) } ``` `src/api/spu.ts` - 销售属性下拉api准备 ```js interface SaleAttrModel { id: number, name: string } ..... getSaleAttrList() { return request.get(`/admin/product/baseSaleAttrList`) } ``` 2. 页面调用接口,获取数据,存储起来 在 SpuForm 页面的 onMounted 中获取数据 ```js // 品牌下拉数据 const tmList = ref([]) const getTrademarkData = async () => { try { let result = await trademarkApi.getTrademarkList(); tmList.value = result; } catch (error) { ElMessage.error('获取品牌下拉数据失败,请重试') } } // 销售属性下拉数据 const baseSaleAttrList = ref([]) const getSaleAttrData = async () => { try { let result = await spuApi.getSaleAttrList(); baseSaleAttrList.value = result; } catch (error) { ElMessage.error('获取销售属性下拉失败,请重试') } } const initData = () => { getTrademarkData(); getSaleAttrData(); } onMounted(() => { initData(); }) ``` 3. 页面展示 - 展示两个下拉 ```html 品牌下拉 销售属性下拉 ``` #### 交互 - 收集页面数据,调用保存api接口 ##### api 准备 看一下保存时候调用接口,需要传给后端的数据有哪些,准备好 保存api之后,等待页面调用 ![](assets/16.png) `src/api/spu.ts` ```js export interface SpuImageModel { imgName: string, // 图片的名称 imgUrl: string, // 图片的url } // 销售属性值 export interface SpuSaleAttrValueModel { baseSaleAttrId: number, saleAttrValueName: string // 输入的销售属性值 } // 销售属性 export interface SpuSaleAttrModel { baseSaleAttrId: number, saleAttrName: string, spuSaleAttrValueList: SpuSaleAttrValueModel[] // 销售属性值列表 } export interface SpuModel { id?: number, // 新增保存的时候,没有id spuName: string, description: string, category3Id: number, tmId: number, spuSaleAttrList: SpuSaleAttrModel[], // 销售属性列表 spuImageList: SpuImageModel[], // 图片列表 } // 保存spu saveSpu(data: SpuModel) { return request.post(`/admin/product/saveSpuInfo`, data) } ``` ##### 收集数据 收集数据分为三部分去写: 收集普通数据(spu名称、spu描述等... 可以使用 v-model 直接收集到的数据),收集图片列表数据,收集销售属性数据 * 收集普通数据 在页面中 创建一个 `spuForm` 【初始化】,这个类型就是 `SpuModel` ,把可以直接收集的数据使用 v-model 直接收集即可 ```js // 当前页面必须有 category3Id 才能打开,【后续】要给主列表界面的"添加SPU"按钮加限制条件 const initSpuForm = (): SpuModel => ({ spuName: '', description: '', category3Id: categoryStore.category3Id as number, // 这里可以直接收集,收集store当中的 catefory3id,当然也可以在保存之前收集 tmId: undefined, spuSaleAttrList: [], spuImageList: [] }) // spuForm 收集页面数据 const spuForm = ref( initSpuForm() ) ``` * 收集图片列表数据 图片的数据收集单独使用一个变量来收集 `spuImageList`,这个类型应该是我们自己设置的图片的类型,但是和上传成功之后的图片列表类型不通,那么此时需要让TS把类型匹配对,那么在赋值的时候,将类型转成any接口(以后开发中,是在不知道类型该怎么办的时候,可以将类型转成any进行赋值) 注意: * **图片列表在页面中展示,数组中存的对象里面 必须有 name 和 url 这两个数据,否则无法显示图片** * 我们收集的数据类型是 SpuImageModel ,它里面的属性是 imgName 和 imgUrl,页面中交互收集数据的时候并没有这两个数据,**【后续】在保存的时候,对图片的数据再次进行组装**,组装成包含 imgName 和 imgUrl 的数据,然后放到 spuForm 中即可(因为 spuForm 要传给后端的) ```js Preview Image // 图片上传相关数据和回调 const spuImageList = ref([]) // 单独收集图片的数据 const dialogImageUrl = ref('') // 预览图片的url const dialogVisible = ref(false) // 预览图片展示 dialog 组件的控制变量 // 上传成功的回调 const handlerSuccess: UploadProps['onSuccess'] = (response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) => { // 等号左右两边的类型不同的时候,将等号右侧数据的类型 断言成一个 any,就可以让TS通过类型了 - 这里可以理解为让TS强行通过类型 spuImageList.value = uploadFiles as any; } // 删除图片的回调 const handleRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => { spuImageList.value = uploadFiles as any; } // 预览图片的回调 - 这个预览图片的回调不用动,里面都是写好的,直接用即可 const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => { dialogImageUrl.value = uploadFile.url! dialogVisible.value = true } ``` > 拓展: > > 关于TS中感叹号使用的场景 > > ```js > let a: number = 25; > let b: number | undefined; > // 这里!的意思: > // 联合类型中有 number 和 undefine 两个类型 > // 这里将这个联合类型赋值给一个具体的类型number的时候,会飘红 > // 因为不确定这个联合类型是不是undefined > // 加上!之后,会忽略掉这个类型检测,让 number | undefined 这个联合类型可以直接赋值给 number 类型 > a = b!; > ``` * 收集销售属性 收集销售属性分三块来做:1. 当选中销售属性的时候,此时点击"添加销售属性"按钮,此时表格中应该多了一条数据,下拉框应该少了一条数据 2. 表格中"属性值列表"的数据收集 3. 删除表格中数据 1. 添加销售属性 * 添加销售属性的时候即需要销售属性的id,又需要销售属性的name,我们这里使用一个全局变量 `saleIdName` 来收集这个数据,下拉的选项值设置为 ```js ....... ``` 此时,只要下拉选中,收集到的数据就是 `"销售属性id:销售属性name"`,这样就可以收集到 id 和 name 了 * 点击"添加销售属性",绑定点击事件,在点击事件当中给表格添加一条数据,表格展示 `spuForm.spuSaleAttrList` 数据 ```js 添加销售属性 ... ... const addSaleAttr = () => { // console.log(saleIdName.value) // ---> 'saleId:nameId' let [id, name] = saleIdName.value.split(":"); // 解构出来 // 给表格添加数据 spuForm.value.spuSaleAttrList.push({ baseSaleAttrId: Number(id), saleAttrName: name, spuSaleAttrValueList: [] }) saleIdName.value = '' // 收集销售下拉的数据变成空串-重置 } ``` 添加到表格中之后,需要变得地方有:1. 下拉绑定得数据没有值(重置下拉收集数据得变量) 2. 表格中已存在得数据在下拉中是没有得 * 过滤销售属性下拉得数据,使用计算属性来写 之前使用得从接口拿过来得数据直接展示的,现在用计算属性,当表格中有下拉里面得数据得时候,此时重新计算,让下拉中没有这个数据 ```js const saleAttrList = computed(() => { let tableSaleIdArr = spuForm.value.spuSaleAttrList.map(item => item.baseSaleAttrId); return baseSaleAttrList.value.filter(item => !tableSaleIdArr.includes(item.id) ); }) ``` 2. 添加销售属性值 ”销售属性值“列的交互 element plus 有现成的,直接粘贴过来改一改就能用,但是数据呢?这里我们展示的数据是谁? **表格展示数据** --> `spuForm.spuSaleAttrList` 销售属性列表 每一行数据是一个 `row`,相当于 `spuForm.spuSaleAttrList` 销售属性列表里面的一个成员(对象) "销售属性值"这一列展示的数据是 `row.spuSaleAttrValueList` 是销售属性值列表 思路: 首先把 element plus 中 tag 组件 "动态编辑标签" 的代码粘贴过来,粘贴到"销售值属性列" ```html ``` 把"销售值属性列"拆成三部分去看: 1. 循环展示的 tag 是销售属性值列表,每一个 tag 绑定了 closs 事件,closs 事件就是删除当前的销售属性值 ```js // 删除属性值 const handleClose = (index: number, spuSaleAttrValueList: SpuSaleAttrValueModel[]) => { spuSaleAttrValueList.splice(index, 1); // 删除属性值列表中下标为index位置的成员 } ``` 2. **row 是表格展示一行的数据,row是销售属性** 给 row 添加两个数据,`row.inputVisible` 用来切换 button 和 input 的展示,`row.inputValue` 这个值用来收集 input 框输入的内容,注意:添加这两个值得时候需要在TS中也添加 默认是没有 `row.inputVisible` 这个值,是 undefined,此时展示 button 当点击button按钮的时候,此时给 `row.inputVisible` 设置为 true,那么此时就展示成了 input 当 input 失焦或者点击回车的时候,此时将 `row.inputVisible` 设置为 false,那么此时展示成button ```js // input框回车确认的回调,需要把input切换成div展示 const handleInputConfirm = (row: SpuSaleAttrModel) => { row.inputVisible = false; // 切换button展示 // 当失焦或者点击回车的时候,此时需要获取到input框输入的值,添加到前面循环的tag中 // 而 tag 循环的数据是"销售属性值"列表,也就是 row.spuSaleAttrValueList row.spuSaleAttrValueList.push({ baseSaleAttrId: row.baseSaleAttrId, // 销售属性值的id就 是 销售属性的id saleAttrValueName: row.inputValue! // row.inputValue 是input的v-model绑定的值,输入的值 }) // 当讲输入的数据添加完成的时候,此时需要把 row.inputValue 清空 row.inputValue = "" } // 点击"新增"按钮的回调,需要把div切换成 input 展示 const showInput = (row: SpuSaleAttrModel) => { row.inputVisible = true; // 切换input展示 } ``` > 注意: > > 当由 input 切换成 button 的时候,此时需要获取到 input 中的值,添加到 `row.spuSaleAttrValueList` 销售属性值列表中 > > 当然这里需要判断 空 和 重复 这两个条件 > > 输入的内容为空时,不允许添加一个空的值 > > 当输入的内容重复是,不允许添加重复属性值 > > **增加校验明天说,还有"添加销售属性" 按钮也需要加校验** 3. 自动聚焦 - 明天说 > 1208 小灶课 > > 小灶课目的: 回顾之前的知识,不讲新内容 > > * 拿到项目要做的事 > * 登录-首页结构 > * **路由守卫-讲解** > * 未登录-登录流程 > * TS类型添加-api文件准备 > * 书写页面套路-例子:品牌管理-举一反三 > * 昨天Spu新增梳理 > * 疑难解答 > > ### 一、拿到项目要做的事 > > 1. 公司给你的项目应该是一个半成品,不可能是一个什么都没有,让你从零到一搭建 > > 2. 把项目先从远端克隆到本地,安装依赖 > > 安装依赖需要node环境,如果是公司配的电脑自己安装node > > 如果是自己的电脑,直接 `npm i` 安装依赖即可 > > > 注意: > > > > 在安装依赖的时候,有的时候会报各种问题这个时候需要解决,从几个点入手 > > > > 1. 安装依赖的时候,如果 npm 安装不成功,使用 cnpm 安装(或者使用流量或者科学上网)尝试看看能不能安装成功 > > > > 2. 如果还是安装不好,百度查询 npm 报错信息,大多数错误在百度上都有解决方案 > > > > 3. 如果还是安装不好,或者安装成功项目启动不起来,尝试低版本node > > > > 一个版本一个版本去降(node只有双数版本12、14、16、18...) > > > > 4. 按照以上流程基本上可以解决 98% 问题 > > > > 有可能公司内部有自己的npm源(相当于公司自己写了一个包,引用在前端项目中,但是这个包没有上传到公网,只能在公司内网才能下载下来) > > 3. 跑项目的时候,看一眼`package.json`,这个文件中有启动项目的指令 `script` 选项中配置有 > > 从这个文件中可以得到启动项目的指令,也可以看到项目的依赖(例如: vue2 还是 vue3 可以在依赖中看到),有了启动项目指令之后,启动项目 > > ![](assets/17.png) > > 4. 当启动了项目之后,查看页面大概有哪些功能 > > 找一个页面(功能),然后从 main.js 开始看,一点一点往下推,直到找到这个页面组件渲染的位置即可,这样可以大致了解公司中项目的文件结构(包括路由、store、全局注册项等...) > > 找到这个页面之后,研究这个页面的功能,研究功能的意义在于熟悉公司的代码的风格,和了解业务逻辑,然后就可以照着这个面开始抄 > > > 注意: > > > > 一般情况下刚入职之后,会给几天熟悉代码的时间,然后不会直接给你页面去做,会给一个bug让你修改,给bug的目的在于让你熟悉项目结构和代码风格,当改上几个bug之后,会给你一个简单页面去做,时间可能会稍微放宽一点(因为刚入职不熟悉项目),当写了几个页面之后,就开正式上手写一些比较核心逻辑(当然项目中关于钱和最核心的逻辑不会给你写) > > > > 当然也有二般情况,项目比较急,来了就派活,写去吧,那就需要更短的时间熟悉页面,熬夜加班写页面 > > > 二、登录-首页结构 > > 在拿到项目之后,查看页面看布局分布,布局是左侧、右侧顶部导航、右侧下面主体内容 > > 从 main.ts 一步一步找,找到 App.vue 知道了所有的页面都是使用 router-view 渲染出来的,那应该去找路由、找到路由之后,发现一级路由和二级路由,我可以知道一级路由是 App.vue 根组件中的router-view 去渲染,但是二级路由不知道在哪渲染,但是可以知道的是,二级路由一定在以及路由组件中渲染的,查看一级路由组件 `Layout` ,在这个组件中找 router-view 因为 这里面的 router-view 将渲染二级路由,在找的过程当中了解到 `Layout` 页面的结构 左侧是 `SideBar` 、顶部导航是 `NavBar`,主体内容是 `AppMain`,在 AppMain 中找到了router-view,知道了二级路由渲染在哪 > > 三、路由守卫 > > 什么时候会用到路由守卫? > > 登录之后获取个人信息(获取个人信息的时候会做权限)的时候会用到守卫 > > 注意:只要路由发生变化,就会走路由守卫 > > 路由守卫中做了什么事情? > > 在项目中有些页面是可以不登录就能访问的,有些页面必须登录之后才能访问,对于页面访问的限制就是在路由守卫中做的 > > 1. 页面跳转的时候,守卫当中判断有没有 token(有token就代表登录),如果没有token代表没有登录,此时判断去的页面是不是可以在不登录的情况下访问(访问的页面是不是在白名单),如果是那么直接放行进入这个页面,如果不是 `next` 跳转至登录页让用户进行登录 > > 2. 守卫中如果有token,代表登录,那么此时判断用户信息是否存在,如存在,直接放行,如果不存在,调用 store 中的 actions ,actions 调用获取用户信息接口,来获取用户信息(获取用户信息需要在请求头当中携带token去获取),等待调用接口的结束,如果可以获取到用户信息,直接放行,如果获取不到用户信息,那么要么是网络错误,要么是token过期 > > 获取不到用户信息,那么清除token,使用 `next` 跳转至登录页,让用户重新登录获取token > > 四、登录 - 登录流程 > > 从根组件 App.vue 中只有所有的页面都是一级路由渲染的,通过路由的设置知道 login 页面也是一级路由渲染的,说明登录页在 App.vue 这个组件中渲染的 > > 找到 login 的组件,开始一步一步走登录流程 > > 1. 点击"登录"按钮,开始登录流程,调用store中的actions,actions中发请求获取 token,当获取到token的时候,此时将 token 存储到 store 中 和 localStorage 中 > > 存到 store 中相当于数据放在内存中,内存中存取数据比较块,这就是为什么放到store中 > > 存到 localStorage ,目的是为了刷新页面的时候,不丢失token,刷新页面重新将 localStorage 中的token放到store中 > > 注意:token是获取用户信息的标识,没有token什么事都干不了 > > 2. 存储完token,回到"登录"按钮的回调,然后进行页面跳转,在跳转的过程中要走 路由守卫 > > 走路由守卫要获取用户信息(获取用户信息的时候鉴权) > > 注意:在此之前需要把 token 放到请求拦截器的请求头当中 > > 3. 此时跳转页面,走路由守卫,这个时候已经有token了,但是没有用户信息,在路由守卫中调用获取用户信息的 actions,来获取用户信息,在这个actions中会做用户的权限 > > 4. 当获取到用户信息的之后,直接放心,此时就出了守卫了,跳转到目标页面了 > > 登录的流程完事之后,还有退出登录的流程 > > 1. 退出登录需要调用接口,告诉后端当前请求头携带的 token 需要失效,后端把这个token设置为失效 > 2. 请求调用成功之后,前端但凡存储 token 的地方都需要 清除token(清除 store 和 localStorage 中token) > > 五、TS类型添加-api文件准备 > > 项目当中添加TS的意义是什么? > > 添加TS的意义在于,给所有使用的数据加类型,让代码更加规范 > > 加了TS之后,别人在调用你的方法,或者你在调用别人写的方法的时候,直接根据TS的参数类型就知道传什么参数,并且可以知道这个函数的返回值是什么,这样我们就不需要去研究这个函数中具体写了哪些代码,只需要知道入参(函数参数)和出参(函数返回值)即可,看一下出参是不是我们当前写的业务想要的数据,如果是那么此时看入参,准备好这个入参就可以得到我们的结果 > > 我们自己在项目中怎么加的TS? > > 给函数参数加类型,给页面使用的数据加类型,给接口返回的数加类型 > > **在给接口返回数据加TS类型的时候,这个最难加TS类型,我们使用的 `ctrl + 鼠标左键` 点击 axios 的 post 方法,进入TS类型文件中,得到在使用 post 方法的时候,可以设置泛型的类型** > > > 问题:什么地方加TS什么地方不加TS? > > > > 解答:按理来说,应该所有的数据都加TS,但是我们实际使用的过程中,一般不飘红都不加,之哟啊飘红了就加TS类型 > > > > **但是在写api函数的时候都应该加TS类型,如果不加会在拿到返回数据赋值的时候飘红** > > 六、书写页面套路-例子:品牌管理-举一反三 > > 在后天管理项目中其实是有固定套路的,所有的页面都在增、删、改、查这4个功能,写页面的时候固定上步骤,按步骤写问题不到 > > 1. 静态页面搭建 > > 定义、注册、使用 > > > 注意: > > > > 定义组件的时候,都是.vue定义的 > > > > 注册组件的时候,普通组件和路由组件注册的位置不一样 > > > > ​ 路由组件在路由中注册 > > > > ​ 普通组件在组件内部注册或者全局注册 > > > > 使用,普通组件直接在页面中去写就行 > > > > ​ 路由组件使用 router-view 渲染 > > > > 注意: > > > > ​ 对于新手而言,搭建界面的时候别考虑数据,步骤越细越能做出来 > > 2. 初始化数据展示 > > 一般情况下是主列表页的分页展示,这个就是增删改查中的查 > > 步骤: > > 1. 准备api > > 准备api的意义在于,知道后端返回的数据有什么(知道页面中展示什么内容),需要传递的参数是什么,同时加上TS类型 > > 2. 组件内调用 api函数获取数据 > > 页面初始化,或者某一个事件(或者条件)触发回调,在回调中调用 api 函数,获取数据,拿到数据之后再页面中展示即可 > > 3. 交互 > > 增、删、改最终页面所有的交互都可以用这三个字来说明,写交互的时候也有步骤 > > 1. 先理解了交互的逻辑再去写(我们目前是看线上已完成的项目来理解交互逻辑,真实的场景中是产品会告诉交互的逻辑,如果交互的逻辑有不清晰的地方需要问产品) > > 这个步骤的目的是自己了解自己要做什么东西 > > 2. 准备 api > > 知道交互之后调用接口是哪一个,传给后端的数据是什么,得到后端返回的数据是什么,同时加上TS的类型限制 > > 3. 在页面中的 事件回调(触发机制)中组装数据,调用接口 > > 七、昨天Spu新增梳理 > > 看销售属性交互发生的事情: > > 1. 选择销售属性下拉框内容,发生的变化,下拉框被选中到值 > > 2. 点击"添加销售按钮",发生的变化 > > 表格里面多了一条数据 > > 下拉框中少了一条数据 > > SpuForm.vue - 123 行到 149 行 > > ------ > > 3. 点击"属性值名称列表"中的"新增"按钮 > > 列里面显示的button由按钮变成了 input 框 > > 同时这个input框聚焦了 > > 4. input框输入内容,发生的变化,input框中有值了 > > 5. 当鼠标失焦的时候,发生的变化 ---- (昨天做到这个位置) > > input框变成了button 按钮 > > 刚刚在 input 中输入的内容,变成了一个 `el-tag` 展示 > > ------ > > 6. 点击表格中"删除"按钮,表格中的数据删除一行 > # day07 把"销售值属性列"拆成三部分去看: 1. 循环展示的 tag 是销售属性值列表,每一个 tag 绑定了 closs 事件,closs 事件就是删除当前的销售属性值 ```js // 删除属性值 const handleClose = (index: number, spuSaleAttrValueList: SpuSaleAttrValueModel[]) => { spuSaleAttrValueList.splice(index, 1); // 删除属性值列表中下标为index位置的成员 } ``` 2. **row 是表格展示一行的数据,row是销售属性** 给 row 添加两个数据,`row.inputVisible` 用来切换 button 和 input 的展示,`row.inputValue` 这个值用来收集 input 框输入的内容,注意:添加这两个值得时候需要在TS中也添加 默认是没有 `row.inputVisible` 这个值,是 undefined,此时展示 button 当点击button按钮的时候,此时给 `row.inputVisible` 设置为 true,那么此时就展示成了 input 当 input 失焦或者点击回车的时候,此时将 `row.inputVisible` 设置为 false,那么此时展示成button ```js // input框回车确认的回调,需要把input切换成div展示 const handleInputConfirm = (row: SpuSaleAttrModel) => { row.inputVisible = false; // 切换button展示 // 当失焦或者点击回车的时候,此时需要获取到input框输入的值,添加到前面循环的tag中 // 而 tag 循环的数据是"销售属性值"列表,也就是 row.spuSaleAttrValueList row.spuSaleAttrValueList.push({ baseSaleAttrId: row.baseSaleAttrId, // 销售属性值的id就 是 销售属性的id saleAttrValueName: row.inputValue! // row.inputValue 是input的v-model绑定的值,输入的值 }) // 当讲输入的数据添加完成的时候,此时需要把 row.inputValue 清空 row.inputValue = "" } // 点击"新增"按钮的回调,需要把div切换成 input 展示 const showInput = (row: SpuSaleAttrModel) => { row.inputVisible = true; // 切换input展示 } ``` > 注意: > > 当由 input 切换成 button 的时候,此时需要获取到 input 中的值,添加到 `row.spuSaleAttrValueList` 销售属性值列表中 > > 当然这里需要判断 空 和 重复 这两个条件 > > 输入的内容为空时,不允许添加一个空的值 > > 当输入的内容重复是,不允许添加重复属性值 > > **增加校验明天说,还有"添加销售属性" 按钮也需要加校验** 这里当input失焦得时候,会添加一个属性,当input回车得时候,会添加一个正常属性值,还会添加一个空属性值,为什么? 当点击回车得时候,此时 input 中是有值得,然后在事件得回调中,把 input 切换成了 button按钮(`inputVisible: false`),同时把 input 中得内容添加到了销售属性值的数组中,把 `inputvalue` 清空 此时发生了自动失焦,因为页面中没有 input 元素了,再次触发了一次回调,这里进来之后,`inputValue` 得值在回车得时候已经清除过了,所以是空值,填到销售属性值列表中得时候,也就成了空值 ```js const handleInputConfirm = (row: SpuSaleAttrModel) => { row.inputVisible = false; // 切换button展示 // 校验空值,如果为空,直接return,不添加到销售属性值列表 if (!row.inputValue?.trim()) { return } // 重复校验 if ( row.spuSaleAttrValueList.map(item => item.saleAttrValueName).includes(row.inputValue) ) { ElMessage.error('输入的属性值重复,请重新输入') row.inputValue = "" // 清空一下刚刚输入的值 return } .... 将收集的input添加到销售属性值列表 ``` "添加销售属性" 按钮,加校验 现在点击"添加销售属性",可以添加空值,需要判断,如果销售属性下拉没有收集到数据,那么点击应该是无效的 ```js const addSaleAttr = () => { if (!saleIdName.value) { // 加上校验 return } ......... ``` 3. 自动聚焦 点击"新增"按钮,显示 input ,等待DOM更新完毕之后,获取到这个input,进行聚焦 使用 nextTick 等待DOM更新 此时页面中永远只有一个 input ```js const InputRef = ref() // 点击"新增"按钮的回调,需要把div切换成 input 展示 const showInput = (row: SpuSaleAttrModel) => { row.inputVisible = true; // 切换input展示 // 等待DOM更新完毕之后,获取到input实例,进行聚焦即可 nextTick(() => { InputRef.value?.focus() }) } ``` **表格销售属性删除** 这里表格展示的是 销售属性 `spuForm.spuSaleAttrList` ,点击删除按钮的时候,传递下标 ($index) ,直接删除即可,这里没有直接调用接口修改数据库数据,可以直接删除 销售属性下拉的数据,是计算属性的出来的,根据表格中是否存在这个数据计算出来,现在表格中没有这个数据,此时计算属性计算的下拉就会多一个数据 ```js // 在表格删除销售属性 const deleteSaleAttr = (index: number) => { spuForm.value.spuSaleAttrList.splice(index, 1); } ``` ##### 保存、取消 新增页面的所有功能搞定了,该"保存"和"取消"两个功能了 保存要组装数据,调用保存接口,给提示保存成功,切换主列表界面,重新获取主列表的数据 取消切换主列表界面,..... ```js const onSave = async () => { // 组装数据 - 组装保存的时候需要的数据 spuForm.value.category3Id = categoryStore.category3Id!; let spuImageListTemp = spuImageList.value.map(item => {//全局spuImageList不符合调用接口格式 return { imgName: item.name, imgUrl: item.response.data } }) spuForm.value.spuImageList = spuImageListTemp as any; // 中转一下数据 // 把销售属性的 inputVisible 和 inputValue 删除掉(不删也可以,但是接口中就会带着个数据了,带上这俩数据不影响后端,影响自己看数据) spuForm.value.spuSaleAttrList.forEach(row => { delete row.inputValue delete row.inputVisible }) // 做个简单校验 let { category3Id, spuName, tmId, description, spuImageList: sIList, spuSaleAttrList } = spuForm.value; if ( !(category3Id && spuName && tmId && description && sIList.length && spuSaleAttrList.length) ) { ElMessage.error('SPU名称、品牌、三级分类、描述、图片列表、销售属性数据可能为空,请检查输入') return } try { await spuApi.saveSpu(spuForm.value); ElMessage.success('保存成功') cancelSave(); // 切换主列表 // 刷新列表需要在 SpuList 组件中的 watch 监听 加 immediate } catch (error) { ElMessage.error('保存失败,请重试') } } const cancelSave = () => { emit('update:modelValue', STATUS.SPULIST); // 切换页面 } ``` `spuList 组件` ```js watch(() => categoryStore.category3Id, (nval) => { ......... }, { immediate: true }) // 加 { immediate: true } 页面渲染获取数据 ``` ##### 新增按钮的限制条件 spuList.vue ```js 添加SPU ``` #### 编辑SPU - 交互 在主列表界面点击一行SPU数据中的"编辑"按钮,进入到编辑页面(SpuFrom),回显数据 这里点击表格的一行数据是 row,是一条 spu 数据,需要把当前组件(SpuList组件)中的这个 row(也就是这个spu数据)传递给 编辑界面(spuForm组件),用来回显数据 展示编辑页面回显数据的时候,同时需要发请求去拿一下 `spuImageList` 和 `spuSaleAttrList` 的数据,做回显,主列表中展示的 spu 数据没有这两个数据 这里做的事两件事: 1. 回显基本数据 2. 回显图片列表和销售属性(初始化数据展示) ##### 回显基本数据 将 当前主列表的数据(SpuList 中的编辑的数据 row),传给父组件,然后父组件再传给子组件编辑页面(SpuForm组件) 1. 先把SpuList中点击编辑的数据 row, 通过自定义事件传给父组件,让父组件存储一下 2. 在父组件中把刚刚存储的 spu 数据通过 props 传给 spuForm 子组件 具体操作: 1. 先把SpuList中点击编辑的数据 row, 通过自定义事件传给父组件,让父组件存储一下 spuList组件 ```js const emit = defineEmits<{ ...... (e: 'changeSpuInfo', spu: SpuModel): void }>() const editSpu = (row: SpuModel) => { emit('update:modelValue', STATUS.SPUFORM); // 切换界面 ------------------- emit('changeSpuInfo', row); } ``` 父组件 ```js // 当前页面必须有 category3Id 才能打开,【后续】要给主列表界面的"添加SPU"按钮加限制条件 const initSpuInfo = (): SpuModel => ({ spuName: '', description: '', category3Id: undefined, // 这个地方飘红,需要修改TS类型 tmId: undefined, spuSaleAttrList: [], spuImageList: [] }) const spuInfo = ref( initSpuInfo() ); const changeSpuInfo = (row: SpuModel) => { spuInfo.value = row; } ``` src/api/spu.ts ```js export interface SpuModel { ... category3Id: number | undefined, ... } ``` 2. 在父组件中把刚刚存储的 spu 数据通过 props 传给 spuForm 子组件 父组件 ```js ``` SpuForm子组件 ```js const props = defineProps<{ modelValue: number, spuInfo: SpuModel ---- 接收父组件传过来的数据 }>() ..... const initData = () => { getTrademarkData(); getSaleAttrData(); if (props.spuInfo.id) { // 编辑的时候是有id的 spuForm.value = cloneDeep(props.spuInfo); // 回显数据 // 图片列表和销售属性需要发请求拿回来 } } ``` 此时页面中就可以展示出基本数据的回显了 ![](assets/18.png) 注意: 在编辑回显数据的时候,此时编辑的 spu 数据,是主列表中展示 row,这个 `row.spuSaleAttrList` 是null,这个数据从父组件传给 SpuForm 的时候,此时在SpuForm 中给 spuForm 这个变量赋值了,那么会走 销售列表下拉的计算数据,此时 `row.spuSaleAttrList.map` 相当于 null 调用了 map,所以报错,需要在计算属性中做一个兼容 ```js const saleAttrList = computed(() => { // 得到表格中销售属性id的数组 --> [1, 3] if (!spuForm.value.spuSaleAttrList) { return [] } ... } ``` ##### 回显图片列表和销售属性数据 回显这两个数据需要发请求,调接口,拿数据,具体步骤:先去看api,把 api 函数准备好之后,在 spuForm 初始化数据的地方调用既可以了 `src/api/spu.ts` ```ts 回显数据携带的数据多了,类型也需要改变 export interface SpuImageModel { id?: number, // 回显数据携带的 spuId?: number, // 回显数据携带的 imgName: string, // 图片的名称 imgUrl: string, // 图片的url // ------------------------ name?: string, // upload组件展示使用,必须有name和url两个参数才能展示图片 url?: string, // upload组件展示使用 response?: any // 这个用在前端存储图片交互上 } // 销售属性值 export interface SpuSaleAttrValueModel { id?: number, // 回显数据时存在 spuId?: number, // 回显数据时存在 saleAttrName?: string, // 回显数据时存在 isChecked?: null | boolean, // 回显数据时存在 baseSaleAttrId: number, saleAttrValueName: string // 输入的销售属性值 } // 销售属性 export interface SpuSaleAttrModel { id?: number, // 回显数据存在 spuId?: number, // 回显数据存在 baseSaleAttrId: number, saleAttrName: string, spuSaleAttrValueList: SpuSaleAttrValueModel[], // 销售属性值列表 inputVisible?: boolean, // 用于控制销售属性这一行数据中 input 的切换 inputValue?: string // 用户收集当前输入的属性值,收集好之后在添加到属性值列表中 } getSpuImageListBySpuId(spuId: number) { return request.get(`/admin/product/spuImageList/${spuId}`) }, // 根据spuId获取销售属性列表 getSpuSaleAttrListBySpuId(spuId: number) { return request.get(`/admin/product/spuSaleAttrList/${spuId}`) } ``` 页面组件初始化调用接口 ```js // 编辑回显根据spuId获取图片列表 const getSpuImageListBySpuId = async () => { try { let result = await spuApi.getSpuImageListBySpuId(props.spuInfo.id as number); // 图片列表的数据是用了一个单独数据收集的,回显的时候也需要用这个单独的数据 spuImageList.value = result.map(item => { return { ...item, name: item.imgName, // upload组件展示必须要有name和url属性 url: item.imgUrl } }); } catch (error) { ElMessage.error('获取图片列表失败,请重试') } } // 编辑回显根据spuId获取销售属性列表 const getSaleAttrListBySpuId = async () => { try { let result = await spuApi.getSpuSaleAttrListBySpuId(props.spuInfo.id!) spuForm.value.spuSaleAttrList = result; } catch (error) { ElMessage.error('获取销售属性列表失败,请重试') } } const initData = () => { getTrademarkData(); getSaleAttrData(); if (props.spuInfo.id) { // 编辑的时候是有id的 spuForm.value = cloneDeep(props.spuInfo); // 回显数据 // 图片列表和销售属性需要发请求拿回来 getSpuImageListBySpuId(); getSaleAttrListBySpuId(); } } ``` ##### 编辑保存 编辑保存的时候,调用的接口和新增保存到的接口不是同一个,需要去准备 更新数据的 api 接口,看传递的参数和返回值 准备api ```js updateSpu(spu: SpuModel) { return request.post(`/admin/product/updateSpuInfo`, spu) } ``` SpuForm 组件 ```js const onSave = async () => { ........ let spuImageListTemp = spuImageList.value.map(item => { return { imgName: item.name, imgUrl: item.imgUrl || item.response.data // 只有新上传的图片有response,回显数据的之前上传过的图片是没有的 } }) try { if (spuForm.value.id) { await spuApi.updateSpu(spuForm.value); } else { await spuApi.saveSpu(spuForm.value); } ...... } ``` **bug:只要点击编辑之后,再次点击新增的时,新增界面展示的数据还是之前编辑的数据** 因为编辑的时候,是 SpuList 将一条 spu 的数据传给父组件 存到了 `spuInfo` 这个变量中,当编辑完成之后,这个变量中的数据并没有重置,还是存在的,那么此时点击新增的时候,传给 SpuForm 组件的数据中就有上一次编辑的数据 修复: 当从 SpuForm 组件切换到 主列表的时候(不管是保存还是取消),都应该初始化父组件中 `spuInfo` 这个数据 ![](assets/19.png) 至此为止 Spu 新增编辑结束 ### SkuForm 在主列表界面表格中给当前 spu 点击 "新增sku"按钮,跳转到SkuForm页面,进行新增Sku 步骤 1. 静态页面搭建 定义、注册、使用 2. 初始化数据展示 3. 交互 - 收集数据,调用接口 #### 静态页面搭建 1. 先在主列表页面点击"新增sku",跳转到SkuForm组件 spuList组件 ```js // 新增Sku const addSku = () => { emit('update:modelValue', STATUS.SKUFORM) } ``` 2. SpuFrom组件搭建静态 平台属性、销售属性、图片列表都是初始化数据展示的,根据数据渲染的,静态搭建的时候写一个假的,倒是改成数据渲染即可 #### 初始化数据展示 1. SkuForm 组件,需要展示 spu名称,所以需要把 主列表中要新增的 spu 传递到 SkuForm 中 2. 打开 SkuForm 的时候,需要初始化 平台属性、销售属性、图片列表,这个数据需要调用接口返回数据,在页面中进行展示 ##### 展示spu名称 在主列表点击给某一个spu新增sku的时候,把当前这一行(row)spu信息传递给父组件,父组件再传递给 SkuForm 组件 SpuList 组件 ```js const addSku = (row: SpuModel) => { emit('update:modelValue', STATUS.SKUFORM) emit('changeSpuInfo', row); // 把当前的spu传给父组件 } ``` 父组件 ```html 将 SpuList 传过来的spu存储到了 SpuInfo 中,传递给 SkuForm ``` SkuForm 组件 ```js 接收到之后在面展示 "spu名称" 即可 const porps = defineProps<{ modelValue: number, spuInfo: SpuModel }>() ``` ##### 平台属性、销售属性、图片列表 平台属性的接口是有的,使用的就是平台属性管理页面的初始化数据接口 销售属性的接口是有的,使用的是 spu编辑页面初始化数据的时候,根据 spuId 获取销售属性的接口 图片列表的接口是有的,使用的是 spu编辑页面初始化数据的时候,根据 spuId 获取图片列表的接口 接口都存在,在 `onMounted` 中调用接口拿数据,将数据存储到变量中,在页面展示 ```js // 获取平台属性 const attrList = ref([]) const getAttrList = async () => { try { const { category1Id, category2Id, category3Id } = categoryStore; let result = await attrApi.attrInfoList(category1Id!, category2Id!, category3Id!); attrList.value = result; } catch (error) { ElMessage.error('获取平台属性失败,请重试') } } // 获取销售属性列表 const saleAttrList = ref([]) const getSaleAttrListBySpuId = async () => { try { let result = await spuApi.getSpuSaleAttrListBySpuId(props.spuInfo.id!); saleAttrList.value = result } catch (error) { ElMessage.error('获取销售属性失败,请重试') } } // 获取图片列表数据 const spuImageList = ref([]) const getSpuImageListBySpuId = async () => { try { let result = await spuApi.getSpuImageListBySpuId(props.spuInfo.id!); spuImageList.value = result } catch (error) { ElMessage.error('获取图片列表失败,请重试') } } // 初始化数据 const initData = () => { getAttrList(); // 获取平台属性 getSaleAttrListBySpuId(); // 获取销售属性列表 getSpuImageListBySpuId(); // 获取图片列表数据 } onMounted(() => { initData(); }) ``` # day08 ##### 保存、取消 准备api - 看一下接口入参和出参,把已完成项目的 `src/api/sku.ts` 文件粘贴到项目当中 页面保存的时候需要收集数据,准备一个符合 `SkuModel` 的数据类型进行收集,收集数据分成三块来做:1. 普通数据收集(使用v-mode直接收集) 2. 平台属性 和 销售属性的收集 3. 图片列表 和 默认图片收集 ###### 1. 普通数据收集 ```js 在页面中v-model绑定数据直接收即可 const initSkuForm = (): SkuModel => ({ spuId: undefined, price: undefined, skuName: '', skuDesc: '', weight: '', tmId: undefined, category3Id: undefined, // 保存的时候收集 skuDefaultImg: '', createTime: '', // 不管 skuImageList: [], skuAttrValueList: [], skuSaleAttrValueList: [] }) // 收集数据 const skuForm = ref( initSkuForm() ) ``` ###### 2. 平台属性 和 销售属性的收集 **平台属性** 通过接口调用,发现每一个 平台属性下拉,收集到的数据是 平台属性id 和 平台属性值id组成的对象 ```js { "attrId": "106", // 平台属性id "valueId": "176" // 平台属性值id }, ``` 注意:一个下拉要收集一个这样的数据 给 AttrModel (平台属性)绑定一个 `attrIdvalId` 数据,此时每一个下拉都是一个 平台属性,直接将下拉的数据收集到 平添属性下的`attrIdvalId`属性中,拉下 option 组件绑定的value值 改成 `attr.id:attrVal.id`,此时收集到的数据就既有 属性id 又有属性值id,在点击保存的时候,把这两个数据进行组装 注意:并不是每一个下拉都选中了值 ```js ``` > 注意:在TS类型中加上 attrIdvalId **销售属性** 销售属性收集的时候和平台属性使用一样的方式 ```js ``` > 在TS类型中加上 attrIdvalId 两个数据收集完毕之后打印验证一下,在保存的时候再进行数据的组装 ###### 3. 图片列表 和 默认图片收集 ```js // 图片表格选中的回调 const selectionChange = (val: SpuImageModel[]) => { skuForm.value.skuImageList = val; } // 设置默认图片 const setDetaultImage = (row: SpuImageModel) => { spuImageList.value.forEach(item => item.isDefault = '0') row.isDefault = '1' } ``` > 注意:给 SpuImageModel 这个TS类型加上 isDefault 属性 收集到数据之后,在保存的时候对数据进行组装 调用保存接口保存数据 ```js const emit = defineEmits<{ (e: 'update:modelValue', status: number): void, (e: 'changeSpuInfo'): void // 需要将父组件的spuInfo初始化 }>() const onSave = async () => { // 组装数据 // 组装平台属性数据 skuForm.value.skuAttrValueList = attrList.value.filter(item => item.attrIdvalId).map(item => { const [attrId, valueId] = (item.attrIdvalId as string).split(':'); // "属性id:属性值id" return { attrId, valueId } }) // 组装销售属性 skuForm.value.skuSaleAttrValueList = saleAttrList.value.filter(item => item.attrIdvalId).map(item => { const [saleAttrId, saleAttrValueId] = item.attrIdvalId!.split(':'); return { saleAttrId, saleAttrValueId } }) // 默认图片 skuForm.value.skuDefaultImg = spuImageList.value.find(item => item.isDefault == '1')!.imgUrl // 图片列表已经筹集完毕 skuForm.value.spuId = props.spuInfo.id; skuForm.value.tmId = props.spuInfo.tmId; skuForm.value.category3Id = categoryStore.category3Id; // price,skuName,skuDesc,weight v-model 收集 // 调用接口 try { await skuApi.save(skuForm.value); ElMessage.success('保存成功') cancelSave(); // 切换主界面 } catch (error) { ElMessage.error('保存失败,请重试') } } const cancelSave = () => { emit('update:modelValue', STATUS.SPULIST); // 切换主列表 // 同时需要初始化一下 父组件的spuInfo,防止再次新增 spu 会把 spuInfo 的数据传入 spuForm emit('changeSpuInfo') } ``` 父组件 - 需要绑定一个初始化 spuInfo 的回调 ```js ``` ### SpuList * 点击"查看 sku 列表"按钮,弹出一个弹框,发送请求,获取到Sku列表,在弹框中的表格展示当前的 sku 列表 * 点击"删除spu"按钮,需要用户"双重确认"删除,当用户确认删除的时候,调用接口,删除数据,给提示 #### 查看 sku 列表 ```js // 查看sku列表 const dialogTitle = ref(''); // dialog的标题 const dialogVisible = ref(false); // 控制 dialog 显示隐藏 const skuList = ref([]) // dialog中 table 显示的数据 const showSkuList = async (row: SpuModel) => { try { let result = await skuApi.findBySpuId(row.id!); skuList.value = result; dialogVisible.value = true; // 显示 dialog dialogTitle.value = `[${ row.spuName }]的SKU列表` } catch (error) { ElMessage.error('获取sku列表失败,请重试') } } ``` #### 删除SPU 准备api - `src/api/spu.ts` ```js // 删除SPU deleteSpu(spuId: number) { return request.delete(`/admin/product/deleteSpu/${spuId}`) } ``` SpuList 组件 ```js const deleteSpu = async (row: SpuModel) => { try { await spuApi.deleteSpu(row.id!); ElMessage.success('删除成功') getPage(); } catch (error) { ElMessage.error('删除失败') } } ``` ### SkuList - SKU管理 这个界面下去有空写,这里我们是串讲,讲一下整个的思路和实现过程,使用到的一些组件 写这个页面的步骤也是一样的: 1. 静态页面搭建 不要考虑数据,页面中只有 table 表格 和 分页 2. 初始化数据展示 准备api (已经准备好了),在组件内调用api 获取到数据之后,存到变量中,展示到页面的 table 表格 3. 交互 上架/下架 - 调用接口,给提示、刷新页面 编辑Sku - 不做,给个提示 删除Sku - 双重的校验 - 调接口删除,重新获取数据 查看详情 ​ 查看详情先调用接口拿回数据来,放到组件中的变量,弹出"抽屉"组件,把"抽屉"组件中的静态搭建完毕 ​ 使用布尔值控制抽屉的显示和隐藏 ​ 抽屉中使用的 element plus 中的栅格系统在展示(总共有24 个栅格) ​ 使用 "走马灯" 展示轮播图 #### scoped 讲解 结论: 在 `style` 标签上不加 scoped 样式是全局样式 在 `style` 标签上加上 scoped 样式会作用于当前组件 和 子组件的根标签 > 注意:在组件内部设置样式的时候,只能给自己组件内设置样式,和子组件的根标签设置样式,给子组件根标签里面的内容设置样式不好使 ![](assets/20.png) 为什么会这样? 解释步骤: 1. 当组件中的 `style` 标签加了 `scoped` 的时候,此时当前组件中的所有元素 和 子组件的根元素 会加上一个属性`data-v-f52cbf12` ,这是一个自定义属性,`data-v-` 是固定写法,`f52cbf12` 这是一个 hash 值,目的是唯一,vue帮我们生成的,不固定 2. 当 `scoped` 的时候,组件中的每个元素 和 子组件的根元素 都有了这个属性(`data-v-f52cbf12`) ,此时 `style` 标签中的样式也会变成 属性选择器,只有当有这个属性的元素,才会设置style样式 > 属性选择器 > > ```html > >
哈哈哈
>
    >
  • 内容1
  • >
  • 内容2
  • >
  • 内容3
  • >
  • 内容4
  • >
  • 内容5
  • >
> ``` #### 修改 第三方组件内样式 修改 carousel 组件内小圆点样式 1. 直接在 `style` 标签中 写样式 ```html ``` 注意:此时是没有加 scoped ,这个样式将用于全局,会影响其他特面的样式,不采用 2. 将样式限制在当前组件内,加上 `scoped` 但是此时样式会被限制在当前组件和子组件的根元素上,怎么办? 解决方案: 深度选择器: 1. css 深度选择器 格式: `选择器 >>> 选择器` 例如: `.box >>> .container ` ```html ``` 2. less 深度选择器 格式: `选择器A /deep/ 选择器B` 例如: `.box /deep/ .container { 样式 }` `/deep/ .container { 样式 }` ```html ``` 3. scss 和 less 深度选择器 格式: `选择器 ::v-deep(选择器) { 样式 }` 例如: `.box ::v-deep(.container) { 样式 }` `::v-deep(.container) { 样式 }` ```html ``` 注意: `::v-deep()` 这个写法是vue3的,还有 vue2 的写法 ``` vue2的写法 .box ::v-deep .container { 样式 } ``` 宏观去理解 品牌管理、平台属性管理、Spu管理、Sku管理 ![](assets/21.png) # day09-10 权限管理 - 参考权限管理文档 拓展内容 - 不是大纲要求的 我们的项目中不可能只有商品管理,项目的模块有很多,我们当前的项目中有一些接口是可以使用的,例如:订单的展示、优惠卷的增删改查这些接口是好用的,我们可以调用这些接口去写一写这些页面 这里咱们就不写了,直接拿上个班写过的粘贴到我们的项目中让大家看一看,如果让你去写这个页面有什么思路 关于项目中一些其他模块,自己下去了解了解,去网上找找相关的设计思路: https://zhuanlan.zhihu.com/p/469796462 读一读网上的这些文档,对于以后找工作有帮助