生产管理系统 - 表格组件封装

This commit is contained in:
2025-11-06 15:46:59 +08:00
parent 9f1cf21042
commit 956494b3ed
8 changed files with 1633 additions and 378 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,221 @@
/**
* filekorolheader: 分页组件 - 可配置的分页导航组件
* 功能:分页导航、页码跳转、每页条数设置、分页信息显示
* 路径:/components/common/searchFormPagination/components/PaginationComponent
* 规范遵循crop-x/docs/开发项目规范.md使用shadcn语义化样式支持完全自定义配置
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight } from 'lucide-react';
// 分页配置接口
export interface PaginationConfig {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
// 组件Props接口
export interface PaginationComponentProps {
pagination: PaginationConfig;
onPageChange: (page: number) => void;
onSizeChange?: (size: number) => void;
loading?: boolean;
showSizeSelector?: boolean;
showPageInfo?: boolean;
showQuickJumper?: boolean;
sizeOptions?: number[];
maxVisiblePages?: number;
className?: string;
}
export function PaginationComponent({
pagination,
onPageChange,
onSizeChange,
loading = false,
showSizeSelector = true,
showPageInfo = true,
showQuickJumper = false,
sizeOptions = [10, 30, 50, 100],
maxVisiblePages = 7,
className = '',
}: PaginationComponentProps) {
const [jumpPage, setJumpPage] = useState('');
// 处理页码变化
const handlePageChange = (page: number) => {
// 边界检查
if (page < 1) page = 1;
if (page > pagination.totalPages && pagination.totalPages > 0) {
page = pagination.totalPages;
}
onPageChange(page);
};
// 处理每页条数变化
const handleSizeChange = (size: string) => {
const newSize = parseInt(size, 10);
onSizeChange?.(newSize);
};
// 处理快速跳转
const handleJumpPage = () => {
const page = parseInt(jumpPage, 10);
if (!isNaN(page) && page >= 1 && page <= pagination.totalPages) {
handlePageChange(page);
setJumpPage('');
}
};
// 处理跳转输入框回车
const handleJumpKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleJumpPage();
}
};
// 生成可见页码数组
const generateVisiblePages = () => {
const { page, totalPages } = pagination;
const visiblePages: number[] = [];
if (totalPages <= maxVisiblePages) {
// 如果总页数少于最大可见页数,显示所有页码
for (let i = 1; i <= totalPages; i++) {
visiblePages.push(i);
}
} else {
// 否则生成智能的页码显示范围
const half = Math.floor(maxVisiblePages / 2);
let start = Math.max(1, page - half);
let end = Math.min(totalPages, start + maxVisiblePages - 1);
// 调整开始位置,确保显示足够数量的页码
if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1);
}
for (let i = start; i <= end; i++) {
visiblePages.push(i);
}
}
return visiblePages;
};
const visiblePages = generateVisiblePages();
const { page, total, totalPages, hasPrev, hasNext } = pagination;
return (
<div className={`flex items-center justify-between mt-4 ${className}`}>
{/* 左侧信息 */}
<div className="flex items-center gap-4">
{showPageInfo && (
<div className="text-sm text-muted-foreground">
{page} {totalPages}
<span className="ml-2"> {total} </span>
</div>
)}
{showSizeSelector && onSizeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Select value={String(pagination.size)} onValueChange={handleSizeChange}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground"></span>
</div>
)}
</div>
{/* 右侧分页导航 - 只有超过一页时才显示分页按钮 */}
{totalPages > 1 && (
<div className="flex items-center gap-2">
{/* 上一页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(page - 1)}
disabled={!hasPrev || loading}
>
<ChevronLeft className="w-4 h-4" />
</Button>
{/* 页码按钮 */}
<div className="flex items-center gap-1">
{visiblePages.map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === page ? 'default' : 'outline'}
size="sm"
onClick={() => handlePageChange(pageNum)}
disabled={loading}
className="min-w-[2.5rem]"
>
{pageNum}
</Button>
))}
</div>
{/* 下一页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(page + 1)}
disabled={!hasNext || loading}
>
<ChevronRight className="w-4 h-4" />
</Button>
{/* 快速跳转 */}
{showQuickJumper && totalPages > 5 && (
<div className="flex items-center gap-2 ml-4">
<span className="text-sm text-muted-foreground"></span>
<Input
type="number"
min={1}
max={totalPages}
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onKeyPress={handleJumpKeyPress}
placeholder="页码"
className="w-16 h-8"
disabled={loading}
/>
<span className="text-sm text-muted-foreground"></span>
<Button
variant="outline"
size="sm"
onClick={handleJumpPage}
disabled={loading || !jumpPage}
>
</Button>
</div>
)}
</div>
)}
</div>
);
}
export default PaginationComponent;

