feat: 修改
Some checks failed
Dev Deploy / Explore-Gitea-Actions (push) Failing after 14s

This commit is contained in:
jinyaqiu 2025-07-07 14:58:49 +08:00
parent 11b0c051d1
commit 0c98b399b9
10 changed files with 29 additions and 1048 deletions

View File

@ -24,6 +24,7 @@ export default function AskScreen() {
const [userMessages, setUserMessages] = useState<Message[]>([]);
const createNewConversation = useCallback(async () => {
// TODO 用户未输入时,显示提示信息
setUserMessages([{
content: {
text: "请输入您的问题,寻找,请稍等..."

View File

@ -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>

View File

@ -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',
},
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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',
},
});

View File

@ -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
View 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
View 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!"
}
}

View File

@ -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
},
};