Vue 3 + TypeScript 项目快速参考

目录


1. 项目技术栈

1.1 核心依赖

类别

技术选型

说明

框架

Vue 3

Composition API + <script setup> 语法

语言

TypeScript

strict 模式,禁止 any

构建

Vite

使用 vite@5+

路由

Vue Router 4

基于文件或配置的路由管理

状态管理

Pinia

替代 Vuex,模块化 store

HTTP 请求

Axios

统一封装实例

CSS 方案

UnoCSS / Tailwind CSS

原子化 CSS 优先,辅以 scoped style

包管理器

pnpm

优先使用 pnpm,禁用 npm/yarn

代码规范

ESLint + Prettier

统一格式化

提交规范

commitlint + husky

Conventional Commits

测试

Vitest + @vue/test-utils

单元测试与组件测试

E2E 测试

Playwright

端到端测试

1.2 可选依赖(按需引入)

  • 数据获取:@tanstack/vue-query(SWR 模式,缓存 + 自动重取)

  • 表格:TanStack Table

  • 表单:VeeValidate + Zod(校验逻辑与 UI 解耦)

  • UI 组件库 — PC 端

    • Element Plus:Vue 3 官方推荐,成熟稳定,生态最完善,适合后台管理系统

    • Naive UI:TypeScript 友好,按需加载原生支持,主题定制灵活,适合中后台

    • Ant Design Vue:Ant Design 设计语言,适合需要 AntD 风格的项目

    • Arco Design Vue:字节跳动出品,支持暗色模式,适合中后台

  • UI 组件库 — 移动端

    • Vant:有赞出品,Vue 3 生态最成熟的移动端库,适合 H5 / 移动商城

    • NutUI:京东出品,支持按需加载,适合移动端电商场景

    • Varlet:Material Design 风格,社区活跃,适合移动端通用场景

    • TDesign Mobile:腾讯出品,多端统一设计,适合企业级移动应用

  • 图标:unplugin-icons + Iconify(按需加载,避免打包整个图标库)

  • 日期:dayjs(轻量)

  • 富文本:Tiptap / Quill

  • 图表:ECharts / Chart.js

  • 拖拽:vue-draggable-plus

  • 动画:@vueuse/motion / GSAP

  • 工具函数:@vueuse/core(Vue 生态工具集)

1.3 智能体启动前交互规范

在生成项目骨架、选择依赖或编写任何代码之前,智能体必须主动询问以下问题。禁止自行假设。

必须询问的问题(按顺序):
  1. 目标平台: PC 端 / 移动端 H5 / 同时适配(自适应或响应式)
     - 依据回答选择对应的 UI 组件库(PC 选 Element Plus/Naive UI 等,移动端选 Vant/NutUI 等)
     - 如果同时适配,需明确策略(独立路由/独立页面 vs 同一套响应式布局)
  2. UI 组件库偏好: 给出一份简短对比(如已确认平台则从该平台的候选库中选择)
     - 如用户无偏好,PC 端默认 Element Plus,移动端默认 Vant
  3. 项目类型: 后台管理系统 / 面向用户的前台 / 混合
     - 影响路由设计(后台用扁平结构,前台用 SEO 友好的嵌套结构)
     - 影响是否需要 SSR/SSG(Vue 项目选 Nuxt 3 或纯 SPA)

2. 项目目录结构

2.1 根目录结构

project-root/
├── public/                    # 静态资源(不经过构建处理)
├── src/
│   ├── api/                   # API 请求层
│   │   ├── request.ts         # Axios 实例封装
│   │   ├── modules/           # 按业务模块拆分的 API
│   │   │   ├── user.ts
│   │   │   ├── auth.ts
│   │   │   └── dashboard.ts
│   │   └── index.ts           # 统一导出
│   ├── assets/                # 静态资源(经构建处理)
│   │   ├── images/
│   │   ├── fonts/
│   │   └── svg/
│   ├── components/            # 全局共享组件
│   │   ├── base/              # 基础 UI 组件(Button/Input/Modal 等)
│   │   ├── business/          # 业务通用组件(UserAvatar/DataCard 等)
│   │   └── layout/            # 布局组件(AppHeader/AppSidebar/AppFooter 等)
│   ├── composables/           # 可复用组合式逻辑
│   │   ├── useAuth.ts
│   │   ├── usePermission.ts
│   │   ├── useTable.ts
│   │   └── useForm.ts
│   ├── constants/             # 常量定义
│   │   ├── enums.ts           # 枚举
│   │   └── index.ts           # 业务常量
│   ├── directives/            # 自定义指令
│   │   ├── v-permission.ts    # 权限指令
│   │   └── v-debounce.ts      # 防抖指令
│   ├── hooks/                 # (可选)Vue hooks 别名,与 composables 二选一
│   ├── layouts/               # 布局页面
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── locales/               # 国际化
│   │   ├── zh-CN/
│   │   ├── en-US/
│   │   └── index.ts
│   ├── mock/                  # Mock 数据(开发环境)
│   │   └── handlers/
│   ├── pages/                 # 页面视图(或 views/)
│   │   ├── index/             # 首页
│   │   ├── login/             # 登录页
│   │   └── dashboard/         # 仪表盘
│   ├── router/                # 路由配置
│   │   ├── index.ts           # 路由实例
│   │   ├── modules/           # 按模块拆分的路由
│   │   └── guard.ts           # 路由守卫(权限控制)
│   ├── stores/                # Pinia 状态管理
│   │   ├── user.ts
│   │   ├── app.ts
│   │   └── settings.ts
│   ├── styles/                # 全局样式
│   │   ├── variables.css      # CSS 变量
│   │   ├── reset.css          # 重置样式
│   │   └── global.css         # 全局样式
│   ├── types/                 # TypeScript 类型定义
│   │   ├── api.d.ts           # API 请求/响应类型
│   │   ├── global.d.ts        # 全局类型声明
│   │   ├── router.d.ts        # 路由类型
│   │   └── store.d.ts         # Store 类型
│   ├── utils/                 # 工具函数
│   │   ├── format.ts          # 格式化(日期、金额等)
│   │   ├── validate.ts        # 校验函数
│   │   ├── storage.ts         # 本地存储封装
│   │   └── tree.ts            # 树结构工具
│   ├── App.vue                # 根组件
│   └── main.ts                # 入口文件
├── .env                       # 环境变量(所有环境共享)
├── .env.development           # 开发环境变量
├── .env.production            # 生产环境变量
├── .eslintrc.cjs              # ESLint 配置
├── .prettierrc                # Prettier 配置
├── .commitlintrc.cjs          # Commitlint 配置
├── tsconfig.json              # TypeScript 配置
├── tsconfig.node.json         # Node 端 TS 配置
├── vite.config.ts             # Vite 配置
├── vitest.config.ts           # Vitest 配置
├── uno.config.ts              # UnoCSS 配置(如使用)
├── tailwind.config.ts         # Tailwind 配置(如使用)
├── pnpm-lock.yaml
└── package.json

2.2 目录结构原则

  1. 按特征(feature)组织,而非按类型(type)。相关文件就近存放。

  2. 每个模块内部保持内聚:pages/ 下每个页面文件夹如有私有组件,在页面目录下建 components/ 子目录。

  3. 层级不超过 4 层src/pages/dashboard/components/charts/LineChart.vue 已到极限。

  4. 共享代码上提:某组件被 2+ 页面引用 → 提升到 src/components/business/

2.3 工具函数规范(Utils)

核心原则:优先使用成熟的第三方库,不重复造轮子src/utils/ 只做适配层和项目特有的工具函数。

优先使用已有的库

// ✅ 正确:使用已有库
import { debounce } from '@vueuse/core'       // 防抖/节流
import { format } from 'date-fns'              // 日期格式化(或 dayjs)
import { cloneDeep } from 'lodash-es'          // 深拷贝
import { isEqual } from 'lodash-es'            // 对象比较
import { useStorage } from '@vueuse/core'      // 本地存储响应式封装

// ❌ 禁止:自己实现已有库的功能
function debounce(fn, delay) { /* 自己写一套 */ }       // 已有 @vueuse/core
function deepClone(obj) { return JSON.parse(...) }        // 已有 lodash-es
function formatDate(date) { /* 手撸格式化逻辑 */ }        // 已有 dayjs/date-fns

src/utils/ 只应包含以下内容

  • 适配层:对第三方库的二次封装(如 storage.ts 封装 @vueuse/coreuseStorage

  • 项目特有逻辑:业务校验规则(validate.ts)、金额/手机号脱敏等非通用格式化

  • 纯函数:无副作用、输入输出明确的工具函数

  • 禁止:在 utils/ 中实现任何已有库已提供的功能

添加新工具函数的检查清单

在往 utils/ 中添加新函数之前,按顺序问:

  1. 这个功能是否已有成熟的 npm 包提供了? → 使用 npm 包

  2. 这个功能是否在 @vueuse/corelodash-es 中存在? → 直接引用

  3. 是否可以对已有函数做薄封装而非重写? → 做适配层

  4. 以上都不是 → 才在 utils/ 中自己实现,并添加单元测试


3. 网络请求库封装

3.1 Axios 实例封装 src/api/request.ts

import axios, {
  type AxiosInstance,
  type AxiosResponse,
  type InternalAxiosRequestConfig,
} from 'axios'
import { authApi } from '@/api/modules/auth'
import { useUserStore } from '@/stores/user'
import router from '@/router'
import { ElMessage } from 'element-plus' // 如使用 Naive UI 则替换为 useMessage()

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15_000,
  headers: { 'Content-Type': 'application/json' },
})

// ========== Token 刷新状态管理 ==========
let isRefreshing = false

interface PendingTask {
  resolve: (token: string) => void
  reject: (error: unknown) => void
}

/** 等待刷新期间排队的请求队列 */
let pendingRequests: PendingTask[] = []

/**
 * 处理 401 → 尝试刷新 Token,成功后重放队列
 * 防止无限循环:refresh 请求本身返回 401 时直接登出
 */
