Vue3 项目初始化


共计 23833 个字符,预计需要花费 60 分钟才能阅读完成。

1. 初始化项目

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

另外包管理工具推荐 pnmp,其号称高性能的 npmpnmpnpm/yarn 衍生而来,解决了 npm/yarn 内部潜在的 bug,极大的优化了性能,扩展了使用场景。

pnpm 安装:

npm i -g pnpm

项目初始化:

pnpm create vite@latest

过程如下:

>pnpm create vite@latest  
✔ Project name: … web  
✔ Select a framework: › Vue  
✔ Select a variant: › TypeScript

此时可以执行如下命令安装依赖:

pnpm i

执行如下命令可以启动项目:

pnpm run dev

2. 项目配置

2.1 配置 eslint

ESLint 是一个用于识别和修复 JavaScript 代码问题的静态代码分析工具。参考了:ESLint 9.0版本踩坑记录

生成配置文件 eslint.config.js

npm init @eslint/config@latest

过程如下:

>npx eslint --init
You can also run this command directly using 'npm init @eslint/config@latest'.
Need to install the following packages:
@eslint/create-config@1.3.1
Ok to proceed? (y) y
@eslint/create-config: v1.3.1

✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · vue
✔ Does your project use TypeScript? · typescript
✔ Where does your code run? · browser
The config that you've selected requires the following dependencies:

eslint, globals, @eslint/js, typescript-eslint, eslint-plugin-vue
✔ Would you like to install them now? · No / Yes
✔ Which package manager do you want to use? · pnpm
☕️Installing...

如果上面选择立即安装,会安装 @eslint/jseslinteslint-plugin-vueglobalstypescript-eslint 几个库。

eslint.config.js 初始内容为:

import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";

export default [
  { 
    files: ["**/*.{js,mjs,cjs,ts,vue}"] 
  },
  { 
    languageOptions: { 
      globals: globals.browser 
    } 
  },
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs["flat/essential"],
  { 
    files: ["**/*.vue"], 
    languageOptions: { 
      parserOptions: { 
        parser: tseslint.parser 
      } 
    } 
  },
];

简单测试一下 ESLint 是否能够正常使用:

// eslint.config.js
export default [
  { 
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    rules: {
      semi: 2,  //`semi` 规则用于检查是否缺少分号
    },
  },
  //...
];

执行如下命令:

npx eslint src/main.ts

控制台提示缺少分号的错误,说明 rules 配置生效。

然后需要修改 eslint.config.js 配置文件:

import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';

export default [
  {
    ignores: [
      'node_modules',
      'dist',
      'public',
    ],
  },
  {
    files: ['**/*.{js,mjs,cjs,ts,vue}'],
  },
  {
    languageOptions: {
      globals: globals.browser,
    },
  },
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs['flat/essential'],
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: {
        parser: tseslint.parser,
        ecmaVersion: 'latest',
        /** 允许在 .vue 文件中使用 JSX */
        ecmaFeatures: {
          jsx: true,
        },
      },
    },
    rules: {
      // 在这里追加 Vue 规则
      'vue/no-mutating-props': [
        'error',
        {
          shallowOnly: true,
        },
      ],
      // 关闭组件命名规则
      "vue/multi-word-component-names": "off"
    },
  },
  /**
   * JavaScript 规则
   */
  {
    files: ['**/*.{js,mjs,cjs,vue}'],
    rules: {
      'no-console': 'warn',
    },
  },
  /**
   * TypeScript 规则
   */
  {
    files: ['**/*.{ts,tsx,vue}'],
    rules: {
      // 这里可以添加 TypeScript 相关的规则
    },
  },
];

package.json 新增两个运行脚本:

"scripts": {
    "lint": "eslint src",
    "fix": "eslint src --fix"
}

不确定是否需要安装 vite-plugin-eslint...

2.2 配置 prettier

上面配置的 eslint 主要检查语法问题以及少部分格式,而 prettier 则只规范格式,比如强制使用双引号,以及空格的数量,这样可以做到风格统一。

安装依赖包:

pnpm install -D prettier eslint-plugin-prettier

eslint 在 8.53.0 之后就不再支持格式化规则,因此 eslint-config-prettier 作为 eslintprettier 的规则冲突解决插件,已经没用了。

创建 prettier.config.js 配置文件:

// prettier.config.js
/**
 * @type {import('prettier').Config}
 * @see https://www.prettier.cn/docs/options.html
 */
