全前端需要的工程化能力之 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后台管理系统项目。

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

相关推荐
牛牪犇01 小时前
如何搭建一个适配微信小程序,h5,app的uni-app项目
前端·微信小程序·小程序·前端框架
祈澈菇凉2 小时前
React 中如何实现表单的受控组件?
前端·javascript·react.js
计算机-秋大田3 小时前
基于Spring Boot的国产动漫网站的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
有什么东东4 小时前
力扣练习之确定两个字符串是否接近
前端·算法·leetcode
明远湖之鱼4 小时前
手把手带你实现 Vite+React 的简易 SSR 改造【含部分原理讲解】
前端·react.js·vite
野生的程序媛4 小时前
重生之我在学Vue--第10天 Vue 3 项目收尾与部署
前端·javascript·vue.js
qq_35323353894 小时前
【原创】springboot+vue智能办公管理系统设计与实现
vue.js·spring boot·后端
大叔_爱编程5 小时前
wx125基于ssm+vue+uniapp的校园商铺系统小程序
vue.js·小程序·uni-app·毕业设计·ssm·源码·课程设计
烟锁池塘柳05 小时前
技术栈的概念及其组成部分的介绍
前端·后端·web