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

18 KiB
Raw Permalink Blame History

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/         # 生产管理

组件模板

<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,按需导入
// 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 / useRoutervue-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 自动双向转换。