export default {
    trailingComma: 'all',
    singleQuote: true,
    semi: false,
    printWidth: 80,
    arrowParens: 'always',
    proseWrap: 'always',
    endOfLine: 'auto',
    experimentalTernaries: false,
    tabWidth: 2,
    useTabs: false,
    quoteProps: 'consistent',
    jsxSingleQuote: false,
    bracketSpacing: true,
    bracketSameLine: false,
    jsxBracketSameLine: false,
    vueIndentScriptAndStyle: false,
    singleAttributePerLine: false,
}

eslint.config.js 文件新增内容:

import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default [
  // ...
  /**
   * prettier 配置
   * 会合并根目录下的 prettier.config.js 文件
   * @see https://prettier.io/docs/en/options
   */
  eslintPluginPrettierRecommended,
];

2.3 配置 stylelint

暂略...

2.4 配置 husky

暂略...

2.5 强制使用 pnpm

可以暂略...

注意,目前似乎 npm 有 bug,导致 preinstall 脚本 在执行 npm install 后执行。

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

需要在项目根目录下创建 scritps/preinstall.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)
}

然后配置 package.json 文件:

"scripts": {
    "preinstall": "node ./scripts/preinstall.js"
}

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

3. 项目集成

3.1 集成 element-plus

当然了,这里根据自己需要进行 UI 组件库的集成。

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

安装:

pnpm install element-plus @element-plus/icons-vue

main.ts 进行全局安装 element-plus,并配置中文:

// ...
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'

const app = createApp(App)
app.use(ElementPlus, {
    locale: zhCn
})

app.mount('#app')

然后在 src/vite-env.d.ts 文件新增一行:

declare module 'element-plus/dist/locale/zh-cn.mjs';

element-plus 全局组件类型声明:

似乎可以忽略

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"]
  }
}

配置完毕可以测试 element-plus 组件与图标的使用.

3.2 其他配置

  • src 别名配置

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

配置 vite.config.ts

import path from 'path'

export default defineConfig({
    resolve: {
        alias: {
            "@": path.resolve("./src") // 相对路径别名配置,使用 @ 代替 src
        }
    }
})

TypeScript 编译配置,在 tsconfig.json 文件中新增内容:

{
  "compilerOptions": {
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { //路径映射,相对于 baseUrl
      "@/*": ["src/*"] 
    },
  },
  // 不加这个可能在 vue 中引入组件有红色波浪线
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
}
  • setup 插件

vite-plugin-vue-setup-extend 的作用是为了方便定义组件名。

安装:

pnpm i vite-plugin-vue-setup-extend -D

在 vite 配置文件中引入:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

export default defineConfig({
  plugins: [
    vue(),
    VueSetupExtend(),
  ],
})

比如原来需要两个 script 标签:

<script lang="ts">
export default {
    name: 'Login'
}
</script>

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

现在可以写出这样:

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

3.3 配置环境变量

项目开发过程中,至少会经历开发环境、测试环境和生产环境(即正式环境)三个阶段。不同阶段请求的状态(如接口地址等)不尽相同,若手动切换接口地址是相当繁琐且易出错的,于是环境变量配置的需求就应运而生。

项目根目录分别添加开发、生产和测试环境的文件:

#开发环境
.env.development
#生产环境
.env.production
#测试环境
.env.test

文件内容:

NODE_ENV = 'development'
VITE_APP_TITLE = 'APP'
VITE_APP_BASE_API = '/api'
NODE_ENV = 'production'
VITE_APP_TITLE = 'APP'
VITE_APP_BASE_API = '/api'
NODE_ENV = 'test'
VITE_APP_TITLE = 'APP'
VITE_APP_BASE_API = '/api'

变量必须以 VITE_ 为前缀才能暴露给外部读取,通过 import.meta.env 获取环境变量。

console.log(import.meta.env.VITE_APP_TITLE);

配置运行命令:

"scripts": {
    "dev": "vite --host 0.0.0.0 --open",
    "build:test": "vue-tsc && vite build --mode test",
    "build:pro": "vue-tsc && vite build --mode production"
},

3.4 svg 图标配置

svg 即矢量图标,文件比 img 要小的很多,且放大不会失真。

安装插件:

pnpm install vite-plugin-svg-icons -D

配置 vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

