Vue 3 + TypeScript 项目快速参考
目录
1. 项目技术栈
1.1 核心依赖
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.json2.2 目录结构原则
按特征(feature)组织,而非按类型(type)。相关文件就近存放。
每个模块内部保持内聚:pages/ 下每个页面文件夹如有私有组件,在页面目录下建
components/子目录。层级不超过 4 层:
src/pages/dashboard/components/charts/LineChart.vue已到极限。共享代码上提:某组件被 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-fnssrc/utils/ 只应包含以下内容
适配层:对第三方库的二次封装(如
storage.ts封装@vueuse/core的useStorage)项目特有逻辑:业务校验规则(
validate.ts)、金额/手机号脱敏等非通用格式化纯函数:无副作用、输入输出明确的工具函数
禁止:在
utils/中实现任何已有库已提供的功能
添加新工具函数的检查清单
在往 utils/ 中添加新函数之前,按顺序问:
这个功能是否已有成熟的 npm 包提供了? → 使用 npm 包
这个功能是否在
@vueuse/core或lodash-es中存在? → 直接引用是否可以对已有函数做薄封装而非重写? → 做适配层
以上都不是 → 才在
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 instance3.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 → 重放队列 → 重试当前请求
↓
失败 → 清空队列 → 登出 → 跳转登录页无限循环的三种防范
刷新 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 抽离原则
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 文件内)理由:
Vue 的 SFC 编译优化(模板引用推断、静态提升)依赖同一文件内的联合分析
分离后 template 与 script 的绑定关系需要手动同步,容易遗漏
<script setup>+ Composition API 已在文件内部实现了关注点分离编辑器 / 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-for 与 v-if
禁止在同一元素上同时使用 v-for 和 v-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]✅ feat(user): add profile avatar upload
✅ fix(auth): handle token expiry redirect loop
✅ refactor(table): extract pagination logic into composable
❌ fix bug
❌ update code5.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 命名约定
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规则:
所有环境变量必须以
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 库单独打包
},
},
},
},
})插件清单
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 无错误常用命令:
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(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 => /* ... */)
}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 规范
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 原则
按领域拆分:一个 store 只管理一个业务领域的状态
避免 store 循环引用:store A 引用 store B,B 又引用 A → 提取共同状态到新 store
持久化:通过
utils/storage.ts封装类手动管理持久化(如 8.1 示例所示),每个 store 独立控制要持久化的字段。避免引入额外插件增加复杂度不要在 store 中放置组件特有的 UI 状态(如 modal 是否显示、表单临时数据)
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 路由规范
懒加载:所有页面组件使用
() => import()动态导入命名路由:每个路由必须指定
name(用于编程式导航)Meta 元信息:利用
meta传递权限、标题、图标等信息路由守卫:统一在
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 权限管理检查清单
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+。
13.4 页面级布局模板(Sticky Footer + 自适应内容)
标准全页面布局,确保 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 布局规范检查清单
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(官方提供 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/xxxxxxStep 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 监控检查清单
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.ts17.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 语法强制规范速查
附录 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 维护建议: 当技术栈升级或团队达成新的实践共识时,更新此文档。