fix: improve network speed display
This commit is contained in:
parent
1f52939f61
commit
4ef7701b9f
@ -154,6 +154,7 @@ export async function GET() {
|
||||
uploadProcessing.push({
|
||||
uploadId,
|
||||
fileName: session.fileName,
|
||||
fileSize: session.fileSize || 0,
|
||||
progress,
|
||||
userId: session.userId,
|
||||
currentSpeed: (session as any).currentSpeed || 0, // 上传速度(字节/秒,估算)
|
||||
|
||||
@ -11,6 +11,7 @@ import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/Update
|
||||
import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal'
|
||||
import MaintenanceBanner from '@/components/MaintenanceBanner'
|
||||
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
|
||||
import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
|
||||
|
||||
interface HeaderProps {
|
||||
session: Session | null
|
||||
@ -252,18 +253,6 @@ export default function Header({ session }: HeaderProps) {
|
||||
return name?.charAt(0).toUpperCase() || 'U'
|
||||
}
|
||||
|
||||
// 格式化速度(字节/秒 -> 用户友好单位)
|
||||
// 使用字节单位(B/s、KB/s、MB/s),而非比特单位
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
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 formatAgo = (ms: number): string => {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '--'
|
||||
const sec = Math.floor(ms / 1000)
|
||||
@ -382,28 +371,32 @@ export default function Header({ session }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网络带宽 */}
|
||||
{/* 服务器整体网络流量 */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||||
<span>网络带宽</span>
|
||||
<span>服务器网络</span>
|
||||
<span className="text-xs text-gray-500">全站实时</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>上传</span>
|
||||
<span>入站</span>
|
||||
<span>
|
||||
{perfData?.network?.uploadSpeed
|
||||
? formatSpeed(perfData.network.uploadSpeed)
|
||||
? formatHumanSpeed(perfData.network.uploadSpeed)
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>下载</span>
|
||||
<span>出站</span>
|
||||
<span>
|
||||
{perfData?.network?.downloadSpeed
|
||||
? formatSpeed(perfData.network.downloadSpeed)
|
||||
? formatHumanSpeed(perfData.network.downloadSpeed)
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
服务器整体流量,非当前浏览器测速
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -422,26 +415,36 @@ export default function Header({ session }: HeaderProps) {
|
||||
</div>
|
||||
{perfData?.uploadQueue?.processing && perfData.uploadQueue.processing.length > 0 && (
|
||||
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||||
{perfData.uploadQueue.processing.map((task: any, idx: number) => (
|
||||
<div key={task.uploadId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
|
||||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||
<div className="flex justify-between mt-1 text-xs">
|
||||
<span>{task.username || '未知用户'}</span>
|
||||
<span>{task.materialType || '未知类型'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs">
|
||||
<span>进度: {task.progress ?? 0}%</span>
|
||||
<span>
|
||||
{task.currentSpeed ? `速度: ${formatSpeed(task.currentSpeed)}` : '速度: --'}
|
||||
</span>
|
||||
</div>
|
||||
{task.isActive === false && (
|
||||
<div className="text-xs text-amber-400 mt-1">
|
||||
无活动: {formatAgo(task.inactiveMs)}
|
||||
{perfData.uploadQueue.processing.map((task: any, idx: number) => {
|
||||
const uploadedBytes =
|
||||
task.uploadedBytes || ((task.fileSize || 0) * ((task.progress || 0) / 100))
|
||||
const speedText = task.currentSpeed
|
||||
? formatTransferProgressSummary({
|
||||
speedBytesPerSecond: task.currentSpeed,
|
||||
loadedBytes: uploadedBytes,
|
||||
totalBytes: task.fileSize,
|
||||
})
|
||||
: '速度: --'
|
||||
|
||||
return (
|
||||
<div key={task.uploadId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
|
||||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||
<div className="flex justify-between mt-1 text-xs">
|
||||
<span>{task.username || '未知用户'}</span>
|
||||
<span>{task.materialType || '未知类型'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 text-xs">
|
||||
<div>进度: {task.progress ?? 0}%</div>
|
||||
<div>{speedText}</div>
|
||||
</div>
|
||||
{task.isActive === false && (
|
||||
<div className="text-xs text-amber-400 mt-1">
|
||||
无活动: {formatAgo(task.inactiveMs)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{perfData?.uploadQueue?.waiting && perfData.uploadQueue.waiting.length > 0 && (
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import ToastBubble from './ToastBubble'
|
||||
import { formatHumanSpeed } from '@/lib/human-readable-transfer'
|
||||
|
||||
interface NetworkDiagnoseModalProps {
|
||||
isOpen: boolean
|
||||
@ -84,18 +85,6 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 格式化速度(字节/秒 -> 用户友好单位)
|
||||
// 使用字节单位(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`
|
||||
@ -166,13 +155,16 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
const downlink = connection?.downlink || 'unknown'
|
||||
const rtt = connection?.rtt || 'unknown'
|
||||
const saveData = connection?.saveData || false
|
||||
const browserDownlinkSpeed = typeof downlink === 'number'
|
||||
? formatHumanSpeed((downlink * 1000 * 1000) / 8)
|
||||
: '未知'
|
||||
|
||||
const message = [
|
||||
`网络类型: ${data.isIntranet ? '内网' : '外网'}`,
|
||||
`访问域名: ${data.hostname}`,
|
||||
`客户端IP: ${data.clientIp}`,
|
||||
`浏览器网络: ${networkType}`,
|
||||
`下行速度: ${downlink !== 'unknown' ? `${(downlink / 8).toFixed(2)} MB/s` : '未知'}`,
|
||||
`浏览器估算下行: ${browserDownlinkSpeed}`,
|
||||
`RTT延迟: ${rtt !== 'unknown' ? `${rtt} ms` : '未知'}`,
|
||||
saveData ? '省流模式: 开启' : ''
|
||||
].filter(Boolean).join(', ')
|
||||
@ -300,7 +292,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'download',
|
||||
status: speed < 1024 * 1024 ? 'warning' : 'success', // < 1MB/s 警告
|
||||
speed,
|
||||
message: `下载速度: ${formatSpeed(speed)}${ttfb > 0 ? `, TTFB: ${ttfb.toFixed(0)}ms` : ''}`,
|
||||
message: `下载速度: ${formatHumanSpeed(speed)}${ttfb > 0 ? `, TTFB: ${ttfb.toFixed(0)}ms` : ''}`,
|
||||
details: {
|
||||
ttfb: ttfb > 0 ? ttfb : undefined,
|
||||
totalTime: duration * 1000,
|
||||
@ -341,7 +333,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'upload',
|
||||
status: speed < 512 * 1024 ? 'warning' : 'success', // < 512KB/s 警告
|
||||
speed,
|
||||
message: `上传速度: ${formatSpeed(speed)}`,
|
||||
message: `上传速度: ${formatHumanSpeed(speed)}`,
|
||||
details: {
|
||||
totalTime: duration * 1000,
|
||||
fileSize: testData.size
|
||||
@ -448,7 +440,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'preview-image',
|
||||
status: loadTime > 3000 ? 'warning' : 'success',
|
||||
speed,
|
||||
message: `加载时间: ${(loadTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(${formatSpeed(speed)})` : ''}`,
|
||||
message: `加载时间: ${(loadTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(${formatHumanSpeed(speed)})` : ''}`,
|
||||
details: {
|
||||
totalTime: loadTime,
|
||||
ttfb: ttfb > 0 ? ttfb : undefined,
|
||||
@ -713,7 +705,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'proxy-performance',
|
||||
status: ttfb > 1000 ? 'warning' : 'success',
|
||||
speed,
|
||||
message: `代理响应: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
|
||||
message: `代理响应: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatHumanSpeed(speed)}`,
|
||||
details: {
|
||||
dnsTime: dnsTime > 0 ? dnsTime : undefined,
|
||||
connectTime: connectTime > 0 ? connectTime : undefined,
|
||||
@ -782,7 +774,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'video-range',
|
||||
status: rangeTime > 2000 ? 'warning' : 'success',
|
||||
speed,
|
||||
message: `Range请求: ${rangeTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
|
||||
message: `Range请求: ${rangeTime.toFixed(0)}ms, 速度 ${formatHumanSpeed(speed)}`,
|
||||
details: {
|
||||
totalTime: rangeTime,
|
||||
fileSize: blob.size,
|
||||
@ -862,7 +854,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'intranet-minio',
|
||||
status: ttfb > 500 ? 'warning' : 'success',
|
||||
speed,
|
||||
message: `内网MinIO: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
|
||||
message: `内网MinIO: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatHumanSpeed(speed)}`,
|
||||
details: {
|
||||
ttfb: ttfb > 0 ? ttfb : totalTime,
|
||||
totalTime,
|
||||
@ -969,7 +961,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'public-s3',
|
||||
status: ttfb > 2000 ? 'warning' : 'success',
|
||||
speed,
|
||||
message: `公网S3: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatSpeed(speed)}`,
|
||||
message: `公网S3: TTFB ${ttfb > 0 ? ttfb.toFixed(0) : totalTime.toFixed(0)}ms, 速度 ${formatHumanSpeed(speed)}`,
|
||||
details: {
|
||||
dnsTime: dnsTime > 0 ? dnsTime : undefined,
|
||||
connectTime: connectTime > 0 ? connectTime : undefined,
|
||||
@ -1039,7 +1031,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
type: 'concurrent',
|
||||
status: avgTimePerFile > 2000 ? 'warning' : 'success',
|
||||
speed: avgSpeed,
|
||||
message: `并发${imageUrls.length}个文件: 平均${avgTimePerFile.toFixed(0)}ms/文件, 总速度 ${formatSpeed(avgSpeed)}`,
|
||||
message: `并发${imageUrls.length}个文件: 平均${avgTimePerFile.toFixed(0)}ms/文件, 总速度 ${formatHumanSpeed(avgSpeed)}`,
|
||||
details: {
|
||||
totalTime,
|
||||
totalSize,
|
||||
@ -1235,9 +1227,8 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
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
|
||||
const downloadSpeed = downloadTime > 0 ? blob.size / downloadTime : 0
|
||||
|
||||
// 获取Resource Timing
|
||||
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
|
||||
@ -1271,8 +1262,8 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadSpeed < 1 && downloadSpeed > 0) {
|
||||
issues.push(`🔴 下载速度过慢: ${downloadSpeed.toFixed(2)}MB/s(正常>10MB/s)`)
|
||||
if (downloadSpeed < 1024 * 1024 && downloadSpeed > 0) {
|
||||
issues.push(`🔴 下载速度偏慢: ${formatHumanSpeed(downloadSpeed)}(低于 ${formatHumanSpeed(1024 * 1024)})`)
|
||||
if (!isFrp) {
|
||||
suggestions.push('• 带宽不足,建议升级服务器带宽或使用CDN')
|
||||
}
|
||||
@ -1286,12 +1277,12 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
issues.push(`⚠ TCP连接较慢: ${connectTime.toFixed(0)}ms`)
|
||||
}
|
||||
|
||||
const isSuccess = !isFrp && ttfb < 1000 && downloadSpeed > 1
|
||||
const isWarning = !isSuccess && (ttfb < 3000 || downloadSpeed > 0.1)
|
||||
const isSuccess = !isFrp && ttfb < 1000 && downloadSpeed > 1024 * 1024
|
||||
const isWarning = !isSuccess && (ttfb < 3000 || downloadSpeed > 100 * 1024)
|
||||
|
||||
let message = ''
|
||||
if (isSuccess) {
|
||||
message = `性能良好\n下载速度: ${downloadSpeed.toFixed(2)}MB/s, TTFB: ${Math.round(ttfb)}ms`
|
||||
message = `性能良好\n下载速度: ${formatHumanSpeed(downloadSpeed)}, TTFB: ${Math.round(ttfb)}ms`
|
||||
} else if (isWarning) {
|
||||
message = `性能一般\n${issues.slice(0, 2).join('\n')}`
|
||||
} else {
|
||||
@ -2461,6 +2452,9 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
const downlink = connection?.downlink || 'unknown'
|
||||
const rtt = connection?.rtt || 'unknown'
|
||||
const saveData = connection?.saveData || false
|
||||
const browserDownlinkSpeed = typeof downlink === 'number'
|
||||
? formatHumanSpeed((downlink * 1000 * 1000) / 8)
|
||||
: '未知'
|
||||
|
||||
const report = [
|
||||
'=== 网络诊断完整报告 ===',
|
||||
@ -2471,7 +2465,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
'=== 网络环境信息 ===',
|
||||
`在线状态: ${navigator.onLine ? '在线' : '离线'}`,
|
||||
`网络类型: ${networkType}`,
|
||||
`下行速度: ${downlink !== 'unknown' ? `${(downlink / 8).toFixed(2)} MB/s` : '未知'}`,
|
||||
`浏览器估算下行: ${browserDownlinkSpeed}`,
|
||||
`RTT延迟: ${rtt !== 'unknown' ? `${rtt} ms` : '未知'}`,
|
||||
`省流模式: ${saveData ? '开启' : '关闭'}`,
|
||||
networkInfo ? [
|
||||
@ -2533,7 +2527,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
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.downloadSpeed !== undefined) details.push(`下载速度: ${formatHumanSpeed(r.details.downloadSpeed)}`)
|
||||
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 ? '是' : '否'}`)
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { parseApiError, parseFetchError } from '@/lib/error-handler'
|
||||
import { formatSpeed } from '@/components/upload/upload-utils'
|
||||
import { formatTransferProgress } from '@/components/upload/upload-utils'
|
||||
import {
|
||||
STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME,
|
||||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||||
@ -110,6 +110,9 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
const [whiteBgUploadSpeed, setWhiteBgUploadSpeed] = useState(0)
|
||||
const [whiteBgError, setWhiteBgError] = useState('')
|
||||
const [whiteBgSourceType, setWhiteBgSourceType] = useState<StandardModelWhiteBackgroundSourceType | ''>('')
|
||||
const whiteBgUploadTotalBytes =
|
||||
STANDARD_MODEL_WHITE_BG_VIEWS.reduce((sum, view) => sum + (whiteBgFiles[view.key]?.size || 0), 0) +
|
||||
whiteBgOtherImages.reduce((sum, image) => sum + image.file.size, 0)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const previewInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -1361,7 +1364,15 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>上传进度 {whiteBgUploadProgress}%</span>
|
||||
<span>{whiteBgUploadSpeed > 0 ? formatSpeed(whiteBgUploadSpeed) : '计算中...'}</span>
|
||||
<span>
|
||||
{whiteBgUploadSpeed > 0
|
||||
? formatTransferProgress(
|
||||
whiteBgUploadSpeed,
|
||||
whiteBgUploadTotalBytes * (whiteBgUploadProgress / 100),
|
||||
whiteBgUploadTotalBytes || undefined,
|
||||
)
|
||||
: '计算中...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
|
||||
@ -11,8 +11,6 @@ import {
|
||||
} from '@/components/upload/upload-constants'
|
||||
import {
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
calculateRemainingTime,
|
||||
getFileExtension,
|
||||
isValidMainFileType,
|
||||
getMainFileAccept,
|
||||
@ -161,6 +159,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const singleUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set()) // 单文件上传的所有活跃XHR
|
||||
const singleUploadServerStateRef = useRef<{ uploadId?: string; existingFilePath?: string } | null>(null)
|
||||
const batchFileInputRef = useRef<HTMLInputElement>(null) // 批量文件选择input
|
||||
const singleUploadTotalBytes =
|
||||
(zipFile?.size || 0) +
|
||||
previewImages.reduce((sum, file) => sum + file.size, 0) +
|
||||
(displayImage?.size || 0) +
|
||||
modelImages.reduce((sum, file) => sum + file.size, 0)
|
||||
|
||||
// 加载当前用户可见的素材类型
|
||||
useEffect(() => {
|
||||
@ -2334,6 +2337,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
batchFiles={batchFiles}
|
||||
uploadProgress={uploadProgress}
|
||||
uploadSpeed={uploadSpeed}
|
||||
uploadTotalBytes={singleUploadTotalBytes || undefined}
|
||||
isProcessing={isProcessing}
|
||||
onCancel={handleCancelUpload}
|
||||
/>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { BatchUploadStatus, BatchFileItem } from './upload-types'
|
||||
import { formatSpeed, calculateRemainingTime } from './upload-utils'
|
||||
import { formatTransferProgress } from './upload-utils'
|
||||
|
||||
interface UploadProgressProps {
|
||||
uploading: boolean
|
||||
@ -10,6 +10,7 @@ interface UploadProgressProps {
|
||||
batchFiles?: BatchFileItem[] // 用于计算实际文件大小
|
||||
uploadProgress: number
|
||||
uploadSpeed: number
|
||||
uploadTotalBytes?: number
|
||||
isProcessing: boolean
|
||||
onCancel: () => void
|
||||
}
|
||||
@ -21,6 +22,7 @@ export default function UploadProgress({
|
||||
batchFiles,
|
||||
uploadProgress,
|
||||
uploadSpeed,
|
||||
uploadTotalBytes,
|
||||
isProcessing,
|
||||
onCancel,
|
||||
}: UploadProgressProps) {
|
||||
@ -31,24 +33,13 @@ export default function UploadProgress({
|
||||
const completedFiles = fileStatuses.filter(s => s.status === 'done').length
|
||||
const failedFiles = fileStatuses.filter(s => s.status === 'failed').length
|
||||
const processingFiles = fileStatuses.filter(s => s.status === 'uploading' || s.status === 'processing' || s.status === 'pending')
|
||||
const currentBatchFile = batchFiles?.find(bf => bf.file.name === currentFileName)
|
||||
const currentFileTotalBytes = currentBatchFile?.file.size || 0
|
||||
const currentFileLoadedBytes = currentFileTotalBytes * (currentFileProgress / 100)
|
||||
|
||||
// 只显示正在处理/等待中的文件,已完成的文件不显示(避免队列过长)
|
||||
const visibleFileStatuses = fileStatuses.filter(s => s.status !== 'done')
|
||||
|
||||
// 计算剩余字节数(使用实际文件大小)
|
||||
const remainingBytes = fileStatuses
|
||||
.slice(currentFileIndex)
|
||||
.reduce((sum, status, idx) => {
|
||||
const fileStatus = batchUploadStatus.fileStatuses[currentFileIndex + idx]
|
||||
if (!fileStatus) return sum
|
||||
|
||||
// 从batchFiles中查找对应的文件大小
|
||||
const batchFile = batchFiles?.find(bf => bf.file.name === fileStatus.fileName)
|
||||
const fileSize = batchFile?.file.size || 0
|
||||
|
||||
return sum + (fileSize * (100 - status.progress) / 100)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 整体进度 */}
|
||||
@ -83,9 +74,14 @@ export default function UploadProgress({
|
||||
/>
|
||||
</div>
|
||||
{currentFileSpeed > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>速度: {formatSpeed(currentFileSpeed)}</span>
|
||||
<span>剩余时间: {calculateRemainingTime(remainingBytes, currentFileSpeed)}</span>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{formatTransferProgress(
|
||||
currentFileSpeed,
|
||||
currentFileLoadedBytes,
|
||||
currentFileTotalBytes || undefined,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -165,7 +161,11 @@ export default function UploadProgress({
|
||||
</div>
|
||||
{uploadSpeed > 0 && !isProcessing && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
速度: {formatSpeed(uploadSpeed)}
|
||||
{formatTransferProgress(
|
||||
uploadSpeed,
|
||||
uploadTotalBytes ? uploadTotalBytes * (uploadProgress / 100) : undefined,
|
||||
uploadTotalBytes,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
// 上传相关的工具函数
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
import {
|
||||
formatHumanBytes,
|
||||
formatHumanSpeed,
|
||||
formatRemainingTime,
|
||||
formatTransferProgressSummary,
|
||||
} from '@/lib/human-readable-transfer'
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
return formatHumanBytes(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -17,25 +19,26 @@ export function formatBytes(bytes: number): string {
|
||||
* 使用字节单位(B/s、KB/s、MB/s),而非比特单位
|
||||
*/
|
||||
export function formatSpeed(bytesPerSecond: number): string {
|
||||
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`
|
||||
return formatHumanSpeed(bytesPerSecond)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算剩余时间
|
||||
*/
|
||||
export function calculateRemainingTime(remainingBytes: number, speed: number): string {
|
||||
if (speed <= 0) return '计算中...'
|
||||
const seconds = Math.ceil(remainingBytes / speed)
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}分${secs}秒`
|
||||
return formatRemainingTime(remainingBytes, speed).replace(/^剩余\s*/, '')
|
||||
}
|
||||
|
||||
export function formatTransferProgress(
|
||||
speedBytesPerSecond: number,
|
||||
loadedBytes?: number,
|
||||
totalBytes?: number,
|
||||
): string {
|
||||
return formatTransferProgressSummary({
|
||||
speedBytesPerSecond,
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -472,6 +472,15 @@ export async function chunkUpload(
|
||||
lastUploadedBytes = completedBytes // 初始化已上传字节数
|
||||
const speedHistory: number[] = [] // 用于平滑网速计算
|
||||
const SPEED_HISTORY_SIZE = 5 // 保留最近5次的速度记录
|
||||
const inFlightChunkBytes = new Map<number, number>()
|
||||
|
||||
const getInFlightBytes = () => {
|
||||
let total = 0
|
||||
inFlightChunkBytes.forEach((bytes) => {
|
||||
total += bytes
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
const updateProgress = (uploadedBytes: number) => {
|
||||
const clampedBytes = Math.min(file.size, Math.max(0, uploadedBytes))
|
||||
@ -566,8 +575,6 @@ export async function chunkUpload(
|
||||
// 上传块(带自动重试)
|
||||
let retries = 0
|
||||
let success = false
|
||||
let chunkUploadedBytes = 0
|
||||
|
||||
while (!success && retries < maxRetries) {
|
||||
// 每次重试前检查取消状态
|
||||
if (isCancelled?.()) {
|
||||
@ -581,8 +588,8 @@ export async function chunkUpload(
|
||||
try {
|
||||
await uploadChunk(state.uploadId!, chunkIndex, chunk, file.name, {
|
||||
onChunkProgress: (loaded) => {
|
||||
chunkUploadedBytes = loaded
|
||||
updateProgress(completedBytes + chunkUploadedBytes)
|
||||
inFlightChunkBytes.set(chunkIndex, Math.min(loaded, chunkSizeBytes))
|
||||
updateProgress(completedBytes + getInFlightBytes())
|
||||
},
|
||||
onRequest: (xhr) => {
|
||||
// 跟踪所有活跃的 XHR
|
||||
@ -606,12 +613,15 @@ export async function chunkUpload(
|
||||
success = true
|
||||
|
||||
// 更新已上传字节数(完成整个块)
|
||||
inFlightChunkBytes.delete(chunkIndex)
|
||||
completedBytes += chunkSizeBytes
|
||||
chunkUploadedBytes = 0
|
||||
updateProgress(completedBytes)
|
||||
updateProgress(completedBytes + getInFlightBytes())
|
||||
|
||||
console.log(`[ChunkUpload] Chunk ${chunkIndex}/${totalChunks} 完成,剩余 ${pendingChunks.length} 个`)
|
||||
} catch (error) {
|
||||
inFlightChunkBytes.delete(chunkIndex)
|
||||
updateProgress(completedBytes + getInFlightBytes())
|
||||
|
||||
// 清理当前 XHR
|
||||
if (currentXhr) {
|
||||
activeXhrs.delete(currentXhr)
|
||||
|
||||
85
lib/human-readable-transfer.ts
Normal file
85
lib/human-readable-transfer.ts
Normal file
@ -0,0 +1,85 @@
|
||||
function formatCompactNumber(value: number, fractionDigits: number): string {
|
||||
return value.toFixed(fractionDigits).replace(/\.0$/, '')
|
||||
}
|
||||
|
||||
function getFractionDigits(value: number, unitIndex: number): number {
|
||||
if (unitIndex === 0 || value >= 100) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
export function formatHumanBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes)) return '--'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const sign = bytes < 0 ? '-' : ''
|
||||
let value = Math.abs(bytes)
|
||||
let unitIndex = 0
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${sign}${formatCompactNumber(value, getFractionDigits(value, unitIndex))} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
export function formatHumanSpeed(bytesPerSecond: number): string {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond < 0) return '--'
|
||||
return `${formatHumanBytes(bytesPerSecond)}/s`
|
||||
}
|
||||
|
||||
export function formatDurationSeconds(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '计算中'
|
||||
|
||||
const wholeSeconds = Math.max(1, Math.round(seconds))
|
||||
if (wholeSeconds < 60) {
|
||||
return `${wholeSeconds} 秒`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(wholeSeconds / 60)
|
||||
const remainSeconds = wholeSeconds % 60
|
||||
if (minutes < 60) {
|
||||
return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainMinutes = minutes % 60
|
||||
return remainMinutes > 0 ? `${hours} 小时 ${remainMinutes} 分` : `${hours} 小时`
|
||||
}
|
||||
|
||||
export function formatRemainingTime(remainingBytes: number, bytesPerSecond: number): string {
|
||||
if (!Number.isFinite(remainingBytes) || remainingBytes <= 0) return '剩余 0 秒'
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '剩余时间计算中'
|
||||
return `剩余 ${formatDurationSeconds(remainingBytes / bytesPerSecond)}`
|
||||
}
|
||||
|
||||
export function formatTransferProgressSummary(params: {
|
||||
speedBytesPerSecond: number
|
||||
loadedBytes?: number
|
||||
totalBytes?: number
|
||||
includeRemaining?: boolean
|
||||
}): string {
|
||||
const {
|
||||
speedBytesPerSecond,
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
includeRemaining = true,
|
||||
} = params
|
||||
|
||||
const speedText = formatHumanSpeed(speedBytesPerSecond)
|
||||
const hasTotal = Number.isFinite(totalBytes) && (totalBytes || 0) > 0
|
||||
const safeLoaded = Math.max(0, loadedBytes || 0)
|
||||
const safeTotal = hasTotal ? Math.max(0, totalBytes || 0) : 0
|
||||
|
||||
if (!hasTotal) {
|
||||
return speedText
|
||||
}
|
||||
|
||||
const progressText = `${formatHumanBytes(Math.min(safeLoaded, safeTotal))}/${formatHumanBytes(safeTotal)}`
|
||||
if (!includeRemaining) {
|
||||
return `${speedText} - ${progressText}`
|
||||
}
|
||||
|
||||
const remainingText = formatRemainingTime(safeTotal - safeLoaded, speedBytesPerSecond)
|
||||
return `${speedText} - ${progressText},${remainingText}`
|
||||
}
|
||||
@ -7,6 +7,8 @@
|
||||
* - txBytes(服务器发送)= 用户从服务器下载
|
||||
*/
|
||||
|
||||
import { formatHumanSpeed } from './human-readable-transfer'
|
||||
|
||||
interface NetworkStats {
|
||||
rxBytes: number // 接收字节数(用户上传)
|
||||
txBytes: number // 发送字节数(用户下载)
|
||||
@ -182,11 +184,5 @@ export function calculateUploadSpeedFromSessions(uploadSessions: Map<string, any
|
||||
* 使用字节单位(B/s、KB/s、MB/s),而非比特单位
|
||||
*/
|
||||
export function formatSpeed(bytesPerSecond: number): string {
|
||||
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`
|
||||
return formatHumanSpeed(bytesPerSecond)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user