diff --git a/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx b/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx index dc05f17c..bcd7ffc1 100644 --- a/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx +++ b/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { RefreshCw, Users, UserCheck, TrendingUp, Zap, Link2, - CheckCircle2, XCircle, DollarSign, Activity, Smartphone, + CheckCircle2, XCircle, DollarSign, Activity, Smartphone, ExternalLink, } from 'lucide-react' import { get, post } from '@/api/client' @@ -20,110 +20,67 @@ interface MatchStats { paidMatchCount?: number } +interface CKBPlanStats { + ckbTotal: number + withContact: number + byType: { matchType: string; total: number }[] + ckbApiKey: string + ckbApiUrl: string +} + interface CKBTestResult { - endpoint: string - label: string - description: string - method: 'GET' | 'POST' - status: 'idle' | 'testing' | 'success' | 'error' - message?: string - responseTime?: number - ckbResponse?: string + endpoint: string; label: string; description: string + method: 'GET' | 'POST'; status: 'idle' | 'testing' | 'success' | 'error' + message?: string; responseTime?: number } -const typeLabels: Record = { - partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募', -} -const typeIcons: Record = { - partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮', -} +const typeLabels: Record = { partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募' } +const typeIcons: Record = { partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮' } -interface CKBStatsTabProps { - onSwitchTab?: (tabId: string) => void -} +interface Props { onSwitchTab?: (tabId: string) => void } -export function CKBStatsTab({ onSwitchTab }: CKBStatsTabProps = {}) { +export function CKBStatsTab({ onSwitchTab }: Props = {}) { const navigate = useNavigate() const [stats, setStats] = useState(null) + const [ckbStats, setCkbStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [testPhone, setTestPhone] = useState('13800000000') const [testWechat, setTestWechat] = useState('') const [ckbTests, setCkbTests] = useState([ - { - endpoint: '/api/ckb/join', label: '场景获客 — 加入(partner)', - description: '用测试手机号添加到存客宝「创业合伙」计划', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/ckb/join', label: '场景获客 — 加入(investor)', - description: '用测试手机号添加到存客宝「资源对接」计划', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/ckb/join', label: '场景获客 — 加入(mentor)', - description: '用测试手机号添加到存客宝「导师顾问」计划', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/ckb/join', label: '场景获客 — 加入(team)', - description: '用测试手机号添加到存客宝「团队招募」计划', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/ckb/match', label: '匹配上报', - description: '上报匹配行为到存客宝', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/miniprogram/ckb/lead', label: '链接卡若', - description: '首页「链接卡若」留资到存客宝', - method: 'POST', status: 'idle', - }, - { - endpoint: '/api/match/config', label: '匹配配置', - description: '获取匹配类型、价格等配置', - method: 'GET', status: 'idle', - }, + { endpoint: '/api/ckb/join', label: '场景获客 — 找伙伴', description: '添加到存客宝「找伙伴」计划', method: 'POST', status: 'idle' }, + { endpoint: '/api/ckb/join', label: '场景获客 — 资源对接', description: '添加到存客宝「资源对接」计划', method: 'POST', status: 'idle' }, + { endpoint: '/api/ckb/join', label: '场景获客 — 导师顾问', description: '添加到存客宝「导师顾问」计划', method: 'POST', status: 'idle' }, + { endpoint: '/api/ckb/join', label: '场景获客 — 团队招募', description: '添加到存客宝「团队招募」计划', method: 'POST', status: 'idle' }, + { endpoint: '/api/ckb/match', label: '匹配上报', description: '上报匹配行为', method: 'POST', status: 'idle' }, + { endpoint: '/api/miniprogram/ckb/lead', label: '链接卡若', description: '首页留资到存客宝', method: 'POST', status: 'idle' }, + { endpoint: '/api/match/config', label: '匹配配置', description: '获取匹配类型和价格', method: 'GET', status: 'idle' }, ]) const loadStats = useCallback(async () => { setIsLoading(true) try { - const data = await get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true') - if (data?.success && data.data) { - let result = data.data - // 后端 GORM bug 导致 uniqueUsers=0 时,前端自行计算 + const [statsRes, ckbRes] = await Promise.allSettled([ + get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true'), + get<{ success?: boolean; data?: CKBPlanStats }>('/api/db/ckb-plan-stats'), + ]) + + if (statsRes.status === 'fulfilled' && statsRes.value?.success && statsRes.value.data) { + let result = statsRes.value.data if (result.totalMatches > 0 && (!result.uniqueUsers || result.uniqueUsers === 0)) { try { - const allRec = await get<{ success?: boolean; records?: { userId: string }[]; total?: number }>( - '/api/db/match-records?page=1&pageSize=200' - ) + const allRec = await get<{ success?: boolean; records?: { userId: string }[]; total?: number }>('/api/db/match-records?page=1&pageSize=200') if (allRec?.success && allRec.records) { const userSet = new Set(allRec.records.map(r => r.userId).filter(Boolean)) - result = { ...result, uniqueUsers: userSet.size, totalMatches: allRec.total ?? result.totalMatches } + result = { ...result, uniqueUsers: userSet.size } } - } catch { /* ignore fallback error */ } + } catch { /* fallback */ } } setStats(result) - } else { - const fallback = await get<{ success?: boolean; records?: { userId: string; matchType: string; createdAt: string }[]; total?: number }>( - '/api/db/match-records?page=1&pageSize=200' - ) - if (fallback?.success) { - const records = fallback.records || [] - const userSet = new Set(records.map(r => r.userId).filter(Boolean)) - const today = new Date().toISOString().slice(0, 10) - const todayCount = records.filter(r => r.createdAt?.startsWith(today)).length - const typeMap: Record = {} - records.forEach(r => { if (r.matchType) typeMap[r.matchType] = (typeMap[r.matchType] || 0) + 1 }) - setStats({ - totalMatches: fallback.total ?? records.length, - todayMatches: todayCount, - byType: Object.entries(typeMap).map(([matchType, count]) => ({ matchType, count })), - uniqueUsers: userSet.size, - }) - } + } + + if (ckbRes.status === 'fulfilled' && ckbRes.value?.success && ckbRes.value.data) { + setCkbStats(ckbRes.value.data) } } catch (e) { console.error('加载统计失败:', e) } finally { setIsLoading(false) } @@ -131,263 +88,233 @@ export function CKBStatsTab({ onSwitchTab }: CKBStatsTabProps = {}) { useEffect(() => { loadStats() }, [loadStats]) - const getTestBody = (index: number) => { - const phone = testPhone.trim() - const wechat = testWechat.trim() - const typeMap = ['partner', 'investor', 'mentor', 'team'] - if (index <= 3) { - return { - type: typeMap[index], - phone: phone || undefined, - wechat: wechat || undefined, - userId: 'admin_test', - name: '后台测试', - canHelp: index === 1 ? '测试-我能帮到你' : '', - needHelp: index === 1 ? '测试-我需要什么帮助' : '', - } - } - if (index === 4) { - return { - matchType: 'partner', phone: phone || undefined, wechat: wechat || undefined, - userId: 'admin_test', nickname: '后台测试', - matchedUser: { id: 'test_matched', nickname: '测试匹配用户', matchScore: 88 }, - } - } - if (index === 5) { - return { phone: phone || undefined, wechatId: wechat || undefined, userId: 'admin_test', name: '后台测试' } - } + const typeMap = ['partner', 'investor', 'mentor', 'team'] + const getTestBody = (idx: number) => { + const phone = testPhone.trim(); const wechat = testWechat.trim() + if (idx <= 3) return { type: typeMap[idx], phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', name: '后台测试' } + if (idx === 4) return { matchType: 'partner', phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', nickname: '后台测试', matchedUser: { id: 'test', nickname: '测试', matchScore: 88 } } + if (idx === 5) return { phone: phone || undefined, wechatId: wechat || undefined, userId: 'admin_test', name: '后台测试' } return {} } - const testEndpoint = async (index: number) => { - const test = ckbTests[index] - if (test.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { - alert('请至少填写测试手机号或微信号') - return - } - const updated = [...ckbTests] - updated[index] = { ...test, status: 'testing', message: undefined, responseTime: undefined, ckbResponse: undefined } - setCkbTests(updated) - + const testEndpoint = async (idx: number) => { + const test = ckbTests[idx] + if (test.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return } + const updated = [...ckbTests]; updated[idx] = { ...test, status: 'testing', message: undefined, responseTime: undefined }; setCkbTests(updated) const start = performance.now() try { - let res: { success?: boolean; message?: string; code?: number; data?: unknown } - if (test.method === 'GET') { - res = await get(test.endpoint) - } else { - res = await post(test.endpoint, getTestBody(index)) - } + const res = test.method === 'GET' + ? await get<{ success?: boolean; message?: string }>(test.endpoint) + : await post<{ success?: boolean; message?: string }>(test.endpoint, getTestBody(idx)) const elapsed = Math.round(performance.now() - start) - const next = [...ckbTests] const msg = res?.message || '' - const isExist = msg.includes('已存在') || msg.includes('已加入') - const ok = res?.success === true || res?.code === 200 || isExist - next[index] = { - ...test, - status: ok ? 'success' : 'error', - message: msg || (ok ? '接口正常' : '返回异常'), - responseTime: elapsed, - ckbResponse: res?.data ? JSON.stringify(res.data).slice(0, 100) : undefined, - } - setCkbTests(next) + const ok = res?.success === true || msg.includes('已存在') || msg.includes('已加入') + const next = [...ckbTests]; next[idx] = { ...test, status: ok ? 'success' : 'error', message: msg || (ok ? '正常' : '异常'), responseTime: elapsed }; setCkbTests(next) } catch (e: unknown) { const elapsed = Math.round(performance.now() - start) - const next = [...ckbTests] - next[index] = { - ...test, status: 'error', - message: e instanceof Error ? e.message : '请求失败', - responseTime: elapsed, - } - setCkbTests(next) + const next = [...ckbTests]; next[idx] = { ...test, status: 'error', message: e instanceof Error ? e.message : '请求失败', responseTime: elapsed }; setCkbTests(next) } } const testAll = async () => { - if (!testPhone.trim() && !testWechat.trim()) { - alert('请至少填写测试手机号或微信号') - return - } - for (let i = 0; i < ckbTests.length; i++) { - await testEndpoint(i) - } + if (!testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return } + for (let i = 0; i < ckbTests.length; i++) await testEndpoint(i) } - const v = (n: number | undefined) => isLoading ? '-' : (n ?? 0) + const v = (n: number | undefined) => isLoading ? '—' : String(n ?? 0) return ( -
- {/* 核心数据 — 可点击跳转到对应页面 */} -
- onSwitchTab?.('partner')}> - -
-
-

总匹配次数

{v(stats?.totalMatches)}

-
-

点击查看匹配记录 →

-
-
- onSwitchTab?.('partner')}> - -
-
-

今日匹配

{v(stats?.todayMatches)}

-
-

点击查看匹配记录 →

-
-
- navigate('/users')}> - -
-
-

匹配用户数

{v(stats?.uniqueUsers)}

-
-

点击查看用户管理 →

-
-
- - -
-
-

人均匹配

{isLoading ? '-' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}

-
-
-
- - -
-
-

匹配收益

¥{v(stats?.matchRevenue)}

-
-
-
- - -
-
-

付费匹配

{v(stats?.paidMatchCount)}

-
-
-
+
+ + {/* ===== 区块一:找伙伴核心数据 ===== */} +
+

+ 找伙伴数据 +

+
+ onSwitchTab?.('partner')}> + +

总匹配次数

+

{v(stats?.totalMatches)}

+

查看匹配记录

+
+
+ + onSwitchTab?.('partner')}> + +

今日匹配

+

{v(stats?.todayMatches)}

+

今日实时

+
+
+ + navigate('/users')}> + +

