From d970884ff801f70b91de96ea15ef094174eda936 Mon Sep 17 00:00:00 2001 From: Aska Date: Tue, 8 Sep 2026 00:59:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=BE=AE=E4=BF=A1=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=AF=8F45s=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=B7=E6=96=B0+=E5=90=8E=E5=8F=B0=E5=9E=AB'?= =?UTF-8?q?=E8=BF=87=E6=9C=9F'=E5=8F=A0=E5=8A=A0=E5=B1=82=E4=B8=8D?= =?UTF-8?q?=E6=8D=A2=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/modules/channels/WeixinLogin.tsx | 89 +++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx index a294416..8e2a1f0 100644 --- a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx +++ b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx @@ -10,7 +10,17 @@ import { Notice } from "../../components"; * scannable QR from the WeChat QR URL (qrcode_img_content), and polls GET /login-status * until confirmation. The liteapp URL is an HTML page that cannot be fetched as an * 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 "已登录". */ + * 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. */ +const QR_REFRESH_MS = 45_000; +const STATUS_POLL_MS = 1_500; +const SCAN_TIMEOUT_MS = 8 * 60 * 1000; + function QrImage({ value }: { value: string }): React.ReactNode { const [src, setSrc] = useState(""); useEffect(() => { @@ -46,16 +56,24 @@ export function WeixinLogin({ const [botId, setBotId] = useState(""); const [busy, setBusy] = useState(null); 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 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 }) => { + statusRef.current = r.status; setStatus(r.status); if (r.status === "confirmed") { setBotId(r.botId ?? ""); @@ -76,6 +94,48 @@ 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. + const refreshQR = () => { + adminWeixinLoginQR(platform, accountId) + .then((r) => { + log("channel.weixin.login.qr.refresh", "ok"); + setQrCode(r.qrCode ?? ""); + setQrUrl(r.qrUrl ?? ""); + if (r.status === "confirmed") { + statusRef.current = "confirmed"; + setStatus("confirmed"); + stopPolling(); + fetchStatus(); + } + }) + .catch((e: unknown) => { + log("channel.weixin.login.qr.refresh", "error"); + setNotice(e instanceof Error ? e.message : "刷新二维码失败"); + }); + }; + + // 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. + useEffect(() => { + const onVis = () => { + const h = document.hidden; + setHidden(h); + if (!h && statusRef.current === "waiting") refreshQR(); + }; + const onFocus = () => { + setHidden(false); + if (statusRef.current === "waiting") refreshQR(); + }; + 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(() => { @@ -89,28 +149,37 @@ export function WeixinLogin({ setNotice(null); setQrCode(""); setQrUrl(""); + // 只在用户点击「扫码登录」后才 mint 二维码;idle 状态绝不自动生成。 adminWeixinLoginQR(platform, accountId) .then((r) => { log("channel.weixin.login.qr", "ok"); setQrCode(r.qrCode ?? ""); setQrUrl(r.qrUrl ?? ""); if (r.status === "confirmed") { + statusRef.current = "confirmed"; setStatus("confirmed"); stopPolling(); fetchStatus(); return; } + 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 > 8 * 60 * 1000) { + if (Date.now() - started > SCAN_TIMEOUT_MS) { stopPolling(); setNotice("等待扫码超时,请重试。"); } - }, 1500); + }, 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"); @@ -148,7 +217,15 @@ export function WeixinLogin({ {showQR ? (
{qrUrl ? ( - +
+ + {hidden ? ( +
+ 二维码已过期 + 页面在后台无法刷新,请回到本页将自动换新码 +
+ ) : null} +
) : ( 二维码暂不可用,请点击「刷新二维码」重试。 )} @@ -162,7 +239,9 @@ export function WeixinLogin({ 若上方码不识别,点此在微信/浏览器打开扫码页 ) : null} -

请用微信扫描上方二维码,确认后自动连接(最多等待约 8 分钟)。

+

+ 请用微信扫描上方二维码,二维码每 45 秒自动刷新(请在刷新后 45 秒内扫码确认),确认后自动连接。 +

) : null}