async function handleTokenRefresh(error: unknown) {
  const err = error as { config?: InternalAxiosRequestConfig & { _retry?: boolean } }
  const originalRequest = err.config

  // 条件 A:没有请求配置 → 无法重试,直接登出
  if (!originalRequest) {
    const userStore = useUserStore()
    userStore.logout()
    router.push('/login')
    ElMessage.error('登录已过期,请重新登录')
    return Promise.reject(error)
  }

  // 条件 B:_retry 标记为 true → 说明这是 refresh 请求本身返回的 401
  // 此时不再重试,直接登出,否则会无限循环
  if (originalRequest._retry) {
    const userStore = useUserStore()
    userStore.logout()
    router.push('/login')
    ElMessage.error('登录已过期,请重新登录')
    return Promise.reject(error)
  }

  if (!isRefreshing) {
    // ---- 第一个 401 触发者:开始刷新 ----
    isRefreshing = true
    originalRequest._retry = true

    try {
      const newToken = await authApi.refreshToken()

      // 刷新成功 → 更新 store 中的 token
      const userStore = useUserStore()
      userStore.setToken(newToken)

      isRefreshing = false

      // 重放队列中等待的所有请求
      pendingRequests.forEach(({ resolve }) => resolve(newToken))
      pendingRequests = []

      // 用新 token 重试当前请求
      originalRequest.headers.Authorization = `Bearer ${newToken}`
      return instance(originalRequest)
    } catch (refreshError) {
      // 刷新失败 → 清空队列 & 登出
      isRefreshing = false
      pendingRequests.forEach(({ reject }) => reject(refreshError))
      pendingRequests = []

      const userStore = useUserStore()
      userStore.logout()
      router.push('/login')
      ElMessage.error('登录已过期,请重新登录')
      return Promise.reject(refreshError)
    }
  } else {
    // ---- 刷新中:将当前请求加入等待队列 ----
    return new Promise<string>((resolve, reject) => {
      pendingRequests.push({ resolve, reject })
    }).then((newToken) => {
      originalRequest.headers.Authorization = `Bearer ${newToken}`
      return instance(originalRequest)
    })
  }
}

// ========== 请求拦截器 ==========

/** 扩展 Axios 请求配置,支持自定义选项 */
declare module 'axios' {
  interface AxiosRequestConfig {
    /** 标记为无需 token 的公开接口(如登录页、验证码、SSR 静态页) */
    noAuth?: boolean
  }
}

instance.interceptors.request.use(
  (config) => {
    // 0. 显式标记 noAuth → 跳过所有 token 逻辑
    if (config.noAuth) {
      return config
    }

    const userStore = useUserStore()

    // 1. token 不存在 → 未登录,强制跳转登录页并记住当前路径
    if (!userStore.token) {
      const currentRoute = router.currentRoute.value
      const redirect = currentRoute.fullPath
      router.push({ name: 'Login', query: { redirect } })
      return Promise.reject(new Error('需要登录'))
    }

    // 2. 自动附加 Token
    config.headers.Authorization = `Bearer ${userStore.token}`

    // 3. 自动附加业务 ID / 租户 ID(如有)
    // config.headers['X-Tenant-Id'] = userStore.tenantId

    return config
  },
  (error) => {
    return Promise.reject(error)
  },
)

// ========== 响应拦截器 ==========
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    // 1. 解包:假定后端统一返回 { code: number, data: T, message: string }
    const { code, data, message } = response.data

    if (code === 0 || code === 200) {
      return data  // ✅ 类型由 API 模块层的泛型 request.get<T>() 保障
    }

    // 2. 业务错误码处理
    ElMessage.error(message || '请求失败')
    return Promise.reject(new Error(message || `业务错误码: ${code}`))
  },
  (error) => {
    if (error.response) {
      const { status } = error.response as { status: number }

      // 401 → 走 Token 自动刷新流程
      if (status === 401) {
        return handleTokenRefresh(error)
      }

      switch (status) {
        case 403:
          ElMessage.error('没有权限访问')
          break
        case 404:
          ElMessage.error('请求的资源不存在')
          break
        case 500:
          ElMessage.error('服务器错误,请稍后重试')
          break
        default:
          ElMessage.error(error.message || '网络错误')
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时,请检查网络')
    } else {
      ElMessage.error('网络异常,请检查连接')
    }
    return Promise.reject(error)
  },
)

export default instance

3.2 API 模块示例 src/api/modules/user.ts

import request from '../request'
import type {
  LoginParams,
  LoginResult,
  UserInfo,
  UpdateProfileParams,
} from '@/types/user'

/**
 * 用户相关 API
 * 每个 API 函数必须:
 * 1. 明确泛型参数(请求体类型、响应数据类型)
 * 2. 添加 JSDoc 注释
 * 3. 使用具名导出(export const)
 */
export const userApi = {
  /** 登录 */
  login: (data: LoginParams) =>
    request.post<LoginResult>('/user/login', data),

  /** 获取用户信息 */
  getProfile: () =>
    request.get<UserInfo>('/user/profile'),

  /** 更新个人信息 */
  updateProfile: (data: UpdateProfileParams) =>
    request.patch<UserInfo>('/user/profile', data),

  /** 退出登录 */
  logout: () =>
    request.post<void>('/user/logout'),
}

3.3 API 调用规范

// ✅ 正确:在 composable 中使用
// src/composables/useUserProfile.ts
import { ref } from 'vue'
import { userApi } from '@/api/modules/user'
import type { UserInfo } from '@/types/user'

export function useUserProfile() {
  const profile = ref<UserInfo | null>(null)
  const loading = ref(false)

  async function fetchProfile() {
    loading.value = true
    try {
      profile.value = await userApi.getProfile()
    } finally {
      loading.value = false
    }
  }

  return { profile, loading, fetchProfile }
}
// ❌ 禁止:直接在组件中调用 api 却不处理 loading/error 状态
// ✅ 推荐:始终封装 loading、error、data 三态

3.4 Token 自动无感刷新机制

本节是对 3.1 节拦截器中 Token 刷新逻辑的补充说明,以及刷新 Token 的 API 模块规范。

工作流程

请求 → 401 → 是 refresh 请求本身? → 是 → 直接登出(防无限循环)
                 ↓ 否
           正在刷新中? → 是 → 加入等待队列,刷新完成后自动重放
                 ↓ 否
           发起 refreshToken 请求(加 _retry 标记)
                 ↓
           成功 → 更新 token → 重放队列 → 重试当前请求
                 ↓
           失败 → 清空队列 → 登出 → 跳转登录页

无限循环的三种防范

条件

防范的场景

触发后的行为

没有 config 对象

网络错误等非请求级别的异常被误判为 401

直接登出,不重试

_retry === true

refresh 请求本身返回 401(refreshToken 也过期了)

直接登出,不重试 ✅ 防无限循环核心

isRefreshing === true

短时间内多个请求同时返回 401

后续请求排入队列,不发起新的 refresh 请求

刷新 Token 的 API 模块

// src/api/modules/auth.ts
import request from '../request'

export const authApi = {
  /** 刷新 Token — 注意:使用原始的 request,不走拦截器的 401 重试标记 */
  refreshToken: () =>
    request.post<{ accessToken: string }>('/auth/refresh'),

  /** 登录 */
  login: (data: { username: string; password: string }) =>
    request.post<{ accessToken: string; refreshToken: string }>('/auth/login', data),
}

注意authApi.refreshToken() 调用的是同一份 request 实例,拦截器逻辑对它同样生效。关键区别在于:refresh 请求的 config._retry 标记是由拦截器自己添加的。如果 refresh 请求返回 401,此时 _retry === true,条件 B 会拦截并直接登出,形成断路保护。

使用无感刷新后,401 的错误上报

  • 无感刷新对调用方完全透明:API 调用方只需正常 try/catch,不需要关心 token 刷新逻辑

  • 刷新成功 → 调用方正常收到业务响应

  • 刷新失败(refreshToken 过期)→ 调用方收到 401 错误,由拦截器统一登出

3.5 请求竞态取消

import { ref, onUnmounted } from 'vue'
import axios from 'axios'

export function useCancelableRequest() {
  const abortController = ref(new AbortController())

  const cancelPrevious = () => {
    abortController.value.abort()
    abortController.value = new AbortController()
  }

  onUnmounted(() => {
    abortController.value.abort()
  })

  return { cancelPrevious, signal: () => abortController.value.signal }
}

3.6 自动刷新与缓存(可选:配合 @tanstack/vue-query)

import { useQuery } from '@tanstack/vue-query'
import { userApi } from '@/api/modules/user'

export function useProfileQuery() {
  return useQuery({
    queryKey: ['user', 'profile'],
    queryFn: () => userApi.getProfile(),
    staleTime: 5 * 60 * 1000,  // 5 分钟内不重新请求
    retry: 2,                   // 失败重试 2 次
  })
}

3.7 强制登录校验与登录后回跳

本机制与 3.1 请求拦截器配合使用,实现整个链路的闭环:请求拦截器拦截 → 跳转登录页 → 登录后回跳 → 组件自动重新获取数据。

使用方式

调用 API 时通过 noAuth 选项标记公开接口:

// 公开接口(无需 token):登录、注册、验证码、SSR 静态页
import request from '@/api/request'

export const publicApi = {
  getCaptcha: () => request.get<string>('/captcha', { noAuth: true }),
  login: (data: { username: string; password: string }) =>
    request.post<LoginResult>('/auth/login', data, { noAuth: true }),
}

// 私有接口(默认需要 token):无需任何标记
export const orderApi = {
  getList: () => request.get<Order[]>('/orders'),
}

链路流程

用户访问 /dashboard → 组件调用 getOrderList()
  → 拦截器发现 noAuth? → false,继续检查 token
  → token 为空 → Promise.reject('需要登录')
    → 同时 router.push({ name: 'Login', query: { redirect: '/dashboard' } })
      → 登录页读取 redirect 参数
        → 用户登录成功 → router.push('/dashboard')
          → 组件重新挂载 → getOrderList() 再次执行 → 正常返回数据 ✅

路由守卫配合

