18 KiB
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/ # 生产管理
组件模板
<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-formrules,复杂场景配合async-validator - 主题定制:通过 SCSS 变量覆盖 Element Plus 默认主题
- 图标:使用
@element-plus/icons-vue,按需导入
// 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 封装规范
// 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 路由规范
// 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 } |
// 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 |
// 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 |
外部审批流程 | ❌ | ❌ |
// 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'],
},
})
路由守卫完整流程
// 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 集成模式
// 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 直接绑定其字段。
<!-- ❌ 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>
<!-- ✅ 修复后 — 通过 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 的属性。
<!-- ❌ 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" />
<!-- ✅ 修复后 — 拆分为 :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。
<!-- ❌ FilterDrawerBody.vue — 事件回调中直接赋值 prop -->
<LogicTable
:values="logics"
@update-logic="(k: string, v: string) => logics[k] = v"
/>
<!-- ✅ 修复后 — 转发为 emit,由父组件执行赋值 -->
<LogicTable
:values="logics"
@update-logic="(k: string, v: string) => emit('update:logic', k, v)"
/>
父组件响应:@update:logic="(key, val) => logics[key] = val"
附:事件命名规范
以上修复中同步修正了 defineEmits 的事件命名:
// ❌ 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 自动双向转换。