gongxiangp2/components/NetworkDiagnoseModal.tsx.backup

671 lines
23 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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