匹配用户数

+

{v(stats?.uniqueUsers)}

+

查看用户管理

+
+
+ + + +

人均匹配

+

{isLoading ? '—' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}

+
+
+ + + +

匹配收益

+

¥{v(stats?.matchRevenue)}

+
+
+ + + +

付费匹配次数

+

{v(stats?.paidMatchCount)}

+
+
+
- {/* 按类型分布 */} - - - 各类型匹配分布 - - - - {stats?.byType && stats.byType.length > 0 ? ( -
- {stats.byType.map(item => { - const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0 - return ( -
-
- {typeIcons[item.matchType] || '📊'} - {typeLabels[item.matchType] || item.matchType} -
-

{item.count}

-
-
-
-
-

{pct.toFixed(1)}% 占比

-
+ {/* 类型分布 */} + {stats?.byType && stats.byType.length > 0 && ( +
+

各类型匹配分布

+
+ {stats.byType.map(item => { + const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0 + return ( +
+
+ {typeIcons[item.matchType] || '📊'} + {typeLabels[item.matchType] || item.matchType}
- ) - })} -
- ) : ( -

{isLoading ? '加载中...' : '暂无匹配数据'}

- )} - - - - {/* CKB 接口联通测试 */} - - -
-
- - - 存客宝接口联通测试 - -

- 点击测试会用下方手机号/微信号真实添加到存客宝对应计划中 -

-
- -
-
-
- -
- - setTestPhone(e.target.value)} - /> -
-
-
- 💬 -
- - setTestWechat(e.target.value)} - /> -
-
-
-
- -
- {ckbTests.map((test, idx) => ( -
-
- {test.status === 'idle' &&
} - {test.status === 'testing' && } - {test.status === 'success' && } - {test.status === 'error' && } -
-

{test.label}

-

{test.description}

+

{item.count}

+
+
+

{pct.toFixed(1)}%

-
- {test.message && ( - - {test.message} - - )} - {test.responseTime !== undefined && ( - {test.responseTime}ms - )} - + ) + })} +
+
+ )} + + {/* ===== 区块二:存客宝数据 ===== */} +
+

+ 存客宝获客数据 +

+ +
+ + +

CKB 已提交线索

+

{isLoading ? '—' : (ckbStats?.ckbTotal ?? 0)}

+
+
+ + +

有联系方式

+

{isLoading ? '—' : (ckbStats?.withContact ?? 0)}

+
+
+ + +

联系方式比例

+

+ {isLoading ? '—' : (ckbStats?.ckbTotal ? ((ckbStats.withContact / ckbStats.ckbTotal) * 100).toFixed(0) + '%' : '0%')} +

+
+
+ + +

API 连接

+

{ckbStats?.ckbApiUrl || 'ckbapi.quwanzhi.com'}

+

Key: {ckbStats?.ckbApiKey || '...'}

+
+
+
+ + {/* CKB 各类型提交统计 */} + {ckbStats?.byType && ckbStats.byType.length > 0 && ( +
+ {ckbStats.byType.map(item => ( +
+ {typeIcons[item.matchType] || '📋'} +
+

{typeLabels[item.matchType] || item.matchType}

+

{item.total}

))}
- - + )} +
+ + {/* ===== 区块三:接口联通测试 ===== */} +
+
+

+ 接口联通测试 +

+
+ + +
+
+ + {/* 测试手机号输入 */} +
+
+ +
+ + setTestPhone(e.target.value)} placeholder="手机号" /> +
+
+
+ 💬 +
+ + setTestWechat(e.target.value)} placeholder="微信号" /> +
+
+
+ + {/* 测试结果列表 */} +
+ {ckbTests.map((test, idx) => ( +
+
+ {test.status === 'idle' &&
} + {test.status === 'testing' && } + {test.status === 'success' && } + {test.status === 'error' && } +
+

{test.label}

+

{test.description}

+
+
+
+ {test.message && {test.message}} + {test.responseTime !== undefined && {test.responseTime}ms} + +
+
+ ))} +
+
- {/* 存客宝协作需求已移至开发文档/10、项目管理/存客宝协作需求.md */}
) }