feat: add user-level WeChat channel accounts

This commit is contained in:
2026-09-14 00:26:29 +08:00
parent 46e7f6f1cd
commit b7d8b1298f
15 changed files with 575 additions and 63 deletions

View File

@@ -12,6 +12,7 @@ import {
FolderKanban,
LogOut,
Plus,
RadioTower,
Settings as SettingsIcon,
Ticket,
UserCircle2,
@@ -783,10 +784,17 @@ export function Sidebar({
) : (
activeModule === 'cloud_agents' ? <div data-testid="sidebar-cloud-agents-navigation" className="px-2 py-3">
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents')}
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold', sidebarCollapsed && 'justify-center px-0')}>
aria-current={location.pathname === '/cloud-agents' ? 'page' : undefined}
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold hover:bg-surface-tertiary', location.pathname === '/cloud-agents' && 'bg-brand-soft', sidebarCollapsed && 'justify-center px-0')}>
<Bot className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && <span></span>}
</button>
<button type="button" aria-label="渠道" onClick={() => navigate('/cloud-agents/channels')}
aria-current={location.pathname.startsWith('/cloud-agents/channels') ? 'page' : undefined}
className={cn('mt-1 flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold hover:bg-surface-tertiary', location.pathname.startsWith('/cloud-agents/channels') && 'bg-brand-soft', sidebarCollapsed && 'justify-center px-0')}>
<RadioTower className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && <span></span>}
</button>
</div> : <div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Loader2, MessageCircle, Pause, Play, Plus, QrCode, RefreshCw, Unplug, X } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentDraft, CloudPendingOperation, CloudUserChannelAccount, CloudWechatBindState } from '../../../shared/cloud-agents';
import { CloudChannelPanel } from './CloudChannelPanel';
import { CloudRecovery } from './CloudRecovery';
import { CloudWechatQr } from './CloudWechatQr';
const statusText: Record<string, string> = {
connected: '已连接', active: '运行中', enabled: '已启用', paused: '已暂停', disabled: '已暂停',
login_required: '需要扫码', offline: '暂时离线', provisioning: '准备中', unknown: '状态未知',
verification_required: '需要验证码', pending: '处理中', confirmed: '已连接', expired: '已过期', failed: '失败',
};
const blockerText: Record<string, string> = {
target_required: '尚未选择目标智能体', target_unavailable: '原目标智能体暂不可用', publish_required: '目标智能体需要先发布',
login_required: '需要连接微信', worker_offline: '渠道服务暂时离线', channel_sync_degraded: '渠道状态同步异常',
};
const label = (value?: string | null) => statusText[value ?? 'unknown'] ?? value ?? '状态未知';
const errorText = (error: unknown) => error instanceof Error ? error.message : '渠道操作失败,请重试';
const operationId = () => crypto.randomUUID();
async function loadPublishedAgents(): Promise<CloudAgentDraft[]> {
const items: CloudAgentDraft[] = [];
let cursor: string | null = null;
do {
const page = await cloudAgentsApi.list(cursor);
items.push(...page.agents.filter(agent => Boolean(agent.published_version)));
cursor = page.next_cursor;
} while (cursor);
return items;
}
export function ChannelAccounts() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const [accounts, setAccounts] = useState<CloudUserChannelAccount[]>([]);
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
const [selectedId, setSelectedId] = useState(params.get('account') ?? '');
const [targetSlug, setTargetSlug] = useState(params.get('target') ?? '');
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const [busy, setBusy] = 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 [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [pending, setPending] = useState<CloudPendingOperation[]>([]);
const alive = useRef(true);
const selected = useMemo(() => accounts.find(item => item.id === selectedId) ?? null, [accounts, selectedId]);
const refresh = useCallback(async () => {
const [accountPage, published, recovery] = await Promise.all([
cloudAgentsApi.call('channelAccounts', {}),
loadPublishedAgents(),
cloudAgentsApi.recovery(),
]);
if (!alive.current) return;
setAccounts(accountPage.items);
setAgents(published);
setPending(recovery.pending);
setSelectedId(current => accountPage.items.some(item => item.id === current) ? current : accountPage.items[0]?.id ?? '');
}, []);
useEffect(() => {
alive.current = true;
void refresh().catch(error => { if (alive.current) setError(errorText(error)); });
return () => { alive.current = false; };
}, [refresh]);
useEffect(() => {
if (!selected) return;
setTargetSlug(params.get('target') ?? selected.target_agent_slug ?? '');
setQr(null); setQrAccountId(''); setVerifyCode(''); setConfirmDisconnect(false);
const next = new URLSearchParams(params);
next.set('account', selected.id);
next.delete('target');
setParams(next, { replace: true });
// Query params are consumed once; account changes use authoritative account state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.id]);
const run = async (key: string, task: () => Promise<unknown>, success: string) => {
if (busy) return null;
setBusy(key); setError(''); setNotice('');
try {
const result = await task();
await refresh();
if (alive.current) setNotice(success);
return result;
} catch (failure) {
if (alive.current) setError(errorText(failure));
return null;
} finally {
if (alive.current) setBusy('');
}
};
const create = async () => {
if (!newName.trim()) return;
const result = await run('create', () => cloudAgentsApi.call('createChannelAccount', {
operation_id: operationId(), display_name: newName.trim(),
}), '微信账号已添加,可以先扫码连接,再选择目标智能体。');
const channel = (result as { channel?: CloudUserChannelAccount } | null)?.channel;
if (channel) { setSelectedId(channel.id); setCreating(false); setNewName(''); }
};
const saveRoute = async () => {
if (!selected) return;
await run('route', () => cloudAgentsApi.call('routeChannelAccount', {
channel_account_id: selected.id, operation_id: operationId(), target_agent_slug: targetSlug || null,
expected_revision: selected.revision, enabled: targetSlug ? selected.enabled : false,
}), targetSlug ? '目标智能体已更新,微信连接保持不变。' : '已取消目标智能体,微信连接保持不变。');
};
const setEnabled = async (enabled: boolean) => {
if (!selected) return;
const operation = enabled ? 'enableChannelAccount' : 'pauseChannelAccount';
await run(operation, () => cloudAgentsApi.call(operation, {
channel_account_id: selected.id, operation_id: operationId(), expected_revision: selected.revision,
}), enabled ? '渠道已启用。' : '渠道已暂停,账号连接仍保留。');
};
const startQr = async (force: boolean) => {
if (!selected) return;
const result = await run('qr', () => cloudAgentsApi.call('channelAccountWechatBindStart', {
channel_account_id: selected.id, operation_id: operationId(), force,
}), '请使用个人微信扫码。');
if (result && typeof result === 'object' && 'session_key' in result) {
setQr(result as CloudWechatBindState); setQrAccountId(selected.id);
}
};
const verify = async () => {
if (!selected || !qr || qrAccountId !== selected.id || !verifyCode.trim()) return;
const result = await run('verify', () => cloudAgentsApi.call('channelAccountWechatBindVerification', {
channel_account_id: selected.id, operation_id: operationId(), session_key: qr.session_key, verify_code: verifyCode.trim(),
}), '微信连接已确认。');
if (result && typeof result === 'object' && 'session_key' in result) setQr(result as CloudWechatBindState);
};
const pollingAccountId = selected?.id ?? '';
const pollingSessionKey = qr?.session_key ?? '';
const pollingStatus = qr?.status ?? '';
useEffect(() => {
if (!pollingAccountId || !pollingSessionKey || qrAccountId !== pollingAccountId || ['confirmed', 'failed', 'expired'].includes(pollingStatus)) return;
let stopped = false;
const timer = window.setInterval(() => {
if (document.visibilityState === 'hidden') return;
void cloudAgentsApi.call('channelAccountWechatBindStatus', { channel_account_id: pollingAccountId, session_key: pollingSessionKey })
.then(result => {
if (stopped || !alive.current) return;
setQr(result);
if (result.status === 'confirmed') void refresh();
}).catch(failure => { if (!stopped && alive.current) setError(errorText(failure)); });
}, 2000);
return () => { stopped = true; window.clearInterval(timer); };
}, [pollingAccountId, pollingSessionKey, pollingStatus, qrAccountId, refresh]);
const disconnect = async () => {
if (!selected) return;
await run('disconnect', () => cloudAgentsApi.call('disconnectChannelAccount', {
channel_account_id: selected.id, operation_id: operationId(), expected_revision: selected.revision,
}), '微信账号已断开。');
setConfirmDisconnect(false); setQr(null);
};
return <section className="h-full overflow-auto p-4 sm:p-6" data-testid="channel-accounts-page">
<CloudRecovery pending={pending} onResolved={() => void refresh()} />
<header className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border pb-5">
<div><p className="text-sm text-muted-foreground"></p><h1 className="mt-1 text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p></div>
<Button onClick={() => setCreating(true)}><Plus className="mr-2 h-4 w-4" /></Button>
</header>
{creating && <div className="mb-5 flex flex-wrap items-end gap-3 rounded-xl border border-border bg-background p-4">
<label className="min-w-56 flex-1 space-y-2 text-sm"><span></span><Input autoFocus aria-label="账号备注" value={newName} maxLength={100} onChange={event => setNewName(event.target.value)} placeholder="例如:工作微信" /></label>
<Button disabled={Boolean(busy) || !newName.trim()} onClick={() => void create()}>{busy === 'create' ? '添加中…' : '添加'}</Button>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { setCreating(false); setNewName(''); }}></Button>
</div>}
{error && <div role="alert" className="mb-4 flex items-center justify-between gap-3 rounded-lg bg-destructive/10 p-3 text-sm text-destructive"><span>{error}</span><Button variant="ghost" size="sm" onClick={() => void refresh()}></Button></div>}
{notice && <p role="status" className="mb-4 rounded-lg bg-emerald-50 p-3 text-sm text-emerald-800">{notice}</p>}
{!accounts.length ? <div className="rounded-xl border border-dashed border-border p-10 text-center"><MessageCircle className="mx-auto h-8 w-8 text-brand" />
<h2 className="mt-4 font-medium"></h2><p className="mt-2 text-sm text-muted-foreground">使</p>
<Button variant="outline" className="mt-5" onClick={() => setCreating(true)}></Button></div> : <div className="grid gap-5 lg:grid-cols-[minmax(15rem,20rem)_minmax(0,1fr)]">
<aside aria-label="微信账号" className="space-y-2">{accounts.map(account => <button key={account.id} type="button" onClick={() => setSelectedId(account.id)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${selected?.id === account.id ? 'border-brand/40 bg-brand-soft' : 'border-border bg-background hover:bg-muted/30'}`}>
<span className="flex items-center justify-between gap-2"><strong className="truncate text-sm">{account.display_name}</strong><span className="shrink-0 text-xs text-muted-foreground">{label(account.health)}</span></span>
<span className="mt-2 block truncate text-xs text-muted-foreground">{account.target_agent_name ?? '未分配智能体'}</span>
<span className="mt-3 block text-xs">{account.enabled ? '已启用' : '已暂停'}</span>
</button>)}</aside>
{selected && <article aria-label={`${selected.display_name}账号详情`} className="min-w-0 space-y-5 rounded-xl border border-border bg-background p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="text-lg font-semibold">{selected.display_name}</h2><p className="mt-1 text-sm text-muted-foreground">{label(selected.health)} · {selected.enabled ? '已启用' : '已暂停'}</p></div>
<Button variant="ghost" size="sm" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw className="mr-2 h-4 w-4" /></Button></div>
{selected.blockers.length > 0 && <p className="rounded-lg bg-amber-50 p-3 text-sm text-amber-900">{selected.blockers.map(item => blockerText[item] ?? item).join('')}</p>}
<div className="grid gap-4 rounded-xl bg-muted/30 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<label className="space-y-2 text-sm"><span className="font-medium"></span><Select aria-label="目标智能体" value={targetSlug} onChange={event => setTargetSlug(event.target.value)}>
<option value=""></option>{agents.map(agent => <option key={agent.slug} value={agent.slug}>{agent.name} · v{agent.published_version}</option>)}</Select></label>
<Button variant="outline" disabled={Boolean(busy) || targetSlug === (selected.target_agent_slug ?? '')} onClick={() => void saveRoute()}>{busy === 'route' ? '保存中…' : '保存目标'}</Button>
</div>
<div className="flex flex-wrap gap-2">
{selected.health !== 'connected' ? <Button disabled={Boolean(busy)} onClick={() => void startQr(false)}><QrCode className="mr-2 h-4 w-4" /></Button>
: <Button variant="outline" disabled={Boolean(busy)} onClick={() => void startQr(true)}><RefreshCw className="mr-2 h-4 w-4" /></Button>}
{selected.enabled ? <Button variant="outline" disabled={Boolean(busy)} onClick={() => void setEnabled(false)}><Pause className="mr-2 h-4 w-4" /></Button>
: <Button disabled={Boolean(busy) || selected.health !== 'connected' || !selected.target_agent_slug} onClick={() => void setEnabled(true)}><Play className="mr-2 h-4 w-4" /></Button>}
<Button variant="ghost" className="text-destructive" disabled={Boolean(busy)} onClick={() => setConfirmDisconnect(true)}><Unplug className="mr-2 h-4 w-4" /></Button>
</div>
{qr && qrAccountId === selected.id && <div className="flex flex-wrap gap-5 rounded-xl border border-border p-4" aria-label="微信扫码连接">
<div className="flex h-48 w-48 items-center justify-center rounded-lg bg-white p-3">{qr.qrcode_url ? <CloudWechatQr value={qr.qrcode_url} /> : <Loader2 className="h-7 w-7 animate-spin" />}</div>
<div className="min-w-52 flex-1 space-y-3"><div className="flex items-start justify-between"><div><p className="font-medium">{label(qr.status)}</p><p className="mt-1 text-xs text-muted-foreground"></p>{qr.message && <p className="mt-1 text-xs text-muted-foreground">{qr.message}</p>}</div>
<Button aria-label="关闭二维码" variant="ghost" size="sm" onClick={() => setQr(null)}><X className="h-4 w-4" /></Button></div>
{qr.status === 'verification_required' && <><Input aria-label="微信验证码" value={verifyCode} onChange={event => setVerifyCode(event.target.value)} placeholder="输入微信验证码" /><Button disabled={Boolean(busy) || !verifyCode.trim()} onClick={() => void verify()}></Button></>}
</div></div>}
{confirmDisconnect && <div role="dialog" aria-label="断开微信连接" className="rounded-xl bg-destructive/10 p-4 text-sm"><p className="font-medium"></p><p className="mt-2 text-muted-foreground">使</p><div className="mt-3 flex gap-2"><Button variant="destructive" disabled={Boolean(busy)} onClick={() => void disconnect()}></Button><Button variant="ghost" onClick={() => setConfirmDisconnect(false)}></Button></div></div>}
{selected.target_agent_slug && selected.binding_id ? <div className="border-t border-border pt-5"><CloudChannelPanel slug={selected.target_agent_slug} publishedVersion={selected.target_agent_published_version} activeView accountId={selected.id} detailOnly /></div>
: <div className="rounded-xl border border-dashed border-border p-5 text-sm text-muted-foreground"><p></p>
<Button variant="ghost" className="mt-2 px-0" onClick={() => navigate('/cloud-agents')}><ArrowLeft className="mr-2 h-4 w-4" /></Button></div>}
</article>}
</div>}
</section>;
}

View File

@@ -7,7 +7,6 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAccess as Access, CloudApplication, CloudKey } from '../../../shared/cloud-agents';
import { CloudCosts } from './CloudCosts';
import { CloudLifecycle } from './CloudLifecycle';
import { CloudChannelPanel } from './CloudChannelPanel';
import type { CloudAgentDraft } from '../../../shared/cloud-agents';
import type { CloudAgentOperations } from '../../../shared/cloud-agents';
import { cloudStatus } from './CloudChat';
@@ -71,7 +70,7 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('setEnabled', { slug, enabled: !access.enabled }))}>
{access.enabled ? '停用智能体' : '启用智能体'}</Button></div>}
</section>
{access?.published_version && <CloudChannelPanel slug={slug} publishedVersion={access.published_version} activeView={activeView} />}
{access?.published_version && <LinkedChannels slug={slug} activeView={activeView} />}
{error && <p role="alert" className="text-sm text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}></Button></p>}
{notice && <p role="status" className="text-sm text-muted-foreground">{notice}</p>}
<section className="space-y-4"><h2 className="font-medium"></h2>
@@ -108,6 +107,31 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
</div>;
}
function LinkedChannels({ slug, activeView }: { slug: string; activeView: boolean }) {
const [items, setItems] = useState<CloudAgentOperations['channelBindings']['output']['items']>([]);
const [error, setError] = useState('');
useEffect(() => {
if (!activeView) return;
let live = true;
void cloudAgentsApi.call('channelBindings', { slug }).then(result => {
if (live) { setItems(result.items); setError(''); }
}).catch(error => { if (live) setError(errorText(error)); });
return () => { live = false; };
}, [activeView, slug]);
return <section aria-label="已关联渠道" className="space-y-3 rounded-xl border border-border p-5">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium"></h2>
<p className="mt-1 text-sm text-muted-foreground">使</p></div>
<Button variant="outline" onClick={() => { window.location.hash = `#/cloud-agents/channels?target=${encodeURIComponent(slug)}`; }}></Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{!error && items.length === 0 && <p className="text-sm text-muted-foreground"></p>}
{items.map(item => <button key={item.id} type="button" className="flex w-full items-center justify-between gap-3 rounded-lg bg-muted/30 p-3 text-left text-sm"
onClick={() => { window.location.hash = `#/cloud-agents/channels?account=${encodeURIComponent(item.id)}`; }}>
<span><strong>{item.display_name}</strong><span className="mt-1 block text-xs text-muted-foreground">{item.health === 'connected' ? '微信已连接' : '微信待连接'}</span></span>
<span className="text-xs text-muted-foreground">{item.enabled ? '已启用' : '已暂停'} · </span>
</button>)}
</section>;
}
function ApplicationKeys({ application, onUpdated }: { application: CloudApplication; onUpdated: () => Promise<void> }) {
const [keys, setKeys] = useState<CloudKey[]>([]);
const [secret, setSecret] = useState('');

View File

@@ -22,7 +22,7 @@ function useVisible(active: boolean) {
return active && visible;
}
export function CloudChannelConversations({ slug, activeView = true }: { slug: string; activeView?: boolean }) {
export function CloudChannelConversations({ slug, channelAccountId, activeView = true }: { slug: string; channelAccountId?: string; activeView?: boolean }) {
const [sessions, setSessions] = useState<CloudChannelSession[]>([]);
const [selected, setSelected] = useState('');
const [offset, setOffset] = useState<number | null>(null);
@@ -37,15 +37,18 @@ export function CloudChannelConversations({ slug, activeView = true }: { slug: s
setLoading(true);
void cloudAgentsApi.call('channelConversations', { slug }).then(page => {
if (!live) return;
setSessions(page.items); setOffset(page.next_offset); setError('');
setSelected(current => current || page.items[0]?.session_id || '');
const items = channelAccountId ? page.items.filter(item => item.channel_account_id === channelAccountId) : page.items;
setSessions(items); setOffset(page.next_offset); setError('');
setSelected(current => items.some(item => item.session_id === current) ? current : items[0]?.session_id || '');
}).catch(error => { if (live) setError(failureText(error)); }).finally(() => { if (live) setLoading(false); });
return () => { live = false; };
}, [slug, visible, revision]);
}, [channelAccountId, slug, visible, revision]);
const more = async () => {
if (loading || offset === null) return;
setLoading(true);
try { const page = await cloudAgentsApi.call('channelConversations', { slug, offset }); setSessions(items => [...items, ...page.items]); setOffset(page.next_offset); }
try { const page = await cloudAgentsApi.call('channelConversations', { slug, offset });
const next = channelAccountId ? page.items.filter(item => item.channel_account_id === channelAccountId) : page.items;
setSessions(items => [...items, ...next]); setOffset(page.next_offset); }
catch (error) { setError(failureText(error)); } finally { setLoading(false); }
};
return <section aria-label="本人微信会话" className="space-y-4">

View File

@@ -121,11 +121,13 @@ function recoveryPayload(value: unknown): unknown {
return value;
}
export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: { slug: string; publishedVersion: number | null; activeView?: boolean }) {
const [tab, setTab] = useState<PanelTab>('connect');
export function CloudChannelPanel({ slug, publishedVersion, activeView = true, accountId, detailOnly = false }: {
slug: string; publishedVersion: number | null; activeView?: boolean; accountId?: string; detailOnly?: boolean;
}) {
const [tab, setTab] = useState<PanelTab>(detailOnly ? 'access' : 'connect');
const [bindings, setBindings] = useState<CloudChannelView[]>([]);
const [accounts, setAccounts] = useState<CloudChannelAccountView[]>([]);
const [selectedBindingId, setSelectedBindingId] = useState('');
const [selectedBindingId, setSelectedBindingId] = useState(accountId ?? '');
const [selectedAccountId, setSelectedAccountId] = useState('');
const [loadError, setLoadError] = useState('');
const [notice, setNotice] = useState('');
@@ -153,8 +155,8 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
const currentBindingKeyRef = useRef('');
const binding = useMemo(
() => bindings.find(item => channelAccountId(item) === selectedBindingId) ?? bindings[0] ?? null,
[bindings, selectedBindingId],
() => bindings.find(item => channelAccountId(item) === (accountId ?? selectedBindingId)) ?? (accountId ? null : bindings[0] ?? null),
[accountId, bindings, selectedBindingId],
);
const currentId = channelAccountId(binding);
const currentBindingKey = binding ? `${currentId}:${binding.provider_generation}` : '';
@@ -204,13 +206,13 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
if (!alive.current) return;
setBindings(result.items);
setAccounts(result.available_accounts ?? []);
setSelectedBindingId(current => result.items.some(item => channelAccountId(item) === current)
? current
: channelAccountId(result.items[0]));
setSelectedBindingId(current => accountId && result.items.some(item => channelAccountId(item) === accountId)
? accountId
: result.items.some(item => channelAccountId(item) === current) ? current : accountId ? '' : channelAccountId(result.items[0]));
setSelectedAccountId(current => (result.available_accounts ?? []).some(item => channelAccountId(item) === current)
? current
: '');
}, [slug]);
}, [accountId, slug]);
useEffect(() => {
alive.current = true;
@@ -228,13 +230,14 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart',
'wechatBindVerification', 'revokeChannelCaller', 'retryChannelDeliveryPart',
]);
setPendingRecovery(recovery.pending.filter(item => channelOperations.has(item.operation) && item.input.slug === slug));
setPendingRecovery(recovery.pending.filter(item => channelOperations.has(item.operation) && item.input.slug === slug
&& (!accountId || item.input.channel_account_id === accountId)));
setRecoveryReady(true);
} catch (error) {
if (alive.current) setLoadError(errorText(error));
setRecoveryReady(false);
}
}, [slug]);
}, [accountId, slug]);
useEffect(() => { void loadRecovery(); }, [loadRecovery]);
@@ -607,8 +610,8 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
const enabled = binding?.desired_state === 'enabled' || binding?.enabled === true;
const canEnable = Boolean(binding && publishedVersion && connected);
return <section className="space-y-5 rounded-2xl border border-border/80 bg-background p-5" aria-label="个人微信渠道">
<header className="flex flex-wrap items-start justify-between gap-4">
return <section className={detailOnly ? 'space-y-5' : 'space-y-5 rounded-2xl border border-border/80 bg-background p-5'} aria-label="个人微信渠道">
{!detailOnly && <><header className="flex flex-wrap items-start justify-between gap-4">
<div><div className="flex items-center gap-2"><Wifi className="h-4 w-4 text-brand" /><h2 className="font-medium"></h2></div>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"></p></div>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refreshPanel()}><RefreshCw className="mr-2 h-4 w-4" /></Button>
@@ -619,14 +622,14 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
<StatusPill label={statusLabel(binding?.sync_state)} tone={binding?.sync_state === 'ready' ? 'green' : 'gray'} />
{binding?.health && <span className="text-muted-foreground">{statusLabel(binding.health)}</span>}
{binding?.last_confirmed_at && <span className="text-muted-foreground">{shortDate(binding.last_confirmed_at)}</span>}
</div>
</div></>}
<nav className="flex flex-wrap gap-1 rounded-xl bg-muted/40 p-1" aria-label="微信渠道管理">
{([['connect', '连接微信'], ['access', '使用权限'], ['activity', '活动记录'], ['sessions', '本人微信对话']] as [PanelTab, string][]).map(([key, label]) => <button key={key} type="button" className={`rounded-lg px-3 py-2 text-sm ${tab === key ? 'bg-background font-medium shadow-soft' : 'text-muted-foreground'}`} aria-current={tab === key ? 'page' : undefined} onClick={() => setTab(key)}>{label}</button>)}
{(detailOnly ? [['access', '使用权限'], ['activity', '活动记录'], ['sessions', '本人微信对话']] : [['connect', '连接微信'], ['access', '使用权限'], ['activity', '活动记录'], ['sessions', '本人微信对话']] as [PanelTab, string][]).map(([key, label]) => <button key={key} type="button" className={`rounded-lg px-3 py-2 text-sm ${tab === key ? 'bg-background font-medium shadow-soft' : 'text-muted-foreground'}`} aria-current={tab === key ? 'page' : undefined} onClick={() => setTab(key as PanelTab)}>{label}</button>)}
</nav>
{loadError && <div role="alert" className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive"><CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /><span className="min-w-0 flex-1">{loadError}</span><Button variant="ghost" size="sm" onClick={() => void refreshPanel()}></Button></div>}
{notice && <p role="status" className="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-800">{notice}</p>}
{pendingRecovery.length > 0 && <div aria-label="未确认的微信渠道操作" className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-4 text-sm"><p className="font-medium"> {pendingRecovery.length} </p><p className="text-muted-foreground">使</p>{pendingRecovery.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-3 border-t border-amber-200/70 pt-3"><span>{item.operation} · {shortDate(item.created_at)}</span><div className="flex gap-2"><Button size="sm" disabled={busy} onClick={() => void resolveRecovery(item)}>{busyAction === `recovery:${item.id}` ? '正在确认…' : '恢复上次操作'}</Button><Button variant="ghost" size="sm" disabled={busy} onClick={() => void ignoreRecovery(item)}></Button></div></div>)}</div>}
{tab === 'connect' && <ConnectStep
{!detailOnly && tab === 'connect' && <ConnectStep
slug={slug} binding={binding} bindings={bindings} accounts={accounts} selectedBindingId={selectedBindingId} selectedAccountId={selectedAccountId} busy={busy} busyAction={busyAction}
publishedVersion={publishedVersion} qr={activeQr} qrOpen={qrOpen} verificationCode={verificationCode}
confirmEnable={confirmEnable} confirmUnbind={confirmUnbind} canEnable={canEnable} routeTargetSlug={routeTargetSlug} routeAgents={routeAgents} routePickerOpen={routePickerOpen}
@@ -634,9 +637,9 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
onVerificationCode={setVerificationCode} onVerify={() => void verify()} onEnable={() => void stateAction(true)} onPause={() => void stateAction(false)}
onRouteTarget={setRouteTargetSlug} onOpenRoute={() => void openRoutePicker()} onSwitchRoute={() => void switchRoute()} onConfirmEnable={setConfirmEnable} onUnbind={() => void unbind()} onConfirmUnbind={setConfirmUnbind}
/>}
{tab === 'access' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} onMode={setAccessMode} onSave={() => void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'access' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} detailOnly={detailOnly} onMode={setAccessMode} onSave={() => void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'activity' && <ActivityStep slug={slug} channelAccountId={currentId} refreshKey={activityRevision} activity={activity} nextOffset={activityOffset} busy={busy} onRefresh={() => void loadActivity()} onMore={() => void (activityOffset == null ? undefined : loadActivity(activityOffset))} onRetry={retryPart} />}
{tab === 'sessions' && <CloudChannelConversations slug={slug} activeView={activeView && tab === 'sessions'} />}
{tab === 'sessions' && <CloudChannelConversations slug={slug} channelAccountId={currentId} activeView={activeView && tab === 'sessions'} />}
</section>;
}
@@ -686,9 +689,9 @@ function StepHeading({ number, title, description }: { number: string; title: st
return <div className="flex gap-3 border-t border-border/60 pt-5"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-sm font-medium text-brand">{number}</span><div><h3 className="font-medium">{title}</h3><p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p></div></div>;
}
function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) {
function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, detailOnly = false, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; detailOnly?: boolean; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) {
const inviteEnabled = accessMode === 'invited' && binding?.access_mode === 'invited';
return <div className="space-y-5"><StepHeading number="2" title="谁能使用" description="默认只允许绑定时扫码的微信使用。受邀联系人需要单独邀请;你只能查看受邀者状态和费用,不能读取其私聊内容。" />
return <div className="space-y-5">{!detailOnly && <StepHeading number="2" title="谁能使用" description="默认只允许绑定时扫码的微信使用。受邀联系人需要单独邀请;你只能查看受邀者状态和费用,不能读取其私聊内容。" />}
{!binding ? <p className="rounded-xl bg-muted/30 p-4 text-sm text-muted-foreground"></p> : <>
<div className="space-y-3 rounded-xl border border-border p-4"><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'self_only'} onChange={() => onMode('self_only')} /><span><strong></strong><span className="mt-1 block text-muted-foreground">使</span></span></label><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'invited'} onChange={() => onMode('invited')} /><span><strong></strong><span className="mt-1 block text-muted-foreground"></span></span></label><Button variant="outline" disabled={busy || accessMode === (binding.access_mode === 'invited' ? 'invited' : 'self_only')} onClick={onSave}>{busyAction.startsWith('policy:') ? '保存中…' : '保存使用范围'}</Button></div>
<div>{accessMode === 'invited' && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium"></h3><p className="text-xs leading-5 text-muted-foreground"></p><Button variant="outline" disabled={busy || !inviteEnabled} onClick={onInvite}>{busyAction.includes(':invite') ? '生成中…' : '生成邀请码'}</Button>{!inviteEnabled && <p className="text-xs text-amber-800"></p>}</div>}</div>

View File

@@ -8,7 +8,10 @@ const labels: Record<string, string> = {
createApplication: '创建应用', createKey: '创建密钥', createKnowledge: '创建知识库', processKnowledge: '处理文档',
createSchedule: '创建自动任务', runSchedule: '立即运行任务',
importKnowledgeAttachment: '导入附件到知识库', createChild: '创建子智能体',
wechatBindVerification: '微信连接验证',
wechatBindVerification: '微信连接验证', channelAccountWechatBindVerification: '微信连接验证',
createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道',
pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接',
channelAccountWechatUnbind: '解除微信登录',
};
export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) {
@@ -22,7 +25,7 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp
const response = await cloudAgentsApi.resolvePending(item.id, discard);
if (response.requires_input) {
setResult(null);
setNotice('这次微信验证还需要验证码。请进入对应智能体的「发布与访问个人微信」,继续连接验证。');
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
} else if (!discard) setResult({ operation: item.operation, value: response.result });
onResolved();
} catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); }

View File

@@ -13,6 +13,7 @@ import { CloudChat } from './CloudChat';
import { CloudOverview } from './CloudOverview';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { CloudRecovery } from './CloudRecovery';
import { ChannelAccounts } from './ChannelAccounts';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
@@ -241,5 +242,6 @@ function SharedConversation({ shared, activity, recent, onBack }: {
export default function CloudAgents() {
const accountId = useAuthStore((state) => state.user?.userId);
const location = useLocation();
if (location.pathname.startsWith('/cloud-agents/channels')) return <ChannelAccounts key={`${accountId ?? 'signed-out'}:channels`} />;
return <AgentWorkspace key={`${accountId ?? 'signed-out'}:${location.key}`} />;
}