export default defineConfig({
  plugins: [
    vue(),
    createSvgIconsPlugin({
      // Specify the icon folder to be cached
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // Specify symbolId format
      symbolId: 'icon-[dir]-[name]',
    }),
  ],
  resolve: {
    alias: {
      '@': path.resolve('./src'), // 相对路径别名配置,使用 @ 代替 src
    },
  },
})

这里需要注意的是需要创建 src/assets/icons 文件夹,并将需要使用的 svg 文件放在该目录下。

main.ts 配置:

import 'virtual:svg-icons-register'

然后可以将 svg 图标放在该文件下下,使用方式如下:

<template>
    <!-- svg:图标外层容器节点,内部需要与 use 标签结合使用 -->
    <svg>
        <!-- 指定 href 属性,属性值务必 icon-图标名字 -->
        <!-- 会引用 icon 文件夹下的 vue.svg 图标 -->
        <use href="https://www.amjun.com/2665.html#icon-vue"></use>
    </svg>
</template>

因为项目很多模块需要使用图标,因此可以将其封装为全局组件,新建 src/components/SvgIcon/index.vue 文件:

<template>
  <div>
    <svg :style="{ width: width, height: height }">
      <use :href="https://www.amjun.com/prefix + name" :fill="color"></use>
    </svg>
  </div>
</template>

<script setup lang="ts">
defineProps({
  //xlink:href 属性值的前缀
  prefix: {
    type: String,
    default: '#icon-'
  },
  //svg 矢量图的名字
  name: String,
  //svg 图标的颜色
  color: {
    type: String,
    default: ""
  },
  //svg 宽度
  width: {
    type: String,
    default: '16px'
  },
  //svg 高度
  height: {
    type: String,
    default: '16px'
  }

})
</script>

<style scoped></style>

新建 src/components/index.ts 文件,用于注册 components 文件夹内部全部全局组件。

import SvgIcon from './SvgIcon/index.vue';
import type { App, Component } from 'vue';
const components: { [name: string]: Component } = { SvgIcon };
export default {
    install(app: App) {
        Object.keys(components).forEach((key: string) => {
            app.component(key, components[key]);
        })
    }
}

main.ts 安装自定义插件:

import gloablComponent from '@/components/index';
app.use(gloablComponent)

使用:

<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue'
</script>

<template>
  <svg-icon name="vue"></svg-icon>
</template>

3.5 集成 less

安装:

pnpm install less less-loader -D

在组件内可以使用 scss 语法,加上 lang="less" 即可。

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

新建 src/styles/reset.less 用于重置浏览器默认样式。

/* https://github.com/ixyzorg/ixyz-style-reset.git */
/* General reset */
* {
    margin: 0;
    background: none;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    vertical-align: baseline;
}

/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
    display: block;
}

body {
    /* line-height is set by :root */
    line-height: inherit;
}

ol,
ul {
    list-style: none;
}

blockquote,
q {
    quotes: none;
}

blockquote:before,
blockquote:after,
q:before,
q:after {
    content: '';
    content: none;
}

table {
    border-collapse: collapse;
    border-spacing: 0;
}

a {
    color: inherit;
    text-decoration: none;
}

s,
u {
    text-decoration: none;
}

select {
    appearance: none;
    -webkit-appearance: none;
}

input[type="submit"],
button {
    width: auto;
    overflow: visible;
    cursor: pointer;
    line-height: inherit;
    color: inherit;

    /* Corrects inability to style clickable `input` types in iOS */
    appearance: none;
}

button::-moz-focus-inner {
    /* Remove excess padding and border in Firefox 4+ */
    border: 0;
    padding: 0;
}

/* safari requires some special resets for input type="search" */
input[type="search"] {
    -webkit-appearance: textfield;
}

input[type="search"]::-webkit-search-decoration {
    -webkit-appearance: none;
}

/* ie 11 has it's own magic font-size rules for sub and sup */
@media all and (-ms-high-contrast: none),
(-ms-high-contrast: active) {

    sub,
    sup {
        font-size: 120%;
    }
}

/* some sensible global styles */
:root {
    /* prevents mobile browsers from sometimes scaling text */
    text-size-adjust: 100%;
    -webkit-text-size-adjust: 100%;
}

/* sets box-sizing back to the sane border-box for all elements */
*,
*::before,
*::after {
    box-sizing: border-box;
}

新建 src/styles/index.less 文件,项目中需要用到清除默认样式,因此需要引入 reset.less

@import './reset.less';

然后在 main.ts 引入 index.less

import '@/styles/index.less'

