# 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
### 项目介绍

我们的项目只有"权限管理" 和 "商品管理" ,在真实的后台管理项中模块不可能只有两个,我们只是挑出两个模块去说一说,其他的模块可能不知道里面的内容,但是要能够想到有这些模块,举一反三(真实的业务有固定套路)。
### 项目准备
为什么我们的项目不是从零到一自己搭建?
在公司中,不可能等你入职之后再开始做项目,进到公司之后,拿到手里的一定是一个已经开发了一下内容的项目,我们直接拿一个模板(半成品)进行开发,这个模板是 "潘家诚" 写的,尚硅谷改的
潘家诚:写了一套后台管理系统的模板,基于 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 方法看一下,它的参数类型

结论:尖括号中的 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类型
#### 返回的数据说明
接下的内容知道即可,不需要深究:

后台项目和前台项目返回数据在控制台打印的时候不一样,后台项目做了什么事情?
看axios的二次封装中的响应拦截器

调用接口返回数据说明:
响应体中的内容就是我们接口返回的数据,但是在控制台答应的数据不是响应体数据,响应体的数据有 message,code ,data... 等数据,而真正在 store 中调用接口 await 返回的数据是 `响应体.data` 这个数据,相当于数据多脱了一层(相较于前台项目而言)
#### 关于TS

同一张图

结论:
在 store 中调用接口返回的数据有类型是两个地方作用的:1. request.post() 方法调用的时候,限制了 promise 结果的数据格式 2. 在响应拦截器中我们返回的数据 是 `response.data.data`(response是响应报文,response.data 是响应体),这个返回的数据才是我们在 store 中真正拿到的数据
总结 - 套路:
以后在写 api 函数的时候,看一下接口返回的数据(可以使用 swagger 或者 看生产环境接口返回数据),直接定义好类型即可
# day03
### 侧边栏的展示
侧边栏在哪渲染?侧边栏的数据从哪来?
侧边栏的渲染是 `SideBar` 组件

侧边栏的数据从 `userInfoStore.menuRoutes` 来,这个值在哪赋值的?在 `userInfoStore` 的 `actions` 下的 `getInfo()` 方法赋值的,目前写死了,写的是 `staticRoutes`

现在想让侧边栏显示商品管理的所有侧边栏,怎么办?
1. 我们的 token 存在 store 和 localStorage 中,当刷新页面的时候,store中的数据没有了,刷新页面的时候,此时 token 从 localStorage 中取出来放到store中
2. 刷新页面一定经过路由守卫,此时 store 中是有token,但是没有用户信息,调用接口获取用户信息
得到用户信息之后,目前把 `staticRoutes` 赋值给 `userinfoStore.menuRoutes`,目前是写死的
3. 只要我们修改 staticRoutes, 侧边栏就应该展示了

#### 修改 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` 是如何渲染侧边栏的


分析 SideBar 组件展示 --- 了解即可

再往后走,都是套路
## 品牌管理
后台管理项目就是在做 增、删、改、查
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
图片必须是JPG格式且不能大于2MB.
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
图片必须是JPG格式且不能大于2MB.
// 上传的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)都,只是添加了校验规则,和触发校验规则的方式
并没有在保存的回调当中对数据进行校验,保存的时候也需要校验,如果校验不通过,那么不能保存

保存之前校验需要获取到当前 `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
{{ attrVal.valueName }}
```
> 注意:
>
> el-tabl 组件只有表格帮我们展示数据的时候,需要给列加 prop 属性
>
> 我们自己展示数据的时候,不需要加 prop 属性
>
> 列的 prop 属性就是告诉列组件你要展示数据中的哪个字段
#### 交互
##### 新增
新增、编辑界面,当点击"添加属性"按钮的时候,切换界面展示,展示出编辑的界面
```js
添加属性
const addAttr = () => {
isEdit.value = true
}
```
搭建静态,这里没有初始化数据展示,只有交互,收集界面数据,点击保存调用接口
步骤:
1. 搭建静态 - 在编辑展示的界面中搭建静态
```html
添加属性值
取消
保存
取消
```
2. 新增界面没有初始化数据展示 - 准备保存调用的api函数
点击"保存"按钮要调用接口,准备 api 函数(看看接口文档或线上),加上TS,准备好 api 函数就知道了我们收集页面数据需要收集哪些,然后根据需要收集的数据去页面上写交互

了解到数据的来源和结构之后,开始写 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 显示

```js
{{ row.valueName }}
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的插件来提升静态搭建页面的效率

> 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
添加销售属性
{{ row }}
保存
取消
```
#### 初始化数据展示
当打开新增页面的时候,发送了两个请求,获取品牌下拉 和 销售属性下拉的数据
步骤:
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之后,等待页面调用

`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
// 图片上传相关数据和回调
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
{{ tag.saleAttrValueName }}
新增
```
把"销售值属性列"拆成三部分去看:
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 可以在依赖中看到),有了启动项目指令之后,启动项目
>
> 
>
> 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); // 回显数据
// 图片列表和销售属性需要发请求拿回来
}
}
```
此时页面中就可以展示出基本数据的回显了

注意:
在编辑回显数据的时候,此时编辑的 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` 这个数据

至此为止 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 样式会作用于当前组件 和 子组件的根标签
> 注意:在组件内部设置样式的时候,只能给自己组件内设置样式,和子组件的根标签设置样式,给子组件根标签里面的内容设置样式不好使

为什么会这样?
解释步骤:
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管理

# day09-10
权限管理 - 参考权限管理文档
拓展内容 - 不是大纲要求的
我们的项目中不可能只有商品管理,项目的模块有很多,我们当前的项目中有一些接口是可以使用的,例如:订单的展示、优惠卷的增删改查这些接口是好用的,我们可以调用这些接口去写一写这些页面
这里咱们就不写了,直接拿上个班写过的粘贴到我们的项目中让大家看一看,如果让你去写这个页面有什么思路
关于项目中一些其他模块,自己下去了解了解,去网上找找相关的设计思路:
https://zhuanlan.zhihu.com/p/469796462
读一读网上的这些文档,对于以后找工作有帮助