@@ -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 : '已停用' ,
offl ine : '暂时离线 ' , logi n_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 : '等待中 ' ,
pend ing : '处理中 ' , verificatio n_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 , setRecoveryRead y] = 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 , setBus y] = 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 ( ! curre ntId) return ;
const requestedAccountId = currentId ;
const requestedBindingKey = currentBindingKey ;
const result = await cloudAgentsApi . call ( 'channelActivity' , { slug , channel_account_id : accou ntId, 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 ( resul t . next_offset ) ;
setActivityRevision ( v alu e = > 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 ( errorTex t( error ) ) ; }
finally { setBusy ( f als e) ; }
} ;
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/7 0 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 } routeAg ents = { 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 ) } onPau se= { ( ) = > 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-5 0 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-curr ent= { tab === key ? 'page' : undefined } onClick = { ( ) = > setTab ( key ) } > { label } < / button > ) }
< / div >
{ tab === 'activity' && < ActivityStep slug = { slug } channelAccountId = { accountId } refreshKey = { activityRevision } activity = { activity } nextOff set = { 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 } : {