Files
soul-yongping/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx

394 lines
18 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.

import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
RefreshCw, Users, UserCheck, TrendingUp, Zap, Link2,
CheckCircle2, XCircle, DollarSign, Activity, Smartphone,
} from 'lucide-react'
import { get, post } from '@/api/client'
interface MatchStats {
totalMatches: number
todayMatches: number
byType: { matchType: string; count: number }[]
uniqueUsers: number
matchRevenue?: number
paidMatchCount?: number
}
interface CKBTestResult {
endpoint: string
label: string
description: string
method: 'GET' | 'POST'
status: 'idle' | 'testing' | 'success' | 'error'
message?: string
responseTime?: number
ckbResponse?: string
}
const typeLabels: Record<string, string> = {
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
const typeIcons: Record<string, string> = {
partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮',
}
interface CKBStatsTabProps {
onSwitchTab?: (tabId: string) => void
}
export function CKBStatsTab({ onSwitchTab }: CKBStatsTabProps = {}) {
const navigate = useNavigate()
const [stats, setStats] = useState<MatchStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [testPhone, setTestPhone] = useState('13800000000')
const [testWechat, setTestWechat] = useState('')
const [ckbTests, setCkbTests] = useState<CKBTestResult[]>([
{
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',
},
])
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 时,前端自行计算
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'
)
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 }
}
} catch { /* ignore fallback error */ }
}
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<string, number> = {}
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,
})
}
}
} catch (e) { console.error('加载统计失败:', e) }
finally { setIsLoading(false) }
}, [])
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: '后台测试' }
}
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 start = performance.now()
try {
let res: { success?: boolean; message?: string; code?: number; data?: unknown }
if (test.method === 'GET') {
res = await get<typeof res>(test.endpoint)
} else {
res = await post<typeof res>(test.endpoint, getTestBody(index))
}
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)
} 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 testAll = async () => {
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)
return (
<div className="space-y-6">
{/* 核心数据 — 可点击跳转到对应页面 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Card className="bg-[#0f2137] border-gray-700/50 cursor-pointer hover:border-[#38bdac]/50 transition-colors" onClick={() => onSwitchTab?.('partner')}>
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[#38bdac]/20 flex items-center justify-center"><Users className="w-5 h-5 text-[#38bdac]" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">{v(stats?.totalMatches)}</p></div>
</div>
<p className="text-[#38bdac] text-[10px] mt-2"> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 cursor-pointer hover:border-yellow-500/50 transition-colors" onClick={() => onSwitchTab?.('partner')}>
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center"><Zap className="w-5 h-5 text-yellow-400" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">{v(stats?.todayMatches)}</p></div>
</div>
<p className="text-yellow-400/60 text-[10px] mt-2"> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 cursor-pointer hover:border-blue-500/50 transition-colors" onClick={() => navigate('/users')}>
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center"><UserCheck className="w-5 h-5 text-blue-400" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">{v(stats?.uniqueUsers)}</p></div>
</div>
<p className="text-blue-400/60 text-[10px] mt-2"> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center"><TrendingUp className="w-5 h-5 text-green-400" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">{isLoading ? '-' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}</p></div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center"><DollarSign className="w-5 h-5 text-purple-400" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">¥{v(stats?.matchRevenue)}</p></div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center"><Activity className="w-5 h-5 text-orange-400" /></div>
<div><p className="text-gray-400 text-xs"></p><p className="text-xl font-bold text-white">{v(stats?.paidMatchCount)}</p></div>
</div>
</CardContent>
</Card>
</div>
{/* 按类型分布 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-white text-lg"></CardTitle>
<Button onClick={loadStats} disabled={isLoading} variant="outline" size="sm" className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent>
{stats?.byType && stats.byType.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.byType.map(item => {
const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0
return (
<div key={item.matchType} className="bg-[#0a1628] rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{typeIcons[item.matchType] || '📊'}</span>
<span className="text-gray-300 text-sm font-medium">{typeLabels[item.matchType] || item.matchType}</span>
</div>
<p className="text-2xl font-bold text-white">{item.count}</p>
<div className="mt-2">
<div className="w-full h-2 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-[#38bdac] rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<p className="text-gray-500 text-xs mt-1">{pct.toFixed(1)}% </p>
</div>
</div>
)
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">{isLoading ? '加载中...' : '暂无匹配数据'}</p>
)}
</CardContent>
</Card>
{/* CKB 接口联通测试 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<p className="text-gray-400 text-sm mt-1">
/
</p>
</div>
<Button onClick={testAll} className="bg-[#38bdac] hover:bg-[#2da396] text-white shrink-0">
<Zap className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 mt-4 p-4 bg-[#0a1628] rounded-lg border border-gray-700/50">
<div className="flex items-center gap-2 flex-1">
<Smartphone className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0f2137] border-gray-700 text-white h-9"
placeholder="填写真实手机号添加到存客宝"
value={testPhone}
onChange={e => setTestPhone(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2 flex-1">
<span className="text-gray-400 shrink-0 text-sm">💬</span>
<div className="flex-1 space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0f2137] border-gray-700 text-white h-9"
placeholder="填写微信号(可选)"
value={testWechat}
onChange={e => setTestWechat(e.target.value)}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{ckbTests.map((test, idx) => (
<div key={`${test.endpoint}-${idx}`} className="flex items-center justify-between bg-[#0a1628] rounded-lg px-4 py-3 gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
{test.status === 'idle' && <div className="w-3 h-3 rounded-full bg-gray-500 shrink-0" />}
{test.status === 'testing' && <RefreshCw className="w-4 h-4 text-yellow-400 animate-spin shrink-0" />}
{test.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-400 shrink-0" />}
{test.status === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
<div className="min-w-0">
<p className="text-white text-sm font-medium">{test.label}</p>
<p className="text-gray-500 text-xs truncate">{test.description}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{test.message && (
<span className={`text-xs max-w-[200px] truncate ${test.status === 'success' ? 'text-green-400' : test.status === 'error' ? 'text-red-400' : 'text-gray-400'}`}>
{test.message}
</span>
)}
{test.responseTime !== undefined && (
<Badge className="bg-gray-700 text-gray-300 border-0 text-xs">{test.responseTime}ms</Badge>
)}
<Button size="sm" variant="outline"
onClick={() => testEndpoint(idx)}
disabled={test.status === 'testing'}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent text-xs h-8 px-3">
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 存客宝协作需求已移至开发文档/10、项目管理/存客宝协作需求.md */}
</div>
)
}