【前端工程化】深入浅出vite(二)--vue3全家桶+ts构建后管系统

安装基础包

npm create vite@latest
# 这里选择的是Vue+Typescript的组合
cd vue-admin
npm install

# 先安装基础包
npm install vue-router@4
npm i pinia
npm i axios
npm install sass --save-dev
npm install element-plus --save
npm install @element-plus/icons-vue
npm install -D unplugin-vue-components unplugin-auto-import
npm i eslint -D

# 提交规范
npm i lint-staged husky  --save-dev
npm install @commitlint/cli @commitlint/config-conventional -D

代码规范

npm init @eslint/config

接下来会有一堆提示,选择如下:

Need to install the following packages:
  @eslint/create-config
Ok to proceed? (y)
√ How would you like to use ESLint? · style       
√ What type of modules does your project use? · esm
√ Which framework does your project use? · vue
√ Does your project use TypeScript? · No / Yes
√ Where does your code run? · browser
√ How would you like to define a style for your project? · guide
√ Which style guide do you want to follow? · standard-with-typescript
√ What format do you want your config file to be in? · JavaScript
Checking peerDependencies of eslint-config-standard-with-typescript@latest
The config that you've selected requires the following dependencies:

eslint-plugin-vue@latest eslint-config-standard-with-typescript@latest @typescript-eslint/eslint-plugin@^5.50.0 eslint@^8.0.1 eslint-plugin-import@^2.25.2 eslint-plugin-n@^15.0.0 eslint-plugin-promise@^6.0.0 typescript@*
√ Would you like to install them now? · No / Yes
√ Which package manager do you want to use? · npm
Installing eslint-plugin-vue@latest, eslint-config-standard-with-typescript@latest, @typescript-eslint/eslint-plugin@^5.50.0, eslint@^8.0.1, eslint-plugin-import@^2.25.2, eslint-plugin-n@^15.0.0, eslint-plugin-promise@^6.0.0, typescript@*

在项目中就会生成一个.eslintrc.cjs文件,接下来配置一下脚本验证一下:

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

然而运行的时候报错了,由于我当前的"typescript": "^5.1.3",@typescript-eslint/typescript-estree支持的ts版本范围为:=3.3.1 <5.1.0,所以我得降级一下:[email protected],在配置eslint路上出现了很多问题,直接提供解决方案:

首先是修改.eslintrc.cjs:

module.exports = {
    
    
  env: {
    
    
    browser: true,
    es2021: true
  },
  extends: [
    'plugin:vue/vue3-essential',
    'standard-with-typescript'
  ],
  parser: "vue-eslint-parser",
  overrides: [
  ],
  parserOptions: {
    
    
    ecmaVersion: 'latest',
    sourceType: 'module',
    project: ["./tsconfig.json"],
    parser: "@typescript-eslint/parser",
    extraFileExtensions: ['.vue']
  },
  plugins: [
    'vue'
  ],
  rules: {
    
    
    'space-before-function-paren': [2, {
    
    
      anonymous: 'always',
      named: 'never',
      asyncArrow: 'always'
    }],
    'vue/multi-word-component-names': 0,
    "space-before-function-paren": 0,
    "@typescript-eslint/consistent-type-assertions": 0,
    "@typescript-eslint/ban-types": [
      "error",
      {
    
    
        "extendDefaults": true,
        "types": {
    
    
          "{}": false
        }
      }
    ]
  }
}

vite-env.d.ts加上一行注释,忽略检查:

// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference types="vite/client" />

关于eslint配置中遇到的问题,可以参考这个大佬写的,更详细些:Eslint:vue3项目添加eslint(standard规则)

commit规范

git init

package.json中新增如下代码,利用它来调用 eslint 和 stylelint 去检查暂存区内的代码

"lint-staged": {
    
    
    "*.{vue,js}": [
      "npm run lint"
    ]
  }

执行:

npm pkg set scripts.postinstall="husky install"
# 等同于执行npm i,执行过程中会生成.husky文件夹
npm run postinstall

npx husky add .husky/pre-commit "npm lint"
git add .husky/pre-commit

这样我们执行git commit的时候就会自动执行npm lint

很尴尬,在跑的过程中,报错了node不是内部或外部命令。node -v是木有问题的,大抵是nvm这个工具的问题,所以后面就换了volta来做node的版本控制。

npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1"

新建commitlint.config.cjs

module.exports = {
    
    
  extends: ['@commitlint/config-conventional'],
  rules: {
    
    
    'type-enum': [2, 'always', [
      'feat', // 新增功能
      'update', // 更新功能
      'ui', // 样式改动
      'fix', // 修复功能bug
      'merge', // 合并分支
      'refactor', // 重构功能
      'perf', // 性能优化
      'revert', // 回退提交
      'style', // 不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等)
      'build', // 修改项目构建工具(例如 glup,webpack,rollup 的配置等)的提交
      'docs', // 文档新增、改动
      'test', // 增加测试、修改测试
      'chore' // 不修改src或者test的其余修改,例如构建过程或辅助工具的变动
    ]],
    'scope-empty': [0],
    // 'scope-empty': [2, 'never'], 作用域不为空
    'scope-case': [0],
    'subject-full-stop': [0],
    'subject-case': [0]
  }
}

修改tsconfig.json

"include": [
  //...
  "commitlint.config.cjs"
 ],

修改.eslintrc.cjs

project: ["./tsconfig.json", "./commitlint.config.cjs"],
git add .
# 失败
git commit -m "commit校验"
# 成功
git commit -m "feat: commit校验"

设置路径别名

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

const resolve = (dist) => path.resolve(__dirname, dist)

