feat: 补齐缺失页 Phase1 - 多平台IM Bot(企业微信/飞书/QQ/悟空IM)+应用服务页+路由导航
This commit is contained in:
1 parent
fa01b5df5b
commit
6525b4d994
7 files changed
+1163
No files matched your search
@@ -56,6 +56,8 @@ const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
|
||||
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
|
||||
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
|
||||
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
|
||||
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
|
||||
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
|
||||
|
||||
const pageFallback = <div className="loading-screen">加载中...</div>;
|
||||
|
||||
@@ -121,6 +123,8 @@ const App: React.FC = () => {
|
||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||
<Route path="wechat-bot" element={<WechatBot />} />
|
||||
<Route path="im-bots" element={<ImBotHub />} />
|
||||
<Route path="app-services" element={<AppServices />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
|
||||
@@ -1955,5 +1955,31 @@ export const imUserAPI = {
|
||||
|
||||
history: () => get<any>('/im/wechat/history'),
|
||||
|
||||
// 多平台 IM Bot(企业微信/飞书/QQ/悟空IM)
|
||||
getPlatforms: () => get<any>('/im/platform/platforms'),
|
||||
|
||||
listPlatforms: () => get<any>('/im/platform/list'),
|
||||
|
||||
platformConfig: (platform: string) => get<any>(`/im/platform/${platform}/config`),
|
||||
|
||||
savePlatformConfig: (platform: string, data: any) => post<any>(`/im/platform/${platform}/config`, data),
|
||||
|
||||
disconnectPlatform: (platform: string) => post<any>(`/im/platform/${platform}/disconnect`),
|
||||
|
||||
platformHistory: (platform: string) => get<any>(`/im/platform/${platform}/history`),
|
||||
|
||||
platformWebhookUrl: (platform: string) => get<any>(`/im/platform/${platform}/webhook-url`),
|
||||
|
||||
// 应用服务(已发布 AI 应用浏览)
|
||||
appServices: (params?: { category?: string; search?: string; page?: number; page_size?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.category) qs.set('category', params.category);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return get<any>(`/im/platform/apps${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -17,11 +17,16 @@ const navGroups = [
|
||||
title: 'IM Bot',
|
||||
items: [
|
||||
{ path: '/wechat-bot', icon: 'wechat', label: '微信Bot' },
|
||||
{ path: '/im-bots?platform=wecom', icon: 'wecom', label: '企业微信' },
|
||||
{ path: '/im-bots?platform=feishu', icon: 'feishu', label: '飞书' },
|
||||
{ path: '/im-bots?platform=qq', icon: 'qq', label: 'QQ' },
|
||||
{ path: '/im-bots?platform=wukong', icon: 'wukong', label: '悟空IM' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '资源中心',
|
||||
items: [
|
||||
{ path: '/app-services', icon: 'apps', label: '应用服务' },
|
||||
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
|
||||
{ path: '/pricing', icon: 'pricing', label: '会员订阅' },
|
||||
{ path: '/assets', icon: 'assets', label: '资产文件' },
|
||||
@@ -47,6 +52,10 @@ const iconPaths: Record<string, JSX.Element> = {
|
||||
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
|
||||
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
|
||||
wechat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><circle cx="9" cy="11" r="1"/><circle cx="15" cy="11" r="1"/></svg>,
|
||||
wecom: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>,
|
||||
feishu: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.2 3.8a2 2 0 0 0-2.8 0L4 17.2V21h3.8L20.2 6.6a2 2 0 0 0 0-2.8z"/><path d="M14 7l3 3"/></svg>,
|
||||
qq: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.5 9.5a1 1 0 0 0 0 1M15.5 9.5a1 1 0 0 0 0 1M9 15c1-1 5-1 6 0"/></svg>,
|
||||
wukong: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></svg>,
|
||||
};
|
||||
|
||||
const FrontLayout: React.FC = () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { imUserAPI } from '@/api';
|
||||
|
||||
const PRIMARY = '#5B5FED';
|
||||
|
||||
const AppServices: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<string>('');
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 2500);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await imUserAPI.appServices({ category: category || undefined, search: search || undefined, page, page_size: 12 });
|
||||
if (res?.success) {
|
||||
setItems(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} else {
|
||||
showToast('加载应用服务失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('加载应用服务失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [category, search, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const categories = [
|
||||
{ key: '', name: '全部' },
|
||||
{ key: 'customer_service', name: '客服助手' },
|
||||
{ key: 'data_analysis', name: '数据分析' },
|
||||
{ key: 'coding', name: '编程辅助' },
|
||||
{ key: 'writing', name: '内容创作' },
|
||||
{ key: 'education', name: '教育培训' },
|
||||
{ key: 'marketing', name: '营销推广' },
|
||||
{ key: 'hr', name: '人力资源' },
|
||||
{ key: 'finance', name: '财务法务' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
|
||||
<div style={{ maxWidth: '1080px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用服务</h1>
|
||||
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
|
||||
浏览平台已上架的 AI 应用服务,按需接入你的工作流。由七虎AI 大脑统一驱动,覆盖客服、分析、创作等多类场景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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.15)' }}>{toast.msg}</div>
|
||||
)}
|
||||
|
||||
{/* 搜索 + 分类筛选 */}
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
placeholder="搜索应用名称 / 描述…"
|
||||
style={{ flex: '1 1 260px', minWidth: '200px', padding: '9px 14px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => { setCategory(c.key); setPage(1); }}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: '20px', fontSize: '13px', cursor: 'pointer',
|
||||
border: '1px solid', borderColor: category === c.key ? PRIMARY : '#E5E7EB',
|
||||
background: category === c.key ? PRIMARY : '#fff', color: category === c.key ? '#fff' : '#6B7280',
|
||||
}}
|
||||
>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: '50px', textAlign: 'center', color: '#9CA3AF', background: '#F8F9FC', borderRadius: '12px' }}>暂无已发布的应用服务</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
|
||||
{items.map((app) => (
|
||||
<div key={app.id} style={{ border: '1px solid #E5E7EB', borderRadius: '14px', padding: '20px', background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{ width: '44px', height: '44px', borderRadius: '10px', background: PRIMARY + '18', color: PRIMARY, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px' }}>{app.icon ? <img src={app.icon} alt="" style={{ width: 28, height: 28, borderRadius: 6 }} /> : '🤖'}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1A1F36' }}>{app.name}</div>
|
||||
<div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}>
|
||||
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#EEF0FF', color: PRIMARY }}>{app.type_name}</span>
|
||||
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#F1F5F9', color: '#64748B' }}>{app.category_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, flex: 1, margin: '0 0 14px 0' }}>{app.description || '暂无描述'}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>{app.published_at ? '上架于 ' + new Date(app.published_at).toLocaleDateString('zh-CN') : ''}</span>
|
||||
<button onClick={() => showToast('已加入「我的应用」,可在 AI 聊天中调用')} style={{ padding: '7px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '13px', fontWeight: 500, cursor: 'pointer' }}>使用</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > 12 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '24px', alignItems: 'center' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn, opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
|
||||
<span style={{ fontSize: '13px', color: '#6B7280' }}>第 {page} 页 / 共 {Math.ceil(total / 12)} 页({total} 个)</span>
|
||||
<button disabled={page >= Math.ceil(total / 12)} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn, opacity: page >= Math.ceil(total / 12) ? 0.4 : 1 }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const pageBtn: React.CSSProperties = { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', cursor: 'pointer' };
|
||||
|
||||
export default AppServices;
|
||||
@@ -0,0 +1,285 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { imUserAPI } from '@/api';
|
||||
|
||||
const PRIMARY = '#5B5FED';
|
||||
const PRIMARY_ACTIVE = '#4A4ECC';
|
||||
|
||||
const ImBotHub: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const highlight = searchParams.get('platform');
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [schemas, setSchemas] = useState<any[]>([]);
|
||||
const [statuses, setStatuses] = useState<Record<string, any>>({});
|
||||
const [activeConfig, setActiveConfig] = useState<string | null>(null);
|
||||
const [historyPlatform, setHistoryPlatform] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<any>({ messages: [] });
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [pRes, lRes] = await Promise.all([imUserAPI.getPlatforms(), imUserAPI.listPlatforms()]);
|
||||
if (pRes?.success) setSchemas(pRes.data || []);
|
||||
if (lRes?.success) {
|
||||
const map: Record<string, any> = {};
|
||||
(lRes.data || []).forEach((it: any) => { map[it.platform] = it; });
|
||||
setStatuses(map);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('加载平台列表失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlight && schemas.length) {
|
||||
const el = document.getElementById(`imcard-${highlight}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [highlight, schemas]);
|
||||
|
||||
const openConfig = (key: string) => setActiveConfig(key);
|
||||
const closeConfig = () => setActiveConfig(null);
|
||||
|
||||
const openHistory = async (key: string) => {
|
||||
setHistoryPlatform(key);
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await imUserAPI.platformHistory(key);
|
||||
if (res?.success) setHistory(res.data || { messages: [] });
|
||||
} catch (e) {
|
||||
showToast('加载对话记录失败', 'error');
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (key: string) => {
|
||||
try {
|
||||
const res = await imUserAPI.disconnectPlatform(key);
|
||||
if (res?.success) {
|
||||
showToast('已断开连接');
|
||||
await load();
|
||||
} else {
|
||||
showToast(res?.error || '断开失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('断开失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getSchema = (key: string) => schemas.find((s) => s.key === key);
|
||||
const getStatus = (key: string) => statuses[key] || { configured: false, status: 'disconnected' };
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
|
||||
<div style={{ maxWidth: '1080px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>IM 机器人</h1>
|
||||
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
|
||||
把企业级即时通讯接入七虎AI 大脑:配置官方开放平台凭证后,消息经本平台自动应答并回写。支持企业微信、飞书、QQ、悟空IM。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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.15)' }}>{toast.msg}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
||||
{schemas.map((s) => {
|
||||
const st = getStatus(s.key);
|
||||
const connected = st.status === 'connected';
|
||||
const badge = connected
|
||||
? { bg: '#10b98120', color: '#10b981', text: '● 已连接' }
|
||||
: st.configured
|
||||
? { bg: '#f59e0b20', color: '#f59e0b', text: '○ 已配置' }
|
||||
: { bg: '#6b728020', color: '#6b7280', text: '○ 未连接' };
|
||||
return (
|
||||
<div id={`imcard-${s.key}`} key={s.key} style={{ border: `1px solid ${connected ? s.color + '55' : '#E5E7EB'}`, borderRadius: '14px', padding: '20px', background: '#FFFFFF', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{ width: '44px', height: '44px', borderRadius: '10px', background: s.color + '18', color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 700 }}>{s.name.slice(0, 1)}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1A1F36' }}>{s.name}</div>
|
||||
<span style={{ display: 'inline-block', marginTop: '4px', padding: '2px 10px', borderRadius: '4px', fontSize: '12px', background: badge.bg, color: badge.color }}>{badge.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, minHeight: '40px', margin: '0 0 14px 0' }}>{s.description}</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => openConfig(s.key)} style={{ ...btnPrimary, flex: 1 }}>{connected ? '重新配置' : '配置连接'}</button>
|
||||
<button onClick={() => openHistory(s.key)} style={{ ...btnGhost }}>对话</button>
|
||||
{connected && <button onClick={() => handleDisconnect(s.key)} style={{ ...btnGhost, color: '#ef4444', borderColor: '#fca5a5' }}>断开</button>}
|
||||
</div>
|
||||
{st.last_error && (
|
||||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', padding: '8px 10px' }}>⚠ {st.last_error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeConfig && (
|
||||
<ConfigModal
|
||||
schema={getSchema(activeConfig)}
|
||||
status={getStatus(activeConfig)}
|
||||
onClose={closeConfig}
|
||||
onSaved={async () => { await load(); closeConfig(); showToast('配置已保存并启用'); }}
|
||||
onError={(m) => showToast(m, 'error')}
|
||||
saving={saving}
|
||||
setSaving={setSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{historyPlatform && (
|
||||
<HistoryModal
|
||||
schema={getSchema(historyPlatform)}
|
||||
data={history}
|
||||
loading={historyLoading}
|
||||
onClose={() => setHistoryPlatform(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfigModal: React.FC<{ schema: any; status: any; onClose: () => void; onSaved: () => void; onError: (m: string) => void; saving: boolean; setSaving: (b: boolean) => void }> = ({ schema, status, onClose, onSaved, onError, saving, setSaving }) => {
|
||||
const [form, setForm] = useState<any>({});
|
||||
const [sendUrl, setSendUrl] = useState<string>('');
|
||||
const [webhook, setWebhook] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// 预填:若已配置,仅显示占位(机密已脱敏)
|
||||
if (status?.configured) {
|
||||
const c = status.creds || {};
|
||||
const f: any = {};
|
||||
(schema?.fields || []).forEach((fld: any) => { f[fld.key] = c[fld.key] && String(c[fld.key]).startsWith('***') ? '' : (c[fld.key] || ''); });
|
||||
setForm(f);
|
||||
setSendUrl('');
|
||||
}
|
||||
}, [schema, status]);
|
||||
|
||||
const fetchWebhook = async () => {
|
||||
try {
|
||||
const res = await imUserAPI.platformWebhookUrl(schema.key);
|
||||
if (res?.success) setWebhook(res.data.webhook_url);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// 校验必填
|
||||
for (const fld of (schema?.fields || [])) {
|
||||
if (fld.required && !String(form[fld.key] || '').trim()) {
|
||||
onError(`请填写:${fld.label}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: any = { ...form };
|
||||
if (sendUrl) payload.send_url = sendUrl;
|
||||
const res = await imUserAPI.savePlatformConfig(schema.key, payload);
|
||||
if (res?.success) {
|
||||
await fetchWebhook();
|
||||
onSaved();
|
||||
} else {
|
||||
onError(res?.error || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
onError('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '520px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#1A1F36' }}>配置 {schema?.name}</h3>
|
||||
<button onClick={onClose} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', color: '#9CA3AF' }}>×</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, margin: '0 0 16px 0', background: '#F8F9FC', padding: '12px 14px', borderRadius: '8px' }}>{schema?.doc}</p>
|
||||
|
||||
{(schema?.fields || []).map((fld: any) => (
|
||||
<div key={fld.key} style={{ marginBottom: '14px' }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#374151', marginBottom: '6px' }}>{fld.label}{fld.required && <span style={{ color: '#ef4444' }}> *</span>}</label>
|
||||
<input
|
||||
type={fld.secret ? 'password' : 'text'}
|
||||
placeholder={fld.placeholder || ''}
|
||||
value={form[fld.key] || ''}
|
||||
onChange={(e) => setForm((p: any) => ({ ...p, [fld.key]: e.target.value }))}
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{['qq', 'wukong'].includes(schema?.key) && (
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#374151', marginBottom: '6px' }}>回写发送地址 (send_url)</label>
|
||||
<input type="text" placeholder="https://your-platform/send (可选,用于把回复发回平台)" value={sendUrl} onChange={(e) => setSendUrl(e.target.value)} style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleSave} disabled={saving} style={{ ...btnPrimary, width: '100%', marginTop: '6px' }}>{saving ? '保存中…' : '保存并启用'}</button>
|
||||
|
||||
{webhook && (
|
||||
<div style={{ marginTop: '16px', fontSize: '12px' }}>
|
||||
<div style={{ color: '#374151', marginBottom: '6px' }}>回调地址(配置到 {schema?.name} 开放平台):</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<code style={{ flex: 1, wordBreak: 'break-all', background: '#F8F9FC', border: '1px solid #E5E7EB', borderRadius: '6px', padding: '8px 10px', color: '#1A1F36' }}>{webhook}</code>
|
||||
<button onClick={() => { navigator.clipboard?.writeText(webhook); }} style={{ ...btnGhost }}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HistoryModal: React.FC<{ schema: any; data: any; loading: boolean; onClose: () => void }> = ({ schema, data, loading, onClose }) => (
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '560px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#1A1F36' }}>{schema?.name} · 对话记录</h3>
|
||||
<button onClick={onClose} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', color: '#9CA3AF' }}>×</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '30px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
|
||||
) : (data?.messages || []).length === 0 ? (
|
||||
<div style={{ padding: '30px', textAlign: 'center', color: '#9CA3AF' }}>暂无对话记录</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{(data.messages || []).map((m: any) => (
|
||||
<div key={m.id} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||
<div style={{ maxWidth: '80%', padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: 1.6, background: m.role === 'user' ? PRIMARY : '#F1F2FB', color: m.role === 'user' ? '#fff' : '#1A1F36' }}>
|
||||
{m.content}
|
||||
<div style={{ fontSize: '11px', opacity: 0.6, marginTop: '4px' }}>{m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const btnPrimary: React.CSSProperties = { padding: '8px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer' };
|
||||
const btnGhost: React.CSSProperties = { padding: '8px 14px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer' };
|
||||
|
||||
export default ImBotHub;
|
||||
Reference in new issue
Block a user