View File

@@ -0,0 +1,166 @@
/**
* filekorolheader: 搜索表单组件 - 可配置的搜索条件表单
* 功能:搜索条件输入、下拉选择、实时搜索、重置功能
* 路径:/components/common/searchFormPagination/components/SearchFormComponent
* 规范遵循crop-x/docs/开发项目规范.md使用shadcn语义化样式支持完全自定义
*/
'use client';
import { useState, useEffect, useRef, memo } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
// 搜索字段配置接口
export interface SearchFieldConfig {
key: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
defaultValue?: string;
}
// 组件Props接口
export interface SearchFormComponentProps {
fields: SearchFieldConfig[];
filters: Record<string, string>;
onFiltersChange: (filters: Record<string, string>) => void;
placeholder?: string;
loading?: boolean;
layout?: 'horizontal' | 'vertical';
maxVisibleFields?: number;
}
export function SearchFormComponent({
fields,
filters,
onFiltersChange,
placeholder = '请输入搜索关键词...',
loading = false,
layout = 'horizontal',
maxVisibleFields = 3,
}: SearchFormComponentProps) {
const [localFilters, setLocalFilters] = useState<Record<string, string>>(filters);
const [showAllFields, setShowAllFields] = useState(false);
// 使用ref保持最新的onFiltersChange引用避免useEffect重复触发
const onFiltersChangeRef = useRef(onFiltersChange);
onFiltersChangeRef.current = onFiltersChange;
// 同步外部filters到本地state
useEffect(() => {
setLocalFilters(filters);
}, [filters]);
// 处理输入变化 - 防抖搜索避免频繁刷新导致失焦
const handleInputChange = (key: string, value: string) => {
const newFilters = {
...localFilters,
[key]: value,
};
setLocalFilters(newFilters);
};
// 使用防抖来减少搜索频率,避免频繁刷新导致失焦
useEffect(() => {
const timer = setTimeout(() => {
// 使用ref引用最新的onFiltersChange函数避免依赖变化导致重复触发
onFiltersChangeRef.current(localFilters);
}, 300); // 300ms 防抖延迟
return () => clearTimeout(timer);
}, [localFilters]); // 只依赖localFilters使用ref避免函数依赖问题
// 计算显示的字段
const visibleFields = showAllFields
? fields
: fields.slice(0, maxVisibleFields);
const hasMoreFields = fields.length > maxVisibleFields;
// 渲染单个搜索字段
const renderSearchField = (field: SearchFieldConfig) => {
const value = localFilters[field.key] || field.defaultValue || '';
switch (field.type) {
case 'select':
return (
<div key={field.key}>
<Select
value={value}
onValueChange={(newValue) => handleInputChange(field.key, newValue)}
disabled={false} // 始终允许选择,不因加载而禁用
>
<SelectTrigger className="w-40">
<SelectValue placeholder={field.placeholder || '请选择'} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
case 'text':
default:
return (
<div key={field.key} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={field.placeholder || placeholder}
value={value}
onChange={(e) => handleInputChange(field.key, e.target.value)}
disabled={false} // 始终允许输入,不因加载而禁用
className="pl-10 w-64"
/>
</div>
);
}
};
// 主搜索框(当没有配置字段时使用默认搜索)
const renderMainSearch = () => (
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={placeholder}
value={localFilters.search || ''}
onChange={(e) => handleInputChange('search', e.target.value)}
disabled={false} // 始终允许输入,不因加载而禁用
className="pl-10"
/>
</div>
);
// 如果没有配置字段,使用简单搜索
if (fields.length === 0) {
return renderMainSearch();
}
return (
<div className={`flex ${layout === 'horizontal' ? 'flex-row items-end' : 'flex-col items-start'} gap-4 flex-wrap`}>
{/* 渲染搜索字段 */}
{visibleFields.map(renderSearchField)}
{/* 展开/收起按钮 */}
{hasMoreFields && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllFields(!showAllFields)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
{showAllFields ? '收起' : `展开更多 (${fields.length - maxVisibleFields})`}
</Button>
)}
</div>
);
}
const MemoizedSearchFormComponent = memo(SearchFormComponent);
export default MemoizedSearchFormComponent;