但是在 index.less 全局样式文件中没有办法使用变量,因此需要给项目中引入全局变量。

新建 src/styles/variable.less 文件:

@color: red;

然后配置 vite.config.ts 文件:

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

variable.less 后面的分号不能忽略,否则会报错。

vue 里面使用全局变量:

<style scoped lang="less">
h1 {
  color: @color;
}
</style>

不加 lang="less" 也会导致无法使用变量。

3.6 配置 mock

mock 可以模拟数据来代替真实数据,在后端接口没有开发完成的情况下非常有用。

安装依赖:

pnpm install -D vite-plugin-mock mockjs

vite.config.ts 配置文件启用插件。

import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
  plugins: [
    viteMockServe({
      enable: true,
    }),
  ],
})

这里也可以使用 export default ({ command }) => { return {...}} 方式

根目录下创建 mock 文件夹,再创建一个 user.ts 文件:

注意不是 src 目录下创建!!!否则请求报 404 错误

//用户信息数据
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, msg: '账号或者密码不正确' }
            }
            //如果有返回成功信息
            const { token } = checkUser
            return { code: 200, msg: '登录成功', 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, msg: '获取用户信息失败' }
            }
            //如果有返回成功信息
            return { code: 200, msg: '获取用户信息成功', data: { checkUser } }
        },
    },
]

3.7 配置 axios

axios 用于发送网络请求。

安装:

pnpm install axios

然后可以在 main.ts 编写测试代码:

// main.ts
import axios from 'axios';

axios({
  url: '/api/user/login',
  method: 'post',
  data: {
    username: 'admin',
    password: '111111'
  }
})
.then(response => {
  console.log(response.data);
})
.catch(error => {
  console.error('Error:', error);
});

项目上使用 axios 一般都会进行二次封装,目的是为了使用拦截器做一些特殊的处理。

  • 请求拦截器,可以在请求拦截器中处理一些业务(开始进度条、请求头携带公共参数)

  • 响应拦截器,可以在响应拦截器中处理一些业务(进度条结束、简化服务器返回的数据、处理http网络错误)

首先创建 src/utils 文件夹,再创建 request.ts 文件:

import axios from 'axios'
import { ElMessage } from 'element-plus'

//创建axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API,
  timeout: 5000,
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    // 可以在此处添加一些公共的请求头,比如token
    // const token = localStorage.getItem('token');
    // if (token) {
    //   config.headers.Authorization = `Bearer ${token}`;
    // }
    return config
  },
  (error) => {
    // 请求错误时的处理
    ElMessage({
      type: 'error',
      message: '请求发送失败,请重试',
    })
    return Promise.reject(error)
  },
)

// 响应拦截器
service.interceptors.response.use(
  (response) => {
    // 检查 response 是否存在,防止空响应引发的错误
    if (!response || !response.data) {
      ElMessage({
        type: 'error',
        message: '服务器返回异常',
      })
      return Promise.reject('服务器返回异常')
    }

    const res = response.data
    let msg = ''

    if (res.code === 200) {
      return res
    } else {
      switch (res.code) {
        // 这里是后端自定义的 code
        case 401:
          msg = '未授权,请登录'
          // 可以在这里触发登出逻辑,或跳转到登录页面
          break
        case 403:
          msg = '服务器拒绝访问'
          break
        case 500:
          msg = '服务器内部错误'
          break
        default:
          msg = res.msg || '请求失败'
      }

      ElMessage({
        type: 'error',
        message: msg,
      })
      return Promise.reject(msg)
    }
  },
  (error) => {
    let errorMsg = '请求失败,请检查网络连接'
    if (error.response) {
      switch (error.response.status) {
        case 404:
          errorMsg = '请求地址不正确'
          break
        default:
          errorMsg = `请求失败,状态码:${error.response.status}`
      }
    } else if (error.message.includes('timeout')) {
      errorMsg = '请求超时,请重试'
    }

    ElMessage({
      type: 'error',
      message: errorMsg,
    })

    return Promise.reject(error)
  },
)

// 公共参数
interface CommonRequestOption {
  url: string
}
// get 请求参数
export interface GetRequestOption extends CommonRequestOption {
  method: 'get' | 'GET'
  params?: object
}
// post 请求参数
export interface PoseRequestOption extends CommonRequestOption {
  method: 'post' | 'POST'
  data?: object
}

function request<T>(options: GetRequestOption): Promise<ApiResponseData<T>>

