Files
makelore/src/pages/CloudAgents/ChannelAccounts.tsx
T
鲨鱼辣椒 d0451e9a9d build(trial): preserve homepage sidebar while adding WeChat prompts
Implementation: a837c4fd7e52fc1288f1f9a6737bd9775bfee56c
Only product delta: src/pages/CloudAgents/ChannelAccounts.tsx
2026-10-14 11:22:51 +08:00

1133 lines
46 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
ArrowRight,
CheckCircle2,
ChevronLeft,
Link2,
Loader2,
MoreHorizontal,
Pause,
Play,
Plus,
QrCode,
RefreshCw,
Unplug,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import wechatMark from '@/assets/channels/wechat.svg';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type {
CloudAgentDraft,
CloudPendingOperation,
CloudUserChannelAccount,
CloudWechatBindState,
} from '../../../shared/cloud-agents';
import { CloudAvatar } from './CloudAvatar';
import { CloudRecovery } from './CloudRecovery';
import { CloudWechatQr } from './CloudWechatQr';
type Flow = {
mode: 'new' | 'switch' | 'reconnect' | 'unbind';
step: 'choose' | 'connect' | 'result';
accountId: string;
target: string;
};
type Expected = { target: string | null; enabled: boolean; disconnected?: boolean };
const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试');
const usableBlockers = (account: CloudUserChannelAccount) =>
account.blockers.filter((item) => item !== 'binding_paused');
const matches = (account: CloudUserChannelAccount | undefined, expected: Expected) =>
Boolean(
account &&
account.target_agent_slug === expected.target &&
account.enabled === expected.enabled &&
!unsettled(account) &&
(!expected.disconnected || account.health === 'login_required')
);
const qrLabels: Record<string, string> = {
pending: '等待微信确认',
scanned: '已扫码,请在微信中确认',
verification_required: '需要微信验证码',
expired: '二维码已过期',
failed: '连接没有完成',
provisioning: '正在准备二维码',
unknown: '正在确认连接',
};
const blockerText: Record<string, string> = {
target_unavailable: '当前智能体暂时不能使用,可以更换一个智能体。',
publish_required: '请先让智能体开始使用。',
worker_offline: '智能体的微信服务暂时离线,暂时无法自动回复。',
channel_sync_degraded: '微信使用状态尚未同步,请刷新状态后再继续。',
};
function blockerMessage(blocker: string, account: CloudUserChannelAccount) {
const text = blockerText[blocker];
return blocker === 'worker_offline' &&
account.health === 'connected' &&
account.target_agent_slug &&
!account.blockers.includes('wechat_disconnect_pending')
? text + '微信登录和智能体选择已保留,服务恢复后请刷新状态。'
: text;
}
const unsettled = (account: CloudUserChannelAccount) =>
account.blockers.includes('wechat_disconnect_pending') ||
Boolean(account.sync_state && account.sync_state !== 'ready') ||
account.blockers.includes('channel_sync_degraded') ||
(['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) ||
(account.desired_state === 'enabled' && !account.enabled);
function accountState(account: CloudUserChannelAccount, uncertain: boolean, targetDisabled = false) {
if (uncertain || unsettled(account)) return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' };
if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' };
if (['login_required', 'verification_required'].includes(account.health))
return { text: '需要重新扫码', tone: 'bg-[#fff0d8] text-[#876033]' };
if (targetDisabled) return { text: '智能体已停用', tone: 'bg-[#fff0d8] text-[#876033]' };
if (account.health === 'connected' && account.blockers.includes('worker_offline'))
return { text: '微信服务离线', tone: 'bg-[#fff0d8] text-[#876033]' };
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' };
return account.enabled
? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' }
: { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' };
}
function bindingReadinessMessage(account: CloudUserChannelAccount) {
if (unsettled(account)) return '微信使用状态尚未确认,请刷新状态后再继续。';
if (account.health !== 'connected')
return '微信登录状态尚未确认,请先完成微信登录,再检查连接。';
const blocker = usableBlockers(account).find((item) => blockerText[item]);
return blocker
? blockerMessage(blocker, account)
: '暂时无法完成微信绑定,请检查连接后再继续。';
}
async function loadAgents(): Promise<CloudAgentDraft[]> {
const items: CloudAgentDraft[] = [];
let cursor: string | null = null;
do {
const page = await cloudAgentsApi.list(cursor);
items.push(...page.agents);
cursor = page.next_cursor;
} while (cursor);
return items;
}
export function ChannelAccounts() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const initial = useRef({ account: params.get('account') ?? '', target: params.get('target') ?? '' });
const [accounts, setAccounts] = useState<CloudUserChannelAccount[]>([]);
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
const agentsRef = useRef<CloudAgentDraft[]>([]);
const availableAgents = agents.filter(
(agent) => Boolean(agent.published_version) && agent.enabled !== false
);
const [loading, setLoading] = useState(true);
const [accountsLoaded, setAccountsLoaded] = useState(false);
const [agentsLoaded, setAgentsLoaded] = useState(false);
const [agentsError, setAgentsError] = useState('');
const [recoveryLoaded, setRecoveryLoaded] = useState(false);
const [flow, setFlow] = useState<Flow | null>(null);
const flowRef = useRef<Flow | null>(null);
const [more, setMore] = useState('');
const [busy, setBusy] = useState('');
const busyRef = useRef(false);
const [busyAccount, setBusyAccount] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [qr, setQr] = useState<CloudWechatBindState | null>(null);
const [qrAccountId, setQrAccountId] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const [connectionFailed, setConnectionFailed] = useState(false);
const confirmedSessions = useRef(new Set<string>());
const [pending, setPending] = useState<CloudPendingOperation[]>([]);
const alive = useRef(true);
const intents = useRef(new Map<string, { fingerprint: string; id: string }>());
const checks = useRef(new Map<string, Expected>());
const readGeneration = useRef(0);
const ownerPending = useRef(false);
const refreshGeneration = useRef(0);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const selected = accounts.find((account) => account.id === flow?.accountId);
const recoveryBlocked = pending.some((item) => /channelaccount/iu.test(item.operation));
const ownerLogoutPending = accounts.some((account) =>
account.blockers.includes('wechat_disconnect_pending')
);
const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending;
const pendingFor = (id: string) =>
pending.some(
(item) => /channelaccount/iu.test(item.operation) && item.input.channel_account_id === id
);
const updateFlow = (next: Flow | null) => {
flowRef.current = next;
setFlow(next);
};
const setCheck = (id: string, expected: Expected) => {
checks.current.set(id, expected);
setCheckingIds([...checks.current.keys()]);
};
const readAccounts = useCallback(async () => {
const generation = ++readGeneration.current;
const page = await cloudAgentsApi.call('channelAccounts', {});
if (alive.current && generation === readGeneration.current) {
ownerPending.current = page.items.some((account) =>
account.blockers.includes('wechat_disconnect_pending')
);
setAccounts(page.items);
setAccountsLoaded(true);
for (const [id, expected] of checks.current)
if (
matches(
page.items.find((item) => item.id === id),
expected
)
)
checks.current.delete(id);
setCheckingIds([...checks.current.keys()]);
}
return page.items;
}, []);
const refresh = useCallback(async () => {
const generation = ++refreshGeneration.current;
const [accountResult, agentResult, recoveryResult] = await Promise.allSettled([
readAccounts(),
loadAgents(),
cloudAgentsApi.recovery(),
]);
if (!alive.current || generation !== refreshGeneration.current) return;
if (accountResult.status === 'rejected') setError(errorText(accountResult.reason));
if (agentResult.status === 'fulfilled') {
agentsRef.current = agentResult.value;
setAgents(agentResult.value);
setAgentsLoaded(true);
setAgentsError('');
} else setAgentsError(errorText(agentResult.reason));
if (recoveryResult.status === 'fulfilled') {
setPending(recoveryResult.value.pending);
setRecoveryLoaded(true);
} else {
setRecoveryLoaded(false);
setError('暂时无法确认上次操作,请刷新后再试。');
}
}, [readAccounts]);
useEffect(() => {
alive.current = true;
void refresh()
.catch((failure) => {
if (alive.current) setError(errorText(failure));
})
.finally(() => {
if (alive.current) setLoading(false);
});
return () => {
alive.current = false;
};
}, [refresh]);
useEffect(() => {
if (loading || !initial.current.account) return;
const account = accounts.find((item) => item.id === initial.current.account);
if (!account) return;
updateFlow({
mode: 'switch',
step: 'choose',
accountId: account.id,
target: initial.current.target || account.target_agent_slug || '',
});
initial.current = { account: '', target: '' };
const next = new URLSearchParams(params);
next.delete('target');
setParams(next, { replace: true });
// Consume the deep link once; refresh must preserve the user's selection.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accounts, loading]);
const operationId = (key: string, input: unknown) => {
const fingerprint = JSON.stringify(input);
const old = intents.current.get(key);
if (old?.fingerprint === fingerprint) return old.id;
const id = crypto.randomUUID();
intents.current.set(key, { fingerprint, id });
return id;
};
const run = async <T,>(key: string, accountId: string, task: () => Promise<T>): Promise<T | null> => {
if (busyRef.current || recoveryBlocked || !recoveryLoaded || ownerPending.current) return null;
busyRef.current = true;
const generation = ++refreshGeneration.current;
setBusy(key);
setBusyAccount(accountId);
setError('');
setNotice('');
try {
const observedAccounts = await readAccounts();
if (
ownerPending.current ||
observedAccounts.some((account) => account.blockers.includes('wechat_disconnect_pending'))
)
throw new Error('微信退出还在确认中,请先处理原来的操作。');
if (!alive.current) return null;
return await task();
} catch (failure) {
if (alive.current) {
setError(errorText(failure));
await cloudAgentsApi
.recovery()
.then((state) => {
if (alive.current && generation === refreshGeneration.current) {
setPending(state.pending);
setRecoveryLoaded(true);
}
})
.catch(() => {
if (alive.current && generation === refreshGeneration.current) setRecoveryLoaded(false);
});
}
return null;
} finally {
busyRef.current = false;
if (alive.current) {
setBusy('');
setBusyAccount('');
}
}
};
const resetConnection = () => {
setQr(null);
setQrAccountId('');
setVerifyCode('');
setConnectionFailed(false);
};
const close = (clear = true) => {
if (busyRef.current) return;
updateFlow(null);
resetConnection();
setMore('');
if (clear) {
setError('');
setNotice('');
}
const next = new URLSearchParams(params);
next.delete('account');
next.delete('target');
setParams(next, { replace: true });
};
const open = (account: CloudUserChannelAccount, mode: Flow['mode']) => {
resetConnection();
setMore('');
setError('');
setNotice('');
updateFlow({
mode,
step: mode === 'reconnect' ? 'connect' : 'choose',
accountId: account.id,
target: account.target_agent_slug ?? '',
});
const next = new URLSearchParams(params);
next.set('account', account.id);
next.delete('target');
setParams(next, { replace: true });
if (mode === 'reconnect') void startQr(account);
};
const verifyFinal = async (id: string, expected: Expected) => {
setCheck(id, expected);
const fresh = (await readAccounts()).find((item) => item.id === id);
if (!matches(fresh, expected)) throw new Error('操作结果还没有确认,请刷新状态后再试。');
return fresh!;
};
const route = async (account: CloudUserChannelAccount, target: string) => {
if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
const input = {
channel_account_id: account.id,
target_agent_slug: target,
expected_revision: account.revision,
enabled: account.enabled,
};
await cloudAgentsApi.call('routeChannelAccount', {
...input,
operation_id: operationId('route', input),
});
const fresh = await verifyFinal(account.id, { target, enabled: account.enabled });
intents.current.delete('route');
return fresh;
};
const finishBinding = async (id: string, target: string) => {
setConnectionFailed(false);
const fresh = await run('activate', id, async () => {
let account = (await readAccounts()).find((item) => item.id === id);
if (
!alive.current ||
flowRef.current?.accountId !== id ||
flowRef.current.target !== target ||
flowRef.current.step !== 'connect'
)
return null;
if (!account || account.target_agent_slug !== target)
throw new Error('绑定的智能体已发生变化,请返回上一步确认。');
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
throw new Error(bindingReadinessMessage(account));
if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
if (agentsRef.current.some((agent) => agent.slug === target && agent.enabled === false))
throw new Error('当前智能体已停用,请更换智能体后再使用。');
const activate = flowRef.current?.mode !== 'reconnect';
if (!account.enabled && activate) {
const input = { channel_account_id: id, expected_revision: account.revision };
await cloudAgentsApi.call('enableChannelAccount', {
...input,
operation_id: operationId('enable', input),
});
account = await verifyFinal(id, { target, enabled: true });
}
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
throw new Error(bindingReadinessMessage(account));
intents.current.delete('enable');
return account;
});
if (!alive.current || flowRef.current?.accountId !== id) return;
if (fresh) updateFlow({ ...flowRef.current, step: 'result' });
else setConnectionFailed(true);
};
const applyBindState = (result: CloudWechatBindState, accountId: string) => {
if (!alive.current || flowRef.current?.accountId !== accountId || flowRef.current.step !== 'connect')
return;
setQr(result);
setQrAccountId(accountId);
if (result.status === 'confirmed') {
setVerifyCode('');
const identity = accountId + ':' + result.session_key;
if (!confirmedSessions.current.has(identity)) {
confirmedSessions.current.add(identity);
void finishBinding(accountId, flowRef.current.target);
}
}
};
const startQr = async (account: CloudUserChannelAccount, force = false) => {
setVerifyCode('');
setConnectionFailed(false);
const input = { channel_account_id: account.id, force };
const result = await run('qr', account.id, () =>
cloudAgentsApi.call('channelAccountWechatBindStart', {
...input,
operation_id: operationId('qr', input),
})
);
if (result) {
intents.current.delete('qr');
applyBindState(result, account.id);
}
};
const chooseAgent = async () => {
const current = flowRef.current;
if (
!current ||
!agentsLoaded ||
agentsError ||
!availableAgents.some((agent) => agent.slug === current.target) ||
disabled
)
return;
const result = await run('choose', current.accountId, async () => {
let account = current.accountId
? accounts.find((item) => item.id === current.accountId)
: undefined;
if (current.accountId && !account)
throw new Error('这个微信绑定已发生变化,请关闭窗口并刷新列表。');
if (!account) {
const input = { display_name: `微信 ${accounts.length + 1}` };
const created = await cloudAgentsApi.call('createChannelAccount', {
...input,
operation_id: operationId('create', input),
});
account = created.channel;
if (!alive.current) return null;
updateFlow({ ...current, accountId: account.id });
await readAccounts();
intents.current.delete('create');
if (!alive.current || flowRef.current?.accountId !== account.id) return null;
}
if (account.target_agent_slug !== current.target) account = await route(account, current.target);
return account;
});
if (!result || !alive.current || !flowRef.current) return;
if (current.mode === 'switch' && selected?.target_agent_slug) {
setNotice('已更换智能体,微信连接和历史保留。');
close(false);
} else {
updateFlow({ ...flowRef.current, step: 'connect' });
if (result.health === 'connected') void finishBinding(result.id, current.target);
else void startQr(result);
}
};
const setEnabled = async (account: CloudUserChannelAccount) => {
setMore('');
const enabled = !account.enabled;
if (
enabled &&
agentsRef.current.some(
(agent) => agent.slug === account.target_agent_slug && agent.enabled === false
)
)
return;
const key = enabled ? 'enable' : 'pause';
const fresh = await run(key, account.id, async () => {
const input = { channel_account_id: account.id, expected_revision: account.revision };
await cloudAgentsApi.call(enabled ? 'enableChannelAccount' : 'pauseChannelAccount', {
...input,
operation_id: operationId(key, input),
});
const result = await verifyFinal(account.id, { target: account.target_agent_slug, enabled });
intents.current.delete(key);
return result;
});
if (fresh && alive.current)
setNotice(enabled ? '已恢复自动回复。' : '已暂停自动回复,微信连接和智能体保留。');
};
const unbind = async () => {
if (!selected) return;
const fresh = await run('unbind', selected.id, async () => {
const input = { channel_account_id: selected.id, expected_revision: selected.revision };
const expected = { target: null, enabled: false, disconnected: true };
const result = await cloudAgentsApi.call('unbindChannelAccount', {
...input,
operation_id: operationId('unbind', input),
});
setCheck(selected.id, expected);
if (
result.unbound !== true ||
result.disconnected !== true ||
!matches(result.channel, { target: null, enabled: false, disconnected: true })
)
throw new Error('解除结果还没有确认,请先确认上次操作。');
const account = await verifyFinal(selected.id, {
target: null,
enabled: false,
disconnected: true,
});
intents.current.delete('unbind');
return account;
});
if (fresh && alive.current) {
setNotice('微信绑定已解除,历史仍保留。');
close(false);
}
};
const pollingId = flow?.step === 'connect' ? flow.accountId : '';
const pollingSession = qr?.session_key ?? '';
const pollingStatus = qr?.status ?? '';
useEffect(() => {
if (
!pollingId ||
pollingId !== qrAccountId ||
!pollingSession ||
['confirmed', 'failed', 'expired'].includes(pollingStatus) ||
recoveryBlocked ||
ownerLogoutPending
)
return;
let stopped = false;
let polling = false;
const timer = window.setInterval(() => {
if (document.visibilityState === 'hidden' || polling || busyRef.current) return;
polling = true;
void cloudAgentsApi
.call('channelAccountWechatBindStatus', {
channel_account_id: pollingId,
session_key: pollingSession,
})
.then((result) => {
if (!stopped) applyBindState(result, pollingId);
})
.catch((failure) => {
if (!stopped && alive.current) setError(errorText(failure));
})
.finally(() => {
polling = false;
});
}, 2000);
return () => {
stopped = true;
window.clearInterval(timer);
};
// The flow ref identifies the current user-initiated connection without restarting a poll on unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked, ownerLogoutPending]);
const verify = async () => {
if (!selected || !qr || !verifyCode.trim()) return;
const input = {
channel_account_id: selected.id,
session_key: qr.session_key,
verify_code: verifyCode.trim(),
};
const result = await run('verify', selected.id, () =>
cloudAgentsApi.call('channelAccountWechatBindVerification', {
...input,
operation_id: operationId('verify', input),
})
);
if (result) {
intents.current.delete('verify');
applyBindState(result, selected.id);
}
};
const feedback = (
<>
{error && (
<p
role="alert"
className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
{error}
</p>
)}
{agentsError && (
<p
role="alert"
className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
{agentsError}。已有微信仍可以暂停或解除绑定。
</p>
)}
{ownerLogoutPending && (
<div
role="status"
className="rounded-2xl bg-[#fff0d8] px-4 py-3 text-sm leading-6 text-[#876033]"
>
<p>有一个微信的退出操作还在确认中,暂时不能添加、扫码或修改微信绑定。</p>
<p>
{recoveryBlocked
? '请通过待处理记录确认原来的操作。'
: '请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。'}
</p>
<Button
variant="ghost"
size="sm"
disabled={Boolean(busy)}
onClick={() => void refresh().catch((failure) => setError(errorText(failure)))}
>
刷新确认
</Button>
</div>
)}
{notice && (
<p role="status" className="rounded-2xl bg-[#e7f4ec] px-4 py-3 text-sm text-[#3f7458]">
{notice}
</p>
)}
</>
);
const agentHint = (
<div className="rounded-2xl border border-[#e5def0] bg-[#f6f2fb] p-5 text-sm leading-7 text-[#75618e]">
<h2 className="font-semibold text-[#3c3549]">还没有可连接的智能体</h2>
<p className="mt-2">先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。</p>
<Button
variant="outline"
className="mt-4 rounded-xl"
onClick={() => navigate('/cloud-agents?view=mine')}
>
去我的智能体
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
return (
<section
className="h-full overflow-auto bg-[#fffcf7] p-4 sm:p-7 lg:p-9"
data-testid="channel-accounts-page"
>
<div className="mx-auto max-w-6xl space-y-6">
<CloudRecovery
pending={pending}
onResolved={() => void refresh().catch((failure) => setError(errorText(failure)))}
/>
<header className="flex flex-wrap items-center justify-between gap-5 py-2">
<div className="flex min-w-0 items-center gap-4">
<span className="flex h-16 w-16 shrink-0 items-center justify-center rounded-[22px] border border-[#d8e9da] bg-[#eaf5ec] p-4">
<img src={wechatMark} alt="" className="h-9 w-9" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#303747] sm:text-3xl">
连接到微信
</h1>
<p className="mt-2 text-sm leading-6 text-[#677286]">一个微信,一个智能体。</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
aria-label="刷新微信列表"
disabled={Boolean(busy)}
onClick={() => void refresh().catch((failure) => setError(errorText(failure)))}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
className="h-11 rounded-2xl px-5"
disabled={loading || !accountsLoaded || !agentsLoaded || Boolean(agentsError) || disabled}
onClick={() => {
resetConnection();
setError('');
setNotice('');
setMore('');
updateFlow({ mode: 'new', step: 'choose', accountId: '', target: '' });
}}
>
<Plus className="mr-2 h-4 w-4" />
添加微信
</Button>
</div>
</header>
{!flow && feedback}
{!loading &&
accountsLoaded &&
agentsLoaded &&
!agentsError &&
!availableAgents.length &&
!flow &&
agentHint}
{loading ? (
<div
role="status"
className="flex items-center justify-center gap-3 rounded-3xl bg-[#f4f1fb] p-12 text-[#75618e]"
>
<Loader2 className="h-5 w-5 animate-spin" />
正在查看你的微信绑定…
</div>
) : !accountsLoaded ? (
<div className="rounded-3xl border border-[#e4e2de] bg-white p-10 text-center text-sm text-[#677286]">
暂时没能读到微信绑定,请刷新后再试。
</div>
) : !accounts.length ? (
agentsLoaded &&
!agentsError &&
availableAgents.length > 0 && (
<div className="rounded-[28px] border border-[#dfe9e2] bg-[#f1f8f2] px-6 py-14 text-center">
<Link2 className="mx-auto h-10 w-10 text-[#678b70]" />
<h2 className="mt-5 text-xl font-bold text-[#303747]">把你的智能体带到微信吧</h2>
<p className="mt-3 text-sm text-[#677286]">选一个智能体,再用微信扫码,就可以开始聊天。</p>
<Button
className="mt-7 rounded-2xl"
onClick={() => {
resetConnection();
setError('');
updateFlow({ mode: 'new', step: 'choose', accountId: '', target: '' });
}}
>
添加第一个微信
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)
) : (
<div
className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3"
aria-label="我的微信绑定"
>
{accounts.map((account) => {
const agent = agents.find((item) => item.slug === account.target_agent_slug);
const uncertain =
!recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
const targetDisabled = agents.some(
(agent) => agent.slug === account.target_agent_slug && agent.enabled === false
);
const state = accountState(
account,
uncertain || busyAccount === account.id,
targetDisabled
);
const needsScan =
!targetDisabled &&
account.target_agent_slug &&
['login_required', 'verification_required'].includes(account.health);
const blocked = disabled || uncertain;
const changeBlocked =
blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError);
const canResume =
!targetDisabled &&
!unsettled(account) &&
account.health === 'connected' &&
Boolean(account.target_agent_slug) &&
!usableBlockers(account).length;
return (
<article
key={account.id}
aria-label={`${account.display_name}微信`}
className="flex min-w-0 flex-col rounded-[26px] border border-[#e4e2de] bg-white p-5 shadow-[0_3px_0_0_#f1eeea] sm:p-6"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-[#788271]">微信</p>
<h2 className="mt-1 break-words text-lg font-bold text-[#303747]">
{account.display_name}
</h2>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold ${state.tone}`}
>
{state.text}
</span>
</div>
<div className="my-5 flex min-h-20 items-center gap-3 rounded-2xl border border-[#e4def0] bg-[#f7f3fc] p-4">
{account.target_agent_slug ? (
<>
<CloudAvatar
src={agent?.icon_url}
name={account.target_agent_name ?? agent?.name ?? '智能体'}
className="h-11 w-11"
/>
<div className="min-w-0">
<p className="text-xs text-[#7c6d92]">当前智能体</p>
<p className="mt-1 break-words text-sm font-semibold text-[#3c3549]">
{account.target_agent_name ?? agent?.name ?? '智能体暂不可用'}
</p>
</div>
</>
) : (
<>
<Link2 className="h-7 w-7 shrink-0 text-[#9683b3]" />
<p className="text-sm text-[#7c6d92]">还没有绑定智能体</p>
</>
)}
</div>
{targetDisabled && (
<p className="mb-3 text-xs leading-5 text-[#876033]">
当前智能体已停用,可以更换智能体。
</p>
)}
{account.blockers.includes('wechat_disconnect_pending') && (
<p className="mb-3 text-xs leading-5 text-[#876033]">
这个微信的退出操作还在确认中。
</p>
)}
{usableBlockers(account)
.filter((item) => blockerText[item])
.map((item) => (
<p key={item} className="mb-3 text-xs leading-5 text-[#876033]">
{blockerMessage(item, account)}
</p>
))}
<div className="mt-auto flex gap-2">
<Button
variant="outline"
className="h-10 min-w-0 flex-1 rounded-xl"
disabled={changeBlocked}
onClick={() => open(account, needsScan ? 'reconnect' : 'switch')}
>
{needsScan ? '重新扫码' : account.target_agent_slug ? '更换智能体' : '绑定智能体'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="更多操作"
aria-expanded={more === account.id}
aria-controls={'wechat-more-' + account.id}
disabled={blocked}
onClick={() => setMore(more === account.id ? '' : account.id)}
>
<MoreHorizontal className="h-5 w-5" />
</Button>
</div>
{more === account.id && (
<div
id={'wechat-more-' + account.id}
className="mt-3 grid gap-2 rounded-xl border border-border bg-[#fffcf7] p-2"
onKeyDown={(event) => {
if (event.key === 'Escape') setMore('');
}}
>
{needsScan && (
<Button
variant="ghost"
className="justify-start"
disabled={changeBlocked}
onClick={() => open(account, 'switch')}
>
更换智能体
</Button>
)}
{account.target_agent_slug && (
<Button
variant="ghost"
className="justify-start"
disabled={blocked || (!account.enabled && !canResume)}
onClick={() => void setEnabled(account)}
>
{account.enabled ? (
<Pause className="mr-2 h-4 w-4" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
{account.enabled ? '暂停自动回复' : '恢复自动回复'}
</Button>
)}
<Button
variant="ghost"
className="justify-start text-[#89625b]"
disabled={blocked}
onClick={() => open(account, 'unbind')}
>
<Unplug className="mr-2 h-4 w-4" />
解除微信绑定
</Button>
</div>
)}
{uncertain && (
<Button
variant="ghost"
className="mt-2 text-xs"
disabled={Boolean(busy)}
onClick={() => void refresh().catch((failure) => setError(errorText(failure)))}
>
刷新确认
</Button>
)}
</article>
);
})}
</div>
)}
</div>
<Dialog
open={Boolean(flow)}
onOpenChange={(value) => {
if (!value) close();
}}
>
<DialogContent
className="max-h-[calc(100dvh-2rem)] max-w-xl gap-5 overflow-y-auto rounded-[28px] border-[#e5e0d9] bg-[#fffcf7] p-5 sm:p-7"
onInteractOutside={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
if (busy) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{flow?.mode === 'unbind'
? '解除微信绑定'
: flow?.mode === 'reconnect'
? '重新连接微信'
: flow?.mode === 'switch'
? '更换智能体'
: '添加微信'}
</DialogTitle>
<DialogDescription>
{flow?.mode === 'new'
? '选一个智能体,再用微信扫码。'
: (selected?.display_name ?? '微信绑定')}
</DialogDescription>
</DialogHeader>
{feedback}
{recoveryBlocked && (
<p role="status" className="rounded-2xl bg-[#fff0d8] p-3 text-sm text-[#876033]">
上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。
</p>
)}
{flow?.mode === 'unbind' ? (
<>
<p className="text-sm leading-7 text-[#677286]">
解除后,这个微信将停止自动回复,当前任务会停止,并断开微信登录。智能体聊天历史仍保留,再使用需要重新扫码。
</p>
<Button
variant="destructive"
className="h-11 rounded-xl"
disabled={disabled || checkingIds.includes(flow.accountId)}
onClick={() => void unbind()}
>
{busy ? '正在解除…' : '确认解除微信绑定'}
</Button>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => close()}>
取消
</Button>
</>
) : flow?.step === 'choose' ? (
<>
{!agentsError && agentsLoaded && !availableAgents.length ? (
agentHint
) : (
<div
role="radiogroup"
aria-label="选择智能体"
className="grid max-h-72 gap-2 overflow-y-auto"
>
{availableAgents.map((agent) => (
<label
key={agent.slug}
className={`relative flex w-full cursor-pointer items-center gap-3 rounded-2xl border p-4 text-left focus-within:outline focus-within:outline-2 focus-within:outline-[#8468a5] ${flow.target === agent.slug ? 'border-[#a28abb] bg-[#f0eafa]' : 'border-[#e4e2de] bg-white'}`}
>
<input
type="radio"
name="wechat-agent"
aria-label={agent.name}
className="sr-only"
checked={flow.target === agent.slug}
disabled={disabled || Boolean(agentsError)}
onChange={() => updateFlow({ ...flow, target: agent.slug })}
/>
<CloudAvatar src={agent.icon_url} name={agent.name} className="h-10 w-10" />
<span className="min-w-0 flex-1 break-words text-sm font-semibold">
{agent.name}
</span>
{flow.target === agent.slug && (
<CheckCircle2 className="h-5 w-5 shrink-0 text-[#8468a5]" />
)}
</label>
))}
</div>
)}
{flow.mode === 'switch' && selected?.target_agent_slug && (
<p className="text-xs leading-6 text-[#677286]">
更换会停止当前任务,微信连接和历史保留。{!selected.enabled && '这个微信仍保持暂停。'}
</p>
)}
<Button
className="h-12 rounded-2xl"
disabled={
disabled ||
checkingIds.includes(flow.accountId) ||
!agentsLoaded ||
Boolean(agentsError) ||
!availableAgents.some((agent) => agent.slug === flow.target) ||
(flow.mode === 'switch' && flow.target === selected?.target_agent_slug)
}
onClick={() => void chooseAgent()}
>
{busy
? '正在确认…'
: flow.mode === 'switch' && selected?.target_agent_slug
? '确认更换'
: '下一步:扫码连接'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => close()}>
关闭
</Button>
</>
) : flow?.step === 'connect' ? (
<>
{qr?.status === 'confirmed' || selected?.health === 'connected' ? (
<div role="status" className="rounded-2xl bg-[#e7f4ec] p-5 text-center text-[#3f7458]">
<CheckCircle2 className="mx-auto h-9 w-9" />
<p className="mt-3 font-semibold">
{busy
? '正在检查连接并完成绑定…'
: selected?.health === 'connected'
? '微信登录已确认'
: '扫码已确认'}
</p>
<p className="mt-2 text-sm">
{connectionFailed
? '绑定尚未完成,请按上方提示处理,再检查连接。'
: '正在确认智能体和自动回复状态。'}
</p>
</div>
) : (
<div className="rounded-2xl border border-[#dce8de] bg-white p-5 text-center">
<div className="mx-auto flex min-h-48 w-48 items-center justify-center">
{qr?.qrcode_url ? (
<CloudWechatQr value={qr.qrcode_url} />
) : busy === 'qr' ? (
<Loader2 className="h-7 w-7 animate-spin text-[#678b70]" />
) : (
<QrCode className="h-14 w-14 text-[#678b70]" />
)}
</div>
<p className="mt-3 text-sm font-semibold">
{qr ? (qrLabels[qr.status] ?? '请使用微信扫码') : '请使用微信扫码'}
</p>
{qr?.message && <p className="mt-2 text-xs leading-6 text-[#677286]">{qr.message}</p>}
{(!qr || ['expired', 'failed'].includes(qr.status)) && (
<Button
variant="outline"
className="mt-4 rounded-xl"
disabled={disabled || !selected}
onClick={() => selected && void startQr(selected, Boolean(qr))}
>
<RefreshCw className="mr-2 h-4 w-4" />
{qr ? '刷新二维码' : '获取二维码'}
</Button>
)}
</div>
)}
{qr?.status === 'verification_required' && (
<div className="space-y-3">
<label className="block space-y-2 text-sm">
<span>微信验证码</span>
<Input
aria-label="微信验证码"
disabled={disabled}
value={verifyCode}
onChange={(event) => setVerifyCode(event.target.value)}
placeholder="输入微信提示的验证码"
/>
</label>
<Button
className="w-full rounded-xl"
disabled={disabled || !verifyCode.trim()}
onClick={() => void verify()}
>
提交验证码
</Button>
</div>
)}
{connectionFailed && (
<Button
className="h-11 rounded-xl"
disabled={disabled || checkingIds.includes(flow.accountId)}
onClick={() => void finishBinding(flow.accountId, flow.target)}
>
检查连接并完成绑定
</Button>
)}
{checkingIds.includes(flow.accountId) && (
<Button
variant="outline"
disabled={Boolean(busy)}
onClick={() => void refresh().catch((failure) => setError(errorText(failure)))}
>
刷新确认
</Button>
)}
<div className="flex items-center justify-between">
<Button
variant="ghost"
disabled={Boolean(busy)}
onClick={() => {
resetConnection();
updateFlow({ ...flow, step: 'choose' });
}}
>
<ChevronLeft className="mr-1 h-4 w-4" />
上一步
</Button>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => close()}>
稍后继续
</Button>
</div>
</>
) : flow?.step === 'result' ? (
<>
<div className="rounded-3xl border border-[#d8e9da] bg-[#edf7ef] p-7 text-center">
<CheckCircle2 className="mx-auto h-12 w-12 text-[#548364]" />
<h2 className="mt-4 text-xl font-bold text-[#365642]">
{flow.mode === 'reconnect' ? '微信重新连接成功' : '微信绑定成功'}
</h2>
<p className="mt-3 break-words text-sm leading-7 text-[#557062]">
{selected?.enabled
? `现在可以回微信和${selected.target_agent_name ?? '智能体'}聊聊。`
: '微信已连接,自动回复仍保持暂停。'}
</p>
</div>
<Button className="h-12 rounded-2xl" onClick={() => close()}>
完成
</Button>
</>
) : null}
</DialogContent>
</Dialog>
</section>
);
}