Files
qihuaiflow/frontend/src/pages/front/ImBotHub.tsx
T
QihuAI Design 82afe14a23 fix(wechat/token/market): 修复微信对话时间、Token数据重置、技能市场复活、媒体内联
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 在用户删空后重启复活),
   技能市场交由用户后台维护,重启不再复活。
2026-07-16 09:19:23 +08:00

296 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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;