diff --git a/.joycode/modes/rules-4k79ih5s/agentDefinition.md b/.joycode/modes/rules-4k79ih5s/agentDefinition.md new file mode 100644 index 0000000000..b5b3132f23 --- /dev/null +++ b/.joycode/modes/rules-4k79ih5s/agentDefinition.md @@ -0,0 +1,37 @@ +You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Confirm, then write code! +- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. + +### Coding Environment + +The user asks questions about the following coding languages: + +- ReactJS +- NextJS +- JavaScript +- TypeScript +- TailwindCSS +- HTML +- CSS + +### Code Implementation Guidelines + +Follow these rules when you write code: + +- Use early returns whenever possible to make the code more readable. +- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags. +- Use “class:” instead of the tertiary operator in class tags whenever possible. +- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown. +- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes. +- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible. diff --git a/.joycode/modes/rules-4k79ih5s/customInstructions.md b/.joycode/modes/rules-4k79ih5s/customInstructions.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.joycode/prompt.json b/.joycode/prompt.json new file mode 100644 index 0000000000..f806bc0197 --- /dev/null +++ b/.joycode/prompt.json @@ -0,0 +1 @@ +[{"label":"一键安装环境","name":"Install","description":"专注于解决工作空间环境问题","prompt":"你是一位专门从事解决工作空间环境问题的全栈工程师和DevOps专家,你的主要任务是帮助用户诊断、修复和配置当前工作空间`/Users/jiangqi147/github/nutui-react`的开发环境。\n\n## 核心职责\n\n### 1. 环境检测与诊断\n- 自动扫描工作空间中的项目文件(package.json, requirements.txt, pom.xml, Gemfile, go.mod等)\n- 识别项目所需的运行环境和依赖\n- 检测当前系统已安装的环境版本\n- 分析环境配置冲突和兼容性问题\n\n### 2. 主流环境支持\n**Node.js生态系统:**\n- 检测和安装Node.js(如果用户没要求推荐LTS版本)\n- 配置npm/yarn/pnpm包管理器\n- 处理node_modules依赖问题\n- 解决版本冲突和权限问题\n\n**Python生态系统:**\n- 安装Python(2.x/3.x版本管理)\n- 配置pip包管理器和虚拟环境(venv/conda)\n- 处理requirements.txt依赖安装\n- 解决Python路径和模块导入问题\n\n**Java生态系统:**\n- 安装和配置JDK/JRE(版本选择和JAVA_HOME设置)\n- 配置Maven/Gradle构建工具\n- 处理依赖下载和仓库配置\n- 解决类路径和编译问题\n\n**其他主流环境:**\n- Go语言环境配置\n- Ruby和Rails环境\n- PHP和Composer\n- .NET Core环境\n- Docker容器化环境\n\n### 3. 项目启动与运行\n- 分析项目启动脚本和配置文件\n- 提供标准化的启动命令\n- 配置开发服务器和热重载\n- 设置环境变量和配置文件\n- 处理端口冲突和服务依赖\n\n### 4. 问题解决策略\n- 提供跨平台解决方案(Windows/macOS/Linux)\n- 给出详细的安装步骤和命令\n- 提供多种安装方式选择(官方安装器/包管理器/容器化)\n- 预防常见错误和最佳实践建议\n- 提供环境验证和测试方法\n\n### 5. 交互方式\n- 首先询问用户的操作系统和项目类型\n- 逐步引导用户完成环境配置\n- 提供可复制的命令和脚本\n- 在每个步骤后确认执行结果\n- 遇到问题时提供多种备选方案\n\n## 工作流程\n1. **环境扫描**:分析工作空间文件结构,识别项目类型\n2. **需求评估**:确定所需的运行环境和版本要求\n3. **现状检查**:检测当前已安装的环境和工具\n4. **差距分析**:对比需求与现状,列出缺失项\n5. **安装指导**:提供详细的安装和配置步骤\n6. **验证测试**:确保环境配置正确可用\n7. **项目启动**:协助用户成功启动项目\n\n请始终保持耐心和专业,用通俗易懂的语言解释技术概念,并在每个关键步骤提供清晰的指导。现在请开始分析当前工作空间的环境需求。\n"}] \ No newline at end of file diff --git a/src/packages/table/demo.tsx b/src/packages/table/demo.tsx index d65c0ce752..bdbcce51f8 100644 --- a/src/packages/table/demo.tsx +++ b/src/packages/table/demo.tsx @@ -14,6 +14,9 @@ import Demo11 from './demos/h5/demo11' import Demo12 from './demos/h5/demo12' import Demo13 from './demos/h5/demo13' import Demo14 from './demos/h5/demo14' +import Demo15 from './demos/h5/demo15' +import DemoNoVirtual from './demos/h5/demo-no-virtual' +import DemoVirtual from './demos/h5/demo-virtual' const TableDemo = () => { const [translated] = useTranslate({ @@ -31,7 +34,10 @@ const TableDemo = () => { stickyHeader: '固定表头', stickyLeftColumn: '固定左列', stickyRightColumn: '固定右列', + stickyBothColumns: '同时固定表头和左列', customRow: '自定义行', + noVirtual: '普通表格', + virtual: '虚拟滚动', }, 'en-US': { basic: 'Basic Usage', @@ -47,8 +53,11 @@ const TableDemo = () => { sorterIcon: 'Supports Replacing Sorting ICON', stickyHeader: 'Sticky Header', stickyLeftColumn: 'Sticky Left Column', - stickyRightColumn: 'Sticky Rright Column', + stickyRightColumn: 'Sticky Right Column', + stickyBothColumns: 'Sticky Both Header And Left Column', customRow: 'Custom Row', + noVirtual: 'no virtual scroll', + virtual: 'virtual scroll', }, }) @@ -80,8 +89,14 @@ const TableDemo = () => {

{translated.stickyRightColumn}

+

{translated.stickyBothColumns}

+

{translated.customRow}

+

{translated.noVirtual}

+ +

{translated.virtual}

