全前端需要的工程化能力之 Vue3 + TypeScript + Vite 工程化项目搭建最佳实践

大家好,我是鱼樱!!!

关注公众号【鱼樱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后台管理系统项目。

哥哥姐姐弟弟妹妹们都看到这里不给个赞👍🏻

相关推荐
mldong20 分钟前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排1 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马6 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端