# gshop-admin_0222
**Repository Path**: newsegmentfault/gshop-admin_0222
## Basic Information
- **Project Name**: gshop-admin_0222
- **Description**: gshop-admin_0222 后台管理项目
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2023-07-19
- **Last Updated**: 2023-08-03
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
## 说明
* 基于Vue3的电商中台管理项目
* 技术栈: TS + Vue3 + VueRouter4 + Pinia + ElementPlus
* 当前为完成版
* 学习参见 文档 文件夹下的MD文件
eslint 规则配置:http://eslint.cn/docs/rules/
```js
module.exports = {
"rule": {
"no-console": 0 // 0代表该规则不生效,1代表报警告,2代表报错
}
}
```
插件说明:
TypeScript Vue Plugin (Volar)
Vue Language Features (Volar)
Vetur 禁用(vue2插件)
# day01
## 项目介绍

## 项目目录介绍
```
|-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配置 配置代理等
```
## 项目准备工作
项目准备:
上传gitee: https://gitee.com/newsegmentfault/gshop-admin_0222
前台项目地址: http://101.43.227.123/
线上项目地址: http://101.43.227.123:5000/home
后台管理系统接口文档地址: http://39.98.123.211:8510/swagger-ui.html
后台管理系统权限接口文档地址: http://39.98.123.211:8170/swagger-ui.html
## 页面结构
1. 拿到项目之后先看 main.ts 文件,发现整个项目是使用 App.vue 来选然的,打开App.vue 看见了 `router-view` 组件,说明整个项目都是由路由渲染出来的,接下来应该去看路由了
2. 查看路由,看到router中配置的 routes 是 `staticRoutes` , 在 `staticRoutes` 中 发现了 `/` 根路径,得到当我们输入 `localhost:3000` 的时候访问的是 `/` 根路径渲染的组件
```js
{
path: '/',
component: () => import('@/layout/index.vue'),
redirect: '/home',
children: [{
path: 'home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
icon: 'ele-HomeFilled',
}
}]
},
```
根路径渲染的是 `Layout` 组件,同时重定向到了 `/home`
`/home` 路由在当前路由的子路由中,渲染的是 `views/home` 组件,问:这里的二级路由 `/home` 在哪渲染 ?
路由渲染一定使用 `router-view`,一级路由的渲染是在 App.vue 中渲染了 `Layout` 组件
二级路由组件渲染一定是在 `Layout` 组件中,所以我们需要看一下 `Layout` 组件
3. 查看`Layout` 组件发现三个组件
* sidebar - 侧边栏
```html
```
发现侧边栏的每一项渲染的数据是从 `userInfoStore` 中 `menuRoutes` 拿的(至于这个数据从哪里来,按下不表)
打开 `sidebarItem` 组件发现自己调用自己了,这是一个递归组件
* navbar - 顶部导航栏

