deploy-ease-platform/frontend/src/components/ui/paginated-table.tsx
2025-11-28 13:17:07 +08:00

316 lines
9.8 KiB
TypeScript

import * as React from 'react';
import { useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react';
import { Loader2, Search, RotateCcw } from 'lucide-react';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './table';
import { DataTablePagination } from './pagination';
import { Button } from './button';
import { Input } from './input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
// 列定义
export interface ColumnDef<T> {
key: string;
title: React.ReactNode;
width?: string;
sticky?: boolean;
render?: (value: any, record: T, index: number) => React.ReactNode;
dataIndex?: string;
className?: string;
}
// 搜索字段定义
export interface SearchFieldDef {
key: string;
type: 'input' | 'select';
placeholder?: string;
options?: { label: string; value: string | number }[];
width?: string;
/** 是否支持回车搜索(仅 input 类型) */
enterSearch?: boolean;
}
// 查询参数基础类型
export interface BaseQuery {
pageNum?: number;
pageSize?: number;
[key: string]: any;
}
// 组件 Props
export interface PaginatedTableProps<T, Q extends BaseQuery = BaseQuery> {
fetchFn: (query: Q) => Promise<Page<T>>;
columns: ColumnDef<T>[];
/** 搜索字段定义(简化模式) */
searchFields?: SearchFieldDef[];
/** 自定义搜索栏(完全自定义模式) */
searchBar?: React.ReactNode;
/** 工具栏(新建按钮等) */
toolbar?: React.ReactNode;
/** 初始查询参数 */
initialQuery?: Omit<Q, 'pageNum' | 'pageSize'>;
rowKey?: keyof T | ((record: T) => string | number);
minWidth?: string;
pageSize?: number;
emptyText?: React.ReactNode;
loadingText?: React.ReactNode;
/** 搜索栏和表格之间的间距 */
searchGap?: string;
}
// 暴露给外部的方法
export interface PaginatedTableRef<T> {
refresh: (showLoading?: boolean) => Promise<void>;
reset: () => void;
search: () => void;
getData: () => Page<T> | null;
getQuery: () => Record<string, any>;
updateRecord: (id: string | number, updates: Partial<T>) => void;
}
function getNestedValue(obj: any, path?: string): any {
if (!path) return undefined;
return path.split('.').reduce((acc, part) => acc?.[part], obj);
}
function getRowKey<T>(record: T, rowKey: keyof T | ((record: T) => string | number) | undefined, index: number): string | number {
if (!rowKey) return index;
if (typeof rowKey === 'function') return rowKey(record);
return record[rowKey] as string | number;
}
function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery = BaseQuery>(
props: PaginatedTableProps<T, Q>,
ref: React.ForwardedRef<PaginatedTableRef<T>>
) {
const {
fetchFn,
columns,
searchFields,
searchBar,
toolbar,
initialQuery = {} as Omit<Q, 'pageNum' | 'pageSize'>,
rowKey,
minWidth,
pageSize: initialPageSize = DEFAULT_PAGE_SIZE,
emptyText = '暂无数据',
loadingText = '加载中...',
searchGap = 'mb-4',
} = props;
const [data, setData] = useState<Page<T> | null>(null);
const [loading, setLoading] = useState(true); // 首次加载显示 loading
const [pageNum, setPageNum] = useState(DEFAULT_PAGE_NUM);
const [pageSize] = useState(initialPageSize);
const [searchValues, setSearchValues] = useState<Record<string, any>>(initialQuery);
const isFirstLoad = useRef(true);
// 加载数据(不再控制 loading 状态,只有首次加载显示)
const loadData = useCallback(async () => {
try {
const result = await fetchFn({
...searchValues,
pageNum,
pageSize,
} as Q);
setData(result);
} catch (error) {
console.error('PaginatedTable 加载数据失败:', error);
} finally {
if (isFirstLoad.current) {
setLoading(false);
isFirstLoad.current = false;
}
}
}, [fetchFn, searchValues, pageNum, pageSize]);
// 监听分页变化
useEffect(() => {
loadData();
}, [pageNum, pageSize]);
// 搜索
const handleSearch = useCallback(() => {
if (pageNum === DEFAULT_PAGE_NUM) {
loadData();
} else {
setPageNum(DEFAULT_PAGE_NUM);
}
}, [pageNum, loadData]);
// 重置
const handleReset = useCallback(() => {
setSearchValues(initialQuery);
setPageNum(DEFAULT_PAGE_NUM);
}, [initialQuery]);
// 分页切换
const handlePageChange = useCallback((newPageNum: number) => {
setPageNum(newPageNum);
}, []);
// 暴露方法
useImperativeHandle(ref, () => ({
refresh: async () => {
await loadData();
},
reset: handleReset,
search: handleSearch,
getData: () => data,
getQuery: () => searchValues,
updateRecord: (id, updates) => {
setData(prev => {
if (!prev) return prev;
return {
...prev,
content: prev.content.map(item =>
getRowKey(item, rowKey, -1) === id ? { ...item, ...updates } : item
),
};
});
},
}), [data, loadData, handleReset, handleSearch, searchValues, rowKey]);
// 渲染搜索字段
const renderSearchField = (field: SearchFieldDef) => {
const value = searchValues[field.key];
if (field.type === 'input') {
return (
<div key={field.key} className={field.width || 'flex-1 max-w-md'}>
<Input
placeholder={field.placeholder}
value={value || ''}
onChange={(e) => setSearchValues(prev => ({ ...prev, [field.key]: e.target.value }))}
onKeyDown={(e) => field.enterSearch !== false && e.key === 'Enter' && handleSearch()}
/>
</div>
);
}
if (field.type === 'select') {
return (
<Select
key={field.key}
value={value?.toString() || 'all'}
onValueChange={(v) => setSearchValues(prev => ({ ...prev, [field.key]: v !== 'all' ? v : undefined }))}
>
<SelectTrigger className={field.width || 'w-[180px]'}>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{field.placeholder || '全部'}</SelectItem>
{field.options?.map(opt => (
<SelectItem key={opt.value} value={opt.value.toString()}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
);
}
return null;
};
// 渲染单元格
const renderCell = (column: ColumnDef<T>, record: T, index: number) => {
if (column.render) {
const value = column.dataIndex ? getNestedValue(record, column.dataIndex) : undefined;
return column.render(value, record, index);
}
if (column.dataIndex) {
return getNestedValue(record, column.dataIndex);
}
return null;
};
const pageCount = data?.totalPages || 0;
const hasData = data && data.content && data.content.length > 0;
const showSearchBar = searchFields && searchFields.length > 0;
return (
<div className="space-y-0">
{/* 搜索栏 */}
{(showSearchBar || searchBar || toolbar) && (
<div className={`flex flex-wrap items-center gap-4 ${searchGap}`}>
{searchBar || (
<>
{searchFields?.map(renderSearchField)}
<Button type="button" onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
<Button type="button" variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
</>
)}
{toolbar && <div className="ml-auto flex items-center gap-2">{toolbar}</div>}
</div>
)}
{/* 表格 */}
<div className="rounded-md border">
<Table minWidth={minWidth}>
<TableHeader>
<TableRow>
{columns.map(col => (
<TableHead key={col.key} width={col.width} sticky={col.sticky}>
{col.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground">{loadingText}</span>
</div>
</TableCell>
</TableRow>
) : hasData ? (
data.content.map((record, index) => (
<TableRow key={getRowKey(record, rowKey, index)}>
{columns.map(col => (
<TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
{renderCell(col, record, index)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<span className="text-muted-foreground">{emptyText}</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{pageCount > 0 && (
<DataTablePagination
pageIndex={pageNum}
pageSize={pageSize}
pageCount={pageCount}
onPageChange={handlePageChange}
/>
)}
</div>
);
}
export const PaginatedTable = React.forwardRef(PaginatedTableInner) as <
T extends Record<string, any>,
Q extends BaseQuery = BaseQuery
>(
props: PaginatedTableProps<T, Q> & { ref?: React.ForwardedRef<PaginatedTableRef<T>> }
) => React.ReactElement;
export default PaginatedTable;