casualroom/apps/fenghuo/web/hooks/use-tus-upload.ts

301 lines
8.8 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import { useState, useRef, useCallback } from 'react';
import * as tus from 'tus-js-client';
import { useAuth } from '@/components/providers/auth-provider';
/**
*
*/
export interface TusUploadState {
/** 当前上传的文件 */
file: File | null;
/** 上传进度 (0-100) */
progress: number;
/** 上传状态 */
status: 'idle' | 'uploading' | 'success' | 'error' | 'paused';
/** 错误信息 */
error?: string;
/** 成功后的文件URL */
uploadUrl?: string;
/** 已上传的字节数 */
bytesUploaded?: number;
/** 文件总字节数 */
bytesTotal?: number;
}
/**
*
*/
export interface UploadMetadata {
/** 用户ID */
authorId?: string;
/** 父目录ID */
parentId?: string;
/** 组织ID */
organizationId?: string;
/** 文件描述 */
description?: string;
/** 自定义标签 */
tags?: string[];
/** 自定义元数据 */
[key: string]: any;
}
/**
* TUS
*/
export function useTusUpload(defaultMetadata: UploadMetadata = {}) {
const { user } = useAuth();
const [state, setState] = useState<TusUploadState>({
file: null,
progress: 0,
status: 'idle',
});
const uploadRef = useRef<tus.Upload | null>(null);
const retryDelays = [1000, 3000, 5000]; // 重试延迟
/**
* TUS元数据
*/
const buildTusMetadata = useCallback((file: File, customMetadata: UploadMetadata = {}) => {
const metadata: Record<string, string> = {
filename: file.name,
filetype: file.type || 'application/octet-stream',
filesize: file.size.toString(),
};
// 添加用户信息
if (user?.id) {
metadata.authorId = user.id;
}
if (user?.organizationId) {
metadata.organizationId = user.organizationId;
}
// 添加默认元数据
Object.entries(defaultMetadata).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === 'object') {
metadata[key] = JSON.stringify(value);
} else {
metadata[key] = String(value);
}
}
});
// 添加自定义元数据
Object.entries(customMetadata).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === 'object') {
metadata[key] = JSON.stringify(value);
} else {
metadata[key] = String(value);
}
}
});
return metadata;
}, [user, defaultMetadata]);
/**
*
*/
const uploadFile = useCallback((file: File, metadata: UploadMetadata = {}) => {
// 构建元数据
const tusMetadata = buildTusMetadata(file, metadata);
// 创建上传实例
const upload = new tus.Upload(file, {
endpoint: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/files/`,
retryDelays,
metadata: tusMetadata,
chunkSize: 1024 * 1024, // 1MB 分块大小
onError: (error: Error) => {
console.error('上传失败:', error);
console.error('错误详情:', error);
setState((prev) => ({
...prev,
status: 'error',
error: error.message,
}));
},
onProgress: (bytesUploaded: number, bytesTotal: number) => {
const progress = Math.round((bytesUploaded / bytesTotal) * 100);
setState((prev) => ({
...prev,
progress,
bytesUploaded,
bytesTotal,
}));
},
onSuccess: () => {
setState((prev) => ({
...prev,
status: 'success',
uploadUrl: upload.url || undefined,
progress: 100,
}));
},
onShouldRetry: (err: Error, retryAttempt: number) => {
// TUS错误对象可能包含额外属性
const tusError = err as any;
const status = tusError.originalResponse ? tusError.originalResponse.getStatus() : 0;
// 不重试认证错误、权限错误等
if (status === 401 || status === 403 || status === 413) {
return false;
}
// 如果超过最大重试次数,不再重试
if (retryAttempt >= retryDelays.length) {
return false;
}
return true;
},
});
// 保存上传实例引用
uploadRef.current = upload;
// 设置初始状态
setState({
file,
progress: 0,
status: 'uploading',
error: undefined,
uploadUrl: undefined,
bytesUploaded: 0,
bytesTotal: file.size,
});
// 开始上传
upload.start();
}, [buildTusMetadata, retryDelays]);
/**
*
*/
const uploadFiles = useCallback(async (files: File[], metadata: UploadMetadata = {}) => {
const results: Array<{ file: File; success: boolean; error?: string; uploadUrl?: string }> = [];
for (const file of files) {
try {
await new Promise<void>((resolve, reject) => {
const tusMetadata = buildTusMetadata(file, metadata);
const upload = new tus.Upload(file, {
endpoint: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/files/`,
retryDelays,
metadata: tusMetadata,
onError: reject,
onSuccess: () => {
results.push({
file,
success: true,
uploadUrl: upload.url || undefined,
});
resolve();
},
onShouldRetry: (err: Error, retryAttempt: number) => {
const tusError = err as any;
const status = tusError.originalResponse ? tusError.originalResponse.getStatus() : 0;
if (status === 401 || status === 403 || status === 413) {
return false;
}
if (retryAttempt >= retryDelays.length) {
return false;
}
return true;
},
});
upload.start();
});
} catch (error) {
results.push({
file,
success: false,
error: error instanceof Error ? error.message : '上传失败',
});
}
}
return results;
}, [buildTusMetadata, retryDelays]);
/**
*
*/
const pauseUpload = useCallback(() => {
if (uploadRef.current) {
uploadRef.current.abort();
setState((prev) => ({
...prev,
status: 'paused',
}));
}
}, []);
/**
*
*/
const resumeUpload = useCallback(() => {
if (uploadRef.current) {
uploadRef.current.start();
setState((prev) => ({
...prev,
status: 'uploading',
error: undefined,
}));
}
}, []);
/**
*
*/
const cancelUpload = useCallback(() => {
if (uploadRef.current) {
uploadRef.current.abort();
uploadRef.current = null;
}
setState({
file: null,
progress: 0,
status: 'idle',
});
}, []);
/**
*
*/
const reset = useCallback(() => {
setState({
file: null,
progress: 0,
status: 'idle',
});
}, []);
return {
state,
uploadFile,
uploadFiles,
pauseUpload,
resumeUpload,
cancelUpload,
reset,
// 便利方法
isUploading: state.status === 'uploading',
isSuccess: state.status === 'success',
isError: state.status === 'error',
isPaused: state.status === 'paused',
canResume: state.status === 'paused',
canPause: state.status === 'uploading',
};
}