feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: 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:
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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
RagIndexRequest,
|
||||
Role,
|
||||
SessionMeta,
|
||||
WeixinLoginQRResponse,
|
||||
WeixinLoginStatusResponse,
|
||||
SessionRecord,
|
||||
} from "./types";
|
||||
import { getClientId } from "./client";
|
||||
@@ -640,6 +642,28 @@ export function adminChannelMessageTest(
|
||||
body: JSON.stringify({ targetId, content }),
|
||||
});
|
||||
}
|
||||
/** GET /api/v1/admin/channels/{platform}/login-qr — trigger/mint a weixin-iLink
|
||||
* scan-login QR code (owner only, audited). */
|
||||
export function adminWeixinLoginQR(
|
||||
platform: string,
|
||||
accountId?: string,
|
||||
): Promise<WeixinLoginQRResponse> {
|
||||
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
|
||||
return request<WeixinLoginQRResponse>(
|
||||
`/admin/channels/${encodeURIComponent(platform)}/login-qr${q}`,
|
||||
);
|
||||
}
|
||||
/** GET /api/v1/admin/channels/{platform}/login-status — poll a weixin-iLink scan
|
||||
* login. On confirmed the backend persists the token and hot-connects. */
|
||||
export function adminWeixinLoginStatus(
|
||||
platform: string,
|
||||
accountId?: string,
|
||||
): Promise<WeixinLoginStatusResponse> {
|
||||
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
|
||||
return request<WeixinLoginStatusResponse>(
|
||||
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
|
||||
);
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
||||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
||||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||||
|
||||
@@ -289,6 +289,25 @@ export interface AdminChannelAccount {
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
||||
* qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed
|
||||
* directly. status is one of idle|waiting|confirmed|failed. The qrcode scan
|
||||
* identifier is never exposed. */
|
||||
export interface WeixinLoginQRResponse {
|
||||
qrUrl?: string;
|
||||
qrImage?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status).
|
||||
* botToken is always masked (never a raw secret). */
|
||||
export interface WeixinLoginStatusResponse {
|
||||
status: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
/** One message in the platform (IM channel) send/receive log. Mirrors the
|
||||
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
||||
export interface AdminChannelMessage {
|
||||
|
||||
Reference in new issue
Block a user