feat(admin): 微信扫码仅在'超45s且未扫到'才换码,已扫待确认固定不换
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-08 01:14:59 +08:00
1 parent c515de28bf
commit 2baa2cc7ca
1 file changed
+52 -44
@@ -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<string | null>(null);
const [hidden, setHidden] = useState<boolean>(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshRef = useRef<ReturnType<typeof setInterval> | null>(null);
const statusRef = useRef<string>("idle");
const qrMintedRef = useRef<number>(0);
const scannedRef = useRef<boolean>(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({
<Tag tone="accent">微信 iLink 扫码登录</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
{status === "confirmed" ? <Tag tone="ok">已登录</Tag> : null}
{status === "waiting" && scannedRef.current ? <Tag tone="info">已扫码·待确认</Tag> : null}
{status === "waiting" ? <Tag tone="warn">等待扫码</Tag> : null}
{botId ? <Tag tone="neutral">bot_id:{botId}</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
@@ -241,7 +249,7 @@ export function WeixinLogin({
</a>
) : null}
<p className="text-xs text-[var(--mu-text-lo)]">
请用微信扫描上方二维码,二维码每 45 秒自动刷新(请在刷新后 45 秒内扫码确认),确认后自动连接。
请用微信扫描上方二维码,确认后自动连接。二维码仅在「生成超 45 秒且未被扫到」时自动更新,扫码后将固定不换。
</p>
</div>
) : null}