Vue.js 3 + Vite + TypeScript 實戰項目開發


一、使用 Vite 創建項目

參考 Vite 官方指南

npm init vite@latest

√ Project name: ... lagou-shop-admin
√ Select a framework: » vue
√ Select a variant: » vue-ts

Scaffolding project in C:\Users\lpz\Projects\lagou-shop-admin...

Done. Now run:

  cd lagou-shop-admin
  npm install
  npm run dev

初始目錄結構說明

.
├── public
│   └── favicon.ico
├── src
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── App.vue
│   ├── main.ts
│   ├── shims-vue.d.ts
│   └── vite-env.d.ts
├── .gitignore
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── tsconfig.json
└── vite.config.ts

在安裝了 Vite 的項目中,可以在 npm scripts 中使用 vite 可執行文件,或者直接使用 npx vite 運行它。下面是通過腳手架創建的 Vite 項目中默認的 npm scripts:

{
  "scripts": {
    "dev": "vite", // 啟動開發服務器
    "build": "vite build", // 為生產環境構建產物
    "serve": "vite preview" // 本地預覽生產構建產物
  }
}

可以指定額外的命令行選項,如 --port 或 --https。運行 npx vite --help 獲得完整的命令行選項列表

二、代碼規范和 ESLint

基礎配置

1、安裝 ESLint 到項目中

npm install eslint --save-dev

2、初始化 ESLint 配置

npx eslint --init

? How would you like to use ESLint? ...
  To check syntax only
  To check syntax and find problems
> To check syntax, find problems, and enforce code style

? What type of modules does your project use? ...
> JavaScript modules (import/export)
  CommonJS (require/exports)
  None of these
  
 ? Which framework does your project use? ...
  React
> Vue.js
  None of these
  
? Does your project use TypeScript? » No / Yes
  
? Where does your code run? ...  (Press <space> to select, <a> to toggle all, <i> to invert selection)
√ Browser
√ Node

? How would you like to define a style for your project? ...
> Use a popular style guide
  Answer questions about your style
  Inspect your JavaScript file(s)
  
? Which style guide do you want to follow? ...
  Airbnb: https://github.com/airbnb/javascript
> Standard: https://github.com/standard/standard
  Google: https://github.com/google/eslint-config-google
  XO: https://github.com/xojs/eslint-config-xo
  
 ? What format do you want your config file to be in? ...
> JavaScript
  YAML
  JSON
  
 Checking peerDependencies of eslint-config-standard@latest
The config that you've selected requires the following dependencies:

eslint-plugin-vue@latest @typescript-eslint/eslint-plugin@latest eslint-config-standard@latest eslint@^7.12.1 eslint-plugin-import@^2.22.1 eslint-plugin-node@^11.1.0 eslint-plugin-promise@^4.2.1 || ^5.0.0 @typescript-eslint/parser@latest
? Would you like to install them now with npm?

+ eslint-plugin-import@2.23.4
+ eslint-plugin-node@11.1.0
+ eslint-config-standard@16.0.3
+ eslint-plugin-vue@7.11.1
+ eslint@7.29.0
+ @typescript-eslint/parser@4.27.0
+ @typescript-eslint/eslint-plugin@4.27.0
+ eslint-plugin-promise@5.1.0

3、ESLint 配置文件

在這里插入圖片描述

這里改成 vue3-strongly-recommended

// .eslintrc.js
module.exports = {
  env: {
    browser: true,
    es2021: true
  },
  extends: [
    // 'plugin:vue/essential',
    
    // 使用 Vue 3 規則
    // https://eslint.vuejs.org/user-guide/#bundle-configurations
    'plugin:vue/vue3-strongly-recommended',
    'standard'
  ],
  parserOptions: {
    ecmaVersion: 12,
    parser: '@typescript-eslint/parser',
    sourceType: 'module'
  },
  plugins: [
    'vue',
    '@typescript-eslint'
  ],
  rules: {}
}

4、在 npm scripts 中添加驗證腳本

"scripts": {
	...
  "lint": "eslint src/**/*.{js,jsx,vue,ts,tsx} --fix",
}

注意:eslint 后面的路徑最好加上引號,否則在類 Unix 系統(比如 macOS)中會報錯說找不到資源。

vue-eslint-plugin

https://eslint.vuejs.org/

編譯器宏和 defineProps、defineEmits、no-undef 規則警告

您需要定義全局變量 (打開新窗口)在您的 ESLint 配置文件中。
如果您不想定義全局變量,請使用 import { defineProps, defineEmits } from 'vue'

示例 .eslintrc.js:

module.exports = {
  globals: {
    defineProps: "readonly",
    defineEmits: "readonly",
    defineExpose: "readonly",
    withDefaults: "readonly"
  }
}

另請參閱 ESLint - 指定全局變量 > 使用配置文件。

三、編輯器集成
● 禁用 Vetur
● 安裝 eslint 插件
● 安裝 volar 插件

使用dbaeumer.vscode-eslint (打開新窗口)微軟官方提供的擴展。

您必須配置eslint.validate擴展的選項來檢查.vue文件,因為擴展默認只針對*.js或*.jsx文件。

示例**.vscode/settings.json:**

{
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "vue"
  ]
}

