671 lines
23 KiB
Plaintext
671 lines
23 KiB
Plaintext
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import ToastBubble from './ToastBubble'
|
||
|
||
interface NetworkDiagnoseModalProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
}
|
||
|
||
interface TestResult {
|
||
type: 'download' | 'upload' | 'storage' | 'preview-image' | 'preview-video' | 'preview-audio' | 'dns' | 'connection'
|
||
status: 'testing' | 'success' | 'error' | 'warning'
|
||
speed?: number
|
||
message?: string
|
||
details?: {
|
||
dnsTime?: number
|
||
connectTime?: number
|
||
ttfb?: number // Time To First Byte
|
||
downloadTime?: number
|
||
totalTime?: number
|
||
fileSize?: number
|
||
url?: string
|
||
}
|
||
}
|
||
|
||
export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnoseModalProps) {
|
||
const [testing, setTesting] = useState(false)
|
||
const [results, setResults] = useState<TestResult[]>([])
|
||
const [copied, setCopied] = useState(false)
|
||
const { toast, showToast } = useToast()
|
||
|
||
// 格式化速度
|
||
const formatSpeed = (bytesPerSecond: number) => {
|
||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(2)} B/s`
|
||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`
|
||
return `${(bytesPerSecond / 1024 / 1024).toFixed(2)} MB/s`
|
||
}
|
||
|
||
// 下载测速
|
||
const testDownloadSpeed = async (): Promise<TestResult> => {
|
||
try {
|
||
const testFileSize = 1024 * 1024 // 1MB
|
||
const startTime = Date.now()
|
||
|
||
const response = await fetch('/api/network/test-download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ size: testFileSize })
|
||
})
|
||
|
||
if (!response.ok) throw new Error('下载测试失败')
|
||
|
||
const blob = await response.blob()
|
||
const endTime = Date.now()
|
||
const duration = (endTime - startTime) / 1000 // 秒
|
||
const speed = blob.size / duration // 字节/秒
|
||
|
||
return {
|
||
type: 'download',
|
||
status: 'success',
|
||
speed,
|
||
message: `下载速度: ${formatSpeed(speed)}`
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'download',
|
||
status: 'error',
|
||
message: '下载测试失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 上传测速
|
||
const testUploadSpeed = async (): Promise<TestResult> => {
|
||
try {
|
||
const testData = new Blob([new ArrayBuffer(1024 * 1024)]) // 1MB
|
||
const formData = new FormData()
|
||
formData.append('file', testData, 'test.bin')
|
||
|
||
const startTime = Date.now()
|
||
|
||
const response = await fetch('/api/network/test-upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
if (!response.ok) throw new Error('上传测试失败')
|
||
|
||
const endTime = Date.now()
|
||
const duration = (endTime - startTime) / 1000 // 秒
|
||
const speed = testData.size / duration // 字节/秒
|
||
|
||
return {
|
||
type: 'upload',
|
||
status: 'success',
|
||
speed,
|
||
message: `上传速度: ${formatSpeed(speed)}`
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'upload',
|
||
status: 'error',
|
||
message: '上传测试失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 存储连通性测试
|
||
const testStorageConnectivity = async (): Promise<TestResult> => {
|
||
try {
|
||
const response = await fetch('/api/network/test-storage')
|
||
|
||
if (!response.ok) throw new Error('存储连通性测试失败')
|
||
|
||
const data = await response.json()
|
||
|
||
return {
|
||
type: 'storage',
|
||
status: 'success',
|
||
message: data.message || '存储服务连接正常'
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'storage',
|
||
status: 'error',
|
||
message: '存储服务连接失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 预览图片加载测试
|
||
const testPreviewImageLoad = async (): Promise<TestResult> => {
|
||
try {
|
||
// 获取一个真实的预览图片URL
|
||
const response = await fetch('/api/materials?pageSize=1&materialType=3D场景')
|
||
if (!response.ok) throw new Error('获取素材失败')
|
||
|
||
const data = await response.json()
|
||
const materials = data.materials || data
|
||
if (!materials || materials.length === 0) {
|
||
return {
|
||
type: 'preview-image',
|
||
status: 'warning',
|
||
message: '没有可测试的预览图片'
|
||
}
|
||
}
|
||
|
||
const imageUrl = materials[0].previewUrl
|
||
if (!imageUrl) {
|
||
return {
|
||
type: 'preview-image',
|
||
status: 'warning',
|
||
message: '素材没有预览图'
|
||
}
|
||
}
|
||
|
||
// 使用Performance API测量加载时间
|
||
const startTime = performance.now()
|
||
const img = new Image()
|
||
|
||
await new Promise((resolve, reject) => {
|
||
img.onload = resolve
|
||
img.onerror = reject
|
||
img.src = imageUrl
|
||
})
|
||
|
||
const endTime = performance.now()
|
||
const loadTime = endTime - startTime
|
||
|
||
// 估算文件大小(通过fetch获取)
|
||
const sizeResponse = await fetch(imageUrl, { method: 'HEAD' })
|
||
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
|
||
const speed = fileSize > 0 ? (fileSize / (loadTime / 1000)) : 0
|
||
|
||
return {
|
||
type: 'preview-image',
|
||
status: loadTime > 3000 ? 'warning' : 'success',
|
||
speed,
|
||
message: `加载时间: ${(loadTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(${formatSpeed(speed)})` : ''}`,
|
||
details: {
|
||
totalTime: loadTime,
|
||
fileSize,
|
||
url: imageUrl
|
||
}
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'preview-image',
|
||
status: 'error',
|
||
message: `预览图片加载失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||
}
|
||
}
|
||
}
|
||
|
||
// 预览视频加载测试
|
||
const testPreviewVideoLoad = async (): Promise<TestResult> => {
|
||
try {
|
||
// 获取一个真实的视频素材
|
||
const response = await fetch('/api/materials?pageSize=1&materialType=主图视频')
|
||
if (!response.ok) throw new Error('获取视频素材失败')
|
||
|
||
const data = await response.json()
|
||
const materials = data.materials || data
|
||
if (!materials || materials.length === 0) {
|
||
return {
|
||
type: 'preview-video',
|
||
status: 'warning',
|
||
message: '没有可测试的视频素材'
|
||
}
|
||
}
|
||
|
||
const videoUrl = materials[0].videoPreviewClipUrl || materials[0].videoPreview540Url || materials[0].videoSourceUrl
|
||
if (!videoUrl) {
|
||
return {
|
||
type: 'preview-video',
|
||
status: 'warning',
|
||
message: '视频素材没有预览URL'
|
||
}
|
||
}
|
||
|
||
const startTime = performance.now()
|
||
|
||
// 测试视频元数据加载(首字节时间)
|
||
const video = document.createElement('video')
|
||
video.preload = 'metadata'
|
||
|
||
await new Promise((resolve, reject) => {
|
||
video.onloadedmetadata = resolve
|
||
video.onerror = reject
|
||
video.src = videoUrl
|
||
})
|
||
|
||
const metadataTime = performance.now() - startTime
|
||
|
||
// 获取文件大小
|
||
const sizeResponse = await fetch(videoUrl, { method: 'HEAD' })
|
||
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
|
||
|
||
return {
|
||
type: 'preview-video',
|
||
status: metadataTime > 5000 ? 'warning' : 'success',
|
||
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
|
||
details: {
|
||
ttfb: metadataTime,
|
||
totalTime: metadataTime,
|
||
fileSize,
|
||
url: videoUrl
|
||
}
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'preview-video',
|
||
status: 'error',
|
||
message: `视频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||
}
|
||
}
|
||
}
|
||
|
||
// 音频文件加载测试
|
||
const testPreviewAudioLoad = async (): Promise<TestResult> => {
|
||
try {
|
||
// 获取一个真实的音频素材
|
||
const response = await fetch('/api/materials?pageSize=1&materialType=音乐')
|
||
if (!response.ok) throw new Error('获取音频素材失败')
|
||
|
||
const data = await response.json()
|
||
const materials = data.materials || data
|
||
if (!materials || materials.length === 0) {
|
||
return {
|
||
type: 'preview-audio',
|
||
status: 'warning',
|
||
message: '没有可测试的音频素材'
|
||
}
|
||
}
|
||
|
||
const audioUrl = materials[0].zipFileName
|
||
if (!audioUrl) {
|
||
return {
|
||
type: 'preview-audio',
|
||
status: 'warning',
|
||
message: '音频素材没有URL'
|
||
}
|
||
}
|
||
|
||
const startTime = performance.now()
|
||
|
||
// 测试音频元数据加载
|
||
const audio = new Audio()
|
||
audio.preload = 'metadata'
|
||
|
||
await new Promise((resolve, reject) => {
|
||
audio.onloadedmetadata = resolve
|
||
audio.onerror = reject
|
||
audio.src = audioUrl
|
||
})
|
||
|
||
const metadataTime = performance.now() - startTime
|
||
|
||
// 获取文件大小
|
||
const sizeResponse = await fetch(audioUrl, { method: 'HEAD' })
|
||
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
|
||
|
||
return {
|
||
type: 'preview-audio',
|
||
status: metadataTime > 3000 ? 'warning' : 'success',
|
||
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
|
||
details: {
|
||
ttfb: metadataTime,
|
||
totalTime: metadataTime,
|
||
fileSize,
|
||
url: audioUrl
|
||
}
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'preview-audio',
|
||
status: 'error',
|
||
message: `音频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||
}
|
||
}
|
||
}
|
||
|
||
// DNS和连接测试
|
||
const testDNSAndConnection = async (): Promise<TestResult> => {
|
||
try {
|
||
const testUrl = `${window.location.origin}/api/network-status`
|
||
|
||
// 使用Resource Timing API获取详细的网络时序
|
||
const startMark = `dns-test-${Date.now()}`
|
||
performance.mark(startMark)
|
||
|
||
const startTime = performance.now()
|
||
const response = await fetch(testUrl, { cache: 'no-store' })
|
||
const endTime = performance.now()
|
||
|
||
if (!response.ok) throw new Error('连接测试失败')
|
||
|
||
// 尝试获取Resource Timing信息
|
||
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
|
||
const entry = entries.find(e => e.name.includes('/api/network-status'))
|
||
|
||
let dnsTime = 0
|
||
let connectTime = 0
|
||
let ttfb = 0
|
||
|
||
if (entry) {
|
||
dnsTime = entry.domainLookupEnd - entry.domainLookupStart
|
||
connectTime = entry.connectEnd - entry.connectStart
|
||
ttfb = entry.responseStart - entry.requestStart
|
||
}
|
||
|
||
const totalTime = endTime - startTime
|
||
|
||
return {
|
||
type: 'connection',
|
||
status: totalTime > 1000 ? 'warning' : 'success',
|
||
message: `总耗时: ${totalTime.toFixed(0)}ms`,
|
||
details: {
|
||
dnsTime: dnsTime > 0 ? dnsTime : undefined,
|
||
connectTime: connectTime > 0 ? connectTime : undefined,
|
||
ttfb: ttfb > 0 ? ttfb : undefined,
|
||
totalTime
|
||
}
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
type: 'connection',
|
||
status: 'error',
|
||
message: `连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||
}
|
||
}
|
||
}
|
||
|
||
// 开始诊断
|
||
const startDiagnose = async () => {
|
||
setTesting(true)
|
||
setResults([])
|
||
|
||
// 1. DNS和连接测试
|
||
setResults([{ type: 'connection', status: 'testing' }])
|
||
const connectionResult = await testDNSAndConnection()
|
||
setResults([connectionResult])
|
||
|
||
// 2. 下载测速
|
||
setResults(prev => [...prev, { type: 'download', status: 'testing' }])
|
||
const downloadResult = await testDownloadSpeed()
|
||
setResults(prev => [...prev.slice(0, -1), downloadResult])
|
||
|
||
// 3. 上传测速
|
||
setResults(prev => [...prev, { type: 'upload', status: 'testing' }])
|
||
const uploadResult = await testUploadSpeed()
|
||
setResults(prev => [...prev.slice(0, -1), uploadResult])
|
||
|
||
// 4. 存储连通性
|
||
setResults(prev => [...prev, { type: 'storage', status: 'testing' }])
|
||
const storageResult = await testStorageConnectivity()
|
||
setResults(prev => [...prev.slice(0, -1), storageResult])
|
||
|
||
// 5. 预览图片加载测试
|
||
setResults(prev => [...prev, { type: 'preview-image', status: 'testing' }])
|
||
const imageResult = await testPreviewImageLoad()
|
||
setResults(prev => [...prev.slice(0, -1), imageResult])
|
||
|
||
// 6. 预览视频加载测试
|
||
setResults(prev => [...prev, { type: 'preview-video', status: 'testing' }])
|
||
const videoResult = await testPreviewVideoLoad()
|
||
setResults(prev => [...prev.slice(0, -1), videoResult])
|
||
|
||
// 7. 音频文件加载测试
|
||
setResults(prev => [...prev, { type: 'preview-audio', status: 'testing' }])
|
||
const audioResult = await testPreviewAudioLoad()
|
||
setResults(prev => [...prev.slice(0, -1), audioResult])
|
||
|
||
setTesting(false)
|
||
}
|
||
|
||
// 复制结果(包含完整的诊断信息,用于AI分析)
|
||
const copyResults = async () => {
|
||
if (results.length === 0) return
|
||
|
||
// 生成详细的诊断报告
|
||
const timestamp = new Date().toLocaleString('zh-CN')
|
||
const report = [
|
||
'=== 网络诊断报告 ===',
|
||
`时间: ${timestamp}`,
|
||
`浏览器: ${navigator.userAgent}`,
|
||
`当前URL: ${window.location.href}`,
|
||
'',
|
||
'=== 测试结果 ===',
|
||
...results.map(r => {
|
||
const typeMap: Record<string, string> = {
|
||
'download': '下载测速',
|
||
'upload': '上传测速',
|
||
'storage': '存储连通性',
|
||
'preview-image': '预览图片加载',
|
||
'preview-video': '预览视频加载',
|
||
'preview-audio': '音频文件加载',
|
||
'connection': 'DNS和连接测试'
|
||
}
|
||
const typeText = typeMap[r.type] || r.type
|
||
|
||
const statusText = r.status === 'success' ? '✓ 成功' : r.status === 'error' ? '✗ 失败' : r.status === 'warning' ? '⚠ 警告' : '⋯ 测试中'
|
||
|
||
let detailsText = ''
|
||
if (r.details) {
|
||
const details = []
|
||
if (r.details.dnsTime) details.push(`DNS解析: ${r.details.dnsTime.toFixed(0)}ms`)
|
||
if (r.details.connectTime) details.push(`TCP连接: ${r.details.connectTime.toFixed(0)}ms`)
|
||
if (r.details.ttfb) details.push(`首字节时间: ${r.details.ttfb.toFixed(0)}ms`)
|
||
if (r.details.totalTime) details.push(`总耗时: ${r.details.totalTime.toFixed(0)}ms`)
|
||
if (r.details.fileSize) details.push(`文件大小: ${(r.details.fileSize / 1024 / 1024).toFixed(2)}MB`)
|
||
if (r.details.url) details.push(`URL: ${r.details.url}`)
|
||
if (details.length > 0) {
|
||
detailsText = '\n 详细信息: ' + details.join(', ')
|
||
}
|
||
}
|
||
|
||
return `${typeText}: ${statusText}\n ${r.message || '测试中...'}${detailsText}`
|
||
}),
|
||
'',
|
||
'=== 网络环境信息 ===',
|
||
`在线状态: ${navigator.onLine ? '在线' : '离线'}`,
|
||
`连接类型: ${(navigator as any).connection?.effectiveType || '未知'}`,
|
||
`下行速度: ${(navigator as any).connection?.downlink || '未知'} Mbps`,
|
||
`RTT延迟: ${(navigator as any).connection?.rtt || '未知'} ms`,
|
||
'',
|
||
'=== 性能分析 ===',
|
||
...results.filter(r => r.status === 'warning' || r.status === 'error').map(r => {
|
||
const typeMap: Record<string, string> = {
|
||
'preview-image': '预览图片',
|
||
'preview-video': '预览视频',
|
||
'preview-audio': '音频文件',
|
||
'connection': '网络连接'
|
||
}
|
||
const typeText = typeMap[r.type] || r.type
|
||
return `⚠ ${typeText}加载较慢或失败,可能影响用户体验`
|
||
}),
|
||
'',
|
||
'提示: 请将此报告提供给技术支持以便快速定位问题'
|
||
].join('\n')
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(report)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
} catch (error) {
|
||
console.error('复制失败,尝试降级方案:', error)
|
||
// 降级方案:使用传统方法复制
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = report
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.left = '-999999px'
|
||
textarea.style.top = '-999999px'
|
||
document.body.appendChild(textarea)
|
||
textarea.focus()
|
||
textarea.select()
|
||
try {
|
||
const successful = document.execCommand('copy')
|
||
if (successful) {
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}
|
||
} catch (err) {
|
||
console.error('降级复制也失败:', err)
|
||
showToast('复制失败,请手动复制结果', { variant: 'error' })
|
||
}
|
||
document.body.removeChild(textarea)
|
||
}
|
||
}
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'testing':
|
||
return (
|
||
<svg className="w-5 h-5 text-blue-500 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
)
|
||
case 'success':
|
||
return (
|
||
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
)
|
||
case 'warning':
|
||
return (
|
||
<svg className="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
)
|
||
case 'error':
|
||
return (
|
||
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
const getTypeText = (type: string) => {
|
||
switch (type) {
|
||
case 'download':
|
||
return '下载测速'
|
||
case 'upload':
|
||
return '上传测速'
|
||
case 'storage':
|
||
return '存储连通性'
|
||
case 'preview-image':
|
||
return '预览图片加载'
|
||
case 'preview-video':
|
||
return '预览视频加载'
|
||
case 'preview-audio':
|
||
return '音频文件加载'
|
||
case 'connection':
|
||
return 'DNS和连接'
|
||
default:
|
||
return ''
|
||
}
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl max-w-md w-full shadow-2xl">
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">网络诊断</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||
测试网络连接速度和存储服务状态
|
||
</p>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6 space-y-4 max-h-[60vh] overflow-y-auto">
|
||
{results.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<svg className="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<p className="text-gray-500 dark:text-gray-400 mb-2">
|
||
点击下方按钮开始诊断
|
||
</p>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||
将测试网络速度、预览文件加载等性能指标
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{results.map((result, index) => (
|
||
<div
|
||
key={index}
|
||
className={`flex items-start gap-3 p-4 rounded-xl transition-all ${
|
||
result.status === 'warning'
|
||
? 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
|
||
: result.status === 'error'
|
||
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||
: 'bg-gray-50 dark:bg-gray-700'
|
||
}`}
|
||
>
|
||
<div className="flex-shrink-0 mt-0.5">
|
||
{getStatusIcon(result.status)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{getTypeText(result.type)}
|
||
</p>
|
||
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1">
|
||
{result.message || '测试中...'}
|
||
</p>
|
||
{result.details && (
|
||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
|
||
{result.details.dnsTime !== undefined && (
|
||
<div>DNS解析: {result.details.dnsTime.toFixed(0)}ms</div>
|
||
)}
|
||
{result.details.connectTime !== undefined && (
|
||
<div>TCP连接: {result.details.connectTime.toFixed(0)}ms</div>
|
||
)}
|
||
{result.details.ttfb !== undefined && (
|
||
<div>首字节时间: {result.details.ttfb.toFixed(0)}ms</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||
<button
|
||
onClick={startDiagnose}
|
||
disabled={testing}
|
||
className="w-full btn-primary disabled:opacity-50"
|
||
>
|
||
{testing ? '诊断中...' : results.length > 0 ? '重新诊断' : '开始诊断'}
|
||
</button>
|
||
|
||
{results.length > 0 && !testing && (
|
||
<button
|
||
onClick={copyResults}
|
||
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
{copied ? '已复制' : '复制结果'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统一的Toast提示 */}
|
||
{toast && toast.show && (
|
||
<ToastBubble message={toast.message} variant={toast.variant} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|