Skip to content

fix(Table): affix width not correct#4131

Open
RylanBot wants to merge 7 commits intodevelopfrom
rylan/fix/table/bar
Open

fix(Table): affix width not correct#4131
RylanBot wants to merge 7 commits intodevelopfrom
rylan/fix/table/bar

Conversation

@RylanBot
Copy link
Collaborator

@RylanBot RylanBot commented Feb 9, 2026

🤔 这个 PR 的性质是?

  • 日常 bug 修复
  • 新特性提交
  • 文档改进
  • 演示代码改进
  • 组件样式/交互改进
  • CI/CD 改进
  • 重构
  • 代码风格优化
  • 测试用例
  • 分支合并
  • 其他

🔗 相关 Issue

💡 需求背景和解决方案

Table + Dialog + 虚拟滚动叠加使用例子
/* eslint-disable camelcase */
import React, { useState } from 'react';
import type { FilterValue, TableProps } from 'tdesign-react';
import { DialogPlugin, Link, Table } from 'tdesign-react';
import { v4 as uuidv4 } from 'uuid';

interface ITableMaxHeightParams {
  topPosStart?: number;
  reservationHeight?: number;
  heightLowerLimit?: number;
  heightUpperLimit?: number;
  viewHeight?: number;
}

const getTableMaxHeight = (params: ITableMaxHeightParams = {}): number => {
  const {
    topPosStart = 0,
    reservationHeight = 0,
    heightLowerLimit = 500,
    heightUpperLimit = 1200,
    viewHeight = window.innerHeight,
  } = params;

  let tableHeight = viewHeight - topPosStart - reservationHeight;
  if (tableHeight < heightLowerLimit) {
    tableHeight = heightLowerLimit;
  }
  if (tableHeight > heightUpperLimit) {
    tableHeight = heightUpperLimit;
  }

  return tableHeight;
};

// 工具函数:随机加权选择
function randomWeightedChoice(items: string[], weights: number[]) {
  if (items.length !== weights.length) {
    throw new Error('Items and weights must have the same length.');
  }

  const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
  const random = Math.random() * totalWeight;

  let weightSum = 0;
  for (let i = 0; i < items.length; i++) {
    weightSum += weights[i];
    if (random <= weightSum) {
      return items[i];
    }
  }

  return items[items.length - 1];
}

// 生成随机数据
const getRandomData = (rowCount = 500) => {
  const upperLimit = 1000;
  const result: TableProps['data'] = new Array(rowCount).fill(null).map((_, i) => {
    const type_name = randomWeightedChoice(['电脑', '平板', '手机'], [0.75, 0.2, 0.05]);
    let typeLabel = '';
    if (type_name === '电脑') typeLabel = 'Computer';
    else if (type_name === '平板') typeLabel = 'Tablet';
    else if (type_name === '手机') typeLabel = 'Phone';

    return {
      id: uuidv4(),
      model_name: `Model_Name_${typeLabel}_${i.toString().padStart(4, '0')}`,
      type_name,
      quantity_1: Math.floor(Math.random() * upperLimit),
      quantity_2: Math.floor(Math.random() * upperLimit),
      quantity_3: Math.floor(Math.random() * upperLimit),
      quantity_4: Math.floor(Math.random() * upperLimit),
      quantity_5: Math.floor(Math.random() * upperLimit),
      quantity_6: Math.floor(Math.random() * upperLimit),
    };
  });

  return result;
};

// 数字格式化函数
function numeral(value: number | string | null | undefined) {
  const num = Number(value) || 0;

  return {
    format(pattern: string): string {
      const hasComma = pattern.includes(',');
      const decimalMatch = pattern.match(/\.(0+)/);
      const decimals = decimalMatch ? decimalMatch[1].length : 0;

      return num.toLocaleString('en-US', {
        useGrouping: hasComma,
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      });
    },
  };
}

// 表格组件接口
interface TableDemoProps {
  tableMaxHeight: number;
  filterProductNames?: string[];
  enableVirtualScroll?: boolean;
  onClickCallback?: () => void;
}

