初始化
This commit is contained in:
618
.cursor/rules/references/011-vue-deep.md
Normal file
618
.cursor/rules/references/011-vue-deep.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# 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 自动双向转换。
|
||||
Reference in New Issue
Block a user