Files
mes-ui-d2/src/components/page-table/index.vue
sheng 874cbeaeea
Some checks failed
Release pipeline / publish (push) Has been cancelled
Release pipeline / Always run job (push) Has been cancelled
迁移设备损耗品管理功能
2026-06-26 00:27:03 +08:00

577 lines
18 KiB
Vue
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.

<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"
:title="getToolbarDisabledTip(btn)"
@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="elTableListeners"
@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"
:selectable="selectionSelectable || undefined"
/>
<!-- 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="paginationCurrent"
:page-size="paginationSize"
:total="paginationTotal"
: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: () => ({}) },
/**
* 选择列可选规则,透传给 el-table-column 的 selectable。
* 例row => row.active === 1
*/
selectionSelectable: { type: Function, default: null },
/**
* 帮助文档的跳转 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
},
paginationCurrent () {
if (!this.pagination) return 1
return Number(this.pagination.current || this.pagination.currentPage || 1)
},
paginationSize () {
if (!this.pagination) return 10
return Number(this.pagination.size || this.pagination.pageSize || 10)
},
paginationTotal () {
if (!this.pagination) return 0
return Number(this.pagination.total || 0)
},
/**
* 表头样式:浅灰背景 + 黑色加粗文字
*/
headerCellStyle () {
return () => 'background-color: #F8F8F8; color: #000000; font-weight: 500;'
},
/**
* 将父组件直接绑定在 page-table 上的 el-table 原生事件透传下去。
* selection-change 由当前组件统一维护选中行状态,避免父级监听被触发两次。
*/
elTableListeners () {
const listeners = {
...this.$listeners,
...this.tableListeners
}
delete listeners['selection-change']
delete listeners['page-change']
return listeners
}
},
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)
},
clearSelection () {
if (this.$refs.table && this.$refs.table.clearSelection) {
this.$refs.table.clearSelection()
}
this.tableSelected = []
this.$emit('selection-change', [])
},
/* ============ 帮助按钮 ============ */
openHelp () {
if (this.helpUrl) {
window.open(this.helpUrl, '_blank')
}
},
getToolbarDisabledTip (btn) {
if (!btn || !btn.needSelection || this.selectedCount) return ''
return btn.disabledTip ? this.$t(btn.disabledTip) : ''
},
/* ============ 分页事件 ============ */
onSizeChange (size) {
this.$emit('page-change', {
current: 1,
currentPage: 1,
size,
pageSize: size,
total: this.paginationTotal
})
},
onCurrentChange (current) {
this.$emit('page-change', {
current,
currentPage: current,
size: this.paginationSize,
pageSize: this.paginationSize,
total: this.paginationTotal
})
},
/* ============ 自适应高度 ============ */
/**
* 启动表格高度自适应
*
* 原理:
* 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>