添加体检客户列表懒加载&搜索

This commit is contained in:
xianyi
2025-12-22 09:51:41 +08:00
parent b72765695d
commit 4bfb09b7d9
2 changed files with 288 additions and 112 deletions

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import type { ExamClient, ExamModalTab } from '../../data/mockData';
import { EXAM_TAGS } from '../../data/mockData';
import { getTodayExamProgress } from '../../api';
import { isExamActionDone } from '../../utils/examActions';
import { Badge, Card, CardContent, CardHeader, InfoCard } from '../ui';
import { Badge, Card, CardContent, CardHeader, InfoCard, Input } from '../ui';
import { cls } from '../../utils/cls';
interface ExamSectionProps {
@@ -12,14 +12,23 @@ interface ExamSectionProps {
examFilterTag: (typeof EXAM_TAGS)[number];
onFilterChange: (tag: (typeof EXAM_TAGS)[number]) => void;
onOpenModal: (id: string, tab: ExamModalTab) => void;
searchValue: string;
onSearchChange: (value: string) => void;
loading?: boolean;
}
const INITIAL_LOAD_COUNT = 9; // 初始加载数量3列 x 3行
const LOAD_MORE_COUNT = 9; // 每次加载更多时的数量
export const ExamSection = ({
filteredClients,
selectedExamClient,
examFilterTag,
onFilterChange,
onOpenModal,
searchValue,
onSearchChange,
loading = false,
}: ExamSectionProps) => {
const [progressStats, setProgressStats] = useState([
{ label: '预约人数', value: 0 },
@@ -27,6 +36,9 @@ export const ExamSection = ({
{ label: '体检中', value: 0 },
{ label: '用餐', value: 0 },
]);
const [displayCount, setDisplayCount] = useState(INITIAL_LOAD_COUNT);
const observerTarget = useRef<HTMLDivElement>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
useEffect(() => {
getTodayExamProgress({})
@@ -44,6 +56,49 @@ export const ExamSection = ({
});
}, []);
// 当 filteredClients 变化时,重置显示数量
useEffect(() => {
setDisplayCount(INITIAL_LOAD_COUNT);
}, [filteredClients.length, searchValue, examFilterTag]);
// 懒加载:使用 Intersection Observer 监听底部元素
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading && !isLoadingMore) {
const hasMore = displayCount < filteredClients.length;
if (hasMore) {
setIsLoadingMore(true);
// 模拟加载延迟,让用户看到 loading 效果
setTimeout(() => {
setDisplayCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredClients.length));
setIsLoadingMore(false);
}, 300);
}
}
},
{
root: null,
rootMargin: '100px',
threshold: 0.1,
}
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [displayCount, filteredClients.length, loading, isLoadingMore]);
const displayedClients = filteredClients.slice(0, displayCount);
const hasMore = displayCount < filteredClients.length;
return (
<div className='space-y-4'>
<Card>
@@ -56,123 +111,192 @@ export const ExamSection = ({
</div>
</CardContent>
</Card>
<div className='w-[320px]'>
<Input
placeholder='搜索 姓名 / 手机号 / 身份证号 / 卡号'
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className='text-sm'
/>
</div>
<Card>
<CardHeader>
<span></span>
<div className='flex items-center gap-2 text-xs'>
{EXAM_TAGS.map((tag) => (
<button
key={tag}
onClick={() => onFilterChange(tag)}
className={cls(
'px-3 py-1 rounded-2xl border',
examFilterTag === tag ? 'bg-gray-900 text-white border-gray-900' : 'bg-white text-gray-700',
)}
>
{tag}
</button>
))}
<div className='flex items-center justify-between w-full'>
<span></span>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2 text-xs'>
{EXAM_TAGS.map((tag) => (
<button
key={tag}
onClick={() => onFilterChange(tag)}
className={cls(
'px-3 py-1 rounded-2xl border',
examFilterTag === tag ? 'bg-gray-900 text-white border-gray-900' : 'bg-white text-gray-700',
)}
>
{tag}
</button>
))}
</div>
</div>
</div>
</CardHeader>
<CardContent>
{filteredClients.length === 0 ? (
{loading && filteredClients.length === 0 ? (
<div className='text-center text-gray-500 py-8'>
<div className='flex items-center justify-center gap-2'>
<svg
className='animate-spin h-5 w-5 text-gray-600'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
></circle>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
<span className='text-sm'>...</span>
</div>
</div>
) : filteredClients.length === 0 ? (
<div className='text-center text-gray-500 py-8'>
<div className='text-sm'></div>
<div className='text-xs mt-1'></div>
</div>
) : (
<div className='grid grid-cols-3 gap-3 text-sm'>
{filteredClients.map((client) => {
// 检查操作记录:优先使用 localStorage 记录,如果没有则使用原有逻辑
const idCardSignInDone = isExamActionDone(client.id, 'idCardSignIn');
const printSignDone = isExamActionDone(client.id, 'printSign');
<>
<div className='grid grid-cols-3 gap-3 text-sm'>
{displayedClients.map((client) => {
// 检查操作记录:优先使用 localStorage 记录,如果没有则使用原有逻辑
const idCardSignInDone = isExamActionDone(client.id, 'idCardSignIn');
const printSignDone = isExamActionDone(client.id, 'printSign');
const signDone = idCardSignInDone || client.signStatus === '已登记' || client.checkedItems.includes('签到');
const addonCount = client.addonCount || 0;
const printDone = printSignDone || !!client.guidePrinted;
const openModal = (tab: ExamModalTab) => onOpenModal(client.id, tab);
const signDone = idCardSignInDone || client.signStatus === '已登记' || client.checkedItems.includes('签到');
const addonCount = client.addonCount || 0;
const printDone = printSignDone || !!client.guidePrinted;
const openModal = (tab: ExamModalTab) => onOpenModal(client.id, tab);
return (
<div
key={client.id}
role='button'
tabIndex={0}
onClick={() => openModal('detail')}
className={cls(
'text-left p-3 rounded-2xl border bg-white hover:bg-gray-50 flex flex-col gap-1 cursor-pointer',
selectedExamClient?.id === client.id && 'border-gray-900 bg-gray-50',
)}
>
<div className='flex items-center justify-between mb-1'>
<span className='font-medium'>{client.name}</span>
<Badge>{client.level}</Badge>
</div>
<div className='text-xs text-gray-500 truncate'>{client.packageName}</div>
<div className='flex items-center justify-between text-xs text-gray-500 mt-1'>
<span>{client.status}</span>
<span>{client.elapsed}</span>
</div>
<div className='mt-2 flex flex-wrap gap-1 text-[11px]'>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('detail');
}}
>
<span></span>
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('sign');
}}
>
<span></span>
{signDone && <span></span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('addon');
}}
>
<span></span>
{addonCount > 0 && <span className='opacity-80'>({addonCount})</span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('print');
}}
>
<span></span>
{printDone && <span></span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('delivery');
}}
>
<span></span>
</button>
return (
<div
key={client.id}
role='button'
tabIndex={0}
onClick={() => openModal('detail')}
className={cls(
'text-left p-3 rounded-2xl border bg-white hover:bg-gray-50 flex flex-col gap-1 cursor-pointer',
selectedExamClient?.id === client.id && 'border-gray-900 bg-gray-50',
)}
>
<div className='flex items-center justify-between mb-1'>
<span className='font-medium'>{client.name}</span>
<Badge>{client.level}</Badge>
</div>
<div className='text-xs text-gray-500 truncate'>{client.packageName}</div>
<div className='flex items-center justify-between text-xs text-gray-500 mt-1'>
<span>{client.status}</span>
<span>{client.elapsed}</span>
</div>
<div className='mt-2 flex flex-wrap gap-1 text-[11px]'>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('detail');
}}
>
<span></span>
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('sign');
}}
>
<span></span>
{signDone && <span></span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('addon');
}}
>
<span></span>
{addonCount > 0 && <span className='opacity-80'>({addonCount})</span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('print');
}}
>
<span></span>
{printDone && <span></span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
openModal('delivery');
}}
>
<span></span>
</button>
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
{/* 懒加载触发区域和 loading */}
{(hasMore || isLoadingMore) && (
<div ref={observerTarget} className='flex items-center justify-center py-4'>
{isLoadingMore && (
<div className='flex items-center gap-2 text-gray-500'>
<svg
className='animate-spin h-4 w-4 text-gray-600'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
></circle>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
<span className='text-xs'>...</span>
</div>
)}
</div>
)}
</>
)}
</CardContent>
</Card>