619 lines
18 KiB
Markdown
619 lines
18 KiB
Markdown
# 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 export(Vue 惯例)
|
||
|
||
## 项目目录结构
|
||
|
||
```
|
||
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 自动双向转换。
|