This commit is contained in:
parent
11b0c051d1
commit
0c98b399b9
@ -24,6 +24,7 @@ export default function AskScreen() {
|
|||||||
const [userMessages, setUserMessages] = useState<Message[]>([]);
|
const [userMessages, setUserMessages] = useState<Message[]>([]);
|
||||||
|
|
||||||
const createNewConversation = useCallback(async () => {
|
const createNewConversation = useCallback(async () => {
|
||||||
|
// TODO 用户未输入时,显示提示信息
|
||||||
setUserMessages([{
|
setUserMessages([{
|
||||||
content: {
|
content: {
|
||||||
text: "请输入您的问题,寻找,请稍等..."
|
text: "请输入您的问题,寻找,请稍等..."
|
||||||
|
|||||||
@ -1,24 +1,26 @@
|
|||||||
import IP from "@/assets/icons/svg/ip.svg";
|
import IP from "@/assets/icons/svg/ip.svg";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
|
|
||||||
export default function AskHello() {
|
export default function AskHello() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white overflow-auto w-full">
|
<View className="flex-1 bg-white overflow-auto w-full">
|
||||||
{/* 内容区域 IP与介绍文本*/}
|
{/* 内容区域 IP与介绍文本*/}
|
||||||
<View className="items-center flex-1">
|
<View className="items-center flex-1">
|
||||||
<ThemedText className="text-3xl font-bold text-center">
|
<ThemedText className="text-3xl font-bold text-center">
|
||||||
Hi,
|
{t('ask.hi', { ns: 'ask' })}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
I'm MeMo!
|
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<View className="justify-center items-center"><IP /></View>
|
<View className="justify-center items-center"><IP /></View>
|
||||||
|
|
||||||
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
|
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
|
||||||
Ready to wake up your memories?
|
{t('ask.ready', { ns: 'ask' })}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
Just ask MeMo, let me bring them back to life!
|
{t('ask.justAsk', { ns: 'ask' })}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,117 +0,0 @@
|
|||||||
import SvgIcon from "@/components/svg-icon";
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { FileStatus } from "./file-uploader";
|
|
||||||
|
|
||||||
interface FileItemProps {
|
|
||||||
fileStatus: FileStatus;
|
|
||||||
index: number;
|
|
||||||
onRemove: (file: File) => void;
|
|
||||||
formatFileSize: (bytes: number) => string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileItem({
|
|
||||||
fileStatus,
|
|
||||||
index,
|
|
||||||
onRemove,
|
|
||||||
formatFileSize,
|
|
||||||
disabled = false
|
|
||||||
}: FileItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
|
||||||
const translateY = React.useRef(new Animated.Value(10)).current;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 200,
|
|
||||||
delay: index * 50,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 200,
|
|
||||||
delay: index * 50,
|
|
||||||
useNativeDriver: true,
|
|
||||||
})
|
|
||||||
]).start();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.card,
|
|
||||||
{
|
|
||||||
opacity: fadeAnim,
|
|
||||||
transform: [{ translateY }]
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.cardBody}>
|
|
||||||
<View style={styles.fileInfo}>
|
|
||||||
<Text style={styles.fileName}>{fileStatus.file.name}</Text>
|
|
||||||
<Text style={styles.fileSize}>{formatFileSize(fileStatus.file.size)}</Text>
|
|
||||||
</View>
|
|
||||||
{!disabled && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onRemove(fileStatus.file)}
|
|
||||||
style={styles.removeButton}
|
|
||||||
>
|
|
||||||
<SvgIcon name="close" size={16} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{fileStatus.progress !== undefined && fileStatus.progress < 100 && (
|
|
||||||
<View style={styles.progressContainer}>
|
|
||||||
<View style={[styles.progressBar, { width: `${fileStatus.progress}%` }]} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardBody: {
|
|
||||||
padding: 12,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
fileInfo: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
fileName: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#333',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
fileSize: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
removeButton: {
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
progressContainer: {
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#e9ecef',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
progressBar: {
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: '#007bff',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,609 +0,0 @@
|
|||||||
import { fetchApi } from "@/lib/server-api-util";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import uuid from 'react-native-uuid';
|
|
||||||
|
|
||||||
// 导入子组件
|
|
||||||
import { ConfirmUpload } from "@/types/upload";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import MultiFileUploader from "./multi-file-uploader";
|
|
||||||
import SingleFileUploader from "./single-file-uploader";
|
|
||||||
import UploadDropzone from "./upload-dropzone";
|
|
||||||
import { extractVideoFirstFrame, getVideoDuration } from "./utils/videoUtils";
|
|
||||||
|
|
||||||
// 默认允许的文件类型
|
|
||||||
export const DEFAULT_ALLOWED_FILE_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"];
|
|
||||||
export const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
|
||||||
|
|
||||||
// 上传URL响应接口
|
|
||||||
export interface UploadUrlResponse {
|
|
||||||
upload_url: string;
|
|
||||||
file_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件状态接口
|
|
||||||
export interface FileStatus {
|
|
||||||
file: File;
|
|
||||||
id?: string;
|
|
||||||
progress: number;
|
|
||||||
error?: string;
|
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
||||||
url?: string;
|
|
||||||
thumbnailUrl?: string; // 添加缩略图URL
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileUploaderProps {
|
|
||||||
onFilesUploaded?: (files: FileStatus[]) => void;
|
|
||||||
maxFiles?: number;
|
|
||||||
allowMultiple?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
allowedFileTypes?: string[]; // 外部传入的允许文件类型
|
|
||||||
maxFileSize?: number; // 外部传入的最大文件大小
|
|
||||||
thumbnailPropsUrl?: string; // 注册后返回ai处理展示缩略图
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileUploader({
|
|
||||||
onFilesUploaded,
|
|
||||||
maxFiles = 1,
|
|
||||||
allowMultiple = false,
|
|
||||||
disabled = false,
|
|
||||||
className = "",
|
|
||||||
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
|
|
||||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
||||||
thumbnailPropsUrl = ""
|
|
||||||
}: FileUploaderProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
// 图片的最小值
|
|
||||||
const MIN_IMAGE_SIZE = 300;
|
|
||||||
|
|
||||||
// 校验图片尺寸(异步)
|
|
||||||
function validateImageDimensions(file: File): Promise<string | null> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new window.Image();
|
|
||||||
img.onload = () => {
|
|
||||||
if (img.width < MIN_IMAGE_SIZE || img.height < MIN_IMAGE_SIZE) {
|
|
||||||
resolve(`图片尺寸不能小于${MIN_IMAGE_SIZE}px,当前为${img.width}x${img.height}`);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
img.onerror = () => resolve("无法读取图片尺寸");
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 格式化文件大小
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 验证文件
|
|
||||||
const validateFile = (file: File): string | null => {
|
|
||||||
// 验证文件类型
|
|
||||||
if (!allowedFileTypes.includes(file.type)) {
|
|
||||||
const errorMsg = t('validation.file.invalidType');
|
|
||||||
// addToast({
|
|
||||||
// title: t('fileUploader.invalidFileTitle'),
|
|
||||||
// description: errorMsg,
|
|
||||||
// color: "danger",
|
|
||||||
// });
|
|
||||||
return errorMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件大小
|
|
||||||
// if (file.size > maxFileSize) {
|
|
||||||
// const errorMsg = t('validation.file.tooLarge');
|
|
||||||
// addToast({
|
|
||||||
// title: t('fileUploader.fileTooLargeTitle'),
|
|
||||||
// description: `${errorMsg} ${formatFileSize(maxFileSize)}`,
|
|
||||||
// color: "danger",
|
|
||||||
// });
|
|
||||||
// return errorMsg;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建缩略图
|
|
||||||
const createThumbnail = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 如果是图片文件,直接使用图片作为缩略图
|
|
||||||
if (file.type.startsWith('image/')) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
if (e.target && typeof e.target.result === 'string') {
|
|
||||||
resolve(e.target.result);
|
|
||||||
} else {
|
|
||||||
reject(new Error('图片加载失败'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
reject(new Error('图片读取失败'));
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是视频文件,创建视频缩略图
|
|
||||||
const videoUrl = URL.createObjectURL(file);
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.src = videoUrl;
|
|
||||||
video.crossOrigin = 'anonymous';
|
|
||||||
video.muted = true;
|
|
||||||
video.preload = 'metadata';
|
|
||||||
|
|
||||||
video.onloadeddata = () => {
|
|
||||||
try {
|
|
||||||
// 设置视频时间到第一帧
|
|
||||||
video.currentTime = 0.1;
|
|
||||||
} catch (e) {
|
|
||||||
URL.revokeObjectURL(videoUrl);
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.onseeked = () => {
|
|
||||||
try {
|
|
||||||
// 创建canvas并绘制视频帧
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
||||||
const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.7);
|
|
||||||
URL.revokeObjectURL(videoUrl);
|
|
||||||
resolve(thumbnailUrl);
|
|
||||||
} else {
|
|
||||||
reject(new Error('无法创建canvas上下文'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
URL.revokeObjectURL(videoUrl);
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.onerror = () => {
|
|
||||||
URL.revokeObjectURL(videoUrl);
|
|
||||||
reject(new Error('视频加载失败'));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// 压缩图片函数
|
|
||||||
async function compressImageToFile(
|
|
||||||
file: File,
|
|
||||||
maxWidth = 600,
|
|
||||||
quality = 0.7,
|
|
||||||
outputType = 'image/png'
|
|
||||||
): Promise<File> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
|
||||||
const target = event.target as FileReader;
|
|
||||||
if (!target || !target.result) {
|
|
||||||
reject(new Error('Failed to read file'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
// 计算压缩尺寸
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
let width = img.width;
|
|
||||||
let height = img.height;
|
|
||||||
|
|
||||||
if (width > maxWidth) {
|
|
||||||
height = Math.round((height * maxWidth) / width);
|
|
||||||
width = maxWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
// 绘制压缩图片
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('Could not get 2D context from canvas');
|
|
||||||
}
|
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
|
||||||
|
|
||||||
// 转为 Blob 并生成 File 对象
|
|
||||||
canvas.toBlob(
|
|
||||||
(blob: Blob | null) => {
|
|
||||||
if (!blob) {
|
|
||||||
reject(new Error('Failed to create blob from canvas'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let file_name = uuid.v4() + ".png"
|
|
||||||
const compressedFile = new File([blob], file_name, {
|
|
||||||
type: outputType,
|
|
||||||
lastModified: Date.now()
|
|
||||||
});
|
|
||||||
resolve(compressedFile);
|
|
||||||
},
|
|
||||||
outputType,
|
|
||||||
quality
|
|
||||||
);
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = target.result as string;
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 新增素材
|
|
||||||
const addMaterial = async (file: string, compressFile: string) => {
|
|
||||||
await fetchApi('/material', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify([{
|
|
||||||
"file_id": file,
|
|
||||||
"preview_file_id": compressFile
|
|
||||||
}])
|
|
||||||
}).catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传单个文件
|
|
||||||
const uploadFile = async (fileStatus: FileStatus, fileIndex: number, compressedFile: File) => {
|
|
||||||
// 创建新的文件状态对象,而不是修改原来的数组
|
|
||||||
// 这样可以避免多个异步操作对同一个数组的并发修改
|
|
||||||
let currentFileStatus: FileStatus = {
|
|
||||||
...fileStatus,
|
|
||||||
status: 'uploading' as const,
|
|
||||||
progress: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用函数更新文件状态,确保每次更新都是原子的
|
|
||||||
const updateFileStatus = (updates: Partial<FileStatus>) => {
|
|
||||||
currentFileStatus = { ...currentFileStatus, ...updates };
|
|
||||||
setFiles(prevFiles => {
|
|
||||||
const newFiles = [...prevFiles];
|
|
||||||
newFiles[fileIndex] = currentFileStatus;
|
|
||||||
return newFiles;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化上传状态
|
|
||||||
updateFileStatus({ status: 'uploading', progress: 0 });
|
|
||||||
|
|
||||||
// 添加小延迟,确保初始状态能被看到
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取视频时长
|
|
||||||
let metadata = {};
|
|
||||||
if (fileStatus.file.type.startsWith('video/')) {
|
|
||||||
metadata = {
|
|
||||||
duration: (await getVideoDuration(fileStatus.file)).toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取上传URL
|
|
||||||
updateFileStatus({ progress: 10 });
|
|
||||||
const uploadUrlData = await getUploadUrl(fileStatus.file, metadata);
|
|
||||||
const compressedFileData = await getUploadUrl(compressedFile, {})
|
|
||||||
|
|
||||||
// 确保正确更新文件ID
|
|
||||||
updateFileStatus({ id: uploadUrlData.file_id, progress: 20 });
|
|
||||||
|
|
||||||
// 上传文件到URL
|
|
||||||
await uploadFileToUrl(
|
|
||||||
compressedFile,
|
|
||||||
compressedFileData.upload_url,
|
|
||||||
(progress) => {
|
|
||||||
// 将实际进度映射到 60%-90% 区间
|
|
||||||
const mappedProgress = 60 + (progress * 0.3);
|
|
||||||
updateFileStatus({ progress: Math.round(mappedProgress) });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await uploadFileToUrl(
|
|
||||||
fileStatus.file,
|
|
||||||
uploadUrlData.upload_url,
|
|
||||||
(progress) => {
|
|
||||||
// 将实际进度映射到 60%-90% 区间
|
|
||||||
const mappedProgress = 60 + (progress * 0.3);
|
|
||||||
updateFileStatus({ progress: Math.round(mappedProgress) });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 向服务端confirm上传
|
|
||||||
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_id: uploadUrlData.file_id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_id: compressedFileData.file_id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// 等待一些时间再标记为成功
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// 更新状态为成功
|
|
||||||
updateFileStatus({ status: 'success', progress: 100, id: uploadUrlData.file_id });
|
|
||||||
|
|
||||||
await addMaterial(uploadUrlData.file_id, compressedFileData.file_id)
|
|
||||||
// 打印最终状态以进行调试
|
|
||||||
// console.log('最终文件状态:', currentFileStatus);
|
|
||||||
// 调用回调函数
|
|
||||||
if (onFilesUploaded) {
|
|
||||||
// 使用当前文件状态创建一个新的数组传递给回调函数
|
|
||||||
const updatedFiles = [...files];
|
|
||||||
updatedFiles[fileIndex] = {
|
|
||||||
...currentFileStatus,
|
|
||||||
id: uploadUrlData.file_id, // 确保 ID 正确传递
|
|
||||||
};
|
|
||||||
|
|
||||||
// 延迟调用回调函数,确保状态已更新
|
|
||||||
setTimeout(() => {
|
|
||||||
// console.log('传递给回调的文件:', updatedFiles);
|
|
||||||
onFilesUploaded(updatedFiles);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
// 更新状态为错误
|
|
||||||
updateFileStatus({
|
|
||||||
status: 'error',
|
|
||||||
error: error instanceof Error ? error.message : '上传失败'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理文件选择
|
|
||||||
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
|
||||||
if (!selectedFiles) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
const newFiles: FileStatus[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
|
||||||
const file = selectedFiles[i];
|
|
||||||
const error = validateFile(file);
|
|
||||||
let dimensionError: string | null = null;
|
|
||||||
if (!error && file.type.startsWith('image/')) {
|
|
||||||
dimensionError = await validateImageDimensions(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
let thumbnailUrl = '';
|
|
||||||
// 只在文件验证通过且尺寸合格时创建缩略图
|
|
||||||
if (!error && !dimensionError) {
|
|
||||||
try {
|
|
||||||
// 创建缩略图,支持图片和视频
|
|
||||||
thumbnailUrl = await createThumbnail(file);
|
|
||||||
newFiles.push({
|
|
||||||
file,
|
|
||||||
progress: 0,
|
|
||||||
error: error ?? undefined,
|
|
||||||
status: error ? 'error' : 'pending',
|
|
||||||
thumbnailUrl
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('缩略图创建失败:', e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 添加警告
|
|
||||||
// addToast({
|
|
||||||
// title: t('fileUploader.fileTooSmallTitle'),
|
|
||||||
// description: t('fileUploader.fileTooSmall'),
|
|
||||||
// color: "warning",
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新文件列表
|
|
||||||
setFiles(prev => {
|
|
||||||
// 单文件模式下且已有文件时,替换现有文件
|
|
||||||
if (maxFiles === 1 && prev.length > 0 && newFiles.length > 0) {
|
|
||||||
return [newFiles[0]]; // 只保留新选择的第一个文件
|
|
||||||
} else {
|
|
||||||
// 多文件模式,合并并限制数量
|
|
||||||
const combinedFiles = [...prev, ...newFiles];
|
|
||||||
return combinedFiles.length > maxFiles
|
|
||||||
? combinedFiles.slice(0, maxFiles)
|
|
||||||
: combinedFiles;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 在状态更新后,使用 useEffect 来处理上传
|
|
||||||
setIsUploading(false);
|
|
||||||
}, [maxFiles]);
|
|
||||||
|
|
||||||
|
|
||||||
// 获取上传URL
|
|
||||||
const getUploadUrl = async (file: File, metadata: { [key: string]: string }): Promise<UploadUrlResponse> => {
|
|
||||||
const body = {
|
|
||||||
filename: file.name,
|
|
||||||
content_type: file.type,
|
|
||||||
file_size: file.size,
|
|
||||||
metadata
|
|
||||||
}
|
|
||||||
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 上传文件到URL
|
|
||||||
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
xhr.open('PUT', uploadUrl);
|
|
||||||
xhr.setRequestHeader('Content-Type', file.type);
|
|
||||||
|
|
||||||
// 进度监听
|
|
||||||
xhr.upload.onprogress = (event) => {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
const progress = Math.round((event.loaded / event.total) * 100);
|
|
||||||
onProgress(progress);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
reject(new Error('Network error during upload'));
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除文件
|
|
||||||
const removeFile = (fileToRemove: File) => {
|
|
||||||
setFiles(prev => prev.filter(fileStatus => fileStatus.file !== fileToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清除所有文件
|
|
||||||
const clearFiles = () => {
|
|
||||||
setFiles([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开文件选择器
|
|
||||||
const openFileSelector = () => {
|
|
||||||
if (!disabled) {
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.click();
|
|
||||||
} else {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = allowedFileTypes.join(',');
|
|
||||||
input.multiple = allowMultiple;
|
|
||||||
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
|
|
||||||
input.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用 useEffect 监听 files 变化,处理待上传的文件
|
|
||||||
useEffect(() => {
|
|
||||||
const processFiles = async () => {
|
|
||||||
// Only process files that are in 'pending' status
|
|
||||||
const pendingFiles = files
|
|
||||||
.filter(f => f.status === 'pending')
|
|
||||||
.filter((fileStatus, index, self) =>
|
|
||||||
index === self.findIndex(f => f.file === fileStatus.file)
|
|
||||||
); // Remove duplicates
|
|
||||||
|
|
||||||
if (pendingFiles.length === 0) return;
|
|
||||||
|
|
||||||
// Create a new array with updated status to prevent infinite loops
|
|
||||||
setFiles(prevFiles =>
|
|
||||||
prevFiles.map(file =>
|
|
||||||
pendingFiles.some(pf => pf.file === file.file)
|
|
||||||
? { ...file, status: 'uploading' as const }
|
|
||||||
: file
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process each file sequentially to avoid race conditions
|
|
||||||
for (const fileStatus of pendingFiles) {
|
|
||||||
try {
|
|
||||||
const fileIndex = files.findIndex(f => f.file === fileStatus.file);
|
|
||||||
if (fileIndex === -1) continue;
|
|
||||||
|
|
||||||
let compressedFile: File;
|
|
||||||
if (fileStatus.file.type?.includes('video')) {
|
|
||||||
const frameFile = await extractVideoFirstFrame(fileStatus.file);
|
|
||||||
compressedFile = await compressImageToFile(frameFile, 600, 0.7);
|
|
||||||
} else {
|
|
||||||
compressedFile = fileStatus.file;
|
|
||||||
}
|
|
||||||
|
|
||||||
await uploadFile(
|
|
||||||
{ ...fileStatus, status: 'uploading' as const },
|
|
||||||
fileIndex,
|
|
||||||
compressedFile
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing file:', error);
|
|
||||||
setFiles(prevFiles =>
|
|
||||||
prevFiles.map(f =>
|
|
||||||
f.file === fileStatus.file
|
|
||||||
? {
|
|
||||||
...f,
|
|
||||||
status: 'error' as const,
|
|
||||||
error: error instanceof Error ? error.message : '处理文件失败'
|
|
||||||
}
|
|
||||||
: f
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processFiles();
|
|
||||||
}, [files]); // Only run when files array changes
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className={className}
|
|
||||||
aria-label="文件上传"
|
|
||||||
>
|
|
||||||
{/* 隐藏的文件输入 */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={allowedFileTypes.join(',')}
|
|
||||||
multiple={allowMultiple}
|
|
||||||
onChange={(e) => handleFileSelect(e.target.files)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 文件上传区域 - 始终可见 */}
|
|
||||||
<View className="space-y-6 h-full">
|
|
||||||
{/* 上传区域 */}
|
|
||||||
{maxFiles === 1 && files.length === 1 ? (
|
|
||||||
/* 单文件模式且已有文件 - 不添加外层的onClick事件 */
|
|
||||||
<SingleFileUploader
|
|
||||||
file={files[0]}
|
|
||||||
onReplace={openFileSelector}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
) : thumbnailPropsUrl ? <img
|
|
||||||
src={thumbnailPropsUrl}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/> : (
|
|
||||||
/* 多文件模式或无文件 - 只在组件上添加一个onClick事件 */
|
|
||||||
<UploadDropzone
|
|
||||||
onClick={openFileSelector}
|
|
||||||
disabled={disabled}
|
|
||||||
allowedFileTypes={allowedFileTypes}
|
|
||||||
// maxFileSize={maxFileSize}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 文件列表区域 - 仅在多文件模式下显示 */}
|
|
||||||
{maxFiles !== 1 && files.length > 0 && (
|
|
||||||
<MultiFileUploader
|
|
||||||
files={files}
|
|
||||||
onRemove={removeFile}
|
|
||||||
onClearAll={clearFiles}
|
|
||||||
formatFileSize={formatFileSize}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import useWindowSize from "@/hooks/useWindowSize";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { ThemedText } from "../ThemedText";
|
|
||||||
import FileItemPhone from "./file-item-phone";
|
|
||||||
import { FileStatus } from "./file-uploader";
|
|
||||||
|
|
||||||
interface MultiFileUploaderProps {
|
|
||||||
files: FileStatus[];
|
|
||||||
onRemove: (file: File) => void;
|
|
||||||
onClearAll: () => void;
|
|
||||||
formatFileSize: (bytes: number) => string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 多文件上传组件 - 用于显示文件列表和管理多个文件
|
|
||||||
*/
|
|
||||||
export default function MultiFileUploader({
|
|
||||||
files,
|
|
||||||
onRemove,
|
|
||||||
onClearAll,
|
|
||||||
formatFileSize,
|
|
||||||
disabled = false
|
|
||||||
}: MultiFileUploaderProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// 获取当前屏幕尺寸
|
|
||||||
const { isMobile } = useWindowSize();
|
|
||||||
return (
|
|
||||||
<View className="space-y-4">
|
|
||||||
<View className="flex justify-between items-center">
|
|
||||||
<ThemedText className="text-md font-medium">
|
|
||||||
{t('fileUploader.uploadedFiles')}
|
|
||||||
</ThemedText>
|
|
||||||
<View className="p-6 w-full">
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`w-full bg-white rounded-full p-4 items-center `}
|
|
||||||
onPress={onClearAll}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<ThemedText className="text-textTertiary text-lg font-semibold">
|
|
||||||
{t('fileUploader.clearAll')}
|
|
||||||
</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="grid grid-cols-3 xl:grid-cols-4 gap-4 sm:max-h-[15rem] overflow-y-auto w-full">
|
|
||||||
{files.map((fileStatus, index) => (
|
|
||||||
(
|
|
||||||
<FileItemPhone
|
|
||||||
key={index}
|
|
||||||
fileStatus={fileStatus}
|
|
||||||
index={index}
|
|
||||||
onRemove={onRemove}
|
|
||||||
formatFileSize={formatFileSize}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import Icon from 'react-native-vector-icons/MaterialIcons';
|
|
||||||
import { FileStatus } from './file-uploader';
|
|
||||||
|
|
||||||
interface SingleFileUploaderProps {
|
|
||||||
file: FileStatus;
|
|
||||||
onReplace: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
formatFileSize?: (bytes: number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SingleFileUploader({
|
|
||||||
file,
|
|
||||||
onReplace,
|
|
||||||
disabled = false,
|
|
||||||
formatFileSize = (bytes) => `${bytes} B`
|
|
||||||
}: SingleFileUploaderProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* 缩略图容器 */}
|
|
||||||
<View style={styles.thumbnailContainer}>
|
|
||||||
{file.thumbnailUrl ? (
|
|
||||||
<>
|
|
||||||
<Image
|
|
||||||
source={{ uri: file.thumbnailUrl }}
|
|
||||||
style={styles.thumbnailImage}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
|
||||||
{/* 错误信息显示 */}
|
|
||||||
{file.error && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<Text style={styles.errorText}>
|
|
||||||
{file.error}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<View style={styles.placeholderContainer}>
|
|
||||||
<Icon name="videocam" size={40} color="#9CA3AF" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 显示替换按钮 */}
|
|
||||||
{file.thumbnailUrl && !disabled && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.replaceButton}
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onReplace();
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<View style={styles.replaceButtonContent}>
|
|
||||||
<Icon name="upload" size={24} color="white" />
|
|
||||||
<Text style={styles.replaceButtonText}>
|
|
||||||
{t('common.replace')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 文件信息 */}
|
|
||||||
<View style={styles.fileInfo}>
|
|
||||||
<View style={styles.fileInfoText}>
|
|
||||||
<Text style={styles.fileName} numberOfLines={1}>
|
|
||||||
{file.file.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.fileSize}>
|
|
||||||
{formatFileSize(file.file.size)} • {file.status}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{file.status === 'uploading' && (
|
|
||||||
<View style={styles.progressContainer}>
|
|
||||||
<View style={[styles.progressBar, { width: `${file.progress}%` }]} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
thumbnailContainer: {
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
backgroundColor: '#F3F4F6',
|
|
||||||
borderRadius: 6,
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
thumbnailImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
errorContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: 'rgba(220, 38, 38, 0.8)',
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
placeholderContainer: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
replaceButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
replaceButtonContent: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
replaceButtonText: {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
fileInfo: {
|
|
||||||
marginTop: 8,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
fileInfoText: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
fileName: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#111827',
|
|
||||||
},
|
|
||||||
fileSize: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#6B7280',
|
|
||||||
},
|
|
||||||
progressContainer: {
|
|
||||||
width: 96,
|
|
||||||
height: 8,
|
|
||||||
backgroundColor: '#E5E7EB',
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 8,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
progressBar: {
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: '#3B82F6',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import SvgIcon from "@/components/svg-icon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { DEFAULT_ALLOWED_FILE_TYPES, DEFAULT_MAX_FILE_SIZE } from "./file-uploader";
|
|
||||||
|
|
||||||
interface UploadDropzoneProps {
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
allowedFileTypes?: string[];
|
|
||||||
maxFileSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传区域组件 - 用于显示文件拖放和选择区域
|
|
||||||
*/
|
|
||||||
export default function UploadDropzone({
|
|
||||||
onClick,
|
|
||||||
disabled = false,
|
|
||||||
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
|
|
||||||
maxFileSize = DEFAULT_MAX_FILE_SIZE
|
|
||||||
}: UploadDropzoneProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 格式化文件类型显示
|
|
||||||
const formatFileTypes = (types: string[]): string => {
|
|
||||||
return types.map(type => {
|
|
||||||
// 从 MIME 类型提取文件扩展名
|
|
||||||
const extensions: Record<string, string> = {
|
|
||||||
'video/mp4': 'MP4',
|
|
||||||
'video/quicktime': 'MOV',
|
|
||||||
'video/x-msvideo': 'AVI',
|
|
||||||
'video/x-matroska': 'MKV',
|
|
||||||
'image/jpeg': 'JPG',
|
|
||||||
'image/png': 'PNG',
|
|
||||||
'image/gif': 'GIF',
|
|
||||||
'image/webp': 'WEBP'
|
|
||||||
};
|
|
||||||
return extensions[type] || type.split('/')[1]?.toUpperCase() || type;
|
|
||||||
}).join(', ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化文件大小显示
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
|
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary/50'} h-full`}
|
|
||||||
onClick={disabled ? undefined : onClick}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 h-full">
|
|
||||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
|
||||||
<SvgIcon name="upload" className="h-6 w-6 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-medium text-gray-900">
|
|
||||||
{t('fileUploader.dragAndDropFiles')}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
{t('fileUploader.orClickToUpload')}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
{t('fileUploader.supportedFormats')}: {formatFileTypes(allowedFileTypes)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{t('fileUploader.maxFileSize')}: {formatFileSize(maxFileSize)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
i18n/locales/en/ask.json
Normal file
8
i18n/locales/en/ask.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ask": {
|
||||||
|
"hi": "Hi,",
|
||||||
|
"iAmMemo": "I'm Memo!",
|
||||||
|
"ready": "Ready to wake up your memories?",
|
||||||
|
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
i18n/locales/zh/ask.json
Normal file
8
i18n/locales/zh/ask.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ask": {
|
||||||
|
"hi": "Hi,",
|
||||||
|
"iAmMemo": "I'm Memo!",
|
||||||
|
"ready": "Ready to wake up your memories?",
|
||||||
|
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// 自动生成的导入文件,请勿手动修改
|
// 自动生成的导入文件,请勿手动修改
|
||||||
|
|
||||||
import enAdmin from './locales/en/admin.json';
|
import enAdmin from './locales/en/admin.json';
|
||||||
|
import enAsk from './locales/en/ask.json';
|
||||||
import enCommon from './locales/en/common.json';
|
import enCommon from './locales/en/common.json';
|
||||||
import enExample from './locales/en/example.json';
|
import enExample from './locales/en/example.json';
|
||||||
import enFairclip from './locales/en/fairclip.json';
|
import enFairclip from './locales/en/fairclip.json';
|
||||||
@ -9,6 +10,7 @@ import enLogin from './locales/en/login.json';
|
|||||||
import enPersonal from './locales/en/personal.json';
|
import enPersonal from './locales/en/personal.json';
|
||||||
import enUpload from './locales/en/upload.json';
|
import enUpload from './locales/en/upload.json';
|
||||||
import zhAdmin from './locales/zh/admin.json';
|
import zhAdmin from './locales/zh/admin.json';
|
||||||
|
import zhAsk from './locales/zh/ask.json';
|
||||||
import zhCommon from './locales/zh/common.json';
|
import zhCommon from './locales/zh/common.json';
|
||||||
import zhExample from './locales/zh/example.json';
|
import zhExample from './locales/zh/example.json';
|
||||||
import zhFairclip from './locales/zh/fairclip.json';
|
import zhFairclip from './locales/zh/fairclip.json';
|
||||||
@ -26,7 +28,8 @@ const translations = {
|
|||||||
landing: enLanding,
|
landing: enLanding,
|
||||||
login: enLogin,
|
login: enLogin,
|
||||||
personal: enPersonal,
|
personal: enPersonal,
|
||||||
upload: enUpload
|
upload: enUpload,
|
||||||
|
ask: enAsk
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
admin: zhAdmin,
|
admin: zhAdmin,
|
||||||
@ -36,7 +39,8 @@ const translations = {
|
|||||||
landing: zhLanding,
|
landing: zhLanding,
|
||||||
login: zhLogin,
|
login: zhLogin,
|
||||||
personal: zhPersonal,
|
personal: zhPersonal,
|
||||||
upload: zhUpload
|
upload: zhUpload,
|
||||||
|
ask: zhAsk
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user