# 011-vue.mdc (Deep Reference)
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
---
# 🟢 Vue 3 / Vite Standards
> **⚠️ 双前端区分**:本文件中的 Element Plus 相关内容**仅适用于管理端** (`Case-Database-Frontend-admin/`)。
> 用户端 (`Case-Database-Frontend-user/`) 使用 Headless UI + Tailwind CSS,**禁止引入 Element Plus**。
## 组件规范
- **所有组件使用 `
```
## Element Plus 使用规范(仅管理端)
> 以下内容仅适用于 `Case-Database-Frontend-admin/`,用户端禁止使用 Element Plus。
- **按需导入**:使用 `unplugin-vue-components` + `unplugin-auto-import` 自动导入
- **表单验证**:使用 Element Plus 内置 `el-form` rules,复杂场景配合 `async-validator`
- **主题定制**:通过 SCSS 变量覆盖 Element Plus 默认主题
- **图标**:使用 `@element-plus/icons-vue`,按需导入
```typescript
// 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({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] }),
],
})
```
## 状态管理选择
| 场景 | 方案 |
|------|------|
| 组件内部 | `ref` / `reactive` |
| 计算属性 | `computed` |
| 跨组件共享 | **Pinia** (`defineStore`) |
| 服务端状态 | Axios + Pinia Action |
| 表单验证 | 管理端:Element Plus `el-form` rules;用户端:自定义验证 |
| URL 状态 | `useRoute` / `useRouter`(vue-router) |
## Axios 封装规范
```typescript
// src/utils/request.ts
import axios from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 60000,
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
})
// Request interceptor: inject token
service.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor: unified error handling
service.interceptors.response.use(
(response) => {
const { code, data, message } = response.data
if (code === 200) return data
return Promise.reject(new Error(message || 'Request failed'))
},
(error) => {
if (error.response?.status === 401) {
// Token expired -> redirect to login
}
return Promise.reject(error)
},
)
```
## Vue Router 路由规范
```typescript
// src/router/guards/auth.ts
export function setupAuthGuard(router) {
router.beforeEach(async (to, _from, next) => {
const token = localStorage.getItem('access_token')
if (to.meta.requiresAuth && !token) {
return next({ name: 'login', query: { redirect: to.fullPath } })
}
// Dynamic route loading for RBAC
if (token && !hasLoadedDynamicRoutes()) {
await loadDynamicRoutes(router)
return next({ ...to, replace: true })
}
next()
})
}
```
## Composables 目录约定
所有 composable 位于 `src/hooks/`,以 `use` 前缀命名:
| Composable | 职责 | 返回值 |
|------------|------|--------|
| `useTable` | 表格数据加载、分页、搜索、排序 | `{ loading, dataList, pagination, loadData, handleSearch, handleReset }` |
| `useForm` | 表单状态、校验、提交 | `{ formRef, formData, rules, loading, handleSubmit, handleReset }` |
| `useAuth` | 权限判断、按钮/菜单可见性 | `{ hasAuth, hasRole, checkPermission }` |
| `useCommon` | 通用工具(字典、枚举转换) | `{ getDictLabel, formatEnum }` |
| `useFastEnter` | 快速导航、全局搜索 | `{ visible, keyword, results, navigate }` |
| `useHeaderBar` | 顶部栏状态(全屏、消息、用户) | `{ isFullscreen, toggleFullscreen, unreadCount }` |
| `useSmsCode` | 短信验证码倒计时 | `{ countdown, isSending, sendCode }` |
| `useTableColumns` | 表格列配置、列显隐持久化 | `{ columns, visibleColumns, toggleColumn }` |
| `useTheme` | 主题切换 + 系统跟随 | `{ isDark, toggleTheme, colorPrimary }` |
| `useChart` | ECharts 实例管理、自动 resize | `{ chartRef, setOption, resize }` |
| `useWebSocket` | WebSocket 连接管理 | `{ connect, disconnect, send, onMessage }` |
```typescript
// src/hooks/useTable.ts — 标准实现
export function useTable(fetchFn) {
const loading = ref(false)
const dataList = ref([])
const pagination = reactive({ current: 1, size: 10, total: 0 })
const searchForm = reactive({})
async function loadData() {
loading.value = true
try {
const result = await fetchFn({
page: pagination.current,
page_size: pagination.size,
...searchForm,
})
dataList.value = result.data
pagination.total = result.total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleReset() {
Object.keys(searchForm).forEach((key) => { searchForm[key] = undefined })
handleSearch()
}
function handlePageChange(page) {
pagination.current = page
loadData()
}
return { loading, dataList, pagination, searchForm, loadData, handleSearch, handleReset, handlePageChange }
}
```
## 自定义指令
所有指令位于 `src/directives/`:
| 指令 | 用途 | 用法示例 |
|------|------|---------|
| `v-auth` | 按钮级权限控制(权限标识) | `v-auth="'system:user:add'"` |
| `v-roles` | 角色级权限控制 | `v-roles="['admin', 'manager']"` |
| `v-focus` | 自动聚焦输入框 | `v-focus` |
| `v-highlight` | 搜索关键词高亮 | `v-highlight="keyword"` |
| `v-index` | 序号自动生成 | `v-index="{ page, size }"` |
| `v-ripple` | 点击波纹效果 | `v-ripple` |
```typescript
// src/directives/auth.ts
import { useUserStore } from '@/store/modules/user'
export const vAuth = {
mounted(el, binding) {
const userStore = useUserStore()
if (!userStore.permissions.includes(binding.value)) {
el.parentNode?.removeChild(el)
}
},
}
// src/directives/roles.ts
export const vRoles = {
mounted(el, binding) {
const userStore = useUserStore()
const hasRole = binding.value.some((role) => userStore.roles.includes(role))
if (!hasRole) {
el.parentNode?.removeChild(el)
}
},
}
```
## 布局组件约定
布局组件位于 `src/layouts/`:
| 组件 | 职责 |
|------|------|
| `ArtSidebarMenu` | 侧边导航菜单(递归菜单树) |
| `ArtHeader` | 顶部栏(面包屑 + 用户 + 通知 + 全屏) |
| `ArtWorkTab` | 多标签页管理(右键菜单、拖拽排序) |
| `ArtNotification` | 通知面板(WebSocket 实时推送) |
| `ArtChatWindow` | 即时通讯窗口 |
| `ArtFastEnter` | 全局快速搜索导航(Ctrl+K) |
| `ArtGlobalSearch` | 全局搜索弹窗 |
## Pinia Store 模块映射
| Store | 职责 | 持久化 | 加密 |
|-------|------|--------|------|
| `user` | 用户信息、Token、权限、角色 | ✅ | ✅ Token 加密 |
| `menu` | 菜单树、动态路由 | ✅ | ❌ |
| `setting` | 主题、布局、语言设置 | ✅ | ❌ |
| `worktab` | 标签页状态 | ✅ | ❌ |
| `table` | 表格列配置、列显隐 | ✅ | ❌ |
| `notification` | 通知消息、未读计数 | ❌ | ❌ |
| `workflow` | 审批流程状态 | ❌ | ❌ |
| `product` | 产品/生产模块状态 | ❌ | ❌ |
| `outsideflow` | 外部审批流程 | ❌ | ❌ |
```typescript
// Pinia 加密持久化示例
import CryptoJS from 'crypto-js'
const ENCRYPT_KEY = import.meta.env.VITE_STORAGE_KEY || 'default-key'
function encrypt(data) {
return CryptoJS.AES.encrypt(data, ENCRYPT_KEY).toString()
}
function decrypt(data) {
return CryptoJS.AES.decrypt(data, ENCRYPT_KEY).toString(CryptoJS.enc.Utf8)
}
export const useUserStore = defineStore('user', () => {
// ... state and actions
}, {
persist: {
key: 'user-store',
storage: {
getItem: (key) => {
const raw = localStorage.getItem(key)
return raw ? decrypt(raw) : null
},
setItem: (key, value) => {
localStorage.setItem(key, encrypt(value))
},
},
pick: ['token', 'refreshToken'],
},
})
```
## 路由守卫完整流程
```typescript
// src/router/guards/index.ts
import NProgress from 'nprogress'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { useWorktabStore } from '@/store/modules/worktab'
const WHITE_LIST = ['/login', '/register', '/404', '/403']
export function setupRouterGuards(router) {
router.beforeEach(async (to, _from, next) => {
NProgress.start()
document.title = `${to.meta.title || ''} - ${import.meta.env.VITE_APP_TITLE}`
const userStore = useUserStore()
const token = userStore.token
// Step 1: 白名单放行
if (WHITE_LIST.includes(to.path)) {
return next()
}
// Step 2: 无 Token -> 登录
if (!token) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
// Step 3: 已登录访问登录页 -> 首页
if (to.path === '/login') {
return next({ path: '/' })
}
// Step 4: 动态路由未加载 -> 加载
const menuStore = useMenuStore()
if (!menuStore.isRoutesLoaded) {
try {
await menuStore.loadDynamicRoutes(router)
return next({ ...to, replace: true })
} catch {
userStore.logout()
return next({ path: '/login' })
}
}
next()
})
router.afterEach((to) => {
NProgress.done()
// Step 5: 更新标签页
const worktabStore = useWorktabStore()
if (to.meta.title && !to.meta.hideTab) {
worktabStore.addTab({
path: to.path,
title: to.meta.title,
name: to.name,
})
}
})
}
```
## WebSocket 集成模式
```typescript
// src/utils/websocket/notification.ts
import { useUserStore } from '@/store/modules/user'
import { useNotificationStore } from '@/store/modules/notification'
class NotificationWebSocket {
ws = null
reconnectTimer = null
heartbeatTimer = null
reconnectAttempts = 0
maxReconnectAttempts = 5
connect() {
const userStore = useUserStore()
if (!userStore.token) return
const wsUrl = `${import.meta.env.VITE_WS_URL}?token=${userStore.token}`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.reconnectAttempts = 0
this.startHeartbeat()
}
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data)
this.handleMessage(message)
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.tryReconnect()
}
}
handleMessage(message) {
const notificationStore = useNotificationStore()
switch (message.type) {
case 'notification':
notificationStore.addNotification(message.data)
break
case 'heartbeat':
// Pong
break
}
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
this.ws?.send(JSON.stringify({ type: 'heartbeat', data: 'ping' }))
}, 30000)
}
stopHeartbeat() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
}
tryReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++
this.connect()
}, Math.min(1000 * 2 ** this.reconnectAttempts, 30000))
}
disconnect() {
this.stopHeartbeat()
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.ws?.close()
this.ws = null
}
}
export const notificationWs = new NotificationWebSocket()
```
## 性能检查清单
- [ ] 路由使用 `() => import()` 懒加载
- [ ] 列表使用稳定 `:key`(非 index)
- [ ] 大列表使用虚拟化(`@tanstack/vue-virtual`)
- [ ] 使用 `defineAsyncComponent` 做组件懒加载
- [ ] 避免在 `` 中写复杂表达式,提取为 `computed`
- [ ] 动态组件使用 `shallowRef` 存储组件引用
- [ ] 管理端:Element Plus 按需导入,避免全量引入(用户端不使用 Element Plus)
- [ ] Vite 构建配置 `manualChunks` 分离 vendor
- [ ] Pinia 敏感数据(Token)使用加密持久化
- [ ] WebSocket 连接登录后建立,登出后断开
## Props 反模式全集
> 以下案例来源于项目实际 ESLint 修复(`vue/no-mutating-props`),作为永久参考。
### 场景 A:表单子组件 v-model 直接修改对象 prop
父组件传递 `reactive` 对象作为 prop,子组件用 `v-model` 直接绑定其字段。
```vue
```
```vue
```
**父组件响应**:`@update:form="Object.assign(formData, $event)"`
### 场景 B:筛选器子组件动态 key 修改 prop
子组件通过 `v-model` 绑定 `prop[dynamicKey]`,遍历配置项时逐个修改 prop 的属性。
```vue
```
```vue
```
**父组件响应**:`@update:radio="(key, val) => radios[key] = val"`
### 场景 C:事件回调中赋值 prop 属性(隐蔽变体)
不通过 `v-model`,但在事件处理函数中直接赋值 prop 的属性。ESLint 同样检测为 prop mutation。
```vue
logics[k] = v"
/>
```
```vue
emit('update:logic', k, v)"
/>
```
**父组件响应**:`@update:logic="(key, val) => logics[key] = val"`
### 附:事件命名规范
以上修复中同步修正了 `defineEmits` 的事件命名:
```typescript
// ❌ kebab-case(触发 vue/custom-event-name-casing)
defineEmits<{
'add-room-condition': []
'remove-room-condition': [id: number]
'update-room-condition': [id: number, field: string, value: string | number]
}>()
// ✅ camelCase
defineEmits<{
addRoomCondition: []
removeRoomCondition: [id: number]
updateRoomCondition: [id: number, field: string, value: string | number]
}>()
```
模板中 `@add-room-condition` 和 `@addRoomCondition` 均可,Vue 自动双向转换。