组件封装GxTable.vue
javascript
<!-- 表格 -->
<template>
<div class="gx-table">
<div class="gx-table-header" :class="{ 'gx-table-header-small': props.size === 'small' }">
<div class="gx-table-row-item" v-if="props.select === 'checkBox'">
<gx-checkbox v-model="checkAll" :indeterminate="indeterminate" />
</div>
<div class="gx-table-header-item" v-if="props.select === 'radio'" style="width: 32px"></div>
<div
class="gx-table-header-item"
v-for="(column, index) in props.columns"
:key="index"
:style="{ width: column.width }"
>
<div class="divider" v-if="index !== 0"></div>
<div class="text">{{ column.label }}</div>
<div class="sort" v-if="column.sortable">
<gx-popover
placement="top"
:content="sortKey === column.prop && sortDirection === 'asc' ? '取消排序' : '点击升序'"
>
<template #reference>
<div
class="arrow-up"
@click="handleSort(column.prop, 'asc')"
:class="{ asc: sortKey === column.prop && sortDirection === 'asc' }"
></div>
</template>
</gx-popover>
<gx-popover
placement="top"
:content="sortKey === column.prop && sortDirection === 'desc' ? '取消排序' : '点击降序'"
>
<template #reference>
<div
class="arrow-down"
@click="handleSort(column.prop, 'desc')"
:class="{ desc: sortKey === column.prop && sortDirection === 'desc' }"
></div>
</template>
</gx-popover>
</div>
</div>
<div class="gx-table-header-item" v-if="props.action" :style="{ width: props.actionWidth }">
<div class="divider" v-if="props.columns.length > 0"></div>
<div class="text">操作</div>
</div>
</div>
<div
class="gx-table-body"
:style="bodyHeight ? { maxHeight: bodyHeight, overflowY: 'auto' } : {}"
>
<template v-if="sortedData.length > 0">
<div
class="gx-table-row"
v-for="(row, index) in sortedData"
:key="index"
:class="{
'selected-row': isRowSelected(row),
'row-selected': row.selected,
'gx-table-row-small': props.size === 'small',
}"
@click="handleRowClick(row)"
>
<div class="gx-table-row-item" v-if="props.select === 'checkBox'">
<gx-checkbox
:model-value="innerSelectedKeys.includes(row.id)"
@click.stop
@change="toggleRowSelection(row)"
/>
</div>
<div class="gx-table-row-item" v-if="props.select === 'radio'">
<gx-radio v-model="selectedRowId" :value="row.id" @click.stop="handleRowClick(row)" />
</div>
<div
class="gx-table-row-item"
v-for="(column, columnIndex) in props.columns"
:key="columnIndex"
:style="{
width: column.width,
paddingLeft: props.noCellPadding?.includes(column.slot || column.prop)
? columnIndex === 0
? '3px'
: '0px'
: undefined,
}"
@click="handleCell(row[column.prop])"
>
<template v-if="column.slot">
<slot :name="column.slot" :row="row"></slot>
</template>
<template v-else>
<gx-popover
style="width: 100%"
v-if="column.showOverflowTooltip"
:content="row[column.prop]"
>
<template #reference>
<div class="text-ellipsis">
{{ row[column.prop] }}
</div>
</template>
</gx-popover>
<template v-else>
{{ row[column.prop] }}
</template>
</template>
</div>
<div
class="gx-table-row-item action-column"
v-if="props.action"
:style="{ width: props.actionWidth }"
>
<div class="action-buttons">
<gx-button type="link" @click.stop="handleEdit(row)">编辑</gx-button>
<gx-button type="link" @click.stop="handleDelete(row)">删除</gx-button>
</div>
</div>
</div>
</template>
<template v-else>
<div class="gx-table-empty" :style="{ height: bodyHeight }">暂无数据 </div>
</template>
</div>
</div>
<div class="add" @click="handleAdd" v-if="showAdd">
<div class="add-icon"></div>
<div class="add-text">添加</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
type TableSize = 'default' | 'small';
type Select = 'default' | 'radio' | 'checkBox';
interface TableColumn {
label: string;
prop: string;
width: string;
sortable?: boolean;
slot?: string;
showOverflowTooltip?: boolean;
}
interface TableRow {
id: string | number;
[key: string]: any;
}
interface Props {
modelValue: TableRow[];
columns: TableColumn[];
select?: Select;
size?: TableSize;
action?: boolean;
actionWidth?: string;
noCellPadding?: string[];
bodyHeight?: string;
selectedKeys?: (string | number)[];
showAdd?: boolean;
pageSize?: number; // 每页条数
currentPage?: number; // 当前页
}
const emit = defineEmits<{
(e: 'row-selected', val: TableRow | null): void;
(e: 'selection', val: (string | number)[]): void;
(e: 'update:selectedKeys', val: (string | number)[]): void;
(e: 'add'): void;
(e: 'edit', val: TableRow): void;
(e: 'delete', val: TableRow): void;
(e: 'copy', val: string): void;
}>();
const props = withDefaults(defineProps<Props>(), {
columns: () => [],
modelValue: () => [],
select: 'default',
size: 'default',
actionWidth: '100px',
noCellPadding: () => [],
selectedKeys: () => [],
showAdd: false,
pageSize: 10,
currentPage: 1,
});
const sortKey = ref('');
const sortDirection = ref<'asc' | 'desc' | ''>('');
const selectedRowId = ref<string | number | null>(null);
// 内部双向绑定选中行
const innerSelectedKeys = ref<(string | number)[]>([...(props.selectedKeys || [])]);
watch(
() => props.selectedKeys,
(val) => {
innerSelectedKeys.value = [...(val || [])];
},
{ immediate: true }
);
/** 排序数据 */
const sortedData = computed(() => {
if (!sortKey.value) return props.modelValue;
return [...props.modelValue].sort((a, b) => {
const aVal = a[sortKey.value];
const bVal = b[sortKey.value];
if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1;
return 0;
});
});
/** 排序点击 */
const handleSort = (prop: string, direction: 'asc' | 'desc') => {
if (sortKey.value === prop && sortDirection.value === direction) {
sortKey.value = '';
sortDirection.value = '';
} else {
sortKey.value = prop;
sortDirection.value = direction;
}
};
/** 是否选中行 */
const isRowSelected = (row: TableRow) => {
return selectedRowId.value !== null && row.id === selectedRowId.value;
};
/** 点击行 */
const handleRowClick = (row: TableRow) => {
if (props.select === 'radio') {
selectedRowId.value = selectedRowId.value === row.id ? null : row.id;
innerSelectedKeys.value = selectedRowId.value !== null ? [selectedRowId.value] : [];
emit('row-selected', selectedRowId.value ? row : null);
emit('update:selectedKeys', innerSelectedKeys.value);
}
if (props.select === 'checkBox') {
toggleRowSelection(row);
}
};
/** 多选勾选行 */
const toggleRowSelection = (row: TableRow) => {
const idx = innerSelectedKeys.value.indexOf(row.id);
if (idx >= 0) {
innerSelectedKeys.value.splice(idx, 1);
} else {
innerSelectedKeys.value.push(row.id);
}
// 触发事件,通知父组件
emit('selection', [...innerSelectedKeys.value]);
emit('update:selectedKeys', [...innerSelectedKeys.value]);
};
/** 当前页 id,用于表头全选 */
const currentPageIds = computed(() => {
const start = (props.currentPage! - 1) * props.pageSize!;
const end = props.currentPage! * props.pageSize!;
return props.modelValue.slice(start, end).map((row) => row.id);
});
/** 表头全选状态(只作用于当前页) */
const checkAll = computed({
get() {
const selectedOnPage = currentPageIds.value.filter((id) =>
innerSelectedKeys.value.includes(id)
);
return (
selectedOnPage.length === currentPageIds.value.length && currentPageIds.value.length > 0
);
},
set(val: boolean) {
if (val) {
// 当前页全选
const newIds = currentPageIds.value.filter((id) => !innerSelectedKeys.value.includes(id));
innerSelectedKeys.value = [...innerSelectedKeys.value, ...newIds];
} else {
// 取消当前页全选
innerSelectedKeys.value = innerSelectedKeys.value.filter(
(id) => !currentPageIds.value.includes(id)
);
}
emit('selection', [...innerSelectedKeys.value]);
emit('update:selectedKeys', [...innerSelectedKeys.value]);
},
});
/** 当前页半选状态 */
const indeterminate = computed(() => {
const selectedOnPage = currentPageIds.value.filter((id) =>
innerSelectedKeys.value.includes(id)
);
return selectedOnPage.length > 0 && selectedOnPage.length < currentPageIds.value.length;
});
const handleAdd = () => emit('add');
const handleEdit = (row: TableRow) => emit('edit', row);
const handleDelete = (row: TableRow) => emit('delete', row);
const handleCell = (text: string) => emit('copy', text);
</script>
<style scoped>
.gx-table {
display: flex;
flex-direction: column;
}
.gx-table-header {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--gx-color-border-divider-2);
height: 32px;
flex-shrink: 0;
}
.gx-table-header-small {
height: 26px;
}
.gx-table-header.fixed {
position: sticky;
top: 0;
z-index: 1;
}
.gx-table-header-item {
display: flex;
align-items: center;
color: var(--gx-color-text-secondary);
}
.gx-table-header-item .text {
padding-left: 8px;
box-sizing: border-box;
font-weight: 600;
}
.gx-table-header-item:first-child .text {
padding-left: 12px;
box-sizing: border-box;
}
.sort {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 8px;
height: 16px;
gap: 2px;
padding: 4px;
}
.arrow-up,
.arrow-down {
width: 9px;
height: 5px;
}
.arrow-up:hover,
.arrow-down:hover {
cursor: pointer;
}
.arrow-up {
background: url('../../assets/img/components/data/gx-table/arrow-up.svg') center no-repeat;
}
.arrow-down {
background: url('../../assets/img/components/data/gx-table/arrow-down.svg') center no-repeat;
}
.arrow-up:hover,
.arrow-up.asc {
background: url('../../assets/img/components/data/gx-table/arrow-up-active.svg') center
no-repeat;
}
.arrow-down:hover,
.arrow-down.desc {
background: url('../../assets/img/components/data/gx-table/arrow-down-active.svg') center
no-repeat;
}
.gx-table-row {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--gx-color-border-divider-1);
height: 40px;
}
.gx-table-row-small {
height: 30px;
}
.gx-table-row:hover {
background-color: var(--gx-color-bg-table-hover);
}
.gx-table-row-item {
height: 100%;
min-width: 32px;
padding-left: 9px;
box-sizing: border-box;
display: flex;
align-items: center;
color: var(--gx-color-text-brand);
}
.gx-table-row-item:first-child {
padding-left: 12px;
box-sizing: border-box;
}
.selected-row {
background-color: var(--gx-color-bg-table-selected);
}
.row-selected {
background-color: var(--gx-color-bg-table-selected);
}
.add {
display: flex;
align-items: center;
padding: 10px 0;
gap: 5px;
width: 50px;
cursor: pointer;
}
.add-icon {
position: relative;
width: 14px;
height: 14px;
background: url('../../assets/img/components/data/gx-table/add.svg') center no-repeat;
}
.add:hover .add-icon {
background: url('../../assets/img/components/data/gx-table/add-hover.svg') center no-repeat;
}
.add:hover .add-text {
color: var(--gx-color-primary-default);
}
.add:hover .add-icon::after {
background-color: var(--gx-color-primary-default);
}
.add-text {
color: var(--gx-color-text-placeholder);
}
.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
}
.divider {
background-color: var(--gx-color-border-default);
height: 14px;
width: 1px;
}
.action-buttons {
display: flex;
gap: 10px;
}
.gx-table {
display: flex;
flex-direction: column;
}
.gx-table-header.fixed {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--gx-color-border-divider-2);
}
.gx-table-body {
overflow-y: auto;
}
.gx-table-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--gx-color-text-placeholder);
}
</style>
使用方法示例:
1、多选带分页(表头的复选框只是选中当前页面的全部行,不是所有表格数据)
javascript
<template>
<div>
<gx-table
:columns="tableHeader"
:model-value="tableRow"
v-model:selectedKeys="checkedIds"
select="checkBox"
@selection="handleSelection"
/>
</div>
</template>
<script setup lang="ts">
const handleSelection = (selectedIds: number[]) => {
console.log('选中的行id:', selectedIds);
};
</script>
表格属性介绍:
javascript
const attrData = ref<any[]>([
{
attrName: 'columns',
brief: '表格的列信息',
type: `TableColumn[]`,
default: '-',
},
{
attrName: 'model-value',
brief: '表格的数据源',
type: `TableRow[]`,
default: '-',
},
{
attrName: 'size',
brief: '表格尺寸',
type: `enum,'default' | 'small'`,
default: 'default',
},
{
attrName: 'select',
brief: '控制表格的选择模式',
type: `enum,'default' | 'radio' | 'chechBox'`,
default: 'default',
},
{
attrName: 'label',
brief: '表格列标题文本',
type: `string`,
default: '-',
},
{
attrName: 'prop',
brief: '对应表格数据(model-value)中每一行对象的属性名,用于获取该行的具体数据。',
type: `string`,
default: '-',
},
{
attrName: 'width',
brief: '表格列的宽度。',
type: `string`,
default: '-',
},
{
attrName: 'sortable',
brief: '对应列是否可以进行排序。',
type: `boolean`,
default: '-',
},
{
attrName: 'slot',
brief: '替代 prop 的默认渲染。',
type: `boolean`,
default: '-',
},
{
attrName: 'noCellPadding',
brief: '指定需要去除padding-left的列',
type: `string[]`,
default: '[]',
},
{
attrName: 'bodyHeight',
brief: '表格内容区域的高度,超出该高度时会显示竖向滚动条。',
type: `string`,
default: '-',
},
{
attrName: 'v-model:checkedKeys',
brief: '默认选中节点的 id,仅在多选模式下有效',
type: 'Array<string | number>',
default: '[]',
},
]);
const attrColumnData = ref<any[]>([
{
attrName: 'label',
brief: '表格列标题文本',
type: `string`,
default: '-',
},
{
attrName: 'prop',
brief: '对应表格数据(model-value)中每一行对象的属性名,用于获取该行的具体数据。',
type: `string`,
default: '-',
},
{
attrName: 'width',
brief: '表格列的宽度。',
type: `string`,
default: '-',
},
{
attrName: 'sortable',
brief: '对应列是否可以进行排序。',
type: `boolean`,
default: '-',
},
{
attrName: 'slot',
brief: '替代 prop 的默认渲染。',
type: `boolean`,
default: '-',
},
]);
const slotData = ref<any[]>([
{
slotName: 'column.slot(动态名称)',
brief: '列的自定义内容插槽,名称与 columns 中配置的 slot 值对应,如 "action"',
},
]);
const eventData = ref<any[]>([
{
attrName: 'click',
brief: '原生 DOM 元素的点击监听和组件内部自定义行为的触发',
type: `Function`,
},
{
attrName: 'row-selected',
brief: '单选模式下,行选中状态变化时触发的事件',
type: `Function`,
},
{
attrName: 'selection',
brief: '多选模式下,选中项变化时触发的事件',
type: `Function`,
},
]);