# 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` 做组件懒加载 - [ ] 避免在 `