feat: 个人微信改为前台每用户独立绑定(按 user_id 隔离),后台仅保留全局 Bot

This commit is contained in:
WorkBuddy committed 2026-07-09 20:53:25 +08:00
1 parent ce0a7b4243
commit 75a708a14d
7 files changed
+1335 -574

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ import NotificationPage from '@/pages/front/NotificationPage';
import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
import WechatBot from '@/pages/front/WechatBot';
// Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard';
@@ -117,6 +118,7 @@ const App: React.FC = () => {
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
</Route>
{/* Admin Routes */}
+18 -5
View File
@@ -110,7 +110,7 @@ export const chatAPI = {
clearContext: (convId: string) =>
post<any>(`/chat/conversations/${convId}/clear`),
convertToKnowledgeGraph: (convId: string) =>
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
post<any>(`/chat/conversations/${convId}/to-knowledge-graph/`),
getModels: () => get<any[]>('/chat/models'),
};
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents`, data),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents/`, data),
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
getDirectories: () => get<any[]>('/knowledge/directories'),
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
@@ -501,6 +501,7 @@ export const adminAPI = {
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
getAdminStorageUsage: (id: string) => get<any>(`/admin/storage/${id}/usage`),
// Storage Quota APIs (Phase C5)
getStorageQuotaOverview: () => get<any>('/admin/storage/quota/overview'),
getStorageQuotaUsers: (params?: any) => {
@@ -859,9 +860,12 @@ export const evolutionAPI = {
getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`),
createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`),
deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`),
getLangbotConfig: () => get('/admin/im-bot/langbot'),
updateLangbotConfig: (params: string) => post(`/admin/im-bot/langbot${params}`),
testLangbot: (params: string) => post(`/admin/im-bot/langbot/test${params}`),
// 个人微信(iLink 协议)原生适配器
getWechatConfig: () => get('/admin/im-bot/wechat'),
wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'),
wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`),
getWechatStatus: () => get('/admin/im-bot/wechat/status'),
disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'),
getImBotPlatforms: () => get('/admin/im-bot/platforms'),
getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`),
getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`),
@@ -988,3 +992,12 @@ export const evolutionAPI = {
},
};
// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
export const imUserAPI = {
getStatus: () => get<any>('/im/wechat/status'),
qrcode: () => post<any>('/im/wechat/qrcode'),
qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
disconnect: () => post<any>('/im/wechat/disconnect'),
};
@@ -12,6 +12,7 @@ const navItems = [
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
{ path: '/assets', icon: 'assets', label: '资产文件' },
{ path: '/evolution', icon: 'evolution', label: 'AI自进化' },
{ path: '/wechat-bot', icon: 'wechat', label: '微信机器人' },
];
const bottomItems = [
@@ -31,6 +32,7 @@ const iconPaths: Record<string, JSX.Element> = {
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
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>,
};
const FrontLayout: React.FC = () => {
+214
View File
@@ -0,0 +1,214 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { imUserAPI } from '@/api';
const WechatBot: React.FC = () => {
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<any>({ connected: false, configured: false });
const [qrcode, setQrcode] = useState<any>(null);
const [polling, setPolling] = useState(false);
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), 3000);
};
// 加载当前用户的连接状态
const loadStatus = useCallback(async () => {
try {
const res = await imUserAPI.getStatus();
if ((res as any)?.success) setStatus(res.data || { connected: false, configured: false });
} catch (e) {
// 静默失败
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
// 获取扫码二维码(iLink 协议免 AppID)
const handleQrcode = async () => {
setLoading(true);
try {
const res = await imUserAPI.qrcode();
if ((res as any)?.success && (res as any).data) {
setQrcode((res as any).data);
setPolling(true);
showToast('二维码已生成,请用手机微信扫码');
} else {
showToast((res as any).error || '获取二维码失败', 'error');
}
} catch (e) {
showToast('获取二维码失败', 'error');
} finally {
setLoading(false);
}
};
// 轮询扫码状态
const handlePoll = useCallback(async () => {
if (!qrcode?.qrcode) return;
try {
const res = await imUserAPI.qrcodeStatus(qrcode.qrcode);
if ((res as any)?.success && (res as any).data) {
const d = (res as any).data;
if (d.status === 'confirmed' || d.connected) {
setPolling(false);
setStatus({ ...status, connected: true, configured: true });
showToast('个人微信已连接!');
loadStatus();
} else if (d.status === 'expired') {
setPolling(false);
showToast('二维码已过期,请重新获取', 'error');
}
}
} catch (e) {
// 忽略轮询错误
}
}, [qrcode, status, loadStatus]);
useEffect(() => {
if (polling && qrcode?.qrcode) {
const t = setInterval(() => handlePoll(), 3000);
return () => clearInterval(t);
}
}, [polling, qrcode, handlePoll]);
// 断开连接
const handleDisconnect = async () => {
try {
await imUserAPI.disconnect();
setPolling(false);
setQrcode(null);
setStatus({ connected: false, configured: false });
showToast('已断开个人微信连接');
loadStatus();
} catch (e) {
showToast('断开失败', 'error');
}
};
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>我的微信机器人</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
绑定你自己的个人微信,由七虎AI 大脑自动应答。基于腾讯官方 iLink 协议,扫码授权即可,无需任何开放平台凭证。
</p>
</div>
{/* Toast */}
{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', justifyContent: 'space-between', alignItems: 'center', background: '#F8F9FC', borderRadius: '12px', padding: '16px 24px', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: 0, fontSize: '16px', color: '#1A1F36' }}>个人微信(iLink 协议)</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#6B7280' }}>
直接对接腾讯官方 iLink Bot HTTP API,扫码授权后由七虎AI 自动回复。
</p>
</div>
<span style={{
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
background: status.connected ? '#10b98120' : '#6b728020',
color: status.connected ? '#10b981' : '#6b7280',
}}>
{status.connected ? '● 已连接' : status.configured ? '○ 已配置' : '○ 未绑定'}
</span>
</div>
{/* 扫码绑定 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>扫码授权绑定</h4>
{status.connected ? (
<div style={{ fontSize: '13px', color: '#10b981' }}>已绑定,你的个人微信正在自动收发消息并由七虎AI 应答。</div>
) : !qrcode ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '12px' }}>
<div style={{ fontSize: '13px', color: '#6B7280' }}>
点击「获取扫码二维码」,使用手机微信扫描二维码并确认授权,即可将你的个人微信接入七虎AI。整个过程无需申请任何开放平台凭证。
</div>
<button onClick={handleQrcode} disabled={loading} style={primaryBtnStyle}>
{loading ? '生成中…' : '获取扫码二维码'}
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
{qrcode.qrcode_img_content ? (
<img src={qrcode.qrcode_img_content} alt="微信扫码" style={{ width: '220px', height: '220px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }} />
) : (
<div style={{ width: '220px', height: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', fontSize: '12px', color: '#9CA3AF', textAlign: 'center', padding: '12px' }}>
二维码数据缺失,请重新获取。
</div>
)}
<div style={{ fontSize: '13px', color: polling ? '#f59e0b' : '#6B7280' }}>
{polling ? '等待扫码中…(每 3 秒自动检测一次)' : '已停止轮询'}
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{polling && (
<button onClick={() => setPolling(false)} style={secondaryBtnStyle}>取消轮询</button>
)}
<button onClick={handleDisconnect} style={{ ...secondaryBtnStyle, color: '#ef4444', borderColor: '#ef4444' }}>断开连接</button>
</div>
</div>
)}
</div>
{/* 已连接信息 */}
{status.connected && (
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>连接信息</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<DetailItem label="绑定账号" value={status.username || '-'} />
<DetailItem label="连接时间" value={formatTime(status.connected_at)} />
<DetailItem label="iLink Bot ID" value={status.ilink_bot_id || '-'} />
<DetailItem label="Base URL" value={status.baseurl || '-'} />
</div>
</div>
)}
{/* 说明 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>接入说明</h4>
<ul style={{ fontSize: '13px', color: '#6B7280', lineHeight: '1.8', paddingLeft: '20px', margin: 0 }}>
<li>每个账号独立绑定自己的个人微信,数据严格按账号隔离,互不影响</li>
<li>个人微信通过腾讯官方 iLink Bot HTTP API(微信 ClawBot 插件)接入,消息经七虎AI 大脑生成回复</li>
<li>采用官方合法协议,无需 Hook / 逆向,封号风险远低于历史方案,但仍请遵守微信平台规范</li>
<li>消息收发基于长轮询(getupdates)+ 官方 sendmessage,回复自动关联对话窗口(context_token)</li>
</ul>
</div>
</div>
</div>
);
};
const DetailItem: React.FC<{ label: string; value: any }> = ({ label, value }) => (
<div style={{ padding: '8px 12px', background: '#FFFFFF', borderRadius: '6px' }}>
<div style={{ fontSize: '12px', color: '#666' }}>{label}</div>
<div style={{ fontSize: '14px', marginTop: '2px', wordBreak: 'break-all' }}>{value || '-'}</div>
</div>
);
const primaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
const secondaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#F0F2F8', border: '1px solid #F0F2F8', borderRadius: '6px',
color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
export default WechatBot;