Files
test1/app/settings/remote-control/page.tsx
2026-03-20 07:33:46 +00:00

798 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ArrowLeft,
Bot,
CheckCircle2,
Eye,
EyeOff,
Info,
Loader2,
RefreshCw,
Save,
Trash2,
XCircle,
} from 'lucide-react'
import { toast } from 'sonner'
// PLATFORM:TYPE_UNION:START
type Platform = 'discord' | 'dingtalk' | 'lark' | 'telegram' | 'slack'
// PLATFORM:TYPE_UNION:END
type ConnectionStatus = 'connected' | 'disconnected' | 'connecting'
type CSSVarName =
| '--background'
| '--foreground'
| '--card'
| '--card-foreground'
| '--primary'
| '--primary-foreground'
| '--muted'
| '--muted-foreground'
| '--border'
| '--input'
| '--success'
| '--warning'
| '--destructive'
const themeVar = (name: CSSVarName) => `var(${name})`
interface RemoteControlConfig {
// PLATFORM:DINGTALK:CONFIG_INTERFACE:START
dingtalk: {
enabled: boolean
clientId: string
clientSecret: string
}
// PLATFORM:DINGTALK:CONFIG_INTERFACE:END
// PLATFORM:DISCORD:CONFIG_INTERFACE:START
discord: {
enabled: boolean
botToken: string
}
// PLATFORM:DISCORD:CONFIG_INTERFACE:END
// PLATFORM:LARK:CONFIG_INTERFACE:START
lark: {
enabled: boolean
appId: string
appSecret: string
}
// PLATFORM:LARK:CONFIG_INTERFACE:END
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:START
telegram: {
enabled: boolean
botToken: string
}
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:END
// PLATFORM:SLACK:CONFIG_INTERFACE:START
slack: {
enabled: boolean
botToken: string
appToken: string
}
// PLATFORM:SLACK:CONFIG_INTERFACE:END
}
interface PlatformStatus {
platform?: Platform
status: ConnectionStatus
messagesProcessed?: number
activeSessions?: number
lastConnectedAt?: string
uptime?: number
error?: string
}
interface LogsResponse {
logs: LogEntry[]
}
interface LogEntry {
id?: string
timestamp: string
platform: Platform
eventType: string
severity: 'info' | 'warning' | 'error'
details?: string | Record<string, unknown>
message?: string
}
interface AgentInfoResponse {
agentId: string
baseUrl: string
stats?: {
totalMessages?: number
activeConnections?: number
avgResponseTime?: number
}
totalMessages?: number
activeConnections?: number
averageResponseTime?: number
}
const DEFAULT_CONFIG: RemoteControlConfig = {
// PLATFORM:DINGTALK:DEFAULT_CONFIG:START
dingtalk: { enabled: false, clientId: '', clientSecret: '' },
// PLATFORM:DINGTALK:DEFAULT_CONFIG:END
// PLATFORM:DISCORD:DEFAULT_CONFIG:START
discord: { enabled: false, botToken: '' },
// PLATFORM:DISCORD:DEFAULT_CONFIG:END
// PLATFORM:LARK:DEFAULT_CONFIG:START
lark: { enabled: false, appId: '', appSecret: '' },
// PLATFORM:LARK:DEFAULT_CONFIG:END
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:START
telegram: { enabled: false, botToken: '' },
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:END
// PLATFORM:SLACK:DEFAULT_CONFIG:START
slack: { enabled: false, botToken: '', appToken: '' },
// PLATFORM:SLACK:DEFAULT_CONFIG:END
}
const DEFAULT_STATUS: Record<Platform, PlatformStatus> = {
// PLATFORM:DINGTALK:DEFAULT_STATUS:START
dingtalk: { platform: 'dingtalk', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
// PLATFORM:DINGTALK:DEFAULT_STATUS:END
// PLATFORM:DISCORD:DEFAULT_STATUS:START
discord: { platform: 'discord', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
// PLATFORM:DISCORD:DEFAULT_STATUS:END
// PLATFORM:LARK:DEFAULT_STATUS:START
lark: { platform: 'lark', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
// PLATFORM:LARK:DEFAULT_STATUS:END
// PLATFORM:TELEGRAM:DEFAULT_STATUS:START
telegram: { platform: 'telegram', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
// PLATFORM:TELEGRAM:DEFAULT_STATUS:END
// PLATFORM:SLACK:DEFAULT_STATUS:START
slack: { platform: 'slack', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
// PLATFORM:SLACK:DEFAULT_STATUS:END
}
function statusMeta(status: ConnectionStatus) {
if (status === 'connected') {
return { label: '已连接', dot: 'var(--success)' }
}
if (status === 'connecting') {
return { label: '连接中...', dot: 'var(--warning)' }
}
return { label: '断开连接', dot: 'var(--destructive)' }
}
function formatDate(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
function formatDetails(log: LogEntry) {
if (typeof log.details === 'string') return log.details
if (log.message) return log.message
if (log.details) return JSON.stringify(log.details)
return '-'
}
function formatDuration(seconds?: number) {
if (!seconds || seconds <= 0) return '-'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}小时${m}`
if (m > 0) return `${m}${s}`
return `${s}`
}
export default function RemoteControlPage() {
const [config, setConfig] = useState<RemoteControlConfig>(DEFAULT_CONFIG)
const [status, setStatus] = useState<Record<Platform, PlatformStatus>>(DEFAULT_STATUS)
const [logs, setLogs] = useState<LogEntry[]>([])
const [agentInfo, setAgentInfo] = useState<AgentInfoResponse | null>(null)
// PLATFORM:SHOW_SECRETS:START
const [showSecrets, setShowSecrets] = useState({
// PLATFORM:DINGTALK:SHOW_SECRETS:START
dingtalkSecret: false,
// PLATFORM:DINGTALK:SHOW_SECRETS:END
// PLATFORM:DISCORD:SHOW_SECRETS:START
discordToken: false,
// PLATFORM:DISCORD:SHOW_SECRETS:END
// PLATFORM:LARK:SHOW_SECRETS:START
larkSecret: false,
// PLATFORM:LARK:SHOW_SECRETS:END
// PLATFORM:TELEGRAM:SHOW_SECRETS:START
telegramToken: false,
// PLATFORM:TELEGRAM:SHOW_SECRETS:END
// PLATFORM:SLACK:SHOW_SECRETS:START
slackBotToken: false,
slackAppToken: false,
// PLATFORM:SLACK:SHOW_SECRETS:END
})
// PLATFORM:SHOW_SECRETS:END
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
// PLATFORM:TESTING:START
const [testing, setTesting] = useState<Record<Platform, boolean>>({
// PLATFORM:DINGTALK:TESTING:START
dingtalk: false,
// PLATFORM:DINGTALK:TESTING:END
// PLATFORM:DISCORD:TESTING:START
discord: false,
// PLATFORM:DISCORD:TESTING:END
// PLATFORM:LARK:TESTING:START
lark: false,
// PLATFORM:LARK:TESTING:END
// PLATFORM:TELEGRAM:TESTING:START
telegram: false,
// PLATFORM:TELEGRAM:TESTING:END
// PLATFORM:SLACK:TESTING:START
slack: false,
// PLATFORM:SLACK:TESTING:END
})
// PLATFORM:TESTING:END
const [refreshingLogs, setRefreshingLogs] = useState(false)
const [clearingLogs, setClearingLogs] = useState(false)
const loadStatus = useCallback(async () => {
const response = await fetch('/api/remote-control/status', { cache: 'no-store' })
if (!response.ok) {
throw new Error('状态加载失败')
}
const data = (await response.json()) as Partial<Record<Platform, PlatformStatus>>
setStatus({
// PLATFORM:DINGTALK:LOAD_STATUS:START
dingtalk: { ...DEFAULT_STATUS.dingtalk, ...(data.dingtalk ?? {}) },
// PLATFORM:DINGTALK:LOAD_STATUS:END
// PLATFORM:DISCORD:LOAD_STATUS:START
discord: { ...DEFAULT_STATUS.discord, ...(data.discord ?? {}) },
// PLATFORM:DISCORD:LOAD_STATUS:END
// PLATFORM:LARK:LOAD_STATUS:START
lark: { ...DEFAULT_STATUS.lark, ...(data.lark ?? {}) },
// PLATFORM:LARK:LOAD_STATUS:END
// PLATFORM:TELEGRAM:LOAD_STATUS:START
telegram: { ...DEFAULT_STATUS.telegram, ...(data.telegram ?? {}) },
// PLATFORM:TELEGRAM:LOAD_STATUS:END
// PLATFORM:SLACK:LOAD_STATUS:START
slack: { ...DEFAULT_STATUS.slack, ...(data.slack ?? {}) },
// PLATFORM:SLACK:LOAD_STATUS:END
})
}, [])
const loadLogs = useCallback(async () => {
const response = await fetch('/api/remote-control/logs?limit=50', { cache: 'no-store' })
if (!response.ok) {
throw new Error('日志加载失败')
}
const data = (await response.json()) as LogsResponse
setLogs(Array.isArray(data.logs) ? data.logs : [])
}, [])
const loadAgentInfo = useCallback(async () => {
const response = await fetch('/api/remote-control/agent-info', { cache: 'no-store' })
if (!response.ok) {
throw new Error('Agent 信息加载失败')
}
const data = (await response.json()) as AgentInfoResponse
setAgentInfo(data)
}, [])
const loadInitialData = useCallback(async () => {
setLoading(true)
try {
const configResponse = await fetch('/api/remote-control/config', { cache: 'no-store' })
if (!configResponse.ok) {
throw new Error('配置加载失败')
}
const configData = (await configResponse.json()) as Partial<RemoteControlConfig>
setConfig({
// PLATFORM:DINGTALK:LOAD_INITIAL:START
dingtalk: { ...DEFAULT_CONFIG.dingtalk, ...(configData.dingtalk ?? {}) },
// PLATFORM:DINGTALK:LOAD_INITIAL:END
// PLATFORM:DISCORD:LOAD_INITIAL:START
discord: { ...DEFAULT_CONFIG.discord, ...(configData.discord ?? {}) },
// PLATFORM:DISCORD:LOAD_INITIAL:END
// PLATFORM:LARK:LOAD_INITIAL:START
lark: { ...DEFAULT_CONFIG.lark, ...(configData.lark ?? {}) },
// PLATFORM:LARK:LOAD_INITIAL:END
// PLATFORM:TELEGRAM:LOAD_INITIAL:START
telegram: { ...DEFAULT_CONFIG.telegram, ...(configData.telegram ?? {}) },
// PLATFORM:TELEGRAM:LOAD_INITIAL:END
// PLATFORM:SLACK:LOAD_INITIAL:START
slack: { ...DEFAULT_CONFIG.slack, ...(configData.slack ?? {}) },
// PLATFORM:SLACK:LOAD_INITIAL:END
})
await Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
} catch {
toast.error('加载远程控制配置失败')
} finally {
setLoading(false)
}
}, [loadAgentInfo, loadLogs, loadStatus])
useEffect(() => {
void loadInitialData()
}, [loadInitialData])
const saveConfig = async () => {
setSaving(true)
try {
const response = await fetch('/api/remote-control/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
if (!response.ok || !data.success) {
throw new Error(data.error || '保存失败')
}
toast.success(data.message || '配置已保存并生效')
window.setTimeout(() => {
void Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
}, 3000)
} catch (error) {
const message = error instanceof Error ? error.message : '保存失败'
toast.error(message)
} finally {
setSaving(false)
}
}
const testConnection = async (platform: Platform) => {
setTesting(prev => ({ ...prev, [platform]: true }))
try {
const response = await fetch('/api/remote-control/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform }),
})
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
if (!response.ok || !data.success) {
throw new Error(data.error || data.message || '连接测试失败')
}
// PLATFORM:TEST_CONNECTION_SUCCESS:START
const platformNames: Record<Platform, string> = {
discord: 'Discord',
dingtalk: '钉钉',
lark: '飞书',
telegram: 'Telegram',
slack: 'Slack',
}
toast.success(`${platformNames[platform]}连接测试成功`)
// PLATFORM:TEST_CONNECTION_SUCCESS:END
await Promise.all([loadStatus(), loadLogs()])
} catch (error) {
const message = error instanceof Error ? error.message : '连接测试失败'
toast.error(message)
await Promise.all([loadStatus(), loadLogs()])
} finally {
setTesting(prev => ({ ...prev, [platform]: false }))
}
}
const refreshLogs = async () => {
setRefreshingLogs(true)
try {
await loadLogs()
toast.success('日志已刷新')
} catch {
toast.error('刷新日志失败')
} finally {
setRefreshingLogs(false)
}
}
const clearLogs = async () => {
setClearingLogs(true)
try {
const response = await fetch('/api/remote-control/logs', { method: 'DELETE' })
if (!response.ok) {
throw new Error('清空失败')
}
setLogs([])
toast.success('日志已清空')
} catch {
toast.error('清空日志失败')
} finally {
setClearingLogs(false)
}
}
const mergedStats = useMemo(() => {
const totalMessages = agentInfo?.stats?.totalMessages ?? agentInfo?.totalMessages ?? 0
const activeConnections = agentInfo?.stats?.activeConnections ?? agentInfo?.activeConnections ?? 0
const avgResponseTime = agentInfo?.stats?.avgResponseTime ?? agentInfo?.averageResponseTime ?? 0
return { totalMessages, activeConnections, avgResponseTime }
}, [agentInfo])
if (loading) {
return (
<div className="fanling-theme min-h-screen flex items-center justify-center bg-background">
<div className="flex items-center gap-2 text-primary">
<Loader2 className="h-5 w-5 animate-spin" />
<span>...</span>
</div>
</div>
)
}
return (
<div className="fanling-theme min-h-screen px-4 py-6 md:px-6" style={{ backgroundColor: themeVar('--background') }}>
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="flex items-center justify-between rounded-2xl border bg-card px-4 py-3 shadow-sm" style={{ borderColor: themeVar('--border') }}>
<div className="flex items-center gap-3">
<Link
href="/"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm transition-colors"
style={{ color: themeVar('--muted-foreground'), border: `1px solid ${themeVar('--border')}` }}
>
<ArrowLeft className="h-4 w-4" />
</Link>
<h1 className="text-lg font-semibold md:text-xl" style={{ color: themeVar('--foreground') }}>
</h1>
</div>
<Bot className="h-5 w-5" style={{ color: themeVar('--primary') }} />
</div>
{/* PLATFORM:DINGTALK:CARD:START */}
<PlatformCard
title="钉钉 Bot"
status={status.dingtalk}
enabled={config.dingtalk.enabled}
onEnabledChange={enabled => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, enabled } }))}
fields={[
{
label: 'Client ID',
type: 'text',
value: config.dingtalk.clientId,
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientId: value } })),
placeholder: '请输入钉钉 Client ID',
},
{
label: 'Client Secret',
type: showSecrets.dingtalkSecret ? 'text' : 'password',
value: config.dingtalk.clientSecret,
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientSecret: value } })),
placeholder: '请输入钉钉 Client Secret',
secretVisible: showSecrets.dingtalkSecret,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, dingtalkSecret: !prev.dingtalkSecret })),
},
]}
onTest={() => void testConnection('dingtalk')}
testing={testing.dingtalk}
docUrl="https://open.dingtalk.com/document/robots/custom-robot-access"
/>
{/* PLATFORM:DINGTALK:CARD:END */}
{/* PLATFORM:DISCORD:CARD:START */}
<PlatformCard
title="Discord Bot"
status={status.discord}
enabled={config.discord.enabled}
onEnabledChange={enabled => setConfig(prev => ({ ...prev, discord: { ...prev.discord, enabled } }))}
fields={[
{
label: 'Bot Token',
type: showSecrets.discordToken ? 'text' : 'password',
value: config.discord.botToken,
onChange: value => setConfig(prev => ({ ...prev, discord: { ...prev.discord, botToken: value } })),
placeholder: '请输入 Discord Bot Token',
secretVisible: showSecrets.discordToken,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, discordToken: !prev.discordToken })),
},
]}
onTest={() => void testConnection('discord')}
testing={testing.discord}
docUrl="https://docs.discord.com/developers/topics/oauth2#bots"
/>
{/* PLATFORM:DISCORD:CARD:END */}
{/* PLATFORM:LARK:CARD:START */}
<PlatformCard
title="飞书 Bot"
status={status.lark}
enabled={config.lark.enabled}
onEnabledChange={enabled => setConfig(prev => ({ ...prev, lark: { ...prev.lark, enabled } }))}
fields={[
{
label: 'App ID',
type: 'text',
value: config.lark.appId,
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appId: value } })),
placeholder: '请输入飞书 App ID',
},
{
label: 'App Secret',
type: showSecrets.larkSecret ? 'text' : 'password',
value: config.lark.appSecret,
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appSecret: value } })),
placeholder: '请输入飞书 App Secret',
secretVisible: showSecrets.larkSecret,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, larkSecret: !prev.larkSecret })),
},
]}
onTest={() => void testConnection('lark')}
testing={testing.lark}
docUrl="https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process"
/>
{/* PLATFORM:LARK:CARD:END */}
{/* PLATFORM:TELEGRAM:CARD:START */}
<PlatformCard
title="Telegram Bot"
status={status.telegram}
enabled={config.telegram.enabled}
onEnabledChange={enabled => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, enabled } }))}
fields={[
{
label: 'Bot Token',
type: showSecrets.telegramToken ? 'text' : 'password',
value: config.telegram.botToken,
onChange: value => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, botToken: value } })),
placeholder: '请输入 Telegram Bot Token (通过 @BotFather 获取)',
secretVisible: showSecrets.telegramToken,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, telegramToken: !prev.telegramToken })),
},
]}
onTest={() => void testConnection('telegram')}
testing={testing.telegram}
docUrl="https://core.telegram.org/bots#how-do-i-create-a-bot"
/>
{/* PLATFORM:TELEGRAM:CARD:END */}
{/* PLATFORM:SLACK:CARD:START */}
<PlatformCard
title="Slack Bot"
status={status.slack}
enabled={config.slack.enabled}
onEnabledChange={enabled => setConfig(prev => ({ ...prev, slack: { ...prev.slack, enabled } }))}
fields={[
{
label: 'Bot Token (xoxb-)',
type: showSecrets.slackBotToken ? 'text' : 'password',
value: config.slack.botToken,
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, botToken: value } })),
placeholder: '请输入 Slack Bot Token (xoxb-...)',
secretVisible: showSecrets.slackBotToken,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackBotToken: !prev.slackBotToken })),
},
{
label: 'App-Level Token (xapp-)',
type: showSecrets.slackAppToken ? 'text' : 'password',
value: config.slack.appToken,
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, appToken: value } })),
placeholder: '请输入 Slack App Token (xapp-...)',
secretVisible: showSecrets.slackAppToken,
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackAppToken: !prev.slackAppToken })),
},
]}
onTest={() => void testConnection('slack')}
testing={testing.slack}
docUrl="https://api.slack.com/start/quickstart"
/>
{/* PLATFORM:SLACK:CARD:END */}
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>Bot 50 </h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void refreshLogs()}
disabled={refreshingLogs}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm"
style={{ border: `1px solid ${themeVar('--border')}`, color: themeVar('--muted-foreground') }}
>
<RefreshCw className={`h-4 w-4 ${refreshingLogs ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => void clearLogs()}
disabled={clearingLogs}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white"
style={{ backgroundColor: themeVar('--destructive') }}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="max-h-[380px] overflow-auto rounded-xl border" style={{ borderColor: themeVar('--border') }}>
{logs.length === 0 ? (
<div className="p-4 text-sm" style={{ color: themeVar('--muted-foreground') }}></div>
) : (
<div className="divide-y" style={{ borderColor: themeVar('--border') }}>
{logs.map((log, index) => (
<div key={log.id ?? `${log.timestamp}-${index}`} className="grid gap-1 px-4 py-3 text-sm md:grid-cols-[168px_88px_120px_80px_1fr]">
<span style={{ color: themeVar('--muted-foreground') }}>{formatDate(log.timestamp)}</span>
<span className="font-medium" style={{ color: themeVar('--foreground') }}>{log.platform}</span>
<span style={{ color: themeVar('--muted-foreground') }}>{log.eventType}</span>
<span>
<SeverityTag severity={log.severity} />
</span>
<span className="break-all" style={{ color: themeVar('--card-foreground') }}>{formatDetails(log)}</span>
</div>
))}
</div>
)}
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
<h2 className="mb-3 text-base font-semibold" style={{ color: themeVar('--foreground') }}>Nova Agent </h2>
<div className="grid gap-3 text-sm md:grid-cols-2" style={{ color: themeVar('--card-foreground') }}>
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
<div>Agent ID: <span className="font-medium">{agentInfo?.agentId || '-'}</span></div>
<div className="mt-1 break-all">Base URL: <span className="font-medium">{agentInfo?.baseUrl || '-'}</span></div>
</div>
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
<div>: <span className="font-medium">{mergedStats.activeConnections}</span></div>
<div className="mt-1">: <span className="font-medium">{mergedStats.totalMessages}</span></div>
<div className="mt-1">: <span className="font-medium">{mergedStats.avgResponseTime} ms</span></div>
</div>
</div>
</section>
<div className="sticky bottom-4 z-10 flex justify-end">
<button
type="button"
onClick={() => void saveConfig()}
disabled={saving}
className="inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors"
style={{ backgroundColor: themeVar('--primary') }}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</button>
</div>
</div>
</div>
)
}
interface PlatformField {
label: string
type: 'text' | 'password'
value: string
placeholder: string
onChange: (value: string) => void
secretVisible?: boolean
onToggleSecret?: () => void
}
interface PlatformCardProps {
title: string
enabled: boolean
onEnabledChange: (enabled: boolean) => void
status: PlatformStatus
fields: PlatformField[]
onTest: () => void
testing: boolean
docUrl: string
}
function PlatformCard({ title, enabled, onEnabledChange, status, fields, onTest, testing, docUrl }: PlatformCardProps) {
const meta = statusMeta(status.status)
return (
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>{title}</h2>
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex rounded-full p-1"
title="查看官方配置文档"
style={{ color: themeVar('--muted-foreground') }}
>
<Info className="h-4 w-4" />
</a>
</div>
<div className="flex items-center gap-4">
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => onEnabledChange(!enabled)}
className="inline-flex items-center gap-2 text-sm"
style={{ color: themeVar('--card-foreground') }}
>
<span
className="relative h-6 w-11 rounded-full transition-colors"
style={{ backgroundColor: enabled ? themeVar('--primary') : themeVar('--muted') }}
>
<span
className="absolute top-0.5 h-5 w-5 rounded-full transition-all"
style={{ backgroundColor: themeVar('--card'), left: enabled ? '22px' : '2px' }}
/>
</span>
{enabled ? '已启用' : '已禁用'}
</button>
<div className="flex items-center gap-2 text-sm" style={{ color: themeVar('--card-foreground') }}>
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: meta.dot }} />
{meta.label}
</div>
</div>
</div>
<div className="space-y-3">
{fields.map(field => (
<label key={field.label} className="block text-sm">
<span className="mb-1.5 block" style={{ color: themeVar('--card-foreground') }}>{field.label}</span>
<div className="relative">
<input
type={field.type}
value={field.value}
onChange={event => field.onChange(event.target.value)}
placeholder={field.placeholder}
className="h-10 w-full rounded-lg border px-3 pr-10 text-sm outline-none transition-colors focus:border-primary"
style={{ borderColor: themeVar('--border'), color: themeVar('--foreground'), backgroundColor: themeVar('--card') }}
/>
{field.onToggleSecret && (
<button
type="button"
onClick={field.onToggleSecret}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1"
style={{ color: themeVar('--muted-foreground') }}
aria-label={field.secretVisible ? '隐藏密钥' : '显示密钥'}
>
{field.secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
)}
</div>
</label>
))}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-1 text-sm md:grid-cols-2 md:gap-x-6" style={{ color: themeVar('--muted-foreground') }}>
<span>: {formatDate(status.lastConnectedAt)}</span>
<span>: {formatDuration(status.uptime)}</span>
<span>: {status.messagesProcessed ?? 0}</span>
<span>: {status.activeSessions ?? 0}</span>
</div>
<button
type="button"
onClick={onTest}
disabled={testing || !enabled}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
style={{ backgroundColor: themeVar('--primary') }}
title={!enabled ? '请先启用该渠道' : undefined}
>
{testing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</button>
</div>
{status.error && (
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--destructive') }}>
<XCircle className="h-4 w-4" />
{status.error}
</div>
)}
{!status.error && status.status === 'connected' && (
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--success') }}>
<CheckCircle2 className="h-4 w-4" />
</div>
)}
</section>
)
}
function SeverityTag({ severity }: { severity: LogEntry['severity'] }) {
if (severity === 'error') {
return <span className="rounded-full px-2 py-0.5 text-xs text-white" style={{ backgroundColor: themeVar('--destructive') }}>error</span>
}
if (severity === 'warning') {
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--warning'), color: themeVar('--foreground') }}>warning</span>
}
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--success'), color: themeVar('--foreground') }}>info</span>
}