feat(admin): 微信扫码仅在'超45s且未扫到'才换码,已扫待确认固定不换
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user