export default defineConfig({
    
    
  plugins: [vue()],
  resolve: {
    
    
    alias: {
    
    
      '@': resolve('src')
    },
    // 顺便把可以省略的后缀配置一下,在vite中不支持省略.vue
    extensions: [".js", ".ts", ".tsx", ".jsx"]
  }
})

修改tsconfig.json,新增:

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

重置样式

assets文件夹下新建styles/reset.css

/**
 * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/)
 * http://cssreset.com
 */
 
 html, body, div, span, applet, object, iframe,
 h1, h2, h3, h4, h5, h6, p, blockquote, pre,
 a, abbr, acronym, address, big, cite, code,
 del, dfn, em, img, ins, kbd, q, s, samp,
 small, strike, strong, sub, sup, tt, var,
 b, u, i, center,
 dl, dt, dd, ol, ul, li,
 fieldset, form, label, legend,
 table, caption, tbody, tfoot, thead, tr, th, td,
 article, aside, canvas, details, embed, 
 figure, figcaption, footer, header, hgroup, 
 menu, nav, output, ruby, section, summary,
 time, mark, audio, video{
    
    
   margin: 0;
   padding: 0;
   border: 0;
   font-size: 100%;
   font: inherit;
   font-weight: normal;
   vertical-align: baseline;
 }
 /* HTML5 display-role reset for older browsers */
 article, aside, details, figcaption, figure, 
 footer, header, hgroup, menu, nav, section{
    
    
   display: block;
 }
 ol, ul, li{
    
    
   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;
 }
  
 /* custom */
 a{
    
    
   color: #7e8c8d;
   text-decoration: none;
   backface-visibility: hidden;
   -webkit-backface-visibility: hidden;
 }
 ::-webkit-scrollbar{
    
    
   width: 5px;
   height: 5px;
 }
 ::-webkit-scrollbar-track-piece{
    
    
   background-color: rgba(0, 0, 0, 0.2);
   border-radius: 6px;
   -webkit-border-radius: 6px;
 }
 ::-webkit-scrollbar-thumb:vertical{
    
    
   height: 5px;
   background-color: rgba(125, 125, 125, 0.7);
   border-radius: 6px;
   -webkit-border-radius: 6px;
 }
 ::-webkit-scrollbar-thumb:horizontal{
    
    
   width: 5px;
   background-color: rgba(125, 125, 125, 0.7);
   border-radius: 6px;
   -webkit-border-radius: 6px;
 }
 html, body{
    
    
   width: 100%;
   font-family: "Arial", "Microsoft YaHei", "黑体", "宋体", "微软雅黑", sans-serif;
 }
 body{
    
    
   line-height: 1;
   -webkit-text-size-adjust: none;
   -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
 }
 html{
    
    
   overflow-y: scroll;
 }
  
 /*清除浮动*/
 .clearfix:before,
 .clearfix:after{
    
    
   content: " ";
   display: inline-block;
   height: 0;
   clear: both;
   visibility: hidden;
 }
 .clearfix{
    
    
   *zoom: 1;
 }
  
 /*隐藏*/
 .dn{
    
    
   display: none;
 }
 

使用Scss

Vite 提供了对 .scss, .sass, .less, .styl.stylus 文件的内置支持。没有必要为它们安装特定的 Vite 插件,但必须安装相应的预处理器依赖。

一般我们会在项目中定义一些主题色:

// variable.scss
$font-color-gray:rgb(147,147,147);

或者是一些封装好的集合样式:

// mixins.scss
@mixin line-clamp($lines) {
    
    
  word-break: break-all;
  display: -webkit-box;
  overflow: hidden;
  text-overflow: ellipsis;
  -webkit-line-clamp: $lines;
  -webkit-box-orient: vertical;
}

@mixin ellipsis() {
    
    
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}


然后我们在vite.config.js中配置:

css: {
    
    
    preprocessorOptions: {
    
    
      scss: {
    
    
        additionalData: '@import "@/assets/styles/variable.scss";@import "@/assets/styles/mixins.scss";'
      }
    }
  }

删除目录中的style.css,新建styles/common.scss

// common.scss
@import url('./reset.css');

然后在main.ts中引入:

import '@/assets/styles/common.scss'

这样全局样式就初始化了,接下来测试一下variable.scssmixins.scss是否起作用,修改HelloWorld.vue查看是否为灰色且两行省略:

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

<template>
  <div class="box">没错,这里要使用浏览器的获取媒体设备的 api 来拿到摄像头的视频流,设置到 video 上,然后对 video 做下镜像反转,加点模糊就好了。</div>
</template>

<style scoped lang="scss">
.box {
      
      
  color: $font-color-gray;
  height: 40px;
  line-height: 20px;
  width: 200px;
  @include line-clamp(2);
}
</style>

配置路由

现在要来配置路由,希望有这样的结果:

- 登录页
- 带菜单栏的框架
  - 主页
  - 人员管理
    - 客户管理
    - 员工管理
- 404

所以新建以下文件:

在这里插入图片描述

在这个过程中我们会遇到几个问题:

  • 在编写路由的时候我们引入组件,必须有.vue后缀;
  • import xxx from '@/xxx'时会报错,是因为你没有在上面提到的那样在tsconfg.json中设置baseUrlpaths值。

由于layout文件中要嵌套子路由,所以layout中要加入router-view:

<template>
  <div>布局</div>
  <router-view></router-view>
</template>

其他的文件只要写成这样就行:

<template>
  <p>主页</p>
</template>

新建router文件夹:

- router
  -hooks # 后期做登录校验和鉴权用的
  - routes
    - index.ts # 总输出文件
    - others.ts # 不需要layout这一层的路由均可以放在这里
    - person.ts # 人员管理模块
  - index.ts   # 总输出文件