function request<T>(options: PoseRequestOption): Promise<ApiResponseData<T>>

function request<T>(options: GetRequestOption | PoseRequestOption) {
  return service(options.url, options)
    .then((res) => res)
    .catch((err) => {
      return Promise.reject(err)
    }) as Promise<ApiResponseData<T>>
}

export default request

此时发送数据变成了:

// main.ts
import request from '@/utils/request'

request({
  url: '/user/login',
  method: 'post',
  data: {
    username: 'admin',
    password: '111111'
  }
})
.then(response => {
  console.log(response.data)
})
.catch(error => {
  console.error('Error:', error)
});

3.8 配置 api 目录

一般项目中使用到的网络请求地址都会统一放在 src/api 目录。

新建 src/api/login/index.ts 文件:

import { request } from "@/utils/request"

/** 登录并返回 Token */
export function loginApi(data) {
  return request({
    url: "/user/login",
    method: "post",
    data
  })
}

由于使用的是 typescript,所以一般还会新建 src/api/login/types 文件夹,并在该文件夹下创建 login.ts 类型声明文件。

export interface LoginRequestData {
  /** admin 或 editor */
  username: string
  /** 密码 */
  password: string
  // 如果需要验证码可以新增字段
}

export type LoginResponseData = { token: string }

创建 src/types 文件夹,创建 api.d.ts 类型声明文件。

/** 所有 api 接口的响应数据都应该准守该格式 */
interface ApiResponseData<T> {
    code: number
    msg: string
    data: T
}

此时 src/api/login/index.ts 文件可修改为:

import request from '@/utils/request'
import type { LoginRequestData, LoginResponseData } from './types/login.ts'

/** 登录并返回 Token */
export function loginApi(data: LoginRequestData) {
  return request<LoginResponseData>({
    url: '/user/login',
    method: 'post',
    data,
  })
}

此时可以这样使用:

//main.ts
import { loginApi } from '@/api/login'

loginApi({
  username: 'admin',
  password: '111111',
})
.then((response) => {
  console.log(response.data)
})
.catch((error) => {
  console.error('Error:', error)
})

3.9 配置 pinia

pinia 是为 Vue 设计的状态管理库,类似于 Vuex。

安装:

pnpm install pinia

新建 src/store/index.ts 文件:

import { createPinia } from 'pinia'

const store = createPinia()

export default store

main.ts 进行引入:

import store from '@/store'

const app = createApp(App)
app.use(store)
app.mount('#app')

新建 src/store/modules/user.ts 文件:

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginApi } from '@/api/login'
import { type LoginRequestData } from '@/api/login/types/login'

export const useUserStore = defineStore('user', () => {
  //const token = ref<string>(getToken() || "")
  const token = ref<string>('')

  const login = async ({ username, password }: LoginRequestData) => {
    const { data } = await loginApi({ username, password })
    token.value = data.token
    console.log(data)
  }

  return { token, login }
})

然后可以进行使用:

// main.ts
import store from '@/store'
import { useUserStore } from '@/store/modules/user'
import { type LoginRequestData } from "@/api/login/types/login"
import { reactive } from "vue"

// 需要注册 pinia 后面再进行使用 
//...
app.use(store)
app.mount('#app')

const loginFormData: LoginRequestData = reactive({
  username: "admin",
  password: "111111"
})

useUserStore()
.login(loginFormData)
.then(() => {
  console.log('login success')
  //router.push({ path: "/" })
})
.catch(() => {
  console.log('login error')
  loginFormData.password = ""
})

3.10 配置路由

这里使用的是 vue 官方推荐的 vue-router

安装:

pnpm install vue-router

然后创建 src/router/index.ts 文件:

import { createRouter, createWebHistory } from 'vue-router'
import constantRoutes from './constantRoutes'

// 创建路由器实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
})

const whiteList:Array<string> = ['/login', '/404']

router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token');  // 先获取 token

  // 如果有 token,直接继续路由,且可以在此处加入权限控制
  if (token) {
    if (to.path === '/login') {
      return next({ path: '/dashboard' });
    }

    return next();  // 有权限则放行
  }

  // 如果没有 token,则检查是否在白名单内
  if (whiteList.includes(to.path)) {
    return next();
  }

  // 重定向到登录页面,并附加重定向路径
  next(`/login?redirect=${to.fullPath}`);
});

export default router

上面配置了一个路由器,以及设置了导航守卫。其中引入了静态路由文件 src/router/constantRoutes.ts,内容如下:

const constantRoutes = [
  {
    path: '/',
    component: () => import('@/components/Layout/index.vue'),
    children: [
      {
        path: 'dashboard', // 默认子路由
        name: 'dashboard',
        component: () => import('@/views/Dashboard/index.vue'),
      },
      {
        path: 'user', // 默认子路由
        name: 'user',
        component: () => import('@/views/User/index.vue'),
      },
      {
        path: '', 
        redirect: { name: 'dashboard' } 
      }
    ],
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/Login/index.vue'),
  },
  // 通配符路由匹配所有不存在的路由
  {
    path: '/:pathMatch(.*)*', // 通配符
    name: 'notFound',
    component: () => import('@/views/NotFound/index.vue'),
  },
]

export default constantRoutes

这里又引入了几个页面组件,如 loginlogoutuser 组件等,后面再进行展示内容。这里配置完以后,需要在 main.ts 中引入并使用:

import router from '@/router'

//...
app.use(router)
app.mount('#app')

以下是 constantRoutes.ts 中用到的组件:

src/components/Layout/index.vue:

<script setup lang="ts" name="Layout">
import { Expand, Fold, User, HomeFilled, UserFilled } from '@element-plus/icons-vue'
import { ref, onMounted} from 'vue'
import router from '@/router';
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'

// 激活的菜单项
const defaultActive = ref<string>(router.currentRoute.value.path) 
const isCollapse = ref(false)

const handleShow = () => {
  isCollapse.value = !isCollapse.value
}

const handleSelect = (index) => {
  router.push(index)
}

const handleOpen = (key: string, keyPath: string[]) => {
  //console.log(key, keyPath)
}
const handleClose = (key: string, keyPath: string[]) => {
  //console.log(key, keyPath)
}

const handleLogout = () => {
  useUserStore().logout()
}

const handleModifyPassword = () => {
  ElMessage({
    message: '别急,功能还没开发呢',
    type: 'warning',
  })
}
</script>

<template>
  <div class="layout">
    <el-container>
      <el-aside width="collapse">
        <el-menu :default-active="defaultActive" :collapse="isCollapse" @open="handleOpen" @close="handleClose" @select="handleSelect">
          <router-link to="/">
            <div class="logo">
              <div v-if="!isCollapse">Logo</div>
              <div v-else>L</div>
            </div>
          </router-link>
          <el-menu-item index="/dashboard">
            <el-icon>
              <home-filled />
            </el-icon>
            <template #title>主页</template>
          </el-menu-item>
          <el-menu-item index="/user">
            <el-icon>
              <user-filled />
            </el-icon>
            <template #title>用户管理</template>
          </el-menu-item>
        </el-menu>
      </el-aside>
      <el-container class="layout-right">
        <el-header style="text-align: right; font-size: 12px">
          <div class="hamburger" @click="handleShow">
            <div v-if="!isCollapse">
              <el-icon style="margin-top: 1px" :size="20">
                <fold />
              </el-icon>
            </div>
            <div v-else>
              <el-icon style="margin-top: 1px" :size="20">
                <expand />
              </el-icon>
            </div>
          </div>
          <div class="toolbar">
            <el-dropdown>
              <div class="profile">
                <el-icon class="el-icon--left" :size="14" >
                  <user />
                </el-icon>
                <div>admin</div>
              </div>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item @click="handleModifyPassword">修改密码</el-dropdown-item>
                  <el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </div>
        </el-header>
        <div class="main-content">
          <router-view></router-view>
        </div>
      </el-container>
    </el-container>
  </div>
</template>

<style scoped lang="less">
@aside-bgcolor: #304156;
@submenu-bgcolor: #1f2d3d;

.layout {
  height: 100vh;
}

.el-container {
  height: 100%;
}

.el-aside {
  background-color: @aside-bgcolor;
  color: var(--el-color-white);

  .logo {
    height: 60px;
    font-size: 30px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  }

  .el-menu {
    --el-menu-bg-color: @aside-bgcolor;
    --el-menu-text-color: var(--el-color-white);
    --el-menu-active-color: var(--el-color-primary);
    border-right-width: 0;
  }

  .el-menu:not(.el-menu--collapse) {
    width: 170px;
    min-height: 400px;
  }

  .el-sub-menu li {
    background-color: @submenu-bgcolor;
  }
}

