sync: soul-admin 页面 | 原因: 前端页面修改
This commit is contained in:
@@ -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<string, string> = {
|
||||
partner: '超级个体', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
|
||||
}
|
||||
const typeIcons: Record<string, string> = {
|
||||
partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮',
|
||||
}
|
||||
const typeLabels: Record<string, string> = { partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募' }
|
||||
const typeIcons: Record<string, string> = { 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<MatchStats | null>(null)
|
||||
const [ckbStats, setCkbStats] = useState<CKBPlanStats | 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',
|
||||
},
|
||||
{ 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<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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<typeof res>(test.endpoint)
|
||||
} else {
|
||||
res = await post<typeof res>(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 (
|
||||
<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 className="space-y-8">
|
||||
|
||||
{/* ===== 区块一:找伙伴核心数据 ===== */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" /> 找伙伴数据
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-[#38bdac]/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">总匹配次数</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.totalMatches)}</p>
|
||||
<p className="text-[#38bdac] text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> 查看匹配记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-yellow-500/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">今日匹配</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.todayMatches)}</p>
|
||||
<p className="text-yellow-400/60 text-xs mt-3 flex items-center gap-1"><Zap className="w-3 h-3" /> 今日实时</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-blue-500/60 transition-all" onClick={() => navigate('/users')}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">匹配用户数</p>
|
||||
<p className="text-4xl font-bold text-white">{v(stats?.uniqueUsers)}</p>
|
||||
<p className="text-blue-400/60 text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> 查看用户管理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">人均匹配</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">匹配收益</p>
|
||||
<p className="text-3xl font-bold text-white">¥{v(stats?.matchRevenue)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">付费匹配次数</p>
|
||||
<p className="text-3xl font-bold text-white">{v(stats?.paidMatchCount)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
{/* 类型分布 */}
|
||||
{stats?.byType && stats.byType.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">各类型匹配分布</h3>
|
||||
<div className="grid grid-cols-2 lg: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-[#0f2137] border border-gray-700/40 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">{typeIcons[item.matchType] || '📊'}</span>
|
||||
<span className="text-gray-300 font-medium">{typeLabels[item.matchType] || item.matchType}</span>
|
||||
</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>
|
||||
<p className="text-3xl font-bold text-white mb-2">{item.count}</p>
|
||||
<div className="w-full h-2 bg-gray-700/50 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.5">{pct.toFixed(1)}%</p>
|
||||
</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>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-orange-400" /> 存客宝获客数据
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-6">
|
||||
<Card className="bg-[#0f2137] border-orange-500/20">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">CKB 已提交线索</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.ckbTotal ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-orange-500/20">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">有联系方式</p>
|
||||
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.withContact ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-orange-500/20">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">联系方式比例</p>
|
||||
<p className="text-3xl font-bold text-white">
|
||||
{isLoading ? '—' : (ckbStats?.ckbTotal ? ((ckbStats.withContact / ckbStats.ckbTotal) * 100).toFixed(0) + '%' : '0%')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-orange-500/20">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-gray-400 text-sm mb-2">API 连接</p>
|
||||
<p className="text-sm font-mono text-gray-400 mt-1 truncate">{ckbStats?.ckbApiUrl || 'ckbapi.quwanzhi.com'}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Key: {ckbStats?.ckbApiKey || '...'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CKB 各类型提交统计 */}
|
||||
{ckbStats?.byType && ckbStats.byType.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{ckbStats.byType.map(item => (
|
||||
<div key={item.matchType} className="bg-[#0a1628] border border-gray-700/30 rounded-lg p-4 flex items-center gap-3">
|
||||
<span className="text-xl">{typeIcons[item.matchType] || '📋'}</span>
|
||||
<div>
|
||||
<p className="text-gray-400 text-xs">{typeLabels[item.matchType] || item.matchType}</p>
|
||||
<p className="text-xl font-bold text-white">{item.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 区块三:接口联通测试 ===== */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-green-400" /> 接口联通测试
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<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-3.5 h-3.5 mr-1.5 ${isLoading ? 'animate-spin' : ''}`} /> 刷新数据
|
||||
</Button>
|
||||
<Button onClick={testAll} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Zap className="w-3.5 h-3.5 mr-1.5" /> 全部测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试手机号输入 */}
|
||||
<div className="flex gap-4 mb-4 p-4 bg-[#0a1628] rounded-xl border border-gray-700/40">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Smartphone className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-gray-500 text-xs">测试手机号(真实添加到存客宝)</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 mt-1" value={testPhone} onChange={e => setTestPhone(e.target.value)} placeholder="手机号" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-gray-500 text-sm shrink-0">💬</span>
|
||||
<div className="flex-1">
|
||||
<Label className="text-gray-500 text-xs">微信号(可选)</Label>
|
||||
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 mt-1" value={testWechat} onChange={e => setTestWechat(e.target.value)} placeholder="微信号" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试结果列表 */}
|
||||
<div className="space-y-2">
|
||||
{ckbTests.map((test, idx) => (
|
||||
<div key={`${test.endpoint}-${idx}`} className="flex items-center justify-between bg-[#0a1628] border border-gray-700/30 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{test.status === 'idle' && <div className="w-2.5 h-2.5 rounded-full bg-gray-600 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">{test.label}</p>
|
||||
<p className="text-gray-600 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-[180px] truncate ${test.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>{test.message}</span>}
|
||||
{test.responseTime !== undefined && <Badge className="bg-gray-800 text-gray-400 border-0 text-[10px]">{test.responseTime}ms</Badge>}
|
||||
<Button size="sm" variant="outline" onClick={() => testEndpoint(idx)} disabled={test.status === 'testing'}
|
||||
className="border-gray-700 text-gray-400 hover:bg-gray-700/50 bg-transparent text-xs h-7 px-2.5">测试</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存客宝协作需求已移至开发文档/10、项目管理/存客宝协作需求.md */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user