import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { AlertTriangle, ArrowLeft, Camera, CameraOff, ScanLine } from "lucide-react"; import jsQR from "jsqr"; import { useCallback, useEffect, useRef, useState } from "react"; import { AuthShell } from "../features/auth/AuthShell"; import { parseLinkApprovalUrl } from "../net/qr"; import { useAppStore } from "../store"; import { t } from "../i18n"; import { Button, Callout } from "../ui/primitives"; import s from "./link_.scan.module.css"; // 应用内扫码器:在已登录的 cdrop 里直接调相机扫码,解出 /link?r=&c= 后在同一已鉴权 // 会话内导航到批准页——绕开「系统相机打开链接落到未登录浏览器」的断流。批准走既有 // /link 页(step-up 开启时该页会自动要求再认证),本组件只负责「扫到合法码 → 导航」。 export const Route = createFileRoute("/link_/scan")({ component: LinkScanPage, // 守卫:受限访客(scope=guest)不能授权新设备(后端 approve 亦 403),故连扫码 // 授权页都不让进——直接弹回首页,避免误触。未登录交由批准页处理,这里只挡访客。 beforeLoad: () => { if (useAppStore.getState().sessionScope === "guest") { throw redirect({ to: "/" }); } }, }); // 扫码器的几种态:请求相机中 / 正在扫 / 命中(淡出过渡)/ 无摄像头 / 权限被拒 / 其他错误。 type ScanState = "starting" | "scanning" | "hit" | "noCamera" | "denied" | "error"; function LinkScanPage() { const navigate = useNavigate(); const videoRef = useRef(null); // 解码用的离屏 canvas,逐帧把视频画上去再喂给 jsQR。 const canvasRef = useRef(null); const streamRef = useRef(null); const rafRef = useRef(null); // 命中后置位:阻止 rAF 循环继续解码 / 重复导航。 const doneRef = useRef(false); const [ state, setState ] = useState("starting"); const [ errorMsg, setErrorMsg ] = useState(null); // 扫到无关 / 非本站二维码:提示「继续对准」,但相机继续扫,不打断。 const [ sawForeignCode, setSawForeignCode ] = useState(false); // stop 释放相机:停轨道 + 取消 rAF。卸载、命中、出错时都调用,确保相机指示灯熄灭。 const stop = useCallback(() => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const stream = streamRef.current; if (stream) { stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; } }, []); // tick:单帧解码循环。把当前视频帧画到离屏 canvas,jsQR 解一次;解出有效本站 // /link 码即停机 + 应用内导航,否则下一帧继续。 const tick = useCallback(() => { if (doneRef.current) { return; } const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA) { rafRef.current = requestAnimationFrame(tick); return; } const w = video.videoWidth; const h = video.videoHeight; if (!w || !h) { rafRef.current = requestAnimationFrame(tick); return; } canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; } ctx.drawImage(video, 0, 0, w, h); const image = ctx.getImageData(0, 0, w, h); const result = jsQR(image.data, w, h, { inversionAttempts: "dontInvert" }); if (result && result.data) { const link = parseLinkApprovalUrl(result.data); if (link) { // 命中合法本站码:停机、淡出,应用内导航到批准页(留在已登录 SPA 会话)。 doneRef.current = true; stop(); setState("hit"); // p 不带:批准页会回落到更安全的默认「仅此次」(受限访客)。 navigate({ to: "/link", search: { r: link.requestId, c: link.code, p: undefined } }); return; } // 扫到了码,但不是本站 /link 码(别的二维码 / 钓鱼链接):提示继续对准。 setSawForeignCode(true); } rafRef.current = requestAnimationFrame(tick); }, [ navigate, stop ]); // 进页面即请求相机并起解码循环。无摄像头 / 权限被拒分别落到清晰的引导态。 useEffect(() => { doneRef.current = false; let cancelled = false; const start = async () => { if (!navigator.mediaDevices?.getUserMedia) { setState("noCamera"); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ // 优先后置相机(手机扫码自然朝外);桌面无后置则退到任意可用相机。 video: { facingMode: { ideal: "environment" } }, audio: false, }); if (cancelled) { stream.getTracks().forEach((tr) => tr.stop()); return; } streamRef.current = stream; const video = videoRef.current; if (video) { video.srcObject = stream; // iOS Safari 要求 playsInline + 显式 play(),否则不出画面。 await video.play().catch(() => { /* 自动播放被拦:画面仍随轨道更新 */ }); } setState("scanning"); rafRef.current = requestAnimationFrame(tick); } catch (e) { if (cancelled) { return; } const name = e instanceof DOMException ? e.name : ""; if (name === "NotAllowedError" || name === "SecurityError") { setState("denied"); } else if (name === "NotFoundError" || name === "OverconstrainedError") { setState("noCamera"); } else { setErrorMsg(e instanceof Error ? e.message : String(e)); setState("error"); } } }; void start(); return () => { cancelled = true; stop(); }; }, [ tick, stop ]); const goBack = () => navigate({ to: "/" }); // ---- 渲染分支 -------------------------------------------------------- // 无摄像头(桌面常见):优雅降级,引导改用手机扫,不报错。 if (state === "noCamera") { return ( }> {t("qr.scan.noCamera")} ); } // 权限被拒:明确告诉用户去哪儿改回来。 if (state === "denied") { return ( }> {t("qr.scan.denied")} ); } if (state === "error") { return ( }> {errorMsg ?? t("qr.scan.error")} ); } return (
{state === "starting" ? <>{t("qr.scan.starting")} : <>{t("qr.scan.aiming")}}
{/* 扫到非本站码:安静提示继续对准,相机不中断。 */} {sawForeignCode && state === "scanning" && ( }> {t("qr.scan.foreign")} )}
  1. {t("qr.scan.howto1")}
  2. {t("qr.scan.howto2")}
); }