CardTable.jsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect, useRef } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import {
  18. Table,
  19. Card,
  20. Skeleton,
  21. Pagination,
  22. Empty,
  23. Button,
  24. Collapsible,
  25. } from '@douyinfe/semi-ui';
  26. import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
  27. import PropTypes from 'prop-types';
  28. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  29. import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
  30. /**
  31. * CardTable 响应式表格组件
  32. *
  33. * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
  34. * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
  35. */
  36. const CardTable = ({
  37. columns = [],
  38. dataSource = [],
  39. loading = false,
  40. rowKey = 'key',
  41. hidePagination = false,
  42. ...tableProps
  43. }) => {
  44. const isMobile = useIsMobile();
  45. const { t } = useTranslation();
  46. const showSkeleton = useMinimumLoadingTime(loading);
  47. const getRowKey = (record, index) => {
  48. if (typeof rowKey === 'function') return rowKey(record);
  49. return record[rowKey] !== undefined ? record[rowKey] : index;
  50. };
  51. if (!isMobile) {
  52. const finalTableProps = hidePagination
  53. ? { ...tableProps, pagination: false }
  54. : tableProps;
  55. return (
  56. <Table
  57. columns={columns}
  58. dataSource={dataSource}
  59. loading={loading}
  60. rowKey={rowKey}
  61. {...finalTableProps}
  62. />
  63. );
  64. }
  65. if (showSkeleton) {
  66. const visibleCols = columns.filter((col) => {
  67. if (tableProps?.visibleColumns && col.key) {
  68. return tableProps.visibleColumns[col.key];
  69. }
  70. return true;
  71. });
  72. const renderSkeletonCard = (key) => {
  73. const placeholder = (
  74. <div className='p-2'>
  75. {visibleCols.map((col, idx) => {
  76. if (!col.title) {
  77. return (
  78. <div key={idx} className='mt-2 flex justify-end'>
  79. <Skeleton.Title active style={{ width: 100, height: 24 }} />
  80. </div>
  81. );
  82. }
  83. return (
  84. <div
  85. key={idx}
  86. className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
  87. style={{ borderColor: 'var(--semi-color-border)' }}
  88. >
  89. <Skeleton.Title active style={{ width: 80, height: 14 }} />
  90. <Skeleton.Title
  91. active
  92. style={{
  93. width: `${50 + (idx % 3) * 10}%`,
  94. maxWidth: 180,
  95. height: 14,
  96. }}
  97. />
  98. </div>
  99. );
  100. })}
  101. </div>
  102. );
  103. return (
  104. <Card key={key} className='!rounded-2xl shadow-sm'>
  105. <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
  106. </Card>
  107. );
  108. };
  109. return (
  110. <div className='flex flex-col gap-2'>
  111. {[1, 2, 3].map((i) => renderSkeletonCard(i))}
  112. </div>
  113. );
  114. }
  115. const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
  116. const MobileRowCard = ({ record, index }) => {
  117. const [showDetails, setShowDetails] = useState(false);
  118. const rowKeyVal = getRowKey(record, index);
  119. const hasDetails =
  120. tableProps.expandedRowRender &&
  121. (!tableProps.rowExpandable || tableProps.rowExpandable(record));
  122. return (
  123. <Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
  124. {columns.map((col, colIdx) => {
  125. if (
  126. tableProps?.visibleColumns &&
  127. !tableProps.visibleColumns[col.key]
  128. ) {
  129. return null;
  130. }
  131. const title = col.title;
  132. const cellContent = col.render
  133. ? col.render(record[col.dataIndex], record, index)
  134. : record[col.dataIndex];
  135. if (!title) {
  136. return (
  137. <div key={col.key || colIdx} className='mt-2 flex justify-end'>
  138. {cellContent}
  139. </div>
  140. );
  141. }
  142. return (
  143. <div
  144. key={col.key || colIdx}
  145. className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
  146. style={{ borderColor: 'var(--semi-color-border)' }}
  147. >
  148. <span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
  149. {title}
  150. </span>
  151. <div className='flex-1 break-all flex justify-end items-center gap-1'>
  152. {cellContent !== undefined && cellContent !== null
  153. ? cellContent
  154. : '-'}
  155. </div>
  156. </div>
  157. );
  158. })}
  159. {hasDetails && (
  160. <>
  161. <Button
  162. theme='borderless'
  163. size='small'
  164. className='w-full flex justify-center mt-2'
  165. icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
  166. onClick={(e) => {
  167. e.stopPropagation();
  168. setShowDetails(!showDetails);
  169. }}
  170. >
  171. {showDetails ? t('收起') : t('详情')}
  172. </Button>
  173. <Collapsible isOpen={showDetails} keepDOM>
  174. <div className='pt-2'>
  175. {tableProps.expandedRowRender(record, index)}
  176. </div>
  177. </Collapsible>
  178. </>
  179. )}
  180. </Card>
  181. );
  182. };
  183. if (isEmpty) {
  184. if (tableProps.empty) return tableProps.empty;
  185. return (
  186. <div className='flex justify-center p-4'>
  187. <Empty description='No Data' />
  188. </div>
  189. );
  190. }
  191. return (
  192. <div className='flex flex-col gap-2'>
  193. {dataSource.map((record, index) => (
  194. <MobileRowCard
  195. key={getRowKey(record, index)}
  196. record={record}
  197. index={index}
  198. />
  199. ))}
  200. {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
  201. <div className='mt-2 flex justify-center'>
  202. <Pagination {...tableProps.pagination} />
  203. </div>
  204. )}
  205. </div>
  206. );
  207. };
  208. CardTable.propTypes = {
  209. columns: PropTypes.array.isRequired,
  210. dataSource: PropTypes.array,
  211. loading: PropTypes.bool,
  212. rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
  213. hidePagination: PropTypes.bool,
  214. };
  215. export default CardTable;