Skip to content

Vue3 硅谷甄选(后台管理系统)

一、技术选型

  • vue3框架:采用vue框架最新版本,组合式API形式开发项目。
  • vite:构建化工具
  • TypeScript:TypeScript技术栈
  • vue-router:采用vue-router最新版本管理路由
  • pinia:采用pinia集中式管理状态
  • element-plus:UI组件库采用element-plus
  • axios:网络交互
  • echarts:数据可视化大屏.....

二、搭建后台管理系统模板

2.1 初始化项目

一个项目要有统一的规范,需要使用 eslint+stylelint+prettier 来对我们的代码质量做检测和修复,需要使用 husky 来做 commit 拦截,需要使用 commitlint 来统一提交规范,需要使用 preinstall 来统一包管理工具。

2.1.1 环境准备
  • Vite 需要 Node.js 版本 18+,20+
  • pnpm 9.0.5
2.1.2初始化项目

本项目使用vite进行构建,vite官方中文文档参考:cn.vitejs.dev/guide/

使用 pnpm 包管理工具 pnpm:performant npm (高性能的 npm)。

pnpm由npm/yarn衍生而来,解决了npm/yarn内部潜在的bug,极大的优化了性能,扩展了使用场景。被誉为最先进的包管理工具

pnpm安装指令:

npm i -g pnpm

项目初始化:

pnpm create vite

pnpm create vue@latest  # vite的vue定制版

![image-20240423175720094](./Vue3 硅谷甄选.assets/image-20240423175720094.png)

进入项目根目录 pnpm i 安装依赖,运行项目 pnpm run dev

2.1.3 项目运行浏览器自动打开

package.json

json
"scripts": {
    "dev": "vite --open", // --open启动项目后自动打开浏览器
    .......
  },

2.2 eslint 配置

2.2.1 安装 eslint

eslint中文官网:http://eslint.cn/

ESLint最初是由Nicholas C. Zakas 于2013年6月创建的开源项目。它的目标是提供一个插件化的javascript代码检测工具

  1. 首先安装 eslint:

    pnpm i eslint -D
  2. 生成配置文件:.eslint.cjs

    npx eslint --init

    ![image-20240424141041839](./Vue3 硅谷甄选.assets/image-20240424141041839.png)

    js
    module.exports = {
       //运行环境
        "env": { 
            "browser": true,//浏览器端
            "es2021": true,//es2021
        },
        //规则继承
        "extends": [ 
           //全部规则默认是关闭的,这个配置项开启推荐规则,推荐规则参照文档
           //比如:函数不能重名、对象不能出现重复key
            "eslint:recommended",
            //vue3语法规则
            "plugin:vue/vue3-essential",
            //ts语法规则
            "plugin:@typescript-eslint/recommended"
        ],
        //要为特定类型的文件指定处理器
        "overrides": [
        ],
        //指定解析器:解析器
        //Esprima 默认解析器
        //Babel-ESLint babel解析器
        //@typescript-eslint/parser ts解析器
        "parser": "@typescript-eslint/parser",
        //指定解析器选项
        "parserOptions": {
            "ecmaVersion": "latest",//校验ECMA最新版本
            "sourceType": "module"//设置为"script"(默认),或者"module"代码在ECMAScript模块中
        },
        //ESLint支持使用第三方插件。在使用插件之前,您必须使用npm安装它
        //该eslint-plugin-前缀可以从插件名称被省略
        "plugins": [
            "vue",
            "@typescript-eslint"
        ],
        //eslint规则
        "rules": {
        }
    }

    注意:.eslint.cjs 已经被弃用,现在生成的为eslint.config.js 文件

安装如下生产依赖并自行创建.eslint.cjs文件

json
"devDependencies": {
    "@babel/eslint-parser": "^7.21.3",
    "@typescript-eslint/eslint-plugin": "^5.57.1",
    "@typescript-eslint/parser": "^5.57.1",
    "@vitejs/plugin-vue": "^5.0.4",
    "eslint": "^8.38.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-prettier": "^5.1.3",
    "eslint-plugin-vue": "^9.10.0",
    "prettier": "^3.2.5",
    "typescript": "^4.9.3",
    "vite": "^5.2.0",
    "vue-tsc": "^2.0.6"
  }
2.2.2 Vue3环境代码校验插件
pnpm install -D eslint-plugin-import eslint-plugin-vue eslint-plugin-node eslint-plugin-prettier eslint-config-prettier eslint-plugin-node @babel/eslint-parser
php
# 让所有与prettier规则存在冲突的Eslint rules失效,并使用prettier进行代码检查
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-node": "^11.1.0",
# 运行更漂亮的Eslint,使prettier规则优先级更高,Eslint优先级低
"eslint-plugin-prettier": "^4.2.1",
# vue.js的Eslint插件(查找vue语法错误,发现错误指令,查找违规风格指南
"eslint-plugin-vue": "^9.9.0",
# 该解析器允许使用Eslint校验所有babel code
"@babel/eslint-parser": "^7.19.1",
2.2.3 修改.eslintrc.cjs配置文件
js
// @see https://eslint.bootcss.com/docs/rules/
module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
    jest: true,
  },
  /* 指定如何解析语法 */
  parser: 'vue-eslint-parser',
  /* 优先级低于 parse 的语法解析配置 */
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    parser: '@typescript-eslint/parser',
    jsxPragma: 'React',
    ecmaFeatures: {
      jsx: true,
    },
  },
  /* 继承已有的规则 */
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-essential',
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  plugins: ['vue', '@typescript-eslint'],
  /*
   * "off" 或 0    ==>  关闭规则
   * "warn" 或 1   ==>  打开的规则作为警告(不影响代码执行)
   * "error" 或 2  ==>  规则作为一个错误(代码不能执行,界面报错)
   */
  rules: {
    // eslint(https://eslint.bootcss.com/docs/rules/)
    'no-var': 'error', // 要求使用 let 或 const 而不是 var
    'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    'no-unexpected-multiline': 'error', // 禁止空余的多行
    'no-useless-escape': 'off', // 禁止不必要的转义字符

    // typeScript (https://typescript-eslint.io/rules)
    '@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
    '@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
    '@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
    '@typescript-eslint/no-non-null-assertion': 'off',
    '@typescript-eslint/no-namespace': 'off', // 禁止使用自定义 TypeScript 模块和命名空间。
    '@typescript-eslint/semi': 'off',

    // eslint-plugin-vue (https://eslint.vuejs.org/rules/)
    'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
    'vue/script-setup-uses-vars': 'error', // 防止<script setup>使用的变量<template>被标记为未使用
    'vue/no-mutating-props': 'off', // 不允许组件 prop的改变
    'vue/attribute-hyphenation': 'off', // 对模板中的自定义组件强制执行属性命名样式
  },
}
2.2.4 .eslintignore忽略文件
dist
node_modules
2.2.5 运行脚本

package.josn 新增两个运行脚本

json
"scripts": {
    "lint": "eslint src", // 对src目录下的代码进行校验
    "fix": "eslint src --fix",// 对src目录下不符合规范的代码进行自动修改
}

校验代码:npm run lint

自动修改:npm run fix

2.3 prettier 配置

有了eslint,为什么还要有 prettier

eslint 是针对 javascript的一个语法检测工具,包含js语法以及少部分格式问题,在 eslint 看来,语法对了就能保证代码正常运行,格式问题属于其次;

prettier 属于格式化工具,用于统一代码格式,所以它就把eslint没干好的事接着干,另外,prettier支持包含js在内的多种语言。

总结,eslint和prettier这俩兄弟一个保证js代码质量,一个保证代码美观。

2.3.1 安装依赖包
pnpm install -D eslint-plugin-prettier prettier eslint-config-prettier
2.3.2 .pretterrc.json 添加规则
json
{
  "singleQuote": true, // 字符串都是单引号
  "semi": false,	// 不使用语句最后的分号
  "bracketSpacing": true,
  "htmlWhitespaceSensitivity": "ignore",
  "endOfLine": "auto",
  "trailingComma": "all",
  "tabWidth": 2 // 缩进两字符
}

Tips:使用 pretter插件配合 .pretterrc.json 配置文件实现使用 alt+shift+f 格式化

2.3.3 .pretterignore 忽略文件
/dist/*
/html/*
.local
/node_modules/**
**/*.svg
**/*.sh
/public/*

检查语法:pnpm run lint

进行修改:pnpm run fix

2.4 stylelint 配置

stylelint为css的lint工具。可格式化css代码,检查css语法错误与不合理的写法,指定css书写顺序等。

我们的项目中使用scss作为预处理器,安装以下依赖:

pnpm add sass sass-loader stylelint postcss postcss-scss postcss-html stylelint-config-prettier stylelint-config-recess-order stylelint-config-recommended-scss stylelint-config-standard stylelint-config-standard-vue stylelint-scss stylelint-order stylelint-config-standard-scss -D
2.4.1 创建.stylelintre.cjs 配置文件

官网:https://stylelint.bootcss.com/

json
// @see https://stylelint.bootcss.com/

module.exports = {
  extends: [
    'stylelint-config-standard', // 配置stylelint拓展插件
    'stylelint-config-html/vue', // 配置 vue 中 template 样式格式化
    'stylelint-config-standard-scss', // 配置stylelint scss插件
    'stylelint-config-recommended-vue/scss', // 配置 vue 中 scss 样式格式化
    'stylelint-config-recess-order', // 配置stylelint css属性书写顺序插件,
    'stylelint-config-prettier', // 配置stylelint和prettier兼容
  ],
  overrides: [
    {
      files: ['**/*.(scss|css|vue|html)'],
      customSyntax: 'postcss-scss',
    },
    {
      files: ['**/*.(html|vue)'],
      customSyntax: 'postcss-html',
    },
  ],
  ignoreFiles: [
    '**/*.js',
    '**/*.jsx',
    '**/*.tsx',
    '**/*.ts',
    '**/*.json',
    '**/*.md',
    '**/*.yaml',
  ],
  /**
   * null  => 关闭该规则
   * always => 必须
   */
  rules: {
    'value-keyword-case': null, // 在 css 中使用 v-bind,不报错
    'no-descending-specificity': null, // 禁止在具有较高优先级的选择器后出现被其覆盖的较低优先级的选择器
    'function-url-quotes': 'always', // 要求或禁止 URL 的引号 "always(必须加上引号)"|"never(没有引号)"
    'no-empty-source': null, // 关闭禁止空源码
    'selector-class-pattern': null, // 关闭强制选择器类名的格式
    'property-no-unknown': null, // 禁止未知的属性(true 为不允许)
    'block-opening-brace-space-before': 'always', //大括号之前必须有一个空格或不能有空白符
    'value-no-vendor-prefix': null, // 关闭 属性值前缀 --webkit-box
    'property-no-vendor-prefix': null, // 关闭 属性前缀 -webkit-mask
    'selector-pseudo-class-no-unknown': [
      // 不允许未知的选择器
      true,
      {
        ignorePseudoClasses: ['global', 'v-deep', 'deep'], // 忽略属性,修改element默认样式的时候能使用到
      },
    ],
  },
}
2.4.2 .stylelintignore忽略文件
/node_modules/*
/dist/*
/html/*
/public/*
2.4.3 运行脚本

最后配置统一的prettier来格式化我们的 js 和css,html代码

js
 "scripts": {
    "dev": "vite --open",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint src",
    "fix": "eslint src --fix",
    "format": "prettier --write \"./**/*.{html,vue,ts,js,json,md}\"",
    "lint:eslint": "eslint src/**/*.{ts,vue} --cache --fix",
    "lint:style": "stylelint src/**/*.{css,scss,vue} --cache --fix"
  },

代码格式化pnpm run format

2.5 husky 配置

用于强制让开发人员按照代码规范来提交。

husky在代码提交之前触发git hook(git在客户端的钩子),然后执行pnpm run format来自动的格式化我们的代码。

2.5.1 安装 husky
pnpm install -D husky
2.5.2 配置提交前格式化
  1. 执行:npx husky-init

    会在根目录下生成个一个 .husky 目录,在这个目录下面会有一个pre-commit 文件,这个文件里面的命令在我们执行 commit 的时候就会执行

  2. .husky/pre-commit文件添加如下命令:

js
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm run format

当我们对代码进行commit操作的时候,就会执行命令,对代码进行格式化,然后再提交。

2.5.3 配置提交规范

对于我们使用 git提交的commit 信息,也是有统一规范的,不能随便写,要让每个人都按照统一的标准来执行,我们可以利用commitlint来实现。

  1. 安装依赖
pnpm add @commitlint/config-conventional @commitlint/cli -D
  1. 添加配置文件

新建 commitlint.config.cjs 添加如下代码:

js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  // 校验规则
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',
        'fix',
        'docs',
        'style',
        'refactor',
        'perf',
        'test',
        'chore',
        'revert',
        'build',
      ],
    ],
    'type-case': [0],
    'type-empty': [0],
    'scope-empty': [0],
    'scope-case': [0],
    'subject-full-stop': [0, 'never'],
    'subject-case': [0, 'never'],
    'header-max-length': [0, 'always', 72],
  },
}
  1. 配置命令

package.json 中配置 script 命令

js
# 在scrips中添加下面的代码
{
"scripts": {
    "commitlint": "commitlint --config commitlint.config.cjs -e -V"
  },
}

配置结束,现在当我们填写commit信息的时候,前面就需要带着下面的subject

js
'feat',//新特性、新功能
'fix',//修改bug
'docs',//文档修改
'style',//代码格式修改, 注意不是 css 修改
'refactor',//代码重构
'perf',//优化相关,比如提升性能、体验
'test',//测试用例修改
'chore',//其他修改, 比如改变构建流程、或者增加依赖库、工具等
'revert',//回滚到上一个版本
'build',//编译相关的修改,例如发布版本、对项目构建或者依赖的改动
  1. 配置husky
npx husky add .husky/commit-msg

husky文件夹下生成的 commit-msg 文件中添加下面的命令

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm commitlint

当我们 commit 提交信息时,就不能再随意写了,必须是 git commit -m 'fix: xxx' 符合类型的才可以

注意:类型的后面需要用英文的 :,并且冒号后面是需要空一格的,这个是不能省略的

2.6强制使用 pnpm 包管理工具

团队开发项目的时候,需要统一包管理器工具,因为不同包管理器工具下载同一个依赖,可能版本不一样,导致项目出现bug问题,因此包管理器工具需要统一管理!!!

2.6.1 创建 scritps/preinstall.js 文件

在项目根目录创建 scritps/preinstall.js 文件,添加如下内容:

js
if (!/pnpm/.test(process.env.npm_execpath || '')) {
  console.warn(
    `\u001b[33mThis repository must using pnpm as the package manager ` +
    ` for scripts to work properly.\u001b[39m\n`,
  )
  process.exit(1)
}
2.6.2 配置命令
js
"scripts": {
	"preinstall": "node ./scripts/preinstall.js"
}

当我们使用npm或者yarn来安装包的时候,就会报错了。原理就是在install的时候会触发preinstall(npm提供的生命周期钩子)这个文件里面的代码。

三、项目集成

3.1 集成 element-plus

硅谷甄选运营平台,UI组件库采用的element-plus,因此需要集成element-plus插件!!!

官网地址:https://element-plus.gitee.io/zh-CN/

3.1.1 安装 element-plus
pnpm i element-plus
3.1.2 完整引入
js
// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 引入element-plus插件与样式
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

// 获取应用实例对象
const app = createApp(App)
// 安装ElementPlus插件
app.use(ElementPlus)
// 将应用挂载到 #app 节点
app.mount('#app')

自动按需引入:(推荐)

pnpm install -D unplugin-vue-components unplugin-auto-import

vite.config.ts

js
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  // ...
  plugins: [
    // ...
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})
3.1.3 安装ElementUI代码提示插件

![image-20240424170155461](./Vue3 硅谷甄选.assets/image-20240424170155461.png)

3.1.4 安装ElementPlus提供的图标组件库
pnpm i @element-plus/icons-vue

注册所有图标组件

main.ts 或 在注册全局组件的钩子中添加以下代码

ts
// 引入element-plus提供全部的图标组件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

// 将element-plus的图标注册为全局组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}
3.1.4 element-plus默认使用英语设置为中文
ts
import { createApp } from 'vue'
import App from './App.vue'
// 引入element-plus插件与样式
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
//@ts-ignore 忽略当前文件ts类型的检测否则有红色提示(打包会失败)
// 配置element-plus国际化
import zhCn from 'element-plus/es/locale/lang/zh-cn'

