Files
qihuaiflow/frontend/src/pages/admin/ImBots.tsx
T

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;