如果整个页面都需要登录,除了请求拦截器层,建议在路由层面也加一层防护:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/pages/login/index.vue'),
      meta: { noAuth: true },        // 公开页面
    },
    {
      path: '/dashboard',
      name: 'Dashboard',
      component: () => import('@/pages/dashboard/index.vue'),
      meta: { requiresAuth: true },   // 需要登录
    },
  ],
})

router.beforeEach((to) => {
  const userStore = useUserStore()

  // 公开页面 → 放行
  if (to.meta.noAuth) return true

  // 需要登录但未登录 → 跳转登录页,带上 redirect
  if (to.meta.requiresAuth && !userStore.token) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }

  return true
})

为什么需要两层防护?

  • 路由守卫:防止未登录用户看到空白页面(拦截早,体验好)

  • 请求拦截器:防止 API 调用因为没有 token 而报错(兜底防护)

  • 两层独立,互不依赖。即使路由守卫漏过(比如动态路由导致 meta 丢失),请求层也能兜住

登录页回跳

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'

const route = useRoute()
const router = useRouter()
const userStore = useUserStore()

async function handleLogin() {
  try {
    await userStore.login(/* ... */)
    // 优先跳转 redirect 参数指定的页面;没有则跳到首页
    const redirect = (route.query.redirect as string) || '/'
    router.push(redirect)
  } catch {
    // 登录失败错误提示由拦截器里的 ElMessage 统一处理
  }
}
</script>

3.8 前后端接口约定

本节留空的具体格式和数值,需在本项目启动时与后端团队协商确认后补充。

3.8.1 统一响应格式

// 约定后端统一返回结构(与后端协商后确定具体字段名和含义)
// 示例格式(以下字段名/类型均需协商确认):
interface ApiResponse<T> {
  code:    number  // 状态码,与后端协商确定含义(如 0=成功 / 200=成功)
  data:    T       // 业务数据
  message: string  // 提示信息
}

待补充:确定 code 的成功/失败取值、data 为 null 时的处理方式、分页数据的嵌套位置。

3.8.2 分页接口规范

// 请求参数(按后端接口规范确定字段名,以下仅为示例占位)
interface PageParams {
  page:     number  // ⚠️ 字段名和含义待与后端确认
  pageSize: number  // ⚠️ 字段名和含义待与后端确认
}

// 响应数据(按后端接口规范确定字段名,以下仅为示例占位)
interface PageResult<T> {
  list:  T[]    // ⚠️ 字段名待确认(也可能是 records / items / data)
  total: number // ⚠️ 字段名待确认
  // ⚠️ 是否包含 page / pageSize / totalPages 等字段待确认
}

待补充:请求参数字段名、响应字段名、是否返回总页数、默认分页大小。

3.8.3 业务错误码枚举

// 前端统一定义业务错误码,禁止在代码中硬编码数字
// 具体枚举值与后端协商后补充
export enum BusinessCode {
  // ===== 通用错误码(待补充) =====
  Success = 0,      // ⚠️ 具体取值待确认
  // BadRequest = 400,
  // Unauthorized = 401,

  // ===== 业务错误码(待补充) =====
  // UserNotFound = 1001,
  // OrderExpired = 2001,
}

// 使用方式
if (code !== BusinessCode.Success) {
  // 统一处理业务错误
}

待补充:所有 BusinessCode 枚举值需与后端协商确定后填入。

3.8.4 前后端共用枚举

涉及前后端共用的枚举(订单状态、用户类型、审批结果等),统一在前端 TypeScript 中定义,后端作为参考来源之一。

// src/types/enums.ts
// ⚠️ 以下枚举值需与后端确认后补充

/** 订单状态 —— 前后端共用,取值需与后端一致 */
// export enum OrderStatus {
//   Pending   = 0,  // 待支付
//   Paid      = 1,  // 已支付
//   Shipped   = 2,  // 已发货
//   Completed = 3,  // 已完成
//   Cancelled = 4,  // 已取消
// }

/** 用户角色 —— 前后端共用 */
// export enum UserRole {
//   Admin   = 'admin',
//   Editor  = 'editor',
//   Viewer  = 'viewer',
// }

待补充:所有业务枚举类型和取值,需与后端协商后补充完整。

3.8.5 接口命名规范

