feat(P21PWA1-4): 移动扫码页面与盘点路由导航联动
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户