// 获取应用实例对象
const app = createApp(App)
// 安装ElementPlus插件,并使用中文语言
app.use(ElementPlus, {
  locale: zhCn,
})
// 将应用挂载到 #app 节点
app.mount('#app')

3.2 src别名的配置

在开发项目的时候文件与文件关系可能很复杂,因此我们需要给src文件夹配置一个别名!!! 使用 @ 代替 src

  1. 修改 vite.config.ts 文件

    js
    // vite.config.ts
    import {defineConfig} from 'vite'
    import vue from '@vitejs/plugin-vue'
    import { fileURLToPath, URL } from "node:url";
    export default defineConfig({
        plugins: [vue()],
        resolve: {
            alias: {
              "@": fileURLToPath(new URL("./src", import.meta.url)),
            }
        }
    })
  2. 修改 tsconfig.json

    json
    // tsconfig.json
    {
      "compilerOptions": {
        "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
        "paths": { //路径映射,相对于baseUrl
          "@/*": ["src/*"] 
        }
      }
    }

3.3 环境变量的配置

项目开发过程中,至少会经历开发环境测试环境生产环境三个阶段。不同阶段请求的状态(如接口地址等)不尽相同,若手动切换接口地址是相当繁琐且易出错的。我们只需做简单的配置,把环境状态切换的工作交给代码。

  • 开发环境(development) 开发使用的环境,每位开发人员在自己的dev分支上干活,开发到一定程度,同事会合并代码,进行联调。

  • 测试环境(testing) 测试同事干活的环境,一般会由测试同事自己来部署,然后在此环境进行测试

  • 生产环境(production) 生产环境是指正式提供对外服务的,一般会关掉错误报告,打开错误日志。(正式提供给客户使用的环境。)

注意:一般情况下,一个环境对应一台服务器,也有的公司开发与测试环境是一台服务器!!!

3.3.1 项目根目录分别添加 开发、生产和测试环境的文件
.env.development // 开发环境
.env.production // 生产环境
.env.test // 测试环境

文件内容:

.env.development

# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
VITE_APP_TITLE = '硅谷甄选运营平台'
VITE_APP_BASE_API = '/dev-api' // 发请求的基本路径  上线服务器的路径

.env.production

NODE_ENV = 'production'
VITE_APP_TITLE = '硅谷甄选运营平台'
VITE_APP_BASE_API = '/prod-api'// 发请求的基本路径  代理服务器前缀

.env.test

# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'test'
VITE_APP_TITLE = '硅谷甄选运营平台'
VITE_APP_BASE_API = '/test-api'
3.3.2 配置运行命令
json
 "scripts": {
    "dev": "vite --open",
    "build:test": "vue-tsc && vite build --mode test",
    "build:pro": "vue-tsc && vite build --mode production",
    "preview": "vite preview"
  },

通过 import.meta.env 获取环境变量

3.4 SVG 图标配置与注册全局组件

在开发项目的时候经常会用到svg矢量图,而且我们使用SVG以后,页面上加载的不再是图片资源,这对页面性能来说是个很大的提升,而且我们SVG文件比img要小的很多,放在项目中几乎不占用资源。

3.4.1 安装 SVG 依赖插件
pnpm install vite-plugin-svg-icons -D
2.4.2 在 vite.config.ts 中配置插件
js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入svg需要用到的插件
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

export default defineConfig({
  plugins: [
    vue(),
    // 使用svg插件
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      symbolId: 'icon-[dir]-[name]',
    }),
  ],
  resolve: {
    alias: {
      '@': path.resolve('./src'), // 相对路径别名配置,使用 @ 代替 src
    },
  },
})
2.4.3 入口文件导入

main.ts

js
import 'virtual:svg-icons-register'
2.4.4 sgv 图标使用
vue
<template>
  <div>
    <h1>SVG测试</h1>
    <!-- svg:图标外层容器节点,内部需要与use标签结合使用 -->
    <svg style="width: 100px; height: 100px"> <!-- 设置图标的大小 -->
      <!-- xlink:href="#icon-svg文件名" 指定用哪个图标 -->
      <!-- use标签的fill属性可以设置图标的颜色 -->
      <use xlink:href="#icon-dumplings" fill="pink">	  </use>
    </svg>
  </div>
</template>
2.4.5 svg封装为全局组件

因为要在很多模块中使用图标,因此将其封装为全局组件(在任何组件中都可以直接使用,无需引入)

  1. src/components 目录下创建一个 SvgIcon 组件:
vue
<template>
  <!-- svg:图标外层容器节点,内部需要与use标签结合使用 -->
  <svg :style="{width,height}">
    <!-- 设置图标的大小 -->
    <!-- xlink:href="#icon-svg文件名" 指定用哪个图标 -->
    <!-- use标签的fill属性可以设置图标的颜色 -->
    <use :xlink:href="prefix + name" :fill="color"></use>
  </svg>
</template>

<script setup lang="ts">
// 接收父组件传递过来的参数
defineProps({
  // xlink:href属性值的前缀
  prefix: {
    type: String,
    default: '#icon-',
  },
  // 提供使用的图标的名字
  name: String,
  // 接收父组件传递的颜色
  color: {
    type: String,
    default: '',
  },
  // 接收父组件传递的尺寸
  width: {
    type: String,
    default: '18px',
  },
  height: {
    type: String,
    default: '18px',
  },
})
</script>
  1. src/components文件夹目录下创建一个 index.ts 文件:用于注册 Components 文件夹内部全部全局组件
js
// 引入项目中的全部的全局组件
import SvgIcon from '@/components/SvgIcon/Index.vue'
import Pagination from '@/components/Pagination/Index.vue'
import { App, Component } from 'vue'

// 创建一个全局组件的对象
const allGlobalComponent: { [name: string]: Component } = {
  SvgIcon,
  Pagination,
}

// 对外暴露一个插件对象
export default {
  // 插件对象必须有一个install方法,且接收一个app对象作为参数
  install(app: App) {
    // 注册项目的全部全局组件
    Object.keys(allGlobalComponent).forEach((item: string) => {
      app.component(item, allGlobalComponent[item])
    })
  },
}
  1. main.ts 中引入 index.ts 自定义插件并使用
ts
// 引入自定义插件对象:注册整个项目的全局组件
import globalComponent from '@/components/index'
// 安装自定义插件
app.use(globalComponent)

3.5 组件 name 属性设置

组件名默认为文件名 ,组件名单个单词首字母大写,多个单词使用 kebab-case命名或CamelCase命名

<RoterView/><router-view/> 表示同一个组件

本项目采用文件夹作为组件名,所有组件统一命名为 Index.vue 为了在 Vue 开发者工具中便于区分不同的组件,为每个组件添加一个 name 属性

3.5.1 安装 vite-plugin-vue-setup-extend 插件
pnpm i vite-plugin-vue-setup-extend -D
3.5.2 配置 vite.config.ts
ts
.....
// 1.引入
import VueSetupExtend from "vite-plugin-vue-setup-extend"

export default defineConfig({
  plugins: [
    vue(),
    // 2.使用
    VueSetupExtend(),
  ],
.....
3.5.3 在 script 标签中指定组件名
vue
<script setup lang="ts" name="Person">..</script>

3.6 集成sass

3.6.1 使用 scss

我们目前在组件内部已经可以使用 scss 样式,因为在配置styleLint 工具的时候,项目当中已经安装过 sass sass-loader,因此我们再组件内可以使用 scss 语法!!!需要加上lang="scss"

vue
<style scoped lang="scss"></style>
3.6.2 为项目添加一些全局样式
  1. scr/styles 目录下创建一个 index.scss 文件,项目中需要用到清除默认样式,因此在 index.scss 内引入 reset.scss

    scss
    @import "./reset.scss"
  2. npm 中搜索 reset.scss 复制代码,放在 src/reset.scss 文件中或使用 normalize.css推荐

  3. main.ts 中引入 index.scss

    js
    // 引入模板的全局的样式
    import "@/styles/index.scss"

但是此时在 src/styles/index.scss全局样式文件中无法使用 $ 变量

3.6.3 为项目中引入全局变量 $
  1. styles/variable.scss 创建一个 variable.scss 文件,用于配置全局 scss 变量(可以在任何一个组件的 style 标签中使用)

    scss
    // 项目提供的全局变量可以在任意组件中使用
    $base-color:red;
  2. 配置 vite.config.ts 文件:

    js
    export default defineConfig((config) => {
        ....
    	css: {
          preprocessorOptions: {
            scss: {
              javascriptEnabled: true,
              additionalData: '@import "./src/styles/variable.scss";',
            },
          },
        },
        ....
    	})

    @import "./src/styles/variable.scss"; 此文件与用于存储全局变量的文件名相同,且最后的 ; 不能少

配置完毕后就可以在任意组件的 style 标签中使用 variable.scss 中定义的全局变量了

3.7 mock 数据

3.7.1 安装依赖vite-plugin-mock
pnpm install -D vite-plugin-mock mockjs
3.7.2 在vite.config.ts 配置文件启用插件
js
import { UserConfigExport, ConfigEnv } from 'vite'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig(({ command }) => {
  return {
    plugins: [
      ......
      // 使用mock插件
      viteMockServe({
        // 保证开发阶段可以使用mock接口
        localEnabled: command === 'serve',
      }),
    ],
    ......
  }
})
3.7.3 mock 数据

在根目录下创建 mock 文件夹:去创建需要 mock 数据与接口

mock 文件夹内部创建一个 user.ts 文件,实现模拟两个用户数据和两个接口

js
//此函数执行会返回一个数组,数组内部包含两个用户信息
function createUserList() {
  return [
    {
      userId: 1,
      avatar:
        'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
      username: 'admin',
      password: '111111',
      desc: '平台管理员',
      roles: ['平台管理员'],
      buttons: ['cuser.detail'],
      routes: ['home'],
      token: 'Admin Token',
    },
    {
      userId: 2,
      avatar:
        'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
      username: 'system',
      password: '111111',
      desc: '系统管理员',
      roles: ['系统管理员'],
      buttons: ['cuser.detail', 'cuser.user'],
      routes: ['home'],
      token: 'System Token',
    },
  ]
}

// 对外暴露一个数组:数组内包含两个接口
// 登录假的接口
// 获取用户信息的假的接口
export default [
  // 用户登录接口
  {
    url: '/api/user/login', //请求地址
    method: 'post', //请求方式
    response: ({ body }) => {
      //获取请求体携带过来的用户名与密码
      const { username, password } = body
      //调用获取用户信息函数,用于判断是否有此用户
      const checkUser = createUserList().find(
        (item) => item.username === username && item.password === password,
      )
      //没有用户返回失败信息
      if (!checkUser) {
        return { code: 201, data: { message: '账号或者密码不正确' } }
      }
      //如果有返回成功信息
      const { token } = checkUser
      return { code: 200, data: { token } }
    },
  },
  // 获取用户信息
  {
    url: '/api/user/info',
    method: 'get',
    response: (request) => {
      //获取请求头携带token
      const token = request.headers.token
      //查看用户信息是否包含有次token用户
      const checkUser = createUserList().find((item) => item.token === token)
      //没有返回失败的信息
      if (!checkUser) {
        return { code: 201, data: { message: '获取用户信息失败' } }
      }
      //如果有返回成功信息
      return { code: 200, data: { checkUser } }
    },
  },
]
3.7.4 测试接口

安装 axios

pnpm i axios

3.8 axios 二次封装

目的:

  1. 使用请求拦截器,可以在请求拦截器中处理一些业务(请求头携带公共参数)
  2. 使用响应拦截器,可以在响应拦截器中处理一些业务(简化服务器返回的数据、处理http网络错误)

在项目根目录下创建 utils/request.ts 用于对 axios 进行二次封装

js
// 进行axios二次封装:使用请求与响应拦截器
import axios from 'axios'	
import { ElMessage } from 'element-plus'
// 1.利用axios对象的create方法去创建axios实例(其他配置:基础路径,超时时间)
const request = axios.create({
  // 基础路径
  baseURL: import.meta.env.VITE_APP_BASE_API, // 基础路径上会携带/api
  timeout: 5000, // 超时时间的设置
})
// console.log(request) // axios
// 2.为request实例添加请求拦截器(对请求参数进行处理)
request.interceptors.request.use((config) => {
  // config.headers.token = "123" // 设置一些公共参数
  return config // 一定要返回config配置对象
  // config配置对象里面有一个属性很重要headers请求头,经常给服务器端携带公共参数
})
// 3.为request实例添加响应拦截器(对返回数据进行处理)
request.interceptors.response.use(
  // 成功的回调
  (response) => {
    console.log(response);// response:请求返回的数据
    return response.data // 简化返回的数据
  },
  // 失败的回调 处理http网络的错误
  (error) => {
    // 定义一个变量:存储网络错误的信息
    let message = ''
    // http状态码(根据状态码提示不同的错误信息)
 /* let status = error.response.status //获取错误状态码
    switch (status) {
      case 401:
        message = 'Token过期'
        break
      case 403:
        message = '无权访问'
        break
      case 404:
        message = '请求地址错误'
        break
      case 500:
        message = '服务器故障'
        break
      default:
        message = '网络出现问题'
        break
    }
    // 使用ElMessage UI插件提示的错误信息
    ElMessage({
      type: 'error',
      message,
    })*/
 // 有些情况返回的错误信息中无status,使用如下方式:
    ElMessage({
      type: 'error',
      message: error.message,
    })
    // 返回一个失败的Promise对象终结状态
    return Promise.reject(error)
  },
)

// 对外暴露封装好的axios(request)
export default request

3.9 API 接口统一管理

在开发项目的时候,接口可能很多需要统一管理。在 src 目录下去创建 api 文件夹去统一管理项目的接口;

将所有的请求封装成不同的请求函数,在需要时进行引入调用并传递请求参数,提高代码的复用性

3.9.1 在 src 文件夹下创建 api 文件夹存储不同的接口

![image-20240425163446080](./Vue3 硅谷甄选.assets/image-20240425163446080.png)

3.9.2 定义并暴露请求函数

src/api/user/index.ts :定义并暴露获取用户数据的请求函数

js
// 统一管理项目用户相关的接口
import request from '@/utils/request'
import type { loginForm, loginResponseDate, userResponseDate } from './type'
// 统一管理接口
enum API {
  LOGIN_URL = '/user/login',
  USERINFO_URL = 'user/info',
}
// 对外暴露请求函数
// 登录接口方法 返回一个Promise对象
export const reqLogin = (data: loginForm) =>
  request.post<any, loginResponseDate>(API.LOGIN_URL, data)

// 获取用户信息接口方法 返回一个Promise对象
export const reqUserInfo = () =>
  request.get<any, userResponseDate>(API.USERINFO_URL)

src/api/user/type.ts :定义并暴露用户相关接口需要用到的数据类型

ts
// 登录接口需要携带的参数的TS类型
export interface loginForm {
  username: string
  password: string
}

interface dataType {
  token: string
}

// 登录接口返回的数据的ts类型
export interface loginResponseDate {
  code: number
  data: dataType
}
// 定义服务器返回用户信息相关的数据类型
interface userInfo {
  userId: number
  avatar: string
  username: string
  password: string
  desc: string
  roles: string[]
  buttons: string[]
  routes: string[]
  token: string
}
interface user {
  userInfo: userInfo
}
export interface userResponseDate {
  code: number
  data: user
}
3.9.3 在组件中使用接口函数
vue
<script setup lang="ts" name="App">
import { onMounted } from 'vue'
// 1.引入请求登录函数
import { reqLogin } from '@/api/user'
onMounted(() => {
  // 2.调用reqLogin()并传递请求参数 返回的是一个Promise对象
  reqLogin({ username: 'admin', password: '111111' }).then((result) => {
    console.log(result);
  });
  // 或使用 async await
})
</script>

四、登录业务

1.借助本地存储持久化存储 token
js
const useUserStore = defineStore('User', () => {
  // 存储用户唯一标识token,从本地存储中进行获取
  let token = ref(localStorage.getItem('TOKEN'))

  // 用户登录方法
  async function userLogin(data: loginForm) {
    // 发送登录请求(已定义好在api/user/index.ts中)
    const result = await reqLogin(data) // await只接收成功返回的数据
    // 登录请求成功200:获取到token并存储
    // 由于pinia|vuex存储数据其实利用js对象,并非持久化刷新会丢失,需要进行本地存储
    token.value = result.data.token
    // 本地持久化存储token,token用于每次获取用户的数据,每次发请求都需要携带
    localStorage.setItem('TOKEN', token.value)
    // 登录请求失败201:登录失败错误信息
  }

  // 将存储的数据和方法进行暴露
  return { userLogin, token }
})
// 对外暴露获取小仓库的方法
export default useUserStore
2.登录时间的判断:
js
// 封装一个函数,获取一个结果:当前是早上|上午|下午|晚上
function getTime() {
  // 通过内置的构造函数data实现
  let hours = new Date().getHours()
  if (hours <= 9) return '早上'
  else if (hours <= 14) return '上午'
  else if (hours <= 18) return '下午'
  else return '晚上'
}

tips:处理时间日期的 js 插件有很多如:moment.js、day.js

3.el-form表单校验:

使用form组件提供的校验规则

vue
<template>
 <!-- 表单校验1.为el-form添加 model和rules属性(指定存放收集数据的属性和校验规则) -->
        <el-form
          class="login_form"
          :model="loginFrom"
          :rules="rules"
          ref="loginForms"
        ><!-- 表单校验3.为el-form添加ref属性(获取组件示例对象上暴露的validate方法对收集的数据进行验证) -->
<!-- 表单校验2.并将form-Item的prop属性设置为需要验证的特殊键值键名要与属性名相同 -->
          <el-form-item prop="username">
            <el-input
              v-model="loginFrom.username"
              :prefix-icon="User"
              clearable
            ></el-input>
          </el-form-item>
          ....
        </el-form>
</template>
<script>
// 获取表单组件的实例对象
let loginForms = ref()
    ....
// 登录按钮回调
async function login() {  // await必须配合async使用,将函数变为异步函数
  // 保证全部的表单项校验通过再发请求 await会等待异步任务执行成功后再向下执行
  await loginForms.value.validate() // validate()方法返回一个Promise对象,校验通过状态为成功,否则为失败
....
}
..
// 定义表单校验需要的配置对象(设置验证规则)
const rules = {
  username: [
    {
      required: true, // 必填项
      message: '用户名不能为空哦!', // 错误提示信息
      pattern: /^(?:(?:\+|00)86)?1\d{10}$/, // 正则
      trigger: 'blur', // 触发校验表单的方式 blur(失去焦点)/change(文本变化)
    },
    {
      min: 5, // 文本长度至少多少位
      max: 10, // 文本长度至多多少位
      message: '用户名长度为5-10位',
      trigger: 'change',
    },
  ],
  ...
}
</script>

校验规则:

  1. required:true/false:校验规则会让属性名前出现*,表示必填
  2. pattern:正则
  3. trigger:blur/change:触发校验的规则的时机,失焦/发生改变
  4. message:"xxx":校验不通过错误提示
  5. min/max:n:文本长度
  6. validator:fn:自定义校验规则

form组件实例对象上的方法

  1. validate():对整个表单的内容进行验证, 接收一个回调函数,或返回 Promise。(一般用于提交数据前对表单进行校验)
  2. clearValidate('xx'/[]):清理某个字段的表单验证信息(清除上次表单校验残留的错误提示信息)要在组件渲染之后再调用该方法nextTick

注意:

  1. 表单验证要为 form组件添加 modelrules属性,并为 form-item组件添加 prop属性
  2. prop中的属性要与规则中的字段相同,且相同于属性名
  3. 要想使用组件方法要先使用 ref获取组件实例对象
  4. 表单校验的触发方式推荐使用 blur 使用 change可能会出现问题
4.el-form自定义校验规则:
js
// 自定义校验规则函数
const validatorUserName = (rule: any, value: any, callback: any) => {
  // 函数接收三个参数 rule:校验规则对象 value:表单元素的文本内容 callback:校验成功使用callback函数放行,校验失败使用callback函数注入错误信息
  if (
    /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
      value,
    ) ||
    /^[\w-]{4,16}$/.test(value)
  ) {
    // 验证通过
    callback()
  } else {
    // 验证不通过 传递错误信息
    callback(new Error('请输入正确的邮箱或至少四位用户名'))
  }
}
const validatorPassword = (rule: any, value: any, callback: any) => {
  if (/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[a-zA-Z])\S*$/.test(value)) {
    callback()
  } else {
    callback(new Error('密码长度不能小于6位且包含字母和数字'))
  }
}

