📱 feat(ui): Introduce responsive CardTable with mobile card view, dynamic skeletons & pagination
1. Add `web/src/components/common/ui/CardTable.js` • Renders Semi-UI `Table` on desktop; on mobile, transforms each row into a rounded `Card`. • Supports all standard `Table` props, including `rowSelection`, `scroll`, `pagination`, etc. • Adds mobile pagination via Semi-UI `Pagination`. • Implements a 500 ms minimum, active Skeleton loader that mimics real column layout (including operation-button row). 2. Replace legacy `Table` with `CardTable` • Updated all major data pages: Channels, MJ-Logs, Redemptions, Tokens, Task-Logs, Usage-Logs and Users. • Removed unused `Table` imports; kept behaviour on desktop unchanged. 3. UI polish • Right-aligned operation buttons and sensitive fields (e.g., token keys) inside mobile cards. • Improved Skeleton placeholders to better reflect actual UI hierarchy and preserve the active animation. These changes dramatically improve the mobile experience while retaining full functionality on larger screens.
This commit is contained in:
164
web/src/components/common/ui/CardTable.js
Normal file
164
web/src/components/common/ui/CardTable.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Table, Card, Skeleton, Pagination } from '@douyinfe/semi-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
*
|
||||
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
|
||||
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
|
||||
*/
|
||||
const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'key', ...tableProps }) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Skeleton 显示控制,确保至少展示 500ms 动效
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
// 解析行主键
|
||||
const getRowKey = (record, index) => {
|
||||
if (typeof rowKey === 'function') return rowKey(record);
|
||||
return record[rowKey] !== undefined ? record[rowKey] : index;
|
||||
};
|
||||
|
||||
// 如果不是移动端,直接渲染原 Table
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
rowKey={rowKey}
|
||||
{...tableProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载中占位:根据列信息动态模拟真实布局
|
||||
if (showSkeleton) {
|
||||
const visibleCols = columns.filter((col) => {
|
||||
if (tableProps?.visibleColumns && col.key) {
|
||||
return tableProps.visibleColumns[col.key];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const renderSkeletonCard = (key) => {
|
||||
const placeholder = (
|
||||
<div className="p-2">
|
||||
{visibleCols.map((col, idx) => {
|
||||
if (!col.title) {
|
||||
return (
|
||||
<div key={idx} className="mt-2 flex justify-end">
|
||||
<Skeleton.Title active style={{ width: 100, height: 24 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed border-gray-200">
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
<Skeleton.Title active style={{ width: `${50 + (idx % 3) * 10}%`, maxWidth: 180, height: 14 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={key} className="!rounded-2xl shadow-sm">
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染移动端卡片
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{dataSource.map((record, index) => {
|
||||
const rowKeyVal = getRowKey(record, index);
|
||||
return (
|
||||
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
|
||||
{columns.map((col, colIdx) => {
|
||||
// 忽略隐藏列
|
||||
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = col.title;
|
||||
// 计算单元格内容
|
||||
const cellContent = col.render
|
||||
? col.render(record[col.dataIndex], record, index)
|
||||
: record[col.dataIndex];
|
||||
|
||||
// 空标题列(通常为操作按钮)单独渲染
|
||||
if (!title) {
|
||||
return (
|
||||
<div
|
||||
key={col.key || colIdx}
|
||||
className="mt-2 flex justify-end"
|
||||
>
|
||||
{cellContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key || colIdx}
|
||||
className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed border-gray-200"
|
||||
>
|
||||
<span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex-1 break-all flex justify-end items-center gap-1">
|
||||
{cellContent !== undefined && cellContent !== null ? cellContent : '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{/* 分页组件 */}
|
||||
{tableProps.pagination && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Pagination {...tableProps.pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CardTable.propTypes = {
|
||||
columns: PropTypes.array.isRequired,
|
||||
dataSource: PropTypes.array,
|
||||
loading: PropTypes.bool,
|
||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
};
|
||||
|
||||
export default CardTable;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
@@ -96,7 +97,7 @@ const ChannelsTable = (channelsData) => {
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={channels}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
@@ -60,7 +61,7 @@ const MjLogsTable = (mjLogsData) => {
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
@@ -74,7 +75,7 @@ const RedemptionsTable = (redemptionsData) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={redemptions}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
@@ -57,7 +58,7 @@ const TaskLogsTable = (taskLogsData) => {
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
@@ -66,7 +67,7 @@ const TokensTable = (tokensData) => {
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={tokens}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Empty, Descriptions } from '@douyinfe/semi-ui';
|
||||
import { Empty, Descriptions } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
@@ -63,7 +64,7 @@ const LogsTable = (logsData) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
{...(hasExpandableRows() && {
|
||||
expandedRowRender: expandRowRender,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Table, Empty } from '@douyinfe/semi-ui';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
@@ -104,7 +105,7 @@ const UsersTable = (usersData) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={users}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
|
||||
Reference in New Issue
Block a user