接口 URL 风格按后端团队规范执行,前端不做硬性规定。但前端 API 模块需遵循以下原则:

  • 每个资源对应一个 API 模块文件(如 src/api/modules/order.ts

  • 按 HTTP 方法映射操作意图:GET=查询、POST=创建、PATCH=局部更新、DELETE=删除

  • 禁止在组件中直接拼接 URL 调用 axios,所有请求必须通过 API 模块层

// ✅ 正确:统一通过 API 模块层调用
import { orderApi } from '@/api/modules/order'
orderApi.getList(params)

// ❌ 禁止:组件中直接拼接 URL
axios.get('/api/v1/orders')

4. 组件抽离习惯

4.1 抽离原则

条件

操作

单个 .vue 文件超过 250 行

必须拆分

同一模板结构出现 2 次以上

提取为通用组件

组件逻辑可以独立测试

提取为组件(便于单元测试)

组件有明确的复用价值

提升到 components/business/

组件仅供一个页面使用

放在 pages/xxx/components/

4.2 组件命名规范

<!-- 多单词命名,避免 HTML 元素冲突 -->
<!-- ✅ 正确 -->
<UserAvatar />
<DataTable />
<EmailInput />

<!-- ❌ 错误 -->
<Avatar />          <!-- 太通用,容易冲突 -->
<user-avatar />     <!-- 与 Vue 自身风格不一致(模板中可用 kebab-case) -->

<!-- 基础组件前缀:Base -->
<BaseButton />
<BaseInput />
<BaseModal />

<!-- 布局组件前缀:App -->
<AppHeader />
<AppSidebar />
<AppFooter />

4.3 组件文件组织形式

强制规则:一个组件 = 一个 .vue 文件(SFC),禁止将 template 和 script 分离到不同文件。

❌ 禁止:UserProfile/index.html + UserProfile/index.ts + UserProfile/index.css
✅ 强制:UserProfile.vue  (template + script + style 在同一个 .vue 文件内)

理由:

  1. Vue 的 SFC 编译优化(模板引用推断、静态提升)依赖同一文件内的联合分析

  2. 分离后 template 与 script 的绑定关系需要手动同步,容易遗漏

  3. <script setup> + Composition API 已在文件内部实现了关注点分离

  4. 编辑器 / LSP 对 SFC 的支持远优于手拼的分离方案

组件变大时,通过抽取 composable 或拆分子组件来解决(见 4.1 节 250 行红线),而非拆散 template 和 script。

4.4 组件内部结构(必须遵循的顺序)

<script setup lang="ts">
// 1. 类型导入(按字母排序)
import type { UserInfo } from '@/types/user'

// 2. 值导入(按字母排序)
import { computed, ref, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'

// 3. 组件选项(按需指定 name:递归组件、KeepAlive 场景必需;其余场景 SFC 文件名会自动推导)
defineOptions({ name: 'UserProfileCard' })

// 4. props 定义(使用纯类型声明语法,禁止运行时声明)
const props = withDefaults(defineProps<{
  userId: number
  size?: 'small' | 'medium' | 'large'
}>(), {
  size: 'medium',
})

// 5. emits 定义
const emit = defineEmits<{
  (e: 'update', user: UserInfo): void
  (e: 'close'): void
}>()

// 6. composables / stores(集中在一个区域)
const userStore = useUserStore()

// 7. 响应式状态
const loading = ref(false)
const error = ref<string | null>(null)

// 8. 计算属性
const displayName = computed(() => `${props.userId}-${userStore.userName}`)

// 9. 生命周期
onMounted(() => {
  fetchData()
})

// 10. 方法/事件处理
async function fetchData() {
  // ...
}

// 11. expose(极少数情况)
defineExpose({ refresh: fetchData })
</script>

<template>
  <!-- 模板:保持语义化标签,合理使用 v-if/v-else 而非 v-show 控制显示 -->
  <div class="user-profile">
    <slot name="default" />
  </div>
</template>

<style scoped lang="scss">
/* 样式:使用 scoped,避免全局污染 */
.user-profile {
  /* 组件根节点使用组件名作为 class */
}
</style>

4.5 defineOptions 补充

defineOptions 已在 4.4 节的组件结构模板中体现,此处补充额外说明。

  • defineOptions 必须放在 <script setup> 顶部,在 defineProps 之前

  • name 字段必须提供(影响 DevTools 显示名和 <KeepAlive>include/exclude

  • inheritAttrs: false 在需要自定义属性挂载行为时设置,非必需

4.6 Slot / 组合模式优先

✅ 推荐:Slot / 组合模式
<DataTable :data="users">
  <template #action="{ row }">
    <BaseButton @click="edit(row)">编辑</BaseButton>
  </template>
</DataTable>

❌ 避免:庞大的 props 配置对象驱动
<DataTable :data="users" :columns="columns" :actions="actions" :config="bigConfig" />

4.7 Template / HTML 结构层级规范

嵌套深度

  • 模板 DOM 嵌套层级不超过 7 层

  • 超过 7 层 → 将深层部分抽离为子组件

  • 反例:<div> > <div> > <div> > <div> > <div> > <div> > <div> > <div> ← 8 层,必须拆

根元素

  • 每个组件尽量保持 单一根元素(即使 Vue 3 支持多根)

  • 多根组件会丢失 $attrs 的自动透传行为,增加维护成本

  • 需要多根时,显式设置 inheritAttrs: false + 手动绑定 $attrs

语义化标签优先

<!-- ✅ 正确:使用语义化标签 -->
<header> <main> <section> <article> <aside> <nav> <footer>
<button> <ul>/<ol> <table> <form> <label> <fieldset>

<!-- ❌ 避免:用 div 模拟语义 -->
<div class="header"> <div class="main-content">
<div onclick="...">   → 使用 <button>
<div role="button">   → 使用 <button>

v-forv-if

禁止在同一元素上同时使用 v-forv-if。Vue 3 中 v-if 优先级高于 v-for,但混用会让意图不清晰。

<!-- ✅ 正确:先用 computed 过滤,再 v-for -->
<script setup>
const visibleItems = computed(() => items.filter(item => item.visible))
</script>
<template>
  <div v-for="item in visibleItems" :key="item.id">{{ item.name }}</div>
</template>

<!-- ❌ 禁止:v-for + v-if 混用 -->
<div v-for="item in items" v-if="item.visible" :key="item.id">{{ item.name }}</div>

模板表达式复杂度

模板中的表达式不超过一个三元运算符或一个方法调用。复杂逻辑必须使用 computed 或 method。

<!-- ✅ 简单表达式 -->
<div>{{ formatDate(item.createdAt) }}</div>
<div>{{ user.role === 'admin' ? '管理员' : '用户' }}</div>

<!-- ❌ 太复杂,必须提取到 computed -->
<div>{{ user.role === 'admin' ? '管理员' : user.role === 'editor' ? '编辑' : '用户' }}</div>

具名 Slot 命名

具名 slot 使用 kebab-case

<!-- ✅ -->
<template #action-bar>

<!-- ❌ -->
<template #actionBar>

事件绑定

模板中的事件处理函数如果超过一行逻辑,必须提取到 <script> 中的函数。

<!-- ✅ -->
<button @click="handleSubmit">提交</button>

<!-- ❌ -->
<button @click="(e) => { validate(); submit(); reset(); }">提交</button>

4.8 表单处理模式

表单是业务系统最高频的场景之一,需要统一的模式来避免每个组件各自实现。

数据结构分层

// 1️⃣ 表单原始数据(对应后端接口字段,可能含转换逻辑)
interface UserFormData {
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  departmentId: number | null
}

// 2️⃣ 提交参数(API 层的入参类型,可能和表单数据不完全一致)
interface CreateUserParams {
  name: string
  email: string
  roleId: string       // 前端选的是枚举,后端传字符串 ID
  deptId: number        // 字段名可能不同
}

组合式表单管理

// ✅ 推荐:用 composable 封装表单逻辑
// src/composables/useForm.ts
import { reactive, toRefs } from 'vue'
import type { FormInstance } from 'element-plus'  // 按 UI 库调整

export function useFormSubmit<T extends Record<string, unknown>>(
  formRef: { value: FormInstance | null },
  submitFn: (data: T) => Promise<void>,
) {
  const state = reactive({
    submitting: false,
    submitError: null as string | null,
  })

  async function handleSubmit(data: T) {
    if (formRef.value) {
      const valid = await formRef.value.validate().catch(() => false)
      if (!valid) return
    }
    state.submitting = true
    state.submitError = null
    try {
      await submitFn(data)
    } catch (e) {
      state.submitError = e instanceof Error ? e.message : '提交失败'
      throw e  // 交给调用方处理 UI 反馈
    } finally {
      state.submitting = false
    }
  }

  return { ...toRefs(state), handleSubmit }
}

表单校验

<script setup lang="ts">
// ✅ 使用 UI 库自带的校验规则(Element Plus el-form rules / Vant Form 校验)
// 复杂场景可引入 VeeValidate + Zod(见 1.2 节可选依赖)

const formRef = ref(null)
const form = reactive<UserFormData>({ /* 初始值 */ })

const rules = {
  name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
  email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
}

const { submitting, handleSubmit } = useFormSubmit(formRef, async (data) => {
  await userApi.create(data as CreateUserParams)
})
</script>

原则

  • 表单验证规则与 UI 组件库绑定(Element Plus 用 el-form rules、Vant 用 Form)

  • 跨页面复用的复杂校验逻辑提取到 utils/validation.ts

  • 提交前必须校验,提交按钮必须处理 loading 和防重复提交

  • 提交结果统一通过 ElMessage / Toast 反馈,不在每个组件中重复处理

4.9 弹窗/对话框管理

后台管理系统大量使用弹窗进行 CRUD 操作,需统一模式。

<!-- ModalWrapper 示例 -->
<script setup lang="ts">
const visible = ref(false)
const editingId = ref<number | null>(null)

function openCreate() {
  editingId.value = null
  visible.value = true
}

function openEdit(id: number) {
  editingId.value = id
  visible.value = true
}

function handleClose() {
  visible.value = false
  editingId.value = null
}
</script>

<template>
  <BaseButton @click="openCreate">新增</BaseButton>

  <BaseModal v-model:visible="visible" @close="handleClose">
    <UserForm
      v-if="visible"
      :key="editingId ?? 'create'"
      :id="editingId"
      @success="handleClose"
    />
  </BaseModal>
</template>

原则

  • 弹窗开关状态(visible)在宿主组件中管理,不在弹窗内部

  • v-if + :key 确保每次打开重新挂载表单组件(避免残留数据)

  • 编辑模式通过 editingId 区分创建/编辑;null = 创建

  • 弹窗内部通过 emit 通知宿主操作完成(@success/@close

  • 禁止在 <script> 中手动操作 DOM 来打开/关闭弹窗


5. 工程化习惯

5.1 Git 提交规范

使用 Conventional Commits,格式:

<type>(<scope>): <description>

[optional body]

type

含义

feat

新功能

fix

Bug 修复

refactor

重构(既不修复 bug 也不添加功能)

style

样式/格式变更(非代码逻辑)

perf

性能优化

test

测试相关

docs

文档变更

chore

构建/工具/依赖变更

ci

CI 配置变更

✅ feat(user): add profile avatar upload
✅ fix(auth): handle token expiry redirect loop
✅ refactor(table): extract pagination logic into composable
❌ fix bug
❌ update code

5.2 分支策略

main          ← 生产分支(只接受 PR)
  ├── dev     ← 开发分支
  │    ├── feat/user-profile    ← 功能分支
  │    ├── fix/login-timeout    ← 修复分支
  │    └── refactor/api-layer   ← 重构分支

5.3 ESLint 配置

# 安装依赖
pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
pnpm add -D eslint-plugin-vue vue-eslint-parser
// eslint.config.js(Flat Config,ESLint v9+ 推荐格式)
import pluginVue from 'eslint-plugin-vue'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'

export default [
  { ignores: ['dist/', 'node_modules/', '*.config.*'] },
  // ===== JavaScript / TypeScript 通用规则 =====
  {
    files: ['**/*.{js,ts,mjs,mts}'],
    rules: {
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'prefer-const': 'error',
      eqeqeq: ['error', 'always'],
      'no-unused-vars': 'off', // 交给 @typescript-eslint 处理
    },
  },
  // ===== TypeScript 规则 =====
  {
    files: ['**/*.{ts,mts}'],
    languageOptions: {
      parser: tsParser,
      parserOptions: { project: './tsconfig.json' },
    },
    plugins: { '@typescript-eslint': tsPlugin },
    rules: {
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
    },
  },
  // ===== Vue 规则 =====
  {
    files: ['**/*.vue'],
    languageOptions: {
      parser: 'vue-eslint-parser',
      parserOptions: { parser: tsParser },
    },
    plugins: { vue: pluginVue },
    rules: {
      ...pluginVue.configs['vue3-recommended'].rules,
      'vue/component-name-in-template-casing': ['error', 'PascalCase'],
      'vue/multi-word-component-names': 'error',
      'vue/no-unused-refs': 'error',
      'vue/require-default-prop': 'off', // withDefaults() 已处理
    },
  },
]

如果 ESLint 版本仍为 v8,使用 .eslintrc.cjs 格式并配置 parser: 'vue-eslint-parser'parserOptions.parser: '@typescript-eslint/parser'extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', '@typescript-eslint/recommended']

5.4 环境变量

# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_TITLE=MyApp (Dev)

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=MyApp

规范

  • 所有环境变量以 VITE_ 前缀开头

  • 使用 import.meta.env.VITE_XXX 访问

  • 敏感信息(密钥等)不提交到代码库

5.5 命名约定

类型

规范

示例

组件名

PascalCase

UserProfile.vue

目录名

kebab-case

user-profile/

TypeScript 文件

camelCase

useAuth.ts

API 模块

camelCase

userApi.getProfile()

Pinia store

camelCase (文件名)

useUserStore

Composables

camelCase, use 开头

useUserProfile()

常量

UPPER_SNAKE_CASE

MAX_FILE_SIZE

枚举

PascalCase(枚举名), UPPER_SNAKE_CASE(值)

Role.ADMIN

类型/接口

PascalCase

UserInfo, LoginParams

CSS class

kebab-case

.user-profile-card

路由 path

kebab-case

/user-profile

5.6 必要配置与类型声明

项目初始化时必须正确配置以下文件,否则编译或类型检查会失败。

tsconfig.json 路径别名

{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }   // 与 vite.config.ts 的 resolve.alias 保持一致
  }
}

vite.config.ts 对应配置:

import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

.vue 模块声明

// src/types/env.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const component: DefineComponent<Record<string, never>, Record<string, never>, any>
  export default component
}

环境变量类型声明

// src/types/env.d.ts(可合并到上面的文件)
interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_TITLE: string
  // 添加其他自定义环境变量
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

Mock 策略

  • 框架选择:开发环境使用 MSW(Mock Service Worker)或 mockjs

  • 目录:mock 文件放在 src/mock/handlers/,按模块拆分

  • 开关:通过环境变量 VITE_ENABLE_MOCK 控制启用/关闭

  • 生产:生产构建不打包 mock 代码(Vite 会自动 tree-shake 条件引入)

  • 类型:mock 数据尽量从 @/types/ 复用类型定义,避免类型漂移

5.7 Vite 构建配置

路径别名

// vite.config.ts
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

@ 指向 src/,与 tsconfig.json 中的 paths 保持一致。禁止使用 ../ 跨越模块边界引用(例如 ../../components/BaseButton → 应写成 @/components/BaseButton);同一模块或相邻目录内的引用,../ 可以接受。

CSS 预处理器

# 使用 SCSS
pnpm add -D sass

无需额外 Vite 配置,安装 sass 后 .vue 文件中 <style scoped lang="scss"> 即可生效。UnoCSS / Tailwind 的安装与配置见对应文档。

跨域代理

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',   // 后端服务地址
        changeOrigin: true,
        // rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
})