// 使用自定义校验规则
const rules = {
  username: [
    { trigger: 'change', validator: validatorUserName },
  ],
  password: [
    { trigger: 'change', validator: validatorPassword },
  ],
}

自定义校验规则函数接收三个参数:

rule:校验规则对象

value:表单元素的文本内容

callback:校验成功使用callback()函数放行,校验失败使用 callback(new Error('xxx')) 函数注入错误信息

Tips:一般自定校验用于正则匹配

5.登录成功跳转到上次退出的路由

将退出登录时的路由存储到登录路由对象的query参数中,下次登录成功直接跳转到 query参数中存储的路由

登录成功

js
router.replace({
      // 如果有query参数就跳转到query参数指定的路由,否则跳转到首页
      path: (route.query.redirect as string) || '/',
})

退出登录

js
router.replace({
    // 使用replace方法,防止已退出用户再次进入首页
    name: 'login',
    // 携带当前路由地址,登录成功后跳转到这个路由地址
    query: {
      redirect: route.path,
    },
})

五、首页功能(layout组件)

1.自定义滚动条样式

前提已为元素设置 overflow:auto(内容超出自动添加滚动条)overflow:scroll(添加垂直+水平滚动条)

css
// 滚动条外观的设置		
::-webkit-scrollbar {
  width: 10px;
}
::-webkit-scrollbar-track { // 轨道
  background-color: $base-menu-background;
}
::-webkit-scrollbar-thumb{ // 滑块
  width: 10px;
  background-color: yellowgreen;
  border-radius: 10px;
}
// 隐藏滚动条
::-webkit-scrollbar{
	display:none;
}
2.递归组件生成动态菜单:
vue
<template>
    <!-- 遍历所有路由 -->
    <template v-for="item in menuList" :key="item.path">
      <!-- 如果一级路由没有子路由,那么就显示一级路由的标题 -->
      <template v-if="!item.children">
        <!-- el-menu-item和el-sub-menu 必须要用index唯一标识 -->
        <el-menu-item v-if="!item.meta.hidden" :index="item.path">
          <template #title>
            <!-- 插槽:指定标题的名称 -->
            <span>图标&nbsp;&nbsp;</span>
            <span>{{ item.meta.title }}</span>
          </template>
        </el-menu-item>
      </template>
      <!-- 如果一级路由只有一个子路由,那么就显示子路由的标题 -->
      <template v-if="item.children && item.children.length == 1">
        <el-menu-item v-if="!item.meta.hidden" :index="item.children[0].path">
          <template #title>
            <span>{{ item.children[0].meta.title }}</span>
          </template>
        </el-menu-item>
      </template>
      <!-- 如果一级路由有多个子路由,那么就展示折叠的菜单 -->
      <template v-if="item.children && item.children.length > 1">
        <el-sub-menu v-if="!item.meta.hidden" :index="item.path">
          <template #title>
            <span>{{ item.meta.title }}</span>
          </template>
          <!-- 使用递归组件:就是在组件内调用自身组件 递归组件必须要有名字 -->
          <Menu :menuList="item.children"></Menu>
        </el-sub-menu>
      </template>
    </template>
</template>

<script setup lang="ts" name="Menu">
// 获取父组件传递过来的全部路由数据
defineProps(['menuList'])
</script>
3.递归组件

在一个单文件组件中使用组件标签调用自身

vue
// Menu.vue
<Menu :menuList="item.children"></Menu>

注意:递归组件必须要有组件名

4.动态组件

<component>:一个用于渲染动态组件或元素的“元组件”。

vue
<!-- icon图标 <component>根据组件名渲染动态组件 -->
<el-icon>
   <component :is="item.meta.icon"></component> // 根据item.meta.icon动态生成组件
</el-icon>

注意:要渲染的实际组件由 is 属性决定。

5.路由组件添加过渡动画

vue中的过渡动画类名为:

.name/v-enter/leave-from/to {}

.name/v-enter/leave-active{}

<router-view></router-view> 组件进行封装,为路由组件添加动画效果

vue
<template>
  <!-- 路由组件的出口位置 添加过渡动画-->
  <router-view v-slot="{ Component }"> // 插槽会返回当前路由组件
    <!-- transition的name属性用于 name-enter...使用 -->
    <transition name="fade">
      <!-- 渲染layout一级路由组件的子路由 -->
      <component :is="Component" /> // 通过动态组件将路由组件放在transition组件中
    </transition>
  </router-view>
</template>

<script setup lang="ts" name="Main"></script>

<style scoped>
/* 设置路由组件切换的过渡动画 */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
  transform: scale(0);
}
.fade-enter-active,
.fade-leave-active {
  transition: all .3s;
}
.fade-enter-to,
.fade-leave-from {
  opacity: 1;
  transform: scale(1);
}
</style>

注意:<transition> 仅支持单个元素或组件作为其插槽内容。如果内容是一个组件,这个组件必须仅有一个根元素。

6.缓存路由组件

路由组件切换时默认被卸载,使用 keep-alive 缓存路由组件,使其不被销毁

vue
<router-view v-slot="{ Component }"> // 作用域插槽将当前路由组件暴露出来
  <keep-alive>
    <component :is="Component" /> // 使用动态组件,动态渲染
  </keep-alive>
</router-view>

在 Transition 组件内使用 KeepAlive 组件:

vue
<router-view v-slot="{ Component }">
  <transition>
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </transition>
</router-view>

路由组件的两个生命周期钩子: onActivated()onDeactivated()

vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
  // 调用时机为首次挂载
  // 以及每次从缓存中被重新插入时
})
onDeactivated(() => {
  // 在从 DOM 上移除、进入缓存
  // 以及组件卸载时调用
})
</script>

Tips: onActivated 在组件挂载时也会调用,并且 onDeactivated 在组件卸载时也会调用。

7.动态绑定类名

通过为 menu,tabbar,和mian 组件动态绑定类名实现导航栏折叠效果

:class="{flod: 表达式}" 表达式的结果为 true 添加类名 false 不添加

vue
<div class="layout_slider" :class="{ fold: fold ? true : false }">
    
<style>
 .layout_slider {    
     &.fold{ // .layout_slider.flod即类名为layout_slider元素的类名为flod 类似于div.xx
      width: $base-menu-min-width;
    }
  }
</style>
8.通过路由对象获取当前路由的信息

通过获取当前路由对象的路由信制作面包屑导航

js
import { useRoute } from 'vue-router'
// 获取当前路由对象
const route = useRoute()
console.log(route)
// fullPath: (…) // 完整路径
// hash: (…) 
// matched: (…) // 全部路由信息
// meta: Objectname: (…)// 路由元信息
// params: (…)// params参数
// path: (…)// 当前路由path
// query: (…)// query参数
// redirectedFrom: (…)

route 中的一个重要的属性 matched:可以获取到当前匹配路由的一级、二级、三级...和当前路由(可用于动态渲染面包屑导航)

eg:使用 route 获取/acl/role路由的 matched 属性

js
matched: Array(2)
0: {path: '/acl', redirect: undefined, name: 'Acl', meta: {…}, aliasOf: undefined, …}
1: {path: '/acl/role', redirect: undefined, name: 'Role', meta: {…}, aliasOf: undefined, …}
9.页面刷新实现

要想实现页面数据的刷新就要让组件进行卸载后再重新进行挂载,再次执行 onmounted 钩子,(组件在onmounted钩子中发送请求/通过路由守卫发送请求,从服务器端获取数据进行渲染)

使用 v-if 进行组件的卸载与挂载(当 v-if 元素被触发,元素及其所包含的指令/组件都会销毁和重构。)

vue
<template>
  <!-- 路由组件的出口位置 添加过渡动画-->
  <router-view v-slot="{ Component }">
    <!-- transition的name属性用于 name-enter...使用 -->
    <transition name="fade">
      <!-- 渲染layout一级路由组件的子路由 -->
      <!-- 使用 v-if 控制路由组件的卸载与挂载 -->
      <component :is="Component" v-if="flag" />
    </transition>
  </router-view>
</template>

<script setup lang="ts" name="Main">
import { nextTick, ref, watch } from 'vue'
// 引入useLayoutSettingStore用于获取是否刷新的变量
import useLayoutSettingStore from '@/store/modules/setting'
// 获取layoutSettingStore仓库对象
const layoutSettingStore = useLayoutSettingStore()
// 控制当前组件是否销毁重建
let flag = ref(true)
// 监听refresh变量 当发生变化时说明用户点击过刷新按钮 进行页面刷新
watch(
  () => layoutSettingStore.refresh,
  () => {
    // 点击刷新按钮路由组件需要销毁重建
    flag.value = false // 组件的销毁需要时间
    // nextTick是vue3中的钩子函数 等当前组件被完全销毁后再执行回调函数
    nextTick(()=>{
      // 等路由组件被卸载完毕后在执行重新创建
      flag.value = true
    })     
  },
)
</script>
10.原生DOM实现全屏效果

document.fullscreenElement:获取当前是否为全屏模式 null/DOM

document.documentElement.requestFullscreen():切换全屏模式

document.exitFullscreen():退出全屏模式

js
// 点击全屏按钮的回调
const fullScreen = () => {
  //document.fullscreenElement可以获取当前是否为全屏模式 全屏:true非全屏:null
  let full = document.fullscreenElement
  if(!full){
    // 切换为全屏模式 使用文档根节点的requestFullscreen方法实现全屏
    document.documentElement.requestFullscreen()
  }else{
    // 退出全屏模式 使用document.exitFullscreen方法退出全屏
    document.exitFullscreen()
  }
}

也可以使用 fullscreen 插件实现

注意:不同浏览器存在兼容问题

11.设置 token 请求头

在对 axios 二次封装时,通过在请求拦截器中添加请求头,让每次请求都携带 token 请求头,去服务器获取用户数据

js
request.interceptors.request.use((config) => {
  config.headers.token = userStore.token // 设置一些公共参数token
  return config // 一定要返回config配置对象
  // config配置对象里面有一个属性很重要headers请求头,经常给服务器端携带公共参数
})
12.store的组合式 API 写法

采用组合式API的写法,使用 setup函数替代配置对象

优点:可以直接使用 ref,reactive 声明变量且会自动进行类型推断,不需要再声明数据类型,可以直接在函数中使用变量,无需使用 this ,嵌套较浅

js
const usexxxStore = defineStore('仓库名', () => {
// 数据,方法,计算属性
return {....}
})

Tips:注意使用组合式API写法时要将共享的属性和方法使用 return 进行暴露

13.退出登录处理(token失效,数据清空,存储当前浏览路由)
  1. 发请求告诉服务器token失效

  2. 清空仓库 Store 中存储的用户数据[token,username,avatar]

    js
    // 退出登录的方法
      function userLogout() {
        // 目前没有mock退出登录接口:通知服务器本地token失效
        userName.value = ''
        userAvatar.value = ''
        token.value = ''
        REMOVE_TOKEN() // 清除本地token
      }
  3. 路由跳转到登录页并记录当前的路由,下次登录可以直接访问该路由(并以query参数形式携带当前路由地址,登录成功后跳转到这个路由地址)

    js
    router.replace({// 使用replace方法,防止已退出用户再次进入首页
        name: 'login',
        // 携带当前路由地址,登录成功后跳转到这个路由地址
        query: {
          redirect: route.path,
        },
      })

六、路由鉴权

路由鉴权:项目中的路由能不能被访问的权限的设置(某一个路由什么条件下可以被访问,什么条件下不能被访问)

两个全局路由守卫:

router.beforeEach:全局前置路由守卫,在所有路由跳转前执行的钩子(开启进度条,路由鉴权)

router.afterEach:全局后置路由守卫,在所有路由跳转后执行的钩子(关闭进度条,设置页面标题)

1.进度条业务

任意一个路由的切换实现进度条业务 使用 nprogress 插件 pnpm i nprogress

在根目录下创建一个 permission.ts 用于路由的鉴权,并在 main.ts 中引入使用

