394 lines
18 KiB
TypeScript
394 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|