代理配置写入 vite.config.ts,禁止在代码中写死 http://localhost:8080 等开发环境地址。

环境变量

# .env                              # 所有环境共享
VITE_APP_TITLE=My App

# .env.development                  # 开发环境(优先级高于 .env)
VITE_API_BASE_URL=/api

# .env.production                   # 生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxxxxx

# .env.local                        # 本地覆盖(不提交 Git)
VITE_API_BASE_URL=http://localhost:3000

文件

提交 Git

优先级

用途

.env

最低

所有环境共享的默认值

.env.development

开发环境专用

.env.production

生产环境专用

.env.local

最高

本地覆盖,每人自己的配置

规则

  • 所有环境变量必须以 VITE_ 开头

  • 敏感信息(DSN、API Key)只放在 .env.local.env.production,禁止放在 .env

  • 新增环境变量后需要在 src/env.d.ts 中声明类型(见 5.6 节)

构建优化

export default defineConfig({
  build: {
    target: 'es2015',                         // 兼容到 ES2015
    chunkSizeWarningLimit: 500,                // Chunk 超过 500KB 给出警告
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],        // 框架包单独打包
          elementPlus: ['element-plus', '@element-plus/icons-vue'], // UI 库单独打包
        },
      },
    },
  },
})

插件清单

插件

用途

必选

@vitejs/plugin-vue

Vue 3 SFC 编译支持

unplugin-auto-import

自动导入 Vue / VueUse API

可选

unplugin-vue-components

自动导入组件

可选

@sentry/vite-plugin

生产 SourceMap 上传

见第 15 节

unplugin-icons

按需加载图标库

可选

auto-import 插件配置示例(启用后可省略显式 import):

// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
      resolvers: [ElementPlusResolver()],  // 按使用的 UI 库选择 resolver
      dts: 'src/types/auto-imports.d.ts',  // 自动生成类型声明
    }),
    Components({
      dirs: ['src/components'],
      resolvers: [ElementPlusResolver()],
      dts: 'src/types/components.d.ts',
    }),
  ],
})

5.8 项目初始化流程

当智能体被要求创建新项目时,按以下顺序执行:

# Step 1: 脚手架
pnpm create vite my-app --template vue-ts
cd my-app

# Step 2: 安装核心依赖
pnpm add vue-router pinia axios
pnpm add -D sass

# Step 3: 安装可选依赖(按项目需求选择)
pnpm add @vueuse/core dayjs
pnpm add element-plus                     # 后台管理 UI 库
# pnpm add vant                            # 移动端 UI 库
# pnpm add @tanstack/vue-query             # SWR 数据获取

# Step 4: 安装 ESLint(如脚手架未包含)
pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
pnpm add -D eslint-plugin-vue vue-eslint-parser

# Step 5: 安装测试
pnpm add -D vitest @vue/test-utils
pnpm add -D @playwright/test              # E2E 测试(按需安装)

# Step 6: 验证
pnpm dev            # 启动开发服务器
pnpm build          # 确认生产构建通过
pnpm lint           # 确认 ESLint 无错误

常用命令

命令

用途

pnpm dev

启动开发服务器(默认 http://localhost:5173

pnpm build

生产构建

pnpm preview

预览生产构建结果

pnpm lint

ESLint 检查

pnpm test

运行 Vitest 单元测试

pnpm test:e2e

运行 Playwright E2E 测试


6. TypeScript 规范

6.1 严格模式

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": false, // 按需开启
    "noEmit": true,
    "moduleResolution": "bundler",
  }
}

6.2 类型定义原则

// ✅ 正确:使用 interface 定义对象类型,type 定义联合类型/工具类型
export interface UserInfo {
  id: number
  name: string
  email: string
  role: UserRole
  createdAt: string
}

export type UserRole = 'admin' | 'editor' | 'viewer'

export interface LoginParams {
  username: string
  password: string
  remember?: boolean
}

// ✅ 正确:API 响应统一类型
export interface ApiResponse<T = unknown> {
  code: number
  data: T
  message: string
}

export interface PaginatedData<T> {
  items: T[]
  total: number
  page: number
  pageSize: number
  totalPages: number
}

// ❌ 禁止:使用 any
const data: any = await fetchData()   // ❌
const data: unknown = await fetchData() // ✅ 然后用类型守卫收窄

// ⚠️ 限制:as 类型断言仅在以下情况允许
// 1. API 响应拦截器中的类型边界(文档中唯一默认允许的位置)
// 2. 配合运行时校验一起使用(如 Zod parse 后)
// ❌ 禁止:无校验的 as 断言
const user = response.data as UserInfo // ❌ 缺少运行时校验
// ✅ 有校验则是安全的
const parsed = userSchema.parse(response.data) // Zod 校验

6.3 类型文件组织

// types/api.ts —— 通用的 API 类型
// types/user.ts —— 用户业务类型(如果类型多,按模块拆分)
// types/router.ts —— 路由类型
// types/global.d.ts —— 全局类型扩展(如 .vue 模块声明、window 扩展)

// ✅ 在同个模块内部类型就近定义(如 API 模块内 DefineType)
// ✅ 跨模块复用的类型放在 types/ 下

6.4 禁止模式与逃生口

// ❌ 禁止 as any
const x = foo as any

// ❌ 禁止 @ts-ignore(@ts-expect-error 仅在少数场景允许,见下方逃生口规则)

// ❌ 禁止空 catch
try { /* ... */ } catch { /* empty */ }

// ❌ 禁止 Function 类型
const handler: Function = () => {} // 应使用具体函数签名

// ❌ 禁止非空断言浮躁使用
const el = document.getElementById('app')! // 除非你 100% 确定存在

逃生口规则

严格模式下的少数例外,必须在注释中说明原因:

场景

允许的写法

示例

第三方库类型错误

as + 注释说明

const data = response.data as User // 后端返回字段与类型定义一致

第三方库类型缺失

// @ts-expect-error + 建 issue 跟踪

临时绕过,TODO 修复

API 响应拦截器

as(已有运行时校验)

response.data as ApiResponse<T>

// ✅ 允许的 as(API 响应 + 运行时校验兜底)
const res = await axios.get('/api/users')
const data = res.data as ApiResponse<User>

// ✅ 允许的 @ts-expect-error(第三方库类型问题,需建 issue 跟踪)
// @ts-expect-error 已知 xxx 包类型定义不完整,待上游修复
import { xxx } from 'xxx-package'

// ❌ 仍然禁止:逃避类型检查、不做校验的盲目断言

7. 注释规范

7.1 JSDoc — 必须写的场景

以下场景必须使用 /** */ JSDoc 注释,不得使用 //

// 1️⃣ API 函数 — 说明接口用途、参数含义、返回值
/** 获取用户列表 */
export const userApi = {
  /** @param params.page - 页码,从 1 开始 */
  getList: (params: PageParams) => request.get<User[]>('/users', { params }),
}

// 2️⃣ Composables 导出 — 说明返回的每个属性
/**
 * 用户信息管理
 * @returns profile    - 用户信息响应式对象
 * @returns loading    - 加载状态
 * @returns fetchProfile - 触发拉取,调用方自行 try/catch
 */
export function useUserProfile() { /* ... */ }

// 3️⃣ 复杂类型定义 — 非自明的字段需要说明
/** 订单状态枚举(与后端对齐) */
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'

// 4️⃣ Props — 说明每个 prop 的作用
defineProps<{
  /** 用户唯一标识,由列表页传入 */
  userId: number
  /** 头像尺寸,默认 medium */
  size?: 'small' | 'medium' | 'large'
}>()

7.2 不需要注释的场景

// ❌ 不需要:重复代码的废话注释
const name = user.name  // 获取用户姓名     ← 代码本身已自明

// ❌ 不需要:模板中的简单表达式
// {{ formatDate(item.createdAt) }}  ← 不需要注释,函数名已说明意图

// ❌ 不需要:明显的生命周期钩子
onMounted(() => { fetchData() })  // ← 不需要 "组件挂载时获取数据"

判断标准:如果删掉注释后,读者通过函数名、变量名、类型签名能 5 秒内理解意图,就不需要注释。

7.3 风格规范

  • /** */ JSDoc:用于导出 API、函数声明、类型定义、Props — 会被编辑器/IDE 识别并显示在提示中

  • // 行注释:用于解释"为什么这样做"(而非"做了什么"),即解释业务决策或复杂边界条件

// ✅ 好的行注释解释"为什么"
// 使用 setTimeout 而非 nextTick,因为 nextTick 在 Vue 3.3 某些场景下过渡动画未渲染完成
setTimeout(() => { scrollToBottom() }, 16)

// ❌ 差的行注释描述"是什么"
// 设置定时器 16ms 后执行滚动到底部  ← 代码本身已经说清楚了

7.4 TODO / FIXME 标记规范

统一使用以下三种标记,方便全局搜索:

// TODO: 未来需要处理的分页边界情况(contributor 姓名)
// FIXME: 当前已知缺陷,需要在发布日期前修复
// HACK: 临时绕过 xxx 问题,待上游修复后可移除
// 使用示例
function processData(items: Item[]) {
  // TODO: 补充 items 为空时的兜底逻辑 — @张三
  // FIXME: 当前并发写场景存在竞态条件 — v1.2 前必须修复
  return items.map(item => /* ... */)
}

标记

含义

应何时存在

TODO

计划中但未完成的功能

可在代码中短期存在

FIXME

已知缺陷

必须在发布前解决

HACK

临时绕过方案

附上 issue 链接,修复后移除

7.5 文件头注释

不强制写文件头注释。如果写,统一格式:

/**
 * 用户信息卡片组件
 * 展示用户头像、昵称、角色标签
 */

不需要写作者、创建日期、修改历史——这些信息由 Git 管理,注释只会过时。


8. Composables 模式

8.1 Composables 命名与结构

// src/composables/useTable.ts
import { reactive, computed, toRefs } from 'vue'
import type { PaginatedData, TableState, TableOptions } from '@/types/table'

/**
 * 通用表格管理 composable
 * @param options - 表格配置
 * @returns 表格状态、操作方法
 */