js
// 引入路由器对象
import router from '@/router'
// 引入进度条插件
import nprogress from 'nprogress'
// 引入进度条的样式
import 'nprogress/nprogress.css'
// 全局守卫:项目中任意路由切换都会触发的钩子
// 全局前置守卫:访问某一个路由之前会执行该钩子
router.beforeEach((to, from, next) => {
  // to:要访问的路由对象 from:当前路由对象 next:放行函数
  nprogress.start() // 开启进度条
  next() // 放行
})
// 全局后置守卫
router.afterEach((to, from) => {
  nprogress.done() //关闭进度条
})

Tips:

js
router.beforeEach((to, from, next) => {
  // to:要访问的路由对象
  // from:当前路由对象 
  // next:放行函数
  next()
})

注意:next函数在一次逻辑中只能被执行一次

2.根据token进行路由鉴权

全部的路由组件:登录|404|任意路由|首页|数据大屏|权限管理(三个子路由)|商品管理(四个子路由)

用户未登录:可只可以访问/login,访问其他指向/login

登录成功:不能访问/login访问时指向/home,其余的路由可以访问

根据仓库|本地存储中的 token 来判断用户是否已经登录

js
// 获取userStore中的token用于判断用户是否登录成功
import useUserStore from '@/store/modules/user'
// 在组件外访问store中的数据需要先引入pinia
import pinia from './store'
const userStore = useUserStore(pinia)
// 全局守卫:项目中任意路由切换都会触发的钩子
// 全局前置守卫   访问某一个路由之前会执行该钩子
router.beforeEach((to, from, next) => {
  // to:要访问的路由对象 from:当前路由对象 next:放行函数
  nprogress.start() // 开启进度条
  // 使用token判断用户是否已经登录
  if (userStore.token) {
    // 已登录不能访问/login
    to.path === '/login' ? next('/home') : next()
  } else {
    // 未登录只能访问/login并将要访问的路由以query参数的形式添加到url中,登录成功后即可访问该路由
    to.path === '/login'
      ? next()
      : next({ path: '/login', query: { redirect: to.path } })
  }
})

Tips: 路由守卫的回调函数中 next 函数传参改变跳转路:

next()在回调执行时只能被调用一次,next函数可以传递与:to相同的配置对象, 来更改跳转的路径如:next({name:''}),next('/path'),next({path:'/'}),默认 next() 会跳转到参数to中的路由

3.根据用户信息进行路由鉴权

token可以判断用户是否已经登录且已经进行了持久化存储,刷新时不会丢失。但用户数据存储在pinia 仓库中并非持久化,页面刷新后数据会丢失页面会出现错误,因此需要重新发请求进行获取。

在路由跳转时先判断是否有 token 再判断仓库中是否有用户数据,若不存在 token说明用户未登录,跳转登录页面,若有 token无用户信息,只需重新发请求获取用户数据后再进行路由跳转即可

Tips:获取数据时存在 token 过期问题(使用token不能获取到用户信息),当 token 过期时,清空用户数据,路由跳转到登录页面,重新进行登录获取新的token

js
router.beforeEach(async (to, _from, next) => {
  ......
  // 使用token判断用户是否已经登录
  if (token.value) {
    // 已登录不能访问/login
    if (to.path === '/login') {
      next('/home')
    } else {
      // 判断store中是否有用户数据,页面一刷新store中的数据就会丢失
      if (userName.value) {
        next() // 有用户信息直接跳转
      } else {
        // 没有用户信息就发请求获取用户信息后再放行
        try {
          // userInfo()为一个async函数返回一个Promise对象状态由返回值决定
          await userInfo()
          // 获取用户信息成功再进行放行
          next()
        } catch (error) {
          // 发请求不能获取到用户信息(token过期)
          // 退出登录返回登录页面
          userLogout() // 把用户相关的数据清空
          next({ path: '/login', query: { redirect: to.path } })
        }
      }
    }
  } else {
    // 未登录只能访问/login并将要访问的路由以query参数的形式添加到url中,登录成功后即可访问该路由
    to.path === '/login'
      ? next()
      : next({ path: '/login', query: { redirect: to.path } })
  }
})

特别注意:从 store 中获取的数据在使用时必须为响应式

使用如下两种方式:

  1. 直接使用 xxxStore.属性名
  2. 对其进行解构使用时必须使用 storeToRefs 将其转换为响应式数据

使用 usexxxStore() 方法获取到的为一个 Proxy 响应对象

4.路由切换更改浏览器页签标题

在全局后置路由守卫(afterEach),当路由切换成功后设置浏览器页签标题

js
router.afterEach((to) => {
  // 设置页面标题  访问某一路由后会执行该钩子
  document.title = (setting.title.slice(0, 4) + '-' + to.meta.title) as string
  nprogress.done() //关闭进度条
})

七、获取接口数据

1.async ....await + try....catch 处理 Promise

async:将一个函数变成异步函数(一般配合 await使用)不阻塞函数后面的代码,且 async 函数返回一个 Promise 对象,状态由函数的返回值决定

await:必须写在 async函数中,右侧一般为一个 Promise 对象,左侧接收 Promise 成功返回的值,且会阻塞 await 语句下面的代码(直到 await 右侧的 Promise 返回成功状态,如果返回失败状态的话代码会停止执行,所以需要配合 try...catch进行错误处理 )

一般使用 async..await try...catch 来处理 Axios 请求,代替 .then().catch()回调,使异步代码书写起来更加同步。

js
// Axios请求
async function userInfo() {// async函数返回一个Promise对象
  // 获取用户的信息进行存储于仓库中
  let result = await reqUserInfo() // 请求函数返回一个promise对象
  // 如果获取用户信息成功,就存储用户信息
  if (result.code === 200) { // 此处无需做错误处理因为响应拦截器已处理过了
    userName.value = result.data.checkUser.username
    userAvatar.value = result.data.checkUser.avatar
    return 'ok' // 返回一个成功的Promise对象
  } else {
    // 获取信息失败(用户信息不存在)
    return Promise.reject('获取用户信息失败') // 返回一个失败的Promise对象
  }
}

// 使用 try..catch处理Axios请求
router.beforeEach(async (to, _from, next) => {
  .....
  try {
    // userInfo()为一个async函数返回一个Promise对象状态由返回值决定
    await userInfo()
    // 获取用户信息成功再进行放行
    next()
  } catch (error) { // 获取用户信息失败
    // 发请求不能获取到用户信息(token过期
    userLogout() // 把用户相关的数据清空
    .....
  })
2.项目的接口地址

接口服务器域名:http://sph-api.atguigu.cn

swagger接口文档:

http://139.198.104.58:8209/swagger-ui.html

http://139.198.104.58:8212/swagger-ui.html#/

http://39.98.123.211:8510/swagger-ui.html#/

3.配置代理服务器解决跨域

server.proxy

为开发服务器配置自定义代理规则。期望接收一个 { key: options } 对象。任何请求路径以 key 值开头的请求将被代理到对应的目标。

  1. 配置 .env.development.env.production中的服务器地址
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
VITE_APP_TITLE = '硅谷甄选运营平台'
# 开发环境下请求api的baseURL
VITE_APP_BASE_API = '/api' // 代理服务器前缀
# 服务器地址
VITE_SERVE = 'http://sph-api.atguigu.cn'
  1. 配置代理服务器跨域

修改 vite.config.ts

js
export default defineConfig(({ command, mode }) => {
  // 获取各种环境下对应的变量
  let env = loadEnv(mode, process.cwd())
  return {
  	......
          // 代理跨域
    server:{
      proxy:{
        [env.VITE_APP_BASE_API]:{
          // 获取数据的服务器地址的设置
          target: env.VITE_SERVE, // 目标服务器地址
          // 是否代理跨域
          changeOrigin: true,
          // 路径重写 去除前缀/api
          rewrite: (path) => path.replace(/^\/api/, ''),
        }
      }
    }
  }
})
  1. 封装 axios请求时设置 baseURL为代理服务器前缀
js
const request = axios.create({
  // 基础路径
  baseURL: import.meta.env.VITE_APP_BASE_API, // 基础路径上会携带/api(代理服务器前缀)
  timeout: 5000, // 超时时间的设置
})
  1. 定义接口地址与请求方法
js
// api/user/index 统一管理项目用户相关的接口
import request from '@/utils/request'

// 项目用户相关的请求地址
enum API {
  LOGIN_URL = '/admin/acl/index/login',
  USERINFO_URL = '/admin/acl/index/info',
  LOGOUT_URL = '/admin/acl/index/logout',
}

// 登录接口
export const reqLogin = (data: any) =>
  request.post<any, any>(API.LOGIN_URL, data)

// 获取用户信息的接口
export const reqUserInfo = () => request.get<any, any>(API.USERINFO_URL)

// 退出登录接口
export const reqLogout = () =>{
    return request.post<any, any>(API.LOGOUT_URL)
}
  1. 定义接口返回值的ts类型
js
// 定义用户相关的数据的TS类型
// 用户登录接口携带参数的ts类型
export interface loginFormDate {
  username: string
  password: string
}
// 定义全部接口返回数据都拥有的ts类型
export interface ResponseDate {
  code: number
  message: string
  ok: boolean
}
// 定义登录接口返回的数据类型 继承ResponseDate接口
export interface loginResponseDate extends ResponseDate {
  data: string
}
// 定义获取用户信息返回的数据类型
export interface userInfoResponseDate extends ResponseDate {
  data: {
    routes: string[]
    buttons: Array<string>
    roles: string
    name: string
    avatar: string
  }
}

Tips:定义接口返回值的类型,只是为了方便后续的使用,可以根据需要的数据定义返回值的数据类型,只是对返回值的类型进行了约束,不会改变返回的数据(即返回的数据会多于类型定义的数据,但只能访问已经定义类型的数据)

4.get/post请求中参数的携带
  1. get请求中 path参数和 query参数的携带方式
js
request.get<any, any>(API.path + `${xx}/${xx}?Id=${n}`)

path:路径变量 /xx/xx

query:查询字符串 ?key=value

  1. post请求携带请求体的方式
js
request.post<any,any>(API.path,data/{xx})
5.TS定义数据类型补充
  1. interface 用于定义对象类型的数据可以继承
  2. type 用于定义数组函数等其他类型的数据
  3. xx? 表示该属性可有可无
  4. xx[]/Array<xx> 数组内的每个元素都为 XX 的数据类型
  5. {[key: string]: any} 对象里面可以有任意属性
js
// 所有接口返回的相同的数据类型
export interface ResponseDate {
  code: number
  message: string
  ok: boolean
}
// 已有的品牌的TS数据类型
export interface TradeMark {
  id?: number
  createTime: string
  updateTime: string
  tmName: string
  logoUrl: string
}
// 包含全部品牌数据的ts类型
export type Records = TradeMark[]
// 获取的已有全部品牌的数据ts类型 该接口继承了已有的接口
export interface TradeMarkResponseData extends ResponseDate {
  data: {
    records: Records
    total: number
    size: number
    current: number
    searchCount: boolean
    pages: number
  }
}
  1. const Arr = reactive<xx[]>([])

    响应式数组的类型为 xx[]

  2. const Obj = reactive<xx>({必须属性1:值,...})

    响应式对象的类型为 xx对象类型

    const Obj = reactive<xx>()

    响应式对象的类型为 undefinedxx对象类型 后续使用需要可以使用可选链

  3. enum xx{a=xx,b=xx}

    定义一个枚举类型的数据

  4. request.get<any, xxx>(API.TRADEMARK_URL)

    该请求可以返回 any类型的数据

    接收的数据类型为 xxx

    即服务器可以返回任意类型的数据,但只接收指定的数据类型的数据

  5. const obj: { [name: string]: Component }

    该对象的 name 属性值为 string (对象的key本身就为字符串类型)值为 Component(组件)类型,注意中括号

八、Element-Plus UI组件的使用

1.UI组件中的暴露的外部方法的使用

要想使用组件暴露的外部方法必须先获取组件实例对象

  1. 为组件添加 ref 属性并获取组件实例对象
vue
<el-form ref="formRef" >
<el-upload ref="upload" >
...
<script>
    // 获取el-form组件实例对象
	let formRef = ref() // 属性名要和组件ref的属性值相同
    // 获取el-upload组件实例对象
    let upload = ref()
</script>
  1. 调用方法

使用 el-form组件暴露的validate方法

js
// 对话框底部确定按钮
const confirm = async () => {
// 点击确定按钮后,表单验证成功后,向服务器发送增加/修改请求
  await formRef.value.validate()
  .....
}

使用 el-upload组件中的 clearFiles外部方法

js
// 点击对话框底部取消按钮
const cancel = () => {
  ....
  // 清空已上传的文件列表
  upload.value.clearFiles()
}
2.UI组件的事件的使用

直接在组件标签上使用 @事件名="回调"

3.UI组件提供的插槽的使用
  1. 具名插槽的使用

    将自定义结构放在指定的位置

![image-20240503210551543](./Vue3 硅谷甄选.assets/image-20240503210551543.png)

使用方法:<template #插槽名>html</template>

使用场景:当组件标签提供的结构不满足使用,可以通过插槽来自定义结构

  1. 作用域插槽的使用

在自定义结构时可以使用插槽传递的数据

![image-20240505102551449](./Vue3 硅谷甄选.assets/image-20240505102551449.png)

使用:<template #default="{ row,xx,xx }">html</template>

vue
<el-table-column label="属性值名称" align="left">
  <!-- 通过el-table-column默认插槽回传的数据,获取到表格当前行的数据进行渲染 -->
  <template #default="{ row ,$index}"> // row为当前行的数据 $index为当前行数据在数组中的索引
    <!-- 标签 key唯一值  -->
    <el-tag
      v-for="item in row.attrValueList"
      :key="item.id"
      effect="light"
      style="margin: 5px"
      type="primary"
    >
      {{ item.valueName }}
    </el-tag>
  </template>
</el-table-column>

使用场景:组件提供的属性不满足使用,且自定义结构还需要根据组件中的数据进行动态渲染

4.分页器切换页码和切换每页显示条目
  1. 当前页数据发生更新时留在当前页,不返回第一页
  2. 分页器切换每页显示的条目数时,自动回到第一页
  3. 分页器切换页码时,跳转到对页码
vue
<template>
<el-pagination
  v-model:current-page="pageNo"
  v-model:page-size="pageSize"
  :page-sizes="[5, 10, 20]"
  :total="total"
  background="true"
  small="true"
  layout=" prev, pager, next,jumper,->, sizes, total"
  @size-change="getHasSku(1)" // 需要回到第一页时传递参数1
  @current-change="getHasSku" // 处于当前页不需要传递参数
/>
</tempate>
<script>
// 发送请求获取数据
const getHasSku = async (pager=pageNo.value) => { 
  // 使用默认参数控制默认处于当前页
  pageNo.value = pager // 当需要返回第一页时传递1
  const result = await reqSkuList(pageNo.value, pageSize.value)
  if (result.code === 200) {
    // 存储SKU数据
    skuArr.value = result.data.records
    // 存储数据总条数
    total.value = result.data.total
  } else {
    ElMessage.error('获取SKU数据失败')
  }
}
</script>

分页器三个重要的事件:

  1. size-change:page-size 改变时触发 自动在回调中注入当前每页条数
  2. current-change: current-page 改变时触发 自动在回调中注入当前页数码
  3. change:current-pagepage-size 更改时触 自动在回调中注入当前页数和每页条码数
5.收集动态生成的 select中的多个值

处理方法:将要收集的数据以 xx:xx 的形式设置为 optionvalue值,再使用 v-mode收集到每个 select对应的数据对象上,随后在进行整理收集

由于多个select是动态生成的,因此不能使用一个变量去收集多个 select的值,所以将每个 select多选框的值收集到,当前的数据对象的某一个对象上,随后再对数据进行遍历汇总

vue
<template>
<el-form inline> // inline行级form
  <el-form-item
    v-for="attr in attrArr"
    :key="attr.id"
    :label="attr.attrName"
    style="width: 250px; margin-bottom: 10px"
  >
    <!-- 先将属性id和属性值id以attr.is:attrValue.id形式收集到平台属性对象上 -->
    <el-select v-model="attr.attrIdAndValueId" placeholder="请选择">
      <el-option
        v-for="attrValue in attr.attrValueList"
        :key="attrValue.id"
        :label="attrValue.valueName"
        :value="`${attr.id}:${attrValue.id}`"
      />
    </el-select>
  </el-form-item>
</template>
<script>
// 使用reduce函数将一个数组内每个对象的attrIdAndValueId属性拆分为两个属性并组成一个对象,返回一个由此对象组成的数组
skuParams.skuAttrValueList = attrArr.value.reduce((prev: any, next: any) => {
    // 如果存在该属性attrIdAndValueId
    if (next.attrIdAndValueId) {
      const [attrId, valueId] = next.attrIdAndValueId.split(':')
      // 添加到一个空数组中
      prev.push({
        attrId,
        valueId,
      })
    }
    // 返回该数组
    return prev // 作为下一次的prev
  }, []) // []会作为第一次的prev
</script>
6.checkBox复选框

带有全选的复选框:

el-checkbox

v-model:收集复选框的值 、boolean/Array

indeterminate:是否显示中间状态

@change:当复选框发生变化时触发

value:当前复选框的值

el-checkbox-group

v-model:收集复选框组内复选框的值 Array

@change:当绑定值变化时触发的事件

vue
<template>
  <el-checkbox
    v-model="checkAll"
    :indeterminate="isIndeterminate"
    @change="handleCheckAllChange"
  >
    全选
  </el-checkbox>
  <el-checkbox-group
    v-model="checkedCities"
    @change="handleCheckedCitiesChange"
  >
    <el-checkbox v-for="city in cities" :key="city" :label="city" :value="city">
      {{ city }}
    </el-checkbox>
  </el-checkbox-group>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

// 用于控制全选按钮是否被选中
const checkAll = ref(false)
// 用于全选按钮是否处于不确定状态
const isIndeterminate = ref(true)
// 收集复选框组中已选的值
const checkedCities = ref(['Shanghai', 'Beijing'])
// 存储全部复选框的值
const cities = ['Shanghai', 'Beijing', 'Guangzhou', 'Shenzhen']

// 点击全选按钮的回调
const handleCheckAllChange = (val: boolean) => {
  // 取消不确定状态
  isIndeterminate.value = false
  // 回调会自动注入该复选框的值
  checkedCities.value = val ? cities : [] // 修改收集到的值实现全选/全不选
}
// 当复选框组中的选项发生变化时的回调
const handleCheckedCitiesChange = (value: string[]) => {
  // 回调自动注入复选框组收集到的值
  const checkedCount = value.length
  // 判断是否全选
  checkAll.value = checkedCount === cities.length
  // 判断是否为不确定状态
  isIndeterminate.value = checkedCount > 0 && checkedCount < cities.length
}
</script>
7.树形控件

需求:实现一个带有复选框的树形控件

vue
<template>
  <el-tree
    style="max-width: 600px"
    :data="data"  // 要展示的数据
    show-checkbox	  // 节点是否展示复选框
    node-key="id"	  // 每个树节点的唯一标识
    :default-expanded-keys="[2, 3]" // 默认展开的节点的 key 的数组
    :default-checked-keys="[5]" // 默认勾选的节点的 key 的数组
    :props="defaultProps" // 配置对象
    accordion			// 是否每次只打开一个同级树节点展开
   	ref="treeRef"		// 获取组件时实例对象
    v-loading="loading1" // 是否显示加载动画
  />
</template>
<script>
....
// 树形控件props配置选项
const defaultProps = {
  children: 'children', // 指定子树为节点对象的某个属性值
  label: 'label', // 指定节点标签为节点对象的某个属性值
}
// 获取当前树形控件第四级已选的id
const filterSelectArr = (allData: any, initArr: any) => {
  allData.forEach((item: any) => {
    if (item.children.length > 0) {
      // 当前权限有子权限时进行递归调用
      filterSelectArr(item.children, initArr)
    } else {
      // 当前权限没有子权限且为四级权限同时被选中时,添加到数组中
      if (item.select && item.level == 4) {
        initArr.push(item.id)
      }
    }
  })
  // 返回过滤到的四级已选全选的id数组
  return initArr
}
// 获取被选中权限的id和父级id
  let permissionId = treeRef.value
    .getCheckedKeys()
    .concat(treeRef.value.getHalfCheckedKeys())
// 树形控件展示的数据
const data = [
  {
    id: 1,
    label: 'Level one 1',
    children: [
      {
        id: 4,
        label: 'Level two 1-1',
        children: [
          {
            id: 9,
            label: 'Level three 1-1-1',
          },
          {
            id: 10,
            label: 'Level three 1-1-2',
          },
        ],
      },
    ],
  },
.....
]
</script>

注意:设置默认勾选的节点时,要使用最后一级选项的id,不可使用其父级的id

8.加载效果

区域加载(让加载效果显示在某个组件上)

使用指令 v-loading,只需要绑定 boolean 值即可。 默认状况下,Loading 遮罩会插入到绑定元素的子节点。 通过添加 body 修饰符,可以使遮罩插入至 Dom 中的 body 上。

vue
<template>
  <el-table v-loading="loading" :data="tableData" style="width: 100%">
    ....
  </el-table>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

const loading = ref(true)

让加载组件铺满整个屏幕(让加载效果铺满整个屏幕)

js
import { onMounted } from 'vue'
import { ElLoading } from 'element-plus'

// 方式一:使用修饰
  <el-button  // fullscreen全屏 lock锁定屏幕
    v-loading.fullscreen.lock="fullscreenLoading"
    @click="fullscreenLoading = true"
  >
// 方式二:使用服务
// setup函数中 加载组件时开启全屏加载
const loading = ElLoading.service({
  lock: true,
  text: 'Loading',
  background: 'rgba(0, 0, 0, 0.7)',
})
// 组件挂载完毕
onMounted(() => {
  // 关闭全屏加载
  loading.close()
})

九、文件上传

1.el-upload组件上传文件

实现将文件上传到指定服务器并可以在上传前对图片的大小和类型进行限制,在上传成功后将图片数据进行存储

image-20240523135440867

upload组件将图片上传到服务器上,会在on-success上传成功的这个钩子中可以接收到返回图片的地址,再和表单收集数据一起上传到服务器数据库中进行存储

vue
<!-- upload 组件属性 
    action:上传图片的地址要加前缀/api,使用代理服务器
    multiple:是否支持多文件上传
    show-file-list:是否显示已上传文件列表
    :before-upload:这个钩子在图片上传前触发的钩子,参数为上传的文件
    :on-success:文件上传成功后触发的钩子
    -->
<el-upload
  action="/api/admin/product/fileUpload"
  :before-upload="beforeAvatarUpload"
  :on-success="handleAvatarSuccess"
  style="width: 70%"
  drag // 可拖拽
>
  <!-- 上传图标和图片轮流展示 -->
  <img
    v-if="trademarkParams.logoUrl"
    :src="" data-missing="trademarkParams.logoUrl"
    style="width: 100%"
  />
  <template v-else>
    <el-icon class="el-icon--upload"><upload-filled /></el-icon>
    <div class="el-upload__text">
      将文件拖到此处,或
      <em>点击上传</em>
    </div>
  </template>
</el-upload>

<script>
// 定义收集新增品牌数据
const trademarkParams = reactive<TradeMark>({
  tmName: '',
  logoUrl: '',
})
.....
//上传图片组件->上传图片之前触发的钩子函数
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  // 在上传文件前,约束图片的类型和大小
  // console.log(rawFile) //{name: "1.jpg", size: 1024, type: "image/jpeg"}
  // 要求上传的图片的格式必须为 jpg/png/gif <4M
  if (
    rawFile.type === 'image/jpeg' ||
    rawFile.type === 'image/png' ||
    rawFile.type === 'image/gif'
  ) {
    if (rawFile.size <= 4 * 1024 * 1024) {
      return true // 上传
    } else {
      ElMessage({
        type: 'error',
        message: '上传的图片大小必须小于4M',
      })
      return false // 终止上传
    }
  } else {
    ElMessage({
      type: 'error',
      message: '上传的图片格式必须为jpg/png/gif',
    })
    return false // 终止上传
  }
}
// 上传图片组件->上传成功后触发的钩子函数
const handleAvatarSuccess: UploadProps['onSuccess'] = (
  response, // 上传成功后服务器返回的数据
) => {
  // 收集上传成功后服务器返回的图片地址,在添加品牌时使用
  trademarkParams.logoUrl = response.data
}
</script>

