1269 lines
54 KiB
TypeScript
1269 lines
54 KiB
TypeScript
// @ts-nocheck
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
adminAPI,
|
|
} from '@/api';
|
|
|
|
// Platform icons (simple SVG representations)
|
|
const platformIcons: Record<string, string> = {
|
|
feishu: 'Feishu',
|
|
wechat: 'WeChat',
|
|
personal_wechat: 'WeChat',
|
|
wecom: 'WeCom',
|
|
dingtalk: 'DingTalk',
|
|
telegram: 'Telegram',
|
|
whatsapp: 'WhatsApp',
|
|
slack: 'Slack',
|
|
};
|
|
|
|
const platformColors: Record<string, string> = {
|
|
feishu: '#3370FF',
|
|
wechat: '#07C160',
|
|
personal_wechat: '#07C160',
|
|
wecom: '#2F80ED',
|
|
dingtalk: '#0089FF',
|
|
telegram: '#0088CC',
|
|
whatsapp: '#25D366',
|
|
slack: '#4A154B',
|
|
};
|
|
|
|
const statusColors: Record<string, string> = {
|
|
connected: '#10b981',
|
|
pending: '#f59e0b',
|
|
disconnected: '#6b7280',
|
|
};
|
|
|
|
type Tab = 'dashboard' | 'channels' | 'routes' | 'langbot' | 'messages' | 'setup';
|
|
|
|
const ImBots: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<Tab>('dashboard');
|
|
const [loading, setLoading] = useState(false);
|
|
const [stats, setStats] = useState<any>(null);
|
|
const [dashboard, setDashboard] = useState<any>(null);
|
|
const [channels, setChannels] = useState<any[]>([]);
|
|
const [channelsTotal, setChannelsTotal] = useState(0);
|
|
const [channelsPage, setChannelsPage] = useState(1);
|
|
const [filterPlatform, setFilterPlatform] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('');
|
|
const [routes, setRoutes] = useState<any[]>([]);
|
|
const [langbotConfig, setLangbotConfig] = useState<any[]>([]);
|
|
const [messages, setMessages] = useState<any[]>([]);
|
|
const [messagesTotal, setMessagesTotal] = useState(0);
|
|
const [messagesPage, setMessagesPage] = useState(1);
|
|
const [platforms, setPlatforms] = useState<any[]>([]);
|
|
const [selectedChannel, setSelectedChannel] = useState<any>(null);
|
|
const [showChannelDetail, setShowChannelDetail] = useState(false);
|
|
const [showSetupModal, setShowSetupModal] = useState(false);
|
|
const [showRouteModal, setShowRouteModal] = useState(false);
|
|
const [showWebhookModal, setShowWebhookModal] = useState(false);
|
|
const [webhookInfo, setWebhookInfo] = useState<any>(null);
|
|
const [agents, setAgents] = useState<any[]>([]);
|
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
|
|
|
// Route form state
|
|
const [routeForm, setRouteForm] = useState({
|
|
channel_id: '',
|
|
agent_id: '',
|
|
match_type: 'all',
|
|
match_pattern: '',
|
|
priority: 0,
|
|
});
|
|
|
|
// Setup form state
|
|
const [setupForm, setSetupForm] = useState({
|
|
platform: 'feishu',
|
|
name: '',
|
|
agent_id: '',
|
|
credentials: '',
|
|
});
|
|
|
|
// LangBot form state
|
|
const [langbotForm, setLangbotForm] = useState({
|
|
id: 1,
|
|
gateway_url: '',
|
|
api_key: '',
|
|
webhook_secret: '',
|
|
bot_uuid: '',
|
|
callback_url: '',
|
|
sync_channels: 0,
|
|
status: 'inactive',
|
|
});
|
|
|
|
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
|
setToast({ msg, type });
|
|
setTimeout(() => setToast(null), 3000);
|
|
};
|
|
|
|
// Load agents for dropdowns
|
|
const loadAgents = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getAIApps('?type=agent&page_size=100') as any;
|
|
if (res?.data) {
|
|
setAgents(res.data?.items || []);
|
|
}
|
|
} catch (e) {
|
|
// silently fail
|
|
}
|
|
}, []);
|
|
|
|
// Load stats
|
|
const loadStats = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getImBotStats();
|
|
if ((res as any)?.success) setStats(res.data);
|
|
} catch (e) {
|
|
console.error('Stats error:', e);
|
|
}
|
|
}, []);
|
|
|
|
// Load dashboard
|
|
const loadDashboard = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getImBotDashboard();
|
|
if ((res as any)?.success) setDashboard(res.data);
|
|
} catch (e) {
|
|
console.error('Dashboard error:', e);
|
|
}
|
|
}, []);
|
|
|
|
// Load channels
|
|
const loadChannels = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
let endpoint = `?page=${channelsPage}&page_size=20`;
|
|
if (filterPlatform) endpoint += `&platform=${filterPlatform}`;
|
|
if (filterStatus) endpoint += `&status=${filterStatus}`;
|
|
const res = await adminAPI.getImBotChannels(endpoint);
|
|
if ((res as any)?.success) {
|
|
setChannels(res.data?.items || []);
|
|
setChannelsTotal(res.data?.total || 0);
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to load channels', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [channelsPage, filterPlatform, filterStatus]);
|
|
|
|
// Load routes
|
|
const loadRoutes = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getImBotRoutes();
|
|
if ((res as any)?.success) setRoutes(res.data || []);
|
|
} catch (e) {
|
|
showToast('Failed to load routes', 'error');
|
|
}
|
|
}, []);
|
|
|
|
// Load LangBot config
|
|
const loadLangbot = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getLangbotConfig();
|
|
if ((res as any)?.success) {
|
|
const configs = res.data || [];
|
|
setLangbotConfig(configs);
|
|
if (configs.length > 0) {
|
|
setLangbotForm({ ...langbotForm, ...configs[0] });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to load LangBot config', 'error');
|
|
}
|
|
}, []);
|
|
|
|
// Load messages
|
|
const loadMessages = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getImBotMessages(`?page=${messagesPage}&page_size=50`);
|
|
if ((res as any)?.success) {
|
|
setMessages(res.data?.items || []);
|
|
setMessagesTotal(res.data?.total || 0);
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to load messages', 'error');
|
|
}
|
|
}, [messagesPage]);
|
|
|
|
// Load platforms
|
|
const loadPlatforms = useCallback(async () => {
|
|
try {
|
|
const res = await adminAPI.getImBotPlatforms();
|
|
if ((res as any)?.success) setPlatforms(res.data || []);
|
|
} catch (e) {
|
|
// silently fail
|
|
}
|
|
}, []);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadAgents();
|
|
loadStats();
|
|
loadPlatforms();
|
|
}, []);
|
|
|
|
// Tab-specific loads
|
|
useEffect(() => {
|
|
if (activeTab === 'dashboard') {
|
|
loadDashboard();
|
|
loadStats();
|
|
} else if (activeTab === 'channels') {
|
|
loadChannels();
|
|
} else if (activeTab === 'routes') {
|
|
loadRoutes();
|
|
loadChannels();
|
|
} else if (activeTab === 'langbot') {
|
|
loadLangbot();
|
|
} else if (activeTab === 'messages') {
|
|
loadMessages();
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// Handle channel test
|
|
const handleTestChannel = async (channelId: string) => {
|
|
try {
|
|
const res = await adminAPI.testImBotChannel(channelId);
|
|
if ((res as any)?.success) {
|
|
const data = res.data;
|
|
if (data.passed) {
|
|
showToast('Channel test passed!');
|
|
} else {
|
|
showToast(`Test failed: ${data.checks.filter((c: any) => !c.passed).map((c: any) => c.name).join(', ')}`, 'error');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
showToast('Test failed', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle channel activate/deactivate
|
|
const handleToggleChannel = async (channelId: string, currentActive: boolean) => {
|
|
try {
|
|
if (currentActive) {
|
|
await adminAPI.deactivateImBotChannel(channelId);
|
|
showToast('Channel deactivated');
|
|
} else {
|
|
await adminAPI.activateImBotChannel(channelId);
|
|
showToast('Channel activated');
|
|
}
|
|
loadChannels();
|
|
loadStats();
|
|
} catch (e) {
|
|
showToast('Operation failed', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle channel detail
|
|
const handleViewChannel = async (channelId: string) => {
|
|
try {
|
|
const res = await adminAPI.getImBotChannelDetail(channelId);
|
|
if ((res as any)?.success) {
|
|
setSelectedChannel(res.data);
|
|
setShowChannelDetail(true);
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to load channel detail', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle webhook URL
|
|
const handleGetWebhookUrl = async (platform: string) => {
|
|
try {
|
|
const res = await adminAPI.getImBotWebhookUrl(platform);
|
|
if ((res as any)?.success) {
|
|
setWebhookInfo(res.data);
|
|
setShowWebhookModal(true);
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to get webhook URL', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle create route
|
|
const handleCreateRoute = async () => {
|
|
if (!routeForm.channel_id || !routeForm.agent_id) {
|
|
showToast('Please select channel and agent', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const params = new URLSearchParams({
|
|
channel_id: routeForm.channel_id,
|
|
agent_id: routeForm.agent_id,
|
|
match_type: routeForm.match_type,
|
|
priority: String(routeForm.priority),
|
|
});
|
|
if (routeForm.match_pattern) params.append('match_pattern', routeForm.match_pattern);
|
|
await adminAPI.createImBotRoute('?' + params.toString());
|
|
showToast('Route created successfully');
|
|
setShowRouteModal(false);
|
|
setRouteForm({ channel_id: '', agent_id: '', match_type: 'all', match_pattern: '', priority: 0 });
|
|
loadRoutes();
|
|
} catch (e) {
|
|
showToast('Failed to create route', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle delete route
|
|
const handleDeleteRoute = async (routeId: string) => {
|
|
if (!confirm('Delete this routing rule?')) return;
|
|
try {
|
|
await adminAPI.deleteImBotRoute(routeId);
|
|
showToast('Route deleted');
|
|
loadRoutes();
|
|
} catch (e) {
|
|
showToast('Failed to delete route', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle quick setup
|
|
const handleQuickSetup = async () => {
|
|
if (!setupForm.platform || !setupForm.name) {
|
|
showToast('Please fill in platform and name', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const params = new URLSearchParams({
|
|
platform: setupForm.platform,
|
|
name: setupForm.name,
|
|
});
|
|
if (setupForm.agent_id) params.append('agent_id', setupForm.agent_id);
|
|
if (setupForm.credentials) params.append('credentials', setupForm.credentials);
|
|
const res = await adminAPI.imBotQuickSetup('?' + params.toString());
|
|
if ((res as any)?.success) {
|
|
const data = res.data;
|
|
setWebhookInfo({
|
|
platform: data.platform,
|
|
platform_name: data.platform_name,
|
|
full_url: data.webhook_url,
|
|
instructions: data.instructions,
|
|
});
|
|
setShowSetupModal(false);
|
|
setShowWebhookModal(true);
|
|
showToast('Channel created! Follow the instructions to configure the webhook.');
|
|
setSetupForm({ platform: 'feishu', name: '', agent_id: '', credentials: '' });
|
|
loadChannels();
|
|
loadStats();
|
|
}
|
|
} catch (e) {
|
|
showToast('Quick setup failed', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle LangBot config save
|
|
const handleSaveLangbot = async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
config_id: String(langbotForm.id || 1),
|
|
gateway_url: langbotForm.gateway_url,
|
|
status: langbotForm.status,
|
|
sync_channels: String(langbotForm.sync_channels),
|
|
});
|
|
if (langbotForm.api_key) params.append('api_key', langbotForm.api_key);
|
|
if (langbotForm.webhook_secret) params.append('webhook_secret', langbotForm.webhook_secret);
|
|
if (langbotForm.bot_uuid) params.append('bot_uuid', langbotForm.bot_uuid);
|
|
if (langbotForm.callback_url) params.append('callback_url', langbotForm.callback_url);
|
|
await adminAPI.updateLangbotConfig('?' + params.toString());
|
|
showToast('LangBot config saved');
|
|
loadLangbot();
|
|
} catch (e) {
|
|
showToast('Failed to save LangBot config', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle LangBot test
|
|
const handleTestLangbot = async () => {
|
|
if (!langbotForm.gateway_url) {
|
|
showToast('Please enter gateway URL', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const params = new URLSearchParams({
|
|
gateway_url: langbotForm.gateway_url,
|
|
});
|
|
if (langbotForm.api_key) params.append('api_key', langbotForm.api_key);
|
|
const res = await adminAPI.testLangbot('?' + params.toString());
|
|
if ((res as any)?.success) {
|
|
const data = res.data;
|
|
if (data.reachable) {
|
|
showToast(`LangBot reachable! Status: ${data.status_code}`);
|
|
} else {
|
|
showToast(`LangBot unreachable: ${data.error}`, 'error');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
showToast('Test failed', 'error');
|
|
}
|
|
};
|
|
|
|
// Copy to clipboard
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
showToast('Copied to clipboard');
|
|
});
|
|
};
|
|
|
|
// Format time
|
|
const formatTime = (time: string) => {
|
|
if (!time) return '-';
|
|
return new Date(time).toLocaleString('zh-CN');
|
|
};
|
|
|
|
const tabs: { key: Tab; label: string; icon: string }[] = [
|
|
{ key: 'dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
|
{ key: 'channels', label: '频道管理', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z' },
|
|
{ key: 'routes', label: '消息路由', icon: 'M3.5 18.5l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 17l1.5 1.5z' },
|
|
{ key: 'langbot', label: 'LangBot 网关', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z' },
|
|
{ key: 'messages', label: '消息日志', icon: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z' },
|
|
{ key: 'setup', label: '快速接入', icon: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z' },
|
|
];
|
|
|
|
return (
|
|
<div style={{ minHeight: '100vh', background: '#0f1117', color: '#e0e0e0', padding: '24px' }}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
|
<div>
|
|
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>IM 机器人管理</h1>
|
|
<p style={{ color: '#888', fontSize: '14px', margin: '4px 0 0 0' }}>管理即时通讯平台接入、消息路由和 LangBot 网关</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
{stats && (
|
|
<div style={{ display: 'flex', gap: '12px' }}>
|
|
<StatBadge label="频道" value={stats.total_channels} color="#3370FF" />
|
|
<StatBadge label="在线" value={stats.active_channels} color="#10b981" />
|
|
<StatBadge label="今日消息" value={stats.today_messages_in + stats.today_messages_out} color="#f59e0b" />
|
|
<StatBadge label="LangBot" value={stats.langbot_status === 'active' ? 'ON' : 'OFF'} color={stats.langbot_status === 'active' ? '#10b981' : '#6b7280'} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '20px', borderBottom: '1px solid #2a2d35' }}>
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
style={{
|
|
padding: '10px 20px',
|
|
background: activeTab === tab.key ? '#1a1d27' : 'transparent',
|
|
color: activeTab === tab.key ? '#3370FF' : '#888',
|
|
border: 'none',
|
|
borderBottom: activeTab === tab.key ? '2px solid #3370FF' : '2px solid transparent',
|
|
cursor: 'pointer',
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div style={{
|
|
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
|
|
padding: '12px 24px', borderRadius: '8px',
|
|
background: toast.type === 'success' ? '#10b981' : '#ef4444',
|
|
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
}}>
|
|
{toast.msg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dashboard Tab */}
|
|
{activeTab === 'dashboard' && (
|
|
<div>
|
|
{/* Stat Cards */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
|
<StatCard title="频道总数" value={stats?.total_channels || 0} icon="channels" color="#3370FF" />
|
|
<StatCard title="活跃频道" value={stats?.active_channels || 0} icon="active" color="#10b981" />
|
|
<StatCard title="今日消息" value={(stats?.today_messages_in || 0) + (stats?.today_messages_out || 0)} icon="messages" color="#f59e0b" />
|
|
<StatCard title="活跃路由" value={stats?.active_routes || 0} icon="routes" color="#8b5cf6" />
|
|
</div>
|
|
|
|
{/* Platform Distribution */}
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px', marginBottom: '24px' }}>
|
|
<h3 style={{ marginTop: 0, marginBottom: '16px', fontSize: '16px' }}>平台分布</h3>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
|
|
{platforms.map((p) => (
|
|
<div key={p.key} style={{
|
|
background: '#0f1117', borderRadius: '8px', padding: '16px',
|
|
border: `1px solid ${p.active_count > 0 ? p.color + '40' : '#2a2d35'}`,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
|
<div style={{
|
|
width: '32px', height: '32px', borderRadius: '6px',
|
|
background: p.color, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '12px', fontWeight: 700, color: '#fff',
|
|
}}>
|
|
{p.name.slice(0, 2)}
|
|
</div>
|
|
<span style={{ fontSize: '14px', fontWeight: 500 }}>{p.name}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '13px' }}>
|
|
<span style={{ color: '#888' }}>频道: {p.channel_count}</span>
|
|
<span style={{ color: '#10b981' }}>在线: {p.active_count}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message Trend */}
|
|
{dashboard?.message_trend && dashboard.message_trend.length > 0 && (
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px' }}>
|
|
<h3 style={{ marginTop: 0, marginBottom: '16px', fontSize: '16px' }}>7 天消息趋势</h3>
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', height: '120px' }}>
|
|
{dashboard.message_trend.map((item: any, i: number) => {
|
|
const maxVal = Math.max(...dashboard.message_trend.map((t: any) => (t.msg_in || 0) + (t.msg_out || 0)), 1);
|
|
const total = (item.msg_in || 0) + (item.msg_out || 0);
|
|
const height = (total / maxVal) * 100;
|
|
return (
|
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
|
|
<span style={{ fontSize: '11px', color: '#888' }}>{total}</span>
|
|
<div style={{
|
|
width: '100%', height: `${height}%`, minHeight: '4px',
|
|
background: 'linear-gradient(180deg, #3370FF 0%, #1a5fd6 100%)',
|
|
borderRadius: '4px 4px 0 0',
|
|
}} />
|
|
<span style={{ fontSize: '11px', color: '#666' }}>
|
|
{item.stat_date?.slice(5)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Channels */}
|
|
{dashboard?.recent_channels && dashboard.recent_channels.length > 0 && (
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px', marginTop: '24px' }}>
|
|
<h3 style={{ marginTop: 0, marginBottom: '16px', fontSize: '16px' }}>最近频道</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{dashboard.recent_channels.slice(0, 5).map((ch: any) => (
|
|
<div key={ch.id} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '12px', background: '#0f1117', borderRadius: '8px',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<div style={{
|
|
width: '28px', height: '28px', borderRadius: '6px',
|
|
background: platformColors[ch.platform] || '#666',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '10px', fontWeight: 700, color: '#fff',
|
|
}}>
|
|
{ch.platform_info?.name?.slice(0, 2) || ch.platform.slice(0, 2)}
|
|
</div>
|
|
<span style={{ fontWeight: 500 }}>{ch.name}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '16px', fontSize: '13px', color: '#888' }}>
|
|
<span>{ch.msg_count} 消息</span>
|
|
<span style={{ color: statusColors[ch.status] || '#888' }}>{ch.status}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Channels Tab */}
|
|
{activeTab === 'channels' && (
|
|
<div>
|
|
{/* Filters */}
|
|
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
|
<select
|
|
value={filterPlatform}
|
|
onChange={(e) => { setFilterPlatform(e.target.value); setChannelsPage(1); }}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">全部平台</option>
|
|
{platforms.map((p) => (
|
|
<option key={p.key} value={p.key}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => { setFilterStatus(e.target.value); setChannelsPage(1); }}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">全部状态</option>
|
|
<option value="connected">已连接</option>
|
|
<option value="pending">待配置</option>
|
|
<option value="disconnected">已断开</option>
|
|
</select>
|
|
<button
|
|
onClick={() => { setShowSetupModal(true); }}
|
|
style={primaryButtonStyle}
|
|
>
|
|
+ 新建频道
|
|
</button>
|
|
</div>
|
|
|
|
{/* Channel Table */}
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: '#0f1117' }}>
|
|
{['频道名称', '平台', '状态', 'Agent', '消息数', '最后活跃', '操作'].map((h) => (
|
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#888', fontWeight: 600, borderBottom: '1px solid #2a2d35' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>加载中...</td></tr>
|
|
) : channels.length === 0 ? (
|
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>暂无频道,点击「新建频道」开始</td></tr>
|
|
) : (
|
|
channels.map((ch) => (
|
|
<tr key={ch.id} style={{ borderBottom: '1px solid #1a1d27' }}>
|
|
<td style={{ padding: '12px 16px', fontSize: '14px' }}>
|
|
<div style={{ fontWeight: 500 }}>{ch.name}</div>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>{ch.account_name || ch.description || '-'}</div>
|
|
</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: '4px', fontSize: '12px',
|
|
background: (platformColors[ch.platform] || '#666') + '20',
|
|
color: platformColors[ch.platform] || '#666',
|
|
}}>
|
|
{ch.platform_info?.name || ch.platform}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
|
fontSize: '13px', color: statusColors[ch.status] || '#888',
|
|
}}>
|
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: statusColors[ch.status] || '#888' }} />
|
|
{ch.status === 'connected' ? '已连接' : ch.status === 'pending' ? '待配置' : '已断开'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#aaa' }}>{ch.agent_name || '-'}</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#aaa' }}>{ch.msg_count || 0}</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#666' }}>{formatTime(ch.last_active)}</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<div style={{ display: 'flex', gap: '4px' }}>
|
|
<button onClick={() => handleViewChannel(ch.id)} style={actionBtnStyle}>详情</button>
|
|
<button onClick={() => handleTestChannel(ch.id)} style={actionBtnStyle}>测试</button>
|
|
<button onClick={() => handleGetWebhookUrl(ch.platform)} style={actionBtnStyle}>Webhook</button>
|
|
<button
|
|
onClick={() => handleToggleChannel(ch.id, ch.is_active)}
|
|
style={{
|
|
...actionBtnStyle,
|
|
color: ch.is_active ? '#ef4444' : '#10b981',
|
|
}}
|
|
>
|
|
{ch.is_active ? '停用' : '启用'}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{channelsTotal > 20 && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
|
<button
|
|
onClick={() => setChannelsPage(p => Math.max(1, p - 1))}
|
|
disabled={channelsPage === 1}
|
|
style={pageBtnStyle}
|
|
>上一页</button>
|
|
<span style={{ padding: '8px 12px', color: '#888', fontSize: '14px' }}>
|
|
{channelsPage} / {Math.ceil(channelsTotal / 20)}
|
|
</span>
|
|
<button
|
|
onClick={() => setChannelsPage(p => p + 1)}
|
|
disabled={channelsPage >= Math.ceil(channelsTotal / 20)}
|
|
style={pageBtnStyle}
|
|
>下一页</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Routes Tab */}
|
|
{activeTab === 'routes' && (
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
|
<h3 style={{ margin: 0, fontSize: '16px' }}>消息路由规则</h3>
|
|
<button onClick={() => setShowRouteModal(true)} style={primaryButtonStyle}>+ 新建路由</button>
|
|
</div>
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: '#0f1117' }}>
|
|
{['路由名称', '频道', 'Agent', '匹配类型', '匹配规则', '优先级', '状态', '操作'].map((h) => (
|
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#888', fontWeight: 600, borderBottom: '1px solid #2a2d35' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{routes.length === 0 ? (
|
|
<tr><td colSpan={8} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>暂无路由规则</td></tr>
|
|
) : (
|
|
routes.map((r) => (
|
|
<tr key={r.id} style={{ borderBottom: '1px solid #1a1d27' }}>
|
|
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{r.route_name}</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
|
|
{r.channel_name || r.channel_id?.slice(0, 8)}
|
|
<span style={{ marginLeft: '4px', fontSize: '11px', color: '#666' }}>{r.platform}</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{r.agent_name || r.agent_id?.slice(0, 8) || '-'}</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{ padding: '2px 8px', borderRadius: '4px', fontSize: '12px', background: '#3370FF20', color: '#3370FF' }}>
|
|
{r.match_type === 'all' ? '全部消息' : r.match_type === 'keyword' ? '关键词' : r.match_type === 'regex' ? '正则' : '发送者'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#aaa', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{r.match_pattern || '-'}
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{r.priority}</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{ color: r.is_active ? '#10b981' : '#666', fontSize: '13px' }}>
|
|
{r.is_active ? '启用' : '禁用'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<button onClick={() => handleDeleteRoute(r.id)} style={{ ...actionBtnStyle, color: '#ef4444' }}>删除</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* LangBot Tab */}
|
|
{activeTab === 'langbot' && (
|
|
<div style={{ maxWidth: '800px' }}>
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
<h3 style={{ margin: 0, fontSize: '16px' }}>LangBot 网关配置</h3>
|
|
<span style={{
|
|
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
|
|
background: langbotForm.status === 'active' ? '#10b98120' : '#6b728020',
|
|
color: langbotForm.status === 'active' ? '#10b981' : '#6b7280',
|
|
}}>
|
|
{langbotForm.status === 'active' ? '已启用' : '未启用'}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
<FormField label="网关 URL" >
|
|
<input
|
|
value={langbotForm.gateway_url}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, gateway_url: e.target.value })}
|
|
placeholder="http://localhost:5300"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Bot UUID">
|
|
<input
|
|
value={langbotForm.bot_uuid || ''}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, bot_uuid: e.target.value })}
|
|
placeholder="LangBot HTTP Bot UUID"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="API Key">
|
|
<input
|
|
type="password"
|
|
value={langbotForm.api_key || ''}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, api_key: e.target.value })}
|
|
placeholder="LangBot REST API Key"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Webhook Secret">
|
|
<input
|
|
type="password"
|
|
value={langbotForm.webhook_secret || ''}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, webhook_secret: e.target.value })}
|
|
placeholder="HMAC-SHA256 签名密钥"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="回调 URL">
|
|
<input
|
|
value={langbotForm.callback_url || ''}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, callback_url: e.target.value })}
|
|
placeholder="http://210.16.161.218:12888/app/api/channels/webhook/langbot"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="自动同步频道">
|
|
<select
|
|
value={langbotForm.sync_channels}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, sync_channels: Number(e.target.value) })}
|
|
style={selectStyle}
|
|
>
|
|
<option value={0}>否</option>
|
|
<option value={1}>是</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="状态">
|
|
<select
|
|
value={langbotForm.status}
|
|
onChange={(e) => setLangbotForm({ ...langbotForm, status: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="inactive">未启用</option>
|
|
<option value="active">启用</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
|
<button onClick={handleSaveLangbot} style={primaryButtonStyle}>保存配置</button>
|
|
<button onClick={handleTestLangbot} style={secondaryButtonStyle}>测试连接</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* LangBot Info */}
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
|
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>LangBot 集成说明</h4>
|
|
<ul style={{ fontSize: '13px', color: '#aaa', lineHeight: '1.8', paddingLeft: '20px' }}>
|
|
<li>LangBot 是开源的 LLM 原生 IM 机器人平台,支持飞书、微信、钉钉等</li>
|
|
<li>配置 LangBot 作为外部网关后,IM 消息将通过 LangBot 转发到本平台</li>
|
|
<li>在 LangBot 中创建 HTTP Bot 适配器,将 Webhook URL 指向本平台</li>
|
|
<li>API Key 在 LangBot 的 config.yaml 中通过 api.global_api_key 配置</li>
|
|
<li>Webhook Secret 用于 HMAC-SHA256 双向签名验证</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages Tab */}
|
|
{activeTab === 'messages' && (
|
|
<div>
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: '#0f1117' }}>
|
|
{['时间', '频道', '平台', '方向', '发送者', '内容', '状态'].map((h) => (
|
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#888', fontWeight: 600, borderBottom: '1px solid #2a2d35' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{messages.length === 0 ? (
|
|
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>暂无消息记录</td></tr>
|
|
) : (
|
|
messages.map((msg, i) => (
|
|
<tr key={i} style={{ borderBottom: '1px solid #1a1d27' }}>
|
|
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#666' }}>{formatTime(msg.created_at)}</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{msg.channel_name || msg.channel_id?.slice(0, 8)}</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{ fontSize: '12px', color: platformColors[msg.platform] || '#888' }}>
|
|
{msg.platform_info?.name || msg.platform}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{
|
|
padding: '2px 6px', borderRadius: '4px', fontSize: '11px',
|
|
background: msg.direction === 'in' ? '#10b98120' : '#3370FF20',
|
|
color: msg.direction === 'in' ? '#10b981' : '#3370FF',
|
|
}}>
|
|
{msg.direction === 'in' ? '接收' : '发送'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#aaa' }}>{msg.sender_name || msg.sender_id || '-'}</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '13px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{msg.content || '-'}
|
|
</td>
|
|
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#666' }}>{msg.status || '-'}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{messagesTotal > 50 && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
|
<button
|
|
onClick={() => setMessagesPage(p => Math.max(1, p - 1))}
|
|
disabled={messagesPage === 1}
|
|
style={pageBtnStyle}
|
|
>上一页</button>
|
|
<span style={{ padding: '8px 12px', color: '#888', fontSize: '14px' }}>
|
|
{messagesPage} / {Math.ceil(messagesTotal / 50)}
|
|
</span>
|
|
<button
|
|
onClick={() => setMessagesPage(p => p + 1)}
|
|
disabled={messagesPage >= Math.ceil(messagesTotal / 50)}
|
|
style={pageBtnStyle}
|
|
>下一页</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Setup Tab */}
|
|
{activeTab === 'setup' && (
|
|
<div style={{ maxWidth: '600px' }}>
|
|
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '16px' }}>快速接入向导</h3>
|
|
<p style={{ color: '#888', fontSize: '13px' }}>选择平台并填写基本信息,系统将自动创建频道并生成 Webhook 配置指南</p>
|
|
|
|
{/* Platform selection cards */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', margin: '16px 0' }}>
|
|
{platforms.map((p) => (
|
|
<div
|
|
key={p.key}
|
|
onClick={() => setSetupForm({ ...setupForm, platform: p.key })}
|
|
style={{
|
|
padding: '12px', borderRadius: '8px', cursor: 'pointer',
|
|
background: setupForm.platform === p.key ? p.color + '20' : '#0f1117',
|
|
border: setupForm.platform === p.key ? `1px solid ${p.color}` : '1px solid #2a2d35',
|
|
textAlign: 'center', transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: '36px', height: '36px', borderRadius: '8px',
|
|
background: p.color, margin: '0 auto 6px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '13px', fontWeight: 700, color: '#fff',
|
|
}}>
|
|
{p.name.slice(0, 2)}
|
|
</div>
|
|
<div style={{ fontSize: '12px' }}>{p.name}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<FormField label="频道名称">
|
|
<input
|
|
value={setupForm.name}
|
|
onChange={(e) => setSetupForm({ ...setupForm, name: e.target.value })}
|
|
placeholder="例如:飞书客服机器人"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="绑定 Agent(可选)">
|
|
<select
|
|
value={setupForm.agent_id}
|
|
onChange={(e) => setSetupForm({ ...setupForm, agent_id: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">不绑定</option>
|
|
{agents.map((a) => (
|
|
<option key={a.id} value={a.id}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
|
|
<FormField label="凭证信息(可选,JSON 格式)">
|
|
<textarea
|
|
value={setupForm.credentials}
|
|
onChange={(e) => setSetupForm({ ...setupForm, credentials: e.target.value })}
|
|
placeholder='{"app_id": "xxx", "app_secret": "xxx"}'
|
|
style={{ ...inputStyle, minHeight: '80px', fontFamily: 'monospace' }}
|
|
/>
|
|
</FormField>
|
|
|
|
<button onClick={handleQuickSetup} style={{ ...primaryButtonStyle, width: '100%', marginTop: '16px' }}>
|
|
创建频道并获取配置指南
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Channel Detail Modal */}
|
|
{showChannelDetail && selectedChannel && (
|
|
<Modal title="频道详情" onClose={() => setShowChannelDetail(false)} width="700px">
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
<DetailItem label="频道名称" value={selectedChannel.name} />
|
|
<DetailItem label="平台" value={selectedChannel.platform_info?.name || selectedChannel.platform} />
|
|
<DetailItem label="状态" value={selectedChannel.status} />
|
|
<DetailItem label="活跃" value={selectedChannel.is_active ? '是' : '否'} />
|
|
<DetailItem label="Webhook URL" value={selectedChannel.webhook_url || '-'} />
|
|
<DetailItem label="Agent" value={selectedChannel.agent_name || '-'} />
|
|
<DetailItem label="创建时间" value={formatTime(selectedChannel.created_at)} />
|
|
<DetailItem label="最后活跃" value={formatTime(selectedChannel.last_active)} />
|
|
</div>
|
|
|
|
{selectedChannel.routes && selectedChannel.routes.length > 0 && (
|
|
<div style={{ marginTop: '20px' }}>
|
|
<h4 style={{ fontSize: '14px', color: '#888', marginBottom: '8px' }}>路由规则</h4>
|
|
{selectedChannel.routes.map((r: any, i: number) => (
|
|
<div key={i} style={{ padding: '8px 12px', background: '#0f1117', borderRadius: '6px', marginBottom: '4px', fontSize: '13px' }}>
|
|
{r.route_name} → {r.agent_name || r.agent_id?.slice(0, 8)} (优先级: {r.priority})
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedChannel.recent_messages && selectedChannel.recent_messages.length > 0 && (
|
|
<div style={{ marginTop: '20px' }}>
|
|
<h4 style={{ fontSize: '14px', color: '#888', marginBottom: '8px' }}>最近消息</h4>
|
|
{selectedChannel.recent_messages.slice(0, 5).map((msg: any, i: number) => (
|
|
<div key={i} style={{ padding: '8px 12px', background: '#0f1117', borderRadius: '6px', marginBottom: '4px', fontSize: '13px' }}>
|
|
<span style={{ color: msg.direction === 'in' ? '#10b981' : '#3370FF' }}>
|
|
[{msg.direction === 'in' ? '收' : '发'}]
|
|
</span>{' '}
|
|
{msg.content?.slice(0, 100)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Route Modal */}
|
|
{showRouteModal && (
|
|
<Modal title="新建路由规则" onClose={() => setShowRouteModal(false)} width="500px">
|
|
<FormField label="频道">
|
|
<select
|
|
value={routeForm.channel_id}
|
|
onChange={(e) => setRouteForm({ ...routeForm, channel_id: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">选择频道</option>
|
|
{channels.map((ch) => (
|
|
<option key={ch.id} value={ch.id}>{ch.name} ({ch.platform})</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Agent">
|
|
<select
|
|
value={routeForm.agent_id}
|
|
onChange={(e) => setRouteForm({ ...routeForm, agent_id: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">选择 Agent</option>
|
|
{agents.map((a) => (
|
|
<option key={a.id} value={a.id}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="匹配类型">
|
|
<select
|
|
value={routeForm.match_type}
|
|
onChange={(e) => setRouteForm({ ...routeForm, match_type: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="all">全部消息</option>
|
|
<option value="keyword">关键词匹配</option>
|
|
<option value="regex">正则匹配</option>
|
|
<option value="sender">指定发送者</option>
|
|
</select>
|
|
</FormField>
|
|
{routeForm.match_type !== 'all' && (
|
|
<FormField label="匹配规则">
|
|
<input
|
|
value={routeForm.match_pattern}
|
|
onChange={(e) => setRouteForm({ ...routeForm, match_pattern: e.target.value })}
|
|
placeholder={routeForm.match_type === 'keyword' ? '关键词1,关键词2' : routeForm.match_type === 'regex' ? '正则表达式' : '用户ID'}
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
)}
|
|
<FormField label="优先级">
|
|
<input
|
|
type="number"
|
|
value={routeForm.priority}
|
|
onChange={(e) => setRouteForm({ ...routeForm, priority: Number(e.target.value) })}
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<button onClick={handleCreateRoute} style={{ ...primaryButtonStyle, width: '100%', marginTop: '16px' }}>创建路由</button>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Webhook Modal */}
|
|
{showWebhookModal && webhookInfo && (
|
|
<Modal title="Webhook 配置指南" onClose={() => setShowWebhookModal(false)} width="600px">
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ fontSize: '13px', color: '#888' }}>Webhook URL</label>
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
|
<input
|
|
value={webhookInfo.full_url || webhookInfo.webhook_url}
|
|
readOnly
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
/>
|
|
<button onClick={() => copyToClipboard(webhookInfo.full_url || webhookInfo.webhook_url)} style={secondaryButtonStyle}>复制</button>
|
|
</div>
|
|
</div>
|
|
{webhookInfo.secret && (
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<label style={{ fontSize: '13px', color: '#888' }}>验证密钥</label>
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
|
<input
|
|
value={webhookInfo.secret}
|
|
readOnly
|
|
style={{ ...inputStyle, flex: 1, fontFamily: 'monospace' }}
|
|
/>
|
|
<button onClick={() => copyToClipboard(webhookInfo.secret)} style={secondaryButtonStyle}>复制</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h4 style={{ fontSize: '14px', color: '#888', marginBottom: '8px' }}>配置步骤</h4>
|
|
<ol style={{ paddingLeft: '20px', fontSize: '13px', color: '#aaa', lineHeight: '2' }}>
|
|
{webhookInfo.instructions?.map((step: string, i: number) => (
|
|
<li key={i}>{step}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Setup Modal */}
|
|
{showSetupModal && (
|
|
<Modal title="快速接入" onClose={() => setShowSetupModal(false)} width="600px">
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', margin: '16px 0' }}>
|
|
{platforms.map((p) => (
|
|
<div
|
|
key={p.key}
|
|
onClick={() => setSetupForm({ ...setupForm, platform: p.key })}
|
|
style={{
|
|
padding: '12px', borderRadius: '8px', cursor: 'pointer',
|
|
background: setupForm.platform === p.key ? p.color + '20' : '#0f1117',
|
|
border: setupForm.platform === p.key ? `1px solid ${p.color}` : '1px solid #2a2d35',
|
|
textAlign: 'center', transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: '36px', height: '36px', borderRadius: '8px',
|
|
background: p.color, margin: '0 auto 6px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '13px', fontWeight: 700, color: '#fff',
|
|
}}>
|
|
{p.name.slice(0, 2)}
|
|
</div>
|
|
<div style={{ fontSize: '12px' }}>{p.name}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<FormField label="频道名称">
|
|
<input
|
|
value={setupForm.name}
|
|
onChange={(e) => setSetupForm({ ...setupForm, name: e.target.value })}
|
|
placeholder="例如:飞书客服机器人"
|
|
style={inputStyle}
|
|
/>
|
|
</FormField>
|
|
<FormField label="绑定 Agent(可选)">
|
|
<select
|
|
value={setupForm.agent_id}
|
|
onChange={(e) => setSetupForm({ ...setupForm, agent_id: e.target.value })}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">不绑定</option>
|
|
{agents.map((a) => (
|
|
<option key={a.id} value={a.id}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<button onClick={handleQuickSetup} style={{ ...primaryButtonStyle, width: '100%', marginTop: '16px' }}>
|
|
创建频道并获取配置指南
|
|
</button>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// Sub-components
|
|
// ============================================================
|
|
|
|
const StatBadge: React.FC<{ label: string; value: any; color: string }> = ({ label, value, color }) => (
|
|
<div style={{
|
|
padding: '6px 12px', borderRadius: '6px',
|
|
background: color + '15', border: `1px solid ${color}30`,
|
|
display: 'flex', alignItems: 'center', gap: '6px',
|
|
}}>
|
|
<span style={{ fontSize: '12px', color: '#888' }}>{label}</span>
|
|
<span style={{ fontSize: '16px', fontWeight: 700, color }}>{value}</span>
|
|
</div>
|
|
);
|
|
|
|
const StatCard: React.FC<{ title: string; value: any; icon: string; color: string }> = ({ title, value, color }) => (
|
|
<div style={{
|
|
background: '#1a1d27', borderRadius: '12px', padding: '20px',
|
|
display: 'flex', flexDirection: 'column', gap: '8px',
|
|
}}>
|
|
<span style={{ fontSize: '13px', color: '#888' }}>{title}</span>
|
|
<span style={{ fontSize: '32px', fontWeight: 700, color }}>{value}</span>
|
|
</div>
|
|
);
|
|
|
|
const FormField: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={{ display: 'block', fontSize: '13px', color: '#888', marginBottom: '4px' }}>{label}</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const DetailItem: React.FC<{ label: string; value: any }> = ({ label, value }) => (
|
|
<div style={{ padding: '8px 12px', background: '#0f1117', borderRadius: '6px' }}>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>{label}</div>
|
|
<div style={{ fontSize: '14px', marginTop: '2px', wordBreak: 'break-all' }}>{value || '-'}</div>
|
|
</div>
|
|
);
|
|
|
|
const Modal: React.FC<{ title: string; children: React.ReactNode; onClose: () => void; width?: string }> = ({ title, children, onClose, width = '500px' }) => (
|
|
<div style={{
|
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
zIndex: 1000,
|
|
}} onClick={onClose}>
|
|
<div style={{
|
|
background: '#1a1d27', borderRadius: '12px', padding: '24px',
|
|
width, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto',
|
|
}} onClick={(e) => e.stopPropagation()}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
<h3 style={{ margin: 0, fontSize: '16px' }}>{title}</h3>
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '20px' }}>x</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Styles
|
|
const selectStyle: React.CSSProperties = {
|
|
width: '100%', padding: '8px 12px', background: '#0f1117', border: '1px solid #2a2d35',
|
|
borderRadius: '6px', color: '#e0e0e0', fontSize: '14px', outline: 'none',
|
|
};
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%', padding: '8px 12px', background: '#0f1117', border: '1px solid #2a2d35',
|
|
borderRadius: '6px', color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
|
};
|
|
|
|
const primaryButtonStyle: React.CSSProperties = {
|
|
padding: '8px 20px', background: '#3370FF', border: 'none', borderRadius: '6px',
|
|
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
|
|
};
|
|
|
|
const secondaryButtonStyle: React.CSSProperties = {
|
|
padding: '8px 20px', background: '#2a2d35', border: '1px solid #3a3d45', borderRadius: '6px',
|
|
color: '#e0e0e0', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
|
|
};
|
|
|
|
const actionBtnStyle: React.CSSProperties = {
|
|
padding: '4px 10px', background: '#2a2d35', border: '1px solid #3a3d45', borderRadius: '4px',
|
|
color: '#aaa', fontSize: '12px', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
};
|
|
|
|
const pageBtnStyle: React.CSSProperties = {
|
|
padding: '8px 16px', background: '#1a1d27', border: '1px solid #2a2d35', borderRadius: '6px',
|
|
color: '#e0e0e0', fontSize: '14px', cursor: 'pointer',
|
|
};
|
|
|
|
export default ImBots;
|