feat(P21PWA1-4): 移动扫码页面与盘点路由导航联动

这个提交包含在:
2026-03-11 20:52:11 +08:00
父节点 efd798c192
当前提交 f6de5443c4
共修改 8 个文件,包含 977 行新增和 5 行删除
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
type BarcodeScannerPanelProps = {
disabled?: boolean;
onDetected: (rawValue: string) => void;
};
type DetectorResult = {
rawValue?: string;
};
type BarcodeDetectorLike = {
detect: (source: HTMLVideoElement) => Promise<DetectorResult[]>;
};
type BarcodeDetectorConstructorLike = new (options?: { formats?: string[] }) => BarcodeDetectorLike;
type BrowserWithBarcodeDetector = Window & {
BarcodeDetector?: BarcodeDetectorConstructorLike;
};
const SCAN_FORMATS = ["qr_code", "code_128", "code_39", "ean_13", "ean_8", "upc_a", "upc_e"];
function resolveErrorMessage(error: unknown) {
if (error instanceof Error && error.message) {
return error.message;
}
return "无法启动相机扫码,请改用手工输入。";
}
export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeScannerPanelProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const pollTimerRef = useRef<number | null>(null);
const detectingRef = useRef(false);
const [engineLabel, setEngineLabel] = useState("");
const [error, setError] = useState("");
const [isStarting, setIsStarting] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const stopScanner = useCallback(() => {
if (pollTimerRef.current) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
cleanupRef.current?.();
cleanupRef.current = null;
if (mediaStreamRef.current) {
for (const track of mediaStreamRef.current.getTracks()) {
track.stop();
}
mediaStreamRef.current = null;
}
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
}
detectingRef.current = false;
setIsStarting(false);
setIsScanning(false);
setEngineLabel("");
}, []);
useEffect(() => stopScanner, [stopScanner]);
const handleDetected = useCallback(
(value: string) => {
const normalized = value.trim();
if (!normalized) {
return;
}
onDetected(normalized);
stopScanner();
},
[onDetected, stopScanner],
);
const startWithBarcodeDetector = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("当前浏览器不支持相机访问");
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: "environment" },
},
audio: false,
});
const videoElement = videoRef.current;
if (!videoElement) {
for (const track of stream.getTracks()) {
track.stop();
}
throw new Error("视频预览不可用");
}
mediaStreamRef.current = stream;
videoElement.srcObject = stream;
videoElement.muted = true;
videoElement.playsInline = true;
await videoElement.play();
const BarcodeDetector = (window as BrowserWithBarcodeDetector).BarcodeDetector;
if (!BarcodeDetector) {
throw new Error("BarcodeDetector 不可用");
}
const detector = new BarcodeDetector({ formats: SCAN_FORMATS });
setEngineLabel("BarcodeDetector");
setIsScanning(true);
pollTimerRef.current = window.setInterval(async () => {
if (detectingRef.current || !videoRef.current) {
return;
}
detectingRef.current = true;
try {
const results = await detector.detect(videoRef.current);
const matched = results.find((item) => item.rawValue?.trim());
if (matched?.rawValue) {
handleDetected(matched.rawValue);
}
} catch {
} finally {
detectingRef.current = false;
}
}, 600);
}, [handleDetected]);
const startWithZxing = useCallback(async () => {
const videoElement = videoRef.current;
if (!videoElement) {
throw new Error("视频预览不可用");
}
const { BrowserMultiFormatReader } = await import("@zxing/browser");
const reader = new BrowserMultiFormatReader();
const controls = await reader.decodeFromVideoDevice(undefined, videoElement, (result) => {
if (result) {
handleDetected(result.getText());
}
});
cleanupRef.current = () => {
controls.stop();
};
setEngineLabel("ZXing");
setIsScanning(true);
}, [handleDetected]);
const startScanner = useCallback(async () => {
setError("");
setIsStarting(true);
const BarcodeDetector = (window as BrowserWithBarcodeDetector).BarcodeDetector;
if (BarcodeDetector) {
try {
await startWithBarcodeDetector();
setIsStarting(false);
return;
} catch (detectorError) {
stopScanner();
setError(`BarcodeDetector 启动失败,已回退到 ZXing:${resolveErrorMessage(detectorError)}`);
}
}
try {
await startWithZxing();
} catch (fallbackError) {
stopScanner();
setError(resolveErrorMessage(fallbackError));
}
setIsStarting(false);
}, [startWithBarcodeDetector, startWithZxing, stopScanner]);
return (
<div className="space-y-3 rounded-xl border border-zinc-200 p-4" data-testid="barcode-scanner-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">相机扫码</h3>
<p className="text-sm text-zinc-500">优先使用 BarcodeDetector,回退到 ZXing。</p>
</div>
<div className="text-xs text-zinc-500">{isScanning ? `当前引擎:${engineLabel}` : "未启动"}</div>
</div>
{error ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">{error}</p>
) : null}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-black">
<video ref={videoRef} autoPlay muted playsInline className="aspect-video w-full object-cover" />
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" disabled={disabled || isStarting || isScanning} onClick={() => void startScanner()}>
{isStarting ? "启动中..." : isScanning ? "扫码中..." : "开始扫码"}
</Button>
<Button type="button" variant="secondary" disabled={!isScanning && !isStarting} onClick={stopScanner}>
停止扫码
</Button>
</div>
</div>
);
}