注意:

form el-uploadaction属性发送的请求不通过 axios因此不会走请求和响应拦截器,且不会出现跨域问题,但如果使用了代理服务器需要加上代理服务器前缀,才能发送到目标服务器上

Tips:

  1. el-uplod的钩子:

before-upload:这个钩子在图片上传前触发的钩子参数为上传的文件,可以对文件的类型和大小进行限制

on-success:文件上传成功后触发的钩子,可以获取到文件上传到服务器的地址

on-preview:点击文件列表中已上传的文件时的钩子,实现预览

  1. action属性的使用注意

actionupload组件上传图片的目标 URL,若使用了代理服务器则必须在 URL前加代理服务器前缀

2.el-upload组件照片墙展示与上传业务

实现照片的上传,展示,预览,删除功能

![image-20240523135240337](./Vue3 硅谷甄选.assets/image-20240523135240337.png)

vue
<template>
<!-- 
v-model:file-list:用于展示(收集)图片,图片列表的ts类型必须为UploadUserFile[]类型
action:上传图片的请求地址,发送的为post请求且必须在路径前加前缀/api(代理服务器前缀)
list-type:文件列表的类型 text/picture/picture-card(照片墙)
:on-preview:点击图片预览时触发的钩子
:on-remove:删除图片时触发的钩子
:before-upload:图片上传之前触发的钩子
:on-success:图片上传成功时触发的钩子
-->
<el-upload
  v-model:file-list="spu.spuImageList"
  action="/api/admin/product/fileUpload"
  list-type="picture-card"
  :on-preview="handlePictureCardPreview"
  :on-remove="handleRemove"
  :before-upload="handlerUpload"
  :on-success="handleSuccess"
>
  <el-icon><Plus /></el-icon>
  <template #tip> // 提示说明文字
    <div style="color: grey">
      上传的图片类型必须是jpg,jpeg,png,且大小不超过5M
    </div>
  </template>
</el-upload>
<!-- 图片预览对话框 -->
<el-dialog v-model="dialogVisible" width="30%"> // dialog使用v-model控制显示/隐藏
  <img style="width: 100%" :src="" data-missing="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</template>

<script>
// 存储照片弹窗的图片地址
let dialogImageUrl = ref('')
// 控制照片预览弹窗是否显示
let dialogVisible = ref(false)

// 图片上传前触发的钩子,用于限制上传图片的类型和大小,接收一个参数rawFile:上传图片的信息
const handlerUpload = (rawFile: UploadRawFile) => {
  // 限制图片的类型为jpg,jpeg,png大小不超过5M
  if (
    ['image/jpeg', 'image/jpg', 'image/png'].includes(rawFile.type) &&
    rawFile.size <= 1024 * 1024 * 5
  ) {
    return true //允许上传
  } else {
    ElMessage.error('上传的图片类型必须是jpg,jpeg,png,且大小不超过5M')
    return false //取消上传
  }
}
// 图片上传成功后的钩子,接收两个参数response:为上传成功后返回的图片信息,uploadFile:为当前图片对象(Proxy)
const handleSuccess = (response: any, uploadFile: UploadFile) => {
  // 将图片本地url替换为服务器返回的url
  uploadFile.url = response.data
}
// 点击照片预览时触发的钩子,接收一个参数uploadFile为当前点击的图片对象
const handlePictureCardPreview = (uploadFile: UploadFile) => {
  // 显示对话框
  dialogVisible.value = true
  // 获取当前点击的图片的url并展示
  dialogImageUrl.value = uploadFile.url as string
}
// 移除图片后触发的钩子,接受两个参数uploadFile:移除的图片对象,UploadFiles移除后的图片列表
const handleRemove = (uploadFile: UploadFile, uploadFiles: UploadFiles) => {
  console.log(uploadFile, uploadFiles)
  ElMessage.success(`${uploadFile.name}删除成功`)
}
</script>

注意:

  1. v-model:file-list为双向数据绑定,既可以展示数据、也可以收集数据

  2. 照片墙的图片列表TS类型必须为 UploadUserFile[]即:

    [
      {
        name: xx,
        url: xx,
      },
      ..
    ]
  3. action 图片上传的地址必须加上代理服务器的前缀,或写完整路径

  4. 图片上传成功后需要将存储的本地 url 替换为服务器返回的 url

    js
    // 上传成功后的钩子,接收两个参数response:为上传成功后返回的图片信息,uploadFile:为当前图片对象(Proxy)
    const handleSuccess = (response: any, uploadFile: UploadFile) => {
      // 将图片本地url替换为服务器返回的url
      uploadFile.url = response.data
    }
  5. 注意钩子函数回调注入的参数

    • response:为上传成功后返回的图片信息,包含当前图片服务器的url
    • uploadFile:为当前图片对象(Proxy)
    • uploadFiles:为上传图片列表(Proxy)
3.文件单位换算:

上传文件的 size属性默认为字节

1024字节 = 1kb

1024kb = 1mb

十、编辑模式与查看模式

1.编辑模式与查看模式的切换

通过一个变量 flag并配合 v-show实现 input组件与 div标签的相互切换

通过 input组件的失焦事件@blur,和 div标签的点击事件@click来改变 flag值,实现input标签和div标签的切换

input组件存在多个且是动态生成的,只使用一个标识去控制全部的input组件是不行的,要为每个数据对象添加一个flag属性用于控制不同 input组件的显示与隐藏

vue
<template>
<el-table-column label="属性值名称" align="center">
    // 通过插槽获取到当前对象的下标
   <template #default="{ row, $index }"> // 获取当前行数据对象
       // 编辑模式
       <el-input
            v-model.trim="row.valueName"
            placeholder="请输入属性值名称"
            v-show="row.flag"
            @blur="toLook(row, $index)" // 传入当前行的数据对象
       ></el-input>
       // 查看模式
	  <div v-show="!row.flag" @click="toEdit(row)"> // 传入当前行的数据对象
{{ row.valueName }}</div>
   </template>
</el-table-column>
</template>
<script>
// 属性值表单元素失去焦点的事件回调
const toLook = (row: AttrValue, $index: number) => {
  // 当属性值为空时不能变为div,弹窗提示并删除该元素
  if (row.valueName === '') {
    ElMessageBox.alert('属性值不能为空', '提示', {
      type: 'warning',
      confirmButtonText: 'OK',
    })
    // 删除属性值为空的元素
    attrParams.attrValueList.splice($index, 1)
    return
  }
  // 当属性值已经存在时,不能进行添加
  let repeat = attrParams.attrValueList.find((item) => {
    // 返回匹配到的重复对象
    // 遍历匹配时要将当前元素在列表中去除
    if (item != row) {
      return item.valueName === row.valueName
    }
  })
  if (repeat) {
    ElMessage({
      type: 'warning',
      message: '属性值已存在',
    })
    // 将重复的属性值删除
    attrParams.attrValueList.splice($index, 1)
    return
  }
  // 将flag变为false展示div
  row.flag = false
}
// 属性值div点击事件
const toEdit = (row: AttrValue) => {
  // 将flag变为true展示input
  row.flag = true
}
</script>
2.表单自动聚焦

需求:当动态添加一个 input 组件时自动获取焦点,当从查看模式切换到编辑模式时对应的 input组件自动聚焦

el-input组件暴露了一个 focus 方法,可以使 input组件自动聚焦

  1. 通过 ref 的函数式写法获取动态添加的每个 input 组件实例对象,并将其存储于数组中
vue
<el-table-column label="属性值名称" align="center">
  <template #default="{ row, $index }">
    <el-input
      ....
      // 将所有的input组件实例对象存储到inputArr中
      :ref="(vc:any)=>inputArr[$index] = vc"
    ></el-input>
    <div v-show="!row.flag" @click="toEdit(row, $index)">
      {{ row.valueName }}
    </div>

ref 绑定的回调触发时机:当模板的结构发生变化时,参数可以获取到input组件的实例对象

  1. 定义接收input组件的实例对象的数组
js
// 定义一个数组:用于存储input组件对应的实例对象
const inputArr = ref<any>([])
  1. 在添加 input组件时触发 focus方法
js
// 添加属性值按钮的回调
const addAttrValue = () => {
  // 向属性值列表中添加一个属性值
  attrParams.attrValueList.push({
    valueName: '',
    flag: true,
  })
  // 等待页面的input渲染完毕后去自动聚焦
  nextTick(() => {
  	// 将最后一个元素自动聚焦
    inputArr.value[attrParams.attrValueList.length - 1].focus()
    // 此处不使用inputArr的下标是因为其内部存在值为null并未清除对应的元素
  })
}
  1. 当从查看模式切换到编辑模式时对应input组件自动聚焦
