|
|
@@ -0,0 +1,358 @@
|
|
|
+import React, { useState, useCallback } from 'react';
|
|
|
+import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
|
|
|
+import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
|
+import { App } from 'antd';
|
|
|
+import type { UploadFile, UploadProps } from 'antd';
|
|
|
+import type { UploadFileStatus } from 'antd/es/upload/interface';
|
|
|
+import { fileClient } from '@/client/api';
|
|
|
+import type { InferRequestType, InferResponseType } from 'hono/client';
|
|
|
+
|
|
|
+// 定义上传策略响应类型
|
|
|
+type UploadPolicyResponse = InferResponseType<typeof fileClient['upload-policy']['$post'], 200>;
|
|
|
+// 定义创建文件请求类型
|
|
|
+type CreateFileRequest = InferRequestType<typeof fileClient['upload-policy']['$post']>['json'];
|
|
|
+
|
|
|
+interface AliyunOSSUploaderProps {
|
|
|
+ /** 上传路径 */
|
|
|
+ uploadPath: string;
|
|
|
+ /** 允许的文件类型,如['image/*', '.pdf'] */
|
|
|
+ accept?: string;
|
|
|
+ /** 最大文件大小(MB) */
|
|
|
+ maxSize?: number;
|
|
|
+ /** 是否允许多文件上传 */
|
|
|
+ multiple?: boolean;
|
|
|
+ /** 上传成功回调 */
|
|
|
+ onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
|
|
|
+ /** 上传失败回调 */
|
|
|
+ onUploadError?: (error: Error, file: File) => void;
|
|
|
+ /** 自定义上传按钮文本 */
|
|
|
+ buttonText?: string;
|
|
|
+ /** 自定义提示文本 */
|
|
|
+ tipText?: string;
|
|
|
+}
|
|
|
+
|
|
|
+const AliyunOSSUploader: React.FC<AliyunOSSUploaderProps> = ({
|
|
|
+ uploadPath = '/',
|
|
|
+ accept,
|
|
|
+ maxSize = 500, // 默认最大500MB
|
|
|
+ multiple = false,
|
|
|
+ onUploadSuccess,
|
|
|
+ onUploadError,
|
|
|
+ buttonText = '点击或拖拽上传文件',
|
|
|
+ tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
|
|
|
+}) => {
|
|
|
+ const { message: antdMessage } = App.useApp();
|
|
|
+ const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
|
+ const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
|
|
+
|
|
|
+ // 处理上传进度
|
|
|
+ const handleProgress = useCallback((uid: string, percent: number) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: 'uploading' as UploadFileStatus,
|
|
|
+ percent
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理上传成功
|
|
|
+ const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: 'success' as UploadFileStatus,
|
|
|
+ percent: 100,
|
|
|
+ response: { fileKey: result.fileKey },
|
|
|
+ url: result.fileUrl,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ setUploadingFiles(prev => {
|
|
|
+ const newSet = new Set(prev);
|
|
|
+ newSet.delete(uid);
|
|
|
+ return newSet;
|
|
|
+ });
|
|
|
+
|
|
|
+ antdMessage.success(`文件 "${file.name}" 上传成功`);
|
|
|
+ onUploadSuccess?.(result.fileKey, result.fileUrl, file);
|
|
|
+ }, [antdMessage, onUploadSuccess]);
|
|
|
+
|
|
|
+ // 处理上传失败
|
|
|
+ const handleError = useCallback((uid: string, error: Error, file: File) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: 'error' as UploadFileStatus,
|
|
|
+ percent: 0,
|
|
|
+ error: error.message || '上传失败'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ setUploadingFiles(prev => {
|
|
|
+ const newSet = new Set(prev);
|
|
|
+ newSet.delete(uid);
|
|
|
+ return newSet;
|
|
|
+ });
|
|
|
+
|
|
|
+ antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
|
|
|
+ onUploadError?.(error, file);
|
|
|
+ }, [antdMessage, onUploadError]);
|
|
|
+
|
|
|
+ // 获取上传策略
|
|
|
+ const getUploadPolicy = async (file: File): Promise<UploadPolicyResponse> => {
|
|
|
+ const fileName = file.name;
|
|
|
+ const fileSize = file.size;
|
|
|
+ const fileType = file.type;
|
|
|
+
|
|
|
+ // 构造请求数据
|
|
|
+ const requestData: CreateFileRequest = {
|
|
|
+ name: fileName,
|
|
|
+ size: fileSize,
|
|
|
+ type: fileType,
|
|
|
+ path: uploadPath,
|
|
|
+ // 可以根据需要添加更多元数据
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 调用后端接口获取上传策略
|
|
|
+ const response = await fileClient['upload-policy'].$post({
|
|
|
+ json: requestData
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ const errorData = await response.json().catch(() => null);
|
|
|
+ throw new Error(errorData?.message || `获取上传策略失败,状态码: ${response.status}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return await response.json() as UploadPolicyResponse;
|
|
|
+ } catch (error) {
|
|
|
+ throw error instanceof Error ? error : new Error('获取上传策略失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 重试上传
|
|
|
+ const handleRetry = (file: UploadFile) => {
|
|
|
+ if (file.originFileObj) {
|
|
|
+ customRequest({
|
|
|
+ file,
|
|
|
+ onSuccess: () => {},
|
|
|
+ onError: () => {}
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 自定义上传逻辑
|
|
|
+ const customRequest = async (options: {
|
|
|
+ file: UploadFile,
|
|
|
+ onSuccess: () => void,
|
|
|
+ onError: (error: Error) => void
|
|
|
+ }) => {
|
|
|
+ const { file, onSuccess, onError } = options;
|
|
|
+ const uid = file.uid;
|
|
|
+ const originFile = file.originFileObj as File;
|
|
|
+
|
|
|
+ // 添加到文件列表
|
|
|
+ setFileList(prev => [
|
|
|
+ ...prev.filter(item => item.uid !== uid),
|
|
|
+ {
|
|
|
+ uid,
|
|
|
+ name: file.name,
|
|
|
+ size: file.size,
|
|
|
+ type: file.type,
|
|
|
+ status: 'uploading' as UploadFileStatus,
|
|
|
+ percent: 0,
|
|
|
+ }
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 添加到上传中集合
|
|
|
+ setUploadingFiles(prev => new Set(prev).add(uid));
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 获取上传策略
|
|
|
+ handleProgress(uid, 5); // 标记为获取策略中
|
|
|
+ const policyResponse = await getUploadPolicy(originFile);
|
|
|
+ const { uploadPolicy } = policyResponse;
|
|
|
+
|
|
|
+ // 创建表单数据
|
|
|
+ const formData = new FormData();
|
|
|
+ formData.append('key', uploadPolicy.key);
|
|
|
+ formData.append('x-amz-algorithm', uploadPolicy['x-amz-algorithm']);
|
|
|
+ formData.append('x-amz-credential', uploadPolicy['x-amz-credential']);
|
|
|
+ formData.append('x-amz-date', uploadPolicy['x-amz-date']);
|
|
|
+ formData.append('policy', uploadPolicy.policy);
|
|
|
+ formData.append('x-amz-signature', uploadPolicy['x-amz-signature']);
|
|
|
+ if (uploadPolicy['x-amz-security-token']) {
|
|
|
+ formData.append('x-amz-security-token', uploadPolicy['x-amz-security-token']);
|
|
|
+ }
|
|
|
+ formData.append('file', originFile);
|
|
|
+
|
|
|
+ // 创建XMLHttpRequest对象上传文件
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
+ xhr.open('POST', uploadPolicy.host, true);
|
|
|
+
|
|
|
+ // 监听进度事件
|
|
|
+ xhr.upload.addEventListener('progress', (e) => {
|
|
|
+ if (e.lengthComputable) {
|
|
|
+ const percent = Math.round((e.loaded / e.total) * 100);
|
|
|
+ // 加上之前的5%作为获取策略的进度
|
|
|
+ const adjustedPercent = 5 + percent * 0.95;
|
|
|
+ handleProgress(uid, adjustedPercent);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听完成事件
|
|
|
+ xhr.addEventListener('load', () => {
|
|
|
+ if (xhr.status >= 200 && xhr.status < 300) {
|
|
|
+ handleComplete(uid, {
|
|
|
+ fileKey: uploadPolicy.key,
|
|
|
+ fileUrl: `${uploadPolicy.host}/${uploadPolicy.key}`
|
|
|
+ }, originFile);
|
|
|
+ onSuccess();
|
|
|
+ } else {
|
|
|
+ throw new Error(`上传失败,状态码: ${xhr.status}`);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听错误事件
|
|
|
+ xhr.addEventListener('error', () => {
|
|
|
+ throw new Error('网络错误,上传失败');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听中断事件
|
|
|
+ xhr.addEventListener('abort', () => {
|
|
|
+ throw new Error('上传已取消');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 发送请求
|
|
|
+ xhr.send(formData);
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ handleError(uid, error instanceof Error ? error : new Error('未知错误'), originFile);
|
|
|
+ onError(error instanceof Error ? error : new Error('未知错误'));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理文件移除
|
|
|
+ const handleRemove = (uid: string) => {
|
|
|
+ setFileList(prev => prev.filter(item => item.uid !== uid));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 验证文件大小
|
|
|
+ const beforeUpload = (file: File) => {
|
|
|
+ const fileSizeMB = file.size / (1024 * 1024);
|
|
|
+ if (fileSizeMB > maxSize!) {
|
|
|
+ message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染上传状态
|
|
|
+ const renderUploadStatus = (item: UploadFile) => {
|
|
|
+ switch (item.status) {
|
|
|
+ case 'uploading':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <SyncOutlined spin size={12} />
|
|
|
+ <span>{Math.round(item.percent || 0)}%</span>
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ case 'done':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
|
|
|
+ <Tag color="success">上传成功</Tag>
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ case 'error':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
|
|
|
+ <Tag color="error">{item.error || '上传失败'}</Tag>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<ReloadOutlined size={12} />}
|
|
|
+ onClick={() => handleRetry(item)}
|
|
|
+ />
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="aliyun-oss-uploader">
|
|
|
+ <Upload.Dragger
|
|
|
+ name="files"
|
|
|
+ accept={accept}
|
|
|
+ multiple={multiple}
|
|
|
+ customRequest={customRequest}
|
|
|
+ beforeUpload={beforeUpload}
|
|
|
+ showUploadList={false}
|
|
|
+ disabled={uploadingFiles.size > 0 && !multiple}
|
|
|
+ >
|
|
|
+ <div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-md transition-all hover:border-primary">
|
|
|
+ <UploadOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
|
|
+ <Typography.Text className="mt-2">{buttonText}</Typography.Text>
|
|
|
+ <Typography.Text type="secondary" className="mt-1">
|
|
|
+ {tipText}
|
|
|
+ </Typography.Text>
|
|
|
+ </div>
|
|
|
+ </Upload.Dragger>
|
|
|
+
|
|
|
+ {/* 上传进度列表 */}
|
|
|
+ {fileList.length > 0 && (
|
|
|
+ <div className="mt-4 space-y-3">
|
|
|
+ {fileList.map(item => (
|
|
|
+ <div key={item.uid} className="flex items-center p-3 border rounded-md">
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="flex justify-between items-center mb-1">
|
|
|
+ <Typography.Text ellipsis className="max-w-xs">
|
|
|
+ {item.name}
|
|
|
+ </Typography.Text>
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ {renderUploadStatus(item)}
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<CloseOutlined />}
|
|
|
+ onClick={() => handleRemove(item.uid)}
|
|
|
+ disabled={item.status === 'uploading'}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {item.status === 'uploading' && (
|
|
|
+ <Progress
|
|
|
+ percent={item.percent}
|
|
|
+ size="small"
|
|
|
+ status={item.percent === 100 ? 'success' : undefined}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default AliyunOSSUploader;
|