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