Compare commits
2 Commits
9f1cf21042
...
191d218ec4
| Author | SHA1 | Date | |
|---|---|---|---|
| 191d218ec4 | |||
| 956494b3ed |
2
crop-x/next-env.d.ts
vendored
2
crop-x/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
38
crop-x/src/components/common/searchFormPagination/index.ts
Normal file
38
crop-x/src/components/common/searchFormPagination/index.ts
Normal 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';
|
||||
364
crop-x/src/components/common/searchFormPagination/page.tsx
Normal file
364
crop-x/src/components/common/searchFormPagination/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user