Files
ipad/src/components/exam/ExamModal.tsx
2025-12-11 16:35:19 +08:00

909 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useMemo, useRef, useState } from 'react';
import type { ExamClient, ExamModalTab } from '../../data/mockData';
import type {
CustomerAppointmentInfo,
CustomerExamAddItem,
CustomerInfo,
PhysicalExamProgressItem,
} from '../../api';
import { getCustomerDetail, getPhysicalExamProgressDetail, signInMedicalExamCenter, getTongyishuPdf } from '../../api';
import { Button, Input } from '../ui';
interface ExamModalProps {
client: ExamClient;
tab: ExamModalTab;
onTabChange: (key: ExamModalTab) => void;
onClose: () => void;
}
export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps) => {
const tabs: { key: ExamModalTab; label: string }[] = [
{ key: 'detail', label: '详情' },
{ key: 'sign', label: '签到' },
{ key: 'addon', label: '加项' },
{ key: 'print', label: '打印导检单' },
{ key: 'delivery', label: '报告寄送' },
];
const signDone = client.signStatus === '已登记' || client.checkedItems.includes('签到');
const addonDone = (client.addonCount || 0) > 0;
const printDone = !!client.guidePrinted;
const deliveryDone = !!client.deliveryDone;
const tabDone: Record<ExamModalTab, boolean> = {
detail: false,
sign: signDone,
addon: addonDone,
print: printDone,
delivery: deliveryDone,
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length > 1) {
e.preventDefault();
}
};
const [detailLoading, setDetailLoading] = useState(false);
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
const [appointmentInfo, setAppointmentInfo] = useState<CustomerAppointmentInfo | null>(null);
const [addItemInfoList, setAddItemInfoList] = useState<CustomerExamAddItem[] | null>(null);
const [progressList, setProgressList] = useState<PhysicalExamProgressItem[] | null>(null);
useEffect(() => {
const physical_exam_id = Number(client.id);
if (!physical_exam_id) return;
setDetailLoading(true);
Promise.all([
getCustomerDetail({ physical_exam_id }),
getPhysicalExamProgressDetail({ physical_exam_id }),
])
.then(([detailRes, progressRes]) => {
setCustomerInfo(detailRes.Data?.customerInfo ?? null);
setAppointmentInfo(detailRes.Data?.appointmentInfo ?? null);
setAddItemInfoList(detailRes.Data?.addItemInfoList ?? null);
setProgressList(progressRes.Data?.examProgressesList ?? null);
})
.catch((err) => {
console.error('获取客户详情/进度失败', err);
})
.finally(() => setDetailLoading(false));
}, [client.id]);
return (
<div
className='fixed inset-0 z-40 flex items-center justify-center bg-black/50'
style={{ touchAction: 'none' }}
onDoubleClick={handleDoubleClick}
onTouchStart={handleTouchStart}
>
<div
className='w-[960px] max-w-[95vw] max-h-[95vh] bg-white rounded-2xl shadow-xl overflow-hidden text-sm'
style={{ touchAction: 'none' }}
onDoubleClick={handleDoubleClick}
onTouchStart={handleTouchStart}
>
{/* Header 区域:增加了内边距 padding */}
<div className='px-8 pt-6 pb-2'>
<div className='flex items-center justify-between'>
{/* 左侧:姓名 + VIP + 体检号 */}
<div className='flex items-end gap-3'>
<span className='text-2xl font-bold text-gray-900 leading-none'>
{client.name}
</span>
{/* VIP 徽章样式优化:紫色背景 + 左右内边距 */}
<span className='inline-flex items-center justify-center bg-indigo-100 text-indigo-600 text-[11px] font-bold px-2 py-0.5 rounded h-5 align-bottom'>
VIP
</span>
<span className='text-sm text-gray-400 ml-1 leading-none mb-0.5'>
{client.id}
</span>
</div>
{/* 右侧:仅保留关闭按钮 */}
<button
className='text-gray-400 hover:text-gray-600 transition-colors text-sm'
onClick={onClose}
>
</button>
</div>
</div>
{/* Tabs 区域 */}
<div className='px-8 py-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
{tabs.map((t) => {
const isActive = tab === t.key;
const isDone = tabDone[t.key];
return (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
// 样式修改rounded-full (完全圆角), border 颜色调整, 增加选中时的阴影
className={`px-5 py-2 rounded-2xl border text-sm transition-all duration-200 ${isActive
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-200'
: isDone
? 'bg-gray-50 text-gray-400 border-gray-100'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t.label}
{/* 这里保留了原有逻辑,但微调了样式 */}
{t.key === 'addon' && (client.addonCount || 0) > 0 && (
<span className={`text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
({client.addonCount})
</span>
)}
{((t.key === 'sign' && signDone) || (t.key === 'print' && printDone)) && (
<span className=''></span>
)}
</button>
);
})}
</div>
{/* 按钮部分:将原本在 Tab 右侧的任何操作可以放这里,或者留空 */}
</div>
<div className='px-4 py-4 bg-gray-50/60'>
{tab === 'detail' && (
<ExamDetailInfo
client={client}
customerInfo={customerInfo}
appointmentInfo={appointmentInfo}
addItemInfoList={addItemInfoList}
progressList={progressList}
loading={detailLoading}
/>
)}
{tab === 'sign' && <ExamSignPanel />}
{tab === 'addon' && <ExamAddonPanel client={client} />}
{tab === 'print' && <ExamPrintPanel client={client} />}
{tab === 'delivery' && <ExamDeliveryPanel client={client} />}
</div>
</div>
</div>
);
};
const ExamSignPanel = () => {
const [idNo, setIdNo] = useState('');
const [ocrLoading, setOcrLoading] = useState(false);
const [signLoading, setSignLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handlePickFile = () => {
fileInputRef.current?.click();
};
const mockOcr = async (file: File) => {
// 简单模拟 OCR提取文件名中的数字或返回示例身份证
const match = file.name.match(/\d{6,18}/);
await new Promise((r) => setTimeout(r, 600));
return match?.[0] || '440101199001010010';
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setOcrLoading(true);
setMessage(null);
try {
const ocrId = await mockOcr(file);
setIdNo(ocrId);
} catch (err) {
console.error(err);
setMessage('OCR 识别失败,请重试或手动输入');
} finally {
setOcrLoading(false);
e.target.value = '';
}
};
const handleSign = async () => {
const trimmed = idNo.trim();
if (!trimmed) {
setMessage('请输入身份证号');
return;
}
setSignLoading(true);
setMessage(null);
try {
const res = await signInMedicalExamCenter({ id_no: trimmed });
const ok = res.Status === 200 && res.Data?.is_success === 0;
setMessage(ok ? '签到成功' : res.Message || '签到失败');
} catch (err) {
console.error(err);
setMessage('签到请求失败,请稍后重试');
} finally {
setSignLoading(false);
}
};
return (
<div className='grid grid-cols-2 gap-4 text-sm'>
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
<div className='font-medium'></div>
<div className='text-xs text-gray-500'>
</div>
<div className='flex items-center gap-3'>
<Button className='py-1.5 px-3' onClick={handlePickFile} disabled={ocrLoading || signLoading}>
{ocrLoading ? '识别中...' : '扫描身份证'}
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
className='hidden'
onChange={handleFileChange}
/>
<Input
placeholder='身份证号'
value={idNo}
onChange={(e) => setIdNo(e.target.value)}
className='flex-1'
/>
<Button
className='py-1.5 px-4'
onClick={handleSign}
disabled={signLoading}
>
{signLoading ? '签到中...' : '签到'}
</Button>
</div>
{message && <div className='text-xs text-gray-600'>{message}</div>}
<div className='text-[11px] text-gray-400'></div>
</div>
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
<div className='font-medium'></div>
<div className='text-xs text-gray-500'></div>
<Button className='py-1.5 px-3'></Button>
</div>
</div>
);
};
interface AddonTag {
title: string;
type: 1 | 2 | 3 | 4; // 1: 热门(红), 2: 普通(灰), 3: 医生推荐(蓝), 4: 折扣信息
}
interface AddonItem {
id?: string;
name: string;
paid?: boolean;
tags?: AddonTag[];
originalPrice?: string;
currentPrice?: string;
price?: number; // 兼容 addonOptions 结构
}
const ExamAddonPanel = ({ client }: { client: ExamClient }) => {
// 从 client 获取加项选项数据
const addonOptions = (client['addonOptions' as keyof ExamClient] as AddonItem[] | undefined) || [];
const addonSummary = (client['addonSummary' as keyof ExamClient] as AddonItem[] | undefined) || [];
// 合并数据,优先使用 addonOptions如果没有则使用 addonSummary
const allAddons: AddonItem[] = addonOptions.length > 0
? addonOptions.map(item => ({
id: item.id || `addon_${item.name}`,
name: item.name,
paid: item.paid || false,
tags: item.tags || [],
originalPrice: item.originalPrice || (item.price ? item.price.toFixed(2) : '0.00'),
currentPrice: item.currentPrice || (item.price ? item.price.toFixed(2) : '0.00'),
}))
: addonSummary;
const [selectedIds, setSelectedIds] = useState<Set<string>>(
new Set(allAddons.filter(item => item.paid).map(item => item.id || item.name))
);
const maxSelect = 15;
const selectedCount = selectedIds.size;
const [addonSearch, setAddonSearch] = useState('');
const filteredAddons = addonSearch.trim()
? allAddons.filter(item =>
item.name.toLowerCase().includes(addonSearch.toLowerCase())
)
: allAddons;
const toggleSelect = (id: string) => {
if (selectedIds.has(id)) {
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else {
if (selectedCount < maxSelect) {
setSelectedIds(prev => new Set(prev).add(id));
}
}
};
// 计算价格汇总
const selectedItems = allAddons.filter(item => selectedIds.has(item.id || item.name));
const totalOriginal = selectedItems.reduce((sum, item) => {
return sum + parseFloat(item.originalPrice || item.currentPrice || '0');
}, 0);
const totalCurrent = selectedItems.reduce((sum, item) => {
return sum + parseFloat(item.currentPrice || item.originalPrice || '0');
}, 0);
const discount = totalOriginal - totalCurrent;
// 获取标签样式
const getTagStyle = (tag: AddonTag) => {
switch (tag.type) {
case 1: // 热门
return 'bg-[#FDF0F0] text-[#BC4845]';
case 3: // 医生推荐
return 'bg-[#ECF0FF] text-[#6A6AE5]';
case 4: // 折扣信息
return 'bg-[#4C5460] text-[#F1F2F5]';
default: // 2: 普通
return 'bg-[#F1F2F5] text-[#464E5B]';
}
};
// 获取折扣信息文字(从 tags 中提取 type 4 的标签,或计算折扣)
const getDiscountText = (item: AddonItem) => {
const discountTag = item.tags?.find(t => t.type === 4);
if (discountTag) return discountTag.title;
const orig = parseFloat(item.originalPrice || '0');
const curr = parseFloat(item.currentPrice || '0');
if (orig > 0 && curr < orig) {
const percent = Math.round((curr / orig) * 100);
return `渠道 ${percent}`;
}
return '渠道价';
};
return (
<div className='space-y-4'>
{/* 标题和说明 */}
<div>
<div className='flex items-center justify-between mb-2 gap-3'>
<h3 className='text-lg font-semibold text-gray-900'></h3>
<div className='w-[260px]'>
<Input
placeholder='搜索 加项名称'
value={addonSearch}
onChange={(e) => setAddonSearch(e.target.value)}
className='text-sm'
/>
</div>
</div>
<div className='text-xs text-gray-600 space-y-1'>
<div> {maxSelect} · 5 </div>
<div> {selectedCount} ,</div>
</div>
</div>
{/* 加项网格 */}
<div className='grid grid-cols-5 gap-3 min-h-[142px]'>
{filteredAddons.map((item) => {
const id = item.id || item.name;
const isSelected = selectedIds.has(id);
const origPrice = parseFloat(item.originalPrice || '0');
const currPrice = parseFloat(item.currentPrice || '0');
return (
<div
key={id}
className={`border rounded-lg p-3 cursor-pointer transition-all'
}`}
onClick={() => toggleSelect(id)}
>
{/* 复选框 */}
<div className='flex items-start gap-2 mb-2'>
<input
type='checkbox'
checked={isSelected}
onChange={() => toggleSelect(id)}
onClick={(e) => e.stopPropagation()}
className='mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
/>
<div className='flex-1 min-w-0'>
{/* 项目名称 */}
<div className='font-semibold text-[14px] text-gray-900 mb-1'>{item.name}</div>
{/* 标签 */}
{item.tags && item.tags.length > 0 && (
<div className='flex flex-wrap gap-1 mb-2'>
{item.tags
.filter(t => t.type !== 4) // 折扣信息单独显示
.map((tag, idx) => (
<span
key={idx}
className={`text-[10px] px-2 rounded-full ${getTagStyle(tag)}`}
>
{tag.title}
</span>
))}
</div>
)}
</div>
</div>
{/* 价格信息 */}
<div className='mt-2'>
<div className='flex flex-col'>
{origPrice > 0 && origPrice > currPrice && (
<span className='text-xs text-gray-400 line-through'>¥{origPrice.toFixed(0)}</span>
)}
<div className='flex items-center justify-between gap-2'>
<span className='text-[14px] font-bold text-red-600'>¥{currPrice.toFixed(0)}</span>
<span className={`text-[10px] px-2 rounded-full bg-[#EAFCF1] text-[#447955] whitespace-nowrap`}>{getDiscountText(item)}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* 底部汇总和支付 */}
<div className='border-t pt-4 mt-6 flex items-center justify-between'>
<div className='space-y-1 text-sm'>
<div className='text-gray-600'>
: <span className='text-gray-900'>¥{totalOriginal.toFixed(0)}</span>
</div>
<div className='text-gray-600'>
: <span className='text-xl font-bold text-red-600'>¥{totalCurrent.toFixed(0)}</span>
{discount > 0 && (
<span className='text-gray-500 ml-1'> ¥{discount.toFixed(0)}</span>
)}
</div>
<div className='text-xs text-gray-500'>结算方式: 个人支付 ( / )</div>
</div>
<Button
className='bg-[#269745] hover:bg-[#269745]/80 rounded-3xl text-white px-6 py-3 text-base font-medium'
disabled={selectedCount === 0}
>
¥{totalCurrent.toFixed(0)}
</Button>
</div>
</div>
);
};
const ExamDetailInfo = ({
client,
customerInfo,
appointmentInfo,
addItemInfoList,
progressList,
loading,
}: {
client: ExamClient;
customerInfo: CustomerInfo | null;
appointmentInfo: CustomerAppointmentInfo | null;
addItemInfoList: CustomerExamAddItem[] | null;
progressList: PhysicalExamProgressItem[] | null;
loading: boolean;
}) => {
const basePhone = customerInfo?.phone || (client['mobile' as keyof ExamClient] as string | undefined) || '';
const baseMarital =
customerInfo?.patient_marital_status_name ||
(client['maritalStatus' as keyof ExamClient] as string | undefined) ||
'—';
const [phone, setPhone] = useState(basePhone || '—');
const [marital, setMarital] = useState(baseMarital);
const [phoneEditing, setPhoneEditing] = useState(false);
const [maritalEditing, setMaritalEditing] = useState(false);
const customerChannel = client.customerType === '团客' ? '团体客户' : '散客客户';
const familyDoctor = customerInfo?.family_doctor_name || (client['familyDoctor' as keyof ExamClient] as string | undefined) || '—';
const groupTag = client['groupTag' as keyof ExamClient] || (client.customerType === '团客' ? '团检' : '—');
const bookingTime = appointmentInfo?.appointment_time || (client['bookingTime' as keyof ExamClient] || '—');
const signTime = appointmentInfo?.sign_in_time || (client['signTime' as keyof ExamClient] || '—');
const addonSummary =
addItemInfoList && addItemInfoList.length > 0
? addItemInfoList.map((i) => `${i.dept_name ?? ''} ${i.combination_name ?? ''}`.trim()).join('、')
: client['addonSummary' as keyof ExamClient] || '—';
const progressGroups = useMemo(() => {
const checked: string[] = [];
const abandoned: string[] = [];
const pending: string[] = [];
const deferred: string[] = [];
(progressList || []).forEach((p) => {
const name = p.project_name || p.department_name || '项目';
switch (p.exam_status) {
case 1:
checked.push(name);
break;
case 2:
abandoned.push(name);
break;
case 4:
deferred.push(name);
break;
default:
pending.push(name);
}
});
return { checked, abandoned, pending, deferred };
}, [progressList]);
return (
<div className='space-y-4 text-sm'>
<div className='flex items-center gap-4'>
<div className='w-14 h-14 rounded-full bg-gray-200 flex-shrink-0 overflow-hidden'>
<div className='w-full h-full rounded-full bg-gray-300 flex items-center justify-center text-[10px] text-gray-500'>
</div>
</div>
<div className='text-xs text-gray-500'>
{loading ? '加载中…' : '基础信息:头像、姓名、证件号、手机号等(点击图标可进行编辑)'}
</div>
</div>
<div className='space-y-2 text-xs text-gray-700'>
<div className='font-medium text-gray-900'></div>
<div className='grid grid-cols-2 gap-x-8 gap-y-1'>
<div>
<span className='text-gray-900'>{customerInfo?.customer_name || client.name}</span>
</div>
<div>
<span className='text-gray-900'>{customerInfo?.id_no || '—'}</span>
</div>
<div className='flex items-center'>
<span></span>
{!phoneEditing ? (
<span className='text-gray-900 flex items-center'>
{phone}
<button className='ml-1 text-blue-500 text-[11px] hover:underline' onClick={() => setPhoneEditing(true)}>
</button>
</span>
) : (
<span className='flex items-center gap-1'>
<input
className='w-28 rounded-xl border px-2 py-0.5 text-[11px] outline-none'
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<button className='px-2 py-0.5 rounded-xl border text-[11px]' onClick={() => setPhoneEditing(false)}>
</button>
</span>
)}
</div>
<div>
/
<span className='text-gray-900'>
{customerInfo?.gender_name || client.gender} / {client.age}
</span>
</div>
<div>
<span className='text-gray-900'>{client.level}</span>
</div>
<div>
<span className='text-gray-900'>{customerChannel}</span>
</div>
<div className='flex items-center'>
<span></span>
{!maritalEditing ? (
<span className='text-gray-900 flex items-center'>
{marital}
<button className='ml-1 text-blue-500 text-[11px] hover:underline' onClick={() => setMaritalEditing(true)}>
</button>
</span>
) : (
<span className='flex items-center gap-1'>
<input
className='w-20 rounded-xl border px-2 py-0.5 text-[11px] outline-none'
value={marital}
onChange={(e) => setMarital(e.target.value)}
/>
<button className='px-2 py-0.5 rounded-xl border text-[11px]' onClick={() => setMaritalEditing(false)}>
</button>
</span>
)}
</div>
<div>
<span className='text-gray-900'>{familyDoctor}</span>
</div>
<div>
<span className='text-gray-900'>{groupTag as string}</span>
</div>
</div>
</div>
<div className='space-y-2 text-xs text-gray-700'>
<div className='font-medium text-gray-900'></div>
<div className='grid grid-cols-2 gap-x-8 gap-y-1'>
<div>
<span className='text-gray-900'>{bookingTime as string}</span>
</div>
<div>
<span className='text-gray-900'>{signTime as string}</span>
</div>
<div>
<span className='text-gray-900'>
{appointmentInfo?.physical_exam_complete_time && appointmentInfo?.sign_in_time
? `${appointmentInfo.physical_exam_complete_time} - ${appointmentInfo.sign_in_time}`
: client.elapsed}
</span>
</div>
<div className='col-span-2'>
<span className='text-gray-900'>{client.packageName}</span>
</div>
<div className='col-span-2'>
<span className='text-gray-900'>{addonSummary as string}</span>
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-4 text-xs'>
<div className='p-3 rounded-2xl bg-green-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.checked.length} </div>
<div className='flex flex-wrap gap-2'>
{(progressGroups.checked.length ? progressGroups.checked : client.checkedItems).map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))}
</div>
</div>
<div className='p-3 rounded-2xl bg-red-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.abandoned.length} </div>
<div className='flex flex-wrap gap-2'>
{progressGroups.abandoned.length ? (
progressGroups.abandoned.map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))
) : (
<span className='text-gray-400 text-[11px]'></span>
)}
</div>
</div>
<div className='p-3 rounded-2xl bg-yellow-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.pending.length} </div>
<div className='flex flex-wrap gap-2'>
{(progressGroups.pending.length ? progressGroups.pending : client.pendingItems).map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))}
</div>
</div>
<div className='p-3 rounded-2xl bg-blue-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.deferred.length} </div>
<div className='flex flex-wrap gap-2'>
{progressGroups.deferred.length ? (
progressGroups.deferred.map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))
) : (
<span className='text-gray-400 text-[11px]'></span>
)}
</div>
</div>
</div>
</div>
);
};
const ExamDeliveryPanel = ({ client }: { client: ExamClient }) => (
<div className='flex justify-center'>
<div className='w-full rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
<div className='text-lg font-semibold text-gray-900 mb-3'></div>
<div className='grid grid-cols-2 gap-3 mb-3'>
<div>
<Input placeholder='请输入收件人姓名' className='mt-1' />
</div>
<div>
<Input placeholder='用于快递联系' className='mt-1' />
</div>
<div className='col-span-2'>
<Input placeholder='请输入详细寄送地址' className='mt-1' />
</div>
</div>
<div className='space-y-2'>
<div></div>
<textarea
className='w-full rounded-2xl border px-3 py-2 text-xs outline-none focus:ring-2 focus:ring-gray-200 min-h-[80px]'
placeholder='如需多份报告、加急寄送等,请在此备注'
/>
</div>
<div className='mt-4 flex items-center justify-between text-[11px] text-gray-500'>
<div>
<span className='font-medium text-gray-800'>{client.name}</span>{client.id}
</div>
<Button className='px-4 py-1.5 text-xs'></Button>
</div>
</div>
</div>
);
const ExamPrintPanel = ({ client }: { client: ExamClient }) => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pdfReady, setPdfReady] = useState(false);
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
const printRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
// 获取 PDF
useEffect(() => {
const physical_exam_id = Number(client.id);
if (!physical_exam_id) {
setError('无效的体检ID');
setLoading(false);
return;
}
setPdfReady(false);
getTongyishuPdf({ exam_id: physical_exam_id })
.then((res) => {
if (res.Status === 200 && res.Data?.list_pdf_url && res.Data.list_pdf_url.length > 0) {
// 取第一个PDF URL
setPdfUrl(res.Data.list_pdf_url[0].pdf_url);
setPdfReady(false);
} else {
setError(res.Message || '未获取到PDF文件');
}
})
.catch((err) => {
console.error('获取PDF失败', err);
setError('获取PDF失败请稍后重试');
})
.finally(() => {
setLoading(false);
});
}, [client.id]);
// 将 PDF 拉取为同源 blob避免跨域打印限制
useEffect(() => {
if (!pdfUrl) return;
let objectUrl: string | null = null;
setPdfReady(false);
setLoading(true);
fetch(pdfUrl)
.then((resp) => {
if (!resp.ok) throw new Error('获取PDF文件失败');
return resp.blob();
})
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setPdfBlobUrl(objectUrl);
})
.catch((err) => {
console.error('PDF 拉取失败', err);
setError('PDF 加载失败,请稍后重试');
})
.finally(() => {
setLoading(false);
});
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [pdfUrl]);
const handlePrint = () => {
if (!pdfBlobUrl || !pdfReady) return;
// 打开新窗口打印(同源 blob避免跨域和空白页问题
const printWindow = window.open(pdfBlobUrl, '_blank');
if (printWindow) {
printWindow.onload = () => {
printWindow.focus();
printWindow.print();
};
}
};
return (
<div className='flex justify-center'>
<div className='w-full max-w-[95%] bg-white rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
<div className='flex items-center justify-between border-b pb-3 mb-3'>
<div>
<div className='text-sm font-semibold'> · </div>
<div className='text-[11px] text-gray-500 mt-1'></div>
</div>
<div className='flex items-center gap-3'>
<div className='text-right text-[11px] text-gray-500'>
<div>{client.id}</div>
<div>{new Date().toLocaleDateString('zh-CN')}</div>
</div>
<Button
className='px-4 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
onClick={handlePrint}
disabled={loading || !pdfReady || !pdfUrl}
>
</Button>
</div>
</div>
{loading ? (
<div className='flex items-center justify-center py-12 text-gray-500'>
<div>PDF...</div>
</div>
) : error ? (
<div className='flex flex-col items-center justify-center py-12 text-gray-500'>
<div className='mb-2'>{error}</div>
<Button
className='px-4 py-1.5 text-xs'
onClick={() => {
setLoading(true);
setError(null);
const physical_exam_id = Number(client.id);
if (physical_exam_id) {
getTongyishuPdf({ exam_id: physical_exam_id })
.then((res) => {
if (res.Status === 200 && res.Data?.list_pdf_url && res.Data.list_pdf_url.length > 0) {
setPdfUrl(res.Data.list_pdf_url[0].pdf_url);
} else {
setError(res.Message || '未获取到PDF文件');
}
})
.catch((err) => {
console.error('获取PDF失败', err);
setError('获取PDF失败请稍后重试');
})
.finally(() => {
setLoading(false);
});
}
}}
>
</Button>
</div>
) : pdfUrl ? (
<div className='w-full'>
{/* PDF预览区域 */}
<div ref={printRef} className='print-content'>
<div className='flex justify-center border rounded-lg p-4 bg-gray-50 overflow-auto max-h-[600px]'>
<iframe
src={pdfBlobUrl || ''}
className='w-full h-[600px] border rounded-lg'
title='导检单PDF预览'
onLoad={() => setPdfReady(true)}
ref={iframeRef}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
);
};