初始化模版工程
This commit is contained in:
797
app/settings/remote-control/page.tsx
Normal file
797
app/settings/remote-control/page.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
'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>
|
||||
}
|
||||
Reference in New Issue
Block a user