This commit is contained in:
parent
11b0c051d1
commit
0c98b399b9
@ -24,6 +24,7 @@ export default function AskScreen() {
|
||||
const [userMessages, setUserMessages] = useState<Message[]>([]);
|
||||
|
||||
const createNewConversation = useCallback(async () => {
|
||||
// TODO 用户未输入时,显示提示信息
|
||||
setUserMessages([{
|
||||
content: {
|
||||
text: "请输入您的问题,寻找,请稍等..."
|
||||
|
||||
@ -1,24 +1,26 @@
|
||||
import IP from "@/assets/icons/svg/ip.svg";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from 'react-native';
|
||||
|
||||
export default function AskHello() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white overflow-auto w-full">
|
||||
{/* 内容区域 IP与介绍文本*/}
|
||||
<View className="items-center flex-1">
|
||||
<ThemedText className="text-3xl font-bold text-center">
|
||||
Hi,
|
||||
{t('ask.hi', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
I'm MeMo!
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center"><IP /></View>
|
||||
|
||||
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
|
||||
Ready to wake up your memories?
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
Just ask MeMo, let me bring them back to life!
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
</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 enAsk from './locales/en/ask.json';
|
||||
import enCommon from './locales/en/common.json';
|
||||
import enExample from './locales/en/example.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 enUpload from './locales/en/upload.json';
|
||||
import zhAdmin from './locales/zh/admin.json';
|
||||
import zhAsk from './locales/zh/ask.json';
|
||||
import zhCommon from './locales/zh/common.json';
|
||||
import zhExample from './locales/zh/example.json';
|
||||
import zhFairclip from './locales/zh/fairclip.json';
|
||||
@ -26,7 +28,8 @@ const translations = {
|
||||
landing: enLanding,
|
||||
login: enLogin,
|
||||
personal: enPersonal,
|
||||
upload: enUpload
|
||||
upload: enUpload,
|
||||
ask: enAsk
|
||||
},
|
||||
zh: {
|
||||
admin: zhAdmin,
|
||||
@ -36,7 +39,8 @@ const translations = {
|
||||
landing: zhLanding,
|
||||
login: zhLogin,
|
||||
personal: zhPersonal,
|
||||
upload: zhUpload
|
||||
upload: zhUpload,
|
||||
ask: zhAsk
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user