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
|
* 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
|
* WeChat's bot binding QR has a server-side TTL of ~60s (verified). We auto-refresh the
|
||||||
* QR is auto-refreshed every 45s to keep a fresh code on screen at all times. When the
|
* displayed QR, but ONLY when it is both (a) older than 45s AND (b) has NOT been scanned
|
||||||
* browser tab is in the background we do NOT refresh (avoids silently swapping the code)
|
* yet. Once the QR has been scanned (status scaned / "已被扫描"), we never swap it —
|
||||||
* — we overlay "二维码已过期" instead, and re-mint a code on returning to the tab. The
|
* swapping mid-bind cancels the in-progress session and WeChat's confirm never lands.
|
||||||
* QR is only minted after the user clicks 扫码登录 (start), never on idle. */
|
* While the tab is in the background we never refresh and instead show a "过期" overlay. */
|
||||||
const QR_REFRESH_MS = 45_000;
|
const QR_REFRESH_MS = 45_000;
|
||||||
const STATUS_POLL_MS = 1_500;
|
const STATUS_POLL_MS = 1_500;
|
||||||
const SCAN_TIMEOUT_MS = 8 * 60 * 1000;
|
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({
|
export function WeixinLogin({
|
||||||
platform,
|
platform,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -58,23 +64,22 @@ export function WeixinLogin({
|
|||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [hidden, setHidden] = useState<boolean>(false);
|
const [hidden, setHidden] = useState<boolean>(false);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const refreshRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const statusRef = useRef<string>("idle");
|
const statusRef = useRef<string>("idle");
|
||||||
|
const qrMintedRef = useRef<number>(0);
|
||||||
|
const scannedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (pollRef.current) {
|
if (pollRef.current) {
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
pollRef.current = null;
|
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;
|
statusRef.current = r.status;
|
||||||
setStatus(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") {
|
if (r.status === "confirmed") {
|
||||||
setBotId(r.botId ?? "");
|
setBotId(r.botId ?? "");
|
||||||
stopPolling();
|
stopPolling();
|
||||||
@@ -86,7 +91,7 @@ export function WeixinLogin({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchStatus = () => {
|
const fetchStatus = () => {
|
||||||
adminWeixinLoginStatus(platform, accountId)
|
return adminWeixinLoginStatus(platform, accountId)
|
||||||
.then(applyStatus)
|
.then(applyStatus)
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
log("channel.weixin.login.status", "error");
|
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.
|
// Mint a fresh QR. Only called by the explicit refresh, by start(), or by the
|
||||||
// Runs while waiting; cancelled together with the status poll on confirm/fail.
|
// poller when the current code is older than 45s AND has not been scanned.
|
||||||
const refreshQR = () => {
|
const refreshQR = () => {
|
||||||
adminWeixinLoginQR(platform, accountId)
|
adminWeixinLoginQR(platform, accountId)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
log("channel.weixin.login.qr.refresh", "ok");
|
log("channel.weixin.login.qr.refresh", "ok");
|
||||||
|
qrMintedRef.current = Date.now();
|
||||||
|
scannedRef.current = false;
|
||||||
setQrCode(r.qrCode ?? "");
|
setQrCode(r.qrCode ?? "");
|
||||||
setQrUrl(r.qrUrl ?? "");
|
setQrUrl(r.qrUrl ?? "");
|
||||||
if (r.status === "confirmed") {
|
if (r.status === "confirmed") {
|
||||||
@@ -115,30 +122,19 @@ export function WeixinLogin({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track tab visibility: while hidden, do NOT swap the QR (show "过期" overlay instead);
|
// Restore from background: do NOT re-mint. We only re-check state and let the
|
||||||
// on returning, re-mint a fresh code if we were still waiting.
|
// periodic poller decide; a scanned-but-unconfirmed QR must not be swapped.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restoring the tab must NOT re-mint a QR: if the user has already scanned
|
const onVis = () => setHidden(document.hidden);
|
||||||
// (but not confirmed) the on-screen code, swapping it mid-flow cancels the
|
const onFocus = () => setHidden(false);
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", onVis);
|
document.addEventListener("visibilitychange", onVis);
|
||||||
window.addEventListener("focus", onFocus);
|
window.addEventListener("focus", onFocus);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
window.removeEventListener("focus", onFocus);
|
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(() => {
|
useEffect(() => {
|
||||||
adminWeixinLoginStatus(platform, accountId).then(applyStatus).catch(() => setStatus("idle"));
|
adminWeixinLoginStatus(platform, accountId).then(applyStatus).catch(() => setStatus("idle"));
|
||||||
return stopPolling;
|
return stopPolling;
|
||||||
@@ -150,10 +146,12 @@ export function WeixinLogin({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
setQrCode("");
|
setQrCode("");
|
||||||
setQrUrl("");
|
setQrUrl("");
|
||||||
// 只在用户点击「扫码登录」后才 mint 二维码;idle 状态绝不自动生成。
|
// Only mint a QR after the user clicks 扫码登录; idle never auto-generates.
|
||||||
adminWeixinLoginQR(platform, accountId)
|
adminWeixinLoginQR(platform, accountId)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
log("channel.weixin.login.qr", "ok");
|
log("channel.weixin.login.qr", "ok");
|
||||||
|
qrMintedRef.current = Date.now();
|
||||||
|
scannedRef.current = false;
|
||||||
setQrCode(r.qrCode ?? "");
|
setQrCode(r.qrCode ?? "");
|
||||||
setQrUrl(r.qrUrl ?? "");
|
setQrUrl(r.qrUrl ?? "");
|
||||||
if (r.status === "confirmed") {
|
if (r.status === "confirmed") {
|
||||||
@@ -165,22 +163,31 @@ export function WeixinLogin({
|
|||||||
}
|
}
|
||||||
statusRef.current = "waiting";
|
statusRef.current = "waiting";
|
||||||
setStatus("waiting");
|
setStatus("waiting");
|
||||||
// Poll until confirmed / failed / timeout.
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
pollRef.current = setInterval(() => {
|
pollRef.current = setInterval(() => {
|
||||||
fetchStatus();
|
const age = Date.now() - qrMintedRef.current;
|
||||||
if (Date.now() - started > SCAN_TIMEOUT_MS) {
|
adminWeixinLoginStatus(platform, accountId)
|
||||||
stopPolling();
|
.then((res) => {
|
||||||
setNotice("等待扫码超时,请重试。");
|
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);
|
}, 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) => {
|
.catch((e: unknown) => {
|
||||||
log("channel.weixin.login.qr", "error");
|
log("channel.weixin.login.qr", "error");
|
||||||
@@ -198,6 +205,7 @@ export function WeixinLogin({
|
|||||||
<Tag tone="accent">微信 iLink 扫码登录</Tag>
|
<Tag tone="accent">微信 iLink 扫码登录</Tag>
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
|
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
|
||||||
{status === "confirmed" ? <Tag tone="ok">已登录</Tag> : null}
|
{status === "confirmed" ? <Tag tone="ok">已登录</Tag> : null}
|
||||||
|
{status === "waiting" && scannedRef.current ? <Tag tone="info">已扫码·待确认</Tag> : null}
|
||||||
{status === "waiting" ? <Tag tone="warn">等待扫码</Tag> : null}
|
{status === "waiting" ? <Tag tone="warn">等待扫码</Tag> : null}
|
||||||
{botId ? <Tag tone="neutral">bot_id:{botId}</Tag> : null}
|
{botId ? <Tag tone="neutral">bot_id:{botId}</Tag> : null}
|
||||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
|
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
|
||||||
@@ -241,7 +249,7 @@ export function WeixinLogin({
|
|||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||||
请用微信扫描上方二维码,二维码每 45 秒自动刷新(请在刷新后 45 秒内扫码确认),确认后自动连接。
|
请用微信扫描上方二维码,确认后自动连接。二维码仅在「生成超 45 秒且未被扫到」时自动更新,扫码后将固定不换。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in new issue
Block a user