接下里是各个文件的内容:

// person.ts
export default [
  {
    
    
    path: '/person',
    name: 'Person',
    meta: {
    
     title: '人员管理' },
    redirect: '/person/customer',
    children: [
      {
    
    
        path: '/person/customer',
        name: 'PersonCustomer',
        meta: {
    
     title: '客户管理' },
        component: () => import('@/views/person/customer/index.vue')
      },
      {
    
    
        path: '/person/staff',
        name: 'PersonStaff',
        meta: {
    
     title: '员工管理' },
        component: () => import('@/views/person/staff/index.vue')
      }
    ]
  }
];

// others.ts
export default [
  {
    
    
    path: '/login',
    name: 'Login',
    meta: {
    
     title: '登录' },
    component: () => import('@/views/login/index.vue')
  }
];

// router/routes/index.ts
import Layout from '@/views/layout/index.vue';
import personRoutes from './person';
import otherRoutes from './others';

export default [
  {
    
    
    path: '/',
    name: 'Layout',
    component: Layout,
    children: [
      {
    
    
        path: '/',
        name: 'Index',
        meta: {
    
     title: '主页' },
        component: () => import('@/views/index/index.vue')
      },

      ...personRoutes,
    ]
  },
  ...otherRoutes,
  {
    
    
    path: '/404',
    name: 'NotFound',
    meta: {
    
     title: '404' },
    component: () => import('@/views/404/index.vue')
  },
  {
    
    
    path: "/:pathMatch(.*)",
    redirect: "/404",
    name:'ErrorPage',
    meta: {
    
     title: '' },
  }
];

先抛开hooks文件夹,简单的写一下index.ts

// router/index.ts
import routes from "./routes";
export default routes;

新建一个src/plugins/index.ts,之前我们注册内容的时候都是直接放在main.ts中,不太容易维护,所以以后统一在这里挂载:

// src/plugins/index.ts
import {
    
     createRouter, createWebHashHistory } from 'vue-router';
import routes from '@/router/index';

export default (app: any) => {
    
    
  // 注册路由
  const router = createRouter({
    
    
    history: createWebHashHistory(),
    routes
  })

  app.use(router);
}

不要忘了修改App.vue:

<template>
  <router-view></router-view>
</template>
import {
    
     createApp } from 'vue'
import '@/assets/styles/common.scss'
import App from './App.vue'
import installPlugins from '@/plugins';

const app = createApp(App);
installPlugins(app);
app.mount('#app')

这样就可以测试了:

http://127.0.0.1:5173/#/
http://127.0.0.1:5173/#/login
http://127.0.0.1:5173/#/person
http://127.0.0.1:5173/#/person/customer
http://127.0.0.1:5173/#/person/staff

如果在配置过程中发现报错:找不到模块“xxx.vue”或其相应的类型声明,则在vite-env.d.ts中新增:

declare module '*.vue' {
    
    
  import type {
    
     DefineComponent } from 'vue';
  const vueComponent: DefineComponent<{
    
    }, {
    
    }, any>;
  export default vueComponent;
} 

使用element plus

如果您使用 Volar,请在 tsconfig.json 中通过 compilerOptions.type 指定全局组件类型。

// tsconfig.json
{
    
    
  "compilerOptions": {
    
    
    // ...
    // 然而这个配置在后期打包的时候报错了...
    "types": ["element-plus/global"]
  }
}

这里采用了按需引入的方式,如果对体积不追求的,可以采用完整引入:

// vite.config.ts
import {
    
     defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

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

const resolve = (dist) => path.resolve(__dirname, dist)

export default defineConfig({
    
    
  plugins: [
    vue(),
    // 新增
    AutoImport({
    
    
      resolvers: [ElementPlusResolver()]
    }),
    // 新增
    Components({
    
    
      resolvers: [ElementPlusResolver()]
    })
  ],
  resolve: {
    
    
    alias: {
    
    
      '@': resolve('./src')
    },
    extensions: [".js", ".ts", ".tsx", ".jsx"]
  },
  css: {
    
    
    preprocessorOptions: {
    
    
      scss: {
    
    
        additionalData: '@import "@/assets/styles/variable.scss";@import "@/assets/styles/mixins.scss";'
      }
    }
  }
})


element plus中日期等组件默认是英文,所以我们把组件改为中文:

在这里插入图片描述

修改App.vue

<script setup lang="ts">
import locale from 'element-plus/lib/locale/lang/zh-cn'
</script>

<template>
  <el-config-provider :locale="locale">
    <router-view></router-view>
  </el-config-provider>
</template>

这样就会出现中文了。

在这里插入图片描述

关于引入的两个插件,这里解释一下:

  • unplugin-vue-components用于自动识别Vue模板中使用的组件,自动按需导入和注册;
  • unplugin-auto-import可以在vite、webpack等环境下自动按需导入配置库常用的API,如Vueref,不需要手动import,所以我们可以配置一下,并删除一些API的引入:
export default defineConfig({
    
    
  plugins: [
    // ...
    AutoImport({
    
    
      imports: [
        'vue',
        'vue-router',
        'pinia'
      ],
      eslintrc: {
    
    
        enabled: true,
        filepath: './.eslintrc-auto-import.json',
        globalsPropValue: true
      },
      resolvers: [ElementPlusResolver()]
    }),
    // ...
  ],
})

保存生效后,auto-imports.d.ts 会自动填充内容,并且会在项目根目录生成 .eslintrc-auto-import.json eslint 全局变量配置。

然后修改tsconfg.json.eslintrc.cjs

// tsconfg.json
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "commitlint.config.cjs", "auto-imports.d.ts"],