"登陆"的"陆"错了,要改成"录"
同时知道退出登录的功能在 `navbar` 去实现
* app-main - 主体内容
```html
```
主体内容拿着 `router-view` 的 v-slot 解构出当前要渲染的组件,然后传给 `` 动态渲染组件
transition 标签是 vue 中过渡
这里是对整个二级路由渲染的组件进行过渡和渲染,这里渲染的 `/home` 路由,渲染的 `src/views/home` 组件
## 需要修改的内容
1. 登录页的背景
把图片放到 `assets` 文件夹中,然后修改css即可
2. 登录页的滚动条
全局css文件 `styles/index.scss` 设置
```css
* {
margin: 0;
padding: 0;
}
```
3. 登录页的录是"陆",也需要修改
`src/views/login/index.vue` 修改
4. 顶部导航"退出登陆"的陆也需要修改
`src/layout/components/Navbar.vue` 修改
## 登录流程思路
1. 第一次打开 `localhost:3000` 访问的 `/`根路径,然后重定向到 `/home`,为啥没去到 `/home` 呢 ?
在后台项目中,除了登录页是可以直接访问的,其他的所有页面都需要登陆,此时就跳转到了登陆页,什么在让页面跳转'登陆页'?路由守卫在起作用,应该去看看路由守卫
2. 路由守卫中判断有没有token,此时没有登陆过,是没有token的
此时路由守卫走 `else` 跳转登陆页,同时携带想去而没有去到的页面的路径在query参数中
3. 在登陆页,点击"登陆"按钮,调用了 store 中的 actions 中的 `login` 方法,此时应该去调接口登陆,但是我们发现,actions中的`login` 是个假的,需要我们自己手动去改成真的
当登陆成功,把假的token存到了 localStorage 和 state 中
4. 登陆完成,跳转到刚刚想去而没有去到的页面
跳转的过程一定经过路由守卫,此时再看路由守卫
5. 走路由守卫的时候已经有token了,有token不允许去登陆页,去登陆页就跳转`/`根路径
我们现在去的是刚刚想去而没有去到的页面,此时判断有没有个人信息
如果有个人信息直接放行,没有个人信息重新发请求获取个人信息
此时发现store中的actions中获取个人信息的函数也是假的,需要我们自己改成真的
当获取完个人信息之后,跳转到想去而没有去的页面
在这个过程中,调用获取个人信息的actions是有可能报错的,什么情况下会报错?
* 网络错误
* token过期
当报错的时候走 catch 方法,把 token 和 个人信息全部清除,跳转登陆页,让用户重新登陆
### 登录流程
在登录页点击"登录"按钮,调用actions中的login方法,actions中的login方法要调用接口,准备api
在 `src/api/userinfo.ts` 文件来封装api函数
```js
export default {
login(data: LoginModel) {
return request.post(`/admin/acl/index/login`, data)
}
}
```
在userinfoStore中的actions调用api方法,得到token,存储token
```js
async login (username: string, password: string) {
try {
let result = await userInfoApi.login({ username, password })
setToken(result.token)
this.token = result.token
} catch (error) {
ElMessage.error('登录失败,请重试')
return Promise.reject(error)
}
},
```
当存储完token之后跳转至首页
> 注意:在这个过程中需要配置vite代理(用来转发)
>
> ```js
> server: {
> proxy: {
> '/app-dev': {
> target: 'http://gmall-h5-api.atguigu.cn',
> changeOrigin: true,
> rewrite: (path) => path.replace(/^\/app-dev/, ''),
> },
> }
> }
> ```
>
> 配置项的改变需要重启项目
>
> 注意:这里并没有获取个人信息
### 获取个人信息
在跳转到首页的过程中需要获取个人信息,在路由守卫中获取个人信息
> 注意:获取个人信息的前提是有token,token要携带在请求头当中
>
> ```js
> service.interceptors.request.use(
> (config) => {
> const userInfoStore = useUserInfoStore()
> if (userInfoStore.token) {
> (config.headers as AxiosRequestHeaders).token = userInfoStore.token
> }
> return config;
> }
> );
> ```
api函数准备
```js
export default {
...,
getInfo() {
return request.get(`/admin/acl/index/info`)
}
}
```
写 userInfoStore 中的 actions 中 getInfo 方法
> 注意:state 中存储个人信息的地方结构发生了改变
```tsx
interface UserInfoModel {
avatar: string,
name: string,
buttons: string[],
roles: string[],
routes: string[]
}
interface UserInfoState {
token: string,
userInfo: UserInfoModel,
menuRoutes: RouteRecordRaw[] // 用于生成导航菜单的路由列表
}
const initUserInfo = () => ({
avatar: '',
name: '',
buttons: [],
roles: [],
routes: []
})
state: (): UserInfoState => ({
token: getToken() as string,
userInfo: initUserInfo(),
menuRoutes: []
}),
actions: {
.......
async getInfo () {
try {
let result = await userInfoApi.getInfo()
console.log(result)
this.userInfo = result;
// 注意: menuRoutes 是侧边菜单栏的数据
// 这里需要获取个人信息的时候,同时拿到个人的权限信息,在这个位置进行过滤才能希显示侧边菜单栏
// 这里目前写死即可,到最后我们会过来做权限控制
this.menuRoutes = staticRoutes // 目前写死
} catch (error) {
ElMessage.error('获取个人信息失败,请重试')
return Promise.reject(error)
}
},
}
```
按道理来说,现在存储上个人信息就完事了,但是因为state中个人信息位置的结构发生了改变,但凡用到展示个人信息的位置都需要修改
1. 路由守卫中获取个人信息的位置
```js
const hasLogin = !!userInfoStore.userInfo.name
```
2. 首页中会展示个人信息
```js
Hello, {{userInfoStore.userInfo.name}}
```
3. navbar组件中要展示个人信息(头像和名称需要改)
```html
```
### 退出登录
点击`navbar`组件中的"退出登录"按钮,调用 userInfoStore 中的 actions 的 reset() 方法进行退出登录
```js
async reset () {
try {
await userInfoApi.logout()
} catch (error) {
console.error('登出失败', error)
} finally { // 不管成功和失败都会执行的代码
// 删除local中保存的token
removeToken()
// 提交重置用户信息的mutation
this.token = ''
// 重置个人信息
this.userInfo = initUserInfo()
}
},
```
## 给api函数添加TS类型 - 固定套路
在登录的流程当中发现有飘红的问题,原因是因为actions函数中调用api函数没有返回值类型,所以造成了飘红的问题,如何解决?
在api封装的时候,给 `request.post()` 或 `request.get()` 加返回值类型即可

在TS类型中可以看到post方法的类型 R 是 返回的 Promise 的类型,所以在调用 post 方法的时候,只要确定了 R 的类型即可
```ts
export interface UserInfoModel {
avatar: string,
name: string,
buttons: string[],
roles: string[],
routes: string[]
}
export default {
login(data: LoginModel) {
return request.post(`/admin/acl/index/login`, data)
},
getInfo() {
return request.get(`/admin/acl/index/info`)
},
logout() {
return request.post(`/admin/acl/index/logout`)
}
}
```
注意:以后在写接口的时候,直接把类型写上
### axios 二次封装响应数据设置
我们如何确定 `request.post()` 方法返回的一定是 接口返回数据中的data的?
`request` 帮我们做了事情,在响应拦截器中,先获取到 `let res = response.data` 拿到响应体内容,但后在响应拦截器中`return res.data`




