vue3封装table组件及属性介绍

组件封装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`,
    },
  ]);
相关推荐
逻极3 小时前
Next.js vs Vue.js:2025年全栈战场,谁主沉浮?
开发语言·javascript·vue.js·reactjs
一枚前端小能手3 小时前
🗂️ Blob对象深度解析 - 从文件处理到内存优化的完整实战指南
前端·javascript
杰克尼3 小时前
vue-day02
前端·javascript·vue.js
一只小阿乐3 小时前
vue3 中实现父子组件v-model双向绑定 总结
前端·javascript·vue.js·vue3·组件·v-model语法糖
qq_338032923 小时前
Vue 3 的<script setup> 和 Vue 2 的 Options API的关系
前端·javascript·vue.js
lumi.3 小时前
Vue Router页面跳转指南:告别a标签,拥抱组件化无刷新跳转
前端·javascript·vue.js
Mintopia3 小时前
🧠 一文吃透 Next.js 中的 JWT vs Session:底层原理+幽默拆解指南
前端·javascript·全栈
前端开发爱好者3 小时前
字节出手!「Vue Native」真的要来了!
前端·javascript·vue.js
Python私教3 小时前
C 语言进制转换全景指南
c语言·开发语言·arm开发