.el-header {
  position: relative;
  background-color: var(--el-color-white);
  color: var(--el-text-color-primary);
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000),
    var(--tw-shadow);

  display: flex;
  align-items: center;
  justify-content: space-between;

  .toolbar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100px;
    height: 100%;
    right: 20px;

    .profile {
      display: flex;
      /** 去除悬浮黑框 */
      outline: none;
    }

  }
}

.main-content {
  height: 100%;
  background-color: #f0f2f5;
}
</style>

src/views/Dashboard/index.vue:

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

<template>
  <div class="main-content">
    <h1>我是首页</h1>
  </div>
</template>

<style lang="less">
.main-content {
  display: flex;
  justify-content: center;
  align-items: center;

  h1 {
    font-size: 60px;
  }
}
</style>

src/views/Login/index.vue:

<script setup lang="ts" name="Login">
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { type LoginRequestData } from '@/api/login/types/login'
import { reactive } from 'vue'
import router from '@/router'
import { useRoute } from 'vue-router'

const loginFormData: LoginRequestData = reactive({
  username: 'admin',
  password: '111111',
})

const route = useRoute();

async function handleLogin() {
  await useUserStore()
    .login(loginFormData);

  // 获取 URL 中的重定向参数(如果存在)
  const redirectPath = route.query.redirect || '/dashboard';
  // 重定向到指定路径
  router.push(redirectPath as string);
}
</script>

<template>
  <div class="login">
    <el-row class="login-main">
      <el-col :span="15" :xs="0" class="login-left">
        <div class="login-left-text">
          <div class="text-line1">Welcome to</div>
          <div class="text-line2">Remind</div>
        </div>
      </el-col>
      <el-col :span="9" :xs="24" class="login-right">
        <el-form label-width="auto" class="login-form" :model="loginFormData">
          <h1 class="login-form-title">登录</h1>
          <el-input
            :prefix-icon="User"
            style="width: 100%"
            placeholder="请输入账号"
            v-model="loginFormData.username"
          />
          <el-input
            :prefix-icon="Lock"
            style="width: 100%"
            placeholder="请输入密码"
            v-model="loginFormData.password"
            type="password"
            show-password
          />
          <el-button class="login-form-button" @click.prevent="handleLogin">Login</el-button>
        </el-form>
      </el-col>
    </el-row>
  </div>
</template>

<style scoped lang="less">
@login-main-color: #07294c;
@shadow-color: rgba(100, 104, 104, 0.5);

.login {
  height: 100vh;
  width: 100%;
  padding: 4% 8%;
}

.login-main {
  height: 100%;
}

// 使用BEM命名法,将 .login-left 和 .text 提升
.login-left {
  font-weight: 300;
  color: var(--el-color-white);
  background-color: @login-main-color;
  display: flex;
  flex-flow: column;
  justify-content: center;
  align-items: center;
}

.login-left-text {
  margin: 20px 0 0 40px;
  font-size: 80px;
}

.login-right {
  box-shadow: 5px 5px 15px @shadow-color;
}

.login-form {
  height: 100%;
  display: flex;
  flex-flow: column;
  justify-content: center;
  align-items: center;
  gap: 20px;
  padding: 0 50px;
}

.login-form-title {
  font-size: 20px;
  font-weight: bold;
}

.login-form-button {
  color: var(--el-color-white);
  background-color: @login-main-color;
  font-weight: 400;
  //align-self: flex-end;
  height: 40px;
  width: 100%;
  padding: 10px 20px;
}
</style>

src/views/NotFound/index.vue:

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

<template>
  <div class="not-found">
    <h1>404</h1>
    <p>请求的路径不存在.</p>
    <router-link to="/">回到首页</router-link>
  </div>
</template>

<style scoped lang="less">
.not-found {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100vh;
  text-align: center;
}

h1 {
  font-size: 3rem;
  color: #ff6f61;
}

p {
  font-size: 1.5rem;
}

a {
  color: #409eff;
  font-size: 1.2rem;
  text-decoration: underline;
  margin-top: 1rem;
}
</style>

src/views/User/index.vue:

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

<template>
  <div class="main-content">
    <h1>我是用户管理</h1>
  </div>
</template>

<style lang="less">
.main-content {
  display: flex;
  justify-content: center;
  align-items: center;

  h1 {
    font-size: 60px;
  }
}
</style>

4. 最终效果

Vue3 项目初始化

/>

Tips:清朝云网络工作室

阅读剩余
THE END