# day02
> #### 关于泛型
>
> ```tsx
> 泛型函数
> function fn1 () {}
> function fn2 (n: number) {}
> function fn3 (n: number): number {
> return n
> }
> function fn4(n: T): T {
> return n
> }
>
>
> 泛型接口
> interface ResultModel {
> code: number,
> data: T,
> message: string
> }
> ```
>
> 什么泛型?
> 定义的时候定义泛型,使用的时候确定泛型,泛型不是一个具体的类型
## 侧边栏显示
侧边栏是 SideBar 组件,发现
```html
```
数据来源是 userInfoStore.menuRoutes,而这个值在哪里赋值的?
这个数据是在获取个人信息的actions中进行赋值的
```js
async getInfo () {
try {
let result = await userInfoApi.getInfo();
this.userInfo = result; // 存储个人信息(包括个人的基本信息和权限信息)
// 获取个人用户信息的时候,除了用户的基本信息以外还有权限信息
// 根据用户的权限信息来展示侧边栏(权限相关的会最后说,这里先写死)
this.menuRoutes = staticRoutes
}
}
```
这里注意,目前使用的是 staticRoutes , 是一个我们前端写死的数据(路由),后续会在讲解权限的时候,进行动态处理(为什么要动态处理? 因为每一个用户可以看到的侧边栏不一样)
想要侧边栏渲染出 "商品管理",需要我们去修改 staticRoutes 这个数据(路由)
```js
{
path: '/product',
name: "Product", // 所有的name属性,这个值必须和我写的一样,为什么?按下不表,后续再说
component: () => import('@/layout/index.vue'),
meta: {
title: '商品管理',
icon: 'ele-GoodsFilled',
},
children: [
{
path: 'trademark',
name: 'Trademark',
component: () => import('@/views/product/trademark/index.vue'),
meta: {
title: '品牌管理'
}
},
{
path: 'attr',
name: 'Attr',
component: () => import('@/views/product/attr/index.vue'),
meta: {
title: '平台属性管理'
}
},
{
path: 'spu',
name: 'Spu',
component: () => import('@/views/product/spu/index.vue'),
meta: {
title: 'SPU管理'
}
},
{
path: 'sku',
name: 'Sku',
component: () => import('@/views/product/sku/index.vue'),
meta: {
title: 'SKU管理'
}
}
]
},
```
通过查看 SideBar-Item 组件发现这是一个递归组件,当一级路由有且仅有一个子集路由的时候,会直接渲染这个子集路由作为菜单,当一级路由有多个子集路由的时候,通过递归组件进行渲染二级路由
## 品牌管理
正常在公司中写页面的时候,会有设计图或原型图给到开发者,让开发者遵循设计图或原型图去开发页面,我们这里没有这些东西,照着已完成项目去写即可
写页面的步骤:
1. 静态页面搭建
2. 初始化数据展示
发请求,调接口,拿数据
- 书写api,书写api的同时把TS类型写完
- 在页面初始化的时候调用api,拿到数据,进行展示
3. 交互
- 翻页
- 添加
点击添加的时候,弹出一个弹框,弹框中有表单内容,表单需要收集 "品牌名称" 和 "品牌LOGO" 这两个字段,当点击保存的时候,调用接口发送请求,将数据传给后端,保存成功之后,弹框消失,并且刷新页面数据
1. 点击添加按钮弹出弹框
2. 弹框中静态的搭建(form表单的搭建)
以上为弹框的静态搭建
3. 收集表单数据,使用v-model收集表单数据
4. 当点击保存的时候,调用接口
先准备api(同时写好TS类型)
然后在点击"保存"按钮的时候,调用api发送数据
5. 调用接口成功之后,弹框消失,重新获取页面数据
6. 点击的是"取消",需要清空表单数据,弹框消失
- 修改
点击"编辑"按钮,弹出弹框,回显数据,当再次点击保存的时候,将修改后的数据进行保存,调用更新接口
1. 弹出弹框,回显数据
2. 再次点击保存
- 删除
所有删除只要涉及到发请求,都需要做"双重校验",要提示用户当前的数据会被删除
## 表单校验
el-form 表单校验需要三个条件:
1. el-form 绑定 :model="tmForm" 属性,把数据交给 el-form 组件
2. el-form 绑定 :rules="rules" 属性,定义校验的规则
```js
const rules = reactive({
tmName: [
// required 必填 message 提示消息 trigger 触发方式(还支持 change)
{ required: true, message: '请输入品牌名称', trigger: 'blur' },
{ min: 2, max: 10, message: '品牌名长度应为2到10个字符', trigger: 'blur' },
],
logoUrl: [
{ required: true, message: '请上传图片', trigger: 'blur' },
]
})
```
3. el-form-item 绑定 prop="tmName" 属性,告诉 el-form-item 组件要校验的是哪一个字段
当我们点击保存的时候,需要手动触发一次校验规则,当符合校验规则的时候才能调用保存接口
手动触发校验的时候,需要获取到 el-form 的表单实例才能调用校验方法 validate()
```js
const ruleFormRef = ref(); // el-form实例
const onSave = async (formEl: FormInstance | undefined) => {
if (!formEl) return
// 获取到form表单的实例,调用validate()方法 手动对表单进行一次校验,校验通过回调中的 valid 参数为true
await formEl.validate(async (valid, fields) => {
if (valid) {
// 之前保存的代码
try {
await tradermarkApi.save(tmForm.value)
ElMessage.success('保存成功') // 用户提示
getPage(); // 刷新页面
cancelSave(formEl);
} catch (error) {
ElMessage.error('保存失败')
}
} else {
console.log('保存失败', fields)
}
})
}
```
重置校验飘红
```js
// formEl 是el-form组件实例
formEl.resetFields()
```
### 自定义校验规则
给 rules 规则中配置字段 `validator` 即可
```js
// 自定义校验的回调
const checkTmName = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('品牌名不能为空'))
} else {
if (tmForm.value.tmName.length >= 2 && tmForm.value.tmName.length <= 10) {
callback()
} else {
callback(new Error('品牌名称的长度应为2到10个字符!!'))
}
}
}
// 表单校验
const rules = reactive({
// 自定义校验规则
tmName: [
{ validator: checkTmName, trigger: 'blur' },
],
})
```
# day03
### 数据拷贝
* 浅拷贝
* 直接赋值
* 扩展运算符
* Object.assign()
* 深拷贝 - 拷贝出来的数据和原来的数据完全没有关系
* `JSON.parse(JSON.stringify(p1))` 使用JSON进行深拷贝不会去拷贝函数
* 自己写深拷贝函数 - 递归深拷贝
```js
let p1 = {
name: "张三",
base: {
weight: 150,
height: 170
},
hobby: ['抽烟', '喝酒', '烫头'],
eat: function () {
console.log('胃口好,能吃')
}
}
// let p1 = 'qwer'
// function p1 () {
// console.log('123123')
// }
function cloneDeep(data) {
function isReference(data) {
return (typeof data === 'object' || typeof data === 'function') && data !== null
}
// 判断是不是引用数据类型,是引用数据类型,才要继续,不是引用数据类型,直接返回
if (!isReference(data)) { // 把基本数据类型先直接返回,剩下的我们再做深拷贝
return data
}
// 剩下函数、对象、数组,先考虑函数,再考虑数组和对象
function isFunc(data) {
return typeof data === 'function'
}
// 对函数进行单独处理
if (isFunc(data)) {
let funcString = data.toString()
let startIndex = funcString.indexOf('{')
let lastIndex = funcString.lastIndexOf('}')
let fnString = funcString.slice(startIndex + 1, lastIndex)
let fn = new Function(fnString)
return fn
}
// 考虑对象和数组
let res = Array.isArray(data) ? [] : {}
for (const key in data) { // key是对象中的属性名 data对象 data[key]是属性值
res[key] = cloneDeep(data[key])
}
return res
}
let p2 = cloneDeep(p1)
```
## 平台属性管理
一、静态页面搭建
搭建静态的时候,发现三级分类是一个公用的内容,可以封装成一个公用的组件来使用,由于咱们项目中没有其他地方能用到 pinia 了,所以写这个组件的时候使用 pinia 来管理数据
先把三级分类组件搞定,再来搭建页面的静态
二、初始化数据展示
三、交互
- 新增
- 修改
- 删除
### 三级分类组件
组件初始化的时候默认获取到一级分类下拉的数据
当选择到一级分类的数据时,将这个数据存储起来,同时需要请求二级分类下拉数据
当选择到二级分类的数据时,将这个数据存储起来,同时需要请求三级分类下拉数据
当选择到三级分类的数据时,应该初始化主列表显示
### 静态页面搭建
搭建 attr 主界面的时候需要注意,界面有两个状态显示,列表展示状态 和 新增编辑状态,需要一个布尔值来控制这两个状态的切换
```html
添加属性
主列表状态
const isEdit = ref(false); // 默认不是编辑状态,是列表展示状态
```
### 初始化数据展示
1. 准备api(同时把TS类型写上)
```js
export interface AttrValueModel {
id?: number,
valueName: string,
attrId?: number
}
export interface AttrModel {
id?: number, // 新增数据没有id,只有在编辑的情况下才有id
attrName: string,
categoryId: number | undefined,
categoryLevel: number,
attrValueList: AttrValueModel[]
}
export default {
getAttrInfoList(category1Id: number, category2Id: number, category3Id: number) {
return request.get(`/admin/product/attrInfoList/${category1Id}/${category2Id}/${category3Id}`)
},
}
```
2. 调用 - 当第三级的数据被选择了之后,展示页面的数据
```js
watch(() => categoryStore.category3Id, (nval) => {
if (nval) {
// 请求数据
getList();
} else {
// 清空数据
attrList.value = []
}
})
```
### 交互
#### 新增
当点击新增按钮的时候,切换到新增边界的界面,需要静态的搭建
静态搭建完毕之后,创建收集表单数据,对编辑界面的数据进行收集,当收集完毕之后调用准备好的api接口
```js
// api
saveAttrInfo(data: AttrModel) {
return request.post(`/admin/product/saveAttrInfo`, data)
}
```
保存按钮点击调用保存接口
```js
// 保存
const onSave = async () => {
// 组装数据
attrForm.value.categoryId = categoryStore.category3Id;
// 发送请求
try {
await attrApi.saveAttrInfo(attrForm.value)
ElMessage.success('保存成功') // 提示
isEdit.value = false; // 切换回列表显示状态
attrForm.value = initAttrForm(); // 初始化收集数据的对象
getList(); // 重新发送请求拿数据
} catch (error) {
ElMessage.error('保存失败,请重试')
}
}
// 取消保存
const cancelSave = () => {
attrForm.value = initAttrForm(); // 初始化收集数据的变量
isEdit.value = false; // 切换主列表显示
}
```
#### 增加限制条件
1. "添加属性"按钮限制条件
在三级分类的第三级选择了之后,才能点击
```html
添加属性
```
2. 在编辑状态下"三级分类"组件是禁用状态
```html
```
3. 编辑状态下"添加属性值"按钮,必须在有"属性名"的情况下才能点击
```html
添加属性值
```
4. 新增"保存"按钮,只有在 "属性名" 和 "属性值列表" 都有值得情况下才能点击
```html
保存
```
#### 修改
1. 回显数据(注意回显的数据深拷贝)
2. 准备api(已经存在),调用接口
#### 删除
点击删除按钮需要调用接口,删除只要调接口就需要"双重校验"
#### 编辑状态的input切换
点击"添加属性值"的时候,表格添加了一条新的数据,这个数据应该是一个空串
同时"属性值名称"这一列展示的是一个input框,让用户输入内容修改这个空串的值(切换input)
同时展示的input框应该聚焦(自动聚焦)
------
当输入完内容的时候,点击页面其他位置,input框失焦,然后切换input框的显示(切换input)
------
当再次点击"属性值名称"列中的单元格,input框显示,input聚焦(切换input、自动聚焦)
------
结论:
input 框的切换 和 聚焦是两件事,不要混为一谈
1. input 框的切换
给 attrValueList 属性值列表的每一个数据添加一个 'inputVisible' 布尔值来控制div和input框的切换
2. 自动聚焦
- 当点击按钮添加数据的时候需要自动聚焦
- 当点击div切换input的时候需要自动聚焦
只要展示input框就需要自动聚焦
方法:获取到input框组件实例,调用 focus 方法进行聚焦
```js
nextTick(() => {
inputRef.value?.focus()
})
```
`nextTick()` 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。
3. 对输入input框内容进行了校验
当失焦的时候,输入的内容不能为空,且不能重复
```js
const inputBlur = (row: AttrValueModel, index: number) => {
// 需要判断input输入的有没有内容,如果没有内容,该数据不应该存在
if (!row.valueName) {
attrForm.value.attrValueList.splice(index, 1)
ElMessage.error('输入内容不能空')
return
}
// 去重
// attrForm.value.attrValueList 中 查找 有没有和当前 row.valueName 相同的
// isRepeat 是否有重复(需要把自己排除掉)
const isRepeat = attrForm.value.attrValueList.some((item, idx) => {
if (index === idx) { // 代表是自己,把自己排除掉
return false
}
return item.valueName === row.valueName
})
if (isRepeat) { // 代表有重复
attrForm.value.attrValueList.splice(index, 1)
ElMessage.error('输入内容重复,请重新输入')
return
}
row.inputVisible = false
}
```
4. 关于 ?
```js
inputRef.value?.focus() ----- inputRef.value 有值就继续之后问号之后的.语法,没有值直接返回这个空值
等价的
inputRef.value && inputRef.value.focus()
```
# day04
#### 拓展 - vue2和vue3设置响应式数据的区别 - $set
vue2 的 API 中 $set
```js
export default {
data() {
return {
name: '张三'
}
}
}
// 这里的 name 是响应式数据
this.name = "李四" // 页面会自动更新
this.age = 18 // 期望有一个age的响应式数据,但是这样写并不能把age属性变成响应式的
this.$set(this, 'age', 18) // 这样添加age属性才能变成响应式的
// 底层实现 Object.defineProperty(this, 'age', { get() {}, set(){} })
```
vue3 中移除了 $set (不需要了),为什么?
目标:为了将数据变成一个响应的
实现:
- 在vue2中,响应式数据的实现是使用 Object.defineProperty() 这个方法实现的,这个方法是针对某一个对象下的某个属性的,如果在初始化数据的时候,没有这个数据,使用 $set 底层调用 Object.defineProperty() 给当前实例(数据)添加响应式
- 在vue3中,响应式数据的底层实现是 proxy,当创建出一个数据的时候 `new Proxy(obj, { set(){}, get() {} }) `, 参数二是配置对象的拦截器的,这里真对的是这个对象,当给这个对象添加属性的时候,例如 `obj.inputVisible = true` 是会经过 set 方法的,所以不需要单独对 inputVisible 进行处理了
## SPU 管理
SPU 和 SKU 介绍

