2026-04-17 12:38:59 +08:00

164 lines
4.7 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.

"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>
);
}