// 表格组件
const TableDemo: React.FC<TableDemoProps> = ({
  tableMaxHeight,
  filterProductNames,
  enableVirtualScroll = false,
  onClickCallback,
}) => {
  const ranDatas = React.useMemo(() => getRandomData(), []);
  const [filterValue, setFilterValue] = useState<FilterValue>({});

  const onFilterChange: TableProps['onFilterChange'] = (filterValue) => {
    setFilterValue(filterValue);
  };

  const onClickDrillDown = (row: any) => {
    const { props } = row.row_shared;
    props.onClickCallback?.();
  };

  const columns: TableProps['columns'] = [
    { colKey: 'serial-number', title: '序号', width: 60, align: 'center' },
    {
      colKey: 'model_name',
      title: '名称',
      width: 160,
      align: 'left',
      sortType: 'asc',
      sorter: (a: any, b: any) => a.model_name.localeCompare(b.model_name),
      foot: (col: any) => (
        <div style={{ textAlign: 'left' }}>
          <b style={{ fontWeight: 'bold' }}>{`全部(${col.row.total_rows})`}</b>
        </div>
      ),
    },
    {
      colKey: 'type_name',
      title: '类型',
      width: 100,
      align: 'center',
      sortType: 'asc',
      sorter: (a: any, b: any) => a.type_name.localeCompare(b.type_name),
      filter: {
        type: 'multiple',
        resetValue: [],
        list: [
          { label: '全部', checkAll: true },
          { label: '电脑', value: '电脑' },
          { label: '平板', value: '平板' },
          { label: '手机', value: '手机' },
        ],
      },
    },
    {
      colKey: 'quantity_1',
      title: 'AAA数量1',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => (
        <Link theme="primary" onClick={() => onClickDrillDown(row)}>
          {numeral(row.quantity_1).format('0,0')}
        </Link>
      ),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_1 - b.quantity_1,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>
            <Link theme="primary" onClick={() => onClickDrillDown(col.row)}>
              {numeral(col.row.quantity_1).format('0,0')}
            </Link>
          </b>
        </div>
      ),
    },
    {
      colKey: 'quantity_2',
      title: 'AAA数量2',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => numeral(row.quantity_2).format('0,0'),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_2 - b.quantity_2,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>{numeral(col.row.quantity_2).format('0,0')}</b>
        </div>
      ),
    },
    {
      colKey: 'quantity_3',
      title: 'AAA数量3',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => numeral(row.quantity_3).format('0,0'),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_3 - b.quantity_3,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>{numeral(col.row.quantity_3).format('0,0')}</b>
        </div>
      ),
    },
    {
      colKey: 'quantity_4',
      title: '表格标题-数量4',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => numeral(row.quantity_4).format('0,0'),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_4 - b.quantity_4,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>{numeral(col.row.quantity_4).format('0,0')}</b>
        </div>
      ),
    },
    {
      colKey: 'quantity_5',
      title: 'AAA数量5',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => numeral(row.quantity_5).format('0,0'),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_5 - b.quantity_5,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>{numeral(col.row.quantity_5).format('0,0')}</b>
        </div>
      ),
    },
    {
      colKey: 'quantity_6',
      title: 'AA数量6',
      width: 100,
      align: 'right',
      cell: ({ row }: any) => numeral(row.quantity_6).format('0,0'),
      sortType: 'desc',
      sorter: (a: any, b: any) => a.quantity_6 - b.quantity_6,
      foot: (col: any) => (
        <div style={{ textAlign: 'right' }}>
          <b style={{ fontWeight: 'bold' }}>{numeral(col.row.quantity_6).format('0,0')}</b>
        </div>
      ),
    },
  ];

  // 列筛选函数
  const getFilterRows = (tableRows: any[], columnFilter: FilterValue) => {
    const filterRows = tableRows.filter((row) =>
      Object.keys(columnFilter).every((key) => {
        const filterValues = columnFilter[key];
        if (Array.isArray(filterValues) && filterValues.length === 0) {
          return true;
        }
        return Array.isArray(filterValues) && filterValues.includes(row[key]);
      }),
    );

    return filterRows;
  };

  // 计算汇总数据
  const getFootCumulativeData = (rowDatas: any[]) => {
    const result: { [key: string]: any } = {};
    const cumProps = ['quantity_1', 'quantity_2', 'quantity_3', 'quantity_4', 'quantity_5', 'quantity_6'];

    cumProps.forEach((prop) => {
      result[prop] = rowDatas.reduce((acc, row) => acc + row[prop], 0);
    });

    return result;
  };

  // 计算表格数据
  const computedProps = React.useMemo(() => {
    let tableRows = ranDatas.map((row: any) => ({ ...row, row_shared: { props: { onClickCallback } } }));

    // 产品筛选
    if (filterProductNames && filterProductNames.length > 0) {
      tableRows = tableRows.filter((row) => filterProductNames.includes(row.model_name));
    }

    // 列筛选
    tableRows = getFilterRows(tableRows, filterValue);

    const cumRow = getFootCumulativeData(tableRows);
    const footRows = [
      {
        ...cumRow,
        total_rows: tableRows.length,
        row_shared: { props: { onClickCallback } },
      },
    ];
    return { tableRows, footRows };
  }, [ranDatas, onClickCallback, filterProductNames, filterValue]);

  const isEmptyFilter = Object.entries(filterValue).every(([, value]) => Array.isArray(value) && value.length === 0);

  return (
    <Table
      rowKey="id"
      data={computedProps.tableRows}
      footData={computedProps.footRows}
      columns={columns}
      resizable={true}
      tableLayout="fixed"
      maxHeight={tableMaxHeight}
      hideSortTips={true}
      footerAffixedBottom={false}
      filterValue={filterValue}
      filterRow={isEmptyFilter ? null : undefined}
      onFilterChange={onFilterChange}
      scroll={enableVirtualScroll ? { type: 'virtual' } : undefined}
    />
  );
};

