512 lines
20 KiB
TypeScript
512 lines
20 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
||
import { useNavigate } from "react-router";
|
||
import { motion, AnimatePresence } from "motion/react";
|
||
import {
|
||
Camera,
|
||
X,
|
||
Check,
|
||
RotateCw,
|
||
Zap,
|
||
Image as ImageIcon,
|
||
AlertCircle,
|
||
Loader2,
|
||
Plus,
|
||
ChevronRight,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { Button } from "../components/ui/button";
|
||
import {
|
||
uploadReportImage,
|
||
addImageToReport,
|
||
type UploadReportResponse,
|
||
type OcrDataItem,
|
||
} from "../services/api";
|
||
|
||
/** 压缩图片:限制最大宽度 1200px,JPEG 质量 0.7 */
|
||
function compressImage(file: File, maxWidth = 1200, quality = 0.7): Promise<File> {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
let { width, height } = img;
|
||
if (width > maxWidth) {
|
||
height = Math.round((height * maxWidth) / width);
|
||
width = maxWidth;
|
||
}
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
resolve(blob ? new File([blob], "report.jpg", { type: "image/jpeg" }) : file);
|
||
},
|
||
"image/jpeg",
|
||
quality
|
||
);
|
||
};
|
||
img.src = URL.createObjectURL(file);
|
||
});
|
||
}
|
||
|
||
interface ImageEntry {
|
||
id: string;
|
||
preview: string; // data URL for thumbnail
|
||
status: "uploading" | "done" | "error";
|
||
error?: string;
|
||
ocrItems: OcrDataItem[];
|
||
reportType: string;
|
||
}
|
||
|
||
export function CameraCapturePage() {
|
||
const navigate = useNavigate();
|
||
|
||
// Multi-image state
|
||
const [images, setImages] = useState<ImageEntry[]>([]);
|
||
const [reportId, setReportId] = useState<string | null>(null);
|
||
const [notPregnancyTip, setNotPregnancyTip] = useState<string | null>(null);
|
||
|
||
// Camera state
|
||
const [isCapturing, setIsCapturing] = useState(true); // show camera view
|
||
const [showTips, setShowTips] = useState(true);
|
||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||
const [cameraReady, setCameraReady] = useState(false);
|
||
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const streamRef = useRef<MediaStream | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 启动摄像头
|
||
const startCamera = useCallback(async () => {
|
||
try {
|
||
setCameraError(null);
|
||
if (!window.isSecureContext) {
|
||
setCameraError("摄像头需要 HTTPS 安全连接,请使用「从相册选择」");
|
||
return;
|
||
}
|
||
if (!navigator.mediaDevices?.getUserMedia) {
|
||
setCameraError("当前浏览器不支持摄像头,请使用相册上传");
|
||
return;
|
||
}
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||
audio: false,
|
||
});
|
||
streamRef.current = stream;
|
||
if (videoRef.current) {
|
||
videoRef.current.srcObject = stream;
|
||
videoRef.current.onloadedmetadata = () => {
|
||
videoRef.current?.play();
|
||
setCameraReady(true);
|
||
};
|
||
}
|
||
} catch {
|
||
setCameraError("无法访问摄像头,请检查权限设置或使用相册上传");
|
||
}
|
||
}, []);
|
||
|
||
const stopCamera = useCallback(() => {
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||
streamRef.current = null;
|
||
}
|
||
setCameraReady(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isCapturing) startCamera();
|
||
return () => stopCamera();
|
||
}, [isCapturing, startCamera, stopCamera]);
|
||
|
||
// 上传单张图片(自动判断是首张还是追加)
|
||
const uploadImage = useCallback(
|
||
async (file: File, preview: string) => {
|
||
const entryId = Date.now().toString();
|
||
const entry: ImageEntry = {
|
||
id: entryId,
|
||
preview,
|
||
status: "uploading",
|
||
ocrItems: [],
|
||
reportType: "",
|
||
};
|
||
setImages((prev) => [...prev, entry]);
|
||
|
||
try {
|
||
const compressed = await compressImage(file);
|
||
let ocrItems: OcrDataItem[] = [];
|
||
let reportType = "";
|
||
|
||
if (!reportId) {
|
||
// First image — create report
|
||
const result: UploadReportResponse = await uploadReportImage(compressed);
|
||
setReportId(result.report_id);
|
||
ocrItems = result.ocr_result.data_items;
|
||
reportType = result.ocr_result.report_type;
|
||
} else {
|
||
// Additional image — add to existing report
|
||
const result = await addImageToReport(reportId, compressed);
|
||
ocrItems = result.ocr_result.data_items;
|
||
reportType = result.ocr_result.report_type;
|
||
}
|
||
|
||
setImages((prev) =>
|
||
prev.map((img) =>
|
||
img.id === entryId
|
||
? { ...img, status: "done", ocrItems, reportType }
|
||
: img
|
||
)
|
||
);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : "识别失败";
|
||
// 非孕期报告提示
|
||
if (msg.includes("请上传孕期检查报告") || msg.includes("请上传孕期")) {
|
||
setNotPregnancyTip(msg);
|
||
// 移除该图片
|
||
setImages((prev) => prev.filter((img) => img.id !== entryId));
|
||
return;
|
||
}
|
||
const friendlyMsg = /load failed|failed to fetch|networkerror/i.test(msg)
|
||
? "网络连接失败"
|
||
: msg.length > 100
|
||
? "AI 识别服务暂时不可用"
|
||
: msg;
|
||
setImages((prev) =>
|
||
prev.map((img) =>
|
||
img.id === entryId ? { ...img, status: "error", error: friendlyMsg } : img
|
||
)
|
||
);
|
||
}
|
||
},
|
||
[reportId]
|
||
);
|
||
|
||
// 拍照
|
||
const handleCapture = () => {
|
||
if (!videoRef.current || !canvasRef.current) return;
|
||
const video = videoRef.current;
|
||
const canvas = canvasRef.current;
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.drawImage(video, 0, 0);
|
||
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
|
||
setShowTips(false);
|
||
canvas.toBlob((blob) => {
|
||
if (blob) {
|
||
const file = new File([blob], "report.jpg", { type: "image/jpeg" });
|
||
uploadImage(file, dataUrl);
|
||
}
|
||
}, "image/jpeg", 0.9);
|
||
};
|
||
|
||
// 相册选择(支持多选)
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files?.length) return;
|
||
setShowTips(false);
|
||
Array.from(files).forEach((file) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
const preview = ev.target?.result as string;
|
||
uploadImage(file, preview);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
e.target.value = "";
|
||
};
|
||
|
||
// 删除某张图片(仅从前端列表移除,不影响已上传的数据)
|
||
const removeImage = (id: string) => {
|
||
setImages((prev) => prev.filter((img) => img.id !== id));
|
||
};
|
||
|
||
// 统计
|
||
const doneCount = images.filter((img) => img.status === "done").length;
|
||
const uploadingCount = images.filter((img) => img.status === "uploading").length;
|
||
const totalOcrItems = images.reduce((sum, img) => sum + img.ocrItems.length, 0);
|
||
const allDone = images.length > 0 && uploadingCount === 0;
|
||
|
||
// 进入校验页面
|
||
const handleGoToVerify = () => {
|
||
if (!reportId || !allDone) return;
|
||
// Merge all OCR items for navigation state
|
||
const mergedItems = images.flatMap((img) => img.ocrItems);
|
||
const reportTypes = [...new Set(images.map((img) => img.reportType).filter(Boolean))];
|
||
navigate("/verify", {
|
||
state: {
|
||
reportId,
|
||
ocrResult: {
|
||
report_type: reportTypes.join("+"),
|
||
data_items: mergedItems,
|
||
patient_name: "",
|
||
},
|
||
imageUrls: images.map((img) => img.preview),
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleAlbumClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-dvh bg-black relative overflow-hidden">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
<canvas ref={canvasRef} className="hidden" />
|
||
|
||
{/* 顶部导航栏 */}
|
||
<div className="absolute top-0 left-0 right-0 z-20 bg-gradient-to-b from-black/50 to-transparent">
|
||
<div className="flex items-center justify-between p-4">
|
||
<button
|
||
onClick={() => { stopCamera(); navigate(-1); }}
|
||
className="p-2 rounded-full bg-black/30 backdrop-blur-sm"
|
||
>
|
||
<X className="w-6 h-6 text-white" />
|
||
</button>
|
||
<h1 className="text-white font-medium">拍摄检查报告</h1>
|
||
<div className="w-10" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 相机预览区域 */}
|
||
<div className="relative w-full h-screen flex items-center justify-center">
|
||
<video
|
||
ref={videoRef}
|
||
autoPlay playsInline muted
|
||
className={`w-full h-full object-cover ${cameraReady ? "block" : "hidden"}`}
|
||
/>
|
||
{!cameraReady && (
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||
className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||
<div className="text-center px-8">
|
||
{cameraError ? (
|
||
<>
|
||
<AlertCircle className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
||
<p className="text-gray-400 text-sm mb-4">{cameraError}</p>
|
||
<Button onClick={handleAlbumClick} className="bg-morandi-400 hover:bg-morandi-500 text-white">
|
||
<ImageIcon className="w-4 h-4 mr-2" />从相册选择
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 2, repeat: Infinity, ease: "linear" }}>
|
||
<Camera className="w-12 h-12 text-gray-500 mx-auto" />
|
||
</motion.div>
|
||
<p className="text-gray-400 mt-4">正在启动摄像头...</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 取景框辅助线 */}
|
||
{cameraReady && (
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||
className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<div className="relative w-11/12 max-w-md aspect-[3/4] border-2 border-white/50 rounded-2xl">
|
||
<div className="absolute top-0 left-0 w-8 h-8 border-l-4 border-t-4 border-morandi-500 rounded-tl-2xl" />
|
||
<div className="absolute top-0 right-0 w-8 h-8 border-r-4 border-t-4 border-morandi-500 rounded-tr-2xl" />
|
||
<div className="absolute bottom-0 left-0 w-8 h-8 border-l-4 border-b-4 border-morandi-500 rounded-bl-2xl" />
|
||
<div className="absolute bottom-0 right-0 w-8 h-8 border-r-4 border-b-4 border-morandi-500 rounded-br-2xl" />
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 拍摄提示 */}
|
||
<AnimatePresence>
|
||
{showTips && cameraReady && images.length === 0 && (
|
||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
|
||
className="absolute top-24 left-0 right-0 z-10">
|
||
<div className="mx-auto max-w-sm px-4">
|
||
<div className="bg-morandi-400 text-white rounded-2xl p-4 shadow-sm">
|
||
<div className="flex items-start gap-3">
|
||
<Zap className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||
<div className="flex-1">
|
||
<h3 className="font-semibold mb-1">支持多张报告</h3>
|
||
<ul className="text-sm space-y-1 opacity-90">
|
||
<li>- 可拍摄多张报告,AI 合并解读</li>
|
||
<li>- 确保光线充足,避免反光</li>
|
||
<li>- 将报告完整放入取景框内</li>
|
||
</ul>
|
||
</div>
|
||
<button onClick={() => setShowTips(false)} className="p-1 hover:bg-white/20 rounded-lg">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* 已添加图片缩略图条 */}
|
||
{images.length > 0 && (
|
||
<div className="absolute top-20 left-0 right-0 z-10 px-4">
|
||
<div className="mx-auto max-w-lg">
|
||
<div className="bg-black/60 backdrop-blur-sm rounded-2xl p-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-white text-sm font-medium">
|
||
已添加 {images.length} 张报告
|
||
{totalOcrItems > 0 && (
|
||
<span className="text-white/70 ml-1">· {totalOcrItems} 项指标</span>
|
||
)}
|
||
</span>
|
||
{uploadingCount > 0 && (
|
||
<span className="text-amber-300 text-xs flex items-center">
|
||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||
识别中 {uploadingCount} 张
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||
{images.map((img) => (
|
||
<div key={img.id} className="relative flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden">
|
||
<img src={img.preview} alt="" className="w-full h-full object-cover" />
|
||
{/* Status overlay */}
|
||
{img.status === "uploading" && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||
<Loader2 className="w-5 h-5 text-white animate-spin" />
|
||
</div>
|
||
)}
|
||
{img.status === "done" && (
|
||
<div className="absolute top-1 right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
|
||
<Check className="w-3 h-3 text-white" />
|
||
</div>
|
||
)}
|
||
{img.status === "error" && (
|
||
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||
<AlertCircle className="w-5 h-5 text-white" />
|
||
</div>
|
||
)}
|
||
{/* Remove button */}
|
||
<button
|
||
onClick={() => removeImage(img.id)}
|
||
className="absolute top-1 left-1 w-5 h-5 bg-black/60 rounded-full flex items-center justify-center"
|
||
>
|
||
<X className="w-3 h-3 text-white" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 底部操作区 */}
|
||
<div className="absolute bottom-0 left-0 right-0 z-20 bg-gradient-to-t from-black/80 to-transparent pb-8 pt-12">
|
||
<div className="mx-auto px-4">
|
||
{/* 主操作按钮行 */}
|
||
<div className="flex items-center justify-center gap-8">
|
||
{/* 相册 */}
|
||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-center">
|
||
<Button onClick={handleAlbumClick} variant="outline" size="lg"
|
||
className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm border-2 border-white/50 hover:bg-white/30">
|
||
<ImageIcon className="w-6 h-6 text-white" />
|
||
</Button>
|
||
<p className="text-white text-xs text-center mt-2">相册</p>
|
||
</motion.div>
|
||
|
||
{/* 拍摄 */}
|
||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.1 }} className="text-center">
|
||
<Button onClick={handleCapture} disabled={!cameraReady} size="lg"
|
||
className="w-20 h-20 rounded-full bg-white hover:bg-gray-100 shadow-sm shadow-white/50 border-4 border-gray-800 disabled:opacity-50">
|
||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-morandi-400 to-morandi-500" />
|
||
</Button>
|
||
<p className="text-white text-sm text-center mt-2 font-medium">拍摄</p>
|
||
</motion.div>
|
||
|
||
{/* 开始解读 / 继续添加状态 */}
|
||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.2 }} className="text-center">
|
||
{images.length > 0 ? (
|
||
<>
|
||
<Button
|
||
onClick={handleGoToVerify}
|
||
disabled={!allDone || doneCount === 0}
|
||
size="lg"
|
||
className="w-16 h-16 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 shadow-sm disabled:opacity-50"
|
||
>
|
||
<ChevronRight className="w-7 h-7 text-white" />
|
||
</Button>
|
||
<p className="text-white text-xs text-center mt-2">
|
||
{uploadingCount > 0 ? "识别中..." : "开始解读"}
|
||
</p>
|
||
</>
|
||
) : (
|
||
<div className="w-16" />
|
||
)}
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 隐私保护提示 */}
|
||
{images.length === 0 && !notPregnancyTip && (
|
||
<div className="absolute bottom-32 left-0 right-0 z-10">
|
||
<div className="mx-auto max-w-sm px-4">
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }}
|
||
className="bg-black/50 backdrop-blur-sm text-white rounded-xl p-3 flex items-center gap-2">
|
||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||
<p className="text-xs opacity-90">您的隐私受到保护,个人信息将自动脱敏处理</p>
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 非孕期报告提示弹窗 */}
|
||
<AnimatePresence>
|
||
{notPregnancyTip && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-8"
|
||
onClick={() => setNotPregnancyTip(null)}
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.85, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
exit={{ scale: 0.85, opacity: 0 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="bg-white rounded-2xl p-6 w-full max-w-sm shadow-xl"
|
||
>
|
||
<div className="flex flex-col items-center gap-3 mb-5">
|
||
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
|
||
<AlertCircle className="w-7 h-7 text-amber-500" />
|
||
</div>
|
||
<h3 className="font-semibold text-gray-800 text-lg">非孕期报告</h3>
|
||
<p className="text-sm text-gray-500 text-center leading-relaxed">
|
||
{notPregnancyTip}
|
||
</p>
|
||
</div>
|
||
<div className="bg-amber-50 rounded-xl p-3 mb-5">
|
||
<p className="text-xs text-gray-600 leading-relaxed">
|
||
💡 支持的报告类型:B超/彩超、血常规、尿常规、唐氏筛查、糖耐量、肝肾功能、TORCH、甲功等孕期产检报告
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={() => setNotPregnancyTip(null)}
|
||
className="w-full h-11 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white font-medium"
|
||
>
|
||
我知道了,重新上传
|
||
</Button>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|