pet/uni_modules/uview-next/components/u-table/u-table.vue

844 lines
29 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>
<u-scroll-list :indicator="false" :scroll="scrollX">
<view class="u-table" :class="[customClass, border ? 'is-border' : '']" :style="tableStyle">
<!-- 表头 -->
<view v-if="showHeader" class="u-table__header" ref="u-table__header">
<block v-for="(column, index) in childrenData" :key="index">
<view v-if="column.show" class="u-table__header__column" :style="[cellStyles(column)]"
@click="handleHeaderClick(column, index)">
<view class="u-table__cell" :class="[cellClasses(column, index)]"
:style="{ minHeight: $u.addUnit(rowHeight) }">
<!-- 多选表头复选框 -->
<template v-if="column.type === 'selection'">
<u-checkbox :checked="isAllSelected" :indeterminate="isIndeterminate"
@change="handleSelectAll"></u-checkbox>
</template>
<!-- 普通表头 -->
<template v-else>
<text class="u-table__value" :class="[{'is-ellipsis': column.headerEllipsis }]">{{ column.label }}</text>
<view v-if="column.sortable && column.type != 'index'" class="u-table__sort-icon">
<view class="u-table__sort-arrow u-table__sort-arrow--up"
:class="{ 'is-active': sortColumn === column.prop && sortOrder === 'asc' }">
</view>
<view class="u-table__sort-arrow u-table__sort-arrow--down"
:class="{ 'is-active': sortColumn === column.prop && sortOrder === 'desc' }">
</view>
</view>
</template>
</view>
</view>
</block>
</view>
<!-- 表体 -->
<scroll-view class="u-table__body" :style="bodyStyle" :scroll-y="true">
<!-- 数据行容器 -->
<view class="u-table__rows">
<slot></slot>
</view>
<!-- 空数据提示 -->
<view v-if="!data || data.length === 0" class="u-table__empty"
:style="{ height: $u.addUnit(emptyHeight) }">
<slot name="empty">
<view class="u-table__empty-content">
<text class="u-table__empty-text">{{ emptyText }}</text>
</view>
</slot>
</view>
</scroll-view>
<!-- 表尾合计行 -->
<view v-if="showSummary && data && data.length > 0" class="u-table__footer" ref="u-table__footer">
<block v-for="(column, index) in children" :key="index">
<view v-if="column.show" class="u-table__footer__column" :style="[cellStyles(column)]">
<view class="u-table__cell u-table__summary-cell" :class="[cellClasses(column, index)]"
:style="{ height: $u.addUnit(rowHeight) }">
<text class="u-table__value">{{ getSummaryValue(column, index) }}</text>
</view>
</view>
</block>
</view>
</view>
</u-scroll-list>
</template>
<script>
// #ifdef APP-NVUE
const dom = uni.requireNativePlugin('dom')
// #endif
import props from './props.js';
import mixin from '../../libs/mixin/mixin'
import mpMixin from '../../libs/mixin/mpMixin'
/**
* table 表格
* @description 用于展示多条结构类似的数据,可对数据进行排序等操作
* @tutorial https://www.uviewui.com/components/table.html
* @property {Array} data 显示的数据 (默认 [] )
* @property {Boolean} border 是否带有边框 (默认 true )
* @property {Boolean} scrollX 是否允许横向滚动 (默认 true )
* @property {String|Number} round 设置圆角值 (默认 0 )
* @property {Boolean} stripe 是否为斑马纹表 (默认 true )
* @property {String|Number} height Table 的高度 (默认 null )
* @property {String|Number} rowHeight 行高 (默认 50 )
* @property {Boolean} showHeader 是否显示表头 (默认 true )
* @property {Object} headerCellStyle 表头单元格样式 (默认 {} )
* @property {Boolean} ellipsis 是否超出2行隐藏 (默认 true )
* @property {String} emptyText 空数据时显示的文本 (默认 '暂无数据' )
* @property {String|Number} emptyHeight 空数据区域高度 (默认 200 )
* @property {String} rowKey 行数据的Key用于优化Table的渲染 (默认 'id' )
* @property {Array} defaultSelection 默认选中的行 (默认 [] )
* @property {Boolean} showSummary 是否显示表尾合计行 (默认 false )
* @property {String} sumText 表尾合计行第一列的文本 (默认 '合计' )
* @property {Function} summaryMethod 自定义的合计计算方法 (默认 null )
* @property {Array} summaryColumns 需要合计的列prop数组 (默认 [] )
* @property {Object} customStyle 定义需要用到的外部样式
* @event {Function} sort-change 排序变化事件
* @event {Function} row-click 行点击事件
* @event {Function} cell-click 单元格点击事件
* @event {Function} selection-change 选择项发生变化时会触发该事件
* @event {Function} select 用户手动勾选数据行的 Checkbox 时触发的事件
* @event {Function} select-all 用户手动勾选全选 Checkbox 时触发的事件
* @example <u-table :data="dataList" :columns="columns"></u-table>
*/
export default {
name: 'u-table',
mixins: [mpMixin, mixin, props],
data() {
return {
children: [],
childrenData: [],
sortColumn: null,
sortOrder: null,
headerList: [],
headerHeight: 0,
footerHeight: 0,
// 多选相关状态
selectedRows: [], // 当前选中的行数据
selectedRowKeys: [], // 当前选中的行Key
expandedRowKeys: [] // 当前展开的行Key
}
},
computed: {
parentData() {
return [
this.data,
this.border,
this.stripe,
this.rowHeight,
this.ellipsis,
this.cellStyle,
this.selectedRowKeys,
this.toggleRowSelection,
this.mergeInfo
]
},
// 排序后的数据
sortedData() {
if (!this.sortColumn || !this.sortOrder) {
return this.data || []
}
const dataToSort = [...(this.data || [])]
return dataToSort.sort((a, b) => {
const aVal = this.getValueByPath(a, this.sortColumn)
const bVal = this.getValueByPath(b, this.sortColumn)
// 处理不同数据类型的比较
let result = 0
// 数字比较
if (typeof aVal === 'number' && typeof bVal === 'number') {
result = aVal - bVal
}
// 字符串比较
else if (typeof aVal === 'string' && typeof bVal === 'string') {
result = aVal.localeCompare(bVal)
}
// 日期比较
else if (aVal instanceof Date && bVal instanceof Date) {
result = aVal.getTime() - bVal.getTime()
}
// 其他类型转换为字符串比较
else {
const aStr = String(aVal || '')
const bStr = String(bVal || '')
result = aStr.localeCompare(bStr)
}
return this.sortOrder === 'asc' ? result : -result
})
},
// 合并单元格信息
mergeInfo() {
if (!this.spanMethod && (!this.mergeConfig || this.mergeConfig.length === 0)) {
return {}
}
const info = {}
const data = this.sortedData
// 使用自定义合并方法
if (this.spanMethod && uni.$u.test.func(this.spanMethod)) {
data.forEach((row, rowIndex) => {
this.childrenData.forEach((column, columnIndex) => {
if (!column.prop) return
const spanResult = this.spanMethod({
row,
column: column,
rowIndex,
columnIndex
})
if (spanResult && (spanResult.rowspan !== 1 || spanResult.colspan !== 1)) {
const key = `${rowIndex}-${column.prop}`
info[key] = {
rowspan: spanResult.rowspan || 1,
colspan: spanResult.colspan || 1,
display: spanResult.rowspan === 0 || spanResult.colspan === 0 ? 'none' : 'block'
}
}
})
})
}
// 使用简化配置
else if (this.mergeConfig && this.mergeConfig.length > 0) {
this.mergeConfig.forEach(config => {
if (!config.prop || !config.ranges) return
config.ranges.forEach(range => {
if (range.startRow !== undefined && range.endRow !== undefined) {
const rowspan = range.endRow - range.startRow + 1
// 第一行显示合并的单元格
const firstRowKey = `${range.startRow}-${config.prop}`
info[firstRowKey] = {
rowspan: rowspan,
colspan: range.colspan || 1,
display: 'block'
}
// 其他行隐藏
for (let i = range.startRow + 1; i <= range.endRow; i++) {
const key = `${i}-${config.prop}`
info[key] = {
rowspan: 0,
colspan: 0,
display: 'none'
}
}
}
})
})
}
return info
},
// 是否全选
isAllSelected() {
return this.data.length > 0 && this.selectedRowKeys.length === this.data.length
},
// 是否半选状态
isIndeterminate() {
return this.selectedRowKeys.length > 0 && this.selectedRowKeys.length < this.data.length
},
tableStyle() {
const style = {}
if (this.height) {
style.height = this.$u.addUnit(this.height)
style.display = 'flex'
style.flexDirection = 'column'
}
if (this.round) {
style.borderRadius = this.$u.addUnit(this.round)
}
return uni.$u.deepMerge(style, uni.$u.addStyle(this.customStyle));
},
bodyStyle() {
const style = {}
if (this.height) {
if (this.showHeader || this.showSummary) {
// 表体高度 = 总高度 - 表头高度 - 表尾高度
const totalHeight = parseInt(this.height) || 0
let usedHeight = 0
if (this.showHeader) usedHeight += this.headerHeight
if (this.showSummary && this.data && this.data.length > 0) usedHeight += this.footerHeight
if (totalHeight > usedHeight) {
style.height = this.$u.addUnit(totalHeight - usedHeight)
}
} else {
style.height = this.$u.addUnit(this.height)
}
style.flex = 1
}
return this.$u.addStyle(style)
},
cellClasses(column, index) {
return (column, index) => {
let classes = [
`is-${column.headerAlign || 'left'}`,
];
if (this.border) {
classes.push('is-border');
}
// #ifdef MP-ALIPAY || MP-TOUTIAO
classes = classes.join(' ')
// #endif
return classes;
}
},
cellStyles(column) {
return (column) => {
const style = {}
if (column.width) {
style.width = this.$u.addUnit(column.width)
} else if (column.minWidth) {
style.minWidth = this.$u.addUnit(column.minWidth)
} else {
style.flex = 1;
}
return uni.$u.deepMerge(style, uni.$u.addStyle(this.headerCellStyle));
}
}
},
created() {
//this.children = []
// 初始化默认选中项
this.initDefaultSelection()
},
mounted() {
this.getHeaderHeight()
this.getFooterHeight()
},
watch: {
// 监听排序状态变化,更新子组件数据
sortedData: {
handler() {
this.updateChildrenData()
},
immediate: true
},
// 监听原始数据变化
data: {
handler() {
this.updateChildrenData()
// 数据变化时重新检查选中状态
this.validateSelection()
},
immediate: true
},
// 监听默认选中项变化
defaultSelection: {
handler() {
this.initDefaultSelection()
},
immediate: true
},
// 监听showSummary变化重新计算布局
showSummary: {
handler() {
this.$nextTick(() => {
this.getFooterHeight()
})
}
},
// 监听parentData变化
parentData: {
handler() {
if (this.children.length) {
this.children.map(child => {
child.init()
})
}
}
},
// 监听children变化兼容vue2微信小程序
children: {
// #ifdef VUE3
deep: true, // 兼容微信小程序vue3写法
// #endif
handler(newval) {
if (newval && newval.length > 0) {
this.childrenData = newval.map(item => {
return {
show: item.show,
label: item.label,
prop: item.prop,
headerAlign: item.headerAlign,
headerEllipsis: item.headerEllipsis,
align: item.align,
width: item.width,
minWidth: item.minWidth,
sortable: item.sortable,
type: item.type,
fixed: item.fixed,
columnKey: item.columnKey,
className: item.className,
customStyle: item.customStyle,
formatter: item.formatter,
}
})
}
}
}
},
// #ifdef VUE3
emits: ['cellClick', 'sortChange', 'selectionChange', 'select', 'selectAll'],
// #endif
methods: {
// 获取合计行的值
getSummaryValue(column, index) {
// 如果使用自定义合计方法
if (this.summaryMethod && uni.$u.test.func(this.summaryMethod)) {
const summaryData = this.summaryMethod({
columns: this.childrenData,
data: this.sortedData,
column,
index
})
if (Array.isArray(summaryData)) {
return summaryData[index] || ''
} else if (typeof summaryData === 'object' && summaryData[column.prop]) {
return summaryData[column.prop]
}
}
// 默认合计逻辑
if (index === 0) {
// 第一列显示合计文本
return this.sumText
}
// 检查该列是否需要合计
if (column.prop && this.summaryColumns.includes(column.prop)) {
return this.calculateColumnSum(column.prop)
}
// 如果summaryColumns为空数组则自动合计数字列
if (this.summaryColumns.length === 0 && column.prop && column.type !== 'selection' && column.type !== 'index') {
const isNumericColumn = this.sortedData.every(row => {
const value = this.getValueByPath(row, column.prop)
return value === null || value === undefined || value === '' || !isNaN(Number(value))
})
if (isNumericColumn) {
return this.calculateColumnSum(column.prop)
}
}
return ''
},
// 计算列的合计值
calculateColumnSum(prop) {
if (!prop || !this.sortedData || this.sortedData.length === 0) {
return '0'
}
let sum = 0
let hasValidNumber = false
this.sortedData.forEach(row => {
const value = this.getValueByPath(row, prop)
const numValue = parseFloat(value)
if (!isNaN(numValue)) {
sum += numValue
hasValidNumber = true
}
})
if (!hasValidNumber) {
return ''
}
// 保留两位小数并去除多余的0
return parseFloat(sum.toFixed(2)).toString()
},
// 初始化默认选中项
initDefaultSelection() {
if (this.defaultSelection && this.defaultSelection.length > 0) {
this.selectedRows = [...this.defaultSelection]
this.selectedRowKeys = this.defaultSelection.map(row => this.getRowKey(row))
}
},
// 验证选中状态(数据变化时调用)
validateSelection() {
if (!this.sortedData || this.sortedData.length === 0) {
this.clearSelection()
return
}
// 过滤出仍然存在的选中项
const validSelectedRows = []
const validSelectedRowKeys = []
this.selectedRows.forEach(selectedRow => {
const rowKey = this.getRowKey(selectedRow)
const stillExists = this.sortedData.some(row => this.getRowKey(row) === rowKey)
if (stillExists) {
validSelectedRows.push(selectedRow)
validSelectedRowKeys.push(rowKey)
}
})
if (validSelectedRows.length !== this.selectedRows.length) {
this.selectedRows = validSelectedRows
this.selectedRowKeys = validSelectedRowKeys
this.$emit('selectionChange', this.selectedRows)
}
},
// 获取行的唯一标识
getRowKey(row) {
if (!row) return ''
return this.rowKey ? this.getValueByPath(row, this.rowKey) : JSON.stringify(row)
},
// 切换行选中状态
toggleRowSelection(row, selected) {
const rowKey = this.getRowKey(row)
const index = this.selectedRowKeys.indexOf(rowKey)
if (selected === undefined) {
selected = index === -1
}
if (selected && index === -1) {
// 选中
this.selectedRows.push(row)
this.selectedRowKeys.push(rowKey)
} else if (!selected && index !== -1) {
// 取消选中
this.selectedRows.splice(index, 1)
this.selectedRowKeys.splice(index, 1)
}
this.$emit('select', this.selectedRows, row)
this.$emit('selectionChange', this.selectedRows)
},
// 全选/取消全选
handleSelectAll(checked) {
if (checked) {
// 全选
this.selectedRows = [...this.sortedData]
this.selectedRowKeys = this.sortedData.map(row => this.getRowKey(row))
} else {
// 取消全选
this.selectedRows = []
this.selectedRowKeys = []
}
// 强制更新子组件数据,确保复选框状态同步
this.updateChildrenData()
this.$emit('selectAll', this.selectedRows)
this.$emit('selectionChange', this.selectedRows)
},
// 清空选择
clearSelection() {
this.selectedRows = []
this.selectedRowKeys = []
this.$emit('selectionChange', this.selectedRows)
},
// 获取当前选中的行
getSelectionRows() {
return this.selectedRows
},
// 获取嵌套属性值
getValueByPath(obj, path) {
if (!path || !obj) return ''
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value && typeof value === 'object') {
value = value[key]
} else {
value = ''
break
}
}
return value
},
// 更新子组件数据
updateChildrenData() {
if (this.children && this.children.length > 0) {
this.children.forEach(child => {
if (child.parentData) {
// 传递排序后的数据给子组件
child.parentData.data = this.sortedData
child.parentData.border = this.border
child.parentData.stripe = this.stripe
// 传递合并信息
child.parentData.mergeInfo = this.mergeInfo
// 传递选中状态
if (child.type === 'selection') {
child.parentData.selectedRowKeys = this.selectedRowKeys
child.parentData.toggleRowSelection = this.toggleRowSelection.bind(this)
}
}
})
}
},
// 获取下拉菜单内容的高度
async getHeaderHeight() {
await uni.$u.sleep(30);
// #ifndef APP-NVUE
this.$uGetRect('.u-table__header').then(res => {
this.headerHeight = res.height;
});
// #endif
// #ifdef APP-NVUE
const ref = this.$refs['u-table__header']
ref && dom.getComponentRect(ref, (res) => {
this.headerHeight = res.size.height
});
// #endif
},
// 获取表尾高度
async getFooterHeight() {
if (!this.showSummary) {
this.footerHeight = 0
return
}
await uni.$u.sleep(30);
// #ifndef APP-NVUE
this.$uGetRect('.u-table__footer').then(res => {
this.footerHeight = res.height || 0;
});
// #endif
// #ifdef APP-NVUE
const ref = this.$refs['u-table__footer']
ref && dom.getComponentRect(ref, (res) => {
this.footerHeight = res.size.height || 0
});
// #endif
},
// 表头点击事件处理
handleHeaderClick(column, index) {
if (column.type === 'selection') {
// 多选列不处理排序
return
}
this.handleSort(column, index)
},
handleSort(column, index) {
if (!column.sortable || column.type === 'index') return
let newOrder = 'asc'
// 如果点击的是当前排序列
if (this.sortColumn === column.prop) {
if (this.sortOrder === 'asc') {
// 当前是升序,切换到降序
newOrder = 'desc'
} else if (this.sortOrder === 'desc') {
// 当前是降序,取消排序
this.sortColumn = null
this.sortOrder = null
this.$emit('sortChange', {
prop: null,
order: null,
column: column,
index: index
})
return
}
}
// 设置新的排序状态
this.sortColumn = column.prop
this.sortOrder = newOrder
// 触发排序变化事件
this.$emit('sortChange', {
prop: this.sortColumn,
order: this.sortOrder,
column: column,
index: index
})
}
}
}
</script>
<style lang="scss" scoped>
@import "../../libs/css/components.scss";
.u-table {
position: relative;
font-size: 14px;
overflow: hidden;
min-width: 100%;
&__header {
@include flex(row);
background-color: $u-bg-color;
flex-shrink: 0;
&__column {
@include flex(row);
flex-shrink: 0;
&:last-child .is-border {
border-right: none;
}
}
}
&__footer {
@include flex(row);
background-color: $u-bg-color;
flex-shrink: 0;
border-top: 1px solid $u-border-color;
&__column {
@include flex(row);
flex-shrink: 0;
&:last-child .is-border {
border-right: none;
}
}
}
&__body {
flex: 1;
width: 100%;
}
&__rows {
@include flex(row);
width: 100%;
}
&__cell {
@include flex(row);
flex: 1;
padding: 0px 10px;
align-items: center;
position: relative;
&.is-left {
justify-content: flex-start;
}
&.is-center {
justify-content: center;
}
&.is-right {
justify-content: flex-end;
}
&.is-border {
border-right: 1px solid $u-border-color;
border-bottom: 1px solid $u-border-color;
}
}
&__summary-cell {
font-weight: 500;
color: $u-main-color;
background-color: #fafafa;
&.is-border {
border-bottom: none;
}
}
&__value {
font-size: 13px;
line-height: 15px;
&.is-ellipsis {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
}
&.is-border {
border: 1px solid $u-border-color;
}
}
.u-table__sort-icon {
@include flex(column);
margin-left: 4px;
gap: 2px;
}
.u-table__sort-arrow {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
transition: border-color 0.3s ease;
&--up {
border-bottom: 5px solid #c0c4cc;
&.is-active {
border-bottom-color: $u-primary;
}
}
&--down {
border-top: 5px solid #c0c4cc;
&.is-active {
border-top-color: $u-primary;
}
}
}
.u-table__empty {
@include flex(column);
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
width: 100%;
}
.u-table__empty-content {
text-align: center;
}
.u-table__empty-text {
color: #909399;
}
</style>