fix: improve network speed display

This commit is contained in:
chenpei 2026-07-04 14:31:24 +08:00
parent 1f52939f61
commit 4ef7701b9f
10 changed files with 228 additions and 121 deletions

View File

@ -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, // 上传速度(字节/秒,估算)

View File

@ -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 && (

View File

@ -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 ? '是' : '否'}`)

View File

@ -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

View File

@ -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}
/>

View File

@ -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

View File

@ -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,
})
}
/**

View File

@ -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)

View 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}`
}

View File

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