feat: add user-level WeChat channel accounts
This commit is contained in:
@@ -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"
|
||||
|
||||
217
src/pages/CloudAgents/ChannelAccounts.tsx
Normal file
217
src/pages/CloudAgents/ChannelAccounts.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : '暂时无法确认结果'); }
|
||||
|
||||
@@ -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}`} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user