【前端工程化】深入浅出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,所以我得降级一下:typescript@5.0.4,在配置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 postinstallnpx 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中不支持省略.vueextensions: [".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-plusinstallElementPlus(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() 成功使用')// 也可以使用 appContextconsole.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?: stringlabels?: string[]
}
const props = withDefaults(defineProps<Props>(), {msg: 'hello',labels: () => ['one', 'two']
})
</script><!-- 另一种方式 -->
<script setup lang="ts">
interface Book {title?: stringauthor: 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-inputv-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-elsev-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 : realNameresult[envName] = realNameif (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

参考链接

  • Eslint:vue3项目添加eslint(standard规则)
  • vite官网

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/649.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何访问NetApp E系列存储的CLI命令行

NetApp存储的E系列&#xff08;e-series&#xff09;是收购LSI存储而来的&#xff0c;所以这个产品的install base&#xff0c;也就是安装量其实是很大的&#xff0c;因为早期LSI的商业模式就是OEM&#xff0c;给很多的IT公司做过OEM&#xff0c;比较典型的就是IBM的早期的DS存…

浅谈电瓶车充电桩运营方案 安科瑞 许敏

1. 概述 电动车火灾事故频频发生&#xff0c;毫不起眼的电动车屡次引发夺命大火&#xff0c;电动车已然成为火灾“重灾区”。为预防和遏制电动自行车火灾事故发生&#xff0c;三令五申各种政策&#xff0c;为此安委会曾出台《电动自行车集中停放和充电治理方案》。 大部分充电过…

MySQL日志管理、备份与恢复

文章目录 MySQL日志管理、备份与恢复1 MySQL日志管理1.1 日志的分类1.2 日志的配置1.3 日志查询1.3.1 查看通用查询日志是否开启1.3.2 查看二进制日志是否开启1.3.3 查看慢查询日志功能是否开启1.3.4 查看慢查询时间设置1.3.5 在数据库中设置开启慢查询的方法 2 数据备份2.1 数…

freemarker 使用word模板赋值

1. 引包<dependency><groupId>org.freemarker</groupId><artifactId>freemarker</artifactId><version>2.3.28</version></dependency>word文档工具类import freemarker.template.Configuration; import freemarker.template.…

数据库实验-图书销售管理系统数据库安全管理

一、实验二&#xff1a;图书销售管理系统数据库安全管理 三、实验目的 了解该DBMS系统对数据库管理的内容与方法&#xff0c;特别是理解数据库安全机制和作用&#xff0c;以及PostgreSQL数据库角色管理、用户管理、权限管理的基本方法&#xff0c;培养数据库管理能力。在图书…

Mysql(Linux数据库或者在Navicate中)

Mysql数据库组成 服务端:主要存储数据,并接收用户发过来的SQL语句,并执行结果返回给客户端 客户端:下发用户要执行的sql语句,并显示服务器返回的执行结果 命令行数据库连接方式 mysql -h 数据库 IP -P 端口号 -u 数据库登录用户名 -p 数据库登录密码 -h不加表示为本机,-P不…

Java-String、StringBuffer、StringBuilder区别及相关面试题

目录 一、引言二、String类的基本介绍2.1 创建String对象2.2 字符串的拼接和连接2.3 字符串的比较2.4 字符串的截取和替换2.5 字符串的查找和匹配2.6 创建格式化字符串API文档 三、StringBuffer类的基本介绍3.1 创建StringBuffer对象3.2 字符串的拼接和连接3.3 字符串的插入和删…

简单易用多git服务器ssh密钥配置管理

文章目录 前言一、什么是ssh-key二、配置步骤添加ssh-key配置多ssh-key 总结 前言 快速理解如何配置管理多个git服务器的ssh&#xff0c;当我们有多个git帐号时会涉及如何管理不同的remote使用不同的git账户登陆推送 当前repo的origin remote是github&#xff0c;我们在推送时…

pytorch量化库使用(2)

FX Graph Mode量化模式 训练后量化有多种量化类型&#xff08;仅权重、动态和静态&#xff09;&#xff0c;配置通过qconfig_mapping &#xff08; prepare_fx函数的参数&#xff09;完成。 FXPTQ API 示例&#xff1a; import torch from torch.ao.quantization import (ge…

【MySQL数据库 | 第十九篇】SQL性能分析工具

目录 前言&#xff1a; SQL执行频率&#xff1a; 慢查询日志&#xff1a; profile&#xff1a; profile各个指令&#xff1a; 总结&#xff1a; 前言&#xff1a; 本篇我们将为大家讲解SQL性能的分析工具&#xff0c;而只有熟练的掌握了性能分析的工具&#xff0c;才可以更…

C语言学习(二十九)---内存操作函数

在上一节内容中&#xff0c;我们学习了有关字符串操作的函数&#xff0c;其中分为了限制长度和不限制长度两种方式&#xff0c;虽然上节内容已经在很大程度上有助于程序的实现&#xff0c;但是其有一个致命的缺陷&#xff0c;聪明的你一定已经猜到了吧&#xff0c;对的&#xf…

一面、二面、三面有什么区别?

很多公司面试都分一面、二面、三面甚至更多&#xff0c;大家可能会好奇&#xff0c;为什么要面这么多面&#xff0c;每一面又有啥区别呢&#xff1f; 首先我来回答下为什么要这么多面&#xff0c;最核心的是最后3点&#xff1a; 如果光是一个人面&#xff0c;担心会看走眼&…