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

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 type { ExamClient, ExamModalTab } from '../../data/mockData';
import { EXAM_TAGS } from '../../data/mockData'; import { EXAM_TAGS } from '../../data/mockData';
import { getTodayExamProgress } from '../../api'; import { getTodayExamProgress } from '../../api';
import { isExamActionDone } from '../../utils/examActions'; 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'; import { cls } from '../../utils/cls';
interface ExamSectionProps { interface ExamSectionProps {
@@ -12,14 +12,23 @@ interface ExamSectionProps {
examFilterTag: (typeof EXAM_TAGS)[number]; examFilterTag: (typeof EXAM_TAGS)[number];
onFilterChange: (tag: (typeof EXAM_TAGS)[number]) => void; onFilterChange: (tag: (typeof EXAM_TAGS)[number]) => void;
onOpenModal: (id: string, tab: ExamModalTab) => 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 = ({ export const ExamSection = ({
filteredClients, filteredClients,
selectedExamClient, selectedExamClient,
examFilterTag, examFilterTag,
onFilterChange, onFilterChange,
onOpenModal, onOpenModal,
searchValue,
onSearchChange,
loading = false,
}: ExamSectionProps) => { }: ExamSectionProps) => {
const [progressStats, setProgressStats] = useState([ const [progressStats, setProgressStats] = useState([
{ label: '预约人数', value: 0 }, { label: '预约人数', value: 0 },
@@ -27,6 +36,9 @@ export const ExamSection = ({
{ 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);
useEffect(() => { useEffect(() => {
getTodayExamProgress({}) 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 ( return (
<div className='space-y-4'> <div className='space-y-4'>
<Card> <Card>
@@ -56,123 +111,192 @@ export const ExamSection = ({
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className='w-[320px]'>
<Input
placeholder='搜索 姓名 / 手机号 / 身份证号 / 卡号'
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className='text-sm'
/>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
<span></span> <div className='flex items-center justify-between w-full'>
<div className='flex items-center gap-2 text-xs'> <span></span>
{EXAM_TAGS.map((tag) => ( <div className='flex items-center gap-3'>
<button <div className='flex items-center gap-2 text-xs'>
key={tag} {EXAM_TAGS.map((tag) => (
onClick={() => onFilterChange(tag)} <button
className={cls( key={tag}
'px-3 py-1 rounded-2xl border', onClick={() => onFilterChange(tag)}
examFilterTag === tag ? 'bg-gray-900 text-white border-gray-900' : 'bg-white text-gray-700', 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> >
))} {tag}
</button>
))}
</div>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <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-center text-gray-500 py-8'>
<div className='text-sm'></div> <div className='text-sm'></div>
<div className='text-xs mt-1'></div> <div className='text-xs mt-1'></div>
</div> </div>
) : ( ) : (
<div className='grid grid-cols-3 gap-3 text-sm'> <>
{filteredClients.map((client) => { <div className='grid grid-cols-3 gap-3 text-sm'>
// 检查操作记录:优先使用 localStorage 记录,如果没有则使用原有逻辑 {displayedClients.map((client) => {
const idCardSignInDone = isExamActionDone(client.id, 'idCardSignIn'); // 检查操作记录:优先使用 localStorage 记录,如果没有则使用原有逻辑
const printSignDone = isExamActionDone(client.id, 'printSign'); const idCardSignInDone = isExamActionDone(client.id, 'idCardSignIn');
const printSignDone = isExamActionDone(client.id, 'printSign');
const signDone = idCardSignInDone || client.signStatus === '已登记' || client.checkedItems.includes('签到'); const signDone = idCardSignInDone || client.signStatus === '已登记' || client.checkedItems.includes('签到');
const addonCount = client.addonCount || 0; const addonCount = client.addonCount || 0;
const printDone = printSignDone || !!client.guidePrinted; const printDone = printSignDone || !!client.guidePrinted;
const openModal = (tab: ExamModalTab) => onOpenModal(client.id, tab); const openModal = (tab: ExamModalTab) => onOpenModal(client.id, tab);
return ( return (
<div <div
key={client.id} key={client.id}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={() => openModal('detail')} onClick={() => openModal('detail')}
className={cls( className={cls(
'text-left p-3 rounded-2xl border bg-white hover:bg-gray-50 flex flex-col gap-1 cursor-pointer', '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', selectedExamClient?.id === client.id && 'border-gray-900 bg-gray-50',
)} )}
> >
<div className='flex items-center justify-between mb-1'> <div className='flex items-center justify-between mb-1'>
<span className='font-medium'>{client.name}</span> <span className='font-medium'>{client.name}</span>
<Badge>{client.level}</Badge> <Badge>{client.level}</Badge>
</div> </div>
<div className='text-xs text-gray-500 truncate'>{client.packageName}</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'> <div className='flex items-center justify-between text-xs text-gray-500 mt-1'>
<span>{client.status}</span> <span>{client.status}</span>
<span>{client.elapsed}</span> <span>{client.elapsed}</span>
</div> </div>
<div className='mt-2 flex flex-wrap gap-1 text-[11px]'> <div className='mt-2 flex flex-wrap gap-1 text-[11px]'>
<button <button
type='button' type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1' className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openModal('detail'); openModal('detail');
}} }}
> >
<span></span> <span></span>
</button> </button>
<button <button
type='button' type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1' className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openModal('sign'); openModal('sign');
}} }}
> >
<span></span> <span></span>
{signDone && <span></span>} {signDone && <span></span>}
</button> </button>
<button <button
type='button' type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1' className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openModal('addon'); openModal('addon');
}} }}
> >
<span></span> <span></span>
{addonCount > 0 && <span className='opacity-80'>({addonCount})</span>} {addonCount > 0 && <span className='opacity-80'>({addonCount})</span>}
</button> </button>
<button <button
type='button' type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1' className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openModal('print'); openModal('print');
}} }}
> >
<span></span> <span></span>
{printDone && <span></span>} {printDone && <span></span>}
</button> </button>
<button <button
type='button' type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1' className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openModal('delivery'); openModal('delivery');
}} }}
> >
<span></span> <span></span>
</button> </button>
</div>
</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> </CardContent>
</Card> </Card>

View File

@@ -1,20 +1,19 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import type { ExamClient, ExamModalTab } from '../data/mockData'; import type { ExamClient, ExamModalTab } from '../data/mockData';
import { EXAM_TAGS } from '../data/mockData'; import { EXAM_TAGS } from '../data/mockData';
import { ExamSection } from '../components/exam/ExamSection'; import { ExamSection } from '../components/exam/ExamSection';
import { ExamModal } from '../components/exam/ExamModal'; import { ExamModal } from '../components/exam/ExamModal';
import type { MainLayoutContext } from '../layouts/MainLayout';
import { getPhysicalExamCustomerList } from '../api'; import { getPhysicalExamCustomerList } from '../api';
export const ExamPage = () => { export const ExamPage = () => {
const { search } = useOutletContext<MainLayoutContext>(); const [searchValue, setSearchValue] = useState<string>('');
const [clients, setClients] = useState<ExamClient[]>([]); const [clients, setClients] = useState<ExamClient[]>([]);
const [examSelectedId, setExamSelectedId] = useState<string>(''); const [examSelectedId, setExamSelectedId] = useState<string>('');
const [examPanelTab, setExamPanelTab] = useState<ExamModalTab>('detail'); const [examPanelTab, setExamPanelTab] = useState<ExamModalTab>('detail');
const [examModalOpen, setExamModalOpen] = useState(false); const [examModalOpen, setExamModalOpen] = useState(false);
const [examFilterTag, setExamFilterTag] = useState<(typeof EXAM_TAGS)[number]>('全部'); const [examFilterTag, setExamFilterTag] = useState<(typeof EXAM_TAGS)[number]>('全部');
const [loading, setLoading] = useState(false);
// 将筛选标签映射为接口 filter_type // 将筛选标签映射为接口 filter_type
const filterType = useMemo(() => { const filterType = useMemo(() => {
@@ -40,14 +39,61 @@ export const ExamPage = () => {
} }
}, [examFilterTag]); }, [examFilterTag]);
// 智能识别搜索内容类型
const getSearchParams = useMemo(() => {
const trimmed = searchValue.trim();
if (!trimmed) {
return {
customer_name: undefined,
phone: undefined,
id_no: undefined,
};
}
// 判断是否为手机号11位数字以1开头
if (/^1[3-9]\d{9}$/.test(trimmed)) {
return {
customer_name: undefined,
phone: trimmed,
id_no: undefined,
};
}
// 判断是否为身份证号15位或18位最后一位可能是X
if (/^(\d{15}|\d{17}[\dXx])$/.test(trimmed)) {
return {
customer_name: undefined,
phone: undefined,
id_no: trimmed,
};
}
// 判断是否为卡号纯数字长度在6-20位之间
if (/^\d{6,20}$/.test(trimmed)) {
// 接口中没有卡号字段,暂时使用 customer_name 搜索
// 如果后续接口支持卡号字段,可以添加 card_no 参数
return {
customer_name: trimmed,
phone: undefined,
id_no: undefined,
};
}
// 默认为姓名
return {
customer_name: trimmed,
phone: undefined,
id_no: undefined,
};
}, [searchValue]);
// 从接口拉取体检客户列表 // 从接口拉取体检客户列表
useEffect(() => { useEffect(() => {
const payload = { const payload = {
customer_name: search.trim() || undefined, ...getSearchParams,
phone: undefined,
id_no: undefined,
filter_type: filterType, filter_type: filterType,
}; };
setLoading(true);
getPhysicalExamCustomerList(payload) getPhysicalExamCustomerList(payload)
.then((res) => { .then((res) => {
const list = res.Data || []; const list = res.Data || [];
@@ -96,8 +142,11 @@ export const ExamPage = () => {
}) })
.catch((err) => { .catch((err) => {
console.error('获取体检客户列表失败', err); console.error('获取体检客户列表失败', err);
})
.finally(() => {
setLoading(false);
}); });
}, [search, filterType, examSelectedId]); }, [getSearchParams, filterType, examSelectedId]);
const selectedExamClient: ExamClient | undefined = useMemo( const selectedExamClient: ExamClient | undefined = useMemo(
() => clients.find((c) => c.id === examSelectedId) || clients[0], () => clients.find((c) => c.id === examSelectedId) || clients[0],
@@ -118,6 +167,9 @@ export const ExamPage = () => {
examFilterTag={examFilterTag} examFilterTag={examFilterTag}
onFilterChange={setExamFilterTag} onFilterChange={setExamFilterTag}
onOpenModal={handleOpenModal} onOpenModal={handleOpenModal}
searchValue={searchValue}
onSearchChange={setSearchValue}
loading={loading}
/> />
{examModalOpen && selectedExamClient && ( {examModalOpen && selectedExamClient && (