164 lines
4.7 KiB
TypeScript
164 lines
4.7 KiB
TypeScript
"use client";
|
||
|
||
import QrScanner from "qr-scanner";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
startTransition,
|
||
useEffect,
|
||
useEffectEvent,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
|
||
function extractDeviceUuid(rawValue: string) {
|
||
const trimmedValue = rawValue.trim();
|
||
|
||
if (!trimmedValue) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const parsedUrl = new URL(trimmedValue);
|
||
return (
|
||
parsedUrl.searchParams.get("deviceUuid") ||
|
||
parsedUrl.searchParams.get("device") ||
|
||
null
|
||
);
|
||
} catch {
|
||
return trimmedValue;
|
||
}
|
||
}
|
||
|
||
export function ScanClient() {
|
||
const router = useRouter();
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const scannerRef = useRef<QrScanner | null>(null);
|
||
const [statusText, setStatusText] = useState("正在准备镜头……");
|
||
const [errorText, setErrorText] = useState<string | null>(null);
|
||
|
||
const handleDecode = useEffectEvent(async (result: { data: string }) => {
|
||
const scannedValue = extractDeviceUuid(result.data);
|
||
|
||
if (!scannerRef.current || !scannedValue) {
|
||
return;
|
||
}
|
||
|
||
scannerRef.current.stop();
|
||
setErrorText(null);
|
||
setStatusText("识别到了设备,正在连接……");
|
||
|
||
try {
|
||
const response = await fetch("/api/family/bind", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ deviceUuid: scannedValue }),
|
||
});
|
||
|
||
const payload = (await response.json().catch(() => null)) as
|
||
| { error?: string; deviceUuid?: string }
|
||
| null;
|
||
const deviceUuid = payload?.deviceUuid;
|
||
|
||
if (!response.ok || !deviceUuid) {
|
||
throw new Error(payload?.error || "连接失败,请重新扫描。");
|
||
}
|
||
|
||
startTransition(() => {
|
||
router.replace(
|
||
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
|
||
);
|
||
});
|
||
} catch (error) {
|
||
setErrorText(
|
||
error instanceof Error ? error.message : "连接失败,请重新扫描。",
|
||
);
|
||
setStatusText("请再次将二维码对准镜头。");
|
||
await scannerRef.current.start().catch(() => null);
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
async function startScanner() {
|
||
if (!videoRef.current) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const hasCamera = await QrScanner.hasCamera();
|
||
|
||
if (!hasCamera) {
|
||
setErrorText("当前设备没有可用摄像头,请改用手动粘贴设备码。");
|
||
setStatusText("无法打开镜头");
|
||
return;
|
||
}
|
||
|
||
const scanner = new QrScanner(
|
||
videoRef.current,
|
||
(result) => {
|
||
void handleDecode(result as { data: string });
|
||
},
|
||
{
|
||
preferredCamera: "environment",
|
||
highlightCodeOutline: true,
|
||
highlightScanRegion: true,
|
||
returnDetailedScanResult: true,
|
||
onDecodeError: () => {},
|
||
},
|
||
);
|
||
|
||
scannerRef.current = scanner;
|
||
await scanner.start();
|
||
|
||
if (!cancelled) {
|
||
setStatusText("请将长辈手机上的二维码放到取景框内。");
|
||
}
|
||
} catch (error) {
|
||
setErrorText(
|
||
error instanceof Error
|
||
? error.message
|
||
: "镜头启动失败,请检查权限后重试。",
|
||
);
|
||
setStatusText("镜头启动失败");
|
||
}
|
||
}
|
||
|
||
void startScanner();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
scannerRef.current?.destroy();
|
||
scannerRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div className="rounded-[32px] border border-white/70 bg-white/82 p-5 shadow-[0_30px_70px_rgba(115,72,44,0.14)] backdrop-blur">
|
||
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
|
||
<video
|
||
ref={videoRef}
|
||
muted
|
||
playsInline
|
||
className="aspect-[3/4] w-full object-cover"
|
||
/>
|
||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" />
|
||
<div className="pointer-events-none absolute inset-x-[16%] top-[20%] aspect-square rounded-[28px] border-2 border-white/85 shadow-[0_0_0_999px_rgba(0,0,0,0.18)]" />
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-2">
|
||
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
|
||
<p className="text-sm leading-7 text-[var(--muted)]">
|
||
扫描成功后会自动连接长辈的设备,无需额外操作。
|
||
</p>
|
||
{errorText ? (
|
||
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
|
||
{errorText}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
} |