sync: soul-admin 页面 | 原因: 前端页面修改

This commit is contained in:
卡若
2026-03-08 16:08:17 +08:00
parent 5e7e9772fb
commit 2ee214b0dc

View File

@@ -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>
)
}