// .eslintrc.cjs
project: ["./tsconfig.json", "./commitlint.config.cjs", './.eslintrc-auto-import.json'],

忽略 auto-imports.d.ts ESLint 校验

# .eslintignore
auto-imports.d.ts

这里需要注意一下:

  1. 不是全部 API,例如 Vue Router 的 createRouter 就不会导入。具体可以自动导入的 API 参考 unplugin-auto-import/src/presets
  2. 生成 .eslintrc-auto-import.json 文件后如不需要增加配置建议将 enabled: true 设置为 false,否则每次都会生成这个文件。

配置完可以删除页面中的一些引用,发现是没有问题的。

<script lang='ts' setup>
// import { storeToRefs } from 'pinia'
// import { useRouter } from 'vue-router'
// ...
</script>

测试一下组件:

<template>
  <p><el-button>测试</el-button></p>
</template>

这样页面上就会显示按钮了。

自动按需引入的原理是通过识别<template>中使用的组件自动导入,那类似ElMessage 这类直接在 JS 中调用方法的组件,插件并不会识别并完成自动导入,所以还是需要自己手动导入一下(建议按需引入的方式,仍然引入完整的样式文件,避免这类边界问题):

修改vite-env.d.ts,不然在ts中引入element plus会报错:

declare module "element-plus";

plugins/element-plus.ts中小试一下:

import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import {
    
     ElLoading, ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import 'element-plus/theme-chalk/index.css'

const options = {
    
    
  size: 'small',
  zIndex: 3000
}

const components = [
  ElLoading,
  ElMessage,
  ElMessageBox, 
  ElNotification
]

export default function install (app: any): void {
    
    
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    
    
    app.component(key, component)
  }

  components.forEach((component) => {
    
    
    app.use(component, options)
  })
}


// plugins/index.ts
export default (app: any) => {
    
    
  // ...

  // 注册element-plus
  installElementPlus(app);
}

测试一下:

<template>
  <p><el-button @click="onTest">测试</el-button></p>
</template>

<script setup lang="ts">
const onTest = () => {
      
      
  ElLoading.service({
      
       fullscreen: true });
}
</script>

globalProperties

按照以前的习惯,loading的调用肯定不用上面的方式,而是挂载在全局Vue.prototype上,然而在这个项目中,我们用的是按需引入,且在Vue3中,写法变了,你可能想这么写:

app.config.globalProperties.$loading = ElLoading;
app.config.globalProperties.$message = ElMessage;
app.config.globalProperties.$msgBox = ElMessageBox;
app.config.globalProperties.$notification = ElNotification;

然后再使用的过程中:

<script setup lang="ts">
const instance = getCurrentInstance()

onMounted(() => {
    
    
  instance.proxy.$message.success('setup - getCurrentInstance() 成功使用')
  // 也可以使用 appContext
  console.log(instance.appContext.config.globalProperties.$message === instance.proxy.$message) // true
})
</script>

但是查阅了官方文档,并没有getCurrentInstance该方法,大概是不符合规范吧。所以全局方法的注入,我采用了provide/inject

App.vue

<script setup lang="ts">
import locale from 'element-plus/lib/locale/lang/zh-cn'
import {
      
       ElLoading, ElMessage, ElMessageBox, ElNotification } from 'element-plus'

provide('$loading', ElLoading)
provide('$message', ElMessage)
provide('$messagebox', ElMessageBox)
provide('$notification', ElNotification)
</script>

<template>
  <el-config-provider :locale="locale">
    <router-view></router-view>
  </el-config-provider>
</template>

修改element-plus.ts

import {
    
     App } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// import { ElLoading, ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import 'element-plus/theme-chalk/index.css'

// const options = {
    
    
//   size: 'small',
//   zIndex: 3000
// }

// const components = [
//   ElLoading,
//   ElMessage,
//   ElMessageBox, 
//   ElNotification
// ]

export default function install (app: App): void {
    
    
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    
    
    app.component(key, component)
  }

  // components.forEach((component) => {
    
    
  //   app.use(component, options)
  // })
}

新增src/types/global.d.ts

import {
    
     ElLoading, ElMessage, ElMessageBox, ElNotification } from 'element-plus'

export interface ComponentCustomProperties {
    
    
  $message: typeof ElMessage
  $msgBox: typeof ElMessageBox
  $loading: typeof ElLoading
  $notification: typeof ElNotification
}


测试:

<template>
  <p><el-button @click="onTest">测试</el-button></p>
</template>

<script setup lang="ts">
const $loading = inject('$loading') as any 
const onTest = () => {
      
      
  $loading.service({
      
      
    lock: true,
    text: 'Loading',
  })
}
</script>

复习一些常见的ts写法

开发服务器和打包器将只会对ts文件执行语法转义,而不会执行任何类型检查,保证了vite开发服务器在使用ts时也能保持飞快的速度。

下面是一些常见的例子:

<!-- 对props的类型声明和默认值 -->
<script setup lang="ts">
interface Props {
      
      
  msg?: string
  labels?: string[]
}
const props = withDefaults(defineProps<Props>(), {
      
      
   msg: 'hello',
   labels: () => ['one', 'two']
})
</script>


<!-- 另一种方式 -->
<script setup lang="ts">
interface Book {
      
      
  title?: string
  author: string
}
const props = defineProps({
      
      
  book: Object as PropType<Book>
})
</script>


<!-- 对emits进行声明 -->
<script setup lang="ts">
// 1.运行时
const emit = defineEmits(['change', 'update'])