View File

@@ -0,0 +1,248 @@
/**
* filekorolheader: 搜索表单分页组件使用示例 - 展示如何使用该组件
* 功能:使用示例、配置示例、最佳实践展示
* 路径:/components/common/searchFormPagination/components/example
* 规范遵循crop-x/docs/开发项目规范.md提供完整的使用示例
*/
'use client';
import { SearchFormPagination, SearchFieldConfig, TableColumnConfig } from '../index';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Building2, Eye, Power, PowerOff, Plus } from 'lucide-react';
import { toast } from 'sonner';
// 模拟数据类型
interface MockEnterprise {
id: string;
code: string;
name: string;
type: string;
registrant?: string;
contactPhone?: string;
createdAt: string;
auditStatus: 'draft' | 'pending' | 'approved' | 'rejected';
status: 'active' | 'inactive';
}
// 示例使用
export function EnterpriseManagementExample() {
// 搜索字段配置
const searchFields: SearchFieldConfig[] = [
{
key: 'search',
label: '企业搜索',
type: 'text',
placeholder: '搜索企业名称、编码...',
},
{
key: 'audit_status',
label: '审核状态',
type: 'select',
placeholder: '选择审核状态',
options: [
{ value: '', label: '全部状态' },
{ value: '草稿', label: '草稿' },
{ value: '待审核', label: '待审核' },
{ value: '已通过', label: '审核通过' },
{ value: '已拒绝', label: '已拒绝' },
],
},
];
// 表格列配置
const columns: TableColumnConfig[] = [
{
key: 'code',
label: '企业编码',
sortable: true,
width: '120px',
},
{
key: 'name',
label: '企业名称',
sortable: true,
render: (value: string, row: MockEnterprise) => (
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-500" />
<span className="font-medium">{value}</span>
</div>
),
},
{
key: 'type',
label: '企业类型',
render: (value: string) => (
<Badge variant="outline" className="font-light">{value}</Badge>
),
},
{
key: 'registrant',
label: '登记人',
render: (value?: string) => value || '-',
},
{
key: 'contactPhone',
label: '联系电话',
render: (value?: string) => value || '-',
},
{
key: 'createdAt',
label: '创建时间',
sortable: true,
width: '160px',
},
{
key: 'auditStatus',
label: '审核状态',
render: (value: MockEnterprise['auditStatus']) => {
const getAuditStatusBadge = (status: MockEnterprise['auditStatus']) => {
switch (status) {
case 'draft':
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">稿</Badge>;
case 'pending':
return <Badge className="bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 font-light"></Badge>;
case 'approved':
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light"></Badge>;
case 'rejected':
return <Badge className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 font-light"></Badge>;
default:
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">稿</Badge>;
}
};
return getAuditStatusBadge(value);
},
},
{
key: 'status',
label: '状态',
render: (value: MockEnterprise['status']) => {
const getStatusBadge = (status: MockEnterprise['status']) => {
if (status === 'active') {
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light"></Badge>;
}
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light"></Badge>;
};
return getStatusBadge(value);
},
},
{
key: 'actions',
label: '操作',
render: (_: any, row: MockEnterprise) => (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => toast.success(`查看企业: ${row.name}`)}
>
<Eye className="w-3 h-3 mr-1" />
</Button>
{row.status === 'active' ? (
<Button
size="sm"
variant="outline"
className="text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600"
onClick={() => toast.success(`禁用企业: ${row.name}`)}
>
<PowerOff className="w-3 h-3 mr-1" />
</Button>
) : (
<Button
size="sm"
variant="outline"
className="text-green-600 dark:text-green-400 border-green-300 dark:border-green-600"
onClick={() => toast.success(`启用企业: ${row.name}`)}
>
<Power className="w-3 h-3 mr-1" />
</Button>
)}
</div>
),
},
];
// 模拟数据
const mockData: MockEnterprise[] = [
{
id: '1',
code: 'ENT001',
name: '示例科技有限公司',
type: '科技有限公司',
registrant: '张三',
contactPhone: '13800138000',
createdAt: '2024-01-15 10:30:00',
auditStatus: 'approved',
status: 'active',
},
{
id: '2',
code: 'ENT002',
name: '测试农业发展有限公司',
type: '农业发展有限公司',
registrant: '李四',
contactPhone: '13900139000',
createdAt: '2024-01-16 14:20:00',
auditStatus: 'pending',
status: 'active',
},
];
// 模拟分页配置
const mockPagination = {
page: 1,
size: 10,
total: 2,
totalPages: 1,
hasNext: false,
hasPrev: false,
};
// 处理搜索
const handleSearch = (filters: Record<string, string>) => {
console.log('搜索条件:', filters);
toast.success('搜索条件已更新');
};
// 处理排序
const handleSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
console.log('排序:', { sortBy, sortOrder });
toast.success(`排序: ${sortBy} ${sortOrder}`);
};
// 处理分页
const handlePageChange = (page: number) => {
console.log('切换到页面:', page);
toast.success(`切换到第 ${page}`);
};
// 操作按钮
const actionButtons = (
<Button onClick={() => toast.success('新建企业')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
);
return (
<SearchFormPagination
title="企业管理"
description="管理平台所有企业信息,支持查询、查看详情、启用/禁用企业"
searchFields={searchFields}
onSearch={handleSearch}
columns={columns}
data={mockData}
pagination={mockPagination}
onPageChange={handlePageChange}
onSort={handleSort}
actionButtons={actionButtons}
emptyIcon={<Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />}
emptyText="暂无企业数据"
/>
);
}
export default EnterpriseManagementExample;

