Files
vibe_coding/.cursor/rules/references/011-vue-deep.md
2026-03-05 21:27:11 +08:00

619 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**。
## 组件规范
- **所有组件使用 `<script setup>`**Composition API + 语法糖,不使用 `lang` 类型标注)
- 禁止 Options API除非集成第三方库
- 禁止 Class 组件
- 组件嵌套不超过 3 层,超过则拆分
- 使用 `defineProps` + `defineEmits` 声明接口(编译时宏,无需导入)
- Named exports for composables组件文件本身使用 default exportVue 惯例)
## 项目目录结构
```
src/
├── api/ # API 接口层(按业务模块分目录)
│ ├── production/ # 生产管理 API
│ ├── auth.ts # 认证 API
│ └── common.ts # 通用 API
├── assets/ # 静态资源
│ ├── styles/ # 全局样式 (SCSS)
│ ├── icons/ # 图标
│ └── images/ # 图片
├── components/ # 公共组件
│ ├── core/ # 核心通用组件
│ └── custom/ # 业务组件
├── config/ # 前端配置
├── directives/ # 自定义指令
├── hooks/ # 组合式函数
├── layouts/ # 布局组件
├── locales/ # 国际化
├── router/ # 路由配置
│ ├── guards/ # 路由守卫
│ └── routes/ # 路由定义
├── store/ # Pinia 状态管理
│ └── modules/ # Store 模块
├── types/ # JSDoc 类型定义(可选,.ts 文件)
├── utils/ # 工具函数
│ ├── request.ts # Axios 封装
│ ├── storage/ # 本地存储工具
│ └── websocket/ # WebSocket 工具
└── views/ # 页面视图(按业务模块)
├── auth/ # 认证页面
├── dashboard/ # 仪表盘
└── production/ # 生产管理
```
## 组件模板
```vue
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'primary', // 'primary' | 'secondary' | 'ghost'
},
size: {
type: String,
default: 'default', // 'small' | 'default' | 'large'
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['click'])
</script>
<template>
<el-button
:type="props.variant === 'ghost' ? 'default' : props.variant"
:size="props.size"
:disabled="props.disabled"
@click="emit('click', $event)"
>
<slot />
</el-button>
</template>
```
## 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` 做组件懒加载
- [ ] 避免在 `<template>` 中写复杂表达式,提取为 `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
<!-- ForgotPanel.vue 直接修改 prop.form 的字段 -->
<script setup>
defineProps<{
form: { username: string; phone: string; code: string }
}>()
</script>
<template>
<AppInput v-model="form.username" />
<AppInput v-model="form.phone" />
<AppInput v-model="form.code" />
</template>
```
```vue
<!-- 修复后 通过 emit 通知父组件更新 -->
<script setup>
type FormData = { username: string; phone: string; code: string }
const props = defineProps<{ form: FormData }>()
const emit = defineEmits<{ 'update:form': [value: FormData] }>()
function updateField(field: keyof FormData, value: string) {
emit('update:form', { ...props.form, [field]: value })
}
</script>
<template>
<AppInput :model-value="form.username" @update:model-value="updateField('username', $event)" />
<AppInput :model-value="form.phone" @update:model-value="updateField('phone', $event)" />
<AppInput :model-value="form.code" @update:model-value="updateField('code', $event)" />
</template>
```
**父组件响应**`@update:form="Object.assign(formData, $event)"`
### 场景 B筛选器子组件动态 key 修改 prop
子组件通过 `v-model` 绑定 `prop[dynamicKey]`,遍历配置项时逐个修改 prop 的属性。
```vue
<!-- FilterDrawerBody.vue v-model 绑定动态 prop key -->
<RadioGroup v-model="radios[f.key]" :options="f.options" />
<CheckboxGroup v-model="checkboxes[f.key]" :options="f.options" />
<SelectField v-model="selects[f.key]" :options="f.options" />
```
```vue
<!-- 修复后 拆分为 :model-value + emit -->
<RadioGroup
:model-value="radios[f.key]"
:options="f.options"
@update:model-value="emit('update:radio', f.key, $event)"
/>
<CheckboxGroup
:model-value="checkboxes[f.key]"
:options="f.options"
@update:model-value="emit('update:checkbox', f.key, $event)"
/>
<SelectField
:model-value="selects[f.key]"
:options="f.options"
@update:model-value="emit('update:select', f.key, $event)"
/>
```
**父组件响应**`@update:radio="(key, val) => radios[key] = val"`
### 场景 C事件回调中赋值 prop 属性(隐蔽变体)
不通过 `v-model`,但在事件处理函数中直接赋值 prop 的属性。ESLint 同样检测为 prop mutation。
```vue
<!-- FilterDrawerBody.vue 事件回调中直接赋值 prop -->
<LogicTable
:values="logics"
@update-logic="(k: string, v: string) => logics[k] = v"
/>
```
```vue
<!-- 修复后 转发为 emit由父组件执行赋值 -->
<LogicTable
:values="logics"
@update-logic="(k: string, v: string) => 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 自动双向转换。