301 lines
8.8 KiB
TypeScript
Executable File
301 lines
8.8 KiB
TypeScript
Executable File
'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',
|
|
};
|
|
}
|