如果您使用該 Vetur 插件,請設置 “vetur.validation.template”: false 為避免默認 Vetur 模板驗證。查看vetur 文檔 (打開新窗口)了解更多信息

1、在 vscode 中使用 ESLint 規則格式化代碼
1)安裝 vscode 擴展 ESLint

在這里插入圖片描述
2)在 vscode 配置文件中找到 ESLint 啟用該選項

在這里插入圖片描述
3)重啟 vscode

4)打開帶有 ESLint 配置文件的項目中任意的 .js 或是 .vue 文件

在這里插入圖片描述
右鍵選擇 文檔格式設置方式

在這里插入圖片描述
選擇 配置默認格式化程序

在這里插入圖片描述
選擇 ESLint

6)如果你喜歡保存文件的時候自動格式化代碼,也可以開啟這個功能

在這里插入圖片描述

7) 如果你修改了項目中 ESLint 的校驗規則,一定要重啟 vscode 才能生效。

四、配置 git commit hook
● https://github.com/okonet/lint-staged

安裝:

npx mrm@2 lint-staged
// package.json
{
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "serve": "vite preview",
    "tsc": "vue-tsc --noEmit",
    "lint": "eslint ./src/**/*.ts ./src/**/*.vue --cache --fix",
    "prepare": "husky install"
  },
  "dependencies": {
    "@form-create/element-ui": "^2.5.7",
    "axios": "^0.21.1",
    "element-plus": "^1.0.2-beta.48",
    "nprogress": "^0.2.0",
    "path-to-regexp": "^6.2.0",
    "utility-types": "^3.10.0",
    "vue": "^3.1.1",
    "vue-router": "^4.0.8",
    "vuex": "^4.0.1",
    "vxe-table": "^4.0.22",
    "xe-utils": "^3.3.0"
  },
  "devDependencies": {
    "@types/node": "^15.12.2",
    "@types/nprogress": "^0.2.0",
    "@typescript-eslint/eslint-plugin": "^4.27.0",
    "@typescript-eslint/parser": "^4.27.0",
    "@vitejs/plugin-vue": "^1.2.3",
    "@vue/compiler-sfc": "^3.1.1",
    "eslint": "^7.29.0",
    "eslint-config-standard": "^16.0.3",
    "eslint-plugin-import": "^2.23.4",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-promise": "^5.1.0",
    "eslint-plugin-vue": "^7.11.1",
    "husky": "^6.0.0",
    "lint-staged": "^11.0.0",
    "sass": "^1.34.1",
    "typescript": "^4.1.3",
    "vite": "^2.3.5",
    "vue-tsc": "^0.0.24"
  },
  "lint-staged": {
    "*.{js,jsx,vue,ts,tsx}": [
      "npm run lint",
      // "git add" 之前的版本需要手動把 lint 過程中修改的代碼手動 add,新版本不需要了
    ]
  }
}

1、在開發和構建中進行代碼規范校驗
● https://github.com/vitejs/awesome-vite#plugins
● https://github.com/gxmari007/vite-plugin-eslint

npm install vite-plugin-eslint --save-dev
vite.config.ts里面做下配置
在這里插入圖片描述

效果:

在這里插入圖片描述

五、Git commit 提交規范
● Commit message 和 Change log 編寫指南
● Git 使用規范流程
● Git 工作流程

統一團隊 Git commit 日志標准,便於后續代碼 review,版本發布以及日志自動化生成等等。

● commitlint:驗證 git commit 日志是否符合規范
● Commitizen:輔助編寫符合 git commit 規范的工具

六、Vite中得TS環境說明
● TS 環境說明
● shimes-vue.d.ts 文件的作用
● vite-env.d.ts 文件的作用
● vue-tsc 和 tsc
○ tsc 只能驗證 ts 代碼類型
○ vue-tsc 可以驗證 ts + Vue Template 中的類型(基於 Volar)

建議在 package.json 中新增一個 scripts 腳本用來單獨執行 TS 類型驗證:

"scripts": {
  ...
  "build": "npm run tsc && vite build",
  "tsc": "vue-tsc -noEmit"
},

-noEmit 表示只驗證類型,不輸出編譯結果。

跳過第三方包類型檢查

{
  "compilerOptions": {
    ...
    "baseUrl": "./",
    "skipLibCheck": true
  }
}

1、Vue 3 中的 TS 支持
建議參考:

● https://v3.cn.vuejs.org/guide/typescript-support.html

Vue 3 中的

Vue 3 支持三種寫法:

● Option API
● Composition API
● <script setup>(Composition API 的語法糖)

渲染函數和 JSX/TSX

● 什么是渲染函數:渲染函數
● 在渲染函數中使用 JSX:在渲染函數中使用 JSX
● 在 Vite 中提供 jsx/tsx 支持:@vitejs/plugin-vue-jsx
● Vue 中的 JSX 語法:Babel Plugin JSX for Vue 3.0

提示:
● 編輯器中的 ESLint 需要配置 “eslint.validate”: [“typescriptreact”] 才能驗證和格式化 .tsx 文件。

全局api eslint不識別 需要配置下
在這里插入圖片描述

七、初始化 Vue Router

1、安裝 vue-router

npm install vue-router@4

2、初始化路由實例

