Files
app_03_pregnancy/frontend/src/app/pages/CameraCapturePage.tsx
Karlcarlo 409dabafb5 feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
2026-05-21 00:46:48 +08:00

512 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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>
);
}