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
@@ -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