js
// 属性值div点击事件
const toEdit = (row: AttrValue, $index: number) => {
  // 将flag变为true展示input
  row.flag = true
  // 等模板渲染完毕,再将input聚焦
  nextTick(() => {
    inputArr.value[$index].focus()
  })
}

注意:

  1. 实现切换时 input组件自动聚焦需要使用 input组件实例对象上的 focus方法
  2. 若有多个 input 需要进行编辑模式和查看模式的切换,使用 ref函数写法获取全部 input组件的实例对象 :ref="(vc:any)=inputArr[$index]=vc"
3.查看与编辑模式切换并收集数据

row 为当前行的属性列表对象,其身上拥有两个属性:

  1. flag :用于展示编辑模式 input还是查看模式 button

  2. saleAttrValue:用于存放编辑模式下不同input组件收集的数据

vue
<template #default="{ row, $index }">
<el-button
  v-if="!row.flag"
  type="success"
  size="small"
  icon="Plus"
  @click="toEdit(row, $index)"
></el-button>
<!-- ref函数式写法获取全部的input组件实例对象 -->
<el-input
  v-else
  :ref="(vc:any)=>inputRefArr[$index] = vc"
  v-model.trim="row.saleAttrValue"
  style="width: 50px"
  size="small"
  @blur="toLook(row)"
/>
</template>
<script>
// 点击添加属性值按钮的回调
const toEdit = (row: SaleAttr, $index: number) => {
  // 显示输入框
  row.flag = true
  // 清空上一次收集的数据
  row.saleAttrValue = ''
  // input组件渲染完毕后自动获取焦点
  nextTick(() => {
    inputRefArr.value[$index].focus()
  })
}
// 编辑属性值输入框失焦回调
const toLook = (row: SaleAttr) => {
  // 输入属性值非空判断
  if (!row.saleAttrValue) {
    ElMessage.warning('属性值不能为空')
  }
  // 重复值判断
  else if (
    row.spuSaleAttrValueList.find((item) => {
      return item.saleAttrValueName === row.saleAttrValue
    })
  ) {
    ElMessage.warning('属性值不能重复')
  } else {
    // 输入属性值不为空且不重复时进行存储
    row.spuSaleAttrValueList.push({
      baseSaleAttrId: row.baseSaleAttrId, // 销售属性id
      saleAttrValueName: row.saleAttrValue, // 销售属性值名
    })
  }
  // 显示添加按钮
  row.flag = false
  // 清空输入框
  row.saleAttrValue = ''
}
</script>

注意:

  1. 要对收集的数据进行非空和重复值判断
  2. 在收集完数据后要及时将 input 中的数据清空

十一、响应式数据注意点

1.refreactive 定义对象类型响应式数据

特别注意:reactive只能定义的对象类型的响应式数据且不可直接整体被替换 可以使用 Object.assign() 对两个对象进行合并替换

可以使用 ref 定义一些需要经常被整体替换的对象类型的数据

js
let trademarkArr = ref<Records>([]) // 此处注意要用ref定义不能使用reactive,后面要对整个trademarkArr进行替换

// 将获取已有品牌的接口封装为一个函数,在任何情况下获取数据调用函数即可
const hasTrademark = async () => {
  let result = await reqHasTrademark(pageNo.value, limit.value)
  if (result.code === 200) {
    // 存储品牌的总个数
    total.value = result.data.total
    trademarkArr.value = result.data.records
  }
}

ref可以定义基本类型和对象类型的响应式数据,使用时需要 .value

reactive 只可以定义对象类型的响应式数据,且不能整体被修改若要进行修改必须使用 Object.assign(a,b)使用b的值与a的值进行合并

2.可选链式调用

可选链式调用是一种语法糖,用于访问对象属性时避免出现undefinednull的错误。它允许你在访问对象属性之前检查该属性是否存在,如果不存在则返回undefined而不是抛出错误

通过在属性或方法后面添加一个问号 ?,可以创建一个可选链。如果链中的任何属性为nullundefined,整个表达式将立即返回undefined

js
// 创建一个响应式对象时指定了其ts类型但为设置初始值
let skuInfo = ref<SkuData>()  // ts推断skuInfo为Ref<SkuData | undefined>可能为空

// 后续对skuInfo进行赋值
skuInfo.value = result.data 

// 在模板中使用skuInfo中的属性时
{{ skuInfo.skuName }} // ts报错skuInfo可能未定义
// 解决办法:
// 1.使用类型断言 
{{ (skuInfo as SkuData).skuName }}
// 2.使用可选链式调用
{{ skuInfo?.skuName }}

作用:

  1. 可以在定义变量时只指定类型而不进行初始化,在使用变量时采用可选链式调用,避免TS类型检查报错

  2. 可以代替 nextTick使用

    js
    formRef.value?.clearValidate() // 如果组件已经挂载则调用组件实例对象的方法,如果还为挂载则程序不会报错
    nextTick(() => { // 使用nextTick
        formRef.value.clearValidate()
    })
3.Object.assign使用注意

Object.assign(target, ...sources) 将一个或者多个源对象中所有可枚举的自有属性复制到目标对象,并返回修改后的目标对象。该方法为浅拷贝

浅拷贝:是其属性与拷贝源对象的属性共享相同引用(指向相同的底层值)的副本

target进行修改,source也会发生变化

使其变为深拷贝: Object.assign(target, JSON.parse(JSON.stringify(sources)))

深拷贝:是指其属性与其拷贝的源对象的属性不共享相同的引用(指向相同的底层值)的副本。因此,当你更改源或副本时,可以确保不会导致其他对象也发生更改;

十二、深色模式与主题颜色切换

1.深色模式

使用:

  1. 在 html 标签上添加一个名为 dark 的类
html
<html class="dark">
  <head></head>
  <body></body>
</html>
  1. 只需要在项目入口文件添加一行代码:
js
// main.ts
// 如果只想导入css变量
import 'element-plus/theme-chalk/dark/css-vars.css'

注意:不要为组件设置背景色

2.通过switch开关切换深色模式
vue
<template>
<el-switch
  v-model="dark" // 绑定开关的值
  inline-prompt // 控制图标/文本显示在开关内
  // 设置开关打开的关闭的颜色
  style="--el-switch-on-color: #333; --el-switch-off-color: #bbb"
  active-text="Dark" // 开关打开时显示的文本
  active-action-icon="Moon" // 开关打开时的图标
  inactive-text="Light" // 开关关闭时按钮显示的文本
  inactive-action-icon="Sunny" // 开关关闭时按钮显示的图标
  size="large"
  @change="changeDark" // 开关发生改变时触发的事件
></el-switch>
</template

<script>
// 收集开关的值是否为深色模式
let dark = ref(false)
// switch开关的change事件进行暗黑模式的切换
const changeDark = () => {
  // 获取HTML根节点 为根节点添加一个类名class="dark"
  let html = document.documentElement
  // 根据dark的值为html根节点添加或者删除dark类名
  dark.value ? (html.className = 'dark') : (html.className = '')
}
</script>
3.更改主题色

通过 js 控制主题颜色

js
// document.documentElement 是全局变量时
const el = document.documentElement
// const el = document.getElementById('xxx')

// 获取 css 变量
getComputedStyle(el).getPropertyValue(`--el-color-primary`)

// 设置 css 变量
el.style.setProperty('--el-color-primary', 'red')

可以使用本地存储 持久化存储主题颜色,避免刷新失效

4.解决取色器中的bug

当取色器出现选完颜色未点击确定就消失的问题时可以为 <el-color-picker>组件添加 :teleported="false"属性进行解决

vue
<el-color-picker
  v-model="colorValue"
  :predefine="predefineColors"
  show-alpha
  @change="setColor"
  :teleported="false"  // 不将popover的下拉列表渲染至body下
/>
5.刷新主题色不丢失
  1. 持久化存储主题颜色

utils/theme.ts

js
// 存储和获取主题颜色及暗黑模式
export const SET_COLOR = (color: string) => {
  localStorage.setItem('COLOR', color)
}
export const SET_DARK = (dark: boolean) => {
  localStorage.setItem('DARK', JSON.stringify(dark))
}
export const GET_COLOR = () => localStorage.getItem('COLOR')
export const GET_DARK = () => localStorage.getItem('DARK')
  1. 组件挂载时初始化主题
js
// 组件挂载时初始化主题
onMounted(() => {
  setColor()
  changeDark()
})

十三、数据大屏实现

1.数据大屏解决方案vw与vh

设置数据大屏的宽高时不能使用像素为单位将宽高固定,当屏幕尺寸发生变化时,会出现空白页面

解决方案:使用vwvh 相对单位

VW和VH是CSS3中基于视口宽度和高度的单位兼容IE8+

vw:相对于视口宽度,会随着视口宽度的变化而变化(取值:0-100)

vh:相对于视口高度,会随着视口高度的变化而变化(取值:0-100)

  • 1vw:这表示视口宽度的1%,即如果视口宽度是1000px,那么1vw就等于10px。
  • 1vh:这表示视口高度的1%,同样地,如果视口高度是800px,那么1vh就等于8px。

使用 vwvh可以解决数据大屏的适配问题,当屏幕尺寸发生变化时,内容大小也会随着改变,但计算起来较为麻烦,且 echarts中的文字不支持vwvh

html
 <style>
      * {
        margin: 0;
        padding: 0;
      }
      /* 设计稿宽度1920px 高度1080px 内部有两个100px*100px的子元素,
      使用vw和vh单位使子元素宽高随着视口的变化而变化 */
      .box {
        width: 100vw; /* 宽度相对于视口宽度的100% */
        height: 100vh; /* 高度相对于视口高度的100% */
        background-color: orange;
        font-size: 0.8vw; /*字体大小为视口宽度的0.8%*/
      }
      .top {
        width: 5.2vw; 
        /* 设计稿宽度100px 1920/100=19.2 100/19.2=5.2 相对于视口宽度的5.2% */
        height: 9.26vh; 
        /* 设计稿高度100px 1080/100=10.8 100/10.8=9.26 相对于视口高度的9.26% */
        background-color: red;
        margin-left: 2.6vw; 
        /* 在设计稿中左外边距50px margin-left相对于视口宽度的2.6% */
      }
    </style>
  </head>
  <body>
    <div class="box">
      <div class="top">数据大屏幕</div>
    </div>
  </body>
2.数据大屏解决方案scale

只需要适配一次,元素初始宽高只需按照设计稿即可

transform: scale 是 CSS 中的一个属性,用于改变元素的大小。它接受一个或两个参数,分别表示水平和垂直方向上的缩放比例,transform: scale会同时缩放子元素

步骤:

  1. 通过定位将要缩放的元素定位到屏幕中心位置
  2. 设置 sacle 缩放的基点为元素的左上角(即屏幕中心位置)
  3. 获取元素并计算视口缩放比
  4. 监视视口的变化通过transform:scale(x,y)对元素xy轴进行缩放,再通过 transform:translate(x,y) 将元素移动到左上角
