Files
ipad/src/components/exam/ExamSection.tsx
2025-12-30 17:46:30 +08:00

348 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Button, Card, CardContent, CardHeader, InfoCard, Input } from '../ui';
import { cls } from '../../utils/cls';
interface ExamSectionProps {
filteredClients: ExamClient[];
selectedExamClient: ExamClient | undefined;
examFilterTags: Set<(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,
examFilterTags,
onFilterChange,
onOpenModal,
searchValue,
onSearchChange,
loading = false,
}: ExamSectionProps) => {
const [progressStats, setProgressStats] = useState([
{ label: '预约人数', value: 0 },
{ label: '已签到', value: 0 },
{ label: '体检中', value: 0 },
{ label: '用餐', value: 0 },
]);
const [displayCount, setDisplayCount] = useState(INITIAL_LOAD_COUNT);
const observerTarget = useRef<HTMLDivElement>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// 防抖:内部输入值
const [debouncedInputValue, setDebouncedInputValue] = useState(searchValue);
const debounceTimerRef = useRef<number | null>(null);
useEffect(() => {
getTodayExamProgress({})
.then((res) => {
const d = res.Data;
setProgressStats([
{ label: '预约人数', value: Number(d?.today_appointment_count ?? 0) },
{ label: '已签到', value: Number(d?.today_signin_count ?? 0) },
{ label: '体检中', value: Number(d?.today_in_exam_count ?? 0) },
{ label: '用餐', value: Number(d?.today_meal_count ?? 0) },
]);
})
.catch((err) => {
console.error('获取今日体检进度失败', err);
});
}, []);
// 防抖:当输入值变化时,延迟 0.5 秒后调用 onSearchChange
useEffect(() => {
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = window.setTimeout(() => {
onSearchChange(debouncedInputValue);
}, 500);
return () => {
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current);
}
};
}, [debouncedInputValue, onSearchChange]);
// 当外部 searchValue 变化时(比如清空搜索),同步内部值(仅在值确实不同时更新)
useEffect(() => {
if (searchValue !== debouncedInputValue && searchValue === '') {
// 只在外部清空搜索时同步,避免用户输入时被覆盖
setDebouncedInputValue('');
}
}, [searchValue]);
// 当 filteredClients 变化时,重置显示数量
useEffect(() => {
setDisplayCount(INITIAL_LOAD_COUNT);
}, [filteredClients.length, searchValue, examFilterTags]);
// 懒加载:使用 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);
}, 0);
}
}
},
{
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>
<CardHeader></CardHeader>
<CardContent>
<div className='grid grid-cols-4 gap-3'>
{progressStats.map(({ label, value }) => (
<InfoCard key={label} label={label} value={value} />
))}
</div>
</CardContent>
</Card>
<div className='w-[320px] flex items-center gap-2'>
<Input
placeholder='搜索 姓名 / 手机号 / 身份证号 / 卡号'
value={debouncedInputValue}
onChange={(e) => setDebouncedInputValue(e.target.value)}
className='text-sm flex-1'
/>
{debouncedInputValue && (
<Button
className='px-3 py-1.5 text-xs whitespace-nowrap'
onClick={() => setDebouncedInputValue('')}
>
</Button>
)}
</div>
<Card>
<CardHeader>
<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 transition-colors',
examFilterTags.has(tag)
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-300',
)}
>
{tag}
</button>
))}
</div>
</div>
</div>
</CardHeader>
<CardContent>
{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'>
{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);
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>
{client.familyDoctorName && <span className='text-xs text-gray-500'>{client.familyDoctorName}</span>}
<Badge>{client.level}</Badge>
</div>
<div className='text-xs text-gray-500 truncate'>{client.packageName}</div>
<div className='text-xs text-gray-500 truncate'>{client.channel ?? ''}</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 && 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('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>
{/* 懒加载触发区域和 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>
</div>
);
};