342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
||
import type { ExamClient, ExamModalTab } from '../../data/mockData';
|
||
import { EXAM_TAGS } from '../../data/mockData';
|
||
import { getTodayExamProgress } from '../../api';
|
||
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 h-full overflow-y-auto p-4 pb-10'>
|
||
<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) => {
|
||
const addonCount = client.addonCount || 0;
|
||
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 as any).physical_exam_status_name ?? '未知状态'}</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>
|
||
{client.is_sign_in === 1 && <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>
|
||
);
|
||
};
|
||
|
||
|