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

+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;