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
|
* 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
|
* 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
|
* 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 {
|
function QrImage({ value }: { value: string }): React.ReactNode {
|
||||||
const [src, setSrc] = useState<string>("");
|
const [src, setSrc] = useState<string>("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,16 +56,24 @@ export function WeixinLogin({
|
|||||||
const [botId, setBotId] = useState<string>("");
|
const [botId, setBotId] = useState<string>("");
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = 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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const refreshRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const statusRef = useRef<string>("idle");
|
||||||
|
|
||||||
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 }) => {
|
||||||
|
statusRef.current = r.status;
|
||||||
setStatus(r.status);
|
setStatus(r.status);
|
||||||
if (r.status === "confirmed") {
|
if (r.status === "confirmed") {
|
||||||
setBotId(r.botId ?? "");
|
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
|
// 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.
|
// ready to scan). A stale interval from a previous mount is cleaned up.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,28 +149,37 @@ export function WeixinLogin({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
setQrCode("");
|
setQrCode("");
|
||||||
setQrUrl("");
|
setQrUrl("");
|
||||||
|
// 只在用户点击「扫码登录」后才 mint 二维码;idle 状态绝不自动生成。
|
||||||
adminWeixinLoginQR(platform, accountId)
|
adminWeixinLoginQR(platform, accountId)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
log("channel.weixin.login.qr", "ok");
|
log("channel.weixin.login.qr", "ok");
|
||||||
setQrCode(r.qrCode ?? "");
|
setQrCode(r.qrCode ?? "");
|
||||||
setQrUrl(r.qrUrl ?? "");
|
setQrUrl(r.qrUrl ?? "");
|
||||||
if (r.status === "confirmed") {
|
if (r.status === "confirmed") {
|
||||||
|
statusRef.current = "confirmed";
|
||||||
setStatus("confirmed");
|
setStatus("confirmed");
|
||||||
stopPolling();
|
stopPolling();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
statusRef.current = "waiting";
|
||||||
setStatus("waiting");
|
setStatus("waiting");
|
||||||
// Poll until confirmed / failed / timeout.
|
// 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();
|
fetchStatus();
|
||||||
if (Date.now() - started > 8 * 60 * 1000) {
|
if (Date.now() - started > SCAN_TIMEOUT_MS) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setNotice("等待扫码超时,请重试。");
|
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) => {
|
.catch((e: unknown) => {
|
||||||
log("channel.weixin.login.qr", "error");
|
log("channel.weixin.login.qr", "error");
|
||||||
@@ -148,7 +217,15 @@ export function WeixinLogin({
|
|||||||
{showQR ? (
|
{showQR ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{qrUrl ? (
|
{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>
|
<Notice tone="warn">二维码暂不可用,请点击「刷新二维码」重试。</Notice>
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +239,9 @@ export function WeixinLogin({
|
|||||||
若上方码不识别,点此在微信/浏览器打开扫码页
|
若上方码不识别,点此在微信/浏览器打开扫码页
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user