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 00:59:01 +08:00
1 parent f5950e4bde
commit d970884ff8
1 file changed
+84 -5
@@ -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<string>("");
useEffect(() => {
@@ -46,16 +56,24 @@ export function WeixinLogin({
const [botId, setBotId] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
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 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 ? (
<div className="space-y-2">
{qrUrl ? (
<QrImage value={qrUrl} />
<div className="relative inline-block">
<QrImage value={qrUrl} />
{hidden ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/80 text-center backdrop-blur-sm">
<span className="text-sm font-medium text-[var(--mu-text-lo)]">二维码已过期</span>
<span className="text-xs text-[var(--mu-text-lo)]">页面在后台无法刷新,请回到本页将自动换新码</span>
</div>
) : null}
</div>
) : (
<Notice tone="warn">二维码暂不可用,请点击「刷新二维码」重试。</Notice>
)}
@@ -162,7 +239,9 @@ export function WeixinLogin({
若上方码不识别,点此在微信/浏览器打开扫码页
</a>
) : null}
<p className="text-xs text-[var(--mu-text-lo)]">请用微信扫描上方二维码,确认后自动连接(最多等待约 8 分钟)。</p>
<p className="text-xs text-[var(--mu-text-lo)]">
请用微信扫描上方二维码,二维码每 45 秒自动刷新(请在刷新后 45 秒内扫码确认),确认后自动连接。
</p>
</div>
) : null}