gongxiangp2/components/NetworkDiagnoseModal.tsx
2026-05-07 16:37:22 +08:00

2876 lines
101 KiB
TypeScript
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, useRef, useEffect } from 'react'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
interface NetworkDiagnoseModalProps {
isOpen: boolean
onClose: () => void
}
interface TestResult {
type:
| 'network-env' // 网络环境检测
| 'connection' // DNS和连接测试
| 'download' // 下载测速
| 'upload' // 上传测速
| 'storage' // 存储连通性
| 'preview-image' // 预览图片加载
| 'preview-video' // 预览视频加载
| 'preview-audio' // 音频文件加载
| 'proxy-performance' // 代理性能测试
| 'video-range' // 视频Range请求测试
| 'intranet-minio' // 内网MinIO直连测试
| 'public-s3' // 公网S3直连测试
| 'concurrent' // 并发测试
| 'cache' // 缓存测试
| 's3-performance' // S3存储性能分析
| 'api-materials-list' // API: 素材列表
| 'api-material-detail' // API: 素材详情
| 'api-material-stats' // API: 素材统计
| 'api-upload-init' // API: 上传初始化
| 'api-download' // API: 下载接口
| 'api-user-profile' // API: 用户信息
| 'api-system-settings' // API: 系统设置
| 'api-health' // API: 健康检查
| 'database-query' // 数据库查询性能
| 'redis-connection' // Redis连接测试
| 'page-home-load' // 页面: 首页加载
| 'page-detail-load' // 页面: 详情页加载
| 'upload-flow' // 上传流程测试
| 'download-flow' // 下载流程测试
| 'transcode-service' // 转码服务测试
| 'auth-session' // 认证Session测试
| 'intranet-url-rewrite' // 内网用户URL重写测试
| 'public-url-rewrite' // 外网用户URL重写测试
status: 'testing' | 'success' | 'error' | 'warning'
speed?: number
message?: string
details?: {
dnsTime?: number
connectTime?: number
ttfb?: number
downloadTime?: number
totalTime?: number
fileSize?: number
url?: string
[key: string]: any // 允许其他详细信息
}
}
const PUBLIC_S3_DOMAINS = ['s3.jiansun.vip', 's3.1080p.hk']
function isPublicS3Url(url: string): boolean {
return PUBLIC_S3_DOMAINS.some((domain) => url.includes(domain))
}
export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnoseModalProps) {
const [testing, setTesting] = useState(false)
const [results, setResults] = useState<TestResult[]>([])
const [copied, setCopied] = useState(false)
const [networkInfo, setNetworkInfo] = useState<any>(null)
const { toast, showToast } = useToast()
// 跟踪需要清理的资源
const createdBlobs = useRef<Blob[]>([])
const createdUrls = useRef<string[]>([])
const createdUploadIds = useRef<string[]>([])
// ✅ 组件卸载时自动清理资源
useEffect(() => {
return () => {
cleanupDiagnosticResources()
}
}, [])
// 格式化速度(字节/秒 -> 用户友好单位)
// 使用字节单位(B/s、KB/s、MB/s),与其他地方保持一致
const formatSpeed = (bytesPerSecond: number) => {
if (bytesPerSecond < 1024) {
return `${bytesPerSecond.toFixed(0)} B/s`
}
if (bytesPerSecond < 1024 * 1024) {
return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`
}
return `${(bytesPerSecond / 1024 / 1024).toFixed(2)} MB/s`
}
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
// 清理诊断过程中创建的资源
const cleanupDiagnosticResources = async () => {
try {
// 1. 清理Blob对象(释放内存)
createdBlobs.current.forEach(blob => {
if (blob && typeof blob === 'object' && 'close' in blob) {
// 如果是可关闭的Blob(如File),尝试关闭
try {
(blob as any).close?.()
} catch (e) {
// 忽略错误
}
}
})
createdBlobs.current = []
// 2. 清理URL对象(revokeObjectURL)
createdUrls.current.forEach(url => {
try {
URL.revokeObjectURL(url)
} catch (e) {
// 忽略错误
}
})
createdUrls.current = []
// 3. 清理服务器端的uploadId(删除临时文件)
if (createdUploadIds.current.length > 0) {
// 批量清理服务器端的临时文件
const cleanupPromises = createdUploadIds.current.map(uploadId =>
fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId })
}).catch(err => {
console.warn(`清理uploadId ${uploadId}失败:`, err)
return null
})
)
await Promise.all(cleanupPromises)
console.log(`已清理 ${createdUploadIds.current.length} 个上传会话的临时文件`)
createdUploadIds.current = []
}
} catch (error) {
console.warn('清理诊断资源时出错:', error)
}
}
// 1. 网络环境检测
const testNetworkEnvironment = async (): Promise<TestResult> => {
try {
const response = await fetch('/api/network-status', { cache: 'no-store' })
if (!response.ok) throw new Error('网络环境检测失败')
const data = await response.json()
setNetworkInfo(data)
// 获取浏览器网络信息
const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection
const networkType = connection?.effectiveType || 'unknown'
const downlink = connection?.downlink || 'unknown'
const rtt = connection?.rtt || 'unknown'
const saveData = connection?.saveData || false
const message = [
`网络类型: ${data.isIntranet ? '内网' : '外网'}`,
`访问域名: ${data.hostname}`,
`客户端IP: ${data.clientIp}`,
`浏览器网络: ${networkType}`,
`下行速度: ${downlink !== 'unknown' ? `${(downlink / 8).toFixed(2)} MB/s` : '未知'}`,
`RTT延迟: ${rtt !== 'unknown' ? `${rtt} ms` : '未知'}`,
saveData ? '省流模式: 开启' : ''
].filter(Boolean).join(', ')
return {
type: 'network-env',
status: 'success',
message,
details: {
isIntranet: data.isIntranet,
isPublicDomain: data.isPublicDomain,
clientIp: data.clientIp,
hostname: data.hostname,
networkType,
downlink,
rtt,
saveData
}
}
} catch (error) {
return {
type: 'network-env',
status: 'error',
message: `网络环境检测失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 2. DNS和连接测试(增强版)
const testDNSAndConnection = async (): Promise<TestResult> => {
try {
const testUrl = `${window.location.origin}/api/network-status`
const startTime = performance.now()
const response = await fetch(testUrl, { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('连接测试失败')
// 使用Resource Timing API获取详细的网络时序
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
let sslTime = 0
let requestTime = 0
let responseTime = 0
if (entry) {
dnsTime = entry.domainLookupEnd - entry.domainLookupStart
connectTime = entry.connectEnd - entry.connectStart
sslTime = entry.secureConnectionStart > 0 ? entry.connectEnd - entry.secureConnectionStart : 0
ttfb = entry.responseStart - entry.requestStart
requestTime = entry.responseStart - entry.requestStart
responseTime = entry.responseEnd - entry.responseStart
}
const totalTime = endTime - startTime
const details: any = {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
sslTime: sslTime > 0 ? sslTime : undefined,
ttfb: ttfb > 0 ? ttfb : undefined,
requestTime: requestTime > 0 ? requestTime : undefined,
responseTime: responseTime > 0 ? responseTime : undefined,
totalTime
}
const issues: string[] = []
if (dnsTime > 100) issues.push(`DNS解析较慢: ${dnsTime.toFixed(0)}ms`)
if (connectTime > 200) issues.push(`TCP连接较慢: ${connectTime.toFixed(0)}ms`)
if (ttfb > 500) issues.push(`首字节时间较长: ${ttfb.toFixed(0)}ms`)
const message = issues.length > 0
? `总耗时: ${totalTime.toFixed(0)}ms (${issues.join(', ')})`
: `总耗时: ${totalTime.toFixed(0)}ms`
return {
type: 'connection',
status: totalTime > 1000 ? 'warning' : 'success',
message,
details
}
} catch (error) {
return {
type: 'connection',
status: 'error',
message: `连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 3. 下载测速(增强版:测试不同大小)
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()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const endTime = Date.now()
const duration = (endTime - startTime) / 1000
const speed = blob.size / duration
// 获取Resource Timing信息
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes('/api/network/test-download'))
let ttfb = 0
if (entry) {
ttfb = entry.responseStart - entry.requestStart
}
return {
type: 'download',
status: speed < 1024 * 1024 ? 'warning' : 'success', // < 1MB/s 警告
speed,
message: `下载速度: ${formatSpeed(speed)}${ttfb > 0 ? `, TTFB: ${ttfb.toFixed(0)}ms` : ''}`,
details: {
ttfb: ttfb > 0 ? ttfb : undefined,
totalTime: duration * 1000,
fileSize: blob.size
}
}
} catch (error) {
return {
type: 'download',
status: 'error',
message: '下载测试失败'
}
}
}
// 4. 上传测速
const testUploadSpeed = async (): Promise<TestResult> => {
try {
const testData = new Blob([new ArrayBuffer(1024 * 1024)]) // 1MB
createdBlobs.current.push(testData) // 跟踪Blob以便清理
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: speed < 512 * 1024 ? 'warning' : 'success', // < 512KB/s 警告
speed,
message: `上传速度: ${formatSpeed(speed)}`,
details: {
totalTime: duration * 1000,
fileSize: testData.size
}
}
} catch (error) {
return {
type: 'upload',
status: 'error',
message: '上传测试失败'
}
}
}
// 5. 存储连通性测试
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: '存储服务连接失败'
}
}
}
// 6. 预览图片加载测试(增强版)
const testPreviewImageLoad = async (): Promise<TestResult> => {
try {
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) => {
const timeout = setTimeout(() => reject(new Error('图片加载超时')), 10000)
img.onload = () => {
clearTimeout(timeout)
resolve(undefined)
}
img.onerror = () => {
clearTimeout(timeout)
reject(new Error('图片加载失败'))
}
img.src = imageUrl
})
const endTime = performance.now()
const loadTime = endTime - startTime
// 获取文件大小和详细时序
let fileSize = 0
let ttfb = 0
try {
const sizeResponse = await fetch(imageUrl, { method: 'HEAD' })
fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const imageFileName = imageUrl.split('/').pop() || ''
const entry = entries.find((e: PerformanceResourceTiming) => e.name.includes(imageFileName))
if (entry) {
ttfb = entry.responseStart - entry.requestStart
}
} catch (e) {
// 忽略错误
}
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,
ttfb: ttfb > 0 ? ttfb : undefined,
fileSize,
url: imageUrl
}
}
} catch (error) {
return {
type: 'preview-image',
status: 'error',
message: `预览图片加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 7. 预览视频加载测试(增强版)
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) => {
const timeout = setTimeout(() => reject(new Error('视频加载超时')), 15000)
video.onloadedmetadata = () => {
clearTimeout(timeout)
resolve(undefined)
}
video.onerror = () => {
clearTimeout(timeout)
reject(new Error('视频加载失败'))
}
video.src = videoUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
let fileSize = 0
try {
const sizeResponse = await fetch(videoUrl, { method: 'HEAD' })
fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
} catch (e) {
// 忽略错误
}
return {
type: 'preview-video',
status: metadataTime > 5000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${formatFileSize(fileSize)})` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: videoUrl
}
}
} catch (error) {
return {
type: 'preview-video',
status: 'error',
message: `视频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 8. 音频文件加载测试
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 material = materials[0]
let audioUrl = material.zipFileName || ''
if (!audioUrl && material.zipFilePath) {
const zipFilePath = material.zipFilePath
if (zipFilePath.startsWith('http://') || zipFilePath.startsWith('https://')) {
audioUrl = zipFilePath
} else if (zipFilePath.startsWith('/uploads/') || zipFilePath.startsWith('uploads/')) {
audioUrl = zipFilePath.startsWith('/') ? zipFilePath : `/${zipFilePath}`
} else {
audioUrl = `/uploads/${zipFilePath}`
}
}
if (!audioUrl && material.zipFileName && !material.zipFileName.startsWith('http')) {
audioUrl = `/uploads/${material.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) => {
const timeout = setTimeout(() => reject(new Error('音频加载超时')), 10000)
audio.onloadedmetadata = () => {
clearTimeout(timeout)
resolve(undefined)
}
audio.onerror = () => {
clearTimeout(timeout)
reject(new Error('音频加载失败'))
}
audio.src = audioUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
let fileSize = 0
try {
const sizeResponse = await fetch(audioUrl, { method: 'HEAD' })
fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
} catch (e) {
// 忽略错误
}
return {
type: 'preview-audio',
status: metadataTime > 3000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${formatFileSize(fileSize)})` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: audioUrl
}
}
} catch (error) {
return {
type: 'preview-audio',
status: 'error',
message: `音频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 9. 代理性能测试
const testProxyPerformance = async (): Promise<TestResult> => {
try {
// 获取真实的预览图片URL(通常会走代理),增加pageSize提高找到有效素材的概率
const response = await fetch('/api/materials?pageSize=10')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'proxy-performance',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 'proxy-performance',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
// 检查URL是否为内网MinIO地址(内网用户不应该通过代理访问内网地址)
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
if (isIntranetMinIO) {
return {
type: 'proxy-performance',
status: 'success',
message: '内网用户直接访问内网MinIO,无需代理',
details: { url: testUrl }
}
}
// 如果URL已经是代理URL,直接测试;否则构造代理URL(仅对公网URL)
let proxyUrl = testUrl
if (!testUrl.includes('/api/proxy-media')) {
// 确保URL是公网S3地址,才构造代理URL
const isPublicS3 = isPublicS3Url(testUrl)
if (isPublicS3) {
proxyUrl = `/api/proxy-media?url=${encodeURIComponent(testUrl)}`
} else {
// 如果不是公网S3地址,直接测试原始URL
proxyUrl = testUrl
}
}
const startTime = performance.now()
const testResponse = await fetch(proxyUrl, { cache: 'no-store' })
const endTime = performance.now()
if (!testResponse.ok) throw new Error('代理测试失败')
const blob = await testResponse.blob()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const totalTime = endTime - startTime
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes('/api/proxy-media'))
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 speed = blob.size / (totalTime / 1000)
return {
type: 'proxy-performance',
status: ttfb > 1000 ? 'warning' : 'success',
speed,
message: `代理响应: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
details: {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
ttfb: ttfb > 0 ? ttfb : totalTime,
totalTime,
fileSize: blob.size,
url: proxyUrl
}
}
} catch (error) {
return {
type: 'proxy-performance',
status: 'error',
message: `代理性能测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 10. 视频Range请求测试
const testVideoRangeRequest = 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: 'video-range',
status: 'warning',
message: '没有可测试的视频素材'
}
}
const videoUrl = materials[0].videoPreviewClipUrl || materials[0].videoPreview540Url || materials[0].videoSourceUrl
if (!videoUrl) {
return {
type: 'video-range',
status: 'warning',
message: '视频素材没有预览URL'
}
}
// 测试Range请求(模拟视频拖拽)
const rangeSize = 1024 * 1024 // 1MB
const startTime = performance.now()
const rangeResponse = await fetch(videoUrl, {
headers: {
'Range': `bytes=0-${rangeSize - 1}`
}
})
const endTime = performance.now()
const rangeTime = endTime - startTime
if (!rangeResponse.ok && rangeResponse.status !== 206) {
throw new Error('Range请求不支持')
}
const blob = await rangeResponse.blob()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const speed = blob.size / (rangeTime / 1000)
return {
type: 'video-range',
status: rangeTime > 2000 ? 'warning' : 'success',
speed,
message: `Range请求: ${rangeTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
details: {
totalTime: rangeTime,
fileSize: blob.size,
rangeSize,
url: videoUrl,
supportsRange: rangeResponse.status === 206
}
}
} catch (error) {
return {
type: 'video-range',
status: 'error',
message: `Range请求测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 11. 内网MinIO直连测试
const testIntranetMinIO = async (): Promise<TestResult> => {
try {
// 获取一个素材,检查URL是否会被重写为内网MinIO地址
const response = await fetch('/api/materials?pageSize=1')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'intranet-minio',
status: 'warning',
message: '没有可测试的素材'
}
}
const testUrl = materials[0].previewUrl
if (!testUrl) {
return {
type: 'intranet-minio',
status: 'warning',
message: '素材没有预览URL'
}
}
// 检查URL是否为内网MinIO地址
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
if (!isIntranetMinIO) {
return {
type: 'intranet-minio',
status: 'warning',
message: '当前URL不是内网MinIO地址(可能被重写为公网地址)',
details: { url: testUrl }
}
}
const startTime = performance.now()
const testResponse = await fetch(testUrl, { cache: 'no-store' })
const endTime = performance.now()
if (!testResponse.ok) throw new Error('内网MinIO连接失败')
const blob = await testResponse.blob()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const totalTime = endTime - startTime
const speed = blob.size / (totalTime / 1000)
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes(testUrl.split('/').pop() || ''))
let ttfb = 0
if (entry) {
ttfb = entry.responseStart - entry.requestStart
}
return {
type: 'intranet-minio',
status: ttfb > 500 ? 'warning' : 'success',
speed,
message: `内网MinIO: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
details: {
ttfb: ttfb > 0 ? ttfb : totalTime,
totalTime,
fileSize: blob.size,
url: testUrl
}
}
} catch (error) {
return {
type: 'intranet-minio',
status: 'error',
message: `内网MinIO测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 12. 公网S3直连测试
const testPublicS3 = async (): Promise<TestResult> => {
try {
// 增加pageSize提高找到有效素材的概率
const response = await fetch('/api/materials?pageSize=10')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'public-s3',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 'public-s3',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
// 检查URL是否为公网S3地址
const isPublicS3 = isPublicS3Url(testUrl)
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
// 如果是内网MinIO地址,说明是内网用户,这是正常的(内网用户应该使用内网地址)
if (isIntranetMinIO) {
return {
type: 'public-s3',
status: 'success',
message: '内网用户使用内网MinIO地址(正常,无需测试公网S3)',
details: { url: testUrl }
}
}
if (!isPublicS3 && !testUrl.includes('/api/proxy-media')) {
return {
type: 'public-s3',
status: 'warning',
message: '当前URL不是公网S3地址',
details: { url: testUrl }
}
}
// 如果是代理URL,提取原始URL
let directUrl = testUrl
if (testUrl.includes('/api/proxy-media')) {
const urlParams = new URLSearchParams(testUrl.split('?')[1])
directUrl = urlParams.get('url') || testUrl
}
const startTime = performance.now()
const testResponse = await fetch(directUrl, {
cache: 'no-store',
signal: AbortSignal.timeout(10000)
})
const endTime = performance.now()
if (!testResponse.ok) throw new Error('公网S3连接失败')
const blob = await testResponse.blob()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const totalTime = endTime - startTime
const speed = blob.size / (totalTime / 1000)
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes(directUrl.split('/').pop() || ''))
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
}
return {
type: 'public-s3',
status: ttfb > 2000 ? 'warning' : 'success',
speed,
message: `公网S3: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
details: {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
ttfb: ttfb > 0 ? ttfb : totalTime,
totalTime,
fileSize: blob.size,
url: directUrl
}
}
} catch (error) {
return {
type: 'public-s3',
status: 'error',
message: `公网S3测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 13. 并发测试
const testConcurrent = async (): Promise<TestResult> => {
try {
const response = await fetch('/api/materials?pageSize=5')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'concurrent',
status: 'warning',
message: '没有可测试的素材'
}
}
// 获取5个图片URL
const imageUrls: string[] = materials
.map((m: any) => m.previewUrl)
.filter((url: string | null | undefined): url is string => !!url)
.slice(0, 5)
if (imageUrls.length === 0) {
return {
type: 'concurrent',
status: 'warning',
message: '没有可测试的图片URL'
}
}
const startTime = performance.now()
// 并发加载5个图片
const promises = imageUrls.map((url: string) =>
fetch(url, { cache: 'no-store' }).then(r => r.blob())
)
const results = await Promise.all(promises)
// 跟踪所有Blob以便清理
results.forEach(blob => createdBlobs.current.push(blob))
const endTime = performance.now()
const totalTime = endTime - startTime
const totalSize = results.reduce((sum, blob) => sum + blob.size, 0)
const avgSpeed = totalSize / (totalTime / 1000)
const avgTimePerFile = totalTime / imageUrls.length
return {
type: 'concurrent',
status: avgTimePerFile > 2000 ? 'warning' : 'success',
speed: avgSpeed,
message: `并发${imageUrls.length}个文件: 平均${avgTimePerFile.toFixed(0)}ms/文件, 总速度 ${formatSpeed(avgSpeed)}`,
details: {
totalTime,
totalSize,
fileCount: imageUrls.length,
avgTimePerFile,
urls: imageUrls
}
}
} catch (error) {
return {
type: 'concurrent',
status: 'error',
message: `并发测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 14. 缓存测试
const testCache = async (): Promise<TestResult> => {
try {
// 增加pageSize提高找到有效素材的概率
const response = await fetch('/api/materials?pageSize=10')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'cache',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 'cache',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
// 第一次加载(无缓存,跳过浏览器缓存)
const firstStart = performance.now()
const firstResponse = await fetch(testUrl, { cache: 'no-store' })
const firstBlob = await firstResponse.blob()
createdBlobs.current.push(firstBlob) // 跟踪Blob以便清理
const firstTime = performance.now() - firstStart
// 检查响应头的缓存控制
const cacheControl = firstResponse.headers.get('cache-control') || ''
const etag = firstResponse.headers.get('etag') || ''
const canCache = !cacheControl.includes('no-store') && !cacheControl.includes('no-cache')
const fileSize = firstBlob.size
// 等待更长时间,确保浏览器有足够时间缓存(特别是小文件)
await new Promise(resolve => setTimeout(resolve, 300))
// 第二次加载(尝试使用缓存)
const secondStart = performance.now()
const secondResponse = await fetch(testUrl, {
cache: 'default' // 使用默认缓存策略
})
const secondBlob = await secondResponse.blob()
createdBlobs.current.push(secondBlob) // 跟踪Blob以便清理
const secondTime = performance.now() - secondStart
const cacheSpeedup = firstTime > 0 ? ((firstTime - secondTime) / firstTime) * 100 : 0
// 改进的判断逻辑:
// 1. 对于小文件(<100KB),缓存效果可能不明显(网络延迟占比大),降低判断标准
// 2. 对于大文件,缓存应该明显更快(至少快30%)
// 3. 如果服务器不允许缓存(no-cache/no-store),则认为缓存无效是正常的
const isSmallFile = fileSize < 100 * 1024 // 100KB
const threshold = isSmallFile ? 0.7 : 0.5 // 小文件降低标准到70%
const isCached = canCache && secondTime < firstTime * threshold
// 生成更详细的提示信息
let message = `缓存效果: ${isCached ? '有效' : '无效'}`
if (!canCache) {
message += ' (服务器不允许缓存)'
} else if (isSmallFile) {
message += ` (小文件,缓存效果可能不明显)`
}
message += `, 加速 ${cacheSpeedup.toFixed(1)}% (首次${firstTime.toFixed(0)}ms, 二次${secondTime.toFixed(0)}ms)`
return {
type: 'cache',
status: isCached ? 'success' : (canCache ? 'warning' : 'success'), // 如果服务器不允许缓存,标记为success(这是正常的)
message,
details: {
firstTime,
secondTime,
cacheSpeedup,
isCached,
url: testUrl,
cacheControl,
canCache,
fileSize
}
}
} catch (error) {
return {
type: 'cache',
status: 'error',
message: `缓存测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 15. S3存储性能分析(增强版)
const testS3Performance = async (): Promise<TestResult> => {
try {
// 增加pageSize提高找到有效素材的概率
const response = await fetch('/api/materials?pageSize=10')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 's3-performance',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 's3-performance',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
let fullTestUrl = testUrl
if (!testUrl.startsWith('http://') && !testUrl.startsWith('https://')) {
fullTestUrl = new URL(testUrl, window.location.origin).toString()
}
let urlObj: URL
let s3Domain: string
try {
urlObj = new URL(fullTestUrl)
s3Domain = urlObj.hostname
} catch (e) {
return {
type: 's3-performance',
status: 'error',
message: `S3性能测试失败: 无效的URL格式 - ${testUrl}`
}
}
// 测试1: 检测是否使用frp(仅对公网域名,跳过内网IP以避免CORS错误)
let isFrp = false
const isPublicDomain = isPublicS3Url(s3Domain)
const isInternalIP = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(s3Domain)
// 仅对公网域名进行FRP检测,跳过内网IP和localhost(避免CORS错误)
if (isPublicDomain && !isInternalIP && !['localhost', '127.0.0.1'].includes(s3Domain)) {
try {
const frpTestUrl = `${urlObj.protocol}//${s3Domain}/`
const frpResponse = await fetch(frpTestUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000)
})
const text = await frpResponse.text()
if (text.includes('frp') || text.includes('内网穿透')) {
isFrp = true
}
} catch (e) {
// 忽略错误(CORS等)
}
}
// 测试2: 下载速度和TTFB测试
const startTime = performance.now()
let ttfb = 0
const testResponse = await fetch(fullTestUrl, {
signal: AbortSignal.timeout(30000)
})
ttfb = performance.now() - startTime
const blob = await testResponse.blob()
createdBlobs.current.push(blob) // 跟踪Blob以便清理
const totalTime = performance.now() - startTime
const fileSizeMB = blob.size / (1024 * 1024)
const downloadTime = (totalTime - ttfb) / 1000
const downloadSpeed = downloadTime > 0 ? fileSizeMB / downloadTime : 0
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes(s3Domain))
let dnsTime = 0
let connectTime = 0
if (entry) {
dnsTime = entry.domainLookupEnd - entry.domainLookupStart
connectTime = entry.connectEnd - entry.connectStart
if (ttfb === 0) {
ttfb = entry.responseStart - entry.requestStart
}
}
// 分析结果
const issues: string[] = []
const suggestions: string[] = []
if (isFrp) {
issues.push('⚠ 当前资源仍需经入口层回源,峰值时吞吐会波动')
suggestions.push('• 优先确认展示图是否统一落到 s3.jiansun.vip 的 public-display 前缀')
suggestions.push('• 大源文件、压缩包、视频源文件不要纳入展示缓存')
suggestions.push('• 如问题集中在公网入口,优先排查 180.97.215.57 缓存层与 192.168.58.20:9000 回源链路')
}
if (ttfb > 1000) {
issues.push(`🔴 首字节时间过长: ${Math.round(ttfb)}ms(正常<200ms)`)
if (!isFrp) {
suggestions.push('• TTFB过高可能是服务器响应慢或网络延迟大')
}
}
if (downloadSpeed < 1 && downloadSpeed > 0) {
issues.push(`🔴 下载速度过慢: ${downloadSpeed.toFixed(2)}MB/s(正常>10MB/s)`)
if (!isFrp) {
suggestions.push('• 带宽不足,建议升级服务器带宽或使用CDN')
}
}
if (dnsTime > 100) {
issues.push(`⚠ DNS解析较慢: ${dnsTime.toFixed(0)}ms`)
}
if (connectTime > 200) {
issues.push(`⚠ TCP连接较慢: ${connectTime.toFixed(0)}ms`)
}
const isSuccess = !isFrp && ttfb < 1000 && downloadSpeed > 1
const isWarning = !isSuccess && (ttfb < 3000 || downloadSpeed > 0.1)
let message = ''
if (isSuccess) {
message = `性能良好\n下载速度: ${downloadSpeed.toFixed(2)}MB/s, TTFB: ${Math.round(ttfb)}ms`
} else if (isWarning) {
message = `性能一般\n${issues.slice(0, 2).join('\n')}`
} else {
message = `性能较差\n${issues.slice(0, 2).join('\n')}`
}
return {
type: 's3-performance',
status: isSuccess ? 'success' : isWarning ? 'warning' : 'error',
message,
details: {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
ttfb,
totalTime,
fileSize: blob.size,
downloadSpeed,
url: fullTestUrl,
isFrp,
issues,
suggestions
}
}
} catch (error) {
return {
type: 's3-performance',
status: 'error',
message: `S3性能测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 16. API: 素材列表测试
const testApiMaterialsList = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/materials?pageSize=10', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('素材列表API失败')
const data = await response.json()
const totalTime = endTime - startTime
const materialCount = data.materials?.length || data.length || 0
return {
type: 'api-materials-list',
status: totalTime > 1000 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms, 返回${materialCount}条数据`,
details: {
totalTime,
materialCount,
url: '/api/materials'
}
}
} catch (error) {
return {
type: 'api-materials-list',
status: 'error',
message: `素材列表API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 17. API: 素材详情测试
const testApiMaterialDetail = async (): Promise<TestResult> => {
try {
// 先获取一个素材ID
const listResponse = await fetch('/api/materials?pageSize=1', { cache: 'no-store' })
if (!listResponse.ok) throw new Error('获取素材列表失败')
const listData = await listResponse.json()
const materials = listData.materials || listData
if (!materials || materials.length === 0) {
return {
type: 'api-material-detail',
status: 'warning',
message: '没有可测试的素材'
}
}
const materialId = materials[0].id
const startTime = performance.now()
const response = await fetch(`/api/materials/${materialId}`, { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('素材详情API失败')
const data = await response.json()
const totalTime = endTime - startTime
return {
type: 'api-material-detail',
status: totalTime > 500 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms`,
details: {
totalTime,
materialId,
url: `/api/materials/${materialId}`
}
}
} catch (error) {
return {
type: 'api-material-detail',
status: 'error',
message: `素材详情API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 18. API: 素材统计测试
const testApiMaterialStats = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/materials/stats', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('素材统计API失败')
const data = await response.json()
const totalTime = endTime - startTime
return {
type: 'api-material-stats',
status: totalTime > 1000 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms`,
details: {
totalTime,
stats: data,
url: '/api/materials/stats'
}
}
} catch (error) {
return {
type: 'api-material-stats',
status: 'error',
message: `素材统计API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 19. API: 上传初始化测试
const testApiUploadInit = async (): Promise<TestResult> => {
try {
// 上传初始化API需要JSON格式,不是FormData
const testData = {
fileName: 'test.txt',
fileSize: 4,
totalChunks: 1
}
const startTime = performance.now()
const response = await fetch('/api/materials/upload/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(testData),
cache: 'no-store'
})
const endTime = performance.now()
const totalTime = endTime - startTime
// 如果失败,检查错误原因
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const isUnauthorized = response.status === 401 || response.status === 403
const isBadRequest = response.status === 400
// 根据错误类型返回不同的状态
let status: 'success' | 'warning' | 'error' = 'warning'
let message = `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status}`
if (isUnauthorized) {
status = 'success'
message += ' (需要登录,这是正常的)'
} else if (isBadRequest) {
status = 'warning'
message += ` (${errorData.error || '参数错误'})`
} else {
status = 'error'
message += ` (${errorData.error || '服务器错误'})`
}
return {
type: 'api-upload-init',
status,
message,
details: {
totalTime,
statusCode: response.status,
error: errorData.error,
url: '/api/materials/upload/init'
}
}
}
const data = await response.json()
// 跟踪uploadId以便后续清理
if (data.uploadId) {
createdUploadIds.current.push(data.uploadId)
}
return {
type: 'api-upload-init',
status: totalTime > 1000 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms`,
details: {
totalTime,
uploadId: data.uploadId,
url: '/api/materials/upload/init'
}
}
} catch (error) {
return {
type: 'api-upload-init',
status: 'error',
message: `上传初始化API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 20. API: 下载接口测试
const testApiDownload = async (): Promise<TestResult> => {
try {
// 先获取一个素材ID
const listResponse = await fetch('/api/materials?pageSize=1', { cache: 'no-store' })
if (!listResponse.ok) throw new Error('获取素材列表失败')
const listData = await listResponse.json()
const materials = listData.materials || listData
if (!materials || materials.length === 0) {
return {
type: 'api-download',
status: 'warning',
message: '没有可测试的素材'
}
}
const materialId = materials[0].id
const startTime = performance.now()
const response = await fetch(`/api/materials/download?id=${materialId}`, {
method: 'GET',
cache: 'no-store'
})
const endTime = performance.now()
const totalTime = endTime - startTime
// 即使失败也记录(可能是权限问题)
if (!response.ok) {
return {
type: 'api-download',
status: 'warning',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (可能未登录或无权下载)`,
details: {
totalTime,
statusCode: response.status,
materialId,
url: `/api/materials/download?id=${materialId}`
}
}
}
// 检查是否是重定向或文件流
const contentType = response.headers.get('content-type') || ''
const isFile = contentType.includes('application/zip') || contentType.includes('application/octet-stream')
return {
type: 'api-download',
status: totalTime > 2000 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms${isFile ? ', 返回文件流' : ', 返回数据'}`,
details: {
totalTime,
materialId,
contentType,
url: `/api/materials/download?id=${materialId}`
}
}
} catch (error) {
return {
type: 'api-download',
status: 'error',
message: `下载API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 21. API: 用户信息测试
const testApiUserProfile = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/user/profile', { cache: 'no-store' })
const endTime = performance.now()
const totalTime = endTime - startTime
if (!response.ok) {
return {
type: 'api-user-profile',
status: 'warning',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (可能未登录)`,
details: {
totalTime,
statusCode: response.status,
url: '/api/user/profile'
}
}
}
const data = await response.json()
return {
type: 'api-user-profile',
status: totalTime > 500 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms${data.username ? `, 用户: ${data.username}` : ''}`,
details: {
totalTime,
userId: data.id,
url: '/api/user/profile'
}
}
} catch (error) {
return {
type: 'api-user-profile',
status: 'error',
message: `用户信息API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 22. API: 系统设置测试
const testApiSystemSettings = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/system-settings', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('系统设置API失败')
const data = await response.json()
const totalTime = endTime - startTime
return {
type: 'api-system-settings',
status: totalTime > 500 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms`,
details: {
totalTime,
url: '/api/system-settings'
}
}
} catch (error) {
return {
type: 'api-system-settings',
status: 'error',
message: `系统设置API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 23. API: 健康检查测试
const testApiHealth = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/health', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('健康检查API失败')
const data = await response.json()
const totalTime = endTime - startTime
return {
type: 'api-health',
status: totalTime > 200 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${data.status || 'ok'}`,
details: {
totalTime,
health: data,
url: '/api/health'
}
}
} catch (error) {
return {
type: 'api-health',
status: 'error',
message: `健康检查API测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 24. 数据库查询性能测试
const testDatabaseQuery = async (): Promise<TestResult> => {
try {
// 通过素材列表API间接测试数据库查询性能
const startTime = performance.now()
const response = await fetch('/api/materials?pageSize=1', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('数据库查询测试失败')
const totalTime = endTime - startTime
// 获取Resource Timing
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes('/api/materials'))
let ttfb = 0
if (entry) {
ttfb = entry.responseStart - entry.requestStart
}
return {
type: 'database-query',
status: ttfb > 500 ? 'warning' : 'success',
message: `查询时间: ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms (总耗时: ${totalTime.toFixed(0)}ms)`,
details: {
totalTime,
ttfb: ttfb > 0 ? ttfb : totalTime,
url: '/api/materials'
}
}
} catch (error) {
return {
type: 'database-query',
status: 'error',
message: `数据库查询测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 25. Redis连接测试(通过系统状态API)
const testRedisConnection = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/admin/system-status', { cache: 'no-store' })
const endTime = performance.now()
const totalTime = endTime - startTime
if (!response.ok) {
return {
type: 'redis-connection',
status: 'warning',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (可能未登录或无权访问)`,
details: {
totalTime,
statusCode: response.status,
url: '/api/admin/system-status'
}
}
}
const data = await response.json()
const redisStatus = data.redis || data.redisStatus || 'unknown'
return {
type: 'redis-connection',
status: totalTime > 500 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms, Redis: ${redisStatus === 'connected' || redisStatus === 'ok' ? '已连接' : '状态未知'}`,
details: {
totalTime,
redisStatus,
url: '/api/admin/system-status'
}
}
} catch (error) {
return {
type: 'redis-connection',
status: 'error',
message: `Redis连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 26. 页面: 首页加载测试
const testPageHomeLoad = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/', { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('首页加载失败')
const html = await response.text()
const totalTime = endTime - startTime
const htmlSize = new Blob([html]).size
// 获取Resource Timing
const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]
const entry = entries[0]
let domContentLoaded = 0
let loadComplete = 0
if (entry) {
domContentLoaded = entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart
loadComplete = entry.loadEventEnd - entry.loadEventStart
}
return {
type: 'page-home-load',
status: totalTime > 2000 ? 'warning' : 'success',
message: `加载时间: ${totalTime.toFixed(0)}ms, HTML大小: ${formatFileSize(htmlSize)}`,
details: {
totalTime,
htmlSize,
domContentLoaded: domContentLoaded > 0 ? domContentLoaded : undefined,
loadComplete: loadComplete > 0 ? loadComplete : undefined,
url: '/'
}
}
} catch (error) {
return {
type: 'page-home-load',
status: 'error',
message: `首页加载测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 27. 页面: 详情页加载测试
const testPageDetailLoad = async (): Promise<TestResult> => {
try {
// 先获取一个素材ID
const listResponse = await fetch('/api/materials?pageSize=1', { cache: 'no-store' })
if (!listResponse.ok) throw new Error('获取素材列表失败')
const listData = await listResponse.json()
const materials = listData.materials || listData
if (!materials || materials.length === 0) {
return {
type: 'page-detail-load',
status: 'warning',
message: '没有可测试的素材'
}
}
const materialId = materials[0].id
const startTime = performance.now()
const response = await fetch(`/share/${materialId}`, { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('详情页加载失败')
const html = await response.text()
const totalTime = endTime - startTime
const htmlSize = new Blob([html]).size
return {
type: 'page-detail-load',
status: totalTime > 2000 ? 'warning' : 'success',
message: `加载时间: ${totalTime.toFixed(0)}ms, HTML大小: ${formatFileSize(htmlSize)}`,
details: {
totalTime,
htmlSize,
materialId,
url: `/share/${materialId}`
}
}
} catch (error) {
return {
type: 'page-detail-load',
status: 'error',
message: `详情页加载测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 28. 上传流程测试(完整流程)
const testUploadFlow = async (): Promise<TestResult> => {
try {
// 上传初始化API需要JSON格式
const testData = {
fileName: 'test-upload.txt',
fileSize: 13, // 'test content'的长度
totalChunks: 1
}
const startTime = performance.now()
// 步骤1: 初始化上传
const initResponse = await fetch('/api/materials/upload/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(testData),
cache: 'no-store'
})
const initTime = performance.now() - startTime
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}))
const isUnauthorized = initResponse.status === 401 || initResponse.status === 403
const isBadRequest = initResponse.status === 400
let status: 'success' | 'warning' | 'error' = 'warning'
let message = `初始化失败: ${initTime.toFixed(0)}ms, 状态: ${initResponse.status}`
if (isUnauthorized) {
status = 'success'
message += ' (需要登录,这是正常的)'
} else if (isBadRequest) {
status = 'warning'
message += ` (${errorData.error || '参数错误'})`
} else {
status = 'error'
message += ` (${errorData.error || '服务器错误'})`
}
return {
type: 'upload-flow',
status,
message,
details: {
totalTime: initTime,
step: 'init',
statusCode: initResponse.status,
error: errorData.error,
url: '/api/materials/upload/init'
}
}
}
const initData = await initResponse.json()
const totalTime = performance.now() - startTime
// 跟踪uploadId以便后续清理
if (initData.uploadId) {
createdUploadIds.current.push(initData.uploadId)
}
return {
type: 'upload-flow',
status: totalTime > 2000 ? 'warning' : 'success',
message: `初始化完成: ${initTime.toFixed(0)}ms (总耗时: ${totalTime.toFixed(0)}ms)`,
details: {
totalTime,
initTime,
uploadId: initData.uploadId,
step: 'init',
url: '/api/materials/upload/init'
}
}
} catch (error) {
return {
type: 'upload-flow',
status: 'error',
message: `上传流程测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 29. 下载流程测试
const testDownloadFlow = async (): Promise<TestResult> => {
try {
// 先获取一个素材ID
const listResponse = await fetch('/api/materials?pageSize=1', { cache: 'no-store' })
if (!listResponse.ok) throw new Error('获取素材列表失败')
const listData = await listResponse.json()
const materials = listData.materials || listData
if (!materials || materials.length === 0) {
return {
type: 'download-flow',
status: 'warning',
message: '没有可测试的素材'
}
}
const materialId = materials[0].id
const startTime = performance.now()
// 测试下载接口响应
const response = await fetch(`/api/materials/download?id=${materialId}`, {
method: 'GET',
cache: 'no-store'
})
const totalTime = performance.now() - startTime
if (!response.ok) {
return {
type: 'download-flow',
status: 'warning',
message: `下载接口响应: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (可能未登录或无权下载)`,
details: {
totalTime,
statusCode: response.status,
materialId,
url: `/api/materials/download?id=${materialId}`
}
}
}
const contentType = response.headers.get('content-type') || ''
const isFile = contentType.includes('application/zip') || contentType.includes('application/octet-stream')
return {
type: 'download-flow',
status: totalTime > 2000 ? 'warning' : 'success',
message: `下载接口响应: ${totalTime.toFixed(0)}ms${isFile ? ', 返回文件流' : ', 返回数据'}`,
details: {
totalTime,
contentType,
materialId,
url: `/api/materials/download?id=${materialId}`
}
}
} catch (error) {
return {
type: 'download-flow',
status: 'error',
message: `下载流程测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 30. 转码服务测试
const testTranscodeService = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/admin/video-transcode-stats', { cache: 'no-store' })
const endTime = performance.now()
const totalTime = endTime - startTime
if (!response.ok) {
return {
type: 'transcode-service',
status: 'warning',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (可能未登录或无权访问)`,
details: {
totalTime,
statusCode: response.status,
url: '/api/admin/video-transcode-stats'
}
}
}
const data = await response.json()
const queueSize = data.queueSize || data.pending || 0
const processing = data.processing || 0
return {
type: 'transcode-service',
status: totalTime > 1000 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms, 队列: ${queueSize}, 处理中: ${processing}`,
details: {
totalTime,
queueSize,
processing,
stats: data,
url: '/api/admin/video-transcode-stats'
}
}
} catch (error) {
return {
type: 'transcode-service',
status: 'error',
message: `转码服务测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 31. 认证Session测试
const testAuthSession = async (): Promise<TestResult> => {
try {
const startTime = performance.now()
const response = await fetch('/api/user/profile', { cache: 'no-store' })
const endTime = performance.now()
const totalTime = endTime - startTime
if (response.status === 401 || response.status === 403) {
return {
type: 'auth-session',
status: 'warning',
message: `响应时间: ${totalTime.toFixed(0)}ms, 状态: ${response.status} (未登录或Session过期)`,
details: {
totalTime,
statusCode: response.status,
isAuthenticated: false,
url: '/api/user/profile'
}
}
}
if (!response.ok) throw new Error('认证测试失败')
const data = await response.json()
const isAuthenticated = !!data.id
return {
type: 'auth-session',
status: totalTime > 500 ? 'warning' : 'success',
message: `响应时间: ${totalTime.toFixed(0)}ms, 认证状态: ${isAuthenticated ? '已登录' : '未登录'}`,
details: {
totalTime,
isAuthenticated,
userId: data.id,
url: '/api/user/profile'
}
}
} catch (error) {
return {
type: 'auth-session',
status: 'error',
message: `认证Session测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 开始诊断(完整版)
const startDiagnose = async () => {
setTesting(true)
setResults([])
setNetworkInfo(null)
// 清空资源跟踪数组
createdBlobs.current = []
createdUrls.current = []
createdUploadIds.current = []
try {
// 1. 网络环境检测
setResults([{ type: 'network-env', status: 'testing' }])
const networkEnvResult = await testNetworkEnvironment()
setResults([networkEnvResult])
// 从网络环境检测结果中获取网络信息(用于后续测试判断)
const isIntranet = networkEnvResult.details?.isIntranet ?? false
// 2. DNS和连接测试
setResults(prev => [...prev, { type: 'connection', status: 'testing' }])
const connectionResult = await testDNSAndConnection()
setResults(prev => [...prev.slice(0, -1), connectionResult])
// 3. 下载测速
setResults(prev => [...prev, { type: 'download', status: 'testing' }])
const downloadResult = await testDownloadSpeed()
setResults(prev => [...prev.slice(0, -1), downloadResult])
// 4. 上传测速
setResults(prev => [...prev, { type: 'upload', status: 'testing' }])
const uploadResult = await testUploadSpeed()
setResults(prev => [...prev.slice(0, -1), uploadResult])
// 5. 存储连通性
setResults(prev => [...prev, { type: 'storage', status: 'testing' }])
const storageResult = await testStorageConnectivity()
setResults(prev => [...prev.slice(0, -1), storageResult])
// 6. 预览图片加载测试
setResults(prev => [...prev, { type: 'preview-image', status: 'testing' }])
const imageResult = await testPreviewImageLoad()
setResults(prev => [...prev.slice(0, -1), imageResult])
// 7. 预览视频加载测试
setResults(prev => [...prev, { type: 'preview-video', status: 'testing' }])
const videoResult = await testPreviewVideoLoad()
setResults(prev => [...prev.slice(0, -1), videoResult])
// 8. 音频文件加载测试
setResults(prev => [...prev, { type: 'preview-audio', status: 'testing' }])
const audioResult = await testPreviewAudioLoad()
setResults(prev => [...prev.slice(0, -1), audioResult])
// 9. 代理性能测试
setResults(prev => [...prev, { type: 'proxy-performance', status: 'testing' }])
const proxyResult = await testProxyPerformance()
setResults(prev => [...prev.slice(0, -1), proxyResult])
// 10. 视频Range请求测试
setResults(prev => [...prev, { type: 'video-range', status: 'testing' }])
const rangeResult = await testVideoRangeRequest()
setResults(prev => [...prev.slice(0, -1), rangeResult])
// 11. 内网MinIO直连测试(仅内网用户)
if (isIntranet) {
setResults(prev => [...prev, { type: 'intranet-minio', status: 'testing' }])
const intranetResult = await testIntranetMinIO()
setResults(prev => [...prev.slice(0, -1), intranetResult])
}
// 12. 公网S3直连测试
setResults(prev => [...prev, { type: 'public-s3', status: 'testing' }])
const publicS3Result = await testPublicS3()
setResults(prev => [...prev.slice(0, -1), publicS3Result])
// 13. 并发测试
setResults(prev => [...prev, { type: 'concurrent', status: 'testing' }])
const concurrentResult = await testConcurrent()
setResults(prev => [...prev.slice(0, -1), concurrentResult])
// 14. 缓存测试
setResults(prev => [...prev, { type: 'cache', status: 'testing' }])
const cacheResult = await testCache()
setResults(prev => [...prev.slice(0, -1), cacheResult])
// 15. S3存储性能分析
setResults(prev => [...prev, { type: 's3-performance', status: 'testing' }])
const s3Result = await testS3Performance()
setResults(prev => [...prev.slice(0, -1), s3Result])
// 16. API: 素材列表测试
setResults(prev => [...prev, { type: 'api-materials-list', status: 'testing' }])
const apiMaterialsListResult = await testApiMaterialsList()
setResults(prev => [...prev.slice(0, -1), apiMaterialsListResult])
// 17. API: 素材详情测试
setResults(prev => [...prev, { type: 'api-material-detail', status: 'testing' }])
const apiMaterialDetailResult = await testApiMaterialDetail()
setResults(prev => [...prev.slice(0, -1), apiMaterialDetailResult])
// 18. API: 素材统计测试
setResults(prev => [...prev, { type: 'api-material-stats', status: 'testing' }])
const apiMaterialStatsResult = await testApiMaterialStats()
setResults(prev => [...prev.slice(0, -1), apiMaterialStatsResult])
// 19. API: 上传初始化测试
setResults(prev => [...prev, { type: 'api-upload-init', status: 'testing' }])
const apiUploadInitResult = await testApiUploadInit()
setResults(prev => [...prev.slice(0, -1), apiUploadInitResult])
// 20. API: 下载接口测试
setResults(prev => [...prev, { type: 'api-download', status: 'testing' }])
const apiDownloadResult = await testApiDownload()
setResults(prev => [...prev.slice(0, -1), apiDownloadResult])
// 21. API: 用户信息测试
setResults(prev => [...prev, { type: 'api-user-profile', status: 'testing' }])
const apiUserProfileResult = await testApiUserProfile()
setResults(prev => [...prev.slice(0, -1), apiUserProfileResult])
// 22. API: 系统设置测试
setResults(prev => [...prev, { type: 'api-system-settings', status: 'testing' }])
const apiSystemSettingsResult = await testApiSystemSettings()
setResults(prev => [...prev.slice(0, -1), apiSystemSettingsResult])
// 23. API: 健康检查测试
setResults(prev => [...prev, { type: 'api-health', status: 'testing' }])
const apiHealthResult = await testApiHealth()
setResults(prev => [...prev.slice(0, -1), apiHealthResult])
// 24. 数据库查询性能测试
setResults(prev => [...prev, { type: 'database-query', status: 'testing' }])
const databaseQueryResult = await testDatabaseQuery()
setResults(prev => [...prev.slice(0, -1), databaseQueryResult])
// 25. Redis连接测试
setResults(prev => [...prev, { type: 'redis-connection', status: 'testing' }])
const redisConnectionResult = await testRedisConnection()
setResults(prev => [...prev.slice(0, -1), redisConnectionResult])
// 26. 页面: 首页加载测试
setResults(prev => [...prev, { type: 'page-home-load', status: 'testing' }])
const pageHomeLoadResult = await testPageHomeLoad()
setResults(prev => [...prev.slice(0, -1), pageHomeLoadResult])
// 27. 页面: 详情页加载测试
setResults(prev => [...prev, { type: 'page-detail-load', status: 'testing' }])
const pageDetailLoadResult = await testPageDetailLoad()
setResults(prev => [...prev.slice(0, -1), pageDetailLoadResult])
// 28. 上传流程测试
setResults(prev => [...prev, { type: 'upload-flow', status: 'testing' }])
const uploadFlowResult = await testUploadFlow()
setResults(prev => [...prev.slice(0, -1), uploadFlowResult])
// 29. 下载流程测试
setResults(prev => [...prev, { type: 'download-flow', status: 'testing' }])
const downloadFlowResult = await testDownloadFlow()
setResults(prev => [...prev.slice(0, -1), downloadFlowResult])
// 30. 转码服务测试
setResults(prev => [...prev, { type: 'transcode-service', status: 'testing' }])
const transcodeServiceResult = await testTranscodeService()
setResults(prev => [...prev.slice(0, -1), transcodeServiceResult])
// 31. 认证Session测试
setResults(prev => [...prev, { type: 'auth-session', status: 'testing' }])
const authSessionResult = await testAuthSession()
setResults(prev => [...prev.slice(0, -1), authSessionResult])
// 32. 内外网URL重写测试
if (isIntranet) {
// 内网用户的URL重写测试
setResults(prev => [...prev, { type: 'intranet-url-rewrite', status: 'testing' }])
const intranetUrlRewriteResult = await testIntranetUrlRewrite()
setResults(prev => [...prev.slice(0, -1), intranetUrlRewriteResult])
} else {
// 外网用户的URL重写测试
setResults(prev => [...prev, { type: 'public-url-rewrite', status: 'testing' }])
const publicUrlRewriteResult = await testPublicUrlRewrite()
setResults(prev => [...prev.slice(0, -1), publicUrlRewriteResult])
}
} catch (error) {
console.error('诊断过程出错:', error)
showToast('诊断过程出错', { variant: 'error' })
} finally {
// 清理诊断过程中创建的所有资源
await cleanupDiagnosticResources()
setTesting(false)
}
}
// 32. 内网用户URL重写测试
const testIntranetUrlRewrite = async (): Promise<TestResult> => {
try {
// 增加pageSize提高找到有效素材的概率(内网用户专用测试)
const response = await fetch('/api/materials?pageSize=10', { cache: 'no-store' })
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'intranet-url-rewrite',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 'intranet-url-rewrite',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
// 检查URL是否被正确重写为内网MinIO地址
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
const isProxyUrl = testUrl.includes('/api/proxy-media')
const isPublicS3 = isPublicS3Url(testUrl)
let status: 'success' | 'warning' | 'error' = 'success'
let message = ''
const issues: string[] = []
if (isIntranetMinIO && !isProxyUrl) {
message = 'URL已正确重写为内网MinIO地址(直连,不走代理)'
} else if (isProxyUrl && isPublicS3) {
status = 'warning'
issues.push('内网用户仍通过代理访问公网S3,应直接访问内网MinIO')
message = `URL通过代理访问: ${issues.join(', ')}`
} else if (isPublicS3 && !isProxyUrl) {
status = 'warning'
issues.push('内网用户直接访问公网S3,应重写为内网MinIO')
message = `URL未重写: ${issues.join(', ')}`
} else {
message = `URL格式: ${isProxyUrl ? '代理URL' : isIntranetMinIO ? '内网MinIO' : '其他'}`
}
return {
type: 'intranet-url-rewrite',
status,
message,
details: {
url: testUrl,
isIntranetMinIO,
isProxyUrl,
isPublicS3,
issues: issues.length > 0 ? issues : undefined
}
}
} catch (error) {
return {
type: 'intranet-url-rewrite',
status: 'error',
message: `内网URL重写测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 33. 外网用户URL重写测试
const testPublicUrlRewrite = async (): Promise<TestResult> => {
try {
// 增加pageSize提高找到有效素材的概率(外网用户专用测试)
const response = await fetch('/api/materials?pageSize=10', { cache: 'no-store' })
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'public-url-rewrite',
status: 'warning',
message: '没有可测试的素材'
}
}
// 查找第一个有previewUrl的素材
const materialWithPreview = materials.find((m: any) => m.previewUrl)
if (!materialWithPreview || !materialWithPreview.previewUrl) {
return {
type: 'public-url-rewrite',
status: 'warning',
message: '没有找到有预览URL的素材'
}
}
const testUrl = materialWithPreview.previewUrl
// 检查URL是否正确(外网用户应使用公网S3或代理)
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
const isProxyUrl = testUrl.includes('/api/proxy-media')
const isPublicS3 = isPublicS3Url(testUrl)
let status: 'success' | 'warning' | 'error' = 'success'
let message = ''
const issues: string[] = []
if (isIntranetMinIO) {
status = 'error'
issues.push('外网用户不应访问内网MinIO地址')
message = `URL错误: ${issues.join(', ')}`
} else if (isProxyUrl || isPublicS3) {
message = `URL格式正确: ${isProxyUrl ? '通过代理' : '直接访问公网S3'}`
} else {
status = 'warning'
message = 'URL格式未知'
}
return {
type: 'public-url-rewrite',
status,
message,
details: {
url: testUrl,
isIntranetMinIO,
isProxyUrl,
isPublicS3,
issues: issues.length > 0 ? issues : undefined
}
}
} catch (error) {
return {
type: 'public-url-rewrite',
status: 'error',
message: `外网URL重写测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 复制结果(增强版:包含所有详细信息)
const copyResults = async () => {
if (results.length === 0) return
const timestamp = new Date().toLocaleString('zh-CN')
const browserInfo = navigator.userAgent
const currentUrl = window.location.href
// 获取浏览器详细信息
const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection
const networkType = connection?.effectiveType || 'unknown'
const downlink = connection?.downlink || 'unknown'
const rtt = connection?.rtt || 'unknown'
const saveData = connection?.saveData || false
const report = [
'=== 网络诊断完整报告 ===',
`时间: ${timestamp}`,
`浏览器: ${browserInfo}`,
`当前URL: ${currentUrl}`,
'',
'=== 网络环境信息 ===',
`在线状态: ${navigator.onLine ? '在线' : '离线'}`,
`网络类型: ${networkType}`,
`下行速度: ${downlink !== 'unknown' ? `${(downlink / 8).toFixed(2)} MB/s` : '未知'}`,
`RTT延迟: ${rtt !== 'unknown' ? `${rtt} ms` : '未知'}`,
`省流模式: ${saveData ? '开启' : '关闭'}`,
networkInfo ? [
`内网/外网: ${networkInfo.isIntranet ? '内网' : '外网'}`,
`访问域名: ${networkInfo.hostname}`,
`客户端IP: ${networkInfo.clientIp}`,
`公网域名: ${networkInfo.isPublicDomain ? '是' : '否'}`
].join('\n') : '',
'',
'=== 测试结果 ===',
...results.map(r => {
const typeMap: Record<string, string> = {
'network-env': '网络环境检测',
'connection': 'DNS和连接测试',
'download': '下载测速',
'upload': '上传测速',
'storage': '存储连通性',
'preview-image': '预览图片加载',
'preview-video': '预览视频加载',
'preview-audio': '音频文件加载',
'proxy-performance': '代理性能测试',
'video-range': '视频Range请求测试',
'intranet-minio': '内网MinIO直连测试',
'public-s3': '公网S3直连测试',
'concurrent': '并发测试',
'cache': '缓存测试',
's3-performance': 'S3存储性能分析',
'api-materials-list': 'API: 素材列表',
'api-material-detail': 'API: 素材详情',
'api-material-stats': 'API: 素材统计',
'api-upload-init': 'API: 上传初始化',
'api-download': 'API: 下载接口',
'api-user-profile': 'API: 用户信息',
'api-system-settings': 'API: 系统设置',
'api-health': 'API: 健康检查',
'database-query': '数据库查询性能',
'redis-connection': 'Redis连接测试',
'page-home-load': '页面: 首页加载',
'page-detail-load': '页面: 详情页加载',
'upload-flow': '上传流程测试',
'download-flow': '下载流程测试',
'transcode-service': '转码服务测试',
'auth-session': '认证Session测试',
'intranet-url-rewrite': '内网URL重写测试',
'public-url-rewrite': '外网URL重写测试'
}
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: string[] = []
if (r.details.dnsTime !== undefined) details.push(`DNS解析: ${r.details.dnsTime.toFixed(0)}ms`)
if (r.details.connectTime !== undefined) details.push(`TCP连接: ${r.details.connectTime.toFixed(0)}ms`)
if (r.details.sslTime !== undefined) details.push(`SSL握手: ${r.details.sslTime.toFixed(0)}ms`)
if (r.details.ttfb !== undefined) details.push(`首字节时间: ${r.details.ttfb.toFixed(0)}ms`)
if (r.details.requestTime !== undefined) details.push(`请求时间: ${r.details.requestTime.toFixed(0)}ms`)
if (r.details.responseTime !== undefined) details.push(`响应时间: ${r.details.responseTime.toFixed(0)}ms`)
if (r.details.totalTime !== undefined) details.push(`总耗时: ${r.details.totalTime.toFixed(0)}ms`)
if (r.details.fileSize !== undefined) details.push(`文件大小: ${formatFileSize(r.details.fileSize)}`)
if (r.details.downloadSpeed !== undefined) details.push(`下载速度: ${r.details.downloadSpeed.toFixed(2)}MB/s`)
if (r.details.url) details.push(`URL: ${r.details.url}`)
if (r.details.isFrp !== undefined) details.push(`FRP穿透: ${r.details.isFrp ? '是' : '否'}`)
if (r.details.supportsRange !== undefined) details.push(`Range支持: ${r.details.supportsRange ? '是' : '否'}`)
if (r.details.cacheSpeedup !== undefined) details.push(`缓存加速: ${r.details.cacheSpeedup.toFixed(1)}%`)
if (r.details.fileCount !== undefined) details.push(`并发文件数: ${r.details.fileCount}`)
if (r.details.avgTimePerFile !== undefined) details.push(`平均时间/文件: ${r.details.avgTimePerFile.toFixed(0)}ms`)
if (r.details.materialCount !== undefined) details.push(`素材数量: ${r.details.materialCount}`)
if (r.details.materialId !== undefined) details.push(`素材ID: ${r.details.materialId}`)
if (r.details.uploadId !== undefined) details.push(`上传ID: ${r.details.uploadId}`)
if (r.details.userId !== undefined) details.push(`用户ID: ${r.details.userId}`)
if (r.details.statusCode !== undefined) details.push(`HTTP状态: ${r.details.statusCode}`)
if (r.details.contentType !== undefined) details.push(`内容类型: ${r.details.contentType}`)
if (r.details.htmlSize !== undefined) details.push(`HTML大小: ${formatFileSize(r.details.htmlSize)}`)
if (r.details.queueSize !== undefined) details.push(`队列大小: ${r.details.queueSize}`)
if (r.details.processing !== undefined) details.push(`处理中: ${r.details.processing}`)
if (r.details.isAuthenticated !== undefined) details.push(`认证状态: ${r.details.isAuthenticated ? '已登录' : '未登录'}`)
if (r.details.redisStatus !== undefined) details.push(`Redis状态: ${r.details.redisStatus}`)
if (r.details.step !== undefined) details.push(`步骤: ${r.details.step}`)
if (r.details.stats !== undefined) details.push(`统计信息: ${JSON.stringify(r.details.stats).substring(0, 100)}`)
if (r.details.health !== undefined) details.push(`健康状态: ${JSON.stringify(r.details.health).substring(0, 100)}`)
if (r.details.error !== undefined) details.push(`错误信息: ${r.details.error}`)
if (r.details.issues && Array.isArray(r.details.issues) && r.details.issues.length > 0) {
details.push(`问题: ${r.details.issues.join(', ')}`)
}
if (details.length > 0) {
detailsText = '\n 详细信息: ' + details.join(', ')
}
}
return `${typeText}: ${statusText}\n ${r.message || '测试中...'}${detailsText}`
}),
'',
'=== 性能分析 ===',
...results.filter(r => r.status === 'warning' || r.status === 'error').map(r => {
const typeMap: Record<string, string> = {
'preview-image': '预览图片',
'preview-video': '预览视频',
'preview-audio': '音频文件',
'connection': '网络连接',
'proxy-performance': '代理性能',
'video-range': '视频Range请求',
'intranet-minio': '内网MinIO',
'public-s3': '公网S3',
'concurrent': '并发性能',
'cache': '缓存效果',
's3-performance': 'S3存储性能',
'api-materials-list': '素材列表API',
'api-material-detail': '素材详情API',
'api-material-stats': '素材统计API',
'api-upload-init': '上传初始化API',
'api-download': '下载API',
'api-user-profile': '用户信息API',
'api-system-settings': '系统设置API',
'api-health': '健康检查API',
'database-query': '数据库查询',
'redis-connection': 'Redis连接',
'page-home-load': '首页加载',
'page-detail-load': '详情页加载',
'upload-flow': '上传流程',
'download-flow': '下载流程',
'transcode-service': '转码服务',
'auth-session': '认证Session',
'intranet-url-rewrite': '内网URL重写',
'public-url-rewrite': '外网URL重写'
}
const typeText = typeMap[r.type] || r.type
return `⚠ ${typeText}${r.status === 'error' ? '失败' : '性能较差'},可能影响用户体验`
}),
'',
'=== 优化建议 ===',
...results
.filter(r => r.details?.suggestions && Array.isArray(r.details.suggestions))
.flatMap(r => r.details!.suggestions as string[]),
'',
'提示: 请将此报告提供给技术支持以便快速定位问题'
].filter(Boolean).join('\n')
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
return
}
throw new Error('Clipboard API not available')
} catch (error) {
console.error('复制失败,尝试降级方案:', error)
try {
const textarea = document.createElement('textarea')
textarea.value = report
textarea.style.position = 'fixed'
textarea.style.left = '-999999px'
textarea.style.top = '-999999px'
textarea.style.opacity = '0'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
if (navigator.userAgent.match(/ipad|iphone/i)) {
const range = document.createRange()
range.selectNodeContents(textarea)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
textarea.setSelectionRange(0, 999999)
} else {
textarea.select()
}
const successful = document.execCommand('copy')
document.body.removeChild(textarea)
if (successful) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} else {
throw new Error('execCommand failed')
}
} catch (err) {
console.error('降级复制也失败:', err)
showToast('复制失败,请手动复制结果', { variant: 'error' })
}
}
}
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 'network-env':
return '网络环境检测'
case 'connection':
return 'DNS和连接'
case 'download':
return '下载测速'
case 'upload':
return '上传测速'
case 'storage':
return '存储连通性'
case 'preview-image':
return '预览图片加载'
case 'preview-video':
return '预览视频加载'
case 'preview-audio':
return '音频文件加载'
case 'proxy-performance':
return '代理性能测试'
case 'video-range':
return '视频Range请求'
case 'intranet-minio':
return '内网MinIO直连'
case 'public-s3':
return '公网S3直连'
case 'concurrent':
return '并发测试'
case 'cache':
return '缓存测试'
case 's3-performance':
return 'S3存储性能'
case 'api-materials-list':
return 'API: 素材列表'
case 'api-material-detail':
return 'API: 素材详情'
case 'api-material-stats':
return 'API: 素材统计'
case 'api-upload-init':
return 'API: 上传初始化'
case 'api-download':
return 'API: 下载接口'
case 'api-user-profile':
return 'API: 用户信息'
case 'api-system-settings':
return 'API: 系统设置'
case 'api-health':
return 'API: 健康检查'
case 'database-query':
return '数据库查询'
case 'redis-connection':
return 'Redis连接'
case 'page-home-load':
return '页面: 首页加载'
case 'page-detail-load':
return '页面: 详情页加载'
case 'upload-flow':
return '上传流程'
case 'download-flow':
return '下载流程'
case 'transcode-service':
return '转码服务'
case 'auth-session':
return '认证Session'
case 'intranet-url-rewrite':
return '内网URL重写'
case 'public-url-rewrite':
return '外网URL重写'
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-2xl w-full shadow-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<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-3 overflow-y-auto flex-1">
{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">
将测试33项网络、API、数据库和业务流程指标,预计耗时60-120秒
</p>
</div>
) : (
<div className="space-y-2">
{results.map((result, index) => (
<div
key={index}
className={`flex items-start gap-3 p-3 rounded-lg 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'
: result.status === 'testing'
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-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 whitespace-pre-line">
{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.sslTime !== undefined && (
<div>SSL握手: {result.details.sslTime.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 flex-shrink-0">
<button
onClick={startDiagnose}
disabled={testing}
className="w-full btn-primary disabled:opacity-50"
>
{testing ? `诊断中... (${results.length}/33)` : 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>
)
}