Files
mes-ui-d2/docs/表格组件使用说明.md
sheng a61036e5dc
Some checks failed
Release pipeline / publish (push) Has been cancelled
Release pipeline / Always run job (push) Has been cancelled
feat: 新增角色管理模块,优化API与交互体验
1.  新增角色管理后台页面、路由与国际化文案
2.  重构API请求错误处理逻辑,统一拦截业务与HTTP错误
3.  新增确认弹窗组合式函数,区分取消与请求错误场景
4.  完善表格按钮权限与显示控制逻辑
5.  更新API参数规范与文档说明
6.  修复部分页面分页数据解析问题
2026-05-28 19:16:05 +08:00

1200 lines
42 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-常见问题排查)
---
## 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` 则不显示。
---
## 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. 常见问题排查
### 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。