+ ) } diff --git a/src/packages/table/demos/h5/demo-no-virtual.tsx b/src/packages/table/demos/h5/demo-no-virtual.tsx new file mode 100644 index 0000000000..b4a3ccfccb --- /dev/null +++ b/src/packages/table/demos/h5/demo-no-virtual.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react' +import TableVirtual, { TableColumnProps } from '../../index.virtual' + +// 定义数据项接口 +interface DataItem { + name: string + record: string + age: number + description?: string // 添加描述字段,用于测试不同高度的行 +} + +const DemoNoVirtual = () => { + // 生成大量数据 + const generateData = (count: number): DataItem[] => { + const data: DataItem[] = [] + for (let i = 0; i < count; i++) { + // 为部分行添加不同长度的描述,以测试动态高度 + let description + if (i % 3 === 0) { + description = `这是一段较长的描述文本,用于测试动态高度。行号: ${i}. 这段文字会导致行高增加。` + } else if (i % 5 === 0) { + description = `这是一段非常非常长的描述文本,它会占用多行空间。这是为了测试虚拟滚动表格在处理不同高度的行时的表现。行号: ${i}. 我们希望表格能够正确计算每行的实际高度,并且在滚动时保持良好的性能和用户体验。` + } else { + description = undefined + } + + data.push({ + name: `Name ${i}`, + record: ['小学', '初中', '高中', '大专', '本科'][i % 5], + age: Math.floor(Math.random() * 50) + 10, + description, + }) + } + return data + } + + // 定义列配置 + const [columns] = useState([ + { + title: 'ID', + key: 'id', + width: 50, + fixed: 'left', + render: (_record: any, index: number) => { + return index + 1 + }, + }, + { + title: '姓名', + key: 'name', + width: 80, + }, + { + title: '学历', + key: 'record', + width: 60, + }, + { + title: '年龄', + key: 'age', + width: 70, + sorter: (a: DataItem, b: DataItem) => a.age - b.age, + }, + { + title: '描述', + key: 'description', + width: 100, + fixed: 'right', + render: (record: DataItem) => { + return record.description ? ( +
+ {record.description} +
+ ) : ( + '-' + ) + }, + }, + ]) + + // 使用状态管理数据 + const [data] = useState(generateData(10)) + + return +} + +export default DemoNoVirtual diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx new file mode 100644 index 0000000000..f29eeec5b3 --- /dev/null +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -0,0 +1,123 @@ +import React, { useState, useRef } from 'react' +import TableVirtual, { + TableColumnProps, + VirtualTableRef, +} from '../../index.virtual' + +// 定义数据项接口 +interface DataItem { + name: string + record: string + age: number + description?: string // 添加描述字段,用于测试不同高度的行 +} + +const DemoVirtual = () => { + // 创建表格引用 + const tableRef = useRef(null) + + // 生成大量数据 + const generateData = (count: number): DataItem[] => { + const data: DataItem[] = [] + for (let i = 0; i < count; i++) { + // 为部分行添加不同长度的描述,以测试动态高度 + let description + if (i % 3 === 0) { + description = `这是一段较长的描述文本,用于测试动态高度。行号: ${i}. 这段文字会导致行高增加。` + } else if (i % 5 === 0) { + description = `这是一段非常非常长的描述文本,它会占用多行空间。这是为了测试虚拟滚动表格在处理不同高度的行时的表现。行号: ${i}. 我们希望表格能够正确计算每行的实际高度,并且在滚动时保持良好的性能和用户体验。` + } else { + description = undefined + } + + data.push({ + name: `Name ${i}`, + record: ['小学', '初中', '高中', '大专', '本科'][i % 5], + age: Math.floor(Math.random() * 50) + 10, + description, + }) + } + return data + } + + // 定义列配置 + const [columns] = useState([ + { + title: 'ID', + key: 'id', + width: 50, + fixed: 'left', + render: (_record: any, index: number) => { + return index + 1 + }, + }, + { + title: '姓名', + key: 'name', + width: 80, + }, + { + title: '学历', + key: 'record', + width: 60, + }, + { + title: '年龄', + key: 'age', + width: 70, + sorter: (a: DataItem, b: DataItem) => a.age - b.age, + }, + { + title: '描述', + key: 'description', + width: 100, + fixed: 'right', + render: (record: DataItem) => { + return record.description ? ( +
+ {record.description} +
+ ) : ( + '-' + ) + }, + }, + ]) + + // 使用状态管理数据 + const [data, setData] = useState(generateData(1000)) + + // 更新数据的方法 + const updateData = (count: number) => { + setData(generateData(count)) + } + + // 滚动到指定行的方法 + const handleScrollToRow = (index: number) => { + if (tableRef.current) { + tableRef.current.scrollToIndex(index) + } + } + + return ( + + ) +} + +export default DemoVirtual diff --git a/src/packages/table/demos/h5/demo15.tsx b/src/packages/table/demos/h5/demo15.tsx new file mode 100644 index 0000000000..e68d46a1ca --- /dev/null +++ b/src/packages/table/demos/h5/demo15.tsx @@ -0,0 +1,77 @@ +import { Table, TableColumnProps } from '@nutui/nutui-react' +import React, { useState } from 'react' + +const Demo15 = () => { + const [data] = useState([ + { + name: 'Tom', + gender: '男', + record: '小学', + birthday: '2010-01-01', + age: 10, + }, + { + name: 'Lucy', + gender: '女', + record: '本科', + birthday: '2000-01-01', + age: 30, + }, + { + name: 'Jack', + gender: '男', + record: '高中', + birthday: '2020-01-01', + age: 4, + }, + { + name: 'Sara', + gender: '女', + record: '高中', + birthday: '2020-01-01', + age: 6, + }, + { + name: 'Frank', + gender: '男', + record: '幼儿园', + birthday: '2020-01-01', + age: 3, + }, + ]) + + const [columnsStickHeaderLeft] = useState>([ + { + title: '姓名', + key: 'name', + align: 'center', + fixed: 'left', + width: 100, + }, + { + title: '性别', + key: 'gender', + }, + { + title: '学历', + key: 'record', + }, + { + title: '生日', + key: 'birthday', + }, + { + title: '年龄', + key: 'age', + }, + ]) + + return ( + + ) +} +export default Demo15 diff --git a/src/packages/table/demos/h5/demo9.tsx b/src/packages/table/demos/h5/demo9.tsx index aaf32ce379..5200385b90 100644 --- a/src/packages/table/demos/h5/demo9.tsx +++ b/src/packages/table/demos/h5/demo9.tsx @@ -1,15 +1,12 @@ import React, { useState } from 'react' -import { Table, Toast } from '@nutui/nutui-react' +import { + SortStateType, + Table, + TableColumnProps, + Toast, +} from '@nutui/nutui-react' +import { ArrowDown, ArrowUp } from '@nutui/icons-react' -interface TableColumnProps { - key: string - title?: string - align?: string - sorter?: ((a: any, b: any) => number) | boolean | string - render?: (rowData: any, rowIndex: number) => string | React.ReactNode - fixed?: 'left' | 'right' - width?: number -} const Demo9 = () => { const [data] = useState([ { @@ -50,14 +47,26 @@ const Demo9 = () => { { title: '年龄', key: 'age', - sorter: (row1: any, row2: any) => { - return row1.age - row2.age + sorterIcon: (currentSortState) => { + if (currentSortState === null) + return ( + + ) + if (currentSortState === 'asc') + return + if (currentSortState === 'desc') + return }, + sorter: (row1: any, row2: any) => row1.age - row2.age, }, ]) - const handleSorter = (item: TableColumnProps, data: Array) => { - Toast.show(`${JSON.stringify(item)}`) + const handleSorter = ( + item: TableColumnProps, + sortedData?: Array, + sortState?: SortStateType + ) => { + Toast.show(`${item.title} 排序状态:${sortState || '不排序'}`) } return ( diff --git a/src/packages/table/doc.en-US.md b/src/packages/table/doc.en-US.md index fab4466089..073b36bf91 100644 --- a/src/packages/table/doc.en-US.md +++ b/src/packages/table/doc.en-US.md @@ -114,6 +114,26 @@ import { Table } from '@nutui/nutui-react' ::: +### Disable Virtual Scrolling + +Virtual scrolling table without enabling virtual scrolling, used as a regular table. + +:::demo + + + +::: + +### Virtual Scrolling + Dynamic Row Height + +When the table has a large amount of data, virtual scrolling can be used to optimize performance. Supports dynamic calculation of row height, suitable for scenarios where row content height is not fixed. + +:::demo + + + +::: + ## Table ### Props @@ -142,6 +162,65 @@ import { Table } from '@nutui/nutui-react' | width | Column width | `number` | `auto` | | fixed | Fixed position | `left` \| `right` | `-` | +## TableVirtual + +### Import + +```tsx +import { TableVirtual } from '@nutui/nutui-react' +``` + +### Props + +Inherits all properties of the Table component, and adds the following properties: + +| Property | Description | Type | Default Value | +| --- | --- | --- | --- | +| virtual | Whether to enable virtual scrolling | `boolean` | `false` | +| height | Table viewport height | `number` | `300` | +| rowHeight | Height of each row | `number` | `40` | +| overscan | Number of rows to preload | `number` | `5` | +| dynamicHeight | Whether to enable dynamic height (if true, will try to get the actual height of each row) | `boolean` | `false` | + +### VirtualTableRef + +| Property | Description | Type | +| --- | --- | --- | +| scrollToIndex | Method to scroll to a specific index | `(index: number) => void` | + +### Example + +```tsx +import React, { useRef } from 'react' +import { TableVirtual } from '@nutui/nutui-react' +import type { VirtualTableRef } from '@nutui/nutui-react' + +const VirtualTableExample = () => { + const tableRef = useRef(null) + + // Scroll to specific row + const scrollToRow = (index: number) => { + tableRef.current?.scrollToIndex(index) + } + + return ( + + ) +} + +VirtualTableExample.displayName = 'VirtualTableExample' + +export default VirtualTableExample +``` + ## Theme Customization ### Style Variables diff --git a/src/packages/table/doc.md b/src/packages/table/doc.md index 188d136f57..ef1ae1388f 100644 --- a/src/packages/table/doc.md +++ b/src/packages/table/doc.md @@ -114,6 +114,26 @@ import { Table } from '@nutui/nutui-react' ::: +### 不开启虚拟滚动 + +虚拟滚动表格不开启虚拟滚动,当普通表格使用。 + +:::demo + + + +::: + +### 虚拟滚动 + 动态行高 + +当表格数据量较大时,可以使用虚拟滚动来优化性能。支持动态计算行高,适用于行内容高度不固定的场景。 + +:::demo + + + +::: + ## Table ### Props @@ -142,6 +162,65 @@ import { Table } from '@nutui/nutui-react' | width | 列宽度 | `number` | `auto` | | fixed | 固定位置 | `left` \| `right` | `-` | +## TableVirtual + +### 引入 + +```tsx +import { TableVirtual } from '@nutui/nutui-react' +``` + +### Props + +继承 Table 组件的所有属性,并新增以下属性: + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| virtual | 是否启用虚拟滚动 | `boolean` | `false` | +| height | 表格可视区域高度 | `number` | `300` | +| rowHeight | 每行高度 | `number` | `40` | +| overscan | 预加载的行数 | `number` | `5` | +| dynamicHeight | 是否启用动态高度(如果为true,将尝试获取每行的实际高度) | `boolean` | `false` | + +### VirtualTableRef + +| 属性 | 说明 | 类型 | +| --- | --- | --- | +| scrollToIndex | 滚动到指定索引的方法 | `(index: number) => void` | + +### 示例 + +```tsx +import React, { useRef } from 'react' +import { TableVirtual } from '@nutui/nutui-react' +import type { VirtualTableRef } from '@nutui/nutui-react' + +const VirtualTableExample = () => { + const tableRef = useRef(null) + + // 滚动到指定行 + const scrollToRow = (index: number) => { + tableRef.current?.scrollToIndex(index) + } + + return ( + + ) +} + +VirtualTableExample.displayName = 'VirtualTableExample' + +export default VirtualTableExample +``` + ## 主题定制 ### 样式变量 diff --git a/src/packages/table/index.virtual.ts b/src/packages/table/index.virtual.ts new file mode 100644 index 0000000000..84838d5355 --- /dev/null +++ b/src/packages/table/index.virtual.ts @@ -0,0 +1,16 @@ +import { + TableVirtual, + VirtualTableRef, + VirtualTableProps, + useVirtualScroll, + useThrottle, + useDebounce, +} from './virtual' +import { TableColumnProps } from '@/types/spec/table/base' + +// 导出TableVirtual组件,使其能够接收正确的props +const TableVirtualWrapper = TableVirtual + +export { useVirtualScroll, useThrottle, useDebounce } +export type { TableColumnProps, VirtualTableRef, VirtualTableProps } +export default TableVirtualWrapper diff --git a/src/packages/table/table.scss b/src/packages/table/table.scss index 07221de69a..998d43be31 100644 --- a/src/packages/table/table.scss +++ b/src/packages/table/table.scss @@ -49,6 +49,12 @@ } } + &-virtual { + .nut-table-main-body > div { + background-color: $color-background-overlay; + } + } + &-head, &-body { background: inherit; @@ -64,12 +70,15 @@ } &-th { + box-sizing: border-box; display: table-cell; padding: $table-cols-padding; table-layout: fixed; background: inherit; position: sticky; top: 0; + background-color: $table-th-bg-color; + z-index: 2; &.nut-table-fixed-left, &.nut-table-fixed-right { @@ -82,6 +91,7 @@ } &-td { + box-sizing: border-box; display: table-cell; padding: $table-cols-padding; table-layout: fixed; diff --git a/src/packages/table/table.tsx b/src/packages/table/table.tsx index 37985cf55a..5d141163b8 100644 --- a/src/packages/table/table.tsx +++ b/src/packages/table/table.tsx @@ -5,7 +5,7 @@ import { useConfig, useRtl } from '@/packages/configprovider' import { ComponentDefaults } from '@/utils/typings' import { usePropsValue } from '@/hooks/use-props-value' import { useTableSticky } from './utils' -import { TableColumnProps, WebTableProps } from '@/types' +import { SortStateType, TableColumnProps, WebTableProps } from '@/types' const defaultProps = { ...ComponentDefaults, @@ -42,7 +42,7 @@ export const Table: FunctionComponent< ...defaultProps, ...props, } - const sortedMapping = useRef<{ [key: string]: boolean }>({}) + const sortedMapping = useRef<{ [key: string]: SortStateType }>({}) const [innerValue, setValue] = usePropsValue({ defaultValue: data, finalValue: [], @@ -65,19 +65,59 @@ export const Table: FunctionComponent< const cls = classNames(classPrefix, className) const handleSorterClick = (item: TableColumnProps) => { - if (item.sorter && !sortedMapping.current[item.key]) { + if (!item.sorter) return + + // 获取当前排序状态,如果不存在则默认为 null(不排序) + const currentSortState = sortedMapping.current[item.key] || null + + // 根据当前状态确定下一个状态:null -> asc -> desc -> null + let nextSortState: 'asc' | 'desc' | null + if (currentSortState === null) { + nextSortState = 'asc' // 默认不排序 -> 升序 + } else if (currentSortState === 'asc') { + nextSortState = 'desc' // 升序 -> 降序 + } else { + nextSortState = null // 降序 -> 不排序 + } + + // 更新排序状态 + sortedMapping.current[item.key] = nextSortState + + // 根据排序状态执行相应的排序操作 + if (nextSortState === null) { + // 不排序,恢复原始数据 + setValue(data) + onSort && onSort(item) + } else { const copied = [...innerValue] if (typeof item.sorter === 'function') { - copied.sort(item.sorter as (a: any, b: any) => number) + // 使用自定义排序函数 + if (nextSortState === 'asc') { + copied.sort(item.sorter as (a: any, b: any) => number) + } else { + // 降序:交换排序函数的参数顺序 + copied.sort( + (a, b) => -(item.sorter as (a: any, b: any) => number)(a, b) + ) + } } else if (item.sorter === 'default') { - copied.sort() + // 默认排序 + if (nextSortState === 'asc') { + copied.sort() + } else { + copied.sort().reverse() + } + } else if (item.sorter === true) { + // 简单排序,根据列的 key 值进行排序 + const key = item.key + if (nextSortState === 'asc') { + copied.sort((a, b) => (a[key] > b[key] ? 1 : -1)) + } else { + copied.sort((a, b) => (a[key] > b[key] ? -1 : 1)) + } } - sortedMapping.current[item.key] = true setValue(copied, true) - onSort && onSort(item, copied) - } else { - sortedMapping.current[item.key] = false - setValue(data) + onSort && onSort(item, copied, nextSortState) } } @@ -94,6 +134,42 @@ export const Table: FunctionComponent< const renderHeadCells = () => { return columns.map((item, index) => { + // 获取当前列的排序状态 + const currentSortState = sortedMapping.current[item.key] || null + + // 根据排序状态决定是否显示图标以及显示什么图标 + const renderSorterIcon = () => { + if (!item.sorter) return null + + // 如果列提供了自定义的排序图标函数,优先使用 + if (item.sorterIcon) { + return item.sorterIcon(currentSortState) + } + + // 如果提供了全局的排序图标,使用全局图标 + if (sorterIcon) { + return sorterIcon + } + + // 默认图标逻辑:根据排序状态显示不同的图标 + if (currentSortState === 'asc') { + // 升序状态 + return ( + + ) + } + if (currentSortState === 'desc') { + // 降序状态 + return + } + // 未排序状态 - 显示较淡的图标 + return + } + return (
{item.title}  - {item.sorter && - (sorterIcon || )} + {item.sorter && renderSorterIcon()}
) }) diff --git a/src/packages/table/virtual/hooks.ts b/src/packages/table/virtual/hooks.ts new file mode 100644 index 0000000000..bfc1a0724a --- /dev/null +++ b/src/packages/table/virtual/hooks.ts @@ -0,0 +1,111 @@ +import { useCallback, useRef } from 'react' + +/** + * 节流Hook - 限制函数调用频率 + * @param fn 需要节流的函数 + * @param delay 节流延迟时间(毫秒) + * @param options 配置选项 + * @returns 节流处理后的函数 + */ +export function useThrottle any>( + fn: T, + delay: number = 16, // 默认约60fps + options: { leading?: boolean; trailing?: boolean } = {} +): T { + const { leading = true, trailing = true } = options + const lastCallTime = useRef(0) + const timer = useRef(null) + const lastArgs = useRef([]) + + // 清除定时器 + const clearTimer = () => { + if (timer.current !== null) { + window.cancelAnimationFrame(timer.current) + timer.current = null + } + } + + return useCallback( + (...args: Parameters) => { + const now = Date.now() + const elapsed = now - lastCallTime.current + lastArgs.current = args + + // 重置上次调用时间 + function resetLastCallTime() { + lastCallTime.current = now + } + + // 如果是第一次调用或者已经超过延迟时间 + if (elapsed > delay) { + clearTimer() + + if (leading) { + resetLastCallTime() + fn(...args) + } else if (trailing) { + timer.current = window.requestAnimationFrame(() => { + resetLastCallTime() + fn(...lastArgs.current) + timer.current = null + }) + } + } else if (trailing && timer.current === null) { + // 设置定时器,确保最后一次调用也能执行 + timer.current = window.requestAnimationFrame(() => { + resetLastCallTime() + fn(...lastArgs.current) + timer.current = null + }) + } + }, + [fn, delay, leading, trailing] + ) as T +} + +/** + * 防抖Hook - 延迟函数调用直到停止触发一段时间后 + * @param fn 需要防抖的函数 + * @param delay 防抖延迟时间(毫秒) + * @param options 配置选项 + * @returns 防抖处理后的函数 + */ +export function useDebounce any>( + fn: T, + delay: number = 300, + options: { leading?: boolean; trailing?: boolean } = {} +): T { + const { leading = false, trailing = true } = options + const timer = useRef(null) + const isLeadingCalled = useRef(false) + + return useCallback( + (...args: Parameters) => { + const invokeLeading = leading && !isLeadingCalled.current + + // 清除之前的定时器 + if (timer.current !== null) { + window.clearTimeout(timer.current) + timer.current = null + } + + // 如果是第一次调用并且启用了leading选项 + if (invokeLeading) { + isLeadingCalled.current = true + fn(...args) + } + + // 设置新的定时器 + if (trailing || !leading) { + timer.current = window.setTimeout(() => { + if (trailing) { + fn(...args) + } + isLeadingCalled.current = false + timer.current = null + }, delay) + } + }, + [fn, delay, leading, trailing] + ) as T +} diff --git a/src/packages/table/virtual/index.ts b/src/packages/table/virtual/index.ts new file mode 100644 index 0000000000..7797ad064b --- /dev/null +++ b/src/packages/table/virtual/index.ts @@ -0,0 +1,8 @@ +export { TableVirtual } from './table-virtual' +export type { VirtualTableProps, VirtualTableRef } from './table-virtual' +export { useVirtualScroll } from './virtual-scroll' +export type { + VirtualScrollOptions, + VirtualScrollResult, +} from './virtual-scroll' +export { useThrottle, useDebounce } from './hooks' diff --git a/src/packages/table/virtual/table-row.tsx b/src/packages/table/virtual/table-row.tsx new file mode 100644 index 0000000000..119288846c --- /dev/null +++ b/src/packages/table/virtual/table-row.tsx @@ -0,0 +1,148 @@ +import React, { memo } from 'react' +import classNames from 'classnames' +import { TableColumnProps } from '@/types' + +interface TableRowProps { + // 行数据 + item: any + // 行索引 + rowIndex: number + // 行类名前缀 + bodyClassPrefix: string + // 单元格类名计算函数 + cellClasses: (item: TableColumnProps) => Record + // 获取粘性类名 + getStickyClass: ( + key: string + ) => Record | undefined + // 获取粘性样式 + getStickyStyle: (key: string) => Record | undefined + // 获取列配置 + getColumnItem: (value: string) => TableColumnProps + // 列数据项 + sortDataItem: () => [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + number, + ][] + // 是否启用动态高度 + dynamicHeight?: boolean + // 获取行引用的方法 + getRowRef?: (index: number) => (element: HTMLElement | null) => void +} + +/** + * 表格行组件 - 使用React.memo优化性能 + * 只有当行数据或相关属性变化时才会重新渲染 + */ +const TableRow: React.FC = ({ + item, + rowIndex, + bodyClassPrefix, + cellClasses, + getStickyClass, + getStickyStyle, + getColumnItem, + sortDataItem, + dynamicHeight, + getRowRef, +}) => { + // 渲染单元格 + const renderBodyTds = () => { + return sortDataItem().map( + ([value, render, width]: [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + number, + ]) => { + return ( +
+ {typeof item[value] === 'function' || + typeof render === 'function' ? ( +
{render ? render(item, rowIndex) : item[value](item)}
+ ) : ( + item[value] + )} +
+ ) + } + ) + } + + // 处理自定义行渲染 + const { rowRender } = item + if (rowRender && typeof rowRender === 'function') { + const inner = renderBodyTds() + const renderedRow = rowRender(item, rowIndex, { inner }) + + // 如果自定义渲染函数返回的是React元素,我们需要添加ref + if (React.isValidElement(renderedRow) && dynamicHeight && getRowRef) { + return React.cloneElement(renderedRow, { + // @ts-ignore + ref: getRowRef(rowIndex), + }) + } + return renderedRow + } + + // 标准行渲染 + return ( +
+ {renderBodyTds()} +
+ ) +} + +// 使用React.memo包装组件,避免不必要的重渲染 +// 只有当props发生变化时,组件才会重新渲染 +export default memo(TableRow, (prevProps, nextProps) => { + // 如果行索引不同,需要重新渲染 + if (prevProps.rowIndex !== nextProps.rowIndex) { + return false + } + + // 如果行数据引用不同,需要进一步比较 + if (prevProps.item !== nextProps.item) { + // 简单比较对象的键值是否相同 + // 注意:这是一个浅比较,对于复杂嵌套对象可能需要更深入的比较 + const prevKeys = Object.keys(prevProps.item) + const nextKeys = Object.keys(nextProps.item) + + if (prevKeys.length !== nextKeys.length) { + return false + } + + // 比较每个键的值 + for (const key of prevKeys) { + if (prevProps.item[key] !== nextProps.item[key]) { + return false + } + } + } + + // 其他props变化也需要重新渲染 + if ( + prevProps.bodyClassPrefix !== nextProps.bodyClassPrefix || + prevProps.dynamicHeight !== nextProps.dynamicHeight + ) { + return false + } + + // 如果所有比较都通过,则认为组件不需要重新渲染 + return true +}) diff --git a/src/packages/table/virtual/table-virtual.tsx b/src/packages/table/virtual/table-virtual.tsx new file mode 100644 index 0000000000..3f2332c490 --- /dev/null +++ b/src/packages/table/virtual/table-virtual.tsx @@ -0,0 +1,493 @@ +import React, { + useEffect, + useRef, + useState, + useImperativeHandle, + forwardRef, + ForwardRefRenderFunction, + useCallback, +} from 'react' +import classNames from 'classnames' +import { ArrowDown } from '@nutui/icons-react' +import { useConfig, useRtl } from '@/packages/configprovider' +import { ComponentDefaults } from '@/utils/typings' +import { usePropsValue } from '@/hooks/use-props-value' +import { useTableSticky } from '../utils' +import { useVirtualScroll } from './virtual-scroll' +import TableRow from './table-row' +import { SortStateType, TableColumnProps, WebTableProps } from '@/types' + +export interface VirtualTableProps extends Omit { + // 是否启用虚拟滚动 + virtual?: boolean + // 表格可视区域高度 + height?: number + // 每行高度 + rowHeight?: number + // 预加载的行数 + overscan?: number + // 滚动到指定索引的方法 + scrollToIndex?: (index: number) => void + // 覆盖WebTableProps中的bordered,使其可选 + bordered?: boolean + // 是否启用动态高度(如果为true,将尝试获取每行的实际高度) + dynamicHeight?: boolean +} + +// 定义组件引用类型 +export interface VirtualTableRef { + // 滚动到指定索引的方法 + scrollToIndex: (index: number) => void +} + +const defaultProps = { + ...ComponentDefaults, + columns: [], + data: [], + bordered: true, + striped: false, + noData: '', + sorterIcon: null, + showHeader: true, + virtual: false, + height: 300, + rowHeight: 40, + overscan: 5, + dynamicHeight: false, +} as VirtualTableProps + +// 使用ForwardRefRenderFunction来定义组件,以便支持ref转发 +const TableVirtualComponent: ForwardRefRenderFunction< + VirtualTableRef, + VirtualTableProps +> = (props, ref) => { + const { locale } = useConfig() + const rtl = useRtl() + defaultProps.noData = locale.noData + + const { + children, + className, + style, + columns, + data, + bordered, + summary, + striped, + noData, + sorterIcon, + showHeader, + onSort, + virtual, + height, + rowHeight, + overscan, + scrollToIndex, + dynamicHeight, + ...rest + } = { + ...defaultProps, + ...props, + } + const sortedMapping = useRef<{ [key: string]: SortStateType }>({}) + const [innerValue, setValue] = usePropsValue({ + defaultValue: data, + finalValue: [], + }) + const { + isSticky, + stickyLeftWidth, + stickyRightWidth, + getStickyClass, + getStickyStyle, + } = useTableSticky(columns, rtl) + + // 表头高度 + const [headerHeight, setHeaderHeight] = useState(rowHeight) + const headerRef = useRef(null) + + // 计算表头高度 + useEffect(() => { + if (headerRef.current && showHeader) { + setHeaderHeight(headerRef.current.getBoundingClientRect().height) + } else if (!showHeader) { + setHeaderHeight(0) + } + }, [showHeader, headerRef.current]) + + // 虚拟滚动相关 + const { + visibleRange, + totalHeight, + offsetY, + onScroll, + containerRef, + scrollTo, + getRowRef, + updateItemHeight, + } = useVirtualScroll({ + total: innerValue.length, + viewportHeight: (height || 300) - (showHeader ? headerHeight || 0 : 0), + itemHeight: rowHeight || 40, + overscan: overscan || 5, + dynamicHeight: dynamicHeight || false, + }) + + // 使用useImperativeHandle暴露方法给外部 + useImperativeHandle( + ref, + () => ({ + scrollToIndex: scrollTo, + }), + [scrollTo] + ) + + // 当数据变化时,更新内部值 + useEffect(() => { + setValue(data) + + // 当数据变化时,如果启用了虚拟滚动,需要重新计算虚拟滚动状态 + if (virtual && containerRef.current) { + // 保存当前滚动位置 + const currentScrollTop = containerRef.current.scrollTop + + // 延迟一帧,确保内部状态已更新 + requestAnimationFrame(() => { + // 确保容器引用仍然有效 + if (containerRef.current) { + // 手动触发滚动事件以更新虚拟滚动状态 + const scrollEvent = new UIEvent('scroll', { + bubbles: true, + }) + containerRef.current.dispatchEvent(scrollEvent) + + // 如果数据量变化较大,可能需要再次触发滚动事件 + if (data.length !== innerValue.length) { + setTimeout(() => { + if (containerRef.current) { + const secondScrollEvent = new UIEvent('scroll', { + bubbles: true, + }) + containerRef.current.dispatchEvent(secondScrollEvent) + } + }, 50) + } + } + }) + } + }, [data, virtual, innerValue.length]) + + // 当表头高度变化时,更新虚拟滚动状态 + useEffect(() => { + if (virtual && containerRef.current && showHeader) { + // 触发一次滚动事件,以更新虚拟滚动状态 + const scrollEvent = new UIEvent('scroll', { bubbles: true }) + containerRef.current.dispatchEvent(scrollEvent) + } + }, [headerHeight, virtual, showHeader]) + + // 将scrollTo方法暴露给外部(兼容旧的API) + useEffect(() => { + if (scrollToIndex && virtual && typeof scrollToIndex === 'function') { + // 直接将内部的scrollTo方法赋值给外部的scrollToIndex + // 这样外部可以通过ref.current.scrollToIndex(index)来调用 + scrollToIndex(scrollTo as any) + } + }, [scrollToIndex, virtual, scrollTo]) + + const classPrefix = 'nut-table' + const headerClassPrefix = `${classPrefix}-main-head-tr` + const bodyClassPrefix = `${classPrefix}-main-body-tr` + const cls = classNames(classPrefix, className) + + const handleSorterClick = (item: TableColumnProps) => { + if (!item.sorter) return + + // 获取当前排序状态,如果不存在则默认为 null(不排序) + const currentSortState = sortedMapping.current[item.key] || null + + // 根据当前状态确定下一个状态:null -> asc -> desc -> null + let nextSortState: 'asc' | 'desc' | null + if (currentSortState === null) { + nextSortState = 'asc' // 默认不排序 -> 升序 + } else if (currentSortState === 'asc') { + nextSortState = 'desc' // 升序 -> 降序 + } else { + nextSortState = null // 降序 -> 不排序 + } + + // 更新排序状态 + sortedMapping.current[item.key] = nextSortState + + // 根据排序状态执行相应的排序操作 + if (nextSortState === null) { + // 不排序,恢复原始数据 + setValue(data) + onSort && onSort(item) + } else { + const copied = [...innerValue] + if (typeof item.sorter === 'function') { + // 使用自定义排序函数 + if (nextSortState === 'asc') { + copied.sort(item.sorter as (a: any, b: any) => number) + } else { + // 降序:交换排序函数的参数顺序 + copied.sort( + (a, b) => -(item.sorter as (a: any, b: any) => number)(a, b) + ) + } + } else if (item.sorter === 'default') { + // 默认排序 + if (nextSortState === 'asc') { + copied.sort() + } else { + copied.sort().reverse() + } + } else if (item.sorter === true) { + // 简单排序,根据列的 key 值进行排序 + const key = item.key + if (nextSortState === 'asc') { + copied.sort((a, b) => (a[key] > b[key] ? 1 : -1)) + } else { + copied.sort((a, b) => (a[key] > b[key] ? -1 : 1)) + } + } + setValue(copied, true) + onSort && onSort(item, copied, nextSortState) + } + } + + const cellClasses = useCallback( + (item: TableColumnProps) => { + return { + [`${headerClassPrefix}-border`]: bordered, + [`${headerClassPrefix}-align${item.align ? item.align : ''}`]: true, + } + }, + [headerClassPrefix, bordered] + ) + + const getColumnItem = useCallback( + (value: string): TableColumnProps => { + return columns.filter((item: TableColumnProps) => item.key === value)[0] + }, + [columns] + ) + + const renderHeadCells = () => { + return columns.map((item: TableColumnProps, index: number) => { + // 获取当前列的排序状态 + const currentSortState = sortedMapping.current[item.key] || null + + // 根据排序状态决定是否显示图标以及显示什么图标 + const renderSorterIcon = () => { + if (!item.sorter) return null + + // 如果列提供了自定义的排序图标函数,优先使用 + if (item.sorterIcon) { + return item.sorterIcon(currentSortState) + } + + // 如果提供了全局的排序图标,使用全局图标 + if (sorterIcon) { + return sorterIcon + } + + // 默认图标逻辑:根据排序状态显示不同的图标 + if (currentSortState === 'asc') { + // 升序状态 + return ( + + ) + } + if (currentSortState === 'desc') { + // 降序状态 + return + } + // 未排序状态 - 显示较淡的图标 + return + } + + return ( +
handleSorterClick(item)} + style={{ + ...getStickyStyle(item.key), + width: item.width, + }} + > + {item.title}  + {item.sorter && renderSorterIcon()} +
+ ) + }) + } + + const sortDataItem = useCallback(() => { + return columns.map((column: TableColumnProps) => { + return [column.key, column.render, column.width] as [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + number, + ] + }) + }, [columns]) + + // 使用useCallback优化renderBodyTrs函数,避免不必要的重新创建 + const renderBodyTrs = useCallback(() => { + // 如果启用了虚拟滚动,只渲染可视区域内的行 + const dataToRender = virtual + ? innerValue.slice( + Math.min(visibleRange[0], innerValue.length - 1), + Math.min(visibleRange[1] + 1, innerValue.length) + ) + : innerValue + + // 如果没有数据要渲染,返回空数组 + if (dataToRender.length === 0) { + return [] + } + + return dataToRender + .map((item: any, index: number) => { + // 计算实际行索引(用于虚拟滚动) + const actualIndex = virtual ? visibleRange[0] + index : index + + // 确保item存在且是一个有效的对象 + if (!item || typeof item !== 'object') { + console.warn('Invalid item in table data:', item) + return null + } + + // 使用记忆化的TableRow组件,减少不必要的重渲染 + return ( + + ) + }) + .filter(Boolean) // 过滤掉无效的行 + }, [ + virtual, + innerValue, + visibleRange, + bodyClassPrefix, + cellClasses, + getStickyClass, + getStickyStyle, + getColumnItem, + sortDataItem, + dynamicHeight, + getRowRef, + ]) + + return ( +
+
onScroll(e) : undefined} + style={{ + ...style, + ...(virtual + ? { + height: height || 300, + maxHeight: height || 300, + overflow: 'auto', + position: 'relative', + } + : { + height: height || 300, + }), + }} + > +
+ {showHeader && ( +
+
{renderHeadCells()}
+
+ )} +
+ {virtual && ( +
+ {renderBodyTrs()} +
+ )} + {!virtual && renderBodyTrs()} +
+
+
+ {isSticky ? ( + <> +
+
+ + ) : null} + {(summary || innerValue.length === 0) && ( +
{summary || noData}
+ )} +
+ ) +} + +// 使用forwardRef包装组件,以便支持ref转发 +export const TableVirtual = forwardRef( + TableVirtualComponent +) + +TableVirtual.displayName = 'NutTableVirtual' diff --git a/src/packages/table/virtual/virtual-scroll.ts b/src/packages/table/virtual/virtual-scroll.ts new file mode 100644 index 0000000000..be63f10f6f --- /dev/null +++ b/src/packages/table/virtual/virtual-scroll.ts @@ -0,0 +1,264 @@ +import { useEffect, useRef, useState } from 'react' +import { useThrottle } from './hooks' + +export interface VirtualScrollOptions { + // 数据总条数 + total: number + // 可视区域高度 + viewportHeight: number + // 每行默认高度(当无法获取实际高度时使用) + itemHeight: number + // 预加载的行数(可视区域外上下额外渲染的行数) + overscan?: number + // 是否启用动态高度(如果为true,将尝试获取每行的实际高度) + dynamicHeight?: boolean +} + +export interface VirtualScrollResult { + // 可视区域内的行索引范围 + visibleRange: [number, number] + // 容器总高度 + totalHeight: number + // 当前滚动位置的偏移量 + offsetY: number + // 滚动事件处理函数 + onScroll: (event: React.UIEvent) => void + // 容器引用 + containerRef: React.RefObject + // 滚动到指定索引的方法 + scrollTo: (index: number) => void + // 更新指定行高度的方法 + updateItemHeight: (index: number, height: number) => void + // 获取行元素引用的方法 + getRowRef: (index: number) => (element: HTMLElement | null) => void +} + +/** + * 虚拟滚动Hook + * @param options 虚拟滚动配置选项 + * @returns 虚拟滚动状态和方法 + */ +export function useVirtualScroll( + options: VirtualScrollOptions +): VirtualScrollResult { + const { + total, + viewportHeight, + itemHeight, + overscan = 5, + dynamicHeight = false, + } = options + + // 高度缓存,用于存储每行的实际高度 + const [heightCache, setHeightCache] = useState>({}) + + // 行元素引用缓存 + const rowRefs = useRef>({}) + + // 计算总高度(考虑动态高度) + const calculateTotalHeight = () => { + if (!dynamicHeight) { + return total * itemHeight + } + + let height = 0 + for (let i = 0; i < total; i++) { + height += heightCache[i] || itemHeight + } + return height + } + + const totalHeight = calculateTotalHeight() + + // 容器引用 + const containerRef = useRef(null) + + // 当前滚动位置 - 使用ref而不是state来避免不必要的重渲染 + const scrollTopRef = useRef(0) + // 创建一个状态,但仅用于触发重新渲染,不直接用于计算 + const [scrollTopState, setScrollTopState] = useState(0) + + // 使用ref保存当前的数据总量,以便在滚动事件处理函数中访问最新值 + const totalRef = useRef(total) + totalRef.current = total + + // 使用useEffect监听total变化,确保数据更新时重新计算 + useEffect(() => { + // 当数据总量变化时,如果当前滚动位置超出了新的总高度,则调整滚动位置 + if (containerRef.current && scrollTopRef.current > totalHeight) { + // 如果当前滚动位置超出了新的总高度,则滚动到顶部 + containerRef.current.scrollTop = 0 + scrollTopRef.current = 0 + setScrollTopState(0) + } else if (containerRef.current) { + // 即使滚动位置没有超出范围,也触发一次滚动事件,确保可视区域正确更新 + const scrollEvent = new UIEvent('scroll', { bubbles: true }) + containerRef.current.dispatchEvent(scrollEvent) + } + }, [total, totalHeight]) + + // 计算可视区域内的行索引范围(考虑动态高度) + const calculateVisibleRange = () => { + const currentScrollTop = scrollTopRef.current + + if (!dynamicHeight) { + // 固定高度的简单计算 + const start = Math.max( + 0, + Math.floor(currentScrollTop / itemHeight) - overscan + ) + const end = Math.min( + total - 1, + Math.ceil((currentScrollTop + viewportHeight) / itemHeight) + overscan + ) + return [start, end] as [number, number] + } + + // 动态高度的计算 + let currentHeight = 0 + let startIndex = 0 + let endIndex = 0 + + // 找到起始索引 + for (let i = 0; i < total; i++) { + const rowHeight = heightCache[i] || itemHeight + if (currentHeight + rowHeight > currentScrollTop) { + startIndex = Math.max(0, i - overscan) + break + } + currentHeight += rowHeight + } + + // 找到结束索引 + currentHeight = 0 + for (let i = 0; i < total; i++) { + const rowHeight = heightCache[i] || itemHeight + currentHeight += rowHeight + if (currentHeight > currentScrollTop + viewportHeight) { + endIndex = Math.min(total - 1, i + overscan) + break + } + + // 如果到达最后一行,设置结束索引为最后一行 + if (i === total - 1) { + endIndex = total - 1 + } + } + + return [startIndex, endIndex] as [number, number] + } + + const visibleRange = calculateVisibleRange() + + // 计算偏移量(考虑动态高度) + const calculateOffsetY = () => { + if (!dynamicHeight) { + return visibleRange[0] * itemHeight + } + + let offset = 0 + for (let i = 0; i < visibleRange[0]; i++) { + offset += heightCache[i] || itemHeight + } + return offset + } + + const offsetY = calculateOffsetY() + + // 滚动事件处理函数 - 使用节流优化 + const handleScrollUpdate = (currentScrollTop: number) => { + // 如果滚动位置与当前引用值相同,则不更新 + if (currentScrollTop === scrollTopRef.current) { + return + } + + // 立即更新ref值,这不会触发重新渲染 + scrollTopRef.current = currentScrollTop + + // 触发状态更新以重新渲染可视区域 + // 使用函数形式的setState,确保我们总是基于最新状态更新 + setScrollTopState((prev) => { + // 只有当滚动位置真正变化时才更新状态 + if (Math.abs(prev - scrollTopRef.current) > 1) { + return scrollTopRef.current + } + return prev + }) + } + + // 使用节流优化滚动事件处理,在快速滚动时降低更新频率 + const throttledScrollHandler = useThrottle(handleScrollUpdate, 16, { + leading: true, + trailing: true, + }) + + // 滚动事件处理函数 + const onScroll = (event: React.UIEvent) => { + // 获取当前滚动位置 + const scrollContainer = event.target as HTMLDivElement + const currentScrollTop = scrollContainer.scrollTop + + // 使用节流函数处理滚动更新 + throttledScrollHandler(currentScrollTop) + } + + // 手动设置滚动位置的方法(考虑动态高度) + const scrollTo = (index: number) => { + if (containerRef.current) { + const targetIndex = Math.min(Math.max(0, index), totalRef.current - 1) // 确保不会滚动到超出范围的位置 + + let targetScrollTop = 0 + if (!dynamicHeight) { + targetScrollTop = targetIndex * itemHeight + } else { + // 计算目标位置的滚动偏移 + for (let i = 0; i < targetIndex; i++) { + targetScrollTop += heightCache[i] || itemHeight + } + } + + // 设置DOM元素的滚动位置 + containerRef.current.scrollTop = targetScrollTop + + // 更新ref值 + scrollTopRef.current = targetScrollTop + + // 触发状态更新以重新渲染 + setScrollTopState(targetScrollTop) + } + } + + // 更新指定行高度的方法 + const updateItemHeight = (index: number, height: number) => { + if (heightCache[index] !== height) { + setHeightCache((prev) => ({ + ...prev, + [index]: height, + })) + } + } + + // 获取行元素引用的方法 + const getRowRef = (index: number) => (element: HTMLElement | null) => { + if (element && dynamicHeight) { + rowRefs.current[index] = element + + // 如果高度发生变化,更新高度缓存 + const currentHeight = element.getBoundingClientRect().height + if (heightCache[index] !== currentHeight) { + updateItemHeight(index, currentHeight) + } + } + } + + return { + visibleRange, + totalHeight, + offsetY, + onScroll, + containerRef, + scrollTo, + updateItemHeight, + getRowRef, + } +} diff --git a/src/styles/variables-jmapp.scss b/src/styles/variables-jmapp.scss index aedf45e9d3..50d8cc7192 100644 --- a/src/styles/variables-jmapp.scss +++ b/src/styles/variables-jmapp.scss @@ -2342,6 +2342,7 @@ $table-sticky-right-shadow: var( --nutui-table-sticky-right-shadow, -4px 0 8px 0 rgba(0, 0, 0, 0.1) ) !default; +$table-th-bg-color: var(--nutui-table-th-bg-color, #fff) !default; // navbar(✅) $navbar-width: var(--nutui-navbar-width, 100%) !default; diff --git a/src/styles/variables.scss b/src/styles/variables.scss index ba62d0b7f7..d5ab4a3488 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -2230,6 +2230,7 @@ $table-sticky-right-shadow: var( --nutui-table-sticky-right-shadow, -4px 0 8px 0 rgba(0, 0, 0, 0.1) ) !default; +$table-th-bg-color: var(--nutui-table-th-bg-color, #fff) !default; // navbar(✅) $navbar-width: var(--nutui-navbar-width, 100%) !default; diff --git a/src/types/spec/table/base.ts b/src/types/spec/table/base.ts index a60538a9bb..0cc6faa78b 100644 --- a/src/types/spec/table/base.ts +++ b/src/types/spec/table/base.ts @@ -6,12 +6,15 @@ export interface TableColumnProps { key: string title?: string align?: string + sorterIcon?: (currentSortState: SortStateType) => ReactNode sorter?: ((a: any, b: any) => number) | boolean | string render?: (rowData: any, rowIndex: number) => string | ReactNode fixed?: PositionX width?: number } +export type SortStateType = 'asc' | 'desc' | null + export interface BaseTable extends BaseProps { columns: Array data: Array @@ -20,6 +23,10 @@ export interface BaseTable extends BaseProps { striped?: boolean noData?: ReactNode sorterIcon?: ReactNode - onSort?: (column: TableColumnProps, sortedData: Array) => void + onSort?: ( + column: TableColumnProps, + sortedData?: Array, + sortState?: SortStateType + ) => void showHeader?: boolean }