// src\router\index.ts
import { createRouter, RouteRecordRaw, createWebHashHistory } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('../views/home/index.vue')
  },
  {
    path: '/login',
    component: () => import('../views/login/index.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
// src\main.ts
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'

createApp(App).use(router).mount('#app')

404 未找到

{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { requiresAuth: false } },

注意:由於路由匹配是從前往后的,所有 404 路由記錄一定要放到最后。

八、初始化 Vuex

1、安裝

npm install vuex@next --save

2、配置

// src\store\index.ts
import { createStore } from 'vuex'

const store = createStore({
  state: {},
  getters: {},
  mutations: {},
  actions: {},
  modules: {}
})

export default store
// src\main.ts
import { createApp } from 'vue'
import router from './router'
import store from './store'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/lib/theme-chalk/index.css'

createApp(App).use(store).use(router).use(ElementPlus).mount('#app')

●官方文檔方案(僅支持 state): https://next.vuex.vuejs.org/zh/guide/typescript-support.html
●第三方方案(僅供參考): https://dev.to/3vilarthas/vuex-typescript-m4j

Vuex 4 版本依然沒有很好的解決 TS 類型問題,官方宣稱會在 Vuex 5 中提供更好的方案。

九、配置模塊路徑別名
在 Vite 中支持模塊路徑別名自定義,參考文檔。
npm i -D @types/node

示例如下:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 注意:在 ts 模塊中加載 node 核心模塊需要安裝 node 的類型補充模塊:npm i -D @types/node
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  ...
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

如果項目中使用了 TS,則還需要告訴 TS 別名的路徑,否則 TS 會報錯。

// tsconfig.json
{
  "compilerOptions": {
    ...
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  ...
}

使用示例:

// js
import xxx from '@/api/user.ts'

// html
<img src="@/assets/logo.png">

// css
@import url("@/styles/index.scss");
background: url("@/assets/logo.png");

還有一些插件可以快速配置路徑別名:

● vite-aliases:基於項目結構自動生成別名路徑

十、CSS 樣式管理
Vite 中的樣式支持

Vite 中對 CSS 的支持:

● https://cn.vitejs.dev/guide/features.html#css

(1)由於 Vite 的目標僅為現代瀏覽器,因此建議使用原生 CSS 變量和實現 CSSWG 草案的 PostCSS 插件(例如 postcss-nesting)來編寫簡單的、符合未來標准的 CSS。

(2)但 Vite 也同時提供了對 .scss, .sass, .less, .styl 和 .stylus 文件的內置支持。沒有必要為它們安裝特定的 Vite 插件,但必須安裝相應的預處理器依賴:

# .scss and .sass
npm install -D sass

# .less
npm install -D less

# .styl and .stylus
npm install -D stylus

如果是用的是單文件組件,可以通過 <style lang=“sass”>(或其他預處理器)自動開啟。

注意事項:

● Vite 為 Sass 和 Less 改進了 @import 解析,以保證 Vite 別名也能被使用。
● 另外,url() 中的相對路徑引用的,與根文件不同目錄中的 Sass/Less 文件會自動變基以保證正確性。
● 由於 Stylus API 限制,@import 別名和 URL 變基不支持 Stylus。
● 你還可以通過在文件擴展名前加上 .module 來結合使用 CSS modules 和預處理器,例如 style.module.scss。

樣式作用域
● 深度作用操作符新語法::deep()

樣式目錄結構

variables.scss  # 全局 Sass 變量
mixin.scss      # 全局 mixin
common.scss     # 全局公共樣式
transition.scss # 全局過渡動畫樣式
index.scss      # 組織統一導出

常見的工作流程是,全局樣式都寫在 src/styles 目錄下,每個頁面自己對應的樣式都寫在自己的 .vue 文件之中。

// index.scss
@import './variables.scss';
@import './mixin.scss';
@import './transition.scss';
@import './common.scss';

然后在 main.ts 中導入 index.scss:

import "./styles/index.scss"

這里僅僅是加載了全局樣式,並不能實現在組件內直接使用全局變量。

配置使用全局樣式變量
為了能夠在組件內直接使用全局變量、mixin 等,需要特殊配置。
具體配置參見 Vite 官方文檔:css.preprocessorOptions

這是一個常見的配置參考示例。

scss: {
      additionalData: `@import "~@/variables.scss";`
    },

看下面

css: {
  loaderOptions: {
    // 給 sass-loader 傳遞選項
    sass: {
      // @/ 是 src/ 的別名
      // 所以這里假設你有 `src/variables.sass` 這個文件
      // 注意:在 sass-loader v8 中,這個選項名是 "prependData"
      additionalData: `@import "@/styles/variables.scss"`
    },
    // 默認情況下 `sass` 選項會同時對 `sass` 和 `scss` 語法同時生效
    // 因為 `scss` 語法在內部也是由 sass-loader 處理的
    // 但是在配置 `prependData` 選項的時候
    // `scss` 語法會要求語句結尾必須有分號,`sass` 則要求必須沒有分號
    // 在這種情況下,我們可以使用 `scss` 選項,對 `scss` 語法進行單獨配置
    scss: {
      additionalData: `@import "~@/variables.scss";`
    },
    // 給 less-loader 傳遞 Less.js 相關選項
    less: {
      // http://lesscss.org/usage/#less-options-strict-units `Global Variables`
      // `primary` is global variables fields name
      globalVars: {
        primary: '#fff'
      }
    }
  }
}

十一、基於axios封裝請求模塊

● 基於 axios 的二次封裝
● 關於接口的類型問題
● 多環境 baseURL
● 跨域處理
● 數據 mock

基於 axios 封裝請求模塊

安裝 axios:

npm i axios

基本配置:

// src/utils/request.ts

import axios from 'axios'

const request = axios.create({
  baseURL: 'https://shop.fed.lagou.com/api/admin' // 基礎路徑
})

// 請求攔截器
request.interceptors.request.use(
  config => {
    // 統一設置用戶身份 Token
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 響應攔截器
request.interceptors.response.use(
  response => {
    // 統一處理響應錯誤,例如 token 無效、服務端異常等
    return response
  },
  err => {
    return Promise.reject(err)
  }
)

export default request

封裝 API 請求模塊:

/**
 * 公共基礎接口封裝
 */
import request from '@/utils/request'

export const getLoginInfo = () => {
  return request({
    method: 'GET',
    url: '/login/info'
  })
}

在組件中使用:

import { getLoginInfo } from '@/api/common'
import { onMounted } from '@vue/runtime-core'

onMounted(() => {
  getLoginInfo().then(res => {
    console.log(res)
  })
})

關於接口的類型問題
axios 的請求快捷方式都支持使用泛型參數指定響應數據類型。

interface User {
  name: string
  age: number
}

axios.get<User[]>('xxx')

封裝泛型請求方法:

// src/utils/request.ts

// 其它代碼...

export default <T = any>(config: AxiosRequestConfig) => {
  return request(config).then(res => {
    return (res.data.data || res.data) as T
  })
}

封裝請求方法:

// src\api\common.ts
import request from '@/utils/request'
import { ILoginInfo } from './types/common'

export const getLoginInfo = () => {
  return request<ILoginInfo>({
    method: 'GET',
    url: '/login/info'
  })
}
// src\api\types\common.ts
export interface ILoginInfo {
  logo_square: string
  logo_rectangle: string
  login_logo: string
  slide: string[]
}

在組件中調用:

import { getLoginInfo } from '@/api/common'

getLoginInfo().then(data => { // 這里的 data 就有類型了
  console.log(data)
})
十二、環境變量和模式
● Vite - 環境變量和模式
# .env.development
# 開發模式下加載的環境變量
VITE_API_BASEURL=http://a.com
# .env.production

# 生產模式下加載的環境變量
VITE_API_BASEURL=http://b.com
// src\utils\request.ts

const request = axios.create({
  // localhost:8080/xxx
  // abc.com/xxx
  // test.com/xxx
  baseURL: import.meta.env.VITE_API_BASEURL
})

十三、跨域問題
推薦方案:

開發環境    生產環境
在服務端配置 CORS。    在服務端配置 CORS。
配置開發服務器代理,比如 vite-server.proxy。    配置生產服務器代理,比如 nginx。
1、CORS
CORS 全稱為 Cross Origin Resource Sharing(跨域資源共享)。這種方案對於前端來說沒有什么工作量,和正常發送請求寫法上沒有任何區別,工作量基本都在后端(其實也沒啥工作量,就是配置一些 HTTP 協議)。

● 跨源資源共享(CORS)
● 跨域資源共享 CORS 詳解

2、服務器代理
可能有些后端開發人員覺得配置 CORS 麻煩不想搞,那純前端也是有解決方案的。

在開發模式下可以下使用開發服務器的 proxy 功能,比如 vite - server.proxy

export default defineConfig({
  server: {
    proxy: {
      // 字符串簡寫寫法
      '/foo': 'http://localhost:4567',
      // 選項寫法
      '/api': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      },
      // 正則表達式寫法
      '^/fallback/.*': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/fallback/, '')
      },
      // 使用 proxy 實例
      '/api': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        configure: (proxy, options) => {
          // proxy 是 'http-proxy' 的實例
        }
      }
    }
  }
})

但這種方法在生產環境是不能使用的。在生產環境中需要配置生產服務器(比如 nginx、Apache 等)進行反向代理。在本地服務和生產服務配置代理的原理都是一樣的,通過搭建一個中轉服務器來轉發請求規避跨域的問題。

在這里插入圖片描述

十四、Layout 布局

Container 布局容器

<template>
  <el-container>
    <el-aside width="200px">
      <AppMenu />
    </el-aside>
    <el-container>
      <el-header>
        <AppHeader />
      </el-header>
      <el-main>
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script lang="ts" setup>
import AppMenu from './AppMenu/index.vue'
import AppHeader from './AppHeader/index.vue'

</script>

<style lang="scss" scoped>
.el-container {
  height: 100vh;
}

.el-header {
  background-color: #fff;
  color: #333;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.el-aside {
  background-color: #304156;
  color: #333;
}

.el-main {
  background-color: #E9EEF3;
  color: #333;
}

</style>

菜單欄

<template>
  <el-menu
    :unique-opened="true"
    default-active="2"
    class="el-menu-vertical-demo"
    background-color="#304156"
    text-color="#bcc0c5"
    active-text-color="#2d8cf0"
    router
  >
    <el-menu-item index="/">
      <i class="el-icon-menu" />
      <template #title>
        首頁
      </template>
    </el-menu-item>
    <el-submenu index="1">
      <template #title>
        <i class="el-icon-location" />
        <span>商品</span>
      </template>
      <el-menu-item index="/product/product_list">
        <i class="el-icon-menu" />
        <template #title>
          商品列表
        </template>
      </el-menu-item>
      <el-menu-item index="/product/product_attr">
        <i class="el-icon-menu" />
        <template #title>
          商品規格
        </template>
      </el-menu-item>
    </el-submenu>
    <el-menu-item index="2">
      <i class="el-icon-menu" />
      <template #title>
        導航二
      </template>
    </el-menu-item>
    <el-menu-item
      index="3"
      disabled
    >
      <i class="el-icon-document" />
      <template #title>
        導航三
      </template>
    </el-menu-item>
    <el-menu-item index="4">
      <i class="el-icon-setting" />
      <template #title>
        導航四
      </template>
    </el-menu-item>
    <el-submenu index="5">
      <template #title>
        <i class="el-icon-location" />
        <span>導航一</span>
      </template>
      <el-menu-item-group>
        <template #title>
          分組一
        </template>
        <el-menu-item index="5-1">
          選項1
        </el-menu-item>
        <el-menu-item index="5-2">
          選項2
        </el-menu-item>
      </el-menu-item-group>
      <el-menu-item-group title="分組2">
        <el-menu-item index="5-3">
          選項3
        </el-menu-item>
      </el-menu-item-group>
    </el-submenu>
  </el-menu>
</template>

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

<style lang="scss" scoped>
.el-menu {
  border-right: none;
}
</style>

頭部

<template>
  <el-space size="large">
    <ToggleSidebar />
    <Breadcrumb />
  </el-space>
  <el-space size="large">
    <MenuSearch />
    <FullScreen />
    <Notification />
    <UserInfo />
  </el-space>
</template>

<script lang="ts" setup>
import ToggleSidebar from './ToggleSidebar.vue'
import Breadcrumb from './Breadcrumb.vue'
import MenuSearch from './MenuSearch.vue'
import FullScreen from './FullScreen.vue'
import Notification from './Notification.vue'
import UserInfo from './UserInfo.vue'

</script>

<style lang="scss" scoped>
i {
  font-size: 19px;
  cursor: pointer;
}
</style>

ToggleSidebar

<template>
  <i
    :class="collapseIcon"
    @click="handleCollapse"
  />
</template>

<script lang="ts" setup>
import { useStore } from '@/store'
import { computed } from 'vue'

const store = useStore()

const collapseIcon = computed(() => {
  return !store.state.isCollapse ? 'el-icon-s-fold' : 'el-icon-s-unfold'
})

const handleCollapse = () => {
  store.commit('setIsCollapse', !store.state.isCollapse)
}
</script>

<style lang="scss" scoped></style>

Breadcrumb

<template>
  <el-breadcrumb separator-class="el-icon-arrow-right">
    <el-breadcrumb-item
      v-for="item in routes"
      :key="item.path"
    >
      {{ item.meta.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script lang="ts" setup>
import { computed } from '@vue/runtime-core'
import { useRouter } from 'vue-router'

const router = useRouter()

console.log(router.currentRoute.value.matched)

const routes = computed(() => {
  return router.currentRoute.value.matched.filter(item => item.meta.title)
})
</script>

<style lang="scss" scoped></style>

MenuSearch

<template>
  <el-input
    placeholder="請輸入內容"
    prefix-icon="el-icon-search"
    v-model="input2"
  />
</template>

<script lang="ts" setup>
const input2 = ''
</script>

<style lang="scss" scoped></style>

FullScreen

<template>
  <i class="el-icon-full-screen" />
</template>

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

<style lang="scss" scoped></style>

Notification

<template>
  <i class="el-icon-bell" />
</template>

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

<style lang="scss" scoped></style>

UserInfo

<template>
  <el-dropdown>
    <span class="el-dropdown-link">
      admin
      <i class="el-icon-arrow-down el-icon--right" />
    </span>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item>個人中心</el-dropdown-item>
        <el-dropdown-item>退出登錄</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

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

<style lang="scss" scoped></style>

十五、配置基礎路由頁面

import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import AppLayout from '@/layout/AppLayout.vue'
import productRoutes from './modules/product'
import orderRoutes from './modules/order'
import permissionRoutes from './modules/permission'
import mediaRoutes from './modules/media'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: AppLayout,
    children: [
      {
        path: '', // 默認子路由
        name: 'home',
        component: () => import('../views/home/index.vue')
      },
      productRoutes,
      orderRoutes,
      permissionRoutes,
      mediaRoutes
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('../views/login/index.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(), // 路由模式
  routes // 路由規則
})

export default router
// src\router\modules\order.ts

import { RouteRecordRaw, RouterView } from 'vue-router'

const routes: RouteRecordRaw = {
  path: '/order',
  name: 'order',
  component: RouterView,
  children: [
    {
      path: 'list',
      name: 'order_list',
      component: () => import('@/views/order/list/index.vue')
    },
    {
      path: 'offline',
      name: 'order-offline',
      component: () => import('@/views/order/offline/index.vue')
    }
  ]
}

export default routes

十六、頁面加載進度條

知識點:

● 路由攔截器
● 加載進度條

安裝 nprogress

npm i nprogress

# 如果是 TS 需要補充安裝它的類型補充包
npm i -D @types/nprogress

配置

// src\router\index.ts
import nprogress from 'nprogress'
import 'nprogress/nprogress.css'

// 進度條的配置
nprogress.configure({})

// VueRouter 4 中可以不寫 next 了,默認就是通過狀態
router.beforeEach((to, from) => {
  nprogress.start()
})

router.afterEach(() => {
  nprogress.done()
})

十七、頁面標題處理

● https://github.com/nuxt/vue-meta
● https://github.com/nuxt/vue-meta/tree/next

npm install vue-meta@next --save

十八、面包屑導航

<template>
  <el-breadcrumb separator-class="el-icon-arrow-right">
    <el-breadcrumb-item
      v-for="item in routes"
      :key="item.path"
    >
      {{ item.meta.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script lang="ts" setup>
import { computed } from '@vue/runtime-core'
import { useRouter } from 'vue-router'

const router = useRouter()

const routes = computed(() => {
  return router.currentRoute.value.matched.filter(item => item.meta.title)
})
</script>

<style lang="scss" scoped></style>

十九、全屏切換

<template>
  <i
    class="el-icon-full-screen"
    @click="toggleFullScreen"
  />
</template>

<script lang="ts" setup>
const toggleFullScreen = () => {
  if (!document.fullscreenElement) {
    document.documentElement.requestFullscreen()
  } else {
    if (document.exitFullscreen) {
      document.exitFullscreen()
    }
  }
}
</script>

<style lang="scss" scoped></style>

二十、側邊欄展開/收起

<template>
  <i
    :class="collapseIcon"
    @click="handleCollapse"
  />
</template>

<script lang="ts" setup>
import { useStore } from '@/store'
import { computed } from 'vue'

const store = useStore()

const collapseIcon = computed(() => {
  return !store.state.isCollapse ? 'el-icon-s-fold' : 'el-icon-s-unfold'
})

const handleCollapse = () => {
  store.commit('setIsCollapse', !store.state.isCollapse)
}
</script>

<style lang="scss" scoped></style>

二十一、用戶登錄和身份認證

1、登錄頁面布局

<template>
  <div class="login-container">
    <el-form
      class="login-form"
      :rules="rules"
      ref="form"
      :model="user"
      size="medium"
      @submit.prevent="handleSubmit"
    >
      <div class="login-form__header">
        <img
          class="login-logo"
          src="@/assets/login_logo.png"
          alt="拉勾心選"
        >
      </div>
      <el-form-item prop="account">
        <el-input
          v-model="user.account"
          placeholder="請輸入用戶名"
        >
          <template #prefix>
            <i class="el-input__icon el-icon-user" />
          </template>
        </el-input>
      </el-form-item>
      <el-form-item prop="pwd">
        <el-input
          v-model="user.pwd"
          type="password"
          placeholder="請輸入密碼"
        >
          <template #prefix>
            <i class="el-input__icon el-icon-lock" />
          </template>
        </el-input>
      </el-form-item>
      <el-form-item prop="imgcode">
        <div class="imgcode-wrap">
          <el-input
            v-model="user.imgcode"
            placeholder="請輸入驗證碼"
          >
            <template #prefix>
              <i class="el-input__icon el-icon-key" />
            </template>
          </el-input>
          <img
            class="imgcode"
            alt="驗證碼"
            src="https://shop.fed.lagou.com/api/admin/captcha_pro"
          >
        </div>
      </el-form-item>
      <el-form-item>
        <el-button
          class="submit-button"
          type="primary"
          :loading="loading"
          native-type="submit"
        >
          登錄
        </el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

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

const user = reactive({
  account: 'admin',
  pwd: '123456',
  imgcode: ''
})
const loading = ref(false)
const rules = ref({
  account: [
    { required: true, message: '請輸入賬號', trigger: 'change' }
  ],
  pwd: [
    { required: true, message: '請輸入密碼', trigger: 'change' }
  ],
  imgcode: [
    { required: true, message: '請輸入驗證碼', trigger: 'change' }
  ]
})

const handleSubmit = async () => {
  console.log('handleSubmit')
}

</script>

<style lang="scss" scoped>
.login-container {
  min-width: 400px;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #2d3a4b;
}

.login-form {
  padding: 30px;
  border-radius: 6px;
  background: #fff;
  min-width: 350px;
  .login-form__header {
    display: flex;
    justify-content: center;
    align-items: center;
    padding-bottom: 30px;
  }

  .el-form-item:last-child {
    margin-bottom: 0;
  }

  .login__form-title {
    display: flex;
    justify-content: center;
    color: #fff;
  }

  .submit-button {
    width: 100%;
  }

  .login-logo {
    width: 271px;
    height: 74px;
  }
  .imgcode-wrap {
    display: flex;
    align-items: center;
    .imgcode {
      height: 37px;
    }
  }
}
</style>

2、處理圖片驗證碼

const captchaSrc = ref('')

onMounted(() => {
  loadCaptcha()
})

const loadCaptcha = async () => {
  const data = await getCaptcha()
  captchaSrc.value = URL.createObjectURL(data)
}
export const getCaptcha = () => {
  return request<Blob>({
    method: 'GET',
    url: '/captcha_pro',
    params: {
      stamp: Date.now()
    },
    responseType: 'blob' // 請求獲取圖片數據
  })
}

3、處理登錄邏輯

const handleSubmit = async () => {
  // 表單驗證
  const valid = await form.value?.validate()
  if (!valid) {
    return false
  }

  // 驗證通過,展示 loading
  loading.value = true

  // 請求登錄
  const data = await login(user).finally(() => {
    loading.value = false
  })
  
  // 存儲登錄用戶信息
  store.commit('setUser', {
    ...data.user_info,
    token: data.token
  })
  
  // 跳轉回原來頁面
  let redirect = route.query.redirect || '/'
  if (typeof redirect !== 'string') {
    redirect = '/'
  }
  router.replace(redirect)  // 路由跳轉不想被記錄
}

4、統一處理接口請求失敗

request.interceptors.response.use(
  response => {
    const { status } = response.data

    // 請求成功
    if (!status || status === 200) {
      return response
    }
    
    // 處理 Token 過期

    // 其它錯誤給出提示即可,比如 400 參數錯誤之類的
    ElMessage({
      type: 'error',
      message: response.data.msg,
      duration: 5 * 1000
    })
    return Promise.reject(response)
  },
  err => {
    ElMessage({
      type: 'error',
      message: err.message,
      duration: 5 * 1000
    })
    return Promise.reject(err)
  }
)

5、封裝 element-plus 類型

// src\types\element-plus.ts

import { ElForm } from 'element-plus'
import { FormItemRule } from 'element-plus/packages/form/src/form.type'

export type IElForm = InstanceType<typeof ElForm>

export type IFormRule = Record<string, FormItemRule[]>
// src\utils\storage.ts
export const getItem = <T>(key: string) => {
  const data = window.localStorage.getItem(key)
  if (!data) return null
  try {
    return JSON.parse(data) as T
  } catch (err) {
    return null
  }
}

export const setItem = (key: string, value: object | string | null) => {
  if (typeof value === 'object') {
    value = JSON.stringify(value)
  }
  window.localStorage.setItem(key, value)
}

export const removeItem = (key: string) => {
  window.localStorage.removeItem(key)
}

6、統一設置用戶 Token

request.interceptors.request.use(
  config => {
    // 容錯:防止請求地址中有空格
    config.url = config.url?.trim()

    // 統一設置用戶 token
    const { user } = store.state
    if (user && user.token) {
      config.headers.Authorization = `Bearer ${user.token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

7、未登錄不允許訪問

router.beforeEach((to, from) => {
  nprogress.start() // 開始加載進度條
  if (to.meta.requiresAuth && !store.state.user) {
    // 此路由需要授權,請檢查是否已登錄
    // 如果沒有,則重定向到登錄頁面
    return {
      path: '/login',
      // 保存我們所在的位置,以便以后再來
      query: { redirect: to.fullPath }
    }
  }
})

8、統一處理 Token 失效

// 控制登錄過期的鎖
let isRefreshing = false
request.interceptors.response.use(
  response => {
    const { status } = response.data

    // 請求成功
    if (status === 200 || response.config.responseType === 'blob') {
      return response
    }

    // 登錄過期
    if (status === 410000) {
      if (isRefreshing) return Promise.reject(response)
      isRefreshing = true
      ElMessageBox.confirm('您的登錄已過期,您可以取消停留在此頁面,或確認重新登錄', '登錄過期', {
        confirmButtonText: '確認',
        cancelButtonText: '取消'
      }).then(
        () => {
          // 清除登錄狀態並跳轉到登錄頁
          store.commit('setUser', null)
          router.push({
            name: 'login',
            query: {
              redirect: router.currentRoute.value.fullPath
            }
          })
        }
      ).finally(() => {
        isRefreshing = false
      })

      return Promise.reject(response)
    }

    // 其它錯誤給出提示即可,比如 400 參數錯誤之類的
    ElMessage({
      type: 'error',
      message: response.data.msg,
      duration: 5 * 1000
    })
    return Promise.reject(response)
  },
  err => {
    ElMessage({
      type: 'error',
      message: err.message,
      duration: 5 * 1000
    })
    return Promise.reject(err)
  }
)

二十二、權限管理

1、管理員

<el-form
         ref="form"
         :model="formData"
         :rules="formRules"
         label-width="100px"
         v-loading="formLoading"
         >
  <el-form-item
                label="管理員賬號"
                prop="account"
                >
    <el-input
              v-model="formData.account"
              placeholder="請輸入管理員賬號"
              />
  </el-form-item>
  <el-form-item
                label="管理員密碼"
                prop="pwd"
                >
    <el-input
              v-model="formData.pwd"
              placeholder="請輸入管理員密碼"
              />
  </el-form-item>
  <el-form-item
                label="確認密碼"
                prop="conf_pwd"
                >
    <el-input
              v-model="formData.conf_pwd"
              placeholder="請輸入確認密碼"
              />
  </el-form-item>
  <el-form-item
                label="管理員姓名"
                prop="real_name"
                >
    <el-input
              v-model="formData.real_name"
              placeholder="請輸入管理員姓名"
              />
  </el-form-item>
  <el-form-item
                label="管理員身份"
                prop="roles"
                >
    <el-select
               v-model="formData.roles"
               multiple
               placeholder="請選擇管理員身份"
               >
      <el-option
                 v-for="item in []"
                 :key="item.value"
                 :label="item.label"
                 :value="item.value"
                 />
    </el-select>
  </el-form-item>
  <el-form-item label="狀態">
    <el-radio-group v-model="formData.status">
      <el-radio
                :label="1"
                >
        開啟
      </el-radio>
      <el-radio
                :label="0"
                >
        關閉
      </el-radio>
    </el-radio-group>
  </el-form-item>
</el-form>
<script lang="ts" setup>
import { ref } from 'vue'
import type { IElForm, IFormRule } from '@/types/element-plus'

const form = ref<IElForm | null>(null)
const formData = ref({
  account: '',
  pwd: '',
  conf_pwd: '',
  roles: [] as number[],
  status: 0 as 0 | 1,
  real_name: ''
})

const formRules: IFormRule = {
  account: [
    { required: true, message: '請輸入管理員賬號', trigger: 'blur' }
  ],
  pwd: [
    { required: true, message: '請輸入管理員密碼', trigger: 'blur' }
  ],
  conf_pwd: [
    { required: true, message: '請輸入確認密碼', trigger: 'blur' }
  ],
  roles: [
    { required: true, message: '請選擇管理員角色', trigger: 'blur' }
  ],
  real_name: [
    { required: true, message: '請輸入管理員姓名', trigger: 'blur' }
  ]
}

</script>

element 組件庫的表格樹有性能問題,這里推薦另一個第三方表格組件。
● https://github.com/x-extends/vxe-table

二十三、Excel 導出

npm install xlsx

import XLSX from 'xlsx'

性能優化 import異步加載,沒必要一開始就加載xlsx資源

const handleExportExcel = async () => {
  if (!selectionItems.value.length) {
    return ElMessage.warning('請選擇商品')
  }
  exportExcelLoading.value = true
  try {
    const { jsonToExcel } = await import('@/utils/export-to-excel')
    jsonToExcel({
      data: selectionItems.value,
      header: {
        id: '編號',
        store_name: '商品名稱',
        price: '價格'
      },
      fileName: '測試.xlsx',
      bookType: 'xlsx'
    })
  } catch (err) {
    console.error(err)
  }
  exportExcelLoading.value = false
}

二十四、富文本編輯器

<template>
  <div id="editor" />
</template>

<script lang="ts" setup>
import { onMounted, watch, ref } from '@vue/runtime-core'
import E from 'wangeditor'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

interface EmitsType {
  (e: 'update:model-value', value: string): void
}

const emit = defineEmits<EmitsType>()

const editor = ref<InstanceType<typeof E> | null>(null)

const unWatchModelValue = watch(() => props.modelValue, () => {
  // 操作 DOM 的方式修改內容
  editor.value?.txt.html(props.modelValue)
  unWatchModelValue() // 取消監視
})

onMounted(() => {
  initEditor()
})

const initEditor = () => {
  editor.value = new E('#editor')

  // 配置 onchange 回調函數
  editor.value.config.onchange = function (newHtml: string) {
    emit('update:model-value', newHtml)
  }

  editor.value.create()
  // editor.value.txt.html(props.modelValue) // 注意:必須在 create 之后
}
</script>

<style lang="scss" scoped></style>

二十五、拖拽

<style lang="scss" scoped>
:deep(.el-tag) {
  margin-right: 5px;
}
</style>
<template>
  <div ref="draggableContainer">
    <slot />
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref } from '@vue/runtime-core'
import type { PropType } from 'vue'
import Sortable from 'sortablejs'

const draggableContainer = ref<HTMLDivElement | null>(null)

const props = defineProps({
  modelValue: {
    type: Array as PropType<any[]>,
    default: () => []
  },
  // 參考:https://github.com/SortableJS/Sortable#options
  options: {
    type: Object as PropType<Sortable.Options>,
    default: () => {}
  }
})

interface EmitsType {
  (e: 'update:model-value', value: any[]): void
}

const emit = defineEmits<EmitsType>()

onMounted(() => {
  initDraggable()
})

const initDraggable = () => {
  if (!draggableContainer.value) {
    console.error('容器不能為空')
    return
  }
  const sortable = Sortable.create(draggableContainer.value, {
    animation: 300,
    onUpdate (e) {
      if (e.oldIndex !== undefined && e.newIndex !== undefined) {
        // 刪除拖拽的元素
        const list = props.modelValue.slice(0)
        const item = list.splice(e.oldIndex, 1)[0]
        // 把刪除的元素放到新的位置
        list.splice(e.newIndex, 0, item)
        emit('update:model-value', list)
        // console.log(e, props.modelValue)
      }
    },
    ...props.options
  })
  console.log(sortable)
}
</script>

<style lang="scss" scoped>
:deep(.el-tag) {
  margin-right: 5px;
}
</style>

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM