1. 微信Bot对话记录时间按北京时间渲染(ImBotHub/WechatHistory 的 formatTime 显式 +08:00 + Asia/Shanghai); 2. 新增 /app/api/chat/media/relay 媒体中转端点(白名单 agnES 域名, 经香港代理代取), 改写 _wechat_send_media 用平台中转地址,微信/iLink 可内联播放图片/视频; 3. 删除 014_clean_test_data.sql:该迁移每次重启重跑并 DELETE token_usage_logs/ messages/conversations/knowledge 等,导致 Token 驾驶舱数据每次重启被清空; 4. 002_init_data.sql 移除 market_apps 自动播种(原 WHERE COUNT=0 在用户删空后重启复活), 技能市场交由用户后台维护,重启不再复活。
296 lines
15 KiB
TypeScript
296 lines
15 KiB
TypeScript
// @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';
|
||
|
||
// 后端按北京时间存储(无时区标记的 ISO),此处显式按 Asia/Shanghai 渲染,
|
||
// 避免浏览器本地时区导致对话时间错乱(与个人微信绑定页一致)。
|
||
function formatBjTime(t?: string): string {
|
||
if (!t) return '';
|
||
const iso = /[Z+]/.test(t) ? t : t + '+08:00';
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return t;
|
||
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||
}
|
||
|
||
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' }}>{formatBjTime(m.created_at)}</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;
|