// 2.基于类型
const emit = defineEmits<{
      
      
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()
</script>


<!-- 为computed指定返回类型 --->
<script setup lang="ts">
const double = computed<number>(() => {
      
       /** return number */ })
</script>

<!-- 为函数参数标注类型 -->
<script setup lang="ts">
const onClick = (e: Event) => {
      
      
  console.log((event.target as HTMLInputElement).value)
}
</script>

<!-- project和inject 然而我很少用到 -->
<script setup lang="ts">
import {
      
       provide, inject } from 'vue'
import type {
      
       InjectionKey } from 'vue'

const key = Symbol() as InjectionKey<string>
provide(key, 'foo')

const foo = inject<string>('foo', 'bar')
</script>

<!-- 为模板引用标注类型 -->
<script setup lang="ts">
import {
      
       ref, onMounted } from 'vue'
const el = ref<HTMLInputElement | null>(null)

onMounted(() => {
      
      
  el.value?.focus()
})
</script>

<template>
  <input ref="el" />
</template>


<!-- 为组件模板引用标注类型 -->
<script setup lang="ts">
import MyModal from './MyModal.vue'

const modal = ref<InstanceType<typeof MyModal> | null>(null)

const openModal = () => {
      
      
  modal.value?.open()
}
</script>

登录静态页面

在登录页面中会使用到el-input组件,一般对于这种表单组件,后管使用的频率还是很高的,所以倾向于进行二次封装再使用,一般情况下会优化它的前后空格问题,并将它与textarea拆解出来:

<script lang='ts' setup>
import {
      
       computed } from 'vue';
const emits = defineEmits<{
      
      
  (e: 'update:value', value: string): void;
  (e: 'blur'): void;
  (e: 'focus'): void;
  (e: 'change', value: string): void;
  (e: 'clear'): void;
}>()

const props = defineProps({
      
      
  type: {
      
      
    type: String,
    default: 'string'
  },
  value: {
      
      
    type: [String, Number],
    default: '',
    required: true
  },
  maxlength: [String, Number],
  minlength: [String, Number],
  placeholder: {
      
      
    type: String,
    default: '请输入',
  },
  clearable: {
      
      
    type: Boolean,
    default: true
  },
  showPassword: {
      
      
    type: Boolean,
    default: false
  },
  disabled: {
      
      
    type: Boolean,
    default: false
  },
  prefixIcon: {
      
      
    type: String,
    default: ''
  },
  suffixIcon: {
      
      
    type: String,
    default: ''
  },
  inputStyle: [String, Object],
  showWordLimit: {
      
      
    type: Boolean,
    default: true
  },
  rows: Number,
  autosize: [Boolean, Object]
})

const input = computed({
      
      
  get(){
      
      
    console.log(props.value)
    return props.value;
  },

  set(val: any){
      
      
    if(typeof val === 'string') {
      
      
      val = val ? val.trim() : val;
    }
    emits('update:value', val);
  }
})

const onFocus = () => {
      
      
  emits('focus');
}

const onBlur = () => {
      
      
  emits('blur');
}

const onClear = () => {
      
      
  emits('clear');
}

const onChange = (val: string) => {
      
      
  emits('change', val);
}

</script>

<template>
  <el-input
    v-if="type === 'textarea'"
    v-model="input"
    :rows="rows"
    type="textarea"
    :placeholder="placeholder"
    :maxlength="maxlength"
    :minlength="minlength"
    :show-word-limit="showWordLimit"
    :disabled="disabled"
    :prefixIcon="prefixIcon"
    :suffixIcon="suffixIcon"
    :autosize="autosize"
    :inputStyle="inputStyle"
    @focus="onFocus"
    @blur="onBlur"
    @change="onChange"
  />
  <el-input 
    v-else
    v-model="input" 
    :type="type"
    :placeholder="placeholder"
    :maxlength="maxlength"
    :minlength="minlength"
    :clearable="clearable"
    :showPassword="showPassword"
    :disabled="disabled"
    :prefixIcon="prefixIcon"
    :suffixIcon="suffixIcon"
    :inputStyle="inputStyle"
    @focus="onFocus"
    @blur="onBlur"
    @clear="onClear"
    @change="onChange" />
    
</template>

<style scoped lang='scss'>

</style>

plugins/components.ts下全局注册:

import type {
    
     Component } from 'vue'
import ArInput from '@/components/form/input/index.vue';

const componentObj: {
    
    [propName: string]: Component} = {
    
    
  ArInput
};

export default function install(app: any) {
    
    
  Object.keys(componentObj).forEach((key) => {
    
    
    app.component(key, componentObj[key])
  })
}

记得在plugins/index.ts中加入:

import installComponents from './components'

export default (app: any) => {
    
     
  // ...

  // 注册自定义组件
  installComponents(app);
}

login.vue的源码:

<script lang="ts" setup>
import type {
      
       FormInstance, FormRules } from 'element-plus'
import {
      
       ref, reactive } from 'vue'


const formRef = ref<FormInstance>();
const form = reactive({
      
      
  name: '',
  password: ''
})
const rules = ref<FormRules>({
      
      
  name: [
    {
      
       required: true, message: '请输入账号', trigger: 'blur' },
    {
      
       min: 8, max: 12, message: '账号长度为8-12', trigger: 'blur' },
  ],
  password: [
  {
      
       required: true, message: '请输入密码', trigger: 'blur' },
  ]
})

const onSubmit = async() => {
      
      
  await formRef.value.validate((valid, fields) => {
      
      
    if (valid) {
      
      
      console.log('submit!')
    } else {
      
      
      console.log('error submit!', fields)
    }
  })
}

</script>

<template>
  <div class="login">
    <div class="login-inner">
      <el-form ref="formRef" :model="form" :rules="rules">
        <el-form-item label="账号" prop="name" required>
          <ArInput v-model:value="form.name" prefix-icon="User" />
        </el-form-item>
        <el-form-item label="密码" prop="password" required>
          <ArInput v-model:value="form.password" type="password" prefix-icon="Lock" />
        </el-form-item>
        <el-button type="primary" class="login-btn" @click="onSubmit">登录</el-button>
      </el-form>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.login {
      
      
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  background-color: #ccc;

  &-inner{
      
      
    margin-top: 20%;
    width: 300px;
    padding: 32px;
    background-color: #fff;
    border-radius: 4px;
  }

  &-btn {
      
      
    width: 100%;
  }
}
</style>

最后出来的页面效果即:

在这里插入图片描述

环境变量

在完成页面提交动作之前,先解决环境变量的问题,在测服、预发布或者生产中我们总有些变量是不一样的,所以需要做环境区分

新建env文件夹,内部新增三个四个文件:

.env                # 所有情况下都会加载
.env.development     # 开发环境
.env.release         # 预发布环境
.env.production      # 正服环境

举个例子:

# .env.development 
VITE_ENV = devalopment
# 请求接口
VITE_API_URL = https://api.vvhan.com/testapi/saorao

这样就可以在不同的环境中设置变量,然后我们修改一下脚本命令,区分一下环境:

"scripts": {
    
    
    "watch": "vite",
    "watch:release": "vite --mode release",
    "watch:production": "vite --mode production",
    "build:development": "vue-tsc && vite build --mode development",
    "build:release": "vue-tsc && vite build --mode release",
    "build:production": "vue-tsc && vite build --mode production",
    // ...
  },

在根目录新建build/utils.ts文件:

// Read all environment variable configuration files to process.env
export function wrapperEnv(envConf: Recordable) {
    
    
  const result: any = {
    
    }

  for (const envName of Object.keys(envConf)) {
    
    
    let realName = envConf[envName].replace(/\\n/g, '\n')
    realName =
      realName === 'true' ? true : realName === 'false' ? false : realName

    result[envName] = realName
    if (typeof realName === 'string') {
    
    
      process.env[envName] = realName
    } else if (typeof realName === 'object') {
    
    
      process.env[envName] = JSON.stringify(realName)
    }
  }
  return result
}

然后修改vite.config.js,将我们定义的变量注入进去:

import {
    
     defineConfig, loadEnv, UserConfig, ConfigEnv  } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {
    
     ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import {
    
     wrapperEnv } from './build/utils'

const resolve = (dist) => path.resolve(__dirname, dist)

// https://vitejs.dev/config/
export default ({
    
     command, mode }: ConfigEnv): UserConfig => {
    
    
  const env = loadEnv(mode, './env')
  wrapperEnv(env)

  return {
    
    
    plugins: [
      vue(),
      AutoImport({
    
    
        imports: [
          'vue',
          'vue-router',
          'pinia'
        ],
        eslintrc: {
    
    
          enabled: false,
          filepath: './.eslintrc-auto-import.json',
          globalsPropValue: true
        },
        resolvers: [ElementPlusResolver()]
      }),
      Components({
    
    
        resolvers: [ElementPlusResolver()]
      })
    ],
    resolve: {
    
    
      alias: {
    
    
        '@': resolve('./src')
      },
      extensions: [".js", ".ts", ".tsx", ".jsx"]
    },
    css: {
    
    
      preprocessorOptions: {
    
    
        scss: {
    
    
          additionalData: '@import "@/assets/styles/variable.scss";@import "@/assets/styles/mixins.scss";'
        }
      }
    }
  }
}

这样我们就可以在下面的axios封装中使用了。哦对可能会有点ts的报错,修改tsconfig.node.json

"include": ["vite.config.ts", "build**/*.ts"]

axios封装

新建src/utils/request.ts

import axios, {
    
     AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import {
    
     ElMessage } from 'element-plus'

const request: AxiosInstance | any = axios.create({
    
    
  timeout: 100000,
  headers: {
    
    
    post: {
    
    
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  },
  withCredentials: true
});

request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
    
    
  let token = localStorage.getItem("token");
  if (token && token !== '') {
    
    
    config.headers['Authorization'] = token;
  }

  // 获取环境变量!!!
  const projectUrlPrefix = import.meta.env.VITE_API_URL;
  // 这样更支持多域名接口的情况
  if (config && config.url && !/^(http(|s):\/\/)|^\/\//.test(config.url)) {
    
    
    config.url = projectUrlPrefix + config.url;
  }
  return config;
});

request.interceptors.response.use((res: any) => {
    
    
  if (res.data.status && res.data.status !== 200) {
    
    
    ElMessage.error(res.data.msg || '请求失败,请稍后重试')
    return Promise.reject(res.data)
  }
  // 如果这里是登录信息过期,那么应该给个弹窗提示什么的,最后都应该重定向到登录页面
  return res.data
}, (error: any) => {
    
    
  console.log(`%c 接口异常 `, 'background-color:orange;color: #FFF;border-radius: 4px;', error);
})

export default request;
export const $get = (url: string, params = {
    
    }) => {
    
    
  return request.get(url, {
    
    
    params
  })
}
export const $post = (url: string, params = {
    
    }) => {
    
    
  return request.post(url, params)
}

去登录页面做一下测试:

<script lang="ts" setup>
const $post = inject('$post') as any
// ...

const onSubmit = async () => {
  // ...
  const res = await $post('/login', form)
  // ...
}
</script >

测试不同环境下的域名前缀,如果不一样,则说明配置成功了~

https://api.vvhan.com/testapi/saorao/login
https://api.vvhan.com/releaseapi/saorao/login
https://api.vvhan.com/api/saorao/login

整体布局

在这里插入图片描述

  • 将路由模块中的路径转为菜单显示在左边;
  • 上面部分是用户信息,以及可以退出;
  • 切换路由,会出现一个类似浏览器的tab,可以点击tab切换,也可以关闭当前页面;
  • 最后是页面的主体内容显示。

先看看菜单组件:

<script lang='ts' setup>
import {
    
     ref, computed, watch } from 'vue';
import {
    
     useRoute, useRouter } from 'vue-router'
import {
    
     storeToRefs } from 'pinia'
import routes from '@/router/index';
import {
    
     IRouterItem } from '@/types/menu'
import {
    
     useTagViewsStore } from '@/store/tagViews';

const route = useRoute();
const router = useRouter();
const isCollapse = ref(false);
const defaultActive = ref('0')
const tagViewsStore = useTagViewsStore();
const {
    
     visitedViews } = storeToRefs(tagViewsStore);

// 1. 从声明的路由中获取当前显示的菜单栏(还可以过滤一些不是菜单栏的页面)
const currentMenu = computed(() => {
    
    
  if(routes && routes.length) {
    
    
    const routesArr: any = routes.filter((route) => route.name === 'Layout');
    if(routesArr && routesArr[0] && routesArr[0].children){
    
    
      const res = routesArr[0].children as IRouterItem[];
      return res;
    }else {
    
    
      return [];
    }
  }else {
    
    
    return []
  }
});

// 获取路由对应的菜单下标(如果一打开是客户列表页,则高亮客户列表)
const currentMenuToObj = computed(() => {
    
    
  const routes = currentMenu.value;
  if(routes && routes.length) {
    
    
    let obj: {
    
    [key: string]: any} = {
    
    };
    for(let i = 0; i < routes.length; i++) {
    
    
      const item = routes[i];
      if(item.children) {
    
    
        for(let j = 0; j< item.children.length; j++) {
    
    
          const subItem = item.children[j];
          obj[subItem.path] = {
    
    
            index: `${
      
      i}-${
      
      j}`,
            item: subItem
          };
        }
      }else {
    
    
        obj[item.path] = {
    
    
          index: '' + i,
          item
        }; 
      }
    }

    return obj;
  }else {
    
    
    return {
    
    };
  }
})

// 监听路由获取当前高亮的值,store的使用在后面详细说一下
watch(
  () => route.path,
  (val: string) => {
    
    
    if(!visitedViews.value.length) {
    
    
      const item = {
    
    
        path: '/',
        name: 'Index',
        meta: {
    
     title: '主页' },
      }
      tagViewsStore.addVisitedViews(item)
      tagViewsStore.setActivitedView(item)
    }
    if(val) {
    
    
      const obj = currentMenuToObj.value[val];
      defaultActive.value = obj.index;
      tagViewsStore.addVisitedViews(obj.item)
      tagViewsStore.setActivitedView(obj.item)
    }
  }, {
    
    
    immediate: true
  }
)

// 点击菜单栏进行跳转
const onToPage = (item: IRouterItem) => {
    
    
  if(route.path === item.path) return;
  tagViewsStore.addVisitedViews(item)
  tagViewsStore.setActivitedView(item)
  router.push(item.path)
}

</script>

<template>
  <div class="menu">
    <div class="menu-logo">Logo</div>
    <div class="menu-main">
      <el-menu
        :default-active="defaultActive"
        :collapse="isCollapse"
        background-color="#191919"
        text-color="#7e7e7e"
        active-text-color="#ffffff"
      >
        <template v-for="(item, index) in currentMenu" :key="index">
          <el-menu-item :index="'' + index" v-if="!item.children || !item.children.length" @click="onToPage(item)">
            <template #title>{
    
    {
    
     item.meta.title  }}</template>
          </el-menu-item>
          <el-sub-menu :index="'' + index" v-else>
            <template #title>
              <span>{
    
    {
    
     item.meta.title  }}</span>
            </template>
            <el-menu-item v-for="(subItem, subIndex) in item.children" :key="`${index}-${subIndex}`" :index="`${index}-${subIndex}`" @click="onToPage(subItem)">{
    
    {
    
     subItem.meta.title  }}</el-menu-item>
          </el-sub-menu>
        </template>
      </el-menu>
    </div>
  </div>
</template>

<style scoped lang='scss'>
.menu {
    
    
  height: 100vh;
  max-width: 280px;
  background-color: #191919;
  
  &-logo {
    
    
    height: 60px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #fff;
  }

  &-main {
    
    
    height: calc(100vh - 100px);
  }
}
</style>

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

接下来是顶部栏信息:

<script lang='ts' setup>

</script>

<template>
  <div class="nav">
    <div class="nav-left"></div>
    <div class="nav-right">
      <div class="nav-right-item">
        <el-dropdown>
          <span class="el-dropdown-link">
            您好,XXX
          </span>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item>退出登录</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div>
    </div>
  </div>
</template>

<style scoped lang='scss'>
.nav {
      
      
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 16px;
  height: 60px;
  border-bottom: solid 1px var(--el-menu-border-color);
  background-color: #fff;

  &-right{
      
      
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;

    &-item {
      
      
      cursor: pointer;
    }
  }
}
</style>

接下来是关于导航栏tab的开发了,这里我们运用到了pinia来做状态管理,安装之后我们先在plugins/index.ts中注册:

import {
    
     createPinia } from 'pinia';

// ...
// 注册store (建议这个放在所有注册的首位,方便其他插件可能会用到它)
app.use(createPinia());

声明关于导航啦tabstore,在src/store/tagViews.ts中:

import {
    
     defineStore } from 'pinia';
import {
    
     IRouterItem } from '@/types/menu'

export const useTagViewsStore = defineStore('tagViews', {
    
    
  state: () => {
    
    
    return {
    
    
      // 访问过的页面
      visitedViews: [] as IRouterItem[],
      // 当前访问的页面
      activitedView: {
    
    } as IRouterItem
    }
  },

  actions: {
    
    
    // 新增页面
    addVisitedViews(view: IRouterItem){
    
    
      const item = this.visitedViews.find((item) => item.path === view.path)
      if(item) return;
      this.visitedViews.push(view);
    },
    
    // 删除页面
    deleteVisitedViews(index: number) {
    
    
      this.visitedViews.splice(index, 1);
    },
    
    // 高亮某个页面
    setActivitedView(view: IRouterItem) {
    
    
      this.activitedView = view
    }
  }
})

tagViews组件的源码如下:

<script lang='ts' setup>
import {
      
       storeToRefs } from 'pinia';
import {
      
       useRouter } from 'vue-router'
import {
      
       useTagViewsStore } from '@/store/tagViews';
import {
      
       IRouterItem } from '@/types/menu'

const router = useRouter();
const tagViewsStore = useTagViewsStore();
const {
      
       visitedViews, activitedView } = storeToRefs(tagViewsStore);

// 关闭页面
const onDel = (item: IRouterItem) => {
      
      
  const index = visitedViews.value.findIndex((view) => view.path === item.path);
  if(index === -1) return;

  tagViewsStore.deleteVisitedViews(index);
  if(item.path === activitedView.value.path) {
      
      
    const obj = visitedViews.value[index - 1]
    tagViewsStore.setActivitedView(obj);
    router.push(obj.path)
  }
}

// 切换页面
const onChange = (item: IRouterItem) => {
      
      
  tagViewsStore.setActivitedView(item)
  router.push(item.path)
}

</script>

<template>
  <el-scrollbar class="tags-scrollbar">
    <div class="tags">
      <div v-for="item in visitedViews" :key="item.path" :class="['tags-item', { active: activitedView.path === item.path }]" @click="onChange(item)" >
        <span class="tags-item-title">{
   
   { item.meta ? item.meta.title : '' }}</span>
        <el-icon v-if="item.path !== '/'" @click.stop="onDel(item)"><Close /></el-icon>
      </div>
    </div>
  </el-scrollbar>
</template>

<style scoped lang='scss'>
.tags-scrollbar {
      
      
  height: 30px;
  overflow: hidden;
}
.tags {
      
      
  display: flex;
  background: #f3f3f3;
  border: 1px solid #f2f2f2;
  border-right: none;
  margin: -1px 0 0 -1px;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  &-item {
      
      
    display: flex;
    align-items: center;
    position: relative;
    cursor: pointer;
    height: 26px;
    line-height: 26px;
    border: 1px solid #d8dce5;
    color: #495060;
    background: #fff;
    padding: 0 8px;
    font-size: 12px;
    margin-left: 5px;
    margin-top: 4px;

    &-title {
      
      
      margin-right: 4px;
    }

    &:first-of-type {
      
      
      margin-left: 5px;
    }
    &:last-of-type {
      
      
      margin-right: 5px;
    }
    &.active {
      
      
      background-color: #e25050;
      color: #fff;
      border-color: #e25050;
      &::before {
      
      
        content: '';
        background: #fff;
        display: inline-block;
        width: 8px;
        height: 8px;
        border-radius: 50%;
        position: relative;
        margin-right: 2px;
      }
    }
  }
}
</style>

pinia的使用比vuex简单了很多,最大的区别就是*mutations*不再存在了。

路由拦截

一般我们登录之后会将pinia中关于用户的信息进行更新,还会将一些信息进行加密之后存放在localstorage中。未登录的用户我们得拦截他们进入系统,并重定向到登录页面。(实际项目中还得考虑页面权限的拦截问题)

// router/hooks/index.ts
import type {
    
     Router } from 'vue-router'
import {
    
     USERINFO } from '@/constants/localstorage'

const routerHook = (router: Router) => {
    
    
  router.beforeEach(to => {
    
    
    if(to.path === '/login') {
    
    
      // 可以做一些清空登录信息的操作, 比如跟pinia相关的等操作
      localStorage.removeItem(USERINFO);
      return true;
    }else{
    
    
      // 在这里可以判断用户是否登录,跳转的某个页面是否有权限,这里只是粗略写一下
      const info = localStorage.getItem(USERINFO);
      if(!info) {
    
    
        return {
    
     name: 'Login' }
      }
    } 
  })
}

export default routerHook;


import {
    
     createRouter, createWebHashHistory } from 'vue-router'
import routes from './routes'
import routerHook from './hooks/index'

// 注册路由
const router = createRouter({
    
    
  history: createWebHashHistory(),
  routes
})

routerHook(router)

export default router

修改一下登录页面,模拟一下:

const onSubmit = async () => {
    
    
  await formRef.value.validate(async (valid: boolean, fields: {
     
     [key: string]: any}) => {
    
    
    if (valid) {
    
    
      // 一般这种情况下,localStorage中存储的信息不能太重要,且需要加密,还应该更新pinia中的用户信息
      const info = {
    
    
        name: 'Armouy'
      }
      localStorage.setItem(USERINFO, JSON.stringify(info))
      router.push('/')
    } else {
    
    
      console.log('error submit!', fields)
    }
  })
}

未登录情况下都会重定向到登录页面。

打包

npm run build:production
npm run preview

参考链接


如有错误,欢迎指出,感谢阅读~

猜你喜欢

转载自blog.csdn.net/qq_34086980/article/details/131371511