Implementation: a837c4fd7e52fc1288f1f9a6737bd9775bfee56c Only product delta: src/pages/CloudAgents/ChannelAccounts.tsx
1133 lines
46 KiB
TypeScript
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>
|
|
);
|
|
}
|