feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
       login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
       (锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
       channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
       token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
       AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
       tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
       仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
This commit is contained in:
Aska committed 2026-09-07 20:50:28 +08:00
1 parent 0a597cfa42
commit e037fab7d0
13 files changed
+1155 -46

No files matched your search

@@ -7,6 +7,7 @@ import { PLATFORM_LABEL } from "../channelFields";
import { ChannelLogList } from "./channelLogList";
import { ChannelIdentityTest } from "./channelIdentityTest";
import { KookPanel } from "./KookPanel";
import { WeixinLogin } from "./WeixinLogin";
import { GenericAdapterPanel } from "./GenericAdapterPanel";
import { platformOf } from "./channelUtils";
import { useChannelActions } from "./useChannelActions";
@@ -36,7 +37,9 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
subtitle={
platform === "kook"
? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志"
: "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)"
: platform === "weixin-ilink"
? "该配置专属页 · 连接状态 + 微信 iLink 扫码登录 + 实时日志"
: "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)"
}
actions={
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onBack}>
@@ -78,6 +81,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
{platform === "kook" ? (
<KookPanel platform={platform} accountId={accountId} />
) : platform === "weixin-ilink" ? (
<WeixinLogin platform={platform} accountId={accountId} />
) : (
<GenericAdapterPanel platform={platform} accountId={accountId} record={record} />
)}
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from "react";
import { adminWeixinLoginQR, adminWeixinLoginStatus } from "../../../lib/api";
import { useAuditStore } from "../../audit";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
/** weixin-iLink scan-login panel (design/08 §4). On an account without a bot_token
* it shows a 扫码登录 button; clicking it mints a QR via GET /login-qr, renders the
* QR image, and polls GET /login-status until confirmation. On confirmation the
* backend persists the token and hot-connects the adapter, so the panel just
* flags "已登录". An account that already has a token never shows the button. */
export function WeixinLogin({
platform,
accountId,
}: {
platform: string;
accountId?: string;
}): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [status, setStatus] = useState<string>("idle");
const [qrImage, setQrImage] = useState<string>("");
const [qrUrl, setQrUrl] = useState<string>("");
const [botId, setBotId] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const applyStatus = (r: { status: string; botId?: string; err?: string }) => {
setStatus(r.status);
if (r.status === "confirmed") {
setBotId(r.botId ?? "");
stopPolling();
setNotice("已登录(微信 iLink),适配器已热连接。");
} else if (r.status === "failed") {
stopPolling();
setNotice(r.err ?? "扫码登录失败,请重试。");
}
};
const fetchStatus = () => {
adminWeixinLoginStatus(platform, accountId)
.then(applyStatus)
.catch((e: unknown) => {
log("channel.weixin.login.status", "error");
setNotice(e instanceof Error ? e.message : "查询登录状态失败");
});
};
// 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;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform, accountId]);
const start = () => {
setBusy("__qr__");
setNotice(null);
setQrImage("");
setQrUrl("");
adminWeixinLoginQR(platform, accountId)
.then((r) => {
log("channel.weixin.login.qr", "ok");
setQrImage(r.qrImage ?? "");
setQrUrl(r.qrUrl ?? "");
if (r.status === "confirmed") {
setStatus("confirmed");
stopPolling();
fetchStatus();
return;
}
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) {
stopPolling();
setNotice("等待扫码超时,请重试。");
}
}, 1500);
})
.catch((e: unknown) => {
log("channel.weixin.login.qr", "error");
setNotice(e instanceof Error ? e.message : "获取二维码失败");
setStatus("idle");
})
.finally(() => setBusy(null));
};
const showQR = status === "waiting" || (qrImage !== "" || qrUrl !== "");
return (
<Surface gap="sm" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<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" ? <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)]">
GET /api/v1/admin/channels/{platform}/login-qr · /login-status
</span>
</div>
{notice ? (
<Notice tone={notice.includes("失败") || notice.includes("超时") || notice.includes("错误") ? "err" : "ok"}>
{notice}
</Notice>
) : null}
{status === "confirmed" && !showQR ? (
<Notice>该账号已绑定 bot_token,无需再次扫码。二维码 / token 均不在此回显。</Notice>
) : null}
{showQR ? (
<div className="space-y-2">
{qrImage || qrUrl ? (
<img
src={qrImage || qrUrl}
alt="微信 iLink 登录二维码"
className="h-48 w-48 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] object-contain p-2"
/>
) : (
<Notice tone="warn">二维码图片暂不可用,请点击「刷新二维码」重试。</Notice>
)}
<p className="text-xs text-[var(--mu-text-lo)]">请用微信扫描上方二维码,确认后自动连接(最多等待约 8 分钟)。</p>
</div>
) : null}
{status === "confirmed" ? null : (
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={start}>
{busy === "__qr__" ? "生成中…" : status === "waiting" ? "刷新二维码" : "扫码登录"}
</Button>
{status === "waiting" ? (
<Button variant="ghost" disabled={busy !== null} onClick={stopPolling}>
取消等待
</Button>
) : null}
</div>
)}
</Surface>
);
}