### 结构搭建
发现页面显示是三个界面,通过三个组件切换进行显示 SpuList、SpuForm、SkuForm
第一版:
```html
```
第二版:
改两个地方:
1. 就是咱们的状态值是 1, 2, 3 这样写不好,别人不知道什么意思,换成枚举,以后只要改枚举的值,后续所有引用的地方的值都会发生变化
2. `@updateShowStatus="updateShowStatus" `可以切换成 v-model 指令使用
```html
```
组件内
```js
const emits = defineEmits<{
(e: 'update:modelValue', n: number): void
}>()
const cancelSave = () => {
emits('update:modelValue', STATUS.SPULIST)
}
```
### SPU主列表
1. 界面静态搭建
2. 初始化数据展示
- 准备api(同时写TS的类型)
- 监听三级分类id,如果有值调用api拿数据,将数据在页面上展示即可
3. 主列表交互
- 新增SPU
- 编辑SPU
- 删除SPU
- 新增SKU
- 查看SKU列表
### 新增SPU
1. 静态页面搭建
注意:静态搭建的时候,图片上传使用的是 "照片墙",把所有相关的代码粘贴过来保证不报错即可
2. 初始化数据展示
初始化数据展示需要展示"品牌下拉"和"销售属性"的下拉数据
- 准备api(同时写TS)
- 在页面初始化的时候调用api,拿数据进行展示即可
3. 交互
收集数据,点击保存,调用api
#### 静态页面搭建
根据已完成项目搭建静态
#### 初始化数据展示
获取品牌、销售属性下拉数据进行展示
接下来: 收集数据,点击保存,调用api
#### 准备API函数
看下数据来源,得到的结论:销售属性是一条数据,每一条销售属性中有销售属性值列表
```js
saveSpuInfo(spuInfo: SpuModel) {
return request.post(`/admin/product/saveSpuInfo`, spuInfo)
}
```
看一下真实传递给后端的参数,去修改 SpuModel 类型中的 spuSaleAttrList 和 spuImageList 数据的格式,弄懂了每一条数据的来源
#### 新增spu-普通数据收集
在页面创建收集数据的变量,类型应该是一个SpuModel,准备收集数据
spu名称、品牌、描述都是使用 v-model 直接收集即可
#### 图片列表收集
```js
const spuImageList = ref([])
const handlerSuccess: UploadProps['onSuccess'] = (response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) => {
console.log(response, uploadFile, uploadFiles)
// response 是接口响应的内容
// uploadFile 是单个文件信息,包括文件名、文件大小等,需要注意的属性有
// name 文件名
// url 图片路径(目前是本地url)
// response 是接口响应的内容
// 注意: 图片列表数据中单个图片必须要有 name 和 url 两个属性,如果没有,无法预览(这个条件是element规定的)
// uploadFiles 文件列表,是一个数据,包含多个单个文件
// 当赋值等号两侧数据的类型不一样的时候,可以使用any进行一下中转
spuImageList.value = uploadFiles as any;
}
const handleRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => {
spuImageList.value = uploadFiles as any;
}
```
> 注意:
>
> 1. 当赋值等号两侧数据的类型不一样的时候,可以使用any进行一下中转
> 2. 收集到数据最终在保存之前进行数据组装
##### TS中的感叹号
```jsx
dialogImageUrl.value = uploadFile.url!
let a: number;
let b: number | undefined; // 忽略这里的undefined
a = b as number;
a = b!;
let c: string = '我爱你';
a = c!; // 这样不行
```
TS中的感叹号是用来忽略类型用的,忽略联合类型中有 undefined 的时候,并且联合类型中有和单独类型重复的类型
# day05
#### 销售属性收集
- 销售属性下拉框选中不了,需要一个单独的数据进行收集(单独收集的时候需要同时收集到 id 和 name)
```html
const attrIdAttrName = ref('')
```
##### 销售属性的添加和删除
- 点击"添加销售属性"按钮
- 销售属性表格多了一条数据(多的这条数据来源于下拉框,收集的是 下拉的id和name)
- 下拉框少了一条数据(使用计算属性去做,只要表格中存在这条数据,下拉框中就不应该存在 - 这个可以往后放)
- "销售属性表格"点击删除数据
- 表格减少当前点击的数据
- 下拉框多一个数据(计算属性,只要表格中存在这条数据,下拉框中就不应该存在 - 这个可以往后放)
```js
// 添加销售属性
const addSaleAttr = () => {
const [baseSaleAttrId, saleAttrName] = attrIdAttrName.value.split(':');
// 表格展示的数据是 spuForm.spuSaleAttrList
spuForm.value.spuSaleAttrList.push({
baseSaleAttrId: Number(baseSaleAttrId),
saleAttrName,
spuSaleAttrValueList: []
})
// 清空收集的销售属性
attrIdAttrName.value = ""
}
// 删除销售属性
const deleteSpuSaleAttr = (index: number) => {
spuForm.value.spuSaleAttrList.splice(index, 1)
}
const saleAttrList = computed(() => { // 真正展示的数据,通过计算属性得出
let arr = baseSaleAttrList.value.filter(row => { // row是原始数据中的每个成员 row -> { id: xx, name: xx }
// 过滤条件: 表格中有当前这个row的name时,应该过滤掉,没有的话应该留下来
// 去表格中的数据找有没有row这个name
// spuForm.value.spuSaleAttrList - 表格数据
// some方法是只要有一个符合回调条件,返回true
// isExist 为true代表表格存在当前销售属性原始数据 row
const isExist = spuForm.value.spuSaleAttrList.some(item => { // item是表格中的每个成员 -> { baseSaleAttrId, saleAttrName, spuSaleAttrValueList }
return item.saleAttrName == row.name
})
// const isExist1 = spuForm.value.spuSaleAttrList.map(item => item.saleAttrName).includes(row.name)
return !isExist
})
return arr;
})
```
##### 销售属性值列表的添加和删除
- 点击"销售属性值列表"中的"新增"按钮
- 切换input框显示
在"销售属性"这条数据row,当中添加一个 inputVisible 来控制 input 和 div 的显示和隐藏
- 自动聚焦(input展示的时候自动聚焦,在nextTick中,通过实例调用 focus() 方法聚焦)
```js
新增
const InputRef = ref(); // 自动聚焦
const showInput = (row: SpuSaleAttrModel) => { // row是当前的销售属性
row.inputVisible = true
nextTick(() => {
InputRef.value?.focus()
})
}
```
- 当失焦的时候
- 空不能被添加上,重复不能添加,重复的给提示
- 切换input显示按钮
- 将input输入的内容,加到属性值列表
```js
const handleInputConfirm = (row: SpuSaleAttrModel) => { // row是当前的销售属性
// 对空值进行判断,没有值直接切换回来即可
if (!row.inputValue) {
row.inputVisible = false
return
}
// 重复
let isRepate = row.spuSaleAttrValueList.map(item => item.saleAttrValueName).includes(row.inputValue)
if (isRepate) {
row.inputVisible = false
row.inputValue = "" // 数据已经被收集起来了,这里应该清空
ElMessage.error('输入的内容不能重复,请重新输入')
return
}
row.inputVisible = false // 切换显示div
// row.spuSaleAttrValueList 销售属性值列表
row.spuSaleAttrValueList.push({
baseSaleAttrId: row.baseSaleAttrId,
saleAttrValueName: row.inputValue!
})
row.inputValue = "" // 数据已经被收集起来了,这里应该清空
}
```
- "属性值列表"数据应该循环展示,当点击 tag 标签上的 "x" 按钮的时候,删除掉当前的属性值
```js
{{ attrVal.saleAttrValueName }}
// 删除销售属性值
const handleClose = (index: number, spuSaleAttrValueList: SpuSaleAttrValueModel[]) => {
spuSaleAttrValueList.splice(index, 1)
}
```
#### 新增保存销售属性
```js
const onSave = async () => {
// 组装数据
// 三级分类的收集
spuForm.value.category3Id = categoryStore.category3Id;
// 图片列表数据收集
let tempSpuImageList = spuImageList.value.map(item => {
return {
imgName: item.name,
imgUrl: item.response.data
}
})
spuForm.value.spuImageList = tempSpuImageList as SpuImageModel[];
// 删除掉 销售属性 row 中的inputVisible 和 inputValue(后端不需要这两个数据,传给后端也没事)
spuForm.value.spuSaleAttrList.forEach(item => {
delete item.inputValue
delete item.inputVisible
})
// 简单校验(每个项都有值就通过,没有值给个提示即可)
const { spuName, description, tmId, category3Id, spuSaleAttrList } = spuForm.value;
if ( !(spuName && description && tmId && category3Id && spuForm.value.spuImageList.length && spuSaleAttrList.length) ) {
ElMessage.error('请检查SPU名称、品牌、描述、三级分类、图片列表和销售属性数据')
return
}
// 发送请求
try {
await spuApi.saveSpuInfo(spuForm.value)
ElMessage.success('保存成功')
emits('update:modelValue', STATUS.SPULIST)
} catch (error) {
ElMessage.error('保存失败,请重试')
}
}
```
> 目前取消再次点击添加的时候,表单为空,不需要单独的做处理
##### 新增Spu按钮限制
只有三级分类id存在的时候才能点击"新增spu"按钮,这个功能在主列表中
```html
添加SPU
```
### 编辑
当点击编辑按钮的时候,此时要回显当前这一条 spu 的数据,但是分页数据拿的时候没有 图片列表 和 销售属性的数据,需要在页面初始化的时候发请求拿这两个数据
1. 回显数据
把当前的这条数据(存在于SpuList组件)赋值给 SpuForm 组件中的 spuForm 数据
SpuList 组件 和 SpuForm 组件是兄弟关系,把当前的 row先传给父组件,在通过父组件传给 SpuForm 组件
SpuList组件
```js
const emits = defineEmits<{
(e: 'update:modelValue', n: number): void
(e: 'changeSpuInfo', spu: SpuModel): void // ----
}>()
// 编辑spu
const editSpu = (row: SpuModel) => {
emits('changeSpuInfo', row)
}
```
父组件
```js
// initSpuInfo 仅仅使用回来初始化一个spu用的
const initSpuInfo = ():SpuModel => ({
spuName: '',
description: '',
category3Id: undefined, // 三级分类的id两个位置赋值,要么初始化赋值,保存前组装数据赋值
tmId: undefined,
spuSaleAttrList: [],
spuImageList: []
})
const spuInfo = ref(initSpuInfo()) // 传给子组件
const changeSpuInfo = (spu: SpuModel) => {
spuInfo.value = spu; // 接收到主列表传过来的spu,在传给SpuForm即可
}
```
SpuForm 组件
```js
const props = defineProps<{ // 编辑的时候接收父组件传过来的spu数据
spuInfo: SpuModel
}>()
```
2. 初始化数据 拿 图片列表 和 销售属性的数据
> 在写代码的过程中又去修改了TS的类型,如果以后想把TS类型一次性写好,先看编辑的数据,因为编辑的数据永远比新增的数据多而全
准备根据 spuId 获取图片列表接口 和 销售属性接口
```js
// 根据spuId获取spu图片列表
getSpuImageListBySpuId(spuId: number) {
return request.get(`/admin/product/spuImageList/${ spuId }`)
},
// 根据spuId获取spu销售属性
getSaleAttrListBySpuId(spuId: number) {
return request.get(`/admin/product/spuSaleAttrList/${ spuId }`)
}
```
> 注意:需要再次修改TS类型
页面mounted的时候发起请求拿 图片和销售属性数据
```js
// 拿编辑spu的图片列表数据
const initSpuImageData = async () => {
try {
let result = await spuApi.getSpuImageListBySpuId(spuForm.value.id!)
spuImageList.value = result.map(item => {
return {
...item,
name: item.imgName, // name和url属性是组件预览必要的属性,否则预览不出来
url: item.imgUrl
}
});
} catch (error) {
ElMessage.error('获取图片列表数据失败,请重试')
}
}
// 拿编辑spu销售属性数据
const initSaleAttrData = async () => {
try {
let result = await spuApi.getSaleAttrListBySpuId(spuForm.value.id!)
spuForm.value.spuSaleAttrList = result;
} catch (error) {
ElMessage.error('获取销售属性数据失败,请重试')
}
}
// 初始化数据展示
onMounted(() => {
initTrademarkDate(); // 初始化品牌下拉
initBaseSaleAttrDate(); // 初始化销售属性下拉下拉
if (props.spuInfo.id) { // 有id代表的是编辑
spuForm.value = cloneDeep(props.spuInfo) // 回显数据
initSpuImageData(); // 拿编辑spu的图片列表数据
initSaleAttrData(); // 拿编辑spu销售属性数据
}
})
```
##### 编辑保存
此时有一个报错,计算销售属性下拉中有个 null.属性 的错误,加一个问号解决
```js
const isExist = spuForm.value.spuSaleAttrList?.some
```
还有一个报错,点击保存按钮的时候,图片组装数据报错
```js
let tempSpuImageList = spuImageList.value.map(item => {
return {
imgName: item.name,
imgUrl: item.imgUrl || item.response.data // 什么时候有response?什么时候没有response? 新增图片都有response,编辑回显的图片都没有response
}
})
```
准备了编辑保存的api
```js
updateSpuInfo(spuInfo: SpuModel) {
return request.post(`/admin/product/updateSpuInfo`, spuInfo)
}
```
在点击保存按钮最后调用接口的时候,通过判断 id 是否存在,来决定调用哪个接口
```js
try {
if (spuForm.value.id) { // 有id代表是编辑
await spuApi.updateSpuInfo(spuForm.value)
} else { // 没有id代表是新增
await spuApi.saveSpuInfo(spuForm.value)
}
}
```
遗留问题:不管编辑保存还是取消,当再次点击新增的时候,数据还是存在,原因是 父组件的 `spuInfo` 数据还存在
```js
取消
const emits = defineEmits<{
(e: 'update:modelValue', n: number): void,
(e: 'changeSpuInfo'): void // 用来清除父组件中的spuInfo,当点击保存或取消的时候都应该清除
}>()
const cancelSave = () => {
emits('update:modelValue', STATUS.SPULIST)
emits('changeSpuInfo')
}
```
父组件
```js
const changeSpuInfo = (spu: SpuModel | undefined) => {
spuInfo.value = spu ? spu : initSpuInfo(); // 接收到主列表传过来的spu,在传给SpuForm即可
}
```
### 删除SPU
注意:双重校验
1. 准备api(写TS类型)
2. 点击按钮,调用接口
# day06
### 新增SKU
点击spu数据中的"新增SKU"按钮
1. 页面切换 - 页面由 SpuList 切换到 SkuForm 组件
在切换的过程中,发现 SkuForm 页面(新增sku页面)有展示 SpuName 属性,需要把当前的 spu 传递到 SkuForm 组件中
```
// spuList - 新增sku
const addSku = (row: SpuModel) => {
emits('update:modelValue', STATUS.SKUFORM)
emits('changeSpuInfo', row) // 传要修改的spu数据
}
// 父组件
// skuForm组件接收参数
const props = defineProps<{
spuInfo: SpuModel
}>()
```
2. 搭建静态
3. 初始化数据展示
平台属性、销售属性、图片列表三个几口已经存在,在页面初始化的时候调用即可拿到数据,拿到数据即可展示
```
// 平台属性
const attrList = ref([])
const initAttrList = async () => {
try {
const { category1Id, category2Id, category3Id } = categoryStore;
let result = await attrApi.getAttrInfoList(category1Id!, category2Id!, category3Id as number)
attrList.value = result
} catch (error) {
ElMessage.error('获取平台属性数据失败,请重试')
}
}
// 销售属性
const saleAttrList = ref([])
const initSaleAttrList = async () => {
try {
let result = await spuApi.getSaleAttrListBySpuId(props.spuInfo.id!)
saleAttrList.value = result;
} catch (error) {
ElMessage.error('获取销售属性数据失败,请重试')
}
}
// 图片列表
const spuImageList = ref([])
const initSpuImageList = async () => {
try {
let result = await spuApi.getSpuImageListBySpuId(props.spuInfo.id!)
spuImageList.value = result;
} catch (error) {
ElMessage.error('获取图片列表数据失败,请重试')
}
}
// 初始化数据
const initData = () => {
initAttrList(); // 平台属性
initSaleAttrList(); // 销售属性
initSpuImageList(); // 图片列表
}
onMounted(() => {
initData()
})
```
4. 收集数据,点击保存
- 准备api
- 收集数据
- 普通数据直接使用 v-model 收集即可
- 平台属性、销售属性收集需要收集 `属性id:属性值id`
在收集的时候,展示的数据value值进行拼接,收集数据收到 初始化数据的数组中了
当点击保存的时候,组装数据从初始化数据的数据中把选择到的值进行组装
- 调用api保存数据
### 主列表展示SKU列表弹框
1. 点击"展示sku列表"按钮,弹出 dialog
2. dialog 中表格静态搭建
3. 准备api(已经存在)
4. 弹框出的回调中发请求,拿数据,展示到表格中
## SKU管理
1. 静态搭建
2. 初始化数据展示
- api准备(已经准备完毕了)
- mounted 的时候 调用接口拿数据在页面中展示即可
3. 交互
- 上下架
点击按钮调用接口
- 查看当前sku相信弹框
### style标签上scoped的作用

# day07
参考"权限相关"内容
# day08
参考"拓展"文档