import React, { useState, useRef, useCallback } from 'react'
import * as tus from 'tus-js-client'
type UploadState = 'idle' | 'uploading' | 'paused' | 'completed' | 'error'
const App: React.FC = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [uploadState, setUploadState] = useState<UploadState>('idle')
const [uploadProgress, setUploadProgress] = useState<number>(0)
const [errorMessage, setErrorMessage] = useState<string>('')
const [fileUrl, setFileUrl] = useState<string>('')
const [bytesUploaded, setBytesUploaded] = useState<number>(0)
const [bytesTotal, setBytesTotal] = useState<number>(0)
const [uploadSpeed, setUploadSpeed] = useState<number>(0)
const [timeRemaining, setTimeRemaining] = useState<number>(0)
const lastUpdateTime = useRef<number>(0)
const lastBytesUploaded = useRef<number>(0)
const upload = useRef<tus.Upload | null>(null)
const tusdServerUrl = 'http://localhost:8080/files'
const 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]
}
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}秒`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
if (minutes < 60) return `${minutes}分${secs}秒`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}小时${mins}分`
}
const handleUploadComplete = (file: File, url: string) => {
console.log('文件上传完成:', file.name, '地址:', url)
}
const handleUploadError = (error: Error) => {
console.error('上传失败:', error)
}
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const file = files[0]
setSelectedFile(file)
setUploadState('idle')
setUploadProgress(0)
setErrorMessage('')
setBytesUploaded(0)
setBytesTotal(file.size)
setUploadSpeed(0)
setTimeRemaining(0)
lastUpdateTime.current = 0
lastBytesUploaded.current = 0
}, [])
const startUpload = useCallback(async () => {
if (!selectedFile) {
setErrorMessage('请先选择文件')
return
}
try {
setUploadState('uploading')
setErrorMessage('')
const uploadInstance = new tus.Upload(selectedFile, {
endpoint: tusdServerUrl,
metadata: {
filename: selectedFile.name,
filetype: selectedFile.type,
},
onProgress: (bytesUploaded, bytesTotal) => {
const now = Date.now()
const progress = Math.floor((bytesUploaded / bytesTotal) * 100)
setUploadProgress(progress)
setBytesUploaded(bytesUploaded)
setBytesTotal(bytesTotal)
if (lastUpdateTime.current > 0) {
const timeDiff = (now - lastUpdateTime.current) / 1000
const bytesDiff = bytesUploaded - lastBytesUploaded.current
if (timeDiff > 0) {
const speed = bytesDiff / timeDiff
setUploadSpeed(speed)
const remaining = bytesTotal - bytesUploaded
if (speed > 0) {
setTimeRemaining(Math.ceil(remaining / speed))
}
}
}
lastUpdateTime.current = now
lastBytesUploaded.current = bytesUploaded
},
onSuccess: () => {
setUploadState('completed')
setUploadProgress(100)
const url = uploadInstance.url
setFileUrl(url || '')
handleUploadComplete(selectedFile, url!)
},
onError: (error) => {
setUploadState('error')
setErrorMessage(`上传失败:${error.message}`)
handleUploadError(error)
},
})
const previousUploads = await uploadInstance.findPreviousUploads()
if (previousUploads.length > 0) {
uploadInstance.resumeFromPreviousUpload(previousUploads[0])
}
upload.current = uploadInstance
uploadInstance.start()
} catch (error) {
const err = error as Error
setUploadState('error')
setErrorMessage(`上传初始化失败:${err.message}`)
handleUploadError(err)
}
}, [selectedFile, tusdServerUrl])
const pauseUpload = useCallback(() => {
if (upload.current && uploadState === 'uploading') {
upload.current.abort()
setUploadState('paused')
}
}, [uploadState])
const cancelUpload = useCallback(() => {
if (upload.current) {
upload.current.abort()
upload.current = null
}
setSelectedFile(null)
setUploadState('idle')
setUploadProgress(0)
setErrorMessage('')
setFileUrl('')
setBytesUploaded(0)
setBytesTotal(0)
setUploadSpeed(0)
setTimeRemaining(0)
lastUpdateTime.current = 0
lastBytesUploaded.current = 0
}, [])
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 40, color: 'var(--text-primary)' }}>
<h1 style={{ color: 'var(--text-primary)' }}>React 18 + TS + Tusd 断点续传示例</h1>
<div
style={{
maxWidth: 600,
margin: '20px auto',
padding: 20,
border: '1px solid var(--border-color-light)',
borderRadius: 8,
backgroundColor: 'var(--bg-secondary)',
}}
>
<h3 style={{ color: 'var(--text-primary)', marginTop: 0 }}>
Tusd 断点续传上传(React 18 + TS)
</h3>
<div style={{ marginBottom: 20 }}>
<input
type="file"
onChange={handleFileChange}
disabled={uploadState === 'uploading'}
style={{
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '8px',
cursor: uploadState === 'uploading' ? 'not-allowed' : 'pointer',
}}
/>
{selectedFile && (
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
已选择:{selectedFile.name}({Math.round(selectedFile.size / 1024)} KB)
</p>
)}
</div>
<div style={{ marginBottom: 20, gap: 10, display: 'flex' }}>
<button
onClick={startUpload}
disabled={!selectedFile || uploadState === 'uploading' || uploadState === 'completed'}
style={{
padding: '8px 16px',
cursor:
!selectedFile || uploadState === 'uploading' || uploadState === 'completed'
? 'not-allowed'
: 'pointer',
backgroundColor: 'var(--button-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
opacity:
!selectedFile || uploadState === 'uploading' || uploadState === 'completed'
? 0.5
: 1,
}}
>
{uploadState === 'paused' ? '恢复上传' : '开始上传'}
</button>
<button
onClick={pauseUpload}
disabled={uploadState !== 'uploading'}
style={{
padding: '8px 16px',
cursor: uploadState !== 'uploading' ? 'not-allowed' : 'pointer',
backgroundColor: 'var(--button-bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
opacity: uploadState !== 'uploading' ? 0.5 : 1,
}}
>
暂停上传
</button>
<button
onClick={cancelUpload}
disabled={uploadState === 'idle' && !selectedFile}
style={{
padding: '8px 16px',
cursor: uploadState === 'idle' && !selectedFile ? 'not-allowed' : 'pointer',
backgroundColor: 'var(--button-bg-danger)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
opacity: uploadState === 'idle' && !selectedFile ? 0.5 : 1,
}}
>
取消上传
</button>
</div>
{uploadState !== 'idle' && (
<div style={{ marginBottom: 20 }}>
<div
style={{
height: 8,
width: '100%',
backgroundColor: 'var(--progress-bg)',
borderRadius: 4,
}}
>
<div
style={{
height: '100%',
width: `${uploadProgress}%`,
backgroundColor:
uploadState === 'error' ? 'var(--error-color)' : 'var(--progress-fill)',
borderRadius: 4,
transition: 'width 0.3s ease',
}}
/>
</div>
<div
style={{
marginTop: 8,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<div style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
<span style={{ fontWeight: 500 }}>进度:{uploadProgress}%</span>
<span style={{ marginLeft: 12, color: 'var(--text-secondary)' }}>
{formatBytes(bytesUploaded)} / {formatBytes(bytesTotal)}
</span>
</div>
{uploadState === 'uploading' && uploadSpeed > 0 && (
<div style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
<span style={{ marginRight: 12 }}>速度:{formatBytes(uploadSpeed)}/s</span>
{timeRemaining > 0 && <span>剩余:{formatTime(timeRemaining)}</span>}
</div>
)}
</div>
</div>
)}
{errorMessage && (
<p style={{ color: 'var(--error-color)', marginTop: 10 }}>{errorMessage}</p>
)}
{uploadState === 'completed' && (
<div style={{ marginTop: 10 }}>
<p style={{ color: 'var(--success-color)', marginBottom: 8 }}>上传完成!</p>
{fileUrl && (
<div
style={{
marginTop: 10,
padding: 12,
backgroundColor: 'var(--bg-tertiary)',
borderRadius: 4,
border: '1px solid var(--border-color)',
}}
>
<p style={{ color: 'var(--text-primary)', margin: '0 0 8px 0', fontWeight: 500 }}>
文件链接:
</p>
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--progress-fill)',
textDecoration: 'none',
wordBreak: 'break-all',
display: 'inline-block',
maxWidth: '100%',
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
{fileUrl}
</a>
<button
onClick={() => {
navigator.clipboard.writeText(fileUrl)
}}
style={{
marginLeft: 8,
padding: '4px 8px',
backgroundColor: 'var(--button-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
cursor: 'pointer',
fontSize: 12,
}}
>
复制
</button>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default App