feat: 补齐缺失页 Phase1 - 多平台IM Bot(企业微信/飞书/QQ/悟空IM)+应用服务页+路由导航

This commit is contained in:
QihuAI Design committed 2026-07-14 11:38:08 +08:00
1 parent fa01b5df5b
commit 6525b4d994
7 files changed
+1163

No files matched your search

+4
View File
@@ -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 */}
+26
View File
@@ -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 = () => {
+131
View File
@@ -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;
+285
View File
@@ -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;