export function useTable<T extends Record<string, unknown>>(
  options: TableOptions<T>,
) {
  const state = reactive<TableState<T>>({
    data: [],
    total: 0,
    loading: false,
    currentPage: 1,
    pageSize: 20,
  })

  async function fetchData() {
    state.loading = true
    try {
      const result = await options.fetchFn(state)
      state.data = result.items
      state.total = result.total
    } finally {
      state.loading = false
    }
  }

  function reset() {
    state.currentPage = 1
    fetchData()
  }

  return {
    ...toRefs(state),  // 解构保持响应性
    fetchData,
    reset,
  }
}

8.2 Composables 规范

规则

说明

命名

useXxx 驼峰格式

文件

一个文件只导出一个 composable

位置

src/composables/ 目录

返回值

返回 { state, method } 结构,state 用 toRefs 保持响应性

副作用

不在 composable 顶层执行副作用,由调用方在 onMounted 中触发

参数

用选项对象模式而非多个散参数

清理

使用 onUnmountedwatchEffect 的清理函数


9. 状态管理规范

9.1 Pinia Store 规范

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { userApi } from '@/api/modules/user'
import { storage } from '@/utils/storage'
import type { UserInfo } from '@/types/user'

export const useUserStore = defineStore('user', () => {
  // ========== State(使用 ref/reactive)==========
  const token = ref<string | null>(storage.get('token'))
  const userInfo = ref<UserInfo | null>(null)

  // ========== Getter(使用 computed)==========
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name ?? '')

  // ========== Action(普通函数)==========
  async function login(credentials: { username: string; password: string }) {
    const result = await userApi.login(credentials)
    token.value = result.token
    storage.set('token', result.token)
    userInfo.value = result.user
  }

  function logout() {
    token.value = null
    userInfo.value = null
    storage.remove('token')
    // 不清除其他 store 的持久化数据(由各自 store 自行处理)
  }

  return { token, userInfo, isLoggedIn, userName, login, logout }
})

9.2 Store 原则

  1. 按领域拆分:一个 store 只管理一个业务领域的状态

  2. 避免 store 循环引用:store A 引用 store B,B 又引用 A → 提取共同状态到新 store

  3. 持久化:通过 utils/storage.ts 封装类手动管理持久化(如 8.1 示例所示),每个 store 独立控制要持久化的字段。避免引入额外插件增加复杂度

  4. 不要在 store 中放置组件特有的 UI 状态(如 modal 是否显示、表单临时数据)

  5. Action 中做 API 调用:store 的 action 调用 API,组件只调 action 不直接调 API(除非是纯查询场景)


10. 路由规范

10.1 路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/login/LoginPage.vue'),
    meta: { title: '登录', public: true }, // public: 无需登录
  },
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Dashboard',
        component: () => import('@/pages/dashboard/DashboardPage.vue'),
        meta: { title: '仪表盘', icon: 'dashboard' },
      },
      // ...更多子路由
    ],
  },
]

10.2 路由规范

  1. 懒加载:所有页面组件使用 () => import() 动态导入

  2. 命名路由:每个路由必须指定 name(用于编程式导航)

  3. Meta 元信息:利用 meta 传递权限、标题、图标等信息

  4. 路由守卫:统一在 router/guard.ts 中管理,不分散在组件内

// src/router/guard.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'

export function setupGuards(router: Router) {
  router.beforeEach((to) => {
    const userStore = useUserStore()

    // 更新页面标题
    document.title = `${to.meta.title} - MyApp`

    // 鉴权检查 — Vue Router 4 推荐直接 return 路由地址而非调用 next()
    if (!to.meta.public && !userStore.isLoggedIn) {
      return { name: 'Login', query: { redirect: to.fullPath } }
    }
  })
}

11. 权限管理规范

11.1 权限数据模型

权限相关状态统一存放在 Pinia store 中,登录时获取并持久化。

// src/stores/permission.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/modules/auth'

export interface PermissionState {
  roles: string[]        // 角色列表,如 ['admin', 'editor']
  permissions: string[]  // 权限标识列表,如 ['user:create', 'user:edit', 'dashboard:view']
}

export const usePermissionStore = defineStore('permission', () => {
  const roles = ref<string[]>([])
  const permissions = ref<string[]>([])

  /** 是否拥有某权限 */
  const hasPermission = computed(() => (perm: string) => permissions.value.includes(perm))

  /** 是否属于某角色 */
  const hasRole = computed(() => (role: string) => roles.value.includes(role))

  /** 登录成功后拉取权限数据 */
  async function fetchPermissions() {
    const res = await authApi.getPermissions()
    roles.value = res.roles
    permissions.value = res.permissions
  }

  /** 登出时清除 */
  function clear() {
    roles.value = []
    permissions.value = []
  }

  return { roles, permissions, hasPermission, hasRole, fetchPermissions, clear }
})

11.2 路由级权限

通过路由 meta 字段声明所需权限/角色,在 beforeEach 守卫中统一判断。

// src/router/index.ts
import { usePermissionStore } from '@/stores/permission'
import { useUserStore } from '@/stores/user'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/pages/dashboard/index.vue'),
    meta: {
      requiresAuth: true,
      roles: ['admin', 'editor'],           // 允许访问的角色(与 permissions 二选一即可)
      // permissions: ['dashboard:view'],   // 或使用权限标识
    },
  },
  {
    path: '/settings',
    name: 'Settings',
    component: () => import('@/pages/settings/index.vue'),
    meta: {
      requiresAuth: true,
      roles: ['admin'],                     // 仅 admin 可访问
    },
  },
  // 公开页面不需要 requiresAuth
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/login/index.vue'),
  },
]

router.beforeEach((to) => {
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  // 未登录 → 跳转登录页
  if (to.meta.requiresAuth && !userStore.token) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }

  // 已登录但无权限 → 跳转 403 页面
  const { roles: allowedRoles, permissions: requiredPermissions } = to.meta
  if (allowedRoles || requiredPermissions) {
    const hasAccess =
      (allowedRoles && allowedRoles.some((r: string) => permissionStore.hasRole(r))) ||
      (requiredPermissions && requiredPermissions.some((p: string) => permissionStore.hasPermission(p)))
    if (!hasAccess) {
      return { name: 'Forbidden' }  // 需预先注册 Forbidden 路由
    }
  }

  return true
})

11.3 按钮级权限

使用 v-permission 指令控制按钮/元素的显隐,指令内部封装判断逻辑。

// src/directives/permission.ts
import type { Directive } from 'vue'
import { usePermissionStore } from '@/stores/permission'

export const vPermission: Directive<HTMLElement, string | string[]> = {
  mounted(el, binding) {
    const permissionStore = usePermissionStore()
    const { value } = binding

    // 支持单个权限标识或数组(满足任一即可)
    const required = Array.isArray(value) ? value : [value]
    const hasPermission = required.some((perm) => permissionStore.hasPermission(perm))

    if (!hasPermission) {
      el.parentNode?.removeChild(el)  // 无权限 → 移除 DOM
    }
  },
}
// src/directives/index.ts — 统一注册全局指令
import type { App } from 'vue'
import { vPermission } from './permission'

export function setupDirectives(app: App) {
  app.directive('permission', vPermission)
}
<!-- 使用示例 -->
<template>
  <!-- 单个权限 -->
  <BaseButton v-permission="'user:create'" @click="createUser">
    新增用户
  </BaseButton>

  <!-- 权限列表(满足任一即可见) -->
  <BaseButton v-permission="['order:edit', 'order:admin']" @click="editOrder">
    编辑订单
  </BaseButton>
</template>

11.4 权限判断函数(Composable)

适合模板表达式、v-if 等无法使用指令的场景:

// src/composables/usePermission.ts
import { usePermissionStore } from '@/stores/permission'

export function usePermission() {
  const permissionStore = usePermissionStore()

  /** 是否拥有某权限 */
  function hasPermission(perm: string | string[]): boolean {
    const required = Array.isArray(perm) ? perm : [perm]
    return required.some((p) => permissionStore.hasPermission(p))
  }

  /** 当前角色是否在允许列表中 */
  function hasRole(role: string | string[]): boolean {
    const required = Array.isArray(role) ? role : [role]
    return required.some((r) => permissionStore.hasRole(r))
  }

  return { hasPermission, hasRole }
}
<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'

const { hasPermission } = usePermission()
</script>

<template>
  <div v-if="hasPermission('dashboard:stats')">
    <StatsChart />
  </div>
</template>

11.5 权限管理检查清单

检查项

说明

路由级

meta.roles / meta.permissions + beforeEach 守卫

按钮级

v-permission 指令 + usePermission() composable

默认兜底

无权限时跳转 Forbidden 页面,而非留下空白或报错

权限刷新

用户角色变更后重新调用 fetchPermissions(),不清除页面(可配合 WebSocket)

权限持久化

permissionStore 可以通过 storage.ts 持久化,避免刷新后权限闪白


12. CSS / 样式规范

12.1 方案选择

UnoCSS / Tailwind(原子化) → 80% 的场景
  ├── 布局、间距、颜色、字体、flex/grid
  └── 配合 <style scoped> 处理需要自定义的部分

Scoped CSS → 组件的自定义样式(原子化 CSS 无法覆盖的部分)

全局 CSS → reset、CSS 变量、@font-face(极少数)

12.2 CSS 变量体系

以下色值基于 Element Plus 设计令牌。如使用 Naive UI,请替换为 Naive UI 的对应色值。 核心是维护一套统一的 CSS 变量体系,具体色值按所选 UI 库调整。

/* styles/variables.css */
:root {
  /* 颜色 */
  --color-primary: #409eff;
  --color-success: #67c23a;
  --color-warning: #e6a23c;
  --color-danger: #f56c6c;
  --color-info: #909399;

  /* 文字 */
  --text-primary: #303133;
  --text-regular: #606266;
  --text-secondary: #909399;
  --text-placeholder: #c0c4cc;

  /* 间距 */
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --spacing-xl: 32px;

  /* 圆角 */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;

  /* 阴影 */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);

  /* 断点 */
  --breakpoint-sm: 640px;
  --breakpoint-md: 768px;
  --breakpoint-lg: 1024px;
  --breakpoint-xl: 1280px;
}