// 主组件
const enableVirtualScroll = true;

const showDialog = () => {
  const defaultDlgRestHeightExceptBody = 170;
  const tableMaxHeight = getTableMaxHeight({ reservationHeight: 100 + defaultDlgRestHeightExceptBody });
  const dialogBody = (
    <TableDemo onClickCallback={showDialog} tableMaxHeight={tableMaxHeight} enableVirtualScroll={enableVirtualScroll} />
  );
  const dlgInstance = DialogPlugin({
    header: '递进查询',
    body: dialogBody,
    onClose: () => {
      dlgInstance.destroy();
    },
    confirmBtn: null,
    cancelBtn: '关闭',
    width: '80%',
    top: '32px',
    draggable: true,
  });
};

export default function App() {
  const tableMaxHeight = getTableMaxHeight({ reservationHeight: 35 });

  return (
    <div>
      <style>{`body{margin:8px}`}</style>
      <TableDemo
        onClickCallback={showDialog}
        tableMaxHeight={tableMaxHeight}
        enableVirtualScroll={enableVirtualScroll}
      />
    </div>
  );
}

📝 更新日志

  • 本条 PR 不需要纳入 Changelog

tdesign-react

  • fix(Table): 修复虚拟滚动时,合并单元格消失的问题
  • fix(Table): 修复页面自适应时,empty 宽度比表格还大的问题
  • fix(Table): 修复在 Dialog 内,吸顶表头、吸底表尾、吸底滚动条与表格对齐不稳定的问题
  • fix(Affix): 修复自定义容器时,DOM 节点未准备好就监听导致失败的问题
  • fix(Affix): 修复自定义容器时,滚动整个页面元素会偏离的问题

@tdesign-react/chat

☑️ 请求合并前的自查清单

⚠️ 请自检并全部勾选全部选项⚠️

  • 文档已补充或无须补充
  • 代码演示已提供或无须提供
  • TypeScript 定义已补充或无须补充
  • Changelog 已提供或无须提供

@RylanBot RylanBot changed the title Rylan/fix/table/bar fix(Table): affix width not correct Feb 9, 2026
}
};

const onResize = useDebounce(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

使用 debounce 会导致 resize 时肉眼可见的闪烁

@tdesign-bot
Copy link
Collaborator

tdesign-bot commented Feb 9, 2026

TDesign Component Site Preview Open

Component Preview
tdesign-react 完成
@tdesign-react/chat 完成

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 9, 2026

  • tdesign-react-demo

    npm i https://pkg.pr.new/Tencent/tdesign-react@4131
    
    npm i https://pkg.pr.new/Tencent/tdesign-react/@tdesign-react/chat@4131
    

commit: 7d50ad5

@RylanBot RylanBot force-pushed the rylan/fix/table/bar branch 5 times, most recently from fb2f9d1 to 6f63c48 Compare February 12, 2026 09:59
@RylanBot RylanBot force-pushed the rylan/fix/table/bar branch from 6f63c48 to 8707f69 Compare February 12, 2026 10:02
container | String / Function | () => (() => window) | 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body。TS 类型:`ScrollContainer`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
content | TNode | - | 内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
offsetBottom | Number | 0 | 距离容器顶部达到指定距离后触发固定 | N
offsetBottom | Number | - | 距离容器顶部达到指定距离后触发固定 | N
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offsetTopoffsetBottom 会冲突,理论上只能二选一

scrollContainer.current.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleScroll);

// 当 container 不是 window 时,也需要监听 window 的 scroll 事件
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

监听 window 直接交给组件库内部处理更合理吧...

// 存在纵向滚动条,且固定表头时,需去除滚动条宽度
const reduceWidth = isFixedHeader ? scrollbarWidth : 0;
// 去除滚动条宽度
const reduceWidth = isWidthOverflow ? scrollbarWidth : 0;
Copy link
Collaborator Author

@RylanBot RylanBot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(日志 2➕3)affix 不对齐、empty 不居中等情况都是这里造成的

@RylanBot RylanBot force-pushed the rylan/fix/table/bar branch from af94117 to 7d50ad5 Compare February 12, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants