大家好,我是鱼樱!!!
关注公众号【鱼樱AI实验室】
持续每天分享更多前端和AI辅助前端编码新知识~~喜欢的就一起学反正开源至上,无所谓被诋毁被喷被质疑文章没有价值~
一个城市淘汰的自由职业-农村前端程序员(虽然不靠代码挣钱,写文章就是为爱发电),兼职远程上班目前!!!热心坚持分享~~~
今天大家分享一个0-1搭建后台管理系统的最佳实践模板思路~ 并且推荐一个双越老师的划水AI项目 有需要的可以私我走优惠通道~
目录
项目初始化
1. 创建项目
bash
# 使用Vite创建项目
npm create vite@latest my-vue-admin -- --template vue-ts
# 进入项目目录
cd my-vue-admin
# 安装依赖
npm install
2. 目录结构规划
csharp
├── public # 静态资源
├── src
│ ├── assets # 静态资源
│ ├── components # 公共组件
│ ├── hooks # 自定义hooks
│ ├── layout # 布局组件
│ ├── router # 路由配置
│ ├── store # 状态管理
│ ├── styles # 全局样式
│ ├── utils # 工具函数
│ ├── views # 页面
│ ├── api # API接口
│ ├── directives # 自定义指令
│ ├── constants # 常量定义
│ ├── types # TypeScript类型
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ └── env.d.ts # 环境变量类型声明
├── .eslintrc.js # ESLint配置
├── .prettierrc.js # Prettier配置
├── .editorconfig # 编辑器配置
├── tsconfig.json # TypeScript配置
├── vite.config.ts # Vite配置
└── package.json # 项目依赖
代码规范配置
1. ESLint配置
bash
# 安装ESLint及相关插件
npm install -D eslint eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier
创建.eslintrc.js
:
javascript
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2021: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2021,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
plugins: ['vue', '@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
}
2. Prettier配置
bash
# 安装Prettier
npm install -D prettier eslint-config-prettier eslint-plugin-prettier
创建.prettierrc.js
:
javascript
module.exports = {
printWidth: 100,
tabWidth: 2,
useTabs: false,
semi: false,
singleQuote: true,
quoteProps: 'as-needed',
jsxSingleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
jsxBracketSameLine: false,
arrowParens: 'avoid',
rangeStart: 0,
rangeEnd: Infinity,
requirePragma: false,
insertPragma: false,
proseWrap: 'preserve',
htmlWhitespaceSensitivity: 'css',
endOfLine: 'lf',
}
3. StyleLint配置
bash
# 安装StyleLint
npm install -D stylelint stylelint-config-standard stylelint-config-prettier stylelint-config-recommended-vue postcss-html
创建.stylelintrc.js
:
javascript
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-prettier',
'stylelint-config-recommended-vue',
],
rules: {
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['deep', 'global'],
},
],
'selector-pseudo-element-no-unknown': [
true,
{
ignorePseudoElements: ['v-deep', 'v-global', 'v-slotted'],
},
],
},
}
Git提交规范
1. Husky + lint-staged配置
bash
# 安装husky和lint-staged
npm install -D husky lint-staged
# 初始化husky
npx husky install
npm set-script prepare "husky install"
# 添加pre-commit钩子
npx husky add .husky/pre-commit "npx lint-staged"
配置package.json
:
json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.vue": [
"eslint --fix",
"prettier --write",
"stylelint --fix"
],
"*.{css,scss,less}": [
"stylelint --fix",
"prettier --write"
]
}
}
2. Commitlint规范
bash
# 安装commitlint
npm install -D @commitlint/cli @commitlint/config-conventional
# 添加commit-msg钩子
npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1"
创建commitlint.config.js
:
javascript
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert', 'build'],
],
'subject-case': [0],
},
}
编辑器配置
1. VSCode配置
创建.editorconfig
:
ini
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
2. VSCode插件推荐
创建.vscode/extensions.json
:
json
{
"recommendations": [
"vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"editorconfig.editorconfig",
"streetsidesoftware.code-spell-checker",
"adpyke.vscode-sql-formatter"
]
}
创建.vscode/settings.json
:
json
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"],
"typescript.tsdk": "node_modules/typescript/lib",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
样式规范
1. CSS预处理器
bash
# 安装SCSS
npm install -D sass
2. 全局样式文件
创建src/styles/index.scss
:
scss
// 导入变量和混合器
@import './variables.scss';
@import './mixins.scss';
// 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: $text-color;
background-color: $bg-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 通用样式类
.flex {
display: flex;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
// 固定宽度容器
.container {
width: 1200px;
margin: 0 auto;
}
创建src/styles/variables.scss
:
scss
// 主题色
$primary-color: #1890ff;
$success-color: #52c41a;
$warning-color: #faad14;
$error-color: #f5222d;
// 文字颜色
$text-color: #333333;
$text-color-secondary: #666666;
$text-color-light: #999999;
// 背景色
$bg-color: #f0f2f5;
$bg-color-light: #fafafa;
// 边框色
$border-color: #e8e8e8;
// 阴影
$box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
// 断点
$screen-xs: 480px;
$screen-sm: 576px;
$screen-md: 768px;
$screen-lg: 992px;
$screen-xl: 1200px;
$screen-xxl: 1600px;
创建src/styles/mixins.scss
:
scss
// 文本溢出省略号
@mixin ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// 多行文本溢出省略号
@mixin multi-ellipsis($lines) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $lines;
overflow: hidden;
}
// 响应式媒体查询
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: $screen-xs) {
@content;
}
} @else if $breakpoint == sm {
@media (max-width: $screen-sm) {
@content;
}
} @else if $breakpoint == md {
@media (max-width: $screen-md) {
@content;
}
} @else if $breakpoint == lg {
@media (max-width: $screen-lg) {
@content;
}
} @else if $breakpoint == xl {
@media (max-width: $screen-xl) {
@content;
}
}
}
3. CSS模块化
在vite.config.ts
中配置:
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss"; @import "@/styles/mixins.scss";`,
},
},
modules: {
localsConvention: 'camelCaseOnly',
},
},
})
Axios封装
1. 安装依赖
bash
npm install axios qs
2. 创建HTTP实例和拦截器
创建src/utils/http/index.ts
:
typescript
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import qs from 'qs'
import { useUserStore } from '@/store/user'
import { useAppStore } from '@/store/app'
import router from '@/router'
import { showToast, closeToast } from '@/utils/toast'
// 创建axios实例
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL as string,
timeout: 15000,
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
})
// 请求映射表,用于取消重复请求
const pendingMap = new Map()
/**
* 生成请求Key
*/
function generateRequestKey(config: AxiosRequestConfig): string {
const { method, url, params, data } = config
return [method, url, qs.stringify(params), qs.stringify(data)].join('&')
}
/**
* 添加请求到pendingMap
*/
function addPendingRequest(config: AxiosRequestConfig): void {
if (config.cancelRequest === false) return
const requestKey = generateRequestKey(config)
config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
if (!pendingMap.has(requestKey)) {
pendingMap.set(requestKey, cancel)
}
})
}
/**
* 移除请求从pendingMap
*/
function removePendingRequest(config: AxiosRequestConfig): void {
if (config.cancelRequest === false) return
const requestKey = generateRequestKey(config)
if (pendingMap.has(requestKey)) {
const cancelToken = pendingMap.get(requestKey)
cancelToken(requestKey)
pendingMap.delete(requestKey)
}
}
// 添加请求拦截器
http.interceptors.request.use(
(config: AxiosRequestConfig) => {
const appStore = useAppStore()
const userStore = useUserStore()
// 显示全局loading
if (config.showLoading !== false) {
appStore.setLoading(true)
}
// 显示局部loading
if (config.loadingTarget) {
appStore.addLoadingTarget(config.loadingTarget)
}
// 取消重复请求
removePendingRequest(config)
addPendingRequest(config)
// 添加token
const token = userStore.token
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
}
}
return config
},
(error: AxiosError) => {
const appStore = useAppStore()
appStore.setLoading(false)
return Promise.reject(error)
}
)
// 添加响应拦截器
http.interceptors.response.use(
(response: AxiosResponse) => {
const appStore = useAppStore()
const config = response.config
// 请求完成后移除请求
removePendingRequest(config)
// 关闭loading
if (config.showLoading !== false) {
appStore.setLoading(false)
}
// 关闭局部loading
if (config.loadingTarget) {
appStore.removeLoadingTarget(config.loadingTarget)
}
// 处理二进制数据
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data
}
const { code, data, message } = response.data
// 业务逻辑错误
if (code !== 0 && code !== 200) {
if (config.showErrorMessage !== false) {
showToast(message || '请求失败', 'error')
}
return Promise.reject(new Error(message || '请求失败'))
}
// 缓存数据
if (config.cache) {
const cacheKey = config.cacheKey || generateRequestKey(config)
const cacheTime = config.cacheTime || 60 * 5 // 默认缓存5分钟
const cacheData = {
data,
expire: Date.now() + cacheTime * 1000
}
localStorage.setItem(`http_cache_${cacheKey}`, JSON.stringify(cacheData))
}
return data
},
(error: AxiosError) => {
const appStore = useAppStore()
const userStore = useUserStore()
// 关闭所有loading
appStore.setLoading(false)
appStore.clearLoadingTargets()
// 取消请求不报错
if (axios.isCancel(error)) {
return Promise.reject(error)
}
const config = error.config || {}
// 请求完成后移除请求
if (config) {
removePendingRequest(config)
}
// 处理401未授权
if (error.response?.status === 401) {
userStore.logout()
router.push('/login')
showToast('登录已过期,请重新登录', 'warning')
return Promise.reject(error)
}
// 处理网络错误
if (!navigator.onLine) {
showToast('网络已断开,请检查网络连接', 'error')
return Promise.reject(new Error('网络已断开,请检查网络连接'))
}
// 处理超时
if (error.message.includes('timeout')) {
showToast('请求超时,请稍后重试', 'error')
return Promise.reject(new Error('请求超时,请稍后重试'))
}
// 处理其他错误
if (config.showErrorMessage !== false) {
showToast(error.message || '请求失败', 'error')
}
return Promise.reject(error)
}
)
/**
* 扩展配置选项
*/
interface HttpRequestConfig extends AxiosRequestConfig {
showLoading?: boolean // 是否显示全局loading
loadingTarget?: string // 局部loading的目标元素
showErrorMessage?: boolean // 是否显示错误提示
cancelRequest?: boolean // 是否取消重复请求
cache?: boolean // 是否缓存数据
cacheKey?: string // 缓存key
cacheTime?: number // 缓存时间(秒)
retry?: number // 重试次数
retryDelay?: number // 重试延迟
}
/**
* 请求方法封装
*/
const request = async <T = any>(config: HttpRequestConfig): Promise<T> => {
// 处理缓存
if (config.cache) {
const cacheKey = config.cacheKey || generateRequestKey(config)
const cacheData = localStorage.getItem(`http_cache_${cacheKey}`)
if (cacheData) {
try {
const { data, expire } = JSON.parse(cacheData)
// 缓存未过期
if (expire > Date.now()) {
return data as T
} else {
// 缓存已过期,删除缓存
localStorage.removeItem(`http_cache_${cacheKey}`)
}
} catch (e) {
console.error('缓存数据解析错误', e)
}
}
}
// 处理重试逻辑
const { retry = 0, retryDelay = 300 } = config
let retryCount = 0
const executeRequest = async (): Promise<T> => {
try {
return await http.request<any, T>(config)
} catch (err) {
if (retryCount < retry) {
retryCount++
// 延迟重试
await new Promise(resolve => setTimeout(resolve, retryDelay))
return executeRequest()
}
return Promise.reject(err)
}
}
return executeRequest()
}
// 常用请求方法封装
const http = {
get: <T = any>(url: string, params?: object, config?: HttpRequestConfig) =>
request<T>({ method: 'get', url, params, ...config }),
post: <T = any>(url: string, data?: object, config?: HttpRequestConfig) =>
request<T>({ method: 'post', url, data, ...config }),
put: <T = any>(url: string, data?: object, config?: HttpRequestConfig) =>
request<T>({ method: 'put', url, data, ...config }),
delete: <T = any>(url: string, params?: object, config?: HttpRequestConfig) =>
request<T>({ method: 'delete', url, params, ...config }),
download: (url: string, params?: object, config?: HttpRequestConfig) =>
request<Blob>({
method: 'get',
url,
params,
responseType: 'blob',
...config
}),
upload: <T = any>(url: string, file: File | FormData, config?: HttpRequestConfig) => {
let formData: FormData
if (file instanceof FormData) {
formData = file
} else {
formData = new FormData()
formData.append('file', file)
}
return request<T>({
method: 'post',
url,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
...config
})
},
// 通用请求方法
request
}
export default http
3. 创建Toast组件
创建src/utils/toast.ts
:
typescript
import { createVNode, render } from 'vue'
import Toast from '@/components/Toast.vue'
let toastInstance: any = null
export type ToastType = 'info' | 'success' | 'warning' | 'error'
interface ToastOptions {
message: string
type?: ToastType
duration?: number
onClose?: () => void
}
export function showToast(message: string | ToastOptions, type: ToastType = 'info'): void {
// 关闭已存在的Toast
if (toastInstance) {
closeToast()
}
// 处理参数
const options: ToastOptions = typeof message === 'string'
? { message, type }
: { ...message }
// 创建容器
const container = document.createElement('div')
container.className = 'toast-container'
// 创建VNode
const vnode = createVNode(Toast, {
...options,
onDestroy: () => {
render(null, container)
document.body.removeChild(container)
toastInstance = null
options.onClose?.()
}
})
// 渲染
render(vnode, container)
document.body.appendChild(container)
// 保存实例
toastInstance = vnode.component
}
export function closeToast(): void {
if (toastInstance) {
toastInstance.exposed.close()
}
}
创建src/components/Toast.vue
:
vue
<template>
<Transition name="toast-fade">
<div v-if="visible" class="toast" :class="[`toast-${type}`]">
<div class="toast-icon" v-if="type">
<i class="icon" :class="iconClass"></i>
</div>
<div class="toast-content">{{ message }}</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = withDefaults(defineProps<{
message: string
type?: 'info' | 'success' | 'warning' | 'error'
duration?: number
}>(), {
type: 'info',
duration: 3000
})
const visible = ref(false)
let timer: number | null = null
const iconClass = computed(() => {
switch (props.type) {
case 'success': return 'icon-success'
case 'warning': return 'icon-warning'
case 'error': return 'icon-error'
default: return 'icon-info'
}
})
const emit = defineEmits(['destroy'])
function close() {
visible.value = false
setTimeout(() => {
emit('destroy')
}, 300) // 等待动画结束
}
onMounted(() => {
visible.value = true
if (props.duration > 0) {
timer = window.setTimeout(() => {
close()
}, props.duration)
}
})
onBeforeUnmount(() => {
if (timer) {
clearTimeout(timer)
}
})
defineExpose({
close
})
</script>
<style scoped lang="scss">
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 300px;
max-width: 80%;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
background-color: #fff;
z-index: 9999;
display: flex;
align-items: center;
&-icon {
margin-right: 10px;
font-size: 20px;
}
&-content {
font-size: 14px;
line-height: 1.5;
}
&-info {
.toast-icon {
color: $primary-color;
}
}
&-success {
.toast-icon {
color: $success-color;
}
}
&-warning {
.toast-icon {
color: $warning-color;
}
}
&-error {
.toast-icon {
color: $error-color;
}
}
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
transform: translate(-50%, -20px);
}
</style>
4. 创建Loading组件
创建src/components/Loading.vue
:
vue
<template>
<div class="loading-wrapper" v-if="visible" :class="{ 'fullscreen': fullscreen }">
<div class="loading">
<div class="loading-spinner">
<div class="loading-circle"></div>
</div>
<div class="loading-text" v-if="text">{{ text }}</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
fullscreen?: boolean
text?: string
}>()
</script>
<style scoped lang="scss">
.loading-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
&.fullscreen {
position: fixed;
z-index: 9999;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
&-spinner {
width: 40px;
height: 40px;
position: relative;
}
&-circle {
width: 100%;
height: 100%;
border: 3px solid transparent;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 1s linear infinite;
}
&-text {
margin-top: 10px;
font-size: 14px;
color: $text-color;
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
权限设计
1. 用户状态管理
使用Pinia创建src/store/user.ts
:
typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { UserInfo, LoginParams } from '@/types/user'
import { login, logout, getUserInfo } from '@/api/user'
export const useUserStore = defineStore('user', () => {
const token = ref<string>(localStorage.getItem('token') || '')
const userInfo = ref<UserInfo | null>(null)
const permissions = ref<string[]>([])
const roles = ref<string[]>([])
// 登录
const loginAction = async (params: LoginParams) => {
try {
const data = await login(params)
token.value = data.token
localStorage.setItem('token', data.token)
await getUserInfoAction()
return data
} catch (error) {
return Promise.reject(error)
}
}
// 获取用户信息
const getUserInfoAction = async () => {
try {
const data = await getUserInfo()
userInfo.value = data
permissions.value = data.permissions || []
roles.value = data.roles || []
return data
} catch (error) {
return Promise.reject(error)
}
}
// 退出登录
const logoutAction = async () => {
try {
await logout()
} finally {
resetUserState()
}
}
// 重置用户状态
const resetUserState = () => {
token.value = ''
userInfo.value = null
permissions.value = []
roles.value = []
localStorage.removeItem('token')
}
// 检查是否有权限
const hasPermission = (permission: string) => {
return permissions.value.includes(permission)
}
// 检查是否有某个角色
const hasRole = (role: string) => {
return roles.value.includes(role)
}
return {
token,
userInfo,
permissions,
roles,
loginAction,
logoutAction,
getUserInfoAction,
hasPermission,
hasRole,
resetUserState
}
})
2. 路由权限控制
创建src/router/index.ts
:
typescript
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/store/user'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { showToast } from '@/utils/toast'
// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', hidden: true }
},
{
path: '/404',
name: '404',
component: () => import('@/views/error/404.vue'),
meta: { title: '404', hidden: true }
},
{
path: '/',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'dashboard' }
}
]
}
]
// 异步路由(需要根据权限动态加载)
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/system',
component: () => import('@/layout/index.vue'),
redirect: '/system/user',
meta: { title: '系统管理', icon: 'system', roles: ['admin'] },
children: [
{
path: 'user',
name: 'User',
component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户管理', permissions: ['system:user:list'] }
},
{
path: 'role',
name: 'Role',
component: () => import('@/views/system/role/index.vue'),
meta: { title: '角色管理', permissions: ['system:role:list'] }
}
]
},
// 通配符路由,必须放在最后
{ path: '/:pathMatch(.*)*', redirect: '/404', meta: { hidden: true } }
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
// 白名单
const whiteList = ['/login', '/404']
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
NProgress.start()
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
const userStore = useUserStore()
const token = userStore.token
if (token) {
if (to.path === '/login') {
// 已登录,跳转到首页
next({ path: '/' })
NProgress.done()
} else {
if (userStore.userInfo) {
next()
} else {
try {
// 获取用户信息
await userStore.getUserInfoAction()
// 动态添加路由
const accessRoutes = filterAsyncRoutes(asyncRoutes, userStore)
accessRoutes.forEach(route => {
router.addRoute(route)
})
// 重定向到同一个路由会导致死循环,所以需要判断
next({ ...to, replace: true })
} catch (error) {
// 用户信息获取失败,登出
userStore.resetUserState()
showToast('登录状态已过期,请重新登录', 'error')
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
NProgress.done()
}
}
}
} else {
// 未登录
if (whiteList.includes(to.path)) {
next()
} else {
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
NProgress.done()
}
}
})
// 全局后置守卫
router.afterEach(() => {
NProgress.done()
})
/**
* 过滤异步路由
*/
function filterAsyncRoutes(routes: RouteRecordRaw[], userStore: any): RouteRecordRaw[] {
const res: RouteRecordRaw[] = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(tmp, userStore)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, userStore)
}
res.push(tmp)
}
})
return res
}
/**
* 检查路由权限
*/
function hasPermission(route: RouteRecordRaw, userStore: any): boolean {
if (route.meta) {
// 检查角色权限
if (route.meta.roles) {
return route.meta.roles.some(role => userStore.roles.includes(role))
}
// 检查细粒度权限
if (route.meta.permissions) {
return route.meta.permissions.some(permission => userStore.permissions.includes(permission))
}
}
return true
}
export default router
3. 权限指令
创建src/directives/permission.ts
:
typescript
import type { App, Directive } from 'vue'
import { useUserStore } from '@/store/user'
/**
* 权限指令
* 使用方法:v-permission="'system:user:add'"
* 或者:v-permission="['system:user:add', 'system:user:edit']"
*/
function checkPermission(el: HTMLElement, binding: any) {
const userStore = useUserStore()
const { value } = binding
if (!value) return
if (Array.isArray(value)) {
if (value.length > 0) {
const hasPermission = value.some(permission => userStore.hasPermission(permission))
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
}
} else {
if (!userStore.hasPermission(value)) {
el.parentNode?.removeChild(el)
}
}
}
/**
* 角色指令
* 使用方法:v-role="'admin'"
* 或者:v-role="['admin', 'editor']"
*/
function checkRole(el: HTMLElement, binding: any) {
const userStore = useUserStore()
const { value } = binding
if (!value) return
if (Array.isArray(value)) {
if (value.length > 0) {
const hasRole = value.some(role => userStore.hasRole(role))
if (!hasRole) {
el.parentNode?.removeChild(el)
}
}
} else {
if (!userStore.hasRole(value)) {
el.parentNode?.removeChild(el)
}
}
}
const permissionDirective: Directive = {
mounted(el, binding) {
checkPermission(el, binding)
},
updated(el, binding) {
checkPermission(el, binding)
}
}
const roleDirective: Directive = {
mounted(el, binding) {
checkRole(el, binding)
},
updated(el, binding) {
checkRole(el, binding)
}
}
export function setupPermissionDirectives(app: App) {
app.directive('permission', permissionDirective)
app.directive('role', roleDirective)
}
状态管理
1. Pinia配置
创建src/store/index.ts
:
typescript
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
2. 应用状态
创建src/store/app.ts
:
typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
// 全局loading状态
const loading = ref<boolean>(false)
// 局部loading状态(目标DOM的id或class)
const loadingTargets = ref<Set<string>>(new Set())
// 侧边栏状态
const sidebarCollapsed = ref<boolean>(false)
// 设置全局loading
const setLoading = (status: boolean) => {
loading.value = status
}
// 添加局部loading
const addLoadingTarget = (target: string) => {
loadingTargets.value.add(target)
}
// 移除局部loading
const removeLoadingTarget = (target: string) => {
loadingTargets.value.delete(target)
}
// 清空所有局部loading
const clearLoadingTargets = () => {
loadingTargets.value.clear()
}
// 切换侧边栏状态
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return {
loading,
loadingTargets,
sidebarCollapsed,
setLoading,
addLoadingTarget,
removeLoadingTarget,
clearLoadingTargets,
toggleSidebar
}
}, {
persist: {
paths: ['sidebarCollapsed']
}
})
实用工具库
1. VueUse
bash
# 安装VueUse
npm install @vueuse/core
2. 常用工具函数
创建src/utils/common.ts
:
typescript
/**
* 深度克隆对象
* @param obj 需要克隆的对象
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as unknown as T
}
if (obj instanceof Object) {
const copy = {} as Record<string, any>
Object.keys(obj).forEach(key => {
copy[key] = deepClone((obj as Record<string, any>)[key])
})
return copy as T
}
return obj
}
/**
* 防抖函数
* @param fn 需要防抖的函数
* @param delay 延迟时间(ms)
*/
export function debounce<T extends (...args: any[]) => any>(fn: T, delay = 300): (...args: Parameters<T>) => void {
let timer: number | null = null
return function(this: any, ...args: Parameters<T>) {
if (timer) {
clearTimeout(timer)
}
timer = window.setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
/**
* 节流函数
* @param fn 需要节流的函数
* @param delay 延迟时间(ms)
*/
export function throttle<T extends (...args: any[]) => any>(fn: T, delay = 300): (...args: Parameters<T>) => void {
let lastTime = 0
return function(this: any, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= delay) {
fn.apply(this, args)
lastTime = now
}
}
}
/**
* 格式化日期
* @param date 日期
* @param format 格式
*/
export function formatDate(date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss'): string {
const d = new Date(date)
const year = d.getFullYear()
const month = d.getMonth() + 1
const day = d.getDate()
const hour = d.getHours()
const minute = d.getMinutes()
const second = d.getSeconds()
const padZero = (num: number) => num.toString().padStart(2, '0')
return format
.replace(/YYYY/g, year.toString())
.replace(/MM/g, padZero(month))
.replace(/DD/g, padZero(day))
.replace(/HH/g, padZero(hour))
.replace(/mm/g, padZero(minute))
.replace(/ss/g, padZero(second))
}
/**
* 文件大小格式化
* @param size 文件大小(字节)
*/
export function formatFileSize(size: number): string {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KB`
} else if (size < 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(2)} MB`
} else {
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
}
3. 日期处理-dayjs
bash
# 安装dayjs
npm install dayjs
创建src/utils/date.ts
:
typescript
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import weekday from 'dayjs/plugin/weekday'
import localeData from 'dayjs/plugin/localeData'
// 设置语言
dayjs.locale('zh-cn')
// 加载插件
dayjs.extend(relativeTime)
dayjs.extend(weekday)
dayjs.extend(localeData)
/**
* 格式化日期
* @param date 日期
* @param format 格式
*/
export function formatDate(date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss'): string {
return dayjs(date).format(format)
}
/**
* 相对时间
* @param date 日期
* @param withoutSuffix 是否不带后缀
*/
export function fromNow(date: Date | string | number, withoutSuffix = false): string {
return dayjs(date).fromNow(withoutSuffix)
}
/**
* 两个日期之间的差异
* @param date1 日期1
* @param date2 日期2
* @param unit 单位
*/
export function diff(date1: Date | string | number, date2: Date | string | number, unit: 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' = 'day'): number {
return dayjs(date1).diff(dayjs(date2), unit)
}
/**
* 获取一天的开始时间
* @param date 日期
*/
export function startOfDay(date: Date | string | number): Date {
return dayjs(date).startOf('day').toDate()
}
/**
* 获取一天的结束时间
* @param date 日期
*/
export function endOfDay(date: Date | string | number): Date {
return dayjs(date).endOf('day').toDate()
}
export default dayjs
构建与部署
1. Vite生产环境配置
创建.env.production
:
ini
# 生产环境
NODE_ENV=production
VITE_API_BASE_URL=/api
创建.env.development
:
ini
# 开发环境
NODE_ENV=development
VITE_API_BASE_URL=/dev-api
2. 多环境配置
修改vite.config.ts
:
typescript
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
import viteCompression from 'vite-plugin-compression'
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd())
return defineConfig({
plugins: [
vue(),
// gzip压缩
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz',
}),
// 打包分析
mode === 'analyze' && visualizer({
open: true,
gzipSize: true,
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss"; @import "@/styles/mixins.scss";`,
},
},
},
server: {
port: 3000,
open: true,
proxy: {
'/dev-api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dev-api/, ''),
},
},
},
build: {
target: 'es2015',
outDir: 'dist',
assetsDir: 'assets',
minify: 'terser',
terserOptions: {
compress: {
drop_console: env.VITE_DROP_CONSOLE === 'true',
drop_debugger: true,
},
},
rollupOptions: {
output: {
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus'],
},
},
},
},
})
}
3. Docker部署
创建Dockerfile
:
dockerfile
FROM node:16-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set registry https://registry.npm.taobao.org/ \
&& npm install
COPY . .
RUN npm run build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
创建nginx.conf
:
nginx
server {
listen 80;
server_name localhost;
# 开启gzip
gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend-service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
创建.dockerignore
:
bash
node_modules
dist
.git
.github
.vscode
*.log
4. 持续集成配置
创建.github/workflows/deploy.yml
:
yaml
name: Deploy
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
source: "dist/"
target: "/var/www/my-vue-admin"
- name: Execute remote commands
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /var/www/my-vue-admin
rm -rf /var/www/html/*
cp -r dist/* /var/www/html/
项目最终配置
修改package.json
:
json
{
"name": "my-vue-admin",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:dev": "vue-tsc --noEmit && vite build --mode development",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"analyze": "vue-tsc --noEmit && vite build --mode analyze",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx --fix",
"lint:style": "stylelint \"**/*.{vue,css,scss}\" --fix",
"prepare": "husky install",
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "@commitlint/cz-commitlint"
}
},
"dependencies": {
"axios": "^1.3.4",
"dayjs": "^1.11.7",
"element-plus": "^2.3.0",
"nprogress": "^0.2.0",
"pinia": "^2.0.33",
"pinia-plugin-persistedstate": "^3.1.0",
"qs": "^6.11.1",
"vue": "^3.2.47",
"vue-router": "^4.1.6",
"@vueuse/core": "^9.13.0"
},
"devDependencies": {
"@commitlint/cli": "^17.5.0",
"@commitlint/config-conventional": "^17.4.4",
"@commitlint/cz-commitlint": "^17.5.0",
"@types/node": "^18.15.5",
"@types/nprogress": "^0.2.0",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"@vitejs/plugin-vue": "^4.1.0",
"commitizen": "^4.3.0",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.10.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.0",
"postcss-html": "^1.5.0",
"prettier": "^2.8.6",
"rollup-plugin-visualizer": "^5.9.0",
"sass": "^1.59.3",
"stylelint": "^15.3.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^31.0.0",
"terser": "^5.16.8",
"typescript": "^5.0.2",
"vite": "^4.2.1",
"vite-plugin-compression": "^0.5.1",
"vue-tsc": "^1.2.0"
}
}
总结
本文档详细介绍了从0到1搭建Vue3+TypeScript+Vite工程化项目的最佳实践,包括:
- 项目初始化:使用Vite创建项目并规划目录结构
- 代码规范配置:配置ESLint、Prettier和StyleLint
- Git提交规范:使用Husky、lint-staged和Commitlint
- 编辑器配置:统一编辑器设置
- 样式规范:使用SCSS预处理器和全局变量
- Axios封装:实现请求拦截、响应拦截、错误处理、取消重复请求等功能
- 权限设计:基于角色和权限控制路由和界面元素
- 状态管理:使用Pinia管理全局状态
- 实用工具库:集成VueUse、dayjs等工具库
- 构建与部署:配置多环境构建和Docker部署
通过遵循以上最佳实践,可以快速构建一个高质量、可维护的Vue3后台管理系统项目。
哥哥姐姐弟弟妹妹们都看到这里不给个赞👍🏻