html
 <style>
      .container {
        width: 1920px; 
        height: 1080px;
        background: url(./bg.png) no-repeat;
        background-size: cover;
      }
      .box {
        /* 1.先将盒子左上角定位到中心位置,以盒子左上角进行缩放,缩放后再将盒子通过translate移动到左上角 */
        position: fixed;
        left: 50%;
        top: 50%;
        width: 1920px; /*盒子宽高与设计稿保持一致,通过缩放实现适配 */
        height: 1080px;
        background-color: red;
        /* 设置盒子缩放基点为左上角,默认为元素中心点 */
        transform-origin: left top;
      }
      .top {
        width: 100px; /* 盒子宽高与设计稿保持一致,通过缩放实现适配 */
        height: 100px;/*子元素会随着父元素进行缩放 */
        background-color: hotpink;
        margin-left: 50px;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <!-- 数据展示区 -->
      <div class="box">
        <div class="top">我是数据大屏</div>
      </div>
    </div>
  </body>
</html>
<script>
  // 控制数据大屏的缩放
  // 2.获取需要缩放的盒子
  let box = document.querySelector(".box");

  // 3.计算缩放比率
  // xy轴使用相同缩放比例(可能会出现空白区但元素不会变形)
  function getScale(w = 1920, h = 1080) {
    const ww = window.innerWidth / w;
    const wh = window.innerHeight / h;
    // 取缩放比较小的保证内容全部展示
    return ww < wh ? ww : wh;
  }

  // x,y轴分别使用各自缩放比例(元素可能会不成比例缩放)
  /*function getScaleX(w = 1920) {
    return window.innerWidth / w;
  }
  function getScaleY(h = 1080) {
    return window.innerHeight / h;
  } */

  // 4.当窗口发生变化时触发
  window.onresize = () => {
    // 当浏览器窗口发生变化时先进行缩放再进行平移
    box.style.transform = `scale(${getScale()}) translate(-50%,-50%)`;
    // box.style.transform = `scale(${getScaleX()},${getScaleY()}) translate(-50%,-50%)`;
  };
</script>

scale:的参数有两种写法

  1. 使用宽高缩放比中较小的作为x,y轴的缩放比 scale(n) 可以保证元素不会变形
  2. 分别使用宽高缩放比作为x,y轴的缩放比 scale(x,y) 可以保证页面不出现空白区域

使用时只需适配父元素,对父元素进行缩放,且不需要使用相对单位

3.大屏适配解决方案
vue
<template>
  <div class="container">
    <!-- 数据大屏展示内容区域 -->
    <div ref="screen" class="screen">
      ....
    </div>
  </div>
</template>

<script setup lang="ts" name="Screen">
import { onMounted, ref } from 'vue'
// 获取数据大屏展示内容盒子的DOM元素
let screen = ref()

// 解决适配问题
// 根据窗口大小缩放数据大屏的函数
const changeScreen = () => {
  // 计算宽高缩放比
  let wPercent = window.innerWidth / 1920 // 1920*1080 设计稿宽高
  let hPercent = window.innerHeight / 1080
  // 获取宽高中的较小缩放比(保证显示完整)
  let scalePercent = Math.min(wPercent, hPercent)
  // 缩放数据大屏
  screen.value!.style.transform = `scale(${scalePercent}) translate(-50%, -50%)`
}

onMounted(() => {
  // 组件挂载完毕立即根据当前浏览器窗口进行一次缩放
  changeScreen()
  // 当浏览器窗口发生变化时重新缩放
  window.onresize = () => changeScreen()
})
</script>

<style scoped lang="scss">
.container {
  width: 100vw;
  height: 100vh;
  /* 设置一张背景图当xy轴缩放比例不一致时空白部分显示背景图 */
  background: url(./images/bg.png) no-repeat;
  background-size: cover;
  .screen {
    width: 1920px;
    height: 1080px;
    /* 将元素移动到屏幕中心 */
    position: fixed;
    left: 50%;
    top: 50%;
    /* 设置缩放中心点,相对于元素左上角 */
    transform-origin: left top;
    ....
  }
}
</style>

大屏展示内部组件的宽高只需按照设计稿的宽高进行设置即可,宽度会随缩放比自动变化

4.span布局识别宽高

使用 span进行布局时,若想为元素设置宽高可以通过为元素设置float 或为父元素设置display:flex

  1. 当为 span 或任何其他内联元素设置 float 属性(如 float: left;float: right;),这个元素会从正常的文档流中抽出,表现得更像是一个块级元素

  2. 当你将 <span> 元素的 display 属性设置为 flex 时,这个原本的内联元素转变成了一个弹性容器。在 Flexbox 布局模型中,即使是像 <span> 这样的传统内联元素,也会表现出块级元素的特征,从而允许你直接设置其宽度(width)和高度(height)。

5.moment插件格式化时间

也可以使用 day.js

使用:

  1. 安装:pnpm i moment
  2. 引入:import moment from 'moment'
  3. 使用:moment().format('YYYY-MM-DD HH:mm:ss')
js
// 当组件挂载时开启定时器更新时间
onMounted(() => {
  timer.value = setInterval(() => {
    time.value = moment().format('YYYY-MM-DD HH:mm:ss')
  }, 1000)
})
// 当前组件销毁前关闭定时器
onBeforeUnmount(() => {
  // 清除定时器
  clearInterval(timer.value)
})
6.Echarts使用
  1. 安装ECharts:pnpm i echarts

  2. 引入:import * as echarts from 'echarts'

  3. 获取要展示图表的节点(盒子必须设置宽高)

    js
    <!-- 准备一个有宽高的盒子将来ECharts展示图形图表的节点 -->
    <div style="width=200px;height=50px" ref="charts">123</div> 
    // 获取要展示图表的节点
    let charts = ref()
  4. onmounted钩子中初始化echarts实例对象并设置配置项,初始化echarts实例需要组件实例对象

    示例:横向柱状图

    ![image-20240517232753007](./Vue3 硅谷甄选.assets/image-20240517232753007.png)

    js
    // 组件挂载完毕展示图表(保证dom可用)
    onMounted(() => {
      // 初始化eCharts实例对象
      let myCharts = echarts.init(charts.value)
      // 设置配置项并渲染
      myCharts.setOption({
        // 标题组件
        title: {
          text: '男女比例', // 主标题
          left: "40%", // 标题位置
          textStyle: {  // 标题样式
            color: 'skyblue', // 主标题颜色
            fontSize: '16px', // 主标题字体大小
          },
        },
        // x|y轴组件
        xAxis: {
          show: false, // 不显示x轴
          // type:'category' // 在x轴上均匀分布
          min: 0, // x轴最小值
          max: 100, // x轴最大值
        },
        yAxis: {
          show: false, // 不显示y轴
          type: 'category', // 在y轴上均匀分布
        },
        // 系列
        series: [
          // 男生柱条
          {
            type: 'bar', // 柱状图
            data: [58], // 数据
            barWidth:20, // 柱状图宽度
            z:100, // 柱条层级,值越大越靠上
            // 柱条样式
            itemStyle:{
              borderRadius: 20, // 圆角
            }
          },
          // 女生柱条
          {
            type: 'bar', // 柱状图
            data: [100], // 数据
            barWidth:20, // 柱状图宽度
            // 调整女生柱条的位置,使其覆盖到男生柱条上,实现重叠覆盖效果
            barGap: '-100%',
            // 柱条样式
            itemStyle:{
              color:"pink",
              borderRadius: 20, // 圆角
            }
          },
        ],
        // 布局  占满全部宽高
        grid: {
          left: 0,
          right: 0,
          bottom: 0,
          top: 0,
        },
      })
    })

ECharts配置文档

7.Echarts水球图插件
  1. 安装 ECharts

  2. 安装水球图拓展插件:pnpm i echarts-liquidfill

  3. 引入:import 'echarts-liquidfill'

  4. 配置 option

    示例:![image-20240517232822419](./Vue3 硅谷甄选.assets/image-20240517232822419.png)

    js
    myCharts.setOption({
        // 系列:决定展示什么样的图形图表
        series: [
          {
            type: 'liquidFill', // 图形类型
            data: [0.6, 0.4, 0.2], // 展示的数据
            waveAnimation: true, // 动画
            animationDuration: 1000, // 动画持续时间
            animationDurationUpdate: 1000,
            radius: '90%', // 半径
            color: ['#294D99', '#156ACF', 'aqua'],// 颜色
            center: ['50%', '50%'], // 位置
            amplitude: '8%',  // 振幅
            waveLength: '80%', // 波长
            direction: 'right', // 方向
            shape: 'circle', // 形状
            outline: { // 轮廓
              show: true,
              borderDistance: 8, // 轮廓距离
              itemStyle: {
                color: 'aqua', // 轮廓颜色
                borderColor: '#294D99',
                borderWidth: 8,
                shadowBlur: 20,
                shadowColor: 'rgba(0, 0, 0, 0.25)',
              },
            },
            label: {  // 文字样式
              show: true,
              formatter: '预测量',
              color: '#294D99',
              insideColor: '#fff',
              fontSize: 30,
              fontWeight: 'bold',
              align: 'center',
              baseline: 'bottom',
              position: 'inside',
            },
          },
        ],
      })

    参考网站:https://www.npmjs.com/package/echarts-liquidfill

8.ECharts绘制中国地图 效果参考网站

效果图:image-20240523205331049image-20241011180547983

vue
<template>
  <div>
    <!-- 准备一个容器放置图形 -->
    <div class="box" ref="map"></div>
  </div>
</template>

<script setup lang="ts" name="Map">
// 1.引入echarts
import * as echarts from 'echarts'
// 2.引入中国地图的JSON数据
import chinaJSON from './china.json'
import { onMounted, ref } from 'vue'

// 3.获取图表的DOM元素
let map = ref()
// 4.注册中国地图
echarts.registerMap('china', chinaJSON as any)
// 在组件挂载完毕后 才能获取到DOM元素
onMounted(() => {
  // 5.初始化echarts实例对象
  let myChart = echarts.init(map.value)
  // 6.设置配置项
  myChart.setOption({
    // 地图组件
    geo: {
      map: 'china', //注册的地图名称,中国地图
      show: true, //是否显示地理坐标系组件
      roam: true, //是否开启鼠标缩放和平移漫游
      zoom: 1.3, //缩放比例
      // 地图的位置的设置
      left: 120,
      top: 150,
      right: 100,
      bottom: -150,
      // 地图上文字的设置
      label: {
        show: true, // 显示
        color: 'white', // 文字颜色
        fontSize: '14px', // 文字大小
      },
      // 每一个多边形的样式
      itemStyle: {
        // areaColor: 'skyblue', // 地图区域的颜色
        // 图形颜色(支持渐变色)
        color: {
          type: 'linear', // 线性渐变
          x: 0,
          y: 0,
          x2: 0,
          y2: 1,
          // 渐变色
          colorStops: [
            {
              offset: 0,
              color: 'blue', // 0% 处的颜色
            },
            {
              offset: 1,
              color: 'aqua', // 100% 处的颜色
            },
          ],
          global: false, // 缺省为 false
        },
        opacity: 0.9, // 透明度
      },
      // 高亮状态下的多边形和标签样式
      emphasis: {
        // 每个多边形的样式
        itemStyle: {
          areaColor: 'red',
        },
        // 高亮状态下的标签样式
        label: {
          fontSize: '20px',
          color: 'black',
        },
      },
    },
    // 航线
    series: {
      type: 'lines',
      // 是否是多段线,折线效果
      // polyline: true, 不能和curveness(圆滑线)同时使用
      // 线特效的配置
      effect: {
        show: true,
        period: 6, // 特效时间
        // 特效图形 可以是图片路径/SVG路径
        symbol:
          'path://M599.06048 831.309824l12.106752-193.404928 372.860928 184.430592L984.02816 710.206464 617.906176 367.33952 617.906176 151.638016c0-56.974336-46.188544-143.064064-103.158784-143.064064-56.974336 0-103.158784 86.089728-103.158784 143.064064L411.588608 367.33952 45.461504 710.206464l0 112.129024 366.660608-184.430592 14.999552 209.27488c0 5.05344 0.594944 9.892864 1.124352 14.749696l-66.591744 60.348416 0 66.587648 153.986048-50.879488 2.43712-0.80896 147.439616 51.688448 0-66.587648-68.758528-62.253056L599.06048 831.309824z', //特效图形的标记
        symbolSize: 25, // 特效图形的大小
        trailLength: 0.01, // 特效尾迹长度[0,1]值越大,尾迹越长重
        loop: true, // 循环
        // roundTrip: true, //原路返回
        color: 'white', // 特效图形颜色
      },
      // 航线统一的样式设置
      lineStyle: {
        curveness: 0.3, // 航线线条曲直度
        color: 'aqua', // 线条颜色
        width: '2', // 线宽
      },
      // 线的数据的配置
      data: [
        {
          coords: [
            [116.405285, 39.904989], // 起点 坐标
            [87.617733, 43.792818], // 终点
          ],
        },
        {
          coords: [
            [87.617733, 43.792818], // 起点
            [116.405285, 39.904989], // 终点
          ],
        },
        {
          coords: [
            [116.405285, 39.904989], // 起点
            [102.712251, 25.040609], // 终点
          ],
        },
        ......
      ],
    },
    // 布局
    grid: {
      left: 0,
      top: 0,
      right: 0,
      bottom: 0,
    },
  })
})
</script>

<style scoped lang="scss">
/* 7.为图表盒子设置宽高  */
.box {
  width: 100%;
  height: 100%;
}
</style>

中国地图JSON数据:

https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json

echarts配置项:https://www.isqqw.com/echarts-doc/zh/option.html#

十四、菜单权限与按钮权限设置

1.菜单权限

​ 超级管理员:可以访问到全部菜单

​ 测试账号:可以访问到指定菜单

1.1 目前整个项目路由个数

  1. /login :登录路由
  2. /404:404一级路由
  3. /*: 匹配不到的路由
  4. /home:首页
  5. /screen:数据大屏路由
  6. /acl:权限路由(三个子路由)
  7. /product:商品管理模块(四个子路由)

1.2 实施步骤

第一步:拆分路由

  • 静态(常量)路由:大家都可以拥有的路由:/login/home/screen/404
  • 异步路由:不同角色,拥有的不同路由:/acl/product
  • 任意路由:/*(当用户输入的路径不存在时跳转的路由)
js
// 对外暴露配置路由(常量路由):全部用户都以访问到的路由
export const constantRoute = [
  // 登录
  {
    path: '/login',
    component: () => import('@/views/login/index.vue'), // 路由懒加载
    name: 'login', // 命名路由
    meta: {
      title: '登录', //菜单标题
      hidden: true, // 路由的标题在菜单中是否隐藏
    },
  },
  // 首页路由 登录成功以后展示数据的路由
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    name: 'layout',
    meta: {
      hidden: false,
    },
    redirect: '/home', // 重定向
    children: [
      {
        path: '/home',
        component: () => import('@/views/home/index.vue'),
        meta: {
          title: '首页',
          icon: 'HomeFilled', // icon图标名称(支持 element-plus的全部icon图标)
          hidden: false,
        },
      },
    ],
  },
  // 数据大屏路由 因为要全屏显示,所以必须使用一级路由,使用Screen组件
  {
    path: '/screen',
    component: () => import('@/views/screen/index.vue'),
    name: 'Screen',
    meta: {
      title: '数据大屏',
      icon: 'DataAnalysis',
      hidden: false,
    },
  },
  // 404
  {
    path: '/404',
    component: () => import('@/views/404/index.vue'),
    name: '404',
    meta: {
      title: '404',
      hidden: true, // 路由的标题在菜单中是否隐藏
    },
  },
]

// 异步路由
export const asyncRoute = [
  // 权限管理路由,不需要全屏显示,可以使用layout组件
  {
    path: '/acl',
    component: () => import('@/layout/index.vue'),
    name: 'Acl',
    meta: {
      title: '权限管理',
      icon: 'Lock',
      hidden: false,
    },
    redirect: '/acl/user', // 重定向到用户管理
    children: [
      {
        path: '/acl/user',
        component: () => import('@/views/acl/user/index.vue'),
        name: 'User',
        meta: {
          title: '用户管理',
          icon: 'User',
          hidden: false,
        },
      },
      {
        path: '/acl/role',
        component: () => import('@/views/acl/role/index.vue'),
        name: 'Role',
        meta: {
          title: '角色管理',
          icon: 'Avatar',
          hidden: false,
        },
      },
      {
        path: '/acl/permission',
        component: () => import('@/views/acl/permission/index.vue'),
        name: 'Permission',
        meta: {
          title: '菜单管理',
          icon: 'Operation',
          hidden: false,
        },
      },
    ],
  },
  // 商品管理路由
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    name: 'Product',
    meta: {
      title: '商品管理',
      icon: 'Goods',
      hidden: false,
    },
    redirect: '/product/trademark', // 重定向到品牌管理
    children: [
      {
        path: '/product/trademark',
        component: () => import('@/views/product/trademark/index.vue'),
        name: 'Trademark',
        meta: {
          title: '品牌管理',
          icon: 'ShoppingCartFull',
        },
      },
      {
        path: '/product/attr',
        component: () => import('@/views/product/attr/index.vue'),
        name: 'Attr',
        meta: {
          title: '属性管理',
          icon: 'ChromeFilled',
        },
      },
      {
        path: '/product/spu',
        component: () => import('@/views/product/spu/index.vue'),
        name: 'Spu',
        meta: {
          title: 'SPU管理',
          icon: 'Grid',
        },
      },
      {
        path: '/product/sku',
        component: () => import('@/views/product/sku/index.vue'),
        name: 'Sku',
        meta: {
          title: 'SKU管理',
          icon: 'Tools',
        },
      },
    ],
  },
]

// 任意路由
export const anyRoute = [
  // 任意路由
  {
    path: '/:pathMatch(.*)*',
    redirect: '/404',
    name: 'Any',
    meta: {
      title: '任意路由',
      hidden: true, // 路由的标题在菜单中是否隐藏
    },
  },
]

第二步:获取当前用户的异步路由数据并过滤

​ 用户登录后会返回用户拥有的异步路由名数据

js
// 用于过滤异步路由的方法
  function filterAsyncRoute(asyncRoute: any, routes: any) {
    return asyncRoute.filter((item: any) => {
      // 是否包含当前路由名
      if (routes.includes(item.name)) {
        // 过滤二级路由
        if (item.children && item.children.length > 0) {
          // 递归调用过滤二级路由
          item.children = filterAsyncRoute(item.children, routes)
// 注意:filter为浅拷贝对item.children的修改会影响到asyncRoute的值应使用深拷贝
        }
        // 无子路由将将此路由存储到过滤后的数组中
        return true
      }
    })
  }
// 获取用户信息的方法
async function userInfo() {
....
// 调用过滤异步路由的方法,获取过滤后的异步路由
let userAsyncRoute = filterAsyncRoute(
     cloneDeep(asyncRoute), // 此处使用深拷贝(使用lodash插件)
     result.data.routes,
)
...
}

第三步:获取当前用户的全部路由用于展示到导航栏

js
Object.assign(menuRoutes, [...constantRoute, ...userAsyncRoute, ...anyRoute]);

第四步:为路由器动态追加异步路由和任意路由

js
// 目前路由器管理的只有常量路由:用户计算完毕的异步路由和任意路由需要动态追加
;[...userAsyncRoute, ...anyRoute].forEach((route: any) => {
    // 为路由器动态追加路由
    router.addRoute(route)
})

注意:

  1. filter方法为浅拷贝,对数据的操作会影响到源对象,导致下次过滤的源数据出现错误

    解决办法:

    1. 安装 lodash pnpm i lodash

    2. 引入深拷贝方法 import cloneDeep from 'lodash/cloneDeep'

    3. 过滤异步路由时传递深拷贝的数据

      js
      // 调用过滤异步路由的方法,获取过滤后的异步路由
      let userAsyncRoute = filterAsyncRoute(
          cloneDeep(asyncRoute),
          result.data.routes,)
  2. 异步路由刷新无法渲染页面

    原因:刷新时访问异步路由,用户信息加载完毕,异步组件还未加载完毕,会出现空白效果

    解决方法:

    ​ 路守卫等路由组件渲染完毕再放行 next({...to})

2.深拷贝与浅拷贝

浅拷贝:拷贝后的副本属性与源属性属性共享相同引用(指向相同的底层对象),当改变副本/源对象时,源对象/副本也会发生改变

JS中的浅拷贝:展开语法(...)、Array.prototype.concat()、Array.prototype.slice()、Object.assign()、Array.filter()....

深拷贝:拷贝后的副本与源对象不共享相同的引用(指向相同的底层值),对副本或源对象进行修改时,源对象/副本不会受影响

JS实现深拷贝方法:

  1. 使用 JSON.stringify() 将该对象转换为 JSON 字符串,然后使用 JSON.parse() 将该字符串转换回(全新的)JavaScript 对象如:Object.assign(target, JSON.parse(JSON.stringify(sources)))

  2. 使用lodash 插件

    js
    // 1.安装
    pnpm i lodash
    // 2.引入深拷贝方法
    // @ts-ignore
    import cloneDeep from 'lodash/cloneDeep'
    // 3.将asyncRoute深拷贝
    cloneDeep(asyncRoute),
3.按钮权限

不同的用户拥有的按钮权限也不相同

获取用户信息的接口会返回当前用户拥有的全部按钮的名称

实现原理:

  1. 将获取到的按钮权限的数组存储到用户仓库中

  2. 在组件中引入仓库获取按钮名称对按钮的展示和隐藏进行判断

    vue
    <el-buttom
    v-if="buttons.includes('btn.Trademark.add')"
    >添加品牌
    </el-button>
    
    <script>
    // 引入用户相关仓库
    import useUserStore from '@/store/modules/user'
    import { storeToRefs } from 'pinia'
    
    // 按钮权限的实现
    // 获取用户拥有的全部按钮信息
    const { buttons } = storeToRefs(useUserStore())
    </script>

当异步路由按钮过多时,每个组件都需要引入仓库过于繁琐

实现步骤:

  1. 全局自定义指令 src/directive/has.ts
ts
// 引入仓库
import pinia from '@/store'
import useUserStore from '@/store/modules/user'
import { storeToRefs } from 'pinia'
// 获取用户仓库中的buttons
// storeToRefs 转换为Ref响应式对象
const { buttons } = storeToRefs(useUserStore(pinia)) // 在组件外使用useXxx方法需要注入pinia

export const isHasButton = (app: any) => {
  // 全局自定义指令:实现按钮权限
  app.directive('has', {
    // 当使用该自定义指定的DOM|组件挂载完毕时执行
    mounted(el: any, binding: any) {
      // el:原生DOM,binding:指令对象,包含指令值
      if (!buttons.value.includes(binding.value)) {
        // 如果按钮权限中不包含当前按钮名,移除掉当前DOM
        el.parentNode.removeChild(el)
        // 或隐藏当前DOM
        // el.style.display = 'none'
      }
    },
  })
}
  1. 在mian.ts中调用函数注册全局指令

    js
    // 引入自定义指令文件
    import { isHasButton } from './directive/has.ts'
    isHasButton(app)
  2. 为每个按钮绑定 v-has('按钮名') 全局自定义指令

4.自定义指令

组件内自定义指令:

vue
<script setup>
// 在模板中启用 v-focus
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

<template>
  <input v-focus />
</template>

<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令。在上面的例子中,vFocus 即可以在模板中以 v-focus 的形式使用。

全局自定义指令:

js
const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive('focus', {
  /* ... */
})

