feat(admin): 微信扫码二维码每45s自动刷新+后台垫'过期'叠加层不换码
This commit is contained in:
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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user