12.3 样式规则

✅ <style scoped> 是默认选择,不要去掉 scoped
✅ 组件根节点使用组件同名 CSS class(便于 DevTools 定位)
✅ CSS class 使用 kebab-case
✅ 使用 CSS 变量保持主题一致性
✅ 条件样式使用 class binding(:class)而非内联 style

❌ 禁止 !important(除非覆盖第三方库且别无他法)
❌ 禁止深层选择器 :deep() 滥用(只在覆盖组件库样式时使用)
❌ 禁止行内 style(除了动态计算值的场景)

:deep() 正确使用指南

/* ✅ 使用 :deep() 覆盖组件库内部样式(这是 :deep() 唯一推荐场景) */
:deep(.el-dialog__body) {
  padding: 0;
}

/* ✅ :deep() 配合 CSS 变量实现主题覆盖 */
:deep(.el-table__header) {
  --el-table-header-bg-color: var(--color-primary);
}

/* ❌ 禁止用 :deep() 穿透子组件做样式耦合 */
/* .parent-component :deep(.child-inner-class) { ... } /* 子组件的内部样式不该被父组件覆盖 */

13. 页面布局规范

13.1 固定定位布局(Fixed Header / Footer)

固定头部/底部会导致其覆盖页面主体内容,必须通过 padding 或 margin 预留空间。

<template>
  <!-- ✅ 正确:header + main + footer 三层结构 -->
  <AppHeader />      <!-- position: fixed; top: 0; height: 56px -->
  <main class="main-content">
    <RouterView />
  </main>
  <AppFooter />      <!-- position: fixed; bottom: 0; height: 48px -->
</template>

<style scoped>
.main-content {
  /* 为 fixed header/footer 留出空间 */
  padding-top: 56px;
  padding-bottom: 48px;
  /* 内容区域自己负责滚动 */
  min-height: 100dvh;
}
</style>

13.2 滚动条抖动问题(Scrollbar Gutter)

内容不足时不出现滚动条、内容超出时出现滚动条,会导致页面宽度突然变化,整个布局"抖动"。

/* ✅ 正确:为滚动条预留稳定空间 */
/* 全局生效,放在 App.vue 或全局样式文件中 */
html {
  scrollbar-gutter: stable;
}

/* ❌ 如果使用 overflow-y: scroll 强制显示滚动条,会永远显示一条不可滚动的条 */
/* 推荐 scrollbar-gutter: stable 优于 overflow-y: scroll */

scrollbar-gutter: stable 在 Chrome / Edge / Firefox 中均支持,会为滚动条预留等宽空间,无论是否滚动。

13.3 视口单位与移动端地址栏

移动端浏览器地址栏折叠/展开会导致 100vh 计算变化,底部内容被遮挡或留白。

/* ❌ 错误:移动端 100vh 会超出视口 */
.full-page {
  height: 100vh;
}

/* ✅ 正确:使用动态视口单位 dvh */
/* 当 dvh 不被支持时,用 @supports 兜底回退 */
.full-page {
  height: 100vh;
  height: 100dvh;      /* 覆盖:支持 dvh 的浏览器使用动态值 */
}

/* ✅ 内容区最小高度也使用 dvh */
.main-content {
  min-height: 100dvh;
}

dvh(Dynamic Viewport Height)随浏览器地址栏状态变化,始终等于当前可见视口高度。兼容性:Chrome 108+、Safari 15.4+、Firefox 101+。

标准全页面布局,确保 footer 始终在底部,内容不足时 footer 不浮空:

<template>
  <div class="page-layout">
    <AppHeader />
    <main class="page-content">
      <RouterView />
    </main>
    <AppFooter />
  </div>
</template>

<style scoped>
.page-layout {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;     /* 撑满视口 */
}

.page-content {
  flex: 1;                 /* 占满剩余空间,footer 自然推到底部 */
}
</style>

13.5 内容区域溢出处理

确保页面内容溢出时,滚动发生在内容区而非整个页面(避免固定元素跟着滚动):

<template>
  <div class="app-shell">
    <AppHeader />
    <div class="app-body">
      <AppSidebar />
      <main class="app-main">
        <RouterView />
      </main>
    </div>
  </div>
</template>

<style scoped>
.app-shell {
  height: 100dvh;
  display: flex;
  flex-direction: column;
}

.app-body {
  flex: 1;
  display: flex;
  overflow: hidden;        /* 防止 body 层出现双滚动条 */
}

.app-main {
  flex: 1;
  overflow-y: auto;        /* 只有主内容区滚动 */
}

.app-sidebar {
  width: 240px;
  overflow-y: auto;        /* 侧边栏独立滚动 */
}
</style>

13.6 布局规范检查清单

检查项

规范

Fixed 定位元素

主体内容必须预留 padding-top/bottom

滚动条抖动

全局 scrollbar-gutter: stable

视口高度

使用 100dvh 替代 100vh

内容不足布局

flex: 1 + min-height: 100dvh 实现 sticky footer

溢出滚动区域

只在内容区域 overflow-y: auto,避免全局滚动

移动端兼容

模态框/底部弹出面板在 iOS Safari 上使用 safe-area-inset-*


14. 错误处理规范

14.1 错误分层

UI 层(组件/页面)
  └── 捕获展示级错误 → 展示错误状态/Toast
        └── 业务层(Composables / Stores)
              └── 捕获业务逻辑错误 → 转换/向上抛
                    └── API 层(request.ts)
                          └── 拦截 HTTP 错误 → 统一处理(见 3.1)

14.2 错误处理原则

// ✅ 正确:async/await + try/catch + 三态管理
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)

async function fetchData() {
  loading.value = true
  error.value = null
  try {
    data.value = await api.fetch()
  } catch (e) {
    // 网络/业务错误已经在拦截器中处理,这里只处理组件级错误
    error.value = e instanceof Error ? e.message : '未知错误'
  } finally {
    loading.value = false
  }
}

// ❌ 禁止:catch 后什么都不做
try { /* ... */ } catch { /* 空 */ }

14.3 全局错误边界

<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)

onErrorCaptured((err: unknown) => {
  if (err instanceof Error) {
    error.value = err
    console.error('Captured error:', err)
  }
  return false // 阻止向上传播
})
</script>

<template>
  <div v-if="error" class="error-boundary">
    <h2>出错了</h2>
    <p>{{ error.message }}</p>
    <BaseButton @click="error = null">重试</BaseButton>
  </div>
  <slot v-else />
</template>

15. 日志与监控规范

核心原则:错误监控、性能监控、用户行为埋点三者统一采用 Sentry 集成方案。Sentry 是当前 GitHub Star 最多(40k+)、Vue 3 支持最好、免费配额最慷慨(每月 5k 错误 + 1k Performance 事件)的监控平台。当智能体需要接入 Sentry 时,必须主动引导用户完成以下配置流程,不得自行操作。

15.1 Sentry 能力覆盖

需求

Sentry 对应能力

免费配额

错误监控

Sentry.init() 自动捕获 JS 错误 + Promise 拒绝 + Vue 组件错误

5k events / 月

性能监控

browserTracingIntegration() 自动采集页面加载、接口耗时、LCP/CLS

1k transactions / 月

用户行为埋点

addBreadcrumb() / Replay 记录用户点击、路由跳转、API 调用

1k replays / 月

SourceMap

@sentry/vite-plugin 自动上传可读堆栈

不限

免费额度说明:Sentry 免费配额对于中小型项目足够。如果项目量级超出免费配额,可自建 Sentry(官方提供 Docker 镜像,开源免费,无限制)。

15.2 Sentry 接入配置

当智能体需要接入 Sentry 时,必须按以下步骤引导用户确认,不得跳过任何一步:

Step 1 — 创建 Sentry 项目

引导用户在 sentry.io 上创建项目,选择 Vue + JavaScript 平台,
获取 DSN(Data Source Name)。

Step 2 — 安装依赖

pnpm add @sentry/vue

@sentry/vue 自 v8 起已内置 Performance 和 Browser 支持,无需单独安装 @sentry/browser@sentry/tracing

Step 3 — 在入口文件初始化

// src/main.ts
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import * as Sentry from '@sentry/vue'

const app = createApp(App)
const router = createRouter({ /* ... */ })

Sentry.init({
  app,
  dsn: import.meta.env.VITE_SENTRY_DSN,       // ✅ DSN 通过环境变量注入
  integrations: [
    Sentry.browserTracingIntegration(),         // 性能监控:页面加载 + 接口耗时
    Sentry.replayIntegration(),                 // 会话回放:记录用户操作(可选)
  ],
  tracesSampleRate: 0.2,                        // 性能采样率:生产 0.2(20%),开发可设 1.0
  replaysSessionSampleRate: 0.1,                // 回放采样率:生产 0.1(10%)
  replaysOnErrorSampleRate: 1.0,                // 发生错误时回放 100% 采集
  environment: import.meta.env.MODE,            // development / production
  // release: import.meta.env.VITE_APP_VERSION, // 可选:关联版本号
})

app.use(router)
app.mount('#app')

Step 4 — 配置环境变量

# .env 或 .env.production
VITE_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxxxxx

Step 5 — 上传 SourceMap(生产环境)

# vite.config.ts
import { sentryVitePlugin } from '@sentry/vite-plugin'

export default defineConfig({
  build: {
    sourcemap: true,  // 生成 SourceMap
  },
  plugins: [
    sentryVitePlugin({
      org: 'your-org',
      project: 'your-project',
      authToken: process.env.SENTRY_AUTH_TOKEN,  // 从 Sentry 后台生成
    }),
  ],
})

生产环境必须上传 SourceMap 才能看到原始代码报错位置。@sentry/vite-plugin 上传后会自动删除 .map 文件,避免源码泄露。

Step 6 — 验证接入

// 在任意代码中调用,确认 Sentry 后台能收到上报
Sentry.captureException(new Error('Sentry 接入验证'))

15.3 智能体接入 Sentry 的引导规则

