115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
|
import { useState, useCallback, useEffect, Suspense, lazy } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { QrCode, Loader2 } from "lucide-react";
|
|
import { scanProduct } from "../services/productApi";
|
|
import type { ProductScanResponse } from "../types/api";
|
|
import ManualInput from "../components/scan/ManualInput";
|
|
import QueryResult from "../components/scan/QueryResult";
|
|
|
|
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
|
|
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
|
|
|
|
export default function ScanPage() {
|
|
const [searchParams] = useSearchParams();
|
|
const [cameraActive, setCameraActive] = useState(false);
|
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
|
const [serialNumber, setSerialNumber] = useState("");
|
|
const [lastScanned, setLastScanned] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [product, setProduct] = useState<ProductScanResponse | null>(null);
|
|
|
|
/** 通用查询 */
|
|
const doQuery = useCallback(async (sn: string) => {
|
|
if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; }
|
|
setSerialNumber(sn);
|
|
setLastScanned(sn);
|
|
setLoading(true);
|
|
setError(null);
|
|
setProduct(null);
|
|
try {
|
|
setProduct(await scanProduct(sn));
|
|
} catch (err: unknown) {
|
|
const detail =
|
|
err && typeof err === "object" && "response" in err
|
|
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
|
: null;
|
|
setError(detail ?? "网络错误,请检查后端服务");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// URL 参数自动查询(支持从看板等外部跳转)
|
|
useEffect(() => {
|
|
const sn = searchParams.get("sn");
|
|
if (sn) doQuery(sn.trim());
|
|
}, [searchParams, doQuery]);
|
|
|
|
/** 扫码回调:提取纯产品身份证 */
|
|
const handleScan = useCallback(
|
|
(decodedText: string) => {
|
|
setCameraError(null);
|
|
doQuery(decodedText.trim().replace(/[^a-zA-Z0-9]/g, "").slice(0, 16));
|
|
},
|
|
[doQuery]
|
|
);
|
|
|
|
return (
|
|
<div className="flex min-h-full flex-col pb-20 pt-safe">
|
|
<div className="flex items-center gap-2 px-4 pt-2">
|
|
<QrCode className="h-5 w-5 text-blue-600" />
|
|
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
|
</div>
|
|
|
|
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
|
|
<div className="mt-3 px-4">
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex items-center justify-center rounded-xl border border-gray-200 bg-white py-16 shadow-sm">
|
|
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
|
</div>
|
|
}
|
|
>
|
|
<CameraScanner
|
|
active={cameraActive}
|
|
onToggle={setCameraActive}
|
|
onScan={handleScan}
|
|
error={cameraError}
|
|
onError={setCameraError}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
|
|
{/* 手动输入 */}
|
|
<div className="mt-3 px-4">
|
|
<ManualInput
|
|
value={serialNumber}
|
|
onChange={setSerialNumber}
|
|
onSearch={() => doQuery(serialNumber.trim())}
|
|
loading={loading}
|
|
lastScanned={lastScanned}
|
|
/>
|
|
</div>
|
|
|
|
{/* 宏观状态栏 */}
|
|
{product && (
|
|
<div className="mt-3 px-4">
|
|
<div className="flex items-center gap-2 rounded-lg bg-white px-4 py-2.5 shadow-sm">
|
|
<span className="text-xs text-gray-400">宏观状态</span>
|
|
<span className={`flex-1 text-sm font-bold ${product.overall_status ? "text-blue-600" : "text-red-500"}`}>
|
|
{product.overall_status || "未设定"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 查询结果 */}
|
|
<div className="mt-3 px-4">
|
|
<QueryResult loading={loading} error={error} product={product} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|