diff --git a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx index e99fa4b..89ca228 100644 --- a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx +++ b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx @@ -12,11 +12,11 @@ import { Notice } from "../../components"; * image, so the frontend draws the QR itself from qrUrl. On confirmation the backend * persists the token and hot-connects the adapter; the panel flags "已登录". * - * WeChat's bot binding QR has a server-side TTL of ~60s (verified), so the displayed - * QR is auto-refreshed every 45s to keep a fresh code on screen at all times. When the - * browser tab is in the background we do NOT refresh (avoids silently swapping the code) - * — we overlay "二维码已过期" instead, and re-mint a code on returning to the tab. The - * QR is only minted after the user clicks 扫码登录 (start), never on idle. */ + * WeChat's bot binding QR has a server-side TTL of ~60s (verified). We auto-refresh the + * displayed QR, but ONLY when it is both (a) older than 45s AND (b) has NOT been scanned + * yet. Once the QR has been scanned (status scaned / "已被扫描"), we never swap it — + * swapping mid-bind cancels the in-progress session and WeChat's confirm never lands. + * While the tab is in the background we never refresh and instead show a "过期" overlay. */ const QR_REFRESH_MS = 45_000; const STATUS_POLL_MS = 1_500; const SCAN_TIMEOUT_MS = 8 * 60 * 1000; @@ -42,6 +42,12 @@ function QrImage({ value }: { value: string }): React.ReactNode { /> ); } + +/** True when a weixin login-status response indicates the QR was already scanned. */ +function isScanned(res: { status: string; detail?: string }): boolean { + return res.status === "scaned" || (res.detail ?? "").includes("已被扫描"); +} + export function WeixinLogin({ platform, accountId, @@ -58,23 +64,22 @@ export function WeixinLogin({ const [notice, setNotice] = useState(null); const [hidden, setHidden] = useState(false); const pollRef = useRef | null>(null); - const refreshRef = useRef | null>(null); const statusRef = useRef("idle"); + const qrMintedRef = useRef(0); + const scannedRef = useRef(false); const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (refreshRef.current) { - clearInterval(refreshRef.current); - refreshRef.current = null; - } }; - const applyStatus = (r: { status: string; botId?: string; err?: string }) => { + const applyStatus = (r: { status: string; botId?: string; err?: string; detail?: string }) => { statusRef.current = r.status; setStatus(r.status); + // Once the QR is scanned, freeze it — never let a timed refresh swap it mid-bind. + if (isScanned(r)) scannedRef.current = true; if (r.status === "confirmed") { setBotId(r.botId ?? ""); stopPolling(); @@ -86,7 +91,7 @@ export function WeixinLogin({ }; const fetchStatus = () => { - adminWeixinLoginStatus(platform, accountId) + return adminWeixinLoginStatus(platform, accountId) .then(applyStatus) .catch((e: unknown) => { log("channel.weixin.login.status", "error"); @@ -94,12 +99,14 @@ export function WeixinLogin({ }); }; - // Mint a fresh QR so the on-screen code is never near its ~60s server-side TTL. - // Runs while waiting; cancelled together with the status poll on confirm/fail. + // Mint a fresh QR. Only called by the explicit refresh, by start(), or by the + // poller when the current code is older than 45s AND has not been scanned. const refreshQR = () => { adminWeixinLoginQR(platform, accountId) .then((r) => { log("channel.weixin.login.qr.refresh", "ok"); + qrMintedRef.current = Date.now(); + scannedRef.current = false; setQrCode(r.qrCode ?? ""); setQrUrl(r.qrUrl ?? ""); if (r.status === "confirmed") { @@ -115,30 +122,19 @@ export function WeixinLogin({ }); }; - // Track tab visibility: while hidden, do NOT swap the QR (show "过期" overlay instead); - // on returning, re-mint a fresh code if we were still waiting. + // Restore from background: do NOT re-mint. We only re-check state and let the + // periodic poller decide; a scanned-but-unconfirmed QR must not be swapped. useEffect(() => { - // Restoring the tab must NOT re-mint a QR: if the user has already scanned - // (but not confirmed) the on-screen code, swapping it mid-flow cancels the - // in-progress bind and WeChat's confirm never lands. Re-minting is only done - // by the periodic timer or the explicit 刷新二维码 button. - const onVis = () => { - setHidden(document.hidden); - }; - const onFocus = () => { - setHidden(false); - }; + const onVis = () => setHidden(document.hidden); + const onFocus = () => setHidden(false); document.addEventListener("visibilitychange", onVis); window.addEventListener("focus", onFocus); return () => { document.removeEventListener("visibilitychange", onVis); window.removeEventListener("focus", onFocus); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platform, accountId]); + }, []); - // Reflect the current bind state on mount (confirmed with no button, or idle - // ready to scan). A stale interval from a previous mount is cleaned up. useEffect(() => { adminWeixinLoginStatus(platform, accountId).then(applyStatus).catch(() => setStatus("idle")); return stopPolling; @@ -150,10 +146,12 @@ export function WeixinLogin({ setNotice(null); setQrCode(""); setQrUrl(""); - // 只在用户点击「扫码登录」后才 mint 二维码;idle 状态绝不自动生成。 + // Only mint a QR after the user clicks 扫码登录; idle never auto-generates. adminWeixinLoginQR(platform, accountId) .then((r) => { log("channel.weixin.login.qr", "ok"); + qrMintedRef.current = Date.now(); + scannedRef.current = false; setQrCode(r.qrCode ?? ""); setQrUrl(r.qrUrl ?? ""); if (r.status === "confirmed") { @@ -165,22 +163,31 @@ export function WeixinLogin({ } statusRef.current = "waiting"; setStatus("waiting"); - // Poll until confirmed / failed / timeout. const started = Date.now(); if (pollRef.current) clearInterval(pollRef.current); pollRef.current = setInterval(() => { - fetchStatus(); - if (Date.now() - started > SCAN_TIMEOUT_MS) { - stopPolling(); - setNotice("等待扫码超时,请重试。"); - } + const age = Date.now() - qrMintedRef.current; + adminWeixinLoginStatus(platform, accountId) + .then((res) => { + applyStatus(res); + // Only swap when stale AND not scanned; a scanned-but-unconfirmed QR + // must survive the 45s tick so WeChat's confirm can land. + if (statusRef.current === "waiting") { + if (isScanned(res)) return; + if (age >= QR_REFRESH_MS) { + refreshQR(); + } + } + if (Date.now() - started > SCAN_TIMEOUT_MS) { + stopPolling(); + setNotice("等待扫码超时,请重试。"); + } + }) + .catch((e: unknown) => { + log("channel.weixin.login.status", "error"); + setNotice(e instanceof Error ? e.message : "查询登录状态失败"); + }); }, STATUS_POLL_MS); - // Auto-refresh the QR before it hits the ~60s server-side TTL; skip while hidden. - if (refreshRef.current) clearInterval(refreshRef.current); - refreshRef.current = setInterval(() => { - if (document.hidden) return; - refreshQR(); - }, QR_REFRESH_MS); }) .catch((e: unknown) => { log("channel.weixin.login.qr", "error"); @@ -198,6 +205,7 @@ export function WeixinLogin({ 微信 iLink 扫码登录 owner 专属 · 已审计 {status === "confirmed" ? 已登录 : null} + {status === "waiting" && scannedRef.current ? 已扫码·待确认 : null} {status === "waiting" ? 等待扫码 : null} {botId ? bot_id:{botId} : null} @@ -241,7 +249,7 @@ export function WeixinLogin({ ) : null}

- 请用微信扫描上方二维码,二维码每 45 秒自动刷新(请在刷新后 45 秒内扫码确认),确认后自动连接。 + 请用微信扫描上方二维码,确认后自动连接。二维码仅在「生成超 45 秒且未被扫到」时自动更新,扫码后将固定不换。

) : null}