安装基础包
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 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.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-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());
声明关于导航啦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官网
如有错误,欢迎指出,感谢阅读~