feat: 简化微信渠道账号连接

This commit is contained in:
2026-09-14 12:05:16 +08:00
parent a721b8fb7e
commit 21e707d9c4
21 changed files with 238 additions and 1078 deletions

View File

@@ -7,7 +7,7 @@ export const cloudAgentsApi = {
remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', {
method: 'PUT', body: JSON.stringify(recent),
}),
resolvePending: (id: string, discard = false) => hostApiFetch<{ result?: unknown; operation?: string; input?: Record<string, unknown>; discarded?: boolean; requires_input?: boolean; pending?: boolean }>(
resolvePending: (id: string, discard = false) => hostApiFetch<{ result?: unknown; operation?: string; input?: Record<string, unknown>; discarded?: boolean; retired?: boolean; requires_input?: boolean; pending?: boolean }>(
CLOUD_AGENTS_PATH + '/recovery/pending', { method: 'POST', body: JSON.stringify({ id, discard }) }),
uploadSkill: () => hostApiFetch<CloudSkillDraft | null>(CLOUD_AGENTS_PATH + '/skills/pick', { method: 'POST' }),
uploadKnowledge: (slug: string, kb_id: string, operation_id: string, replaces_file_id?: string) =>

View File

@@ -207,9 +207,9 @@ export function ChannelAccounts() {
<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>
{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 key={selected.id} slug={selected.target_agent_slug} accountId={selected.id} /></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>}

View File

@@ -120,7 +120,7 @@ function LinkedChannels({ slug, activeView }: { slug: string; activeView: boolea
}, [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>
<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>}

View File

@@ -51,14 +51,14 @@ export function CloudChannelConversations({ slug, channelAccountId, activeView =
setSessions(items => [...items, ...next]); setOffset(page.next_offset); }
catch (error) { setError(failureText(error)); } finally { setLoading(false); }
};
return <section aria-label="本人微信会话" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium"></h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p></div>
return <section aria-label="微信会话" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium"></h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p></div>
<Button variant="ghost" size="sm" disabled={loading || busy} onClick={() => setRevision(value => value + 1)}><RefreshCw className="mr-2 h-4 w-4" /></Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading && !sessions.length && <p role="status" className="text-sm text-muted-foreground"></p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">访</p>}
{!!sessions.length && <label className="block space-y-2 text-sm"><span></span>
{loading && !sessions.length && <p role="status" className="text-sm text-muted-foreground"></p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground"> </p>}
{!!sessions.length && <label className="block space-y-2 text-sm"><span></span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" value={selected} disabled={busy} onChange={event => setSelected(event.target.value)}>
{!sessions.some(session => session.session_id === selected) && <option value={selected}></option>}
{sessions.map(session => <option key={session.session_id} value={session.session_id}>{session.agent_name || '微信助手'} · {session.sequence ?? 1} · {new Date(session.created_at).toLocaleString()}{session.session_state === 'closed' ? ' · 历史' : ''}</option>)}

View File

@@ -1,30 +0,0 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudBudget, CloudChannelView } from '../../../shared/cloud-agents';
export function CloudChannelEnableConfirmation({ slug, publishedVersion, binding, busy, onEnable, onCancel }: {
slug: string; publishedVersion: number | null; binding: CloudChannelView; busy: boolean;
onEnable: () => void; onCancel: () => void;
}) {
const [budget, setBudget] = useState<CloudBudget | null>(null);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
useEffect(() => {
let live = true;
void cloudAgentsApi.call('budget', { slug }).then(value => {
if (live) { setBudget(value); setError(''); }
}).catch(error => { if (live) setError(error instanceof Error ? error.message : '暂时无法读取费用上限'); });
return () => { live = false; };
}, [slug, revision]);
const limit = (value: string | null) => value === null ? '不设额外上限' : `${value} 词元点数`;
return <div role="dialog" aria-label="确认启用个人微信渠道" className="space-y-3 rounded-lg bg-emerald-50 p-4 text-sm">
<p className="font-medium"></p>
<p>{binding.display_name || '个人微信'}使 v{publishedVersion}{binding.access_mode === 'invited' ? '本人及受邀联系人' : '仅本人'}</p>
<p>使</p>
{budget ? <div className="space-y-1"><p>{limit(budget.request_limit_points)}</p><p>{limit(budget.daily_limit_points)}</p></div>
: !error && <p role="status"></p>}
{error && <div className="space-y-2"><p role="alert" className="text-destructive">{error}</p><Button variant="outline" size="sm" onClick={() => { setError(''); setRevision(value => value + 1); }}></Button></div>}
<div className="flex gap-2"><Button disabled={busy || !budget || Boolean(error)} onClick={onEnable}></Button><Button variant="ghost" disabled={busy} onClick={onCancel}></Button></div>
</div>;
}

View File

@@ -1,53 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CircleAlert, Copy, Loader2, Pause, Play, QrCode, RefreshCw, RotateCcw, ShieldCheck, Unplug, Users, Wifi, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, ShieldCheck } from 'lucide-react';
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 {
CloudAgentOperations,
CloudAgentDraft,
CloudChannelActivity,
CloudChannelCaller,
CloudChannelAccountView,
CloudChannelDelivery,
CloudChannelPairing,
CloudChannelView,
CloudPendingOperation,
CloudWechatBindState,
} from '../../../shared/cloud-agents';
import { usePendingCloudInput } from './CloudPending';
import type { CloudChannelActivity, CloudChannelDelivery, CloudPendingOperation } from '../../../shared/cloud-agents';
import { CloudChannelConversations } from './CloudChannelConversations';
import { CloudChannelEnableConfirmation } from './CloudChannelEnableConfirmation';
import { CloudWechatQr } from './CloudWechatQr';
type PanelTab = 'connect' | 'access' | 'activity' | 'sessions';
type ChannelMutationName =
| 'createChannelBinding'
| 'switchChannelBindingAgent'
| 'enableChannelBinding'
| 'pauseChannelBinding'
| 'disconnectChannelBinding'
| 'channelPolicy'
| 'createChannelPairing'
| 'wechatBindStart'
| 'wechatBindVerification'
| 'revokeChannelCaller';
type PanelTab = 'activity' | 'sessions';
const statusLabels: Record<string, string> = {
connected: '已连接', bound: '已连接', enabled: '已启用', active: '运行中', running: '执行中',
completed: '已完成', sent: '已送达', failed: '失败', paused: '已暂停', disabled: '已停用',
offline: '暂时离线', login_required: '需要码', provisioning: '准备中', ready: '就绪',
waiting: '等待中', pending: '处理中', verification_required: '需要验证码', scanned: '已扫码',
confirmed: '已确认', expired: '已过期', revoked: '已撤销', unknown: '状态未知',
connected: '已连接', bound: '已连接', enabled: '已启用', active: '运行中', running: '执行中', completed: '已完成', sent: '已送达', failed: '失败',
paused: '已暂停', disabled: '已停用', offline: '暂时离线', login_required: '需要扫码', provisioning: '准备中', ready: '就绪', waiting: '等待中',
pending: '处理中', verification_required: '需要验证码', scanned: '已扫码', confirmed: '已确认', expired: '已过期', revoked: '已撤销', unknown: '状态未知',
queued: '排队中', preparing: '准备中', input_failed: '输入准备失败', delivery_failed: '投递失败',
};
const blockerLabels: Record<string, string> = {
publish_required: '请先发布智能体版本',
channel_sync_degraded: '渠道状态同步异常,请刷新后重试',
worker_offline: '渠道服务暂时离线',
login_required: '请先连接个人微信',
};
function statusLabel(value: unknown): string {
const key = String(value || 'unknown');
@@ -56,10 +21,8 @@ function statusLabel(value: unknown): string {
function deliveryLabel(value: unknown): string {
const labels: Record<string, string> = {
sent: '已送达', delivered: '已送达', failed: '发送失败', dead: '发送失败',
partial: '部分发送失败', queued: '等待发送', pending: '等待发送',
accepted: '已接收,等待投递', processing: '正在发送',
waiting_context: '等待新的微信消息后再发送', unknown: '状态未知',
sent: '已送达', delivered: '已送达', failed: '发送失败', dead: '发送失败', partial: '部分发送失败', queued: '等待发送', pending: '等待发送',
accepted: '已接收,等待投递', processing: '正在发送', waiting_context: '等待新的微信消息后再发送', unknown: '状态未知',
};
return typeof value === 'string' ? labels[value] ?? '状态待确认' : '状态未知';
}
@@ -74,642 +37,89 @@ function shortDate(value?: string | null): string {
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
}
function operationId(): string {
return crypto.randomUUID();
}
function channelAccountId(view: CloudChannelView | CloudChannelAccountView | null | undefined): string {
return view?.id ?? '';
}
function displayAccount(view: CloudChannelView | CloudChannelAccountView): string {
return view.display_name || '个人微信';
}
function isConnected(view: CloudChannelView | null): boolean {
return view?.health === 'connected';
}
function blockerLabel(value: string): string {
return blockerLabels[value] ?? '渠道配置还未就绪';
}
function tokenUsageLabel(value: Record<string, unknown> | null | undefined): string | null {
if (!value || Object.keys(value).length === 0) return null;
const total = value.total_tokens ?? value.total ?? value.tokens;
return typeof total === 'number' || typeof total === 'string' ? `词元 ${total}` : '账单处理中';
}
function isTerminalQr(state: CloudWechatBindState | null): boolean {
return Boolean(state && ['confirmed', 'expired', 'failed'].includes(state.status));
}
function isWechatBindState(value: unknown): value is CloudWechatBindState {
return Boolean(value && typeof value === 'object' && typeof (value as { session_key?: unknown }).session_key === 'string'
&& typeof (value as { status?: unknown }).status === 'string');
}
function isPairing(value: unknown): value is CloudChannelPairing {
return Boolean(value && typeof value === 'object' && typeof (value as { kind?: unknown }).kind === 'string'
&& typeof (value as { binding_id?: unknown }).binding_id === 'string');
}
function recoveryPayload(value: unknown): unknown {
if (value && typeof value === 'object' && 'result' in value && value.result && typeof value.result === 'object') {
return value.result;
}
return value;
}
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(accountId ?? '');
const [selectedAccountId, setSelectedAccountId] = useState('');
const [loadError, setLoadError] = useState('');
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [busyAction, setBusyAction] = useState('');
const [qr, setQr] = useState<CloudWechatBindState | null>(null);
const [qrBindingKey, setQrBindingKey] = useState('');
const [qrOpen, setQrOpen] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [createdPairing, setCreatedPairing] = useState<CloudChannelPairing | null>(null);
const [accessMode, setAccessMode] = useState<'self_only' | 'invited'>('self_only');
const [callers, setCallers] = useState<CloudChannelCaller[]>([]);
export function CloudChannelPanel({ slug, accountId }: { slug: string; accountId: string }) {
const [tab, setTab] = useState<PanelTab>('activity');
const [activity, setActivity] = useState<CloudChannelActivity[]>([]);
const [activityOffset, setActivityOffset] = useState<number | null>(null);
const [activityRevision, setActivityRevision] = useState(0);
const [confirmEnable, setConfirmEnable] = useState(false);
const [confirmUnbind, setConfirmUnbind] = useState(false);
const [routeTargetSlug, setRouteTargetSlug] = useState('');
const [routeAgents, setRouteAgents] = useState<CloudAgentDraft[]>([]);
const [routePickerOpen, setRoutePickerOpen] = useState(false);
const [pendingRecovery, setPendingRecovery] = useState<CloudPendingOperation[]>([]);
const [recoveryReady, setRecoveryReady] = useState(false);
const alive = useRef(true);
const currentIdRef = useRef('');
const currentBindingKeyRef = useRef('');
const binding = useMemo(
() => 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}` : '';
currentBindingKeyRef.current = currentBindingKey;
const activeQr = qrBindingKey === currentBindingKey ? qr : null;
const activePairing = createdPairing && binding
&& createdPairing.binding_id === binding.binding_id
&& createdPairing.provider_generation === binding.provider_generation
? createdPairing : null;
const bindingAccessMode = binding?.access_mode;
useEffect(() => {
currentIdRef.current = currentId;
setQr(null);
setQrBindingKey('');
setQrOpen(false);
setVerificationCode('');
setCreatedPairing(null);
setCallers([]);
setActivity([]);
setActivityOffset(null);
setActivityRevision(0);
}, [currentBindingKey, currentId]);
const selectBinding = useCallback((value: string) => {
const next = bindings.find(item => channelAccountId(item) === value);
const nextKey = next ? `${value}:${next.provider_generation}` : value;
currentIdRef.current = value;
currentBindingKeyRef.current = nextKey;
setQr(null);
setQrBindingKey('');
setQrOpen(false);
setVerificationCode('');
setCreatedPairing(null);
setCallers([]);
setActivity([]);
setActivityOffset(null);
setActivityRevision(0);
setRouteTargetSlug('');
setRouteAgents([]);
setRoutePickerOpen(false);
setSelectedBindingId(value);
}, [bindings]);
const refresh = useCallback(async () => {
const result = await cloudAgentsApi.call('channelBindings', { slug });
if (!alive.current) return;
setBindings(result.items);
setAccounts(result.available_accounts ?? []);
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
: '');
}, [accountId, slug]);
useEffect(() => {
alive.current = true;
void refresh().catch(error => { if (alive.current) setLoadError(errorText(error)); });
return () => { alive.current = false; };
}, [refresh]);
const [busy, setBusy] = useState(false);
const [loadError, setLoadError] = useState('');
const [notice, setNotice] = useState('');
const loadRecovery = useCallback(async () => {
setRecoveryReady(false);
try {
const recovery = await cloudAgentsApi.recovery();
if (!alive.current) return;
const channelOperations = new Set([
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart',
'wechatBindVerification', 'revokeChannelCaller', 'retryChannelDeliveryPart',
]);
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);
}
const recovery = await cloudAgentsApi.recovery();
setPendingRecovery(recovery.pending.filter(item => item.operation === 'retryChannelDeliveryPart'
&& item.input.slug === slug && item.input.channel_account_id === accountId));
}, [accountId, slug]);
useEffect(() => { void loadRecovery(); }, [loadRecovery]);
const refreshPanel = async () => {
setLoadError('');
try { await Promise.all([refresh(), loadRecovery()]); }
catch (error) { setLoadError(errorText(error)); }
};
useEffect(() => {
if (bindingAccessMode) setAccessMode(bindingAccessMode === 'invited' ? 'invited' : 'self_only');
}, [bindingAccessMode, currentId]);
const runMutation = useCallback(async function <K extends ChannelMutationName>(
key: string,
operation: K,
input: CloudAgentOperations[K]['input'] & { operation_id: string; channel_account_id?: string },
success?: string,
): Promise<unknown | null> {
if (busy) return null;
if (!recoveryReady) {
setNotice('正在确认上次渠道操作,请稍后再试');
return null;
}
if (pendingRecovery.length > 0) {
setNotice('请先恢复或移除上次未确认的渠道操作');
return null;
}
setBusy(true);
setBusyAction(key);
setLoadError('');
setNotice('');
try {
const result = await cloudAgentsApi.call(operation, input);
await refresh();
if (alive.current && success) setNotice(success);
return result;
} catch (error) {
if (alive.current) setLoadError(errorText(error));
await loadRecovery();
return null;
} finally {
if (alive.current) { setBusy(false); setBusyAction(''); }
}
}, [busy, loadRecovery, pendingRecovery.length, recoveryReady, refresh]);
const resolveRecovery = async (pending: CloudPendingOperation) => {
if (busy) return;
setBusy(true);
setBusyAction(`recovery:${pending.id}`);
setLoadError('');
try {
const requestedAccountId = typeof pending.input.channel_account_id === 'string' ? pending.input.channel_account_id : '';
if (requestedAccountId) {
const matching = bindings.find(item => channelAccountId(item) === requestedAccountId);
if (!matching) throw new Error('尚未读取到该操作的微信连接。请刷新状态后再次恢复,本机记录已保留。');
if (requestedAccountId !== currentIdRef.current) {
selectBinding(requestedAccountId);
setTab('connect');
setNotice(`已切换到${displayAccount(matching)},请再次点击恢复上次操作。`);
return;
}
}
const response = await cloudAgentsApi.resolvePending(pending.id, false);
const requestedGeneration = typeof pending.input.provider_generation === 'string' ? pending.input.provider_generation : '';
const requestedBindingKey = requestedAccountId && requestedGeneration ? `${requestedAccountId}:${requestedGeneration}` : '';
if ('requires_input' in response && response.requires_input === true) {
const sessionKey = typeof pending.input.session_key === 'string' ? pending.input.session_key : '';
const matchesCurrent = (!requestedAccountId || requestedAccountId === currentIdRef.current)
&& (!requestedBindingKey || requestedBindingKey === currentBindingKeyRef.current);
if (!sessionKey || !matchesCurrent) throw new Error('微信连接已变化,请刷新状态后重新确认。本机记录已保留。');
setQrBindingKey(currentBindingKeyRef.current);
setQr({ session_key: sessionKey, status: 'verification_required', message: '请输入新的微信验证码' });
setQrOpen(true);
setTab('connect');
await cloudAgentsApi.resolvePending(pending.id, true);
setNotice('上次扫码需要新的验证码,请输入后重新提交');
await loadRecovery();
return;
}
if ('pending' in response && response.pending === true) {
await refresh();
await loadRecovery();
setNotice('上次渠道操作仍在处理中,请稍后再次恢复');
return;
}
const payload = recoveryPayload(response.result);
const matchesCurrent = (!requestedAccountId || requestedAccountId === currentIdRef.current)
&& (!requestedBindingKey || requestedBindingKey === currentBindingKeyRef.current);
if (isWechatBindState(payload) && matchesCurrent) {
setQrBindingKey(currentBindingKeyRef.current);
setQr(payload);
setQrOpen(true);
} else if (isPairing(payload) && payload.kind === 'invite' && matchesCurrent) {
setCreatedPairing(payload);
}
await refresh();
await loadRecovery();
setNotice('已恢复上次渠道操作,状态已经刷新');
} catch (error) {
setLoadError(errorText(error));
} finally {
if (alive.current) { setBusy(false); setBusyAction(''); }
}
};
const ignoreRecovery = async (pending: CloudPendingOperation) => {
if (busy) return;
setBusy(true);
setBusyAction(`ignore:${pending.id}`);
setLoadError('');
try {
await cloudAgentsApi.resolvePending(pending.id, true);
await loadRecovery();
setNotice('已移除本机未确认提醒,云端状态未被修改');
} catch (error) {
setLoadError(errorText(error));
} finally {
if (alive.current) { setBusy(false); setBusyAction(''); }
}
};
const startBinding = async () => {
if (!publishedVersion) return;
const input: CloudAgentOperations['createChannelBinding']['input'] = {
slug,
operation_id: operationId(),
display_name: '我的微信助手',
...(selectedAccountId ? { channel_account_id: selectedAccountId } : {}),
};
await runMutation('create-binding', 'createChannelBinding', input, '渠道已创建,下一步连接个人微信');
setTab('connect');
};
const startQr = async (force: boolean) => {
if (!binding) return;
const requestedAccountId = currentId;
const requestedBindingKey = currentBindingKey;
const key = `qr-start:${currentId}:${force ? 'force' : 'normal'}`;
const input: CloudAgentOperations['wechatBindStart']['input'] = {
slug, channel_account_id: currentId, force,
operation_id: operationId(),
};
const result = await runMutation(key, 'wechatBindStart', input, '二维码已生成,请使用个人微信扫码');
if (requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current && isWechatBindState(result)) {
setQrBindingKey(requestedBindingKey);
setQr(result);
setQrOpen(true);
}
};
const refreshQr = useCallback(async (sessionKey: string) => {
if (!currentId || !sessionKey) return;
const requestedAccountId = currentId;
const requestedBindingKey = currentBindingKey;
try {
const result = await cloudAgentsApi.call('wechatBindStatus', {
slug, channel_account_id: currentId, session_key: sessionKey,
});
const isCurrent = alive.current && requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current;
if (isCurrent) setQr(current => current?.session_key === sessionKey ? result : current);
if (isCurrent && result.status === 'confirmed') {
setQrOpen(false);
await refresh();
}
} catch (error) {
if (alive.current) setLoadError(errorText(error));
}
}, [currentBindingKey, currentId, refresh, slug]);
const sessionKey = activeQr?.session_key ?? '';
const qrTerminal = isTerminalQr(activeQr);
useEffect(() => {
if (!activeView || tab !== 'connect' || !qrOpen || !currentId || !sessionKey || qrTerminal) return;
let cancelled = false;
let timer: number | undefined;
const poll = () => {
if (cancelled || document.visibilityState === 'hidden') return;
void refreshQr(sessionKey).finally(() => {
if (!cancelled && document.visibilityState === 'visible') timer = window.setTimeout(poll, 2000);
});
};
const onVisibility = () => { if (document.visibilityState === 'visible') poll(); };
document.addEventListener('visibilitychange', onVisibility);
poll();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [activeView, currentId, qrOpen, qrTerminal, refreshQr, sessionKey, tab]);
const verify = async () => {
if (!binding || !activeQr?.session_key || !verificationCode.trim()) return;
const requestedAccountId = currentId;
const requestedBindingKey = currentBindingKey;
const key = `qr-verify:${currentId}:${verificationCode.trim()}`;
const input: CloudAgentOperations['wechatBindVerification']['input'] = {
slug, channel_account_id: currentId, session_key: activeQr.session_key,
verify_code: verificationCode.trim(), operation_id: operationId(),
};
const result = await runMutation(key, 'wechatBindVerification', input, '验证码已提交,正在确认登录状态');
if (requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current && isWechatBindState(result)) {
setQrBindingKey(requestedBindingKey);
setQr(result);
setVerificationCode('');
setQrOpen(true);
}
};
const updateAccessMode = async () => {
if (!binding) return;
const key = `policy:${currentId}:${accessMode}`;
const input: CloudAgentOperations['channelPolicy']['input'] = {
slug, channel_account_id: currentId, access_mode: accessMode,
expected_policy_revision: binding.policy_revision ?? 0,
operation_id: operationId(),
};
await runMutation(key, 'channelPolicy', input,
accessMode === 'self_only' ? '已切换为仅本人使用' : '已允许受邀联系人使用');
await loadCallers();
};
const issueInvite = async () => {
const kind = 'invite';
if (!binding) return;
if (binding.access_mode !== 'invited') {
setNotice('请先保存“本人及受邀联系人”,再生成邀请码');
return;
}
const requestedAccountId = currentId;
const key = `pairing:${currentId}:${kind}`;
const input: CloudAgentOperations['createChannelPairing']['input'] = {
slug, channel_account_id: currentId, kind,
operation_id: operationId(),
};
const result = await runMutation(key, 'createChannelPairing', input,
'邀请码已生成,请手动发给受邀联系人');
if (requestedAccountId === currentIdRef.current && isPairing(result)) setCreatedPairing(result);
};
const loadCallers = useCallback(async () => {
if (!currentId) return;
const requestedAccountId = currentId;
const requestedBindingKey = currentBindingKey;
try {
const result = await cloudAgentsApi.call('channelCallers', { slug, channel_account_id: currentId });
if (alive.current && requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current) setCallers(result.items);
} catch (error) {
if (alive.current) setLoadError(errorText(error));
}
}, [currentBindingKey, currentId, slug]);
useEffect(() => { if (tab === 'access') void loadCallers(); }, [loadCallers, tab]);
const loadActivity = useCallback(async (offset = 0) => {
if (!currentId) return;
const requestedAccountId = currentId;
const requestedBindingKey = currentBindingKey;
const result = await cloudAgentsApi.call('channelActivity', { slug, channel_account_id: accountId, offset, limit: 50 });
setActivity(current => offset > 0 ? [...current, ...result.items] : result.items);
setActivityOffset(result.next_offset);
setActivityRevision(current => current + 1);
}, [accountId, slug]);
useEffect(() => {
setActivity([]); setActivityOffset(null); setLoadError(''); setNotice('');
void Promise.all([loadActivity(), loadRecovery()]).catch(error => setLoadError(errorText(error)));
}, [loadActivity, loadRecovery]);
const resolveRecovery = async (pending: CloudPendingOperation, discard: boolean) => {
if (busy) return;
setBusy(true); setLoadError(''); setNotice('');
try {
const result = await cloudAgentsApi.call('channelActivity', { slug, channel_account_id: currentId, offset, limit: 50 });
if (!alive.current || requestedAccountId !== currentIdRef.current || requestedBindingKey !== currentBindingKeyRef.current) return;
setActivity(current => offset ? [...current, ...result.items] : result.items);
setActivityOffset(result.next_offset);
setActivityRevision(value => value + 1);
} catch (error) {
if (alive.current) setLoadError(errorText(error));
}
}, [currentBindingKey, currentId, slug]);
await cloudAgentsApi.resolvePending(pending.id, discard);
await Promise.all([loadActivity(), loadRecovery()]);
setNotice(discard ? '已移除本机未确认提醒,云端状态未被修改' : '已恢复上次文件补发,状态已经刷新');
} catch (error) { setLoadError(errorText(error)); }
finally { setBusy(false); }
};
useEffect(() => { if (tab === 'activity') void loadActivity(); }, [loadActivity, tab]);
const retryPart = async (partId: string, messageId: string) => {
if (!currentId) return;
if (!recoveryReady) {
setNotice('正在确认上次渠道操作,请稍后再试');
return;
}
if (pendingRecovery.length > 0) {
setNotice('请先恢复或移除上次未确认的渠道操作');
const retryDelivery = async (partId: string, messageId: string) => {
if (busy || pendingRecovery.length > 0) {
if (pendingRecovery.length > 0) setNotice('请先恢复或移除上次未确认的文件补发');
return;
}
setBusy(true); setLoadError(''); setNotice('');
try {
await cloudAgentsApi.call('retryChannelDeliveryPart', {
slug, channel_account_id: currentId, logical_message_id: messageId, part_id: partId,
});
setNotice('已提交文件补发');
await loadActivity();
await cloudAgentsApi.call('retryChannelDeliveryPart', { slug, channel_account_id: accountId, logical_message_id: messageId, part_id: partId });
await Promise.all([loadActivity(), loadRecovery()]);
setNotice('文件补发已受理,不会重新运行智能体');
} catch (error) {
setLoadError(errorText(error));
await loadRecovery();
};
await loadRecovery().catch(() => undefined);
} finally { setBusy(false); }
};
const revoke = async (caller: CloudChannelCaller) => {
if (!binding) return;
const key = `revoke:${caller.caller_id}`;
const input: CloudAgentOperations['revokeChannelCaller']['input'] = {
slug, channel_account_id: currentId, caller_id: caller.caller_id,
operation_id: operationId(),
};
await runMutation(key, 'revokeChannelCaller', input, '已撤销此调用者');
await loadCallers();
};
const stateAction = async (enabled: boolean) => {
if (!binding) return;
const key = `state:${currentId}:${enabled ? 'enable' : 'pause'}`;
if (enabled) {
const input: CloudAgentOperations['enableChannelBinding']['input'] = {
slug, channel_account_id: currentId,
expected_revision: binding.revision ?? binding.route_revision,
operation_id: operationId(),
};
await runMutation(key, 'enableChannelBinding', input, '微信渠道已启用');
} else {
const input: CloudAgentOperations['pauseChannelBinding']['input'] = {
slug, channel_account_id: currentId,
expected_revision: binding.revision ?? binding.route_revision,
operation_id: operationId(),
};
await runMutation(key, 'pauseChannelBinding', input, '微信渠道已暂停');
}
setConfirmEnable(false);
};
const switchRoute = async () => {
if (!binding || !routeTargetSlug.trim()) return;
const key = `route:${currentId}:${routeTargetSlug.trim()}`;
const input: CloudAgentOperations['switchChannelBindingAgent']['input'] = {
slug, channel_account_id: currentId, target_agent_slug: routeTargetSlug.trim(),
expected_revision: binding.revision ?? binding.route_revision, enabled: binding.enabled,
operation_id: operationId(),
};
const result = await runMutation(key, 'switchChannelBindingAgent', input, '渠道已切换到目标智能体');
if (result) setRouteTargetSlug('');
};
const openRoutePicker = async () => {
try {
const agents: CloudAgentDraft[] = [];
let cursor: string | null = null;
do {
const page = await cloudAgentsApi.list(cursor);
agents.push(...page.agents);
cursor = page.next_cursor;
} while (cursor);
setRouteAgents(agents.filter(agent => agent.slug !== slug && agent.published_version !== null));
setRoutePickerOpen(true);
} catch (error) {
setLoadError(errorText(error));
}
};
const unbind = async () => {
if (!binding) return;
const key = `unbind:${currentId}`;
const input: CloudAgentOperations['disconnectChannelBinding']['input'] = {
slug, channel_account_id: currentId,
expected_revision: binding.revision ?? binding.route_revision,
operation_id: operationId(),
};
await runMutation(key, 'disconnectChannelBinding', input, '微信已解绑,渠道保留历史活动');
setConfirmUnbind(false); setQr(null); setQrOpen(false);
};
usePendingCloudInput(busy || Boolean(verificationCode) || Boolean(createdPairing));
const connected = isConnected(binding);
const enabled = binding?.desired_state === 'enabled' || binding?.enabled === true;
const canEnable = Boolean(binding && publishedVersion && connected);
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>
</header>
<div className="flex flex-wrap items-center gap-2 text-xs" aria-label="渠道状态">
<StatusPill label={enabled ? '渠道已启用' : '渠道未启用'} tone={enabled ? 'green' : 'gray'} />
<StatusPill label={connected ? '微信已连接' : '微信未连接'} tone={connected ? 'green' : 'amber'} />
<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></>}
<nav className="flex flex-wrap gap-1 rounded-xl bg-muted/40 p-1" aria-label="微信渠道管理">
{(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>}
return <section className="space-y-5" aria-label="微信渠道">
{loadError && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{loadError}</p>}
{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>}
{!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}
onSelectBinding={selectBinding} onSelectAccount={setSelectedAccountId} onCreate={() => void startBinding()} onStartQr={() => void startQr(false)} onRestartQr={() => void startQr(true)} onCloseQr={() => setQrOpen(false)}
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} 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} channelAccountId={currentId} activeView={activeView && tab === 'sessions'} />}
{pendingRecovery.map(pending => <div key={pending.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-amber-50 p-3 text-sm">
<span></span><div className="flex gap-2"><Button size="sm" disabled={busy} onClick={() => void resolveRecovery(pending, false)}></Button><Button size="sm" variant="ghost" disabled={busy} onClick={() => void resolveRecovery(pending, true)}></Button></div>
</div>)}
<div className="flex flex-wrap gap-2 rounded-xl bg-muted/40 p-1.5">
{([['activity', '活动记录'], ['sessions', '微信对话']] as const).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>)}
</div>
{tab === 'activity' && <ActivityStep slug={slug} channelAccountId={accountId} refreshKey={activityRevision} activity={activity} nextOffset={activityOffset} busy={busy}
onRefresh={() => void loadActivity().catch(error => setLoadError(errorText(error)))}
onMore={() => activityOffset != null && void loadActivity(activityOffset).catch(error => setLoadError(errorText(error)))}
onRetry={(partId, messageId) => void retryDelivery(partId, messageId)} />}
{tab === 'sessions' && <CloudChannelConversations slug={slug} channelAccountId={accountId} activeView />}
</section>;
}
function StatusPill({ label, tone }: { label: string; tone: 'green' | 'amber' | 'gray' }) {
const color = tone === 'green' ? 'bg-emerald-100 text-emerald-800' : tone === 'amber' ? 'bg-amber-100 text-amber-800' : 'bg-muted text-muted-foreground';
return <span className={`rounded-full px-2.5 py-1 ${color}`}>{label}</span>;
}
function ConnectStep({
slug, binding, bindings, accounts, selectedBindingId, selectedAccountId, busy, busyAction, publishedVersion, qr, qrOpen, verificationCode,
confirmEnable, confirmUnbind, canEnable, routeTargetSlug, routeAgents, routePickerOpen, onSelectBinding, onSelectAccount, onCreate, onStartQr, onRestartQr, onCloseQr,
onVerificationCode, onVerify, onEnable, onPause, onRouteTarget, onOpenRoute, onSwitchRoute, onConfirmEnable, onUnbind, onConfirmUnbind,
}: {
slug: string; binding: CloudChannelView | null; bindings: CloudChannelView[]; accounts: CloudChannelAccountView[]; selectedBindingId: string; selectedAccountId: string; busy: boolean; busyAction: string;
publishedVersion: number | null; qr: CloudWechatBindState | null; qrOpen: boolean; verificationCode: string; confirmEnable: boolean; confirmUnbind: boolean; canEnable: boolean; routeTargetSlug: string; routeAgents: CloudAgentDraft[]; routePickerOpen: boolean;
onSelectBinding: (value: string) => void; onSelectAccount: (value: string) => void; onCreate: () => void; onStartQr: () => void; onRestartQr: () => void; onCloseQr: () => void;
onVerificationCode: (value: string) => void; onVerify: () => void; onEnable: () => void; onPause: () => void; onRouteTarget: (value: string) => void; onOpenRoute: () => void; onSwitchRoute: () => void; onConfirmEnable: (value: boolean) => void;
onUnbind: () => void; onConfirmUnbind: (value: boolean) => void;
}) {
const id = channelAccountId(binding);
const connected = isConnected(binding);
const available = accounts.filter(account => account.adoption_state === 'available');
const occupied = accounts.filter(account => account.adoption_state && account.adoption_state !== 'available');
return <div className="space-y-5">
<StepHeading number="1" title="连接个人微信" description="选择已有的个人微信连接,或创建一个新的连接。账号被其他智能体占用时必须明确切换。" />
{!binding && <div className="space-y-4 rounded-xl bg-muted/30 p-4"><label className="block space-y-2 text-sm"><span></span><Select aria-label="已有个人微信连接" value={selectedAccountId} onChange={event => onSelectAccount(event.target.value)}><option value=""></option>{available.map(account => <option key={channelAccountId(account)} value={channelAccountId(account)}>{displayAccount(account)}</option>)}</Select></label>{occupied.length > 0 && <p className="text-xs leading-5 text-muted-foreground">使</p>}<Button disabled={busy || !publishedVersion} onClick={onCreate}>{busyAction === 'create-binding' ? '创建中…' : selectedAccountId ? '接入选中的连接' : '创建微信连接'}</Button>{!publishedVersion && <p className="text-xs text-muted-foreground"></p>}</div>}
{bindings.length > 1 && <label className="block max-w-md space-y-2 text-sm"><span></span><Select aria-label="当前渠道" value={selectedBindingId || id} onChange={event => onSelectBinding(event.target.value)}>{bindings.map(item => <option key={channelAccountId(item)} value={channelAccountId(item)}>{displayAccount(item)} · {statusLabel(item.desired_state)}</option>)}</Select></label>}
{binding && <div className="space-y-4 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h3 className="font-medium">{displayAccount(binding)}</h3></div><StatusPill label={connected ? '微信已连接' : statusLabel(binding.health)} tone={connected ? 'green' : 'amber'} /></div>
{binding.blockers?.length ? <p className="text-sm text-amber-800">{binding.blockers.map(blockerLabel).join('、')}</p> : null}
{!connected && !qrOpen && <Button disabled={busy} onClick={onStartQr}><QrCode className="mr-2 h-4 w-4" />{busyAction.startsWith('qr-start:') ? '重试扫码' : '扫码连接微信'}</Button>}
{qrOpen && qr && <QrCard qr={qr} verificationCode={verificationCode} busy={busy} onClose={onCloseQr} onRestart={onRestartQr} onVerificationCode={onVerificationCode} onVerify={onVerify} />}
{connected && !qrOpen && <div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy} onClick={onStartQr}><RefreshCw className="mr-2 h-4 w-4" /></Button>{binding.desired_state === 'enabled' || binding.enabled ? <Button variant="outline" disabled={busy} onClick={onPause}><Pause className="mr-2 h-4 w-4" /></Button> : <Button disabled={busy || !canEnable} onClick={() => onConfirmEnable(true)}><Play className="mr-2 h-4 w-4" /></Button>}<Button variant="ghost" className="text-destructive" disabled={busy} onClick={() => onConfirmUnbind(true)}><Unplug className="mr-2 h-4 w-4" /></Button></div>}
{confirmEnable && binding && <CloudChannelEnableConfirmation slug={slug} publishedVersion={publishedVersion} binding={binding} busy={busy} onEnable={onEnable} onCancel={() => onConfirmEnable(false)} />}
{confirmUnbind && <div role="dialog" className="space-y-3 rounded-lg bg-destructive/10 p-4 text-sm"><p className="font-medium"></p><p></p><div className="flex gap-2"><Button variant="destructive" disabled={busy} onClick={onUnbind}></Button><Button variant="ghost" disabled={busy} onClick={() => onConfirmUnbind(false)}></Button></div></div>}
{connected && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h4 className="text-sm font-medium"></h4><p className="text-xs leading-5 text-muted-foreground"></p>{!routePickerOpen && <Button variant="outline" disabled={busy} onClick={onOpenRoute}></Button>}{routePickerOpen && <div className="flex flex-wrap gap-2"><Select className="min-w-[16rem] flex-1" aria-label="目标智能体" value={routeTargetSlug} onChange={event => onRouteTarget(event.target.value)}><option value=""></option>{routeAgents.map(agent => <option key={agent.slug} value={agent.slug}>{agent.name} · {agent.slug}</option>)}</Select><Button variant="outline" disabled={busy || !routeTargetSlug} onClick={onSwitchRoute}></Button>{!routeAgents.length && <p className="basis-full text-xs text-muted-foreground"></p>}</div>}</div>}
</div>}
<StepHeading number="2" title="选择使用范围" description="连接完成后,在“使用权限”中选择仅本人或本人及受邀联系人;保存后还要显式启用渠道。" />
</div>;
}
function QrCard({ qr, verificationCode, busy, onClose, onRestart, onVerificationCode, onVerify }: { qr: CloudWechatBindState; verificationCode: string; busy: boolean; onClose: () => void; onRestart: () => void; onVerificationCode: (value: string) => void; onVerify: () => void }) {
return <div className="flex flex-wrap gap-5 rounded-xl bg-muted/30 p-4"><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-8 w-8 animate-spin text-muted-foreground" />}</div><div className="min-w-[15rem] flex-1 space-y-3"><div className="flex items-start justify-between gap-3"><div><p className="font-medium">{statusLabel(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={onClose}><X className="h-4 w-4" /></Button></div>{qr.status === 'verification_required' && <div className="space-y-2"><label className="block text-sm"><span className="sr-only"></span><Input aria-label="微信验证码" value={verificationCode} onChange={event => onVerificationCode(event.target.value)} placeholder="输入微信验证码" autoComplete="one-time-code" /></label><Button disabled={busy || !verificationCode.trim()} onClick={onVerify}>{busy ? '提交中…' : '提交验证码'}</Button></div>}<div className="flex flex-wrap gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={onRestart}><RotateCcw className="mr-2 h-4 w-4" /></Button><span className="self-center text-xs text-muted-foreground"></span></div></div></div>;
}
function StepHeading({ number, title, description }: { number: string; title: string; description: string }) {
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, 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">{!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>
{pairing?.kind === 'invite' && <PairingResult value={pairing} onClear={onClearPairing} />}
<div className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="flex items-center gap-2 text-sm font-medium"><Users className="h-4 w-4" /></h3><span className="text-xs text-muted-foreground">{callers.length} </span></div>{!callers.length && <p className="text-sm text-muted-foreground"></p>}{callers.map(caller => <div key={caller.caller_id} className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-sm"><div><p>{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}</p><p className="mt-1 text-xs text-muted-foreground">{statusLabel(caller.grant_state)} · {shortDate(caller.created_at)}</p></div><Button variant="ghost" size="sm" className="text-destructive" disabled={busy || caller.grant_state === 'revoked'} onClick={() => onRevoke(caller)}></Button></div>)}</div>
</>}</div>;
}
function PairingResult({ value, onClear }: { value: CloudChannelPairing; onClear: () => void }) {
const copy = () => { if (value.code) void navigator.clipboard.writeText(`邀请 ${value.code}`); };
return <div role="status" className="space-y-3 rounded-xl bg-amber-50 p-4 text-sm"><div className="flex items-start justify-between gap-3"><div><p className="font-medium"></p><p className="mt-1 text-xs text-amber-900/70">{value.expires_at ? shortDate(value.expires_at) : '短期有效'}</p></div><Button variant="ghost" size="sm" onClick={onClear}></Button></div>{value.code ? <div className="flex items-center gap-2"><code className="min-w-0 flex-1 rounded-lg bg-white px-3 py-2 font-mono text-base tracking-widest">{value.code}</code><Button aria-label="复制邀请命令" variant="outline" size="sm" onClick={copy}><Copy className="h-4 w-4" /></Button></div> : <p className="text-xs text-amber-900/70"></p>}</div>;
}
function ActivityStep({ slug, channelAccountId, refreshKey, activity, nextOffset, busy, onRefresh, onMore, onRetry }: {
slug: string; channelAccountId: string; refreshKey: number; activity: CloudChannelActivity[]; nextOffset: number | null; busy: boolean;
onRefresh: () => void; onMore: () => void; onRetry: (partId: string, messageId: string) => void;
}) {
return <div className="space-y-4"><div className="flex items-start justify-between gap-3"><div><h3 className="font-medium"></h3><p className="mt-1 text-sm text-muted-foreground"></p></div><Button variant="ghost" size="sm" disabled={busy} onClick={onRefresh}><RefreshCw className="mr-2 h-4 w-4" /></Button></div>{!activity.length && <p className="rounded-xl bg-muted/30 p-8 text-center text-sm text-muted-foreground"></p>}{activity.map((item, index) => <ActivityItem key={`${item.request_id ?? item.run_id ?? 'activity'}-${index}`} slug={slug} channelAccountId={channelAccountId} refreshKey={refreshKey} item={item} busy={busy} onRetry={onRetry} />)}{nextOffset != null && <Button variant="outline" disabled={busy} onClick={onMore}></Button>}</div>;
return <div className="space-y-4"><div className="flex items-start justify-between gap-3"><div><h3 className="font-medium"></h3><p className="mt-1 text-sm text-muted-foreground"></p></div><Button variant="ghost" size="sm" disabled={busy} onClick={onRefresh}><RefreshCw className="mr-2 h-4 w-4" /></Button></div>{!activity.length && <p className="rounded-xl bg-muted/30 p-8 text-center text-sm text-muted-foreground"></p>}{activity.map((item, index) => <ActivityItem key={`${item.request_id ?? item.run_id ?? 'activity'}-${index}`} slug={slug} channelAccountId={channelAccountId} refreshKey={refreshKey} item={item} busy={busy} onRetry={onRetry} />)}{nextOffset != null && <Button variant="outline" disabled={busy} onClick={onMore}></Button>}</div>;
}
function ActivityItem({ slug, channelAccountId, refreshKey, item, busy, onRetry }: {

View File

@@ -13,6 +13,7 @@ const labels: Record<string, string> = {
pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接',
channelAccountWechatUnbind: '解除微信登录',
};
const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']);
export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) {
const [busy, setBusy] = useState('');
@@ -26,6 +27,9 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp
if (response.requires_input) {
setResult(null);
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
} else if (response.retired) {
setResult(null);
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
} else if (!discard) setResult({ operation: item.operation, value: response.result });
onResolved();
} catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); }
@@ -36,10 +40,12 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp
{pending.length > 0 && <><h2 className="font-medium"> {pending.length} </h2>
<p className="text-muted-foreground"></p></>}
{pending.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-3">
<div><p>{labels[item.operation] ?? '云端操作'} · {new Date(item.created_at).toLocaleString()}</p>
<div><p>{retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '云端操作'} · {new Date(item.created_at).toLocaleString()}</p>
{typeof item.input.query === 'string' && <p className="mt-1 line-clamp-2 text-muted-foreground">{item.input.query}</p>}</div>
<div className="flex gap-2"><Button size="sm" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在确认…' : '重试并确认结果'}</Button>
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item, true)}></Button></div>
{retiredChannelOperations.has(item.operation)
? <Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
: <div className="flex gap-2"><Button size="sm" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在确认…' : '重试并确认结果'}</Button>
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item, true)}></Button></div>}
</div>)}
{result && <div className="space-y-2"><p>{labels[result.operation] ?? '操作'}{result.operation === 'createKey' && '请保存下面的密钥,关闭后不再显示。'}</p>
<details open={result.operation === 'createKey'}><summary className="cursor-pointer"></summary>

View File

@@ -41,7 +41,7 @@ export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { s
<div className="flex items-start justify-between gap-4"><div><h3 className="font-medium">{job.name}</h3>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{job.prompt}</p></div><span className="shrink-0 text-xs">{job.enabled ? '已启用' : '已暂停'}</span></div>
<p className="text-xs text-muted-foreground tabular-nums">{scheduleLabel(job.cron_expression)} · {job.timezone} · {job.enabled && job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '—'}</p>
<p className="text-xs text-muted-foreground">{job.result_notification?.enabled ? '结果同时发送到指定的本人微信对话' : '结果保存在客户端任务历史'}</p>
<p className="text-xs text-muted-foreground">{job.result_notification?.enabled ? '结果同时发送到指定的微信对话' : '结果保存在客户端任务历史'}</p>
<div className="flex flex-wrap gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void act(async () => {
const operation = runs.current.get(job.id) ?? crypto.randomUUID(); runs.current.set(job.id, operation);
await cloudAgentsApi.call('runSchedule', { slug, job_id: job.id, operation_id: operation }); runs.current.delete(job.id);
@@ -56,7 +56,7 @@ export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { s
{job.runs?.map((run, i) => <div key={i} className="flex items-center justify-between gap-3 border-t border-border/50 pt-3 text-sm">
<div><p>{cloudStatus(run.status)}{run.error_message && <span className="ml-2 text-muted-foreground">{run.error_message}</span>}</p>
{run.result_notification && <p className="mt-1 text-xs text-muted-foreground">{run.result_notification.state === 'waiting_context'
? '微信通知待恢复:请确认连接,并在本人微信对话中发送一条消息。任务结果已独立保存。'
? '微信通知待恢复:请确认连接,并在对应微信对话中发送一条消息。任务结果已独立保存。'
: '微信通知已配置,实际送达情况可在发布渠道的活动记录中查看。'}</p>}</div>
{run.conversation_available && <Button variant="ghost" onClick={() => onOpen({ thread_id: run.thread_id, client_thread_id: null, agent_slug: slug,
title: job.name, status: run.status, mode: 'published', unread: false, updated_at: '', run_id: null })}></Button>}
@@ -148,7 +148,7 @@ function ScheduleNotification({ slug, value, onChange }: {
const available = options.some(item => item.caller_id === selected && item.channel_account_id === value?.channel_account_id);
return <section className="space-y-3 rounded-xl border border-border p-4">
<div className="flex items-center justify-between gap-3"><h3 className="text-sm font-medium"></h3>
<Button type="button" variant="ghost" size="sm" disabled={loading} onClick={() => { setLoading(true); setError(''); setRefresh(count => count + 1); }}></Button></div>
<Button type="button" variant="ghost" size="sm" disabled={loading} onClick={() => { setLoading(true); setError(''); setRefresh(count => count + 1); }}></Button></div>
<label className="block space-y-2 text-sm"><span></span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" value={selected} onChange={event => {
const caller = options.find(item => item.caller_id === event.target.value);
@@ -157,11 +157,11 @@ function ScheduleNotification({ slug, value, onChange }: {
<option value=""></option>
{selected && !available && <option value={selected}>{loading ? '正在确认已选微信对话…' : '原微信对话暂不可用(保留配置)'}</option>}
{options.map((caller, index) => <option key={caller.caller_id} value={caller.caller_id} disabled={loading || Boolean(error)}>
{channels.find(channel => channel.id === caller.channel_account_id)?.display_name ?? '个人微信'} · {index + 1}
{channels.find(channel => channel.id === caller.channel_account_id)?.display_name ?? '个人微信'} · {index + 1}
</option>)}
</select></label>
<p className="text-xs leading-6 text-muted-foreground"></p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground">访</p>}
<p className="text-xs leading-6 text-muted-foreground"></p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground"> </p>}
{selected && !available && !loading && <p className="text-xs leading-6 text-muted-foreground"></p>}
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
</section>;

View File

@@ -147,7 +147,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
{visited.has('chat') && (saved.published_version ? <CloudPendingContext.Provider value={formalPending.report}>
<nav aria-label="对话来源" className="mb-3 flex shrink-0 gap-2">
<Button variant={conversationSource === 'desktop' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'desktop'} onClick={() => setConversationSource('desktop')}></Button>
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}></Button>
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}></Button>
</nav>
<div hidden={conversationSource !== 'desktop'} className="flex min-h-0 flex-1 flex-col">
<CloudChat key={openedThread?.thread_id ?? 'published'} slug={saved.slug} initialThread={openedThread} activeView={tab === 'chat' && conversationSource === 'desktop'}