Files
mes-ui-d2/src/components/page-table/index.vue

523 lines
16 KiB
Vue
Raw Normal View History

<template>
<!--
page-table CRUD 表格便捷组合体
================================
这是一个把顶部按钮栏 + 数据表格 + 底部分页打包在一起的高层组件
适合 80% 的增删改查页面你只需要传列定义按钮定义数据和分页参数即可
依赖element-ui <el-table> <el-button> <el-pagination>
i18n由调用方负责翻译组件直接渲染传入的文本
@author 前端团队
@since 2026-05
-->
<div
ref="root"
class="page-table"
:class="{ 'page-table--auto': autoHeight || height === 'auto' }"
v-loading="loading"
>
<!-- ==================== 顶部工具栏按钮区 ==================== -->
<!--
toolbarButtons useTableButtons({ toolbar: [...] }) 生成
内部自动过滤 hasPermission === false 的按钮无权限不显示
按钮被 disabled 只在前端计算 needSelection 需要选中行实际权限由后端校验
-->
<div ref="toolbar" class="page-table__toolbar" v-if="toolbarButtons.length || helpUrl">
<div class="page-table__toolbar-left">
<el-button
v-for="btn in visibleToolbarButtons"
:key="btn.key"
:type="btn.type"
:size="btn.size"
:icon="btn.icon"
:style="btn.cssStyle"
:disabled="btn.needSelection && !selectedCount"
@click="btn.onClick"
>
{{ $t(btn.label) }}
</el-button>
<!-- 自定义工具栏内容如打印按钮列筛选器等 -->
<slot name="toolbar-extra" />
</div>
<!-- 帮助按钮始终显示在工具栏最右侧点击新窗口打开帮助文档 -->
<el-button
v-if="helpUrl"
class="page-table__help-btn"
size="mini"
icon="el-icon-question"
@click="openHelp"
>
{{ helpText }}
</el-button>
</div>
<!-- ==================== 中部数据表格 ==================== -->
<!--
height 由外部传入或自动计算autoHeight=true
v-bind="tableAttrs" / v-on="tableListeners" 透传 el-table 原生属性和事件
-->
<div ref="tableWrapper" class="page-table__body">
<el-table
ref="table"
:data="data"
:height="tableHeight"
:border="border"
:row-key="rowKey"
:header-cell-style="headerCellStyle"
v-bind="tableAttrs"
v-on="tableListeners"
@selection-change="onSelectionChange"
>
<!--
遍历 columns有五种列类型
1. type='selection' 复选框列
2. type='index' 序号列
3. slot='_actions' 操作列自动渲染 rowButtons
4. slot/headerSlot 自定义插槽列
5. 其他 普通数据列
-->
<template v-for="col in columns">
<!-- 1. 复选框列 -->
<el-table-column
v-if="col.type === 'selection'"
:key="'sel-' + col.idx"
type="selection"
:width="col.width"
/>
<!-- 2. 序号列 -->
<el-table-column
v-else-if="col.type === 'index'"
:key="'idx-' + col.idx"
type="index"
:width="col.width"
:label="$t(col.label) || '#'"
/>
<!-- 3. 操作列自动渲染 rowButtons编辑/删除等行内按钮 -->
<!--
约定columns prop='_actions' 的列会被识别为操作列
rowButtons useTableButtons({ row: [...] }) 生成
红色按钮通过 color='danger' 控制
-->
<el-table-column
v-else-if="col.slot === '_actions' && rowButtons.length"
:key="'act-' + col.idx"
v-bind="colAttrs(col)"
>
<template #default="{ row, $index }">
<template v-for="btn in visibleRowButtons">
<span
v-if="btn.visible(row)"
:key="btn.key"
:style="btn.cssStyle"
:class="{ 'action-btn--danger': btn.color === 'danger' }"
class="action-btn"
@click="btn.onClick(row, $index)"
>
<i v-if="btn.icon" :class="btn.icon" />
{{ $t(btn.label) }}
</span>
</template>
</template>
</el-table-column>
<!-- 4. 自定义插槽列列内容或表头可由父组件自定义 -->
<!--
使用方式
- col.slot='status' 父组件用 <template #col-status="{ row }">
- col.headerSlot='xyz' 父组件用 <template #xyz> 自定义表头
-->
<el-table-column
v-else-if="col.slot || col.headerSlot"
:key="'slot-' + col.idx"
v-bind="colAttrs(col)"
>
<template #header="{ column }">
<slot v-if="col.headerSlot" :name="col.headerSlot" :data="col" :column="column">
<span>{{ column.label }}</span>
</slot>
<span v-else>{{ column.label }}</span>
</template>
<template #default="{ row, $index }">
<slot :name="'col-' + col.slot" :row="row" :index="$index">
<span>{{ row[col.prop] }}</span>
</slot>
</template>
</el-table-column>
<!-- 5. 普通数据列直接按 prop row 数据 -->
<el-table-column
v-else
:key="'col-' + col.idx"
v-bind="colAttrs(col)"
/>
</template>
<!-- 表格无数据时显示的占位内容 -->
<template #empty>
<slot name="empty" />
</template>
<!-- 表格最后一行之后追加的内容 -->
<template #append>
<slot name="append" />
</template>
<slot />
</el-table>
</div>
<!-- ==================== 底部分页组件 ==================== -->
<!--
只传 pagination 对象{ current, size, total }才显示分页
page-change 事件回传 { current, size, total }父组件更新 pagination 后重新 fetchData
-->
<div ref="footer" class="page-table__footer" v-if="pagination">
<el-pagination
:current-page="pagination.current"
:page-size="pagination.size || 10"
:total="pagination.total"
:page-sizes="[10, 25, 50, 100, 250, 500]"
:disabled="loading"
layout="total, sizes, prev, pager, next, jumper"
@size-change="onSizeChange"
@current-change="onCurrentChange"
/>
</div>
<!-- 自定义尾部内容 -->
<slot name="extra" />
</div>
</template>
<script>
/**
* PageTable CRUD 表格便捷组合体
*
* 核心职责
* 1. 渲染顶部工具栏增删改查等操作按钮自动权限过滤
* 2. 渲染数据表格5 种列类型自动 i18n 翻译
* 3. 渲染底部分页
* 4. 表格高度自适应可选
*
* 典型用法
* <page-table
* :columns="columns"
* :data="tableData"
* :loading="loading"
* :toolbar-buttons="toolbarButtons"
* :row-buttons="rowButtons"
* :pagination="pagination"
* auto-height
* @page-change="onPageChange"
* @selection-change="onSelect"
* />
*
* 内部事件流
* toolbarButtons[i].onClick() 父组件方法 fetchData data 更新 表格刷新
* el-pagination @change emit('page-change') 父组件更新 pagination fetchData
* el-table @selection-change emit('selection-change') 父组件获取选中行
*
* useTableColumns / useTableButtons 配合
* 详见文档docs/sct-base-table-refactor-design.md
*/
export default {
name: 'PageTable',
props: {
/**
* 列定义 useTableColumns() 生成
*
* 普通列{ prop: 'code', label: '编码', minWidth: 120 }
* 操作列{ prop: '_actions', label: '操作', width: 160, fixed: 'right' }
* 自定义列{ prop: 'status', label: '状态', slot: true }
*
* 组件内部会按 type/slot/prop 自动分类渲染不同的 <el-table-column>
*/
columns: { type: Array, default: () => [] },
/**
* 表格行数据直接传接口返回的数组
*/
data: { type: Array, default: () => [] },
/**
* 是否显示 loading 遮罩通常在 fetchData 期间为 true
*/
loading: { type: Boolean, default: false },
/**
* 表格高度不传则 el-table 按内容撑开
* 'auto' 或同时传 auto-height 启用自动计算窗口高度 - 搜索区 - 工具栏 - 分页
* 传数字/字符串 '500px'则固定高度
*/
height: { type: [String, Number], default: undefined },
/**
* 启用表格高度自适应根据容器可视区域自动计算高度
* 启用后 height prop 被忽略组件内部通过 ResizeObserver 持续计算
*/
autoHeight: { type: Boolean, default: false },
/**
* 是否显示表格边框默认 true
*/
border: { type: Boolean, default: true },
/**
* 行数据的唯一 key默认 'id'用于 el-table row-key
* 树形表格或需要保留选中状态时必需
*/
rowKey: { type: String, default: 'id' },
/**
* 顶部工具栏按钮列表 useTableButtons({ toolbar: [...] }) 生成
*
* 每个按钮字段
* key - 唯一标识必填
* label - 显示文本支持 i18n key组件自动 $t() 翻译
* icon - element-ui 图标名'el-icon-plus'
* type - 按钮样式类型'primary' | 'success' | 'warning' | 'danger'
* auth - 权限 key传给 useTableButtons 的第二个参数判断
* cssStyle - 自定义样式对象
* needSelection - true 的话需要表格有选中行才能点击
* onClick - 点击回调由父组件绑定方法
* hasPermission - 自动注入 useTableButtons 计算不要手动设
*/
toolbarButtons: { type: Array, default: () => [] },
/**
* 行内操作按钮列表 useTableButtons({ row: [...] }) 生成
*
* 每个按钮字段
* key / label / icon / auth / onClick toolbarButtons
* color - 'danger' 文字变红用于删除等危险操作
* cssStyle - 默认 { marginRight: '10px', cursor: 'pointer' }
*/
rowButtons: { type: Array, default: () => [] },
/**
* 分页参数传此 prop 才显示分页组件
*
* { current: 1, size: 10, total: 0 }
*
* total 通常在 getList 接口返回 res.count
*/
pagination: { type: Object, default: null },
/**
* 额外透传给 el-table 的原生属性 { stripe: true, size: 'medium' }
*/
tableAttrs: { type: Object, default: () => ({}) },
/**
* 额外透传给 el-table 的原生事件 { 'sort-change': handleSort }
*/
tableListeners: { type: Object, default: () => ({}) },
/**
* 帮助文档的跳转 URL传了才显示工具栏右侧的问号按钮点击新窗口打开
* '/docs/factory-area-help.html'
*/
helpUrl: { type: String, default: '' },
/**
* 帮助按钮的文字由调用方负责 i18n 翻译
* 默认 '帮助'
*/
helpText: { type: String, default: '帮助' }
},
data () {
return {
tableSelected: [], // 当前选中的行数据
computedHeight: null // autoHeight 时动态计算的高度
}
},
computed: {
/**
* 最终使用的表格高度
* - 用户明确传了 height 'auto' 直接用
* - autoHeight=true height='auto' 用动态计算的 computedHeight
* - 否则 undefinedel-table 按内容自适应
*/
tableHeight () {
if (this.height && this.height !== 'auto') return this.height
if (this.autoHeight || this.height === 'auto') return this.computedHeight || undefined
return undefined
},
/**
* 过滤后的工具栏按钮排除无权限的
*/
visibleToolbarButtons () {
return this.toolbarButtons.filter(b => b.hasPermission !== false)
},
/**
* 过滤后的行内操作按钮排除无权限的
*/
visibleRowButtons () {
return this.rowButtons.filter(b => b.hasPermission !== false)
},
/**
* 选中的行数
*/
selectedCount () {
return this.tableSelected.length
},
/**
* 表头样式浅灰背景 + 黑色加粗文字
*/
headerCellStyle () {
return () => 'background-color: #F8F8F8; color: #000000; font-weight: 500;'
}
},
mounted () {
if (this.autoHeight || this.height === 'auto') {
this.startAutoHeight()
}
},
beforeDestroy () {
this.stopAutoHeight()
},
methods: {
/* ============ 列处理 ============ */
colAttrs (col) {
const attrs = { ...col }
delete attrs.idx
delete attrs.slot
delete attrs.headerSlot
if (attrs.label) {
attrs.label = this.$t(attrs.label)
}
return attrs
},
/* ============ 表格事件 ============ */
onSelectionChange (val) {
this.tableSelected = val
this.$emit('selection-change', val)
},
/* ============ 帮助按钮 ============ */
openHelp () {
if (this.helpUrl) {
window.open(this.helpUrl, '_blank')
}
},
/* ============ 分页事件 ============ */
onSizeChange (size) {
this.$emit('page-change', {
current: 1,
size,
total: this.pagination.total
})
},
onCurrentChange (current) {
this.$emit('page-change', {
current,
size: this.pagination.size,
total: this.pagination.total
})
},
/* ============ 自适应高度 ============ */
/**
* 启动表格高度自适应
*
* 原理
* 1. CSS flex 布局让 page-table 占满父容器 100% 高度
* 2. 工具栏和分页各占自然高度flex-shrink: 0
* 3. tableWrapper 中间区域自动填充剩余空间flex: 1 + min-height: 0
* 4. ResizeObserver 监听 tableWrapper 的实际渲染高度赋给 el-table
*
* 这样 el-table 永远精确等于可用空间不会产生外层滚动条
*/
startAutoHeight () {
this.stopAutoHeight()
this._resizeObserver = new ResizeObserver(() => {
this.$nextTick(() => {
const wrapper = this.$refs.tableWrapper
if (wrapper) {
this.computedHeight = wrapper.clientHeight
}
})
})
if (this.$refs.tableWrapper) {
this._resizeObserver.observe(this.$refs.tableWrapper)
this.$nextTick(() => {
const wrapper = this.$refs.tableWrapper
if (wrapper) {
this.computedHeight = wrapper.clientHeight
}
})
}
},
stopAutoHeight () {
if (this._resizeObserver) {
this._resizeObserver.disconnect()
this._resizeObserver = null
}
}
}
}
</script>
<style scoped>
.page-table {
display: flex;
flex-direction: column;
}
/* 自适应高度模式:占满父容器 100%,禁止自身溢出 */
.page-table--auto {
height: 100%;
overflow: hidden;
}
.page-table__toolbar {
flex-shrink: 0;
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.page-table__toolbar-left {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.page-table__help-btn {
flex-shrink: 0;
margin-left: auto;
}
.page-table__body {
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
.page-table__footer {
flex-shrink: 0;
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.action-btn {
display: inline-block;
margin: 0 5px;
cursor: pointer;
white-space: nowrap;
}
.action-btn--danger {
color: #F56C6C;
}
</style>