2026-06-13 12:44:48 +08:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
|
|
|
|
|
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
|
|
|
|
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
|
|
|
|
import { useLanguageStore } from '../store/languageStore';
|
|
|
|
|
|
|
|
|
|
interface FileUploadProps {
|
|
|
|
|
value?: string[];
|
|
|
|
|
onChange?: (urls: string[]) => void;
|
|
|
|
|
maxCount?: number;
|
|
|
|
|
accept?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 支持的图片格式
|
|
|
|
|
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
|
|
|
|
const officeFormats = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
|
|
|
|
const isImage = (url: string) => {
|
|
|
|
|
const ext = url.split('.').pop()?.toLowerCase();
|
|
|
|
|
return imageFormats.includes(ext || '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isOfficeFile = (url: string) => {
|
|
|
|
|
const ext = url.split('.').pop()?.toLowerCase();
|
|
|
|
|
return officeFormats.includes(ext || '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getOfficePreviewUrl = (url: string) => {
|
|
|
|
|
// 使用微软的Office 365在线预览服务
|
|
|
|
|
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FileUpload: React.FC<FileUploadProps> = ({
|
|
|
|
|
value = [],
|
|
|
|
|
onChange,
|
|
|
|
|
maxCount = 9,
|
|
|
|
|
accept = 'image/*'
|
|
|
|
|
}) => {
|
|
|
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
|
|
|
const [previewImage, setPreviewImage] = useState('');
|
|
|
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const { t, currentLanguage } = useLanguageStore();
|
|
|
|
|
|
|
|
|
|
// 当 value 变化时,更新 fileList
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// 只有当 value 是数组时才更新 fileList
|
|
|
|
|
// 这样可以避免在上传过程中被重置
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
const newFileList = value.map((url, index) => ({
|
|
|
|
|
uid: `-${index}`,
|
|
|
|
|
name: url.split('/').pop() || `file-${index}`,
|
|
|
|
|
status: 'done',
|
|
|
|
|
url,
|
|
|
|
|
thumbUrl: isImage(url) ? url : undefined
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setFileList(newFileList);
|
|
|
|
|
}
|
|
|
|
|
}, [value]);
|
|
|
|
|
|
|
|
|
|
const handlePreview = async (file: UploadFile) => {
|
|
|
|
|
const url = file.url || '';
|
|
|
|
|
if (isImage(url)) {
|
|
|
|
|
setPreviewImage(url);
|
|
|
|
|
setPreviewOpen(true);
|
|
|
|
|
} else if (isOfficeFile(url)) {
|
|
|
|
|
// Office文件,使用微软的在线预览服务
|
|
|
|
|
const previewUrl = getOfficePreviewUrl(url);
|
|
|
|
|
window.open(previewUrl, '_blank');
|
|
|
|
|
} else {
|
|
|
|
|
// 其他文件,新窗口打开
|
|
|
|
|
window.open(url, '_blank');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleChange: UploadProps['onChange'] = (info) => {
|
|
|
|
|
const { fileList } = info;
|
|
|
|
|
setFileList(fileList);
|
|
|
|
|
|
|
|
|
|
// 只有当文件状态发生变化时才调用 onChange
|
|
|
|
|
// 避免在初始化时触发无限循环
|
|
|
|
|
if (info.file.status === 'done' || info.file.status === 'removed') {
|
|
|
|
|
// 提取已上传成功的URL
|
|
|
|
|
const urls = fileList
|
|
|
|
|
.filter(file => file.status === 'done')
|
|
|
|
|
.map(file => {
|
|
|
|
|
// 处理不同格式的文件对象
|
|
|
|
|
if (file.url) {
|
|
|
|
|
return file.url;
|
|
|
|
|
} else if (file.response && file.response.url) {
|
|
|
|
|
return file.response.url;
|
|
|
|
|
} else if (file.response && typeof file.response === 'string') {
|
|
|
|
|
return file.response;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})
|
|
|
|
|
.filter(url => url); // 过滤空字符串
|
|
|
|
|
|
|
|
|
|
onChange?.(urls);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const customRequest = async (options: any) => {
|
|
|
|
|
const { file, onSuccess, onError, onProgress } = options;
|
|
|
|
|
|
|
|
|
|
setUploading(true);
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 从 localStorage 读取认证 token
|
|
|
|
|
let authHeader = '';
|
|
|
|
|
try {
|
|
|
|
|
const authStorage = localStorage.getItem('auth-storage');
|
|
|
|
|
if (authStorage) {
|
|
|
|
|
const parsed = JSON.parse(authStorage);
|
|
|
|
|
const token = parsed?.state?.token;
|
|
|
|
|
if (token) {
|
|
|
|
|
authHeader = `Bearer ${token}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 忽略解析错误
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const res = await fetch('/api/upload/single', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
|
|
|
|
headers: authHeader ? { Authorization: authHeader } : {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
onProgress({ percent: 100 });
|
|
|
|
|
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
|
|
|
|
|
onSuccess({ url: data.data.url }, file);
|
|
|
|
|
message.success(t('fileUpload.uploadSuccess'));
|
|
|
|
|
} else {
|
|
|
|
|
onError(new Error(data.error));
|
|
|
|
|
message.error(data.error || t('fileUpload.uploadFailed'));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('上传错误:', error);
|
|
|
|
|
onError(error);
|
|
|
|
|
message.error(t('fileUpload.uploadFailed'));
|
|
|
|
|
} finally {
|
|
|
|
|
setUploading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const uploadButton = (
|
|
|
|
|
<div>
|
|
|
|
|
<PlusOutlined />
|
|
|
|
|
<div style={{ marginTop: 8 }}>{t('fileUpload.upload')}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Upload
|
|
|
|
|
listType="picture-card"
|
|
|
|
|
fileList={fileList}
|
|
|
|
|
onPreview={handlePreview}
|
|
|
|
|
onChange={handleChange}
|
|
|
|
|
customRequest={customRequest}
|
|
|
|
|
accept={accept}
|
|
|
|
|
maxCount={maxCount}
|
|
|
|
|
multiple
|
|
|
|
|
>
|
|
|
|
|
{fileList.length >= maxCount ? null : uploadButton}
|
|
|
|
|
</Upload>
|
|
|
|
|
|
|
|
|
|
{/* 图片预览弹窗 */}
|
|
|
|
|
<Modal
|
|
|
|
|
open={previewOpen}
|
|
|
|
|
title={t('fileUpload.preview')}
|
|
|
|
|
footer={null}
|
|
|
|
|
onCancel={() => setPreviewOpen(false)}
|
|
|
|
|
width="80%"
|
|
|
|
|
centered
|
|
|
|
|
>
|
|
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
|
|
<Image
|
|
|
|
|
src={previewImage}
|
|
|
|
|
style={{ maxWidth: '100%', maxHeight: '80vh' }}
|
|
|
|
|
preview={false}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
{uploading && (
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
<Spin size="small" /> {t('fileUpload.uploading')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default FileUpload;
|