Files
stock/frontend/src/components/EndpointTestDialog.tsx
T
2026-07-01 22:07:55 +08:00

264 lines
11 KiB
TypeScript

import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react'
import { api, type EndpointItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EXPERT_RANK, tierRank } from '@/lib/capability-labels'
interface EpResult {
ok: boolean
median_ms?: number | null
min_ms?: number | null
max_ms?: number | null
rounds?: number
success?: number
error?: string
}
export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) {
const qc = useQueryClient()
const [results, setResults] = useState<Record<string, EpResult | null>>({})
const [testing, setTesting] = useState<Record<string, boolean>>({})
const [switching, setSwitching] = useState<string | null>(null)
// 动态加载端点清单 —— 前端无法跨域直连数据源官网,走后端代理
const { data, isLoading } = useQuery({
queryKey: QK.endpoints,
queryFn: api.listEndpoints,
staleTime: 5 * 60 * 1000,
})
const endpoints = data?.endpoints ?? []
const isFallback = data?.source === 'fallback'
const testRounds = data?.testRounds
async function testOne(url: string) {
setTesting(prev => ({ ...prev, [url]: true }))
setResults(prev => ({ ...prev, [url]: null }))
try {
const res = await api.testEndpoint(url, testRounds)
setResults(prev => ({ ...prev, [url]: res }))
} catch (e: any) {
setResults(prev => ({ ...prev, [url]: { ok: false, error: e?.message ?? '请求失败' } }))
} finally {
setTesting(prev => ({ ...prev, [url]: false }))
}
}
async function testAll() {
setResults({})
await Promise.all(endpoints.map(ep => testOne(ep.url)))
}
const anyTesting = Object.values(testing).some(Boolean)
const isFree = !hasKey
// 专线端点需 Expert 及以上套餐;Free 模式必然不可用
const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK
const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint
async function applyEndpoint(url: string) {
setSwitching(url)
try {
await api.switchEndpoint(url)
await qc.invalidateQueries({ queryKey: QK.settings })
onClose()
} catch {
// 错误由 query 处理
} finally {
setSwitching(null)
}
}
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[480px] max-h-[90vh] flex flex-col rounded-card border border-border bg-base shadow-2xl overflow-hidden"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground">端点测速</span>
</div>
<div className="flex items-center gap-3">
{isFree && (
<span className="text-[10px] px-2 py-0.5 rounded bg-warning/10 text-warning/80">Free 模式</span>
)}
<button
onClick={testAll}
disabled={isFree || anyTesting || endpoints.length === 0}
title={isFree ? 'Free 模式不可使用付费端点,请先配置 API Key' : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
{anyTesting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
全部测速
</button>
<button onClick={onClose} className="p-1 rounded text-secondary hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 当前使用 */}
<div className="mx-4 mt-3 px-3 py-2 rounded-btn bg-accent/8 border border-accent/20 flex items-center gap-2 shrink-0">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]" />
<span className="text-[11px] text-secondary">当前使用</span>
<span className="text-[11px] font-medium text-foreground">{currentLabel}</span>
<span className="text-[10px] text-muted font-mono ml-auto">{currentEndpoint.replace('https://', '')}</span>
</div>
{/* Free 模式提示 —— 以下均为 Starter+ 付费端点 */}
{isFree && (
<div className="mx-4 mt-2 px-3 py-1.5 rounded-btn bg-warning/8 border border-warning/20 shrink-0">
<span className="text-[10px] text-warning/80 leading-snug">
以下均为 Starter+ 付费端点,Free 模式不可使用。配置 API Key 后可自动切换并测速选优。
</span>
</div>
)}
{/* 端点列表 —— 可滚动区,顶栏/当前使用/底栏始终可见 */}
<div className="p-4 space-y-2 overflow-y-auto min-h-0">
{isLoading ? (
<div className="py-10 flex items-center justify-center gap-2 text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs">加载端点列表…</span>
</div>
) : endpoints.length === 0 ? (
<div className="py-10 text-center text-xs text-muted">未能加载端点列表</div>
) : (
endpoints.map(ep => (
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} canUsePremium={canUsePremium} switching={switching} onApply={applyEndpoint} />
))
)}
</div>
{isFallback ? (
<span className="text-[10px] text-warning/70">远程获取失败,显示内置列表</span>
) : null}
</motion.div>
</div>
</AnimatePresence>
)
}
function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: {
ep: EndpointItem
result: EpResult | null
testing?: boolean
isCurrent?: boolean
isFree?: boolean
canUsePremium?: boolean
switching: string | null
onApply: (url: string) => void
}) {
const isError = result && !result.ok
const canApply = result?.ok && !testing && !isCurrent
const isPremium = ep.premium === true
const median = result?.median_ms
return (
<div
className={`flex items-start gap-3 px-3 py-2.5 rounded-btn border transition-colors ${
isError
? 'border-danger/30 bg-danger/5'
: result?.ok
? 'border-bear/20 bg-bear/5'
: isCurrent
? 'border-accent/20 bg-accent/5'
: 'border-border bg-surface'
}`}
>
<div className="flex-1 min-w-0">
{/* 第1行:label + 徽章(左) / 中位延迟(右) */}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-xs font-medium text-foreground">{ep.label}</span>
{isPremium && (
<span className="inline-flex items-center gap-0.5 text-[9px] px-1.5 py-px rounded-sm bg-warning/15 text-warning font-medium" title={ep.description ?? '需专线加速权限'}>
<Crown className="h-2.5 w-2.5" />
专线
</span>
)}
{isCurrent && (
<span className="text-[9px] px-1.5 py-px rounded-sm bg-accent/15 text-accent font-medium">使用中</span>
)}
</div>
{/* 中位延迟 —— 测试中/前/后都占位,避免高度跳动 */}
<span className="ml-auto shrink-0 text-sm font-mono font-medium tabular-nums leading-none">
{isFree ? (
// Free 模式:普通付费端点需 Starter+,premium 端点需 Expert+
<span
className={`text-[10px] px-1.5 py-0.5 rounded-sm font-medium font-sans ${
isPremium
? 'bg-warning/15 text-warning'
: 'bg-muted/10 text-muted/70'
}`}
title={isPremium ? '需 Expert 及以上套餐' : '需 Starter+ 套餐'}
>
{isPremium ? 'Expert+' : 'Starter+'}
</span>
) : testing ? (
<span className="text-[11px] text-muted animate-pulse">测试中…</span>
) : result && result.ok && median != null ? (
<span className={median < 500 ? 'text-bear' : median < 1000 ? 'text-warning' : 'text-danger'}>
{median} ms
</span>
) : result && !result.ok ? (
<span className="text-[11px] text-danger">{result.error ?? '不可达'}</span>
) : (
<span className="text-[11px] text-muted/40"></span>
)}
</span>
</div>
{/* 第2行:description */}
<span className="block text-[10px] text-muted/70 leading-snug mt-0.5 break-words">{ep.description}</span>
{/* 第3行:URL(左) / min~max·成功率(右) —— 副信息始终占位,行数不变 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/50 font-mono truncate min-w-0" title={ep.url}>{ep.url.replace('https://', '')}</span>
<span className="ml-auto shrink-0 text-[9px] text-muted/50 font-mono whitespace-nowrap">
{result && result.ok && result.min_ms != null
? `${result.min_ms}~${result.max_ms} · ${result.success}/${result.rounds}`
: '\u00A0'}
</span>
</div>
</div>
{/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */}
{isFree ? null : (isPremium && !canUsePremium) ? (
// 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用
<span
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-warning/10 text-warning/70 cursor-not-allowed select-none mt-0.5"
title="需要 Expert 及以上套餐的专线加速权限"
>
<Crown className="h-3 w-3" />
Expert+
</span>
) : canApply ? (
<button
onClick={() => onApply(ep.url)}
disabled={switching !== null}
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors mt-0.5"
>
{switching === ep.url ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
应用
</button>
) : null}
</div>
)
}