View File

@@ -0,0 +1,196 @@
/**
* filekorolheader: 搜索表单分页状态管理 - 管理组件的状态和actions
* 功能:状态管理、数据更新、分页控制、搜索过滤
* 路径:/components/common/searchFormPagination/components/searchFormPaginationReducer
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer模式管理复杂状态
*/
'use client';
// 状态接口定义
export interface SearchFormPaginationState {
// 数据相关
data: any[];
loading: boolean;
error: string | null;
// 搜索过滤
filters: Record<string, string>;
// 分页相关
pagination: {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
// 排序相关
sortBy?: string;
sortOrder: 'asc' | 'desc';
}
// Action类型定义
export type SearchFormPaginationAction =
| { type: 'SET_DATA'; payload: any[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_FILTERS'; payload: Record<string, string> }
| { type: 'UPDATE_FILTER'; payload: { key: string; value: string } }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_PAGINATION'; payload: SearchFormPaginationState['pagination'] }
| { type: 'SET_PAGINATION_PAGE'; payload: number }
| { type: 'SET_PAGINATION_SIZE'; payload: number }
| { type: 'SET_SORT_BY'; payload: string }
| { type: 'SET_SORT_ORDER'; payload: 'asc' | 'desc' }
| { type: 'SET_SORT'; payload: { sortBy?: string; sortOrder: 'asc' | 'desc' } }
| { type: 'TOGGLE_SORT'; payload: string }
| { type: 'SET_DATA_AND_PAGINATION'; payload: { data: any[]; pagination: SearchFormPaginationState['pagination'] } }
| { type: 'RESET_STATE' };
// 初始状态
export const initialState: SearchFormPaginationState = {
data: [],
loading: false,
error: null,
filters: {},
pagination: {
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
},
sortBy: undefined,
sortOrder: 'asc',
};
// Reducer函数
export function SearchFormPaginationReducer(
state: SearchFormPaginationState,
action: SearchFormPaginationAction
): SearchFormPaginationState {
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.payload,
};
case 'SET_LOADING':
return {
...state,
loading: action.payload,
};
case 'SET_ERROR':
return {
...state,
error: action.payload,
loading: false,
};
case 'SET_FILTERS':
return {
...state,
filters: action.payload,
};
case 'UPDATE_FILTER':
return {
...state,
filters: {
...state.filters,
[action.payload.key]: action.payload.value,
},
};
case 'CLEAR_FILTERS':
return {
...state,
filters: {},
};
case 'SET_PAGINATION':
return {
...state,
pagination: action.payload,
};
case 'SET_PAGINATION_PAGE':
return {
...state,
pagination: {
...state.pagination,
page: action.payload,
},
};
case 'SET_PAGINATION_SIZE':
return {
...state,
pagination: {
...state.pagination,
size: action.payload,
},
};
case 'SET_SORT_BY':
return {
...state,
sortBy: action.payload,
};
case 'SET_SORT_ORDER':
return {
...state,
sortOrder: action.payload,
};
case 'SET_SORT':
return {
...state,
sortBy: action.payload.sortBy,
sortOrder: action.payload.sortOrder,
};
case 'TOGGLE_SORT':
const columnKey = action.payload;
let newSortOrder: 'asc' | 'desc';
if (state.sortBy === columnKey) {
// 如果点击的是当前排序列,切换排序方向
newSortOrder = state.sortOrder === 'desc' ? 'asc' : 'desc';
} else {
// 如果点击的是新列,设置为升序
newSortOrder = 'asc';
}
return {
...state,
sortBy: columnKey,
sortOrder: newSortOrder,
};
case 'SET_DATA_AND_PAGINATION':
return {
...state,
data: action.payload.data,
pagination: action.payload.pagination,
loading: false,
error: null,
};
case 'RESET_STATE':
return {
...initialState,
filters: state.filters, // 保留搜索过滤条件
};
default:
console.warn('Unknown action type:', (action as any).type);
return state;
}
}

View File

@@ -0,0 +1,38 @@
/**
* filekorolheader: 搜索表单分页组件导出 - 统一导出所有相关组件和类型
* 功能:组件导出、类型导出、便捷导入
* 路径:/components/common/searchFormPagination
* 规范遵循crop-x/docs/开发项目规范.md提供统一的导出入口
*/
// 主组件
export { SearchFormPagination } from './page';
export { default } from './page';
// 子组件
export { default as SearchFormComponent } from './components/SearchFormComponent';
export { default as PaginationComponent } from './components/PaginationComponent';
// 状态管理
export { SearchFormPaginationReducer, initialState } from './components/searchFormPaginationReducer';
// 类型定义
export type {
SearchFieldConfig,
TableColumnConfig,
PaginationConfig,
SearchFormPaginationProps,
} from './page';
export type {
SearchFormPaginationState,
SearchFormPaginationAction,
} from './components/searchFormPaginationReducer';
export type {
SearchFormComponentProps,
} from './components/SearchFormComponent';
export type {
PaginationComponentProps,
} from './components/PaginationComponent';

View File

@@ -0,0 +1,364 @@
/**
* filekorolheader: 搜索表单分页公共组件 - 提供可复用的搜索、表单和分页功能
* 功能:搜索条件管理、表头渲染、分页控制、加载状态处理
* 路径:/components/common/searchFormPagination
* 规范遵循crop-x/docs/开发项目规范.md使用shadcn语义化样式支持完全自定义配置
*/
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { SearchFormComponent } from './components/SearchFormComponent';
import { PaginationComponent } from './components/PaginationComponent';
// 搜索条件配置接口
export interface SearchFieldConfig {
key: string;
label: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
defaultValue?: string;
}
// 表头配置接口
export interface TableColumnConfig {
key: string;
label: string;
sortable?: boolean;
width?: string;
render?: (value: any, row: any, index: number) => React.ReactNode;
}
// 分页配置接口
export interface PaginationConfig {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
// 组件Props接口 - 简化版本
export interface SearchFormPaginationProps<T = any> {
// 搜索表单配置
formTitle?: string;
formRightContent?: React.ReactNode;
searchFields: SearchFieldConfig[];
searchPlaceholder?: string;
onSearch?: (filters: Record<string, string>) => void;
// 表格配置
columns: TableColumnConfig[];
data?: T[];
loading?: boolean;
error?: string | null;
// 分页配置
pagination?: PaginationConfig;
onPageChange?: (page: number) => void;
onSizeChange?: (size: number) => void;
// 排序配置
sortBy?: string;
sortOrder?: 'asc' | 'desc';
onSort?: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
// 空状态配置
emptyIcon?: React.ReactNode;
emptyText?: string;
// 分页器配置
showSizeSelector?: boolean;
showPageInfo?: boolean;
showQuickJumper?: boolean;
sizeOptions?: number[];
maxVisiblePages?: number;
// 自定义样式
className?: string;
// 数据更新回调 - 用于父组件获取搜索条件变化
onDataUpdate?: (data: {
items: T[];
pagination: PaginationConfig;
loading: boolean;
error: string | null;
}) => void;
}
export function SearchFormPagination<T = any>({
formTitle,
formRightContent,
searchFields,
searchPlaceholder = '请输入搜索关键词...',
onSearch,
columns,
data = [],
loading = false,
error = null,
pagination,
onPageChange,
onSizeChange,
sortBy,
sortOrder,
onSort,
emptyIcon,
emptyText = '暂无数据',
showSizeSelector = true,
showPageInfo = true,
showQuickJumper = false,
sizeOptions = [10, 30, 50, 100],
maxVisiblePages = 7,
className = '',
onDataUpdate,
}: SearchFormPaginationProps<T>) {
// 简化的内部状态 - 只管理搜索条件
const [filters, setFilters] = useState<Record<string, string>>(
searchFields.reduce((acc, field) => {
acc[field.key] = field.defaultValue || '';
return acc;
}, {} as Record<string, string>)
);
// 同步外部排序状态
const [currentSort, setCurrentSort] = useState<{ sortBy?: string; sortOrder: 'asc' | 'desc' }>({
sortBy,
sortOrder: sortOrder || 'asc'
});
// 数据更新回调 - 通知父组件数据变化
useEffect(() => {
onDataUpdate?.({
items: data,
pagination: pagination || {
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
},
loading,
error,
});
}, [data, pagination, loading, error, onDataUpdate]);
// 简化的事件处理器 - 纯粹的状态通知
const handleSearch = useCallback((newFilters: Record<string, string>) => {
setFilters(newFilters);
onSearch?.(newFilters);
}, [onSearch]);
const handleSort = useCallback((columnKey: string) => {
const column = columns.find(col => col.key === columnKey);
if (!column?.sortable) return;
// 计算新的排序状态
let newSortOrder: 'asc' | 'desc';
if (currentSort.sortBy === columnKey) {
newSortOrder = currentSort.sortOrder === 'desc' ? 'asc' : 'desc';
} else {
newSortOrder = 'asc';
}
const newSort = { sortBy: columnKey, sortOrder: newSortOrder };
setCurrentSort(newSort);
onSort?.(columnKey, newSortOrder);
}, [columns, currentSort, onSort]);
const handlePageChange = useCallback((page: number) => {
onPageChange?.(page);
}, [onPageChange]);
const handleSizeChange = useCallback((size: number) => {
onSizeChange?.(size);
}, [onSizeChange]);
// 稳定的filters引用
const stableFilters = useMemo(() => filters, [filters]);
// 渲染表头
const renderTableHeader = () => {
// 计算列宽:对于自定义渲染的列,使用最小宽度;对于简单列,根据内容计算宽度
const getColumnWidth = (column: TableColumnConfig) => {
if (column.width) {
return column.width; // 如果明确指定了宽度,使用指定宽度
}
// 对于简单文本列,计算内容长度并设置合理的最小宽度
if (!column.render) {
return 'min-w-[100px] max-w-[200px]'; // 普通文本列的宽度范围
}
// 对于自定义渲染的列,给一个合理的最小宽度
return 'min-w-[120px] max-w-[300px]'; // 自定义列的宽度范围
};
return (
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.key}
className={column.sortable ? 'cursor-pointer hover:bg-muted' : ''}
style={{
width: getColumnWidth(column),
minWidth: column.render ? '120px' : '100px',
maxWidth: column.render ? '300px' : '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
onClick={() => column.sortable && handleSort(column.key)}
>
<div className="truncate" title={column.label}>
{column.label}
</div>
{column.sortable && currentSort.sortBy === column.key && (
<span className="ml-1 flex-shrink-0">{currentSort.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
))}
</TableRow>
</TableHeader>
);
};
// 渲染表格行
const renderTableRow = (row: T, index: number) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell
key={column.key}
style={{
maxWidth: column.render ? '300px' : '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
<div className="truncate" title={
column.render
? (column.render(row[column.key as keyof T], row, index) as any)?.toString() || ''
: String(row[column.key as keyof T] ?? '-')
}>
{column.render
? column.render(row[column.key as keyof T], row, index)
: String(row[column.key as keyof T] ?? '-')}
</div>
</TableCell>
))}
</TableRow>
);
return (
<div className={`space-y-6 ${className}`}>
{/* 搜索表单和数据表格在同一个Card里面 */}
<Card className="p-6 bg-card">
{/* 搜索表单 - 左右两部分布局 */}
{(formTitle || formRightContent || searchFields.length > 0) && (
<div className="flex items-center justify-between mb-4">
{/* 左侧 - 表单名称 */}
{formTitle && (
<h3 className="text-lg font-semibold text-foreground">{formTitle}</h3>
)}
{/* 右侧 - 搜索控件和自定义内容 */}
<div className="flex items-center gap-3">
<SearchFormComponent
key="search-form"
fields={searchFields}
filters={stableFilters}
onFiltersChange={handleSearch}
placeholder={searchPlaceholder}
loading={loading}
/>
{formRightContent}
</div>
</div>
)}
{/* 错误状态 */}
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
</div>
)}
{/* 数据表格 */}
{!error && (
<>
{/* 初始加载状态 */}
{loading && data.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="w-8 h-8 mx-auto mb-2 animate-spin" />
<p>...</p>
</div>
) : (
<>
{/* 表格加载遮罩 */}
<div className="relative">
{loading && (
<div className="absolute inset-0 bg-white/50 dark:bg-black/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg">
<div className="flex flex-col items-center text-muted-foreground">
<RefreshCw className="w-6 h-6 animate-spin mb-2" />
<p className="text-sm">...</p>
</div>
</div>
)}
<div className="border rounded-lg overflow-hidden">
<Table>
{renderTableHeader()}
<TableBody>
{data.map((row, index) => renderTableRow(row, index))}
</TableBody>
</Table>
</div>
</div>
{/* 空状态 */}
{data.length === 0 && !loading && (
<div className="text-center py-12 text-muted-foreground">
{emptyIcon || <div className="w-12 h-12 mx-auto mb-4 opacity-20" />}
<p>{emptyText}</p>
</div>
)}
{/* 分页组件 */}
{pagination && (
<PaginationComponent
pagination={pagination}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
loading={loading}
showSizeSelector={showSizeSelector}
showPageInfo={showPageInfo}
showQuickJumper={showQuickJumper}
sizeOptions={sizeOptions}
maxVisiblePages={maxVisiblePages}
/>
)}
</>
)}
</>
)}
</Card>
</div>
);
}
export default SearchFormPagination;