Files
mes-ui-d2/docs/表格组件使用说明.md
sheng 20a821ba32 feat: 完成系统管理模块功能迭代
新增用户、菜单、日志、问题帮助等业务模块,优化角色权限分配功能,新增依赖包与全局组件
2026-05-29 18:12:54 +08:00

1529 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 表格组件使用说明
> 基于 `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 和 labelidx 自动补齐
// 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-treeel-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)