- Increase skeleton card count from 6 to 10 for better visual coverage - Extend minimum skeleton display duration from 500ms to 1000ms for smoother UX - Add circle shape to all pricing tags for consistent rounded design - Apply circle styling to billing type, popularity, endpoint, and context tags This commit improves the visual consistency and user experience of the pricing card view by standardizing tag appearance and optimizing skeleton loading timing.
237 lines
7.2 KiB
JavaScript
237 lines
7.2 KiB
JavaScript
/*
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
|
|
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
|
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',
|
|
hidePagination = false,
|
|
...tableProps
|
|
}) => {
|
|
const isMobile = useIsMobile();
|
|
const { t } = useTranslation();
|
|
|
|
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, 1000 - 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;
|
|
};
|
|
|
|
if (!isMobile) {
|
|
const finalTableProps = hidePagination
|
|
? { ...tableProps, pagination: false }
|
|
: tableProps;
|
|
|
|
return (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={dataSource}
|
|
loading={loading}
|
|
rowKey={rowKey}
|
|
{...finalTableProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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" style={{ borderColor: 'var(--semi-color-border)' }}>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
|
|
|
|
const MobileRowCard = ({ record, index }) => {
|
|
const [showDetails, setShowDetails] = useState(false);
|
|
const rowKeyVal = getRowKey(record, index);
|
|
|
|
const hasDetails =
|
|
tableProps.expandedRowRender &&
|
|
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
|
|
|
|
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"
|
|
style={{ borderColor: 'var(--semi-color-border)' }}
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
|
|
{hasDetails && (
|
|
<>
|
|
<Button
|
|
theme='borderless'
|
|
size='small'
|
|
className='w-full flex justify-center mt-2'
|
|
icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowDetails(!showDetails);
|
|
}}
|
|
>
|
|
{showDetails ? t('收起') : t('详情')}
|
|
</Button>
|
|
<Collapsible isOpen={showDetails} keepDOM>
|
|
<div className="pt-2">
|
|
{tableProps.expandedRowRender(record, index)}
|
|
</div>
|
|
</Collapsible>
|
|
</>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
if (isEmpty) {
|
|
if (tableProps.empty) return tableProps.empty;
|
|
return (
|
|
<div className="flex justify-center p-4">
|
|
<Empty description="No Data" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
{dataSource.map((record, index) => (
|
|
<MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
|
|
))}
|
|
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
|
|
<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]),
|
|
hidePagination: PropTypes.bool,
|
|
};
|
|
|
|
export default CardTable;
|