安装基础包
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.scss
和mixins.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
中设置baseUrl
和paths
值。
由于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
,如Vue
的ref
,不需要手动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
这里需要注意一下:
- 不是全部 API,例如 Vue Router 的
createRouter
就不会导入。具体可以自动导入的 API 参考 unplugin-auto-import/src/presets - 生成
.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());
声明关于导航啦tab
的store
,在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官网
如有错误,欢迎指出,感谢阅读~