当智能体判断需要接入 Sentry 时,必须遵循以下流程,不得跳过:

1️⃣ 询问用户 → "项目需要使用 Sentry 做错误监控,是否接入?"
2️⃣ 确认后 → 按 15.2 节的 Step 1~6 逐步配置
3️⃣ 每一步 → 先告知用户接下来做什么(如"现在要在 .env 中添加 DSN")
4️⃣ 涉及账号操作(注册 Sentry、创建项目)→ 给出指引链接,不代替用户操作
5️⃣ DSN / Auth Token → 写入 .env / .env.production,禁止硬编码
6️⃣ 完成后 → 执行 Step 6 验证,确认后台能收到上报

禁止行为

  • 不得在未告知用户的情况下直接安装依赖、修改 main.ts

  • 不得向生产环境写入测试用的 captureException 代码

  • 不得在 utils/ 中重复实现 Sentry 已有的功能(如自研 reportError

15.4 监控检查清单

检查项

Sentry 方案

JS 运行时错误

Sentry.init() 自动捕获

未处理 Promise 拒绝

Sentry.init() 自动捕获

Vue 组件错误

Sentry.init() 自动捕获(通过 app 参数绑定)

接口耗时

browserTracingIntegration() 自动采集

页面加载性能(LCP/CLS)

browserTracingIntegration() 自动采集

用户操作日志

Sentry.addBreadcrumb()

会话回放

replayIntegration()

SourceMap

@sentry/vite-plugin 自动上传

隐私

.env 中的 DSN / Auth Token 禁止写入代码;埋点数据不含密码/手机号

自定义业务错误

Sentry.captureException() / Sentry.captureMessage()


16. 国际化规范

16.1 文件组织

src/locales/
├── zh-CN/
│   ├── common.json       # 通用文案(按钮、提示等)
│   ├── user.json         # 用户模块文案
│   ├── dashboard.json    # 仪表盘文案
│   └── validation.json   # 校验提示
├── en-US/                # 同上,英文版本
└── index.ts              # i18n 实例

16.2 使用规范

// ✅ 正确:使用 $t 函数或 useI18n composable
const { t } = useI18n()
const message = t('user.loginSuccess')

// ✅ 正确:带参数
const msg = t('user.welcome', { name: userInfo.value.name })

// ❌ 禁止:硬编码 UI 文案
// const message = '登录成功'

命名约定:层级使用点号分隔,key 使用 camelCase。

{
  "common": {
    "submit": "提交",
    "cancel": "取消",
    "confirm": "确认",
    "loading": "加载中..."
  },
  "user": {
    "login": "登录",
    "logout": "退出登录",
    "welcome": "欢迎回来,{name}",
    "loginSuccess": "登录成功",
    "loginFailed": "登录失败"
  }
}

17. 测试规范

17.1 测试金字塔

    ╱╲
   ╱ E2E ╲         ← Playwright(关键用户路径)
  ╱────────╲
 ╱ 集成测试 ╲       ← Vitest(Composables / Store / API)
╱────────────╲
╱  单元测试   ╲    ← Vitest(Utils / 纯函数 / 类型校验)

17.2 测试文件命名

测试文件统一放在 __tests__/ 子目录中(而非平级放置),便于 Vite test.include 模式匹配和目录清理时明确区分源码与测试。

src/
├── utils/
│   ├── format.ts
│   └── __tests__/
│       └── format.test.ts
├── composables/
│   ├── useTable.ts
│   └── __tests__/
│       └── useTable.test.ts
├── components/
│   ├── UserAvatar.vue
│   └── __tests__/
│       └── UserAvatar.test.ts

17.3 组件测试示例

// UserAvatar.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserAvatar from '../UserAvatar.vue'

describe('UserAvatar', () => {
  it('renders initials when no avatar URL provided', () => {
    const wrapper = mount(UserAvatar, {
      props: { name: '张三' },
    })
    // 组件应回退显示名字首字符
    expect(wrapper.text()).toContain('张')
  })

  it('renders img element when avatar URL provided', () => {
    const wrapper = mount(UserAvatar, {
      props: { name: '张三', avatarUrl: 'https://example.com/avatar.jpg' },
    })
    expect(wrapper.find('img').exists()).toBe(true)
    expect(wrapper.find('img').attributes('src')).toBe('https://example.com/avatar.jpg')
  })
}

17.4 E2E 测试(Playwright)

# 安装
pnpm add -D @playwright/test
npx playwright install chromium

目录与配置

e2e/
├── fixtures/                   # 测试夹具(登录状态、mock 数据)
│   └── auth.ts
├── pages/                      # Page Object 模型
│   ├── LoginPage.ts
│   └── DashboardPage.ts
├── specs/                      # 测试用例
│   ├── login.spec.ts
│   └── dashboard.spec.ts
└── playwright.config.ts
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
})

测试策略

  • E2E 只覆盖关键用户路径(登录、核心业务流程),不追求全覆盖

  • 日常开发用单元测试覆盖逻辑,E2E 在 CI 中运行

  • 使用 Page Object 模式分离页面操作与断言逻辑


18. 性能优化规范

18.1 编码层面

// ✅ v-for 必须指定 key(不使用 index 当 key,除非列表静态)
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
​
// ✅ 合理使用 v-if/v-show
// v-if → 条件很少变化(切换成本高,渲染成本低)
// v-show → 条件频繁变化(切换成本低,渲染成本高)
​
// ✅ 计算属性缓存:复杂计算使用 computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
​
// ✅ 函数防抖/节流(需安装 @vueuse/core,见 1.2 节可选依赖)
import { debounce } from '@vueuse/core'
const search = debounce((query: string) => api.search(query), 300)
​
// ✅ 虚拟列表(大数据量)
// 数据量 > 1000 条时使用 vue-virtual-scroller 或虚拟列表组件

18.2 构建层面

// ✅ 路由懒加载(Vite 自动代码分割,与第 10 节路由规范一致)
{
  path: '/user/profile',
  name: 'UserProfile',
  component: () => import('@/pages/user/UserProfile.vue'),
}
​
// ✅ 组件懒加载
const HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'))
​
// ✅ 图片懒加载
// <img v-lazy="image.src" />(使用自定义指令或 @vueuse/core 的 useIntersectionObserver)

18.3 包体积优化

✅ unplugin-icons → 按需加载图标,不打包整个图标库
✅ 按需引入 UI 组件库(Element Plus 使用自动按需导入插件)
✅ tree-shakeable 的第三方库(如 lodash-es 而非 lodash)
✅ 图片使用 WebP/AVIF 格式 + 响应式图片
❌ 禁止 import 整个库(import * as _ from 'lodash')

附录 A:Vue 3 语法强制规范速查

场景

✅ 正确

❌ 错误

组件语法

<script setup lang="ts">

Options API

响应式

ref() / reactive()

data() 函数

生命周期

onMounted(() => {})

mounted() {}

计算属性

const full = computed(() => ...)

computed: { full() }

侦听器

watch(source, callback)

watch: { source() }

组件注册

导入即用(setup 自动注册)

components: { X }

Props

defineProps<{ id: number }>()

props: { id: Number }

Emits

defineEmits<{ (e: 'update', v: T): void }>()

emits: ['update']

导入

import { ref } from 'vue'

import Vue from 'vue'

样式

<style scoped lang="scss">

无 scoped / 全局样式

附录 B:智能体指令(Agent Instructions)

以下内容专门给 AI 智能体阅读:

# 当智能体被要求编写 Vue 3 + TypeScript 项目时,必须:
interaction:
  - 在生成任何代码之前,必须询问用户目标平台(PC端/移动端/同时适配)和项目类型(后台管理/前台展示/混合)
  - 根据平台回答选择对应的 UI 组件库;用户无偏好时:PC 默认 Element Plus,移动端默认 Vant
  - 禁止自行假设目标平台或 UI 组件库
  - 详尽的询问规范见第 1.3 节
  - 需要日志/监控时按第 15 节引导用户配置 Sentry,不得自行安装修改
rules:
  - 使用 Composition API + <script setup lang="ts"> 语法
  - TypeScript strict 模式,禁止 any/@ts-ignore/@ts-expect-error
  - as 类型断言仅在 API 响应拦截器和有运行时校验的场景允许
  - 所有 API 请求通过 src/api/request.ts 封装的 Axios 实例发起
  - API 模块按资源拆分(src/api/modules/),见第 3 节
  - 工具函数优先使用 @vueuse/core / lodash-es / dayjs,自建前见第 2.3 节清单
  - 组件超过 250 行必须拆分
  - 使用 Pinia 管理全局状态,组件内部状态用 ref/reactive
  - 一个组件 = 一个 .vue 文件(SFC),禁止分离 template 和 script
  - 所有组件使用 scoped style,组件根节点使用组件同名 CSS class
  - Props 使用纯类型声明语法(defineProps<Type>() + withDefaults)
  - 模板 HTML 嵌套深度 ≤7 层,禁止 v-for + v-if 同元素,见第 4.7 节
  - 组件 name 通过文件名自动推导,递归组件/KeepAlive 场景须用 defineOptions() 指定,见第 4.4 节
  - 页面布局使用 100dvh 替代 100vh(移动端适配),见第 13 节
  - 权限控制:路由 meta.roles + v-permission 指令 + usePermission(),见第 11 节
  - 登录回跳:API 401 → 跳登录页 → 登录后 ?redirect= 参数回跳,见第 3.7 节
  - 前后端接口约定:统一响应格式、分页规范、错误码枚举,见第 3.8 节
  - 注释:公共函数/组件/枚举需写 JSDoc,无需写作者/日期,见第 7 节
  - 命名遵循本规范的命名约定表
  - 遵循 Conventional Commits 提交格式

版本: v1.2 适用框架: Vue 3 + TypeScript + Vite 最后更新: 2026-06-30 维护建议: 当技术栈升级或团队达成新的实践共识时,更新此文档。


Vue 3 + TypeScript 项目快速参考
https://halo.demox.asia/archives/vue-3-typescript-guide
作者
你的降灵
发布于
2026年06月30日
许可协议