316 lines
9.8 KiB
TypeScript
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;
|