优化 Loading 状态
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { ExamClient, ExamModalTab } from '../../data/mockData';
|
import type { ExamClient, ExamModalTab } from '../../data/mockData';
|
||||||
import type { CustomerAppointmentInfo, CustomerExamAddItem, CustomerInfo, OutputTongyishuFileInfo, PhysicalExamProgressItem } from '../../api';
|
import type { CustomerAppointmentInfo, CustomerExamAddItem, CustomerInfo, PhysicalExamProgressItem } from '../../api';
|
||||||
import { getCustomerDetail, getPhysicalExamProgressDetail, getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api';
|
import { getCustomerDetail, getPhysicalExamProgressDetail } from '../../api';
|
||||||
import { Button, SignaturePad, type SignaturePadHandle } from '../ui';
|
|
||||||
import { ExamDetailPanel } from './ExamDetailPanel';
|
import { ExamDetailPanel } from './ExamDetailPanel';
|
||||||
import { ExamAddonPanel } from './ExamAddonPanel';
|
import { ExamAddonPanel } from './ExamAddonPanel';
|
||||||
import { ExamPrintPanel } from './ExamPrintPanel';
|
import { ExamPrintPanel } from './ExamPrintPanel';
|
||||||
import { ExamDeliveryPanel } from './ExamDeliveryPanel';
|
import { ExamDeliveryPanel } from './ExamDeliveryPanel';
|
||||||
|
import { ExamSignPanel } from './ExamSignPanel';
|
||||||
|
|
||||||
interface ExamModalProps {
|
interface ExamModalProps {
|
||||||
client: ExamClient;
|
client: ExamClient;
|
||||||
@@ -54,6 +54,7 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
|||||||
const [appointmentInfo, setAppointmentInfo] = useState<CustomerAppointmentInfo | null>(null);
|
const [appointmentInfo, setAppointmentInfo] = useState<CustomerAppointmentInfo | null>(null);
|
||||||
const [addItemInfoList, setAddItemInfoList] = useState<CustomerExamAddItem[] | null>(null);
|
const [addItemInfoList, setAddItemInfoList] = useState<CustomerExamAddItem[] | null>(null);
|
||||||
const [progressList, setProgressList] = useState<PhysicalExamProgressItem[] | null>(null);
|
const [progressList, setProgressList] = useState<PhysicalExamProgressItem[] | null>(null);
|
||||||
|
const [signBusy, setSignBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const physical_exam_id = Number(client.id);
|
const physical_exam_id = Number(client.id);
|
||||||
@@ -88,16 +89,12 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
|||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Header 区域:增加了内边距 padding */}
|
|
||||||
<div className='px-8 pt-6 pb-2'>
|
<div className='px-8 pt-6 pb-2'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
{/* 左侧:姓名 + VIP + 体检号 */}
|
|
||||||
<div className='flex items-end gap-3'>
|
<div className='flex items-end gap-3'>
|
||||||
<span className='text-2xl font-bold text-gray-900 leading-none'>
|
<span className='text-2xl font-bold text-gray-900 leading-none'>
|
||||||
{client.name}
|
{client.name}
|
||||||
</span>
|
</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'>
|
<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
|
VIP
|
||||||
</span>
|
</span>
|
||||||
@@ -105,18 +102,16 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
|||||||
体检号:{client.id}
|
体检号:{client.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:仅保留关闭按钮 */}
|
|
||||||
<button
|
<button
|
||||||
className='text-gray-400 hover:text-gray-600 transition-colors text-sm'
|
className={`text-sm transition-colors ${signBusy ? 'text-gray-300 cursor-not-allowed' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
onClick={onClose}
|
onClick={!signBusy ? onClose : undefined}
|
||||||
|
disabled={signBusy}
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs 区域 */}
|
|
||||||
<div className='px-8 py-4 flex items-center justify-between'>
|
<div className='px-8 py-4 flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
{tabs.map((t) => {
|
{tabs.map((t) => {
|
||||||
@@ -126,30 +121,24 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
|||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
onClick={() => onTabChange(t.key)}
|
onClick={() => onTabChange(t.key)}
|
||||||
// 样式修改:rounded-full (完全圆角), border 颜色调整, 增加选中时的阴影
|
|
||||||
className={`px-5 py-2 rounded-2xl border text-sm transition-all duration-200 ${isActive
|
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'
|
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-200'
|
||||||
: isDone
|
: isDone
|
||||||
? 'bg-gray-50 text-gray-400 border-gray-100'
|
? 'bg-gray-50 text-gray-400 border-gray-100'
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
|
disabled={signBusy}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
{/* 这里保留了原有逻辑,但微调了样式 */}
|
|
||||||
{t.key === 'addon' && (client.addonCount || 0) > 0 && (
|
{t.key === 'addon' && (client.addonCount || 0) > 0 && (
|
||||||
<span className={`text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
|
<span className={`text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
|
||||||
({client.addonCount})
|
({client.addonCount})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{((t.key === 'sign' && signDone) || (t.key === 'print' && printDone)) && (
|
|
||||||
<span className=''></span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 按钮部分:将原本在 Tab 右侧的任何操作可以放这里,或者留空 */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='px-4 py-4 bg-gray-50/60'>
|
<div className='px-4 py-4 bg-gray-50/60'>
|
||||||
@@ -163,402 +152,13 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
|||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'sign' && <ExamSignPanel examId={Number(client.id)} />}
|
{tab === 'sign' && <ExamSignPanel examId={Number(client.id)} onBusyChange={setSignBusy} />}
|
||||||
{tab === 'addon' && <ExamAddonPanel client={client} />}
|
{tab === 'addon' && <ExamAddonPanel client={client} />}
|
||||||
{tab === 'print' && <ExamPrintPanel client={client} />}
|
{tab === 'print' && <ExamPrintPanel client={client} />}
|
||||||
{tab === 'delivery' && <ExamDeliveryPanel client={client} />}
|
{tab === 'delivery' && <ExamDeliveryPanel client={client} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ExamSignPanel = ({ examId }: { examId?: number }) => {
|
|
||||||
const [idCardFile, setIdCardFile] = useState<File | null>(null);
|
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
|
||||||
const [signLoading, setSignLoading] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [consentList, setConsentList] = useState<OutputTongyishuFileInfo[]>([]);
|
|
||||||
const [consentLoading, setConsentLoading] = useState(false);
|
|
||||||
const [consentMessage, setConsentMessage] = useState<string | null>(null);
|
|
||||||
const [previewPdf, setPreviewPdf] = useState<OutputTongyishuFileInfo | null>(null);
|
|
||||||
const [showSignature, setShowSignature] = useState(false);
|
|
||||||
const signaturePadRef = useRef<SignaturePadHandle | null>(null);
|
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
|
||||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
|
||||||
const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const SIGN_STORAGE_KEY = 'yh_signed_consents';
|
|
||||||
|
|
||||||
const handlePickFile = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const raw = localStorage.getItem(SIGN_STORAGE_KEY);
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
setSignedCombinationCodes(parsed.filter((x) => typeof x === 'number'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('签名记录解析失败', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const convertToJpg = async (file: File): Promise<File> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
reject(new Error('无法创建画布上下文'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) {
|
|
||||||
reject(new Error('图片转换失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const jpgFile = new File([blob], 'id_card.jpg', { type: 'image/jpeg' });
|
|
||||||
resolve(jpgFile);
|
|
||||||
},
|
|
||||||
'image/jpeg',
|
|
||||||
0.92 // 质量 92%
|
|
||||||
);
|
|
||||||
};
|
|
||||||
img.onerror = () => reject(new Error('图片加载失败'));
|
|
||||||
img.src = event.target?.result as string;
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 转换为 JPG 格式
|
|
||||||
const jpgFile = await convertToJpg(file);
|
|
||||||
|
|
||||||
// 保存文件用于签到
|
|
||||||
setIdCardFile(jpgFile);
|
|
||||||
|
|
||||||
// 生成预览图
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
setPreviewImage(event.target?.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(jpgFile);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('图片转换失败', err);
|
|
||||||
setMessage('图片处理失败,请重试');
|
|
||||||
}
|
|
||||||
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSign = async () => {
|
|
||||||
if (!idCardFile) {
|
|
||||||
setMessage('请先上传身份证照片');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSignLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const res = await signInMedicalExamCenter({ id_no_pic: idCardFile });
|
|
||||||
const ok = res.Status === 200 && res.Data?.is_success === 0;
|
|
||||||
setMessage(ok ? '签到成功' : res.Message || '签到失败');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setMessage('签到请求失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setSignLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitSign = async () => {
|
|
||||||
if (!examId || !previewPdf?.combination_code) {
|
|
||||||
setSubmitMessage('缺少必要信息,无法提交签名');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = signaturePadRef.current?.toDataURL('image/png');
|
|
||||||
if (!dataUrl) {
|
|
||||||
setSubmitMessage('请先完成签名');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitLoading(true);
|
|
||||||
setSubmitMessage(null);
|
|
||||||
try {
|
|
||||||
// 将 base64 转换为 Blob
|
|
||||||
const blob = await fetch(dataUrl).then(r => r.blob());
|
|
||||||
|
|
||||||
// 提交签名
|
|
||||||
const res = await submitTongyishuSign({
|
|
||||||
exam_id: examId,
|
|
||||||
combination_code: previewPdf.combination_code,
|
|
||||||
sign_file: blob,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.Status === 200) {
|
|
||||||
setSubmitMessage('签名提交成功');
|
|
||||||
// 记录已签名的组合代码
|
|
||||||
setSignedCombinationCodes((prev) => {
|
|
||||||
const code = Number(previewPdf.combination_code);
|
|
||||||
if (!Number.isFinite(code)) return prev || [];
|
|
||||||
const next = Array.from(new Set([...(prev || []), code]));
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(SIGN_STORAGE_KEY, JSON.stringify(next));
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// 2秒后关闭弹窗
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowSignature(false);
|
|
||||||
setPreviewPdf(null);
|
|
||||||
setSubmitMessage(null);
|
|
||||||
signaturePadRef.current?.clear();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
setSubmitMessage(res.Message || '签名提交失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('提交签名失败', err);
|
|
||||||
setSubmitMessage('签名提交失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setSubmitLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!examId) {
|
|
||||||
setConsentList([]);
|
|
||||||
setConsentMessage('缺少体检ID,无法获取知情同意书');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConsentLoading(true);
|
|
||||||
setConsentMessage(null);
|
|
||||||
getTongyishuPdf({ exam_id: examId })
|
|
||||||
.then((res) => {
|
|
||||||
const list = res.Data?.list_pdf_url || [];
|
|
||||||
setConsentList(list);
|
|
||||||
if (!list.length) {
|
|
||||||
setConsentMessage(res.Data?.message || '暂无知情同意书');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('获取知情同意书失败', err);
|
|
||||||
setConsentMessage('知情同意书加载失败,请稍后重试');
|
|
||||||
})
|
|
||||||
.finally(() => setConsentLoading(false));
|
|
||||||
}, [examId]);
|
|
||||||
|
|
||||||
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={signLoading}
|
|
||||||
>
|
|
||||||
{idCardFile ? '重新拍照' : '拍照'}
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept='image/*'
|
|
||||||
capture='environment'
|
|
||||||
className='hidden'
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
{previewImage && (
|
|
||||||
<div
|
|
||||||
className='w-16 h-16 rounded-lg border-2 border-gray-300 overflow-hidden cursor-pointer hover:border-blue-500 transition-colors'
|
|
||||||
onClick={() => setShowImagePreview(true)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={previewImage}
|
|
||||||
alt='身份证预览'
|
|
||||||
className='w-full h-full object-cover'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className='py-1.5 px-4 flex-1'
|
|
||||||
onClick={handleSign}
|
|
||||||
disabled={signLoading || !idCardFile}
|
|
||||||
>
|
|
||||||
{signLoading ? '签到中...' : '签到'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{message && (
|
|
||||||
<div className={`text-xs ${message.includes('成功') ? 'text-green-600' : 'text-amber-600'}`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showImagePreview && previewImage && (
|
|
||||||
<div
|
|
||||||
className='fixed inset-0 z-[80] bg-black/90 flex items-center justify-center p-6'
|
|
||||||
onClick={() => setShowImagePreview(false)}
|
|
||||||
>
|
|
||||||
<div className='relative max-w-full max-h-full'>
|
|
||||||
<img
|
|
||||||
src={previewImage}
|
|
||||||
alt='身份证预览'
|
|
||||||
className='max-w-full max-h-[90vh] object-contain'
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className='absolute top-4 right-4 bg-white/90 hover:bg-white'
|
|
||||||
onClick={() => setShowImagePreview(false)}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</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>
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
{consentLoading && <div className='text-xs text-gray-500'>加载中...</div>}
|
|
||||||
{!consentLoading && consentMessage && (
|
|
||||||
<div className='text-xs text-amber-600'>{consentMessage}</div>
|
|
||||||
)}
|
|
||||||
{!consentLoading && consentList.length > 0 && (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{consentList.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.pdf_url || item.pdf_name}
|
|
||||||
className='flex items-center justify-between gap-3 p-3 rounded-xl border bg-white shadow-sm'
|
|
||||||
>
|
|
||||||
<div className='text-sm text-gray-800 truncate flex items-center gap-2 relative pr-20'>
|
|
||||||
<span className='truncate'>{item.pdf_name}</span>
|
|
||||||
{item.combination_code !== undefined &&
|
|
||||||
signedCombinationCodes.includes(Number(item.combination_code)) && (
|
|
||||||
<img
|
|
||||||
src='/sign.png'
|
|
||||||
alt='已签名'
|
|
||||||
className='w-16 h-16 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none select-none'
|
|
||||||
loading='lazy'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button className='py-1.5 px-3' onClick={() => setPreviewPdf(item)}>
|
|
||||||
查看
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{previewPdf && (
|
|
||||||
<div className='fixed inset-0 z-[60] bg-black/75 flex flex-col'>
|
|
||||||
<div className='flex items-center justify-between p-4 text-white bg-gray-900/80'>
|
|
||||||
<div className='text-sm font-medium truncate pr-3'>{previewPdf.pdf_name}</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Button
|
|
||||||
className='py-1 px-3'
|
|
||||||
onClick={() => setShowSignature(true)}
|
|
||||||
>
|
|
||||||
签到
|
|
||||||
</Button>
|
|
||||||
<Button className='py-1 px-3' onClick={() => setPreviewPdf(null)}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 bg-gray-100'>
|
|
||||||
<iframe
|
|
||||||
src={previewPdf.pdf_url}
|
|
||||||
title={previewPdf.pdf_name}
|
|
||||||
className='w-full h-full'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showSignature && (
|
|
||||||
<div className='fixed inset-0 z-[70] bg-black/80 flex items-center justify-center px-6'>
|
|
||||||
<div className='bg-white rounded-2xl w-full max-w-3xl shadow-2xl p-4 flex flex-col gap-4'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='text-base font-semibold text-gray-900'>签署知情同意书</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Button className='py-1 px-3' onClick={() => setShowSignature(false)}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='ui7-signature-wrapper border rounded-xl overflow-hidden bg-gray-50'>
|
|
||||||
<SignaturePad
|
|
||||||
ref={signaturePadRef}
|
|
||||||
className='ui7-signature-canvas w-full h-72 bg-white touch-none'
|
|
||||||
/>
|
|
||||||
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t'>
|
|
||||||
<div className='text-xs text-gray-500'>请在上方区域签名完成签到</div>
|
|
||||||
<Button
|
|
||||||
className='ui7-clear-button py-1 px-3'
|
|
||||||
onClick={() => signaturePadRef.current?.clear()}
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{submitMessage && (
|
|
||||||
<div className={`text-sm text-center ${submitMessage.includes('成功') ? 'text-green-600' : 'text-amber-600'
|
|
||||||
}`}>
|
|
||||||
{submitMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='flex items-center justify-end gap-3'>
|
|
||||||
<Button
|
|
||||||
className='py-2 px-6'
|
|
||||||
onClick={() => {
|
|
||||||
setShowSignature(false);
|
|
||||||
setSubmitMessage(null);
|
|
||||||
signaturePadRef.current?.clear();
|
|
||||||
}}
|
|
||||||
disabled={submitLoading}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='py-2 px-6 bg-blue-600 text-white hover:bg-blue-700'
|
|
||||||
onClick={handleSubmitSign}
|
|
||||||
disabled={submitLoading}
|
|
||||||
>
|
|
||||||
{submitLoading ? '提交中...' : '提交签名'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { Button, SignaturePad } from '../ui';
|
|||||||
|
|
||||||
interface ExamSignPanelProps {
|
interface ExamSignPanelProps {
|
||||||
examId?: number;
|
examId?: number;
|
||||||
|
onBusyChange?: (busy: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
|
||||||
const [idCardFile, setIdCardFile] = useState<File | null>(null);
|
const [idCardFile, setIdCardFile] = useState<File | null>(null);
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
@@ -25,6 +26,12 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||||
const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]);
|
const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]);
|
||||||
|
const busy = signLoading || submitLoading || consentLoading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onBusyChange?.(busy);
|
||||||
|
return () => onBusyChange?.(false);
|
||||||
|
}, [busy, onBusyChange]);
|
||||||
|
|
||||||
const SIGN_STORAGE_KEY = 'yh_signed_consents';
|
const SIGN_STORAGE_KEY = 'yh_signed_consents';
|
||||||
|
|
||||||
@@ -205,7 +212,7 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
<div className='font-medium'>身份证拍照与签到</div>
|
<div className='font-medium'>身份证拍照与签到</div>
|
||||||
<div className='text-xs text-gray-500'>拍照身份证后点击签到按钮完成签到。</div>
|
<div className='text-xs text-gray-500'>拍照身份证后点击签到按钮完成签到。</div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<Button className='py-1.5 px-3' onClick={handlePickFile} disabled={signLoading}>
|
<Button className='py-1.5 px-3' onClick={handlePickFile} disabled={busy}>
|
||||||
{idCardFile ? '重新拍照' : '拍照'}
|
{idCardFile ? '重新拍照' : '拍照'}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -224,7 +231,7 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
<img src={previewImage} alt='身份证预览' className='w-full h-full object-cover' />
|
<img src={previewImage} alt='身份证预览' className='w-full h-full object-cover' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className='py-1.5 px-4 flex-1' onClick={handleSign} disabled={signLoading || !idCardFile}>
|
<Button className='py-1.5 px-4 flex-1' onClick={handleSign} disabled={busy || !idCardFile}>
|
||||||
{signLoading ? '签到中...' : '签到'}
|
{signLoading ? '签到中...' : '签到'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +273,7 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button className='py-1.5 px-3' onClick={() => setPreviewPdf(item)}>
|
<Button className='py-1.5 px-3' onClick={() => !busy && setPreviewPdf(item)} disabled={busy}>
|
||||||
查看
|
查看
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,10 +287,10 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
<div className='flex items-center justify-between p-4 text-white bg-gray-900/80'>
|
<div className='flex items-center justify-between p-4 text-white bg-gray-900/80'>
|
||||||
<div className='text-sm font-medium truncate pr-3'>{previewPdf.pdf_name}</div>
|
<div className='text-sm font-medium truncate pr-3'>{previewPdf.pdf_name}</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button className='py-1 px-3' onClick={() => setShowSignature(true)}>
|
<Button className='py-1 px-3' onClick={() => !busy && setShowSignature(true)} disabled={busy}>
|
||||||
签到
|
签到
|
||||||
</Button>
|
</Button>
|
||||||
<Button className='py-1 px-3' onClick={() => setPreviewPdf(null)}>
|
<Button className='py-1 px-3' onClick={() => !busy && setPreviewPdf(null)} disabled={busy}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +306,7 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
<div className='flex items-center justify之间'>
|
<div className='flex items-center justify之间'>
|
||||||
<div className='text-base font-semibold text-gray-900'>签署知情同意书</div>
|
<div className='text-base font-semibold text-gray-900'>签署知情同意书</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button className='py-1 px-3' onClick={() => setShowSignature(false)}>
|
<Button className='py-1 px-3' onClick={() => setShowSignature(false)} disabled={busy}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +318,7 @@ export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => {
|
|||||||
/>
|
/>
|
||||||
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t'>
|
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t'>
|
||||||
<div className='text-xs text-gray-500'>请在上方区域签名完成签到</div>
|
<div className='text-xs text-gray-500'>请在上方区域签名完成签到</div>
|
||||||
<Button className='ui7-clear-button py-1 px-3' onClick={() => signaturePadRef.current?.clear()}>
|
<Button className='ui7-clear-button py-1 px-3' onClick={() => signaturePadRef.current?.clear()} disabled={submitLoading}>
|
||||||
清除
|
清除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user