定义指令时不需要 v- 在使用时需要加上 v-前缀

指令钩子:

一个指令的定义对象可以提供几种钩子函数 (都是可选的):

js
const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode, prevVnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode, prevVnode) {}
}

钩子参数:

  • el:指令绑定到的元素。这可以用于直接操作 DOM。

  • binding:一个对象,包含以下属性。

    • value:传递给指令的值。例如在 v-my-directive="1 + 1" 中,值是 2

    • oldValue:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否更改,它都可用。

      .......

  • vnode:代表绑定元素的底层 VNode。

  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

配合插件进行使用:

src\directives\index.ts 定义全局自定义指令的插件

ts
import type { App } from 'vue'

const directives = {
  install(app: App) {
    // v-big:实现文字放大功能
    app.directive('big', (el, binding) => {
      el.style = `font-size:${binding.value}px`
    })
  }
}

export default directives

src\main.ts

ts
import { createApp } from 'vue'
import App from './App.vue'
import directives from './directives'

const app = createApp(App)
// 使用全局自定义指令插件
app.use(directives)

app.mount('#app')
5.组件外使用使用 store

组件内(.vue文件):只需调用你定义的 useStore() 函数即可得到仓库对象(会自动在仓库的hook函数中注入pinia实例)

组件外(.ts/js文件):需要在 useStore() 函数参数中注入 pinia 实例

ts
// 引入仓库
import pinia from '@/store'
import useUserStore from '@/store/modules/user'
// 获取用户仓库中的buttons
const { buttons } = useUserStore(pinia)

十五、其余注意点

1.路由切换与页面刷新注意点

路由切换时:路由组件会被销毁,但 pinia中的数据不会别销毁,想要在路由组件销毁时清空对应仓库中的数据

仓库对象上存在一个 $reset()方法,可以清空仓库中的所有数据

js
xxxStore.$reset();

页面刷新时:所有APP根组件会进行销毁和重新挂载,pinia中的数据也会被销毁

2.SPU与SKU

SPU:电商术语,代表一个标准化产品单元(类)

eg:华为公司:品牌名称 华为->品牌单元

SPU组成:

  1. 产品品牌的名字
  2. 产品的描述
  3. 公司旗下的产品的介绍
  4. 相应的销售属性[整个项目销售属性一共有三个:颜色,版本,尺码]

SKU:库存量最小单位(实例)

3.v-if与v-show的区别

两者都可以隐藏组件

v-if:会将销毁组件,再次展示时重新挂载组件(可以用于实现页面刷新功能)

v-show:只是为组件标签添加了 display:none属性使组件标签进行隐藏,组件一直处于挂载状态(适用于经常切换场景)

4.v-if与v-for

Vue3v-ifv-for 两者作用于同一个元素上时,v-if 会拥有比 v-for 更高的优先级。

5.nextTick

nextTick是vue3中的钩子函数,用于等待页面渲染(卸载)完成后再执行回调函数,可以用于在组件渲染(卸载)完毕后对DOM(组件)的操作

vue
<template>
  <div>
    <input type="text" v-if="show" ref="inputRef" />
    <button @click="showInput">点我显示input框并聚焦</button>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref } from 'vue'

let show = ref(false)
let inputRef = ref()
const showInput = () => {
  show.value = true
  // 报错:模板此时还未渲染完毕获取不到inputRef组件实例对象
  // inputRef.value.style.display = 'none'
  nextTick(() => {
    // 功能正常,等待组件重新渲染完毕后在执行以下代码,可以获取到组件实例对象
    inputRef.value.style.display = 'none'
  })
}
</script>
6.数组身上的方法
  1. Array.prototype.map()更新数组 返回一个新数组,这个新数组由原数组中的每个元素都调用一次提供的函数后的返回值组成。
js
// 将图片的imgName,imgUrl属性转换为name,url属性,符合照片墙图片的ts类型
spu.value.spuImageList = result1.data.map((item) => {
    return {
        name: item.imgName,
        url: item.imgUrl,
    }
})
  1. Array.prototype.reduce()条件计数 对数组中的每个元素按序执行一个提供的 reducer 函数,可以提供一个初始值,每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
js
// 求数组中各个元素相加总和
const array1 = [1, 2, 3, 4];
const sumWithInitial = array1.reduce(
  (preValue, currentValue) => preValue + currentValue,
  0,
);

// 使用reduce函数将一个数组内每个对象的attrIdAndValueId属性拆分为两个属性并组成一个对象,返回一个由此对象组成的数组
skuParams.skuAttrValueList = attrArr.value.reduce((prev: any, next: any) => {
    // 如果存在该属性attrIdAndValueId
    if (next.attrIdAndValueId) {
      const [attrId, valueId] = next.attrIdAndValueId.split(':')
      // 添加到一个空数组中
      prev.push({
        attrId,
        valueId,
      })
    }
    // 返回该数组
    return prev // 作为下一次的prev
  }, []) // []会作为第一次的prev

// 使用map+filter实现
const attrData = attrArr.value.map((item: any) => {
    if (item.attrIdAndValueId) {
      const [attrId, valueId] = item.attrIdAndValueId.split(':')
      return {
        attrId,
        valueId,
      }
    } 
  }) 
// 过滤掉空的对象
skuParams.skuAttrValueList = attrData.filter((item: any) => {
    return item
})
  1. Array.prototype.find() 查找 返回数组中满足提供的测试函数的第一个元素的值。
js
// 查找销售属性值列表中是否已经存在与row.saleAttrValue相同的值
row.spuSaleAttrValueList.find((item) => {
   return item.saleAttrValueName === row.saleAttrValue
})
  1. Array.prototype.filter() 过滤 返回一个新数组其包含通过所提供函数实现的测试的所有元素。
js
const words = ['spray', 'elite', 'exuberant', 'destruction', 'present'];
const result = words.filter((word) => word.length > 6);
  1. Array.prototype.every() 测试数组内的元素是否全部满足条件 测试一个数组内的所有元素是否都能通过指定函数的测试。 返回ture/false

    filter配合every筛选出a数组中有b数组中没有的数据

js
// 计算当前spu尚未选择的SPU的属性
  // 全部的销售属性列表:AllSaleAttr   已有的销售属性列表:spu.value.spuSaleAttrList
  let unSelectAttr = AllSaleAttr.value.filter((item) => {
    return spu.value.spuSaleAttrList.every((item1) => {
      return item.name !== item1.saleAttrName
    })
  })
7.在父组件中调用子组件中的方法

需求:当点击父组件中的按钮展示子组件时需要发请求获取数据时有两种解决方案

  1. 在父组件点击事件的回调中发送请求获取数据传递给子组件
  2. 在父组件中调用子组件实例对象上暴露的方法,在子组件中发送请求获取并保存数据

父组件:

vue
<template>
// 1.通过ref获取子组件实例对象
<SpuForm
  v-show="scene === 1"
  ref="SpuFormRef"
/>
</template>
<script>
// 2.获取子组件SpuForm实例对象
let SpuFormRef = ref()
// 3.调用子组件实例的方法 让子组件发请求获取数据
SpuFormRef.value.initHasSpuData()
</script>

子组件:

vue
<script>
// 初始化子组件获取数据
const initHasSpuData = async () => {}
// 将子组件内部的方法对外暴露
defineExpose({ initHasSpuData })
</script>

注意:要想在父组件中获取子组件实例对象,需要子组件先挂载

  1. 使用 v-show展示子组件(子组件随父组件一起挂载只不过被隐藏了),
  2. nextTick中获取子组件实例对象 (等子组件渲染完毕)
  3. 使用可选链式调用(即使子组件不存在也不会报错)
8.moment插件格式化时间

也可以使用 day.js

使用:

  1. 安装:pnpm i moment
  2. 使用:moment().format('YYYY-MM-DD HH:mm:ss')
js
// 当组件挂载时开启定时器更新时间
onMounted(() => {
  timer.value = setInterval(() => {
    time.value = moment().format('YYYY-MM-DD HH:mm:ss')
  }, 1000)
})
// 当前组件销毁前关闭定时器
onBeforeUnmount(() => {
  // 清除定时器
  clearInterval(timer.value)
})
9.git仓库设置大小写敏感

默认情况当修改一个文件名为大写的时候,git不能感知到,无法将本地的文件名的更新同步到远程仓库就会导致出现问题

通过设置:

git config core.ignorecase false

将repo设置为大小写敏感,当更改了文件名的大小写时 git 就能感知到了

10.弹性盒子布局

纵向排列且设置纵向占比

scss
.center {
    // 开启弹性盒垂直排列 子组件3:1
    display: flex;
    flex-direction: column; // 子盒子纵向排布
        .map{
          flex: 3; // 扩展比/占比
        }
        .line{
          flex: 1;
        }
}

justify-content

align-items

flex

十六、打包上线

本项目采用的是 后端接口部署在一台服务器,前端页面单独部署到一台服务器,也可以将前端页面和后端服务部署在同一台服务器

1.修改生产环境并打包

1.1 修改生产环境

.env.production(生产环境,打包后使用的环境)

php
NODE_ENV = 'production'
VITE_APP_TITLE = '硅谷甄选运营平台'
# VITE_APP_BASE_API = '/api'
# 将原本代理服务器的前缀改为项目上线的API地址
VITE_APP_BASE_API = 'http://sph-api.atguigu.cn'
VITE_SERVE="http://sph-api.atguigu.cn"

在生产环境中使用代理服务器时,axios请求的URL为 /api/xxxxxx,请求会发向本地服务器即http://localhost:5173/api/xxxxxx,代理服务器会将该请求转发到指定的服务器 http://sph-api.atguigu.cn 返回数据完成代理跨域

但项目打包后就脱离了脚手架,就没有了代理服务器,无法转发请求到【提供数据】的服务器,因此直接让请求发往目标服务器即可(前提是后端接口允许跨域)

1.2 打包

pnpm run build

2.本地服务器部署

2.1 使用NodeJS搭建服务器

使用 NodeJS搭建一台服务器将打包后项目的 dist 文件夹内的文件放在 public 文件夹下

启动服务器

部署完毕后

ipconfig 查看 ip 在局域网内可以访问本主机 ip:端口号 即可访问项目

2.2 解决页面刷新404问题

页面刷新时浏览器会把地址栏的前端路由作为后端路由发请求,但实际无此后端路由所以出现404页面

解决方法:

方法一:将路由模式由 history改为 hash模式,地址栏路径中会出现一个 # #后面的路径不会带给服务器(不建议)

方法二:让服务器在收到未配置的GET路由时,都返回index.html即可。

其实是把 url 中的 path,交给了前端路由去处理,具体配置如下:

​ 在 server.js中添加一条路由规则

js
// 当后端路由规则匹配不上时渲染前端页面内部js工作,此时前端路由就可以工作了
app.get('*',(req,res)=>{
	res.sendFile(__dirname + 'public/index.html')
})

浏览器地址栏发送的请求为 get 请求

方法三:使用 connect-history-api-fallback 插件

  1. 安装 npm install --save connect-history-api-fallback
  2. 引入:const history = require('connect-history-api-fallback');
  3. 使用:app.use(history());
js
const history = require('connect-history-api-fallback');

app.use(history());
// 配置静态资源
app.use(express.static(__dirname + '/public'))
2.3 解决请求无法发送问题(接口存在跨域问题)

问题分析:脱离脚手架后,就没有了代理服务器,无法转发请求到【提供数据】的服务器。

若在项目的.env.production(生产环境)中将请求的基础路径修改为服务器地址,则不会出现此问题(允许跨域)

若未进行修改可以在 Node 服务器中借助http-proxy-middleware中间件配置代理,具体配置如下:

  1. 安装:npm i http-proxy-middleware

  2. 引入:const { createProxyMiddleware } = require('http-proxy-middleware');

  3. 使用:

    js
    app.use(
      '/api', // 请求前缀
      createProxyMiddleware({
        target: 'http://sph-api.atguigu.cn', // 目标服务器
        changeOrigin: true,
    		pathRewrite: {
    			'^/api': '' // 路径重写
    		}
      }),
    );

相当于在 NodeJS中使用代理服务器

3. nginx 服务器部署

3.1 nginx 简介:

Nginx(发音为“engine-x”)是一款高性能的 HTTP 服务器和反向代理服务器,同时也是一个 IMAP/POP3/SMTP 代理服务器。Nginx 最初由 Igor Sysoev 编写,于 2004 年发布。它以其高性能、高稳定性、丰富的功能集和低系统资源消耗而闻名,主要功能有:

  1. 反向代理
  2. 负载均衡
  3. 静态内容服务
  4. HTTP/2 支持
  5. SSL/TLS 支持
  6. 高速缓存
3.2 nginx 部署前端项目

整体思路:让nginx充当两个角色,既是 静态内容服务器,又是代理服务器

  1. 修改nginx配置 conf/nginx.conf 如下,注意nginx的根目录最好不是 C 盘
js
server {
        listen       8099; // 项目运行的端口
        server_name  localhost;
        location / {
            root   E:\dist; // 配置nginx根目录,项目的dist文件夹 ;不可少
            index  index.html index.htm;
        }
  1. 解决刷新404问题
php
location / {
  root   D:\dist;
  index  index.html index.htm;
  try_files $uri $uri/ /index.html; # 解决刷新404
}
  1. 配置代理转发请求
php
location /dev/ {
  # 设置代理目标
  proxy_pass http://sph-h5-api.atguigu.cn/;
}

注意:当修改 nginx配置后需要重启 nginx服务 ,在进程管理器中结束 nginx进程并重新运行 nginx

4. 云服务器部署

  1. 购买云服务器

    腾讯云、阿里云、百度云

  2. 购买完成后记得重置密码

  3. linux 远程操作软件:Xshell、Xftp、electerm

  4. 使用 electerm 将打包后的dist文件传到服务器上或使用Xftp

    dist 目录放在 /root/www 文件夹下

  5. 使用 nginx 代理服务

    安装:yum install nginx

    配置: cd nginxvim nginx.conf

    ​ 添加以下配置:

    user root;
    .....
    location / {
    	root /root/www/dist;
    	index index.html index.html; 
    	try_files $uri $uri/ /index.html; # 解决刷新404
        }
    # 配置代理
       location /dev/ {
       # 设置代理目标
       proxy_pass http://sph-h5-api.atguigu.cn/;
    }

    ​ esc :wq

    重启nginx服务:systemctl restart nginx.service

  6. 访问云服务器IP地址即可访问