CardTable.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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 { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
  18. import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
  19. import PropTypes from 'prop-types';
  20. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  21. import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
  22. /**
  23. * CardTable 响应式表格组件
  24. *
  25. * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
  26. * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
  27. */
  28. const CardTable = ({
  29. columns = [],
  30. dataSource = [],
  31. loading = false,
  32. rowKey = 'key',
  33. hidePagination = false,
  34. ...tableProps
  35. }) => {
  36. const isMobile = useIsMobile();
  37. const { t } = useTranslation();
  38. const showSkeleton = useMinimumLoadingTime(loading);
  39. const getRowKey = (record, index) => {
  40. if (typeof rowKey === 'function') return rowKey(record);
  41. return record[rowKey] !== undefined ? record[rowKey] : index;
  42. };
  43. if (!isMobile) {
  44. const finalTableProps = hidePagination
  45. ? { ...tableProps, pagination: false }
  46. : tableProps;
  47. return (
  48. <Table
  49. columns={columns}
  50. dataSource={dataSource}
  51. loading={loading}
  52. rowKey={rowKey}
  53. {...finalTableProps}
  54. />
  55. );
  56. }
  57. if (showSkeleton) {
  58. const visibleCols = columns.filter((col) => {
  59. if (tableProps?.visibleColumns && col.key) {
  60. return tableProps.visibleColumns[col.key];
  61. }
  62. return true;
  63. });
  64. const renderSkeletonCard = (key) => {
  65. const placeholder = (
  66. <div className="p-2">
  67. {visibleCols.map((col, idx) => {
  68. if (!col.title) {
  69. return (
  70. <div key={idx} className="mt-2 flex justify-end">
  71. <Skeleton.Title active style={{ width: 100, height: 24 }} />
  72. </div>
  73. );
  74. }
  75. return (
  76. <div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}>
  77. <Skeleton.Title active style={{ width: 80, height: 14 }} />
  78. <Skeleton.Title
  79. active
  80. style={{
  81. width: `${50 + (idx % 3) * 10}%`,
  82. maxWidth: 180,
  83. height: 14,
  84. }}
  85. />
  86. </div>
  87. );
  88. })}
  89. </div>
  90. );
  91. return (
  92. <Card key={key} className="!rounded-2xl shadow-sm">
  93. <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
  94. </Card>
  95. );
  96. };
  97. return (
  98. <div className="flex flex-col gap-2">
  99. {[1, 2, 3].map((i) => renderSkeletonCard(i))}
  100. </div>
  101. );
  102. }
  103. const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
  104. const MobileRowCard = ({ record, index }) => {
  105. const [showDetails, setShowDetails] = useState(false);
  106. const rowKeyVal = getRowKey(record, index);
  107. const hasDetails =
  108. tableProps.expandedRowRender &&
  109. (!tableProps.rowExpandable || tableProps.rowExpandable(record));
  110. return (
  111. <Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
  112. {columns.map((col, colIdx) => {
  113. if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
  114. return null;
  115. }
  116. const title = col.title;
  117. const cellContent = col.render
  118. ? col.render(record[col.dataIndex], record, index)
  119. : record[col.dataIndex];
  120. if (!title) {
  121. return (
  122. <div key={col.key || colIdx} className="mt-2 flex justify-end">
  123. {cellContent}
  124. </div>
  125. );
  126. }
  127. return (
  128. <div
  129. key={col.key || colIdx}
  130. className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed"
  131. style={{ borderColor: 'var(--semi-color-border)' }}
  132. >
  133. <span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
  134. {title}
  135. </span>
  136. <div className="flex-1 break-all flex justify-end items-center gap-1">
  137. {cellContent !== undefined && cellContent !== null ? cellContent : '-'}
  138. </div>
  139. </div>
  140. );
  141. })}
  142. {hasDetails && (
  143. <>
  144. <Button
  145. theme='borderless'
  146. size='small'
  147. className='w-full flex justify-center mt-2'
  148. icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
  149. onClick={(e) => {
  150. e.stopPropagation();
  151. setShowDetails(!showDetails);
  152. }}
  153. >
  154. {showDetails ? t('收起') : t('详情')}
  155. </Button>
  156. <Collapsible isOpen={showDetails} keepDOM>
  157. <div className="pt-2">
  158. {tableProps.expandedRowRender(record, index)}
  159. </div>
  160. </Collapsible>
  161. </>
  162. )}
  163. </Card>
  164. );
  165. };
  166. if (isEmpty) {
  167. if (tableProps.empty) return tableProps.empty;
  168. return (
  169. <div className="flex justify-center p-4">
  170. <Empty description="No Data" />
  171. </div>
  172. );
  173. }
  174. return (
  175. <div className="flex flex-col gap-2">
  176. {dataSource.map((record, index) => (
  177. <MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
  178. ))}
  179. {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
  180. <div className="mt-2 flex justify-center">
  181. <Pagination {...tableProps.pagination} />
  182. </div>
  183. )}
  184. </div>
  185. );
  186. };
  187. CardTable.propTypes = {
  188. columns: PropTypes.array.isRequired,
  189. dataSource: PropTypes.array,
  190. loading: PropTypes.bool,
  191. rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
  192. hidePagination: PropTypes.bool,
  193. };
  194. export default CardTable;