# 表格组件使用说明

> 基于 `page-table` + `page-dialog-form` 的新一代 CRUD 表格方案。  
> 源码位置：`src/components/page-table/`、`src/components/page-dialog-form/`、`src/composables/`  
> 完整可运行示例：`src/views/production-master-data/factory-model/factory-area/index.vue`

---

## 目录

1. [快速开始（三步走）](#1-快速开始三步走)
2. [完整示例代码](#2-完整示例代码)
3. [组件 API 参考](#3-组件-api-参考)
4. [Composable API 参考](#4-composable-api-参考)
5. [i18n 国际化方案](#5-i18n-国际化方案)
6. [常用场景速查](#6-常用场景速查)
7. [路由配置](#7-路由配置)
8. [API 文件写法](#8-api-文件写法)
9. [旧代码迁移对照](#9-旧代码迁移对照)
10. [接口请求错误处理规范](#10-接口请求错误处理规范)
11. [依赖安装规范](#11-依赖安装规范)
12. [常见问题排查](#12-常见问题排查)
13. [特殊弹出框组件规范](#13-特殊弹出框组件规范)

---

## 1. 快速开始（三步走）

### 第一步：定义列和按钮（`created()` 中）

```js
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'

export default {
  mixins: [i18nMixin('page.模块名.二级模块.三级模块')],

  created () {
    // --- 列定义 ---
    // 只需声明 prop 和 label，idx 自动补齐
    // prop: '_actions' 约定为操作列，自动渲染 rowButtons
    this.columns = useTableColumns([
      { prop: 'sort', label: this.key('sort'), width: 80 },
      { prop: 'code', label: this.key('code'), minWidth: 120 },
      { prop: 'name', label: this.key('name') },
      { prop: '_actions', label: this.key('operation'), width: 160, fixed: 'right' }
    ])

    // --- 按钮定义 ---
    // 不再分开写 buttonList / tableButtonList
    const btns = useTableButtons({
      toolbar: [
        { key: 'add', label: this.key('add'), icon: 'el-icon-plus',
          type: 'primary', auth: '/xxx/create', onClick: this.openAdd }
      ],
      row: [
        { key: 'edit', label: this.key('edit'), icon: 'el-icon-edit',
          auth: '/xxx/edit', onClick: this.openEdit },
        { key: 'delete', label: this.key('delete'), icon: 'el-icon-delete',
          color: 'danger', auth: '/xxx/delete', onClick: this.handleDelete }
      ]
    }, this.$permission) // 第二个参数传入权限校验函数
    this.toolbarButtons = btns.toolbarButtons
    this.rowButtons = btns.rowButtons
  }
}
```

### 第二步：写模板（`<template>` 中）

```vue
<template>
  <d2-container>
    <!-- 搜索区 -->
    <template #header>
      <el-form :inline="true" size="mini">
        <el-form-item :label="$t(key('code'))">
          <el-input v-model="search.code" :placeholder="$t(key('enter_code'))"
                    clearable style="width:200px" @keyup.enter.native="onSearch" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" icon="el-icon-search" @click="onSearch">
            {{ $t(key('search')) }}
          </el-button>
          <el-button icon="el-icon-refresh" @click="onReset">
            {{ $t(key('reset')) }}
          </el-button>
        </el-form-item>
      </el-form>
    </template>

    <!-- 表格 + 按钮栏 + 分页 -->
    <page-table
      :columns="columns"
      :data="tableData"
      :loading="loading"
      :toolbar-buttons="toolbarButtons"
      :row-buttons="rowButtons"
      :pagination="pagination"
      help-url="/help/your-page"
      :help-text="$t(ckey('help'))"
      auto-height
      @page-change="onPageChange"
      @selection-change="onSelect"
    />

    <!-- 新增/编辑弹框 -->
    <page-dialog-form
      ref="dialogForm"
      :visible.sync="dialogVisible"
      :title="dialogTitle"
      width="35%"
      :form-cols="formCols"
      :form-data="formData"
      :rules="rules"
      :submitting="submitting"
      :confirm-text="key('confirm')"
      :cancel-text="key('cancel')"
      @submit="onDialogSubmit"
      @close="onDialogClose"
    />
  </d2-container>
</template>
```

### 第三步：写业务方法（增删改查）

```js
methods: {
  // 获取列表数据
  async fetchData () {
    this.loading = true
    try {
      const res = await getList({
        ...this.search,
        page_no: this.pagination.current,
        page_size: this.pagination.size
      })
      this.tableData = res.data || []
      this.pagination.total = res.count || 0
    } finally { this.loading = false }
  },

  // 搜索 / 重置
  onSearch () { this.pagination.current = 1; this.fetchData() },
  onReset () { this.search = { code: '', name: '' }; this.pagination.current = 1; this.fetchData() },

  // 分页变化
  onPageChange (page) {
    this.pagination.current = page.current
    this.pagination.size = page.size
    this.fetchData()
  },

  // 新增：打开弹窗
  openAdd () {
    this.handleType = 'create'
    this.dialogTitle = this.key('add_title')
    this.$nextTick(() => {
      this.$refs.dialogForm.reset()
      this.resetForm()
      this.dialogVisible = true
    })
  },

  // 编辑：回填表单
  openEdit (row) {
    this.handleType = 'edit'
    this.dialogTitle = this.key('edit_title')
    this.editId = row.id
    this.formData = { code: row.code, name: row.name }
    this.dialogVisible = true
  },

  // 提交表单
  async onDialogSubmit () {
    this.submitting = true
    try {
      if (this.handleType === 'create') {
        await createApi(this.formData)
      } else {
        await editApi({ ...this.formData, id: this.editId })
      }
      this.$message.success(this.$t(this.key('operation_success')))
      this.dialogVisible = false
      this.fetchData()
    } finally { this.submitting = false }
  },

  // 关闭弹窗
  onDialogClose () { this.resetForm() },

  // 删除
  async handleDelete (row) {
    try {
      await this.$confirm(
        this.$t(this.key('confirm_delete')),
        this.$t(this.key('tip')),
        { confirmButtonText: this.$t(this.key('confirm')),
          cancelButtonText: this.$t(this.key('cancel')),
          type: 'warning', closeOnClickModal: false }
      )
      await deleteApi({ id: [row.id] })
      this.$message.success(this.$t(this.key('operation_success')))
      this.fetchData()
    } catch (e) { /* 取消或失败 */ }
  }
}
```

---

## 2. 完整示例代码

> 📁 `src/views/production-master-data/factory-model/factory-area/index.vue`  
> 这是一个**可直接运行的完整 CRUD 页面**，包含搜索栏、表格、分页、新增/编辑弹框、删除确认。

### 模板部分

```vue
<template>
  <d2-container>
    <template #header>
      <div class="search-bar">
        <el-form :inline="true" size="mini">
          <el-form-item :label="$t(key('code'))">
            <el-input v-model="search.code" :placeholder="$t(key('enter_code'))"
                      clearable style="width:200px" @keyup.enter.native="onSearch" />
          </el-form-item>
          <el-form-item :label="$t(key('name'))">
            <el-input v-model="search.name" :placeholder="$t(key('enter_name'))"
                      clearable style="width:200px" @keyup.enter.native="onSearch" />
          </el-form-item>
          <el-form-item>
            <el-button type="primary" icon="el-icon-search" @click="onSearch">
              {{ $t(key('search')) }}
            </el-button>
            <el-button icon="el-icon-refresh" @click="onReset">
              {{ $t(key('reset')) }}
            </el-button>
          </el-form-item>
        </el-form>
      </div>
    </template>

    <page-table
      ref="pageTable"
      :columns="columns"
      :data="tableData"
      :loading="loading"
      :toolbar-buttons="toolbarButtons"
      :row-buttons="rowButtons"
      :pagination="pagination"
      help-url="/help/factory-area"
      :help-text="$t(ckey('help'))"
      auto-height
      @page-change="onPageChange"
      @selection-change="onSelect"
    />

    <page-dialog-form
      ref="dialogForm"
      :visible.sync="dialogVisible"
      :title="dialogTitle"
      width="35%"
      :form-cols="formCols"
      :form-data="formData"
      :rules="rules"
      :submitting="submitting"
      :confirm-text="key('confirm')"
      :cancel-text="key('cancel')"
      @submit="onDialogSubmit"
      @close="onDialogClose"
    />
  </d2-container>
</template>
```

### Script 部分

```js
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { getFactoryAreaList, createFactoryArea, editFactoryArea, deleteFactoryArea }
  from '@/api/production-master-data/factory-area'
import PageTable from '@/components/page-table'
import PageDialogForm from '@/components/page-dialog-form'

export default {
  name: 'factory-area',
  components: { PageTable, PageDialogForm },
  mixins: [i18nMixin('page.production_master_data.factory_model.factory_area')],

  data () {
    return {
      loading: false,
      submitting: false,
      tableData: [],
      selectedRows: [],
      dialogVisible: false,
      dialogTitle: '',
      editId: '',
      handleType: 'create',
      search: { code: '', name: '' },
      pagination: { current: 1, size: 10, total: 0 },
      formData: { code: '', name: '', remark: '' },
      rules: {
        code: [
          { required: true, message: this.key('enter_code'), trigger: 'blur' },
          { min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
        ],
        name: [
          { required: true, message: this.key('enter_name'), trigger: 'blur' },
          { min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
        ]
      },
      columns: [],
      toolbarButtons: [],
      rowButtons: [],
      formCols: [
        [
          { type: 'input', prop: 'code',
            label: this.key('code'), placeholder: this.key('enter_code'),
            clearable: true, style: { width: '90%' } }
        ],
        [
          { type: 'input', prop: 'name',
            label: this.key('name'), placeholder: this.key('enter_name'),
            clearable: true, style: { width: '90%' } }
        ],
        [
          { type: 'input', prop: 'remark', inputType: 'textarea',
            autosize: { minRows: 2, maxRows: 6 },
            label: this.key('remark'), placeholder: this.key('remark_required'),
            clearable: true, style: { width: '90%' } }
        ]
      ]
    }
  },

  created () {
    this.columns = useTableColumns([
      { prop: 'sort', label: this.key('sort'), width: 80 },
      { prop: 'code', label: this.key('code'), minWidth: 120 },
      { prop: 'name', label: this.key('name'), minWidth: 120 },
      { prop: 'remark', label: this.key('remark') },
      { prop: '_actions', label: this.key('operation'), width: 160, fixed: 'right' }
    ])
    const btns = useTableButtons({
      toolbar: [
        { key: 'add', label: this.key('add'), icon: 'el-icon-plus', type: 'primary',
          auth: '/production_master_data/factory_model/factory_area/create',
          onClick: this.openAdd }
      ],
      row: [
        { key: 'edit', label: this.key('edit'), icon: 'el-icon-edit',
          auth: '/production_master_data/factory_model/factory_area/edit',
          onClick: this.openEdit },
        { key: 'delete', label: this.key('delete'), icon: 'el-icon-delete',
          color: 'danger',
          auth: '/production_master_data/factory_model/factory_area/delete',
          onClick: this.handleDelete }
      ]
    }, this.$permission)
    this.toolbarButtons = btns.toolbarButtons
    this.rowButtons = btns.rowButtons
    this.fetchData()
  },

  methods: {
    async fetchData () {
      this.loading = true
      try {
        const res = await getFactoryAreaList({
          ...this.search, page_no: this.pagination.current, page_size: this.pagination.size
        })
        this.tableData = res.data || []
        this.pagination.total = res.count || 0
      } finally { this.loading = false }
    },

    onSearch () { this.pagination.current = 1; this.fetchData() },
    onReset () { this.search = { code: '', name: '' }; this.pagination.current = 1; this.fetchData() },
    onPageChange (page) {
      this.pagination.current = page.current
      this.pagination.size = page.size
      this.fetchData()
    },
    onSelect (rows) { this.selectedRows = rows },

    resetForm () { this.formData = { code: '', name: '', remark: '' }; this.editId = '' },

    openAdd () {
      this.handleType = 'create'
      this.dialogTitle = this.key('add_title')
      this.$nextTick(() => {
        this.$refs.dialogForm && this.$refs.dialogForm.reset()
        this.resetForm()
        this.dialogVisible = true
      })
    },

    openEdit (row) {
      this.handleType = 'edit'
      this.dialogTitle = this.key('edit_title')
      this.editId = row.id
      this.formData = { code: row.code, name: row.name, remark: row.remark || '' }
      this.dialogVisible = true
    },

    async onDialogSubmit () {
      this.submitting = true
      try {
        if (this.handleType === 'create') {
          await createFactoryArea(this.formData)
        } else {
          await editFactoryArea({ ...this.formData, id: this.editId })
        }
        this.$message.success(this.$t(this.key('operation_success')))
        this.dialogVisible = false
        this.fetchData()
      } finally { this.submitting = false }
    },

    onDialogClose () { this.resetForm() },

    async handleDelete (row) {
      try {
        await this.$confirm(
          this.$t(this.key('confirm_delete')),
          this.$t(this.key('tip')),
          { confirmButtonText: this.$t(this.key('confirm')),
            cancelButtonText: this.$t(this.key('cancel')),
            type: 'warning', closeOnClickModal: false }
        )
        await deleteFactoryArea({ id: [row.id] })
        this.$message.success(this.$t(this.key('operation_success')))
        this.pagination.current = Math.min(
          this.pagination.current,
          Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
        )
        this.fetchData()
      } catch (e) { /* 取消删除 / 请求失败不处理 */ }
    }
  }
}
```

---

## 3. 组件 API 参考

### 3.1 `page-table` — 表格 + 按钮栏 + 分页

#### Props

| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `columns` | `Array` | `[]` | 列定义，由 `useTableColumns()` 生成 |
| `data` | `Array` | `[]` | 表格行数据 |
| `loading` | `Boolean` | `false` | 是否显示 loading 遮罩 |
| `height` | `String/Number` | — | 表格高度，传 `'auto'` 启用自适应 |
| `auto-height` | `Boolean` | `false` | 启用高度自适应，填满可用空间 |
| `border` | `Boolean` | `true` | 是否带边框 |
| `row-key` | `String` | `'id'` | 行唯一 key |
| `toolbar-buttons` | `Array` | `[]` | 顶部工具栏按钮，由 `useTableButtons()` 生成 |
| `row-buttons` | `Array` | `[]` | 行内操作按钮，由 `useTableButtons()` 生成 |
| `pagination` | `Object` | `null` | 分页参数 `{ current, size, total }` |
| `table-attrs` | `Object` | `{}` | 额外透传给 `el-table` 的属性 |
| `table-listeners` | `Object` | `{}` | 额外透传给 `el-table` 的事件 |
| `help-url` | `String` | `''` | 帮助文档 URL，传了显示问号按钮 |
| `help-text` | `String` | `'帮助'` | 帮助按钮文字，支持 i18n key |

#### 事件

| 事件名 | 参数 | 说明 |
|--------|------|------|
| `@page-change` | `{ current, size, total }` | 分页变化 |
| `@selection-change` | `rows: Array` | 选中行变化 |

#### 插槽

| 插槽名 | 作用域 | 说明 |
|--------|--------|------|
| `#col-{prop}` | `{ row, index }` | 自定义列渲染（列需设 `slot: true`） |
| `#toolbar-extra` | — | 工具栏追加内容 |
| `#empty` | — | 空数据占位 |
| `#append` | — | 表格末尾追加 |
| `#extra` | — | 页面底部追加 |

#### 列定义规范

```js
// 普通列
{ prop: 'code', label: '编码', minWidth: 120 }

// 操作列（约定：prop === '_actions'）
{ prop: '_actions', label: '操作', width: 160, fixed: 'right' }

// 自定义插槽列（slot: true）
{ prop: 'status', label: '状态', slot: true, width: 100 }

// 复选框 + 序号列（通过 useTableColumns 第二个参数）
useTableColumns([...], { selectionWidth: 55, indexWidth: 60 })
```

---

### 3.2 `page-dialog-form` — 增删改查弹框

#### Props

| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `visible` | `Boolean` | `false` | 弹框显隐，使用 `.sync` |
| `title` | `String` | `''` | 弹框标题，支持 i18n key |
| `width` | `String` | `'35%'` | 弹框宽度 |
| `form-cols` | `Array` | `[]` | 表单字段结构（二维数组） |
| `form-data` | `Object` | `{}` | 表单数据对象 |
| `rules` | `Object` | `{}` | 校验规则，与 `el-form` rules 一致 |
| `label-width` | `String` | `'100px'` | label 宽度 |
| `submitting` | `Boolean` | `false` | 提交 loading 状态 |
| `confirm-text` | `String` | `'确定'` | 确定按钮文字，支持 i18n key |
| `cancel-text` | `String` | `'取消'` | 取消按钮文字，支持 i18n key |

#### 事件

| 事件名 | 说明 |
|--------|------|
| `@submit` | 表单验证通过后触发 |
| `@close` | 弹框关闭后触发 |

#### 方法（通过 ref 调用）

| 方法 | 说明 |
|------|------|
| `reset()` | 重置表单 |
| `validate()` | 手动验证，返回 `Promise<boolean>` |

#### formCols 数据结构

传入 i18n key（用 `this.key()` 拼接完整 key），组件内部自动翻译且跟随语言切换：

```js
formCols: [
  [
    { type: 'input', prop: 'code',
      label: this.key('code'), placeholder: this.key('enter_code'),
      clearable: true, style: { width: '90%' } }
  ],
  [
    { type: 'input', prop: 'name',
      label: this.key('name'), placeholder: this.key('enter_name'),
      clearable: true, style: { width: '90%' } }
  ],
  [
    { type: 'input', prop: 'remark', inputType: 'textarea',
      autosize: { minRows: 2, maxRows: 6 },
      label: this.key('remark'), placeholder: this.key('remark_required'),
      clearable: true, style: { width: '90%' } }
  ]
]
```

**字段支持的属性：**

| 属性 | 适用类型 | 说明 |
|------|---------|------|
| `type` | 全部 | `'input'`（文本） / `'select'`（下拉） |
| `prop` | 全部 | 绑定 `formData` 中的 key |
| `label` | 全部 | 表单项标签，支持 i18n key |
| `placeholder` | 全部 | 占位提示，支持 i18n key |
| `inputType` | `input` | `'textarea'` / 不传为普通 text |
| `autosize` | `input` | textarea 自适应：`{ minRows, maxRows }` |
| `clearable` | 全部 | 是否可清空，默认 `true` |
| `style` | 全部 | 样式对象 |
| `options` | `select` | 选项数组 `[{ label, value }]` |
| `filterable` | `select` | 是否可搜索，默认 `true` |

---

## 4. Composable API 参考

### 4.1 `useTableColumns(rawColumns, options?)`

**作用**：消除手动分配 `idx` 序号，自动识别操作列和插槽列。

| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `rawColumns` | `Array` | — | 列定义 |
| `options.selectionWidth` | `number` | `55` | 复选框列宽，传 `0` 隐藏 |
| `options.indexWidth` | `number` | `0` | 序号列宽，传 `0` 隐藏 |

```js
const columns = useTableColumns([
  { prop: 'code', label: '编码', width: 120 },
  { prop: 'name', label: '名称' },
  { prop: '_actions', label: '操作', width: 160, fixed: 'right' }  // 操作列
])
```

**内部约定**：`prop === '_actions'` 自动标记为 `slot: '_actions'`，由 `page-table` 渲染为操作列。

---

### 4.2 `useTableButtons(options, permissionCheck?)`

**作用**：统一生成工具栏按钮和行内操作按钮，自动注入权限校验结果。

| 参数 | 类型 | 说明 |
|------|------|------|
| `options.toolbar` | `Array` | 顶部按钮列表 |
| `options.row` | `Array` | 行内按钮列表 |
| `permissionCheck` | `Function` | 权限函数，通常为 `this.$permission` |

**返回值**：`{ toolbarButtons, rowButtons }`

**按钮字段：**

| 字段 | toolbar | row | 说明 |
|------|:---:|:---:|------|
| `key` | √ | √ | 唯一标识 |
| `label` | √ | √ | 显示文本 |
| `icon` | √ | √ | Element UI 图标 |
| `type` | √ | — | `primary`/`success`/`warning`/`danger` |
| `color` | — | √ | `'danger'` 使文字变红 |
| `auth` | √ | √ | 权限 key |
| `cssStyle` | √ | √ | 自定义样式 |
| `onClick` | √ | √ | 点击回调 |
| `hasPermission` | √ | √ | **自动注入**，由权限函数计算 |
| `needSelection` | √ | — | 需选中行才能点击 |

```js
const btns = useTableButtons({
  toolbar: [
    { key: 'add', label: '新增', icon: 'el-icon-plus', type: 'primary',
      auth: '/xxx/create', onClick: this.openAdd }
  ],
  row: [
    { key: 'edit', label: '编辑', icon: 'el-icon-edit',
      auth: '/xxx/edit', onClick: this.openEdit },
    { key: 'delete', label: '删除', icon: 'el-icon-delete',
      color: 'danger', auth: '/xxx/delete', onClick: this.handleDelete }
  ]
}, this.$permission)
```

---

### 4.3 `i18nMixin(prefix)`

**作用**：注入 `key(suffix)` 和 `ckey(suffix)` 两个方法，消除每个页面手动定义常量和 `tkey` 方法。

| 方法 | 返回值 | 说明 |
|------|--------|------|
| `key(suffix)` | `prefix.suffix` | 当前页面的 i18n key |
| `ckey(suffix)` | `page.common.suffix` | 公共 i18n key |

| 参数 | 类型 | 说明 |
|------|------|------|
| `prefix` | `String` | 该页面的 i18n key 前缀 |

```js
import { i18nMixin } from '@/composables/useI18n'

export default {
  mixins: [i18nMixin('page.production_master_data.factory_model.factory_area')],
  // 模板中用 $t(key('code')) 访问当前页面翻译
  // 模板中用 $t(ckey('help')) 访问公共翻译 'page.common.help'
  // JS 中用 this.$t(this.key('code')) / this.$t(this.ckey('help'))
}
```

**`data()` 中 i18n key 传参技巧**：

```js
data () {
  return {
    // 传入完整 i18n key，组件内部 $t() 翻译，切换语言自动响应
    formCols: [
      [{ label: this.key('code'), placeholder: this.key('enter_code') }]
    ],
    rules: {
      code: [{ required: true, message: this.key('enter_code'), trigger: 'blur' }]
    }
  }
}
```

> **原理**：`page-table` / `page-dialog-form` 内部使用 `$t()` 翻译传入的 key。`$t()` 是 Vue I18n 的响应式方法，切换语言时自动返回新的翻译结果，触发组件重新渲染。不要用 `k()` 等辅助函数提前翻译成静态字符串，否则语言切换后不会更新。

---

## 5. i18n 国际化方案

### 5.1 语言包文件

| 语言 | 文件路径 |
|------|---------|
| 简体中文 | `src/locales/zh-chs.json` |
| 繁体中文 | `src/locales/zh-cht.json` |
| 英文 | `src/locales/en.json` |
| 日文 | `src/locales/ja.json` |

### 5.2 语言包结构

按 `一级模块 > 二级模块 > 三级模块` 三层嵌套：

```json
{
  "page": {
    "production_master_data": {
      "factory_model": {
        "factory_area": {
          "search": "查询",
          "reset": "重置",
          "code": "所区编码",
          "name": "所区名称",
          "add": "新 增",
          "edit": "编 辑",
          "delete": "删 除",
          "enter_code": "请输入所区编码",
          "enter_name": "请输入所区名称",
          "add_title": "新增所区",
          "edit_title": "编辑所区",
          "confirm": "确定",
          "cancel": "取消",
          "operation_success": "操作成功"
        }
      }
    },
    "common": {
      "help": "帮 助"
    }
  }
}
```

### 5.3 组件自动翻译

`page-table` 和 `page-dialog-form` 内部使用 `$t()` 翻译：

- **列 label** — 自动翻译
- **按钮 label** — 自动翻译
- **表单 label / placeholder** — 自动翻译
- **弹框 title / confirm / cancel** — 自动翻译
- **验证规则 message** — 自动翻译（`translatedRules` 计算属性）

页面只需传入 i18n key，组件渲染时自动替换为当前语言的文本。

### 5.4 页面中手动翻译

搜索区、提示消息、`$confirm` 等 UI 文字需手动 `$t()`：

```vue
<!-- 模板中：key() 返回 i18n key -->
<el-form-item :label="$t(key('code'))">
  <el-input :placeholder="$t(key('enter_code'))" />
</el-form-item>
<el-button>{{ $t(key('search')) }}</el-button>
```

```js
// JS 中
this.$message.success(this.$t(this.key('operation_success')))
this.$confirm(this.$t(this.key('confirm_delete')), this.$t(this.key('tip')), { ... })
```

---

## 6. 常用场景速查

### 场景 1：自定义列渲染（状态 tag）

列定义加 `slot: true`：

```js
useTableColumns([
  { prop: 'status', label: '状态', slot: true, width: 100 }
])
```

模板中用 `#col-status` 插槽：

```vue
<page-table :columns="columns" :data="tableData">
  <template #col-status="{ row }">
    <el-tag :type="row.status === 1 ? 'success' : 'danger'">
      {{ row.status === 1 ? '启用' : '禁用' }}
    </el-tag>
  </template>
</page-table>
```

### 场景 2：工具栏追加自定义按钮

```vue
<page-table :columns="columns" :toolbar-buttons="toolbarButtons">
  <template #toolbar-extra>
    <el-button size="mini" icon="el-icon-printer" @click="print">打印</el-button>
  </template>
</page-table>
```

### 场景 3：复选框 + 序号列

```js
useTableColumns(
  [
    { prop: 'code', label: '编码' },
    { prop: 'name', label: '名称' }
  ],
  { selectionWidth: 55, indexWidth: 60 }
)
```

### 场景 4：带下拉选择的表单

```js
formCols: [
  [
    { type: 'select', prop: 'area_id',
      label: this.key('area'), placeholder: this.key('select_area'),
      options: [{ label: 'A厂区', value: 1 }, { label: 'B厂区', value: 2 }],
      clearable: true, style: { width: '90%' } }
  ]
]
```

### 场景 5：批量删除（工具栏 + 需要选中行）

```js
useTableButtons({
  toolbar: [
    { key: 'batchDelete', label: '批量删除', icon: 'el-icon-delete',
      type: 'danger', auth: '/xxx/batch-delete',
      needSelection: true,  // 需要选中行才能点击
      onClick: this.batchDelete }
  ]
})
```

### 场景 6：表格自适应高度

只需加 `auto-height` 属性：

```vue
<page-table ... auto-height />
```

表格高度自动填满 `d2-container` 的 body 区域剩余空间，窗口 resize / 侧栏折叠时自动重算。

### 场景 7：帮助按钮

传 `help-url` 即可在工具栏最右侧显示问号帮助按钮：

```vue
<page-table ... help-url="/help/factory-area" :help-text="$t(ckey('help'))" />
```

推荐使用公共 key `page.common.help`（模板中用 `$t(ckey('help'))`）。不传 `help-url` 则不显示。

### 场景 8：折叠式搜索区（搜索条件过多时）

当搜索条件超过一行时，用 `v-show` + `searchExpanded` 控制额外条件的显隐，避免 header 区域过长。

**数据定义（`data()` 中）**：

```js
data () {
  return {
    searchExpanded: false,  // 控制展开/收起
    // ...其他数据
  }
}
```

**模板结构**：

```vue
<template>
  <d2-container>
    <template #header>
      <div class="search-bar">
        <el-form :inline="true" ref="searchFormRef" size="mini" @submit.native.prevent>
          <!-- 常用条件：始终可见 -->
          <el-form-item :label="$t(key('ip'))">
            <el-input v-model="search.ip" :placeholder="$t(key('placeholder_ip'))"
                      clearable style="width:160px" @keyup.enter.native="onSearch" />
          </el-form-item>
          <el-form-item :label="$t(key('status'))">
            <el-select v-model="search.status" :placeholder="$t(key('placeholder_status'))"
                       clearable style="width:120px">
              <el-option :value="200" :label="$t(key('success'))" />
              <el-option :value="4001" :label="$t(key('failure'))" />
            </el-select>
          </el-form-item>
          <!-- 搜索 + 重置 + 展开/收起按钮 -->
          <el-form-item>
            <el-button type="primary" icon="el-icon-search" @click="onSearch">
              {{ $t(key('search')) }}
            </el-button>
            <el-button icon="el-icon-refresh" @click="onReset">
              {{ $t(key('reset')) }}
            </el-button>
            <el-button
              v-if="!searchExpanded"
              type="text"
              icon="el-icon-arrow-down"
              @click="searchExpanded = true"
            >
              {{ $t(key('expand')) }}
            </el-button>
            <el-button
              v-else
              type="text"
              icon="el-icon-arrow-up"
              @click="searchExpanded = false"
            >
              {{ $t(key('collapse')) }}
            </el-button>
          </el-form-item>

          <!-- 扩展条件：v-show 控制显隐 -->
          <div v-show="searchExpanded" class="search-bar__extra">
            <el-form-item :label="$t(key('tray_number'))">
              <el-input v-model="search.tray" :placeholder="$t(key('placeholder_tray_no'))"
                        clearable style="width:160px" @keyup.enter.native="onSearch" />
            </el-form-item>
            <el-form-item :label="$t(key('process_code'))">
              <el-input v-model="search.process_code"
                        :placeholder="$t(key('placeholder_process_code'))"
                        clearable style="width:160px" @keyup.enter.native="onSearch" />
            </el-form-item>
            <el-form-item :label="$t(key('create_time'))">
              <el-date-picker v-model="search.time" type="datetimerange"
                              :placeholder="$t(key('placeholder_create_time'))"
                              range-separator="-" start-placeholder="" end-placeholder=""
                              value-format="yyyy-MM-dd HH:mm:ss" style="width:340px" />
            </el-form-item>
          </div>
        </el-form>
      </div>
    </template>
    <!-- ... -->
  </d2-container>
</template>
```

**样式**：

```css
.search-bar {
  padding: 10px 0;
}
.search-bar .el-form-item--mini.el-form-item {
  margin-bottom: 4px;
}
.search-bar__extra {
  display: inline-block;
  width: 100%;
}
```

**i18n 附加 key**：

| key | 中文 | English |
|-----|------|---------|
| `expand` | 展开更多 | Expand |
| `collapse` | 收起 | Collapse |

**设计要点**：

1. 常用高频筛选条件放在第一行始终可见（如 IP、状态）
2. 低频条件用 `v-show="searchExpanded"` 包裹，默认隐藏
3. 展开/收起按钮放在操作按钮组同一行，使用 `type="text"` + 箭头图标
4. 折叠时重置不展开，重置只清空 `search` 数据，不改变 `searchExpanded` 状态
5. 适用于只读类页面（如日志查看），无需选中框和批量操作

**完整参考**：[接口日志页](file:///d:/code/mes/mes-ui/src/views/system-administration/system-utilities/api-logs/index.vue)

---

## 7. 路由配置

```js
// src/router/modules/production-master-data.js
import layoutHeaderAside from '@/layout/header-aside'

const meta = { auth: true }
const _import = require('@/libs/util.import.' + process.env.NODE_ENV)

export default {
  path: '/production_master_data',
  component: layoutHeaderAside,
  children: (pre => [
    {
      path: 'factory_model/factory_area',
      name: `${pre}factory_model-factory_area`,
      meta: { ...meta, cache: true, title: '工厂区域' },
      component: _import('production-master-data/factory-model/factory-area')
    }
  ])('production_master_data-')
}
```

之后在 `src/router/routes.js` 中引入该模块并加入 `frameIn` 数组：

```js
import productionConfiguration from './modules/production-master-data'

const frameIn = [
  // ... 其他路由 ...
  productionConfiguration
]
```

---

## 8. API 文件写法

```js
// src/api/production-master-data/factory-area.js
import { request } from '@/api/_service'

const BASE = 'production_master_data/factory_model/factory_area/'

function apiParams (method, data = {}) {
  return {
    method: `production_master_data_factory_model_factory_area_${method}`,
    platform: 'background',
    ...data
  }
}

export function getFactoryAreaList (data) {
  return request({
    url: BASE + 'list',
    method: 'get',
    params: apiParams('list', data)
  })
}

export function createFactoryArea (data) {
  return request({
    url: BASE + 'create',
    method: 'post',
    data: apiParams('create', data)
  })
}

export function editFactoryArea (data) {
  return request({
    url: BASE + 'edit',
    method: 'put',
    data: apiParams('edit', data)
  })
}

export function deleteFactoryArea (data) {
  return request({
    url: BASE + 'delete',
    method: 'delete',
    data: apiParams('delete', data)
  })
}
```

---

## 9. 旧代码迁移对照

| 旧写法 | 新写法 |
|--------|--------|
| 手动构建 `columns` 带 `idx` 序号 | `useTableColumns([{ prop, label }])` |
| `buttonList` + `tableButtonList` 分开 | `useTableButtons({ toolbar: [...], row: [...] })` |
| `<sct-base-table>` + `<sct-base-dialog>` + `<SctBaseForm>` | `<page-table>` + `<page-dialog-form>` |
| `<sct-form-search>` 组件 | 原生 `<el-form :inline>` |
| `<sct-back-to-top>` 组件 | 需要时自行添加 |
| 额外 `<page-footer>` 分页 | `page-table` 内置分页 |
| 每个页面定义 `T` 常量 + `tkey()` 方法 | `mixins: [i18nMixin(prefix)]`，使用 `this.key('xxx')` |
| `data()` 中用 `k()` 提前翻译 | 用 `this.key()` 传 key，组件内部 `$t()` 翻译 |

---

## 10. 接口请求错误处理规范

> 错误处理采用 **三层架构**：拦截器全局兜底 → confirmMixin 区分取消/错误 → 页面标准模式。

### 10.1 错误处理总流程

```
                    ┌─────────────────────┐
                    │   页面发起 API 请求   │
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │  是否需 confirm ?    │
                    └─────┬────────┬──────┘
                          │ 否     │ 是
                          │        │
                          │   ┌────▼───────────┐
                          │   │ $confirmAction() │
                          │   │  (confirmMixin)  │
                          │   └────┬──────┬─────┘
                          │    取消 │      │ 确认
                          │   ┌────▼──┐    │
                          │   │return │    │
                          │   │ true  │    │
                          │   └───────┘    │
                          │                │
               ┌──────────▼────────────────▼──┐
               │         axios 请求            │
               └──────────┬───────────────────┘
                          │
              ┌───────────▼────────────┐
              │   HTTP 状态码是什么？    │
              └──┬──────────────┬──────┘
           200  │              │ 非 200
                │              │
    ┌───────────▼──────┐  ┌───▼─────────────┐
    │  response.data.  │  │ HTTP 拦截器       │
    │  code 是什么？    │  │ error.message =  │
    └──┬───────────┬───┘  │ '未授权/超时/...' │
  0    │           │≠0    └───┬──────────────┘
       │           │          │
  ┌────▼──┐  ┌─────▼──────┐  │
  │resolve│  │handleError()│◄─┘
  │ data  │  │Message.error│
  └───────┘  └─────┬──────┘
                   │
              ┌────▼────┐
              │ throw   │
              │ error   │
              └────┬────┘
                   │
         ┌─────────▼─────────┐
         │ 页面层 catch 到了吗？│
         └────┬──────────┬───┘
         没写  │          │ 写了
              │          │
    ┌─────────▼──┐  ┌────▼─────────────┐
    │ 什么都不发生 │  │ finally 关锁      │
    │（依赖拦截器  │  │ loading/submitting│
    │  Message）  │  │ = false           │
    └────────────┘  └──────────────────┘
```

### 10.2 三层架构详图

```
┌─────────────────────────────────────────────────┐
│              第一层：全局拦截器                     │
│           src/api/_service.js                     │
│                                                   │
│  拦截所有 axios 响应，统一处理两类错误：              │
│                                                   │
│  HTTP 错误 (4xx/5xx)                              │
│  ├─ 400 → error.message = '请求错误'               │
│  ├─ 401 → error.message = '未授权，请登录'          │
│  ├─ 403 → error.message = '拒绝访问'               │
│  ├─ 404 → error.message = '请求地址出错'            │
│  ├─ 408 → error.message = '请求超时'               │
│  ├─ 500 → error.message = '服务器内部错误'          │
│  ├─ 502 → error.message = '网关错误'               │
│  ├─ ... 其它状态码                                 │
│  │                                                │
│  └─ handleError(error)                            │
│     ├─ store.dispatch('d2admin/log/push', ...)    │
│     ├─ console.log(error)  (dev only)             │
│     └─ Message.error(error.message)  ← 弹红色提示  │
│                                                   │
│  业务错误 (code ≠ 0, 如 code=500 "参数错误")       │
│  ├─ 取出 response.data.msg 或 '请求失败'            │
│  ├─ const err = new Error(`${msg}: ${url}`)      │
│  ├─ handleError(err)                              │
│  └─ throw err  ← 继续向上抛给页面层                 │
└──────────────────────┬──────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────┐
│             第二层：confirmMixin                   │
│        src/composables/useConfirmHandle.js        │
│                                                   │
│  $confirmAction(confirmOpts, action)              │
│  ├─ 第一层 try/catch                              │
│  │   await this.$confirm(...)                     │
│  │   ├─ 用户点"确定" → 继续                        │
│  │   └─ 用户点"取消" → catch → return true         │
│  │                                                │
│  └─ 第二层 try/catch                              │
│      await action()   ← 执行 API 调用              │
│      ├─ 成功 → return false                        │
│      └─ 失败 → catch → return false                │
│          （拦截器已弹出 Message，此处静默吞掉）        │
└──────────────────────┬──────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────┐
│              第三层：页面标准模式                    │
│                                                   │
│  场景A — 查询 (fetchData)                         │
│  ┌─────────────────────────────────────────────┐ │
│  │ async fetchData () {                         │ │
│  │   this.loading = true                        │ │
│  │   try {                                      │ │
│  │     const res = await getList(...)            │ │
│  │     this.tableData = res.data                 │ │
│  │   } finally { this.loading = false }          │ │
│  │ }                                            │ │
│  └─────────────────────────────────────────────┘ │
│  • try 内无 catch：拦截器已弹错误提示               │
│  • finally 始终执行：loading 无论成败都关闭          │
│                                                   │
│  场景B — 提交 (onDialogSubmit)                     │
│  ┌─────────────────────────────────────────────┐ │
│  │ async onDialogSubmit () {                    │ │
│  │   this.submitting = true                     │ │
│  │   try {                                      │ │
│  │     await createApi(this.formData)            │ │
│  │     this.$message.success(...)  ← 成功才执行  │ │
│  │     this.dialogVisible = false               │ │
│  │     this.fetchData()                         │ │
│  │   } finally { this.submitting = false }       │ │
│  │ }                                            │ │
│  └─────────────────────────────────────────────┘ │
│  • 出错时弹窗不关，用户可修改后重试                  │
│                                                   │
│  场景C — 删除 (handleDelete)                       │
│  ┌─────────────────────────────────────────────┐ │
│  │ async handleDelete (row) {                   │ │
│  │   const cancelled = await this.$confirmAction(│ │
│  │     { message: this.key('confirm_delete'),   │ │
│  │       title: this.key('tip') },              │ │
│  │     () => deleteApi({ id: row.id })          │ │
│  │   )                                          │ │
│  │   if (cancelled) return  ← 用户取消，静默       │ │
│  │   this.$message.success(...)  ← 成功才执行     │ │
│  │   this.fetchData()                           │ │
│  │ }                                            │ │
│  └─────────────────────────────────────────────┘ │
│  • $confirmAction 内已处理错误 Message，不额外 catch│
│  • 没有 try/catch 包裹 → 代码更简洁直观             │
└─────────────────────────────────────────────────┘
```

### 10.3 关键原则

| 原则 | 说明 |
|------|------|
| **拦截器兜底** | 所有 API 错误（HTTP + 业务）统一在 `_service.js` 中弹出 `Message.error` |
| **finally 关锁** | `loading` / `submitting` 用 `try { ... } finally { lock = false }`，不论成败都关闭 |
| **只用 catch 不动** | 成功后的 `$message.success` / `dialogVisible = false` / `fetchData` 不放在 `finally`，只有成功才执行 |
| **$confirmAction 包装** | 删除/批量操作类的 confirm + API 用 `$confirmAction()` 区分取消和错误 |
| **不吞业务错误** | `fetchData` / `onDialogSubmit` 的 try 只写 `finally`，不写 `catch`，错误由拦截器处理 |

### 10.4 confirmMixin 使用

页面中引入：

```js
import { confirmMixin } from '@/composables/useConfirmHandle'

export default {
  mixins: [
    i18nMixin('page.xxx.xxx'),
    confirmMixin               // ← 新增
  ]
}
```

API：

```js
// $confirmAction(confirmOpts, actionFn) → Promise<boolean>
// 返回 true  = 用户点了取消
// 返回 false = action 已执行完成（无论成功失败）

const cancelled = await this.$confirmAction(
  {
    message: this.key('confirm_delete'),   // confirm 正文
    title: this.key('tip'),                // confirm 标题
    // type: 'warning',                    // 可选，默认 'warning'
    // confirmButtonText / cancelButtonText 可选，默认用 page.common 的
  },
  () => deleteApi({ id: row.id })          // 确认后执行的异步函数
)
if (cancelled) return
// 以下只在成功时执行
this.$message.success(...)
this.fetchData()
```

---

## 11. 依赖安装规范

### 包管理器

本项目使用 **pnpm** 作为包管理器，**禁止使用 npm 或 yarn**。

### 安装新依赖

当页面需要额外的第三方库时（如 `mavon-editor`、`vue-json-tree-view` 等），使用：

```bash
pnpm add <package-name>
```

### 常见页面依赖速查

| 依赖包 | 用途 | 安装命令 |
|--------|------|---------|
| `mavon-editor` | Markdown 编辑器（问题帮助、文档编辑） | `pnpm add mavon-editor` |
| `vue-json-tree-view` | JSON 树形展示（日志查看响应） | 已预装 |
| `marked` + `highlight.js` | Markdown 渲染（d2-markdown 组件依赖） | 已预装 |

### 注意事项

1. 安装前检查 `package.json` 是否已有该依赖（`vue-json-tree-view`、`marked` 等项目已预装）
2. 安装后确认版本兼容性，特别是 Vue 2.x 项目不要安装仅支持 Vue 3 的包
3. 若因网络问题安装失败，检查 registry 配置（本项目使用 `npmmirror.com` 镜像）

---

## 12. 常见问题排查

### Q1：弹框打开后不显示内容？

确认 `formCols` 中的 `label` 和 `placeholder` 是否传入了正确的 i18n key。应该用 `this.key()` 拼接完整 key，不要用 `k()` 提前翻译成静态字符串。

### Q2：表格高度自适应不生效？

确认 `d2-container` 的 body 区域高度被正确约束（flex 填充）。给 `d2-container` 的 body 部分设置 `overflow: hidden` 通常可解决。

### Q3：权限校验有按钮不显示？

`useTableButtons` 的第二个参数必须传入 `this.$permission`。如果项目没有注册这个全局方法，可以传入 `() => true` 占位。

### Q4：新增一条后页码不跳回第一页？

在 `onDialogSubmit` 成功后调用 `this.fetchData()` 前，如果删除当前页最后一行导致 `total - 1` 超出范围，需手动修正页码。

### Q5：切换语言后弹框/表格内容不更新？

检查 `data()` 中是否使用了 `k()` 等辅助函数提前翻译。应该用 `this.key()` 传 i18n key，由组件内部 `$t()` 处理翻译，这样切换语言时才能自动响应。

### Q6：表单验证错误提示显示为原始 i18n key？

`page-dialog-form` 会通过 `translatedRules` 计算属性自动翻译验证规则的 `message` 字段。确认传入的 `rules.message` 使用了 `this.key()` 传入完整 key。

---

## 13. 特殊弹出框组件规范

### 适用范围

当页面需要**超出 `page-dialog-form` 能力的弹出框**时（如权限分配树、多步骤向导、ifream 嵌入、复杂联动表单等），**禁止在 `index.vue` 中直接堆砌内联代码**，必须抽离为独立组件。

### 目录结构标准

```
src/views/{模块}/{功能}/
├── index.vue                      ← 主页面，只负责引入和组装
└── components/                    ← 所有额外弹框统一放这里
    ├── PermDrawer/                ← 示例：权限分配抽屉
    │   └── index.vue
    ├── ImportDialog/              ← 示例：批量导入
    │   └── index.vue
    └── DetailDrawer/              ← 示例：详情抽屉
        └── index.vue
```

### 命名规范

| 规则 | 说明 | 示例 |
|------|------|------|
| 组件文件夹 | **英文 PascalCase**，描述功能 | `PermDrawer`（权限抽屉）、`ImportDialog`（导入弹框）、`DetailDrawer`（详情抽屉） |
| 组件入口文件 | 统一 `index.vue` | `PermDrawer/index.vue` |
| 组件注册名 | 英文 PascalCase 或 kebab-case 前缀 | `RolePermDrawer`、`role-perm-drawer` |
| 禁止 | 中文名、拼音、模糊命名 | ❌ `权限分配/`、❌ `quanxian/`、❌ `Popup/` |

### 主页面用法（`index.vue`）

```vue
<template>
  <d2-container>
    <!-- 搜索区 -->
    <template #header>...</template>

    <!-- 标准 CRUD 表格 -->
    <page-table ... />

    <!-- 标准新增/编辑弹框 -->
    <page-dialog-form ... />

    <!-- 特殊弹框：仅引用 + 传 props，简洁清晰 -->
    <role-perm-drawer
      :visible.sync="permVisible"
      :role="permRole"
      :title="key('assign_permissions')"
      :confirm-text="key('confirm')"
      :cancel-text="key('cancel')"
      @saved="fetchData"
    />
  </d2-container>
</template>

<script>
import RolePermDrawer from './components/PermDrawer/index.vue'

export default {
  components: { ..., RolePermDrawer },
  data () {
    return {
      permVisible: false,
      permRole: {}     // 只存当前操作行，其余逻辑全在子组件
    }
  },
  methods: {
    openPermDialog (row) {
      this.permRole = row
      this.permVisible = true
    }
  }
}
</script>
```

### 子组件模板（`components/PermDrawer/index.vue`）

```vue
<template>
  <el-drawer
    :visible.sync="visibleProxy"
    :title="$t(title)"
    :size="width"
    :close-on-click-modal="false"
    direction="rtl"
    @close="onClose"
  >
    <div v-loading="loading">
      <!-- 业务内容，如 el-tree、el-transfer 等 -->
    </div>

    <div class="my-drawer__footer">
      <el-button size="mini" @click="onCancel">{{ $t(cancelText) }}</el-button>
      <el-button type="primary" size="mini" :loading="submitting" @click="onSubmit">
        {{ $t(confirmText) }}
      </el-button>
    </div>
  </el-drawer>
</template>

<script>
import { i18nMixin } from '@/composables/useI18n'

export default {
  name: 'RolePermDrawer',
  mixins: [i18nMixin('page.xxx.xxx.xxx')],
  props: {
    visible: Boolean,            // 用 .sync 双向绑定
    title: String,               // 弹框标题（i18n key）
    confirmText: String,         // 确定按钮文本（i18n key）
    cancelText: String,          // 取消按钮文本（i18n key）
    width: { type: String, default: '360px' }
    // 业务 props 按需添加，如 role、data 等
  },
  data () {
    return {
      loading: false,
      submitting: false
    }
  },
  computed: {
    visibleProxy: {
      get () { return this.visible },
      set (val) { this.$emit('update:visible', val) }
    }
  },
  watch: {
    visible (val) {
      if (val) { this.init() }   // 打开时加载数据
    }
  },
  methods: {
    init () { /* 加载数据 */ },
    onSubmit () {
      this.submitting = true
      try {
        // 提交后
        this.$emit('saved')      // 通知父组件刷新
        this.visibleProxy = false
      } finally {
        this.submitting = false
      }
    },
    onCancel () { this.visibleProxy = false },
    onClose () { /* 清理状态 */ }
  }
}
</script>
```

### 组件通信约定

| 方向 | 方式 | 说明 |
|------|------|------|
| 父 → 子 | `props` | 传递 `visible`（sync）、业务数据对象、i18n key |
| 子 → 父 | `$emit` | `@saved` 通知父组件刷新表格，`@closed` 通知关闭完成 |
| 避免 | `$parent` / `$refs` | 禁止子组件通过 `$refs.parent.xxx` 访问父组件方法 |

### 判断标准：何时需要独立组件？

| 场景 | 使用方案 |
|------|---------|
| 新增/编辑表单（input + select + textarea） | `page-dialog-form` 即可 |
| 权限分配树 | **独立组件** → `components/PermDrawer/` |
| 批量导入/导出向导 | **独立组件** → `components/ImportDialog/` |
| 关联数据选择器（多选表格） | **独立组件** → `components/SelectorDialog/` |
| 详情查看（非编辑） | **独立组件** → `components/DetailDrawer/` |
| 复杂多步骤流程 | **独立组件** → `components/FlowWizard/` |

### 实际案例

参考完整实现：
- 权限分配抽屉：[`src/views/system-administration/user-management/role/components/PermDrawer/index.vue`](file:///d:/code/mes/mes-ui/src/views/system-administration/user-management/role/components/PermDrawer/index.vue)
- 主页面引用方式：[`src/views/system-administration/user-management/role/index.vue`](file:///d:/code/mes/mes-ui/src/views/system-administration/user-management/role/index.vue#L79-L87)
