diff --git a/app.json b/app.json index 426908f..e201da3 100644 --- a/app.json +++ b/app.json @@ -12,7 +12,7 @@ "supportsTablet": true, "infoPlist": { "NSPhotoLibraryUsageDescription": "允许访问照片库,以便模型使用您照片库中的素材进行视频创作”(例如:上传您参加音乐节的现场图,生成一个音乐节体验Vlog", - "NSPhotoLibraryAddUsageDescription": "需要保存图片到相册", + "NSPhotoLibraryAddUsageDescription": "App需要访问相册来保存图片", "NSLocationWhenInUseUsageDescription": "允许获取位置信息,以便模型使用您的位置信息进行个性化创作”(例如:上传您去欧洲旅游的位置信息,结合在当地拍摄的照片,生成一个欧洲旅行攻略Vlog)", "ITSAppUsesNonExemptEncryption": false, "UIBackgroundModes": [ diff --git a/components/ask/aiChat.tsx b/components/ask/aiChat.tsx index 5ecd81b..de470a9 100644 --- a/components/ask/aiChat.tsx +++ b/components/ask/aiChat.tsx @@ -1,4 +1,6 @@ +import CancelSvg from '@/assets/icons/svg/cancel.svg'; import ChatSvg from "@/assets/icons/svg/chat.svg"; +import DownloadSvg from '@/assets/icons/svg/download.svg'; import MoreSvg from "@/assets/icons/svg/more.svg"; import { Message, Video } from "@/types/ask"; import { MaterialItem } from "@/types/personal-info"; @@ -12,11 +14,12 @@ import { TouchableOpacity, View } from 'react-native'; +import ContextMenu from "../gusture/contextMenu"; import { ThemedText } from "../ThemedText"; import SelectModel from "./selectModel"; import SingleContentModel from "./singleContentModel"; import TypewriterText from "./typewriterText"; -import { mergeArrays } from "./utils"; +import { mergeArrays, saveMediaToGallery } from "./utils"; interface RenderMessageProps { insets: { top: number }; @@ -69,16 +72,50 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible marginBottom: 8, }} > - , + label: "保存", + onPress: () => { + const imageUrl = image?.preview_file_info?.url || image.video?.preview_file_info?.url; + if (imageUrl) { + saveMediaToGallery(imageUrl); + } + }, + textStyle: { color: '#4C320C' } + }, + { + svg: , + label: "取消", + onPress: () => console.log('取消'), + textStyle: { color: 'red' } + } + ]} + menuStyle={{ + backgroundColor: 'white', + borderRadius: 8, + padding: 8, + minWidth: 150, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, }} - resizeMode="cover" - loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} - /> + > + + + ))} diff --git a/components/ask/selectModel.tsx b/components/ask/selectModel.tsx index 3b91652..d5c2dea 100644 --- a/components/ask/selectModel.tsx +++ b/components/ask/selectModel.tsx @@ -1,16 +1,14 @@ +import CancelSvg from '@/assets/icons/svg/cancel.svg'; +import DownloadSvg from '@/assets/icons/svg/download.svg'; import FolderSvg from "@/assets/icons/svg/folder.svg"; import ReturnArrow from "@/assets/icons/svg/returnArrow.svg"; import YesSvg from "@/assets/icons/svg/yes.svg"; import { TFunction } from "i18next"; import React from "react"; import { FlatList, Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native"; -import { Gesture } from 'react-native-gesture-handler'; import ContextMenu from "../gusture/contextMenu"; import { ThemedText } from "../ThemedText"; -import { mergeArrays } from "./utils"; - -import CancelSvg from '@/assets/icons/svg/cancel.svg'; -import DownloadSvg from '@/assets/icons/svg/download.svg'; +import { mergeArrays, saveMediaToGallery } from "./utils"; interface SelectModelProps { modalDetailsVisible: { visible: boolean, content: any }; @@ -22,11 +20,6 @@ interface SelectModelProps { } const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t }: SelectModelProps) => { - const longPressGesture = Gesture.LongPress().onEnd((e, success) => { - if (success) { - console.log(`Long pressed for ${e.duration} ms!`); - } - }); return ( , label: "保存", - onPress: () => console.log('保存图片'), + onPress: () => { + const imageUrl = item?.file_info?.url || item.video?.file_info?.url; + if (imageUrl) { + saveMediaToGallery(imageUrl); + } + }, textStyle: { color: '#4C320C' } }, { @@ -156,7 +154,7 @@ const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setS - + ) } diff --git a/components/ask/utils.ts b/components/ask/utils.ts index 341dca7..945e3a4 100644 --- a/components/ask/utils.ts +++ b/components/ask/utils.ts @@ -1,6 +1,9 @@ import { fetchApi } from "@/lib/server-api-util"; import { Message } from "@/types/ask"; +import * as FileSystem from 'expo-file-system'; +import * as MediaLibrary from 'expo-media-library'; import { useCallback } from "react"; +import { Alert } from 'react-native'; // 实现一个函数,从两个数组中轮流插入新数组 export const mergeArrays = (arr1: any[], arr2: any[]) => { @@ -49,4 +52,77 @@ export const getConversation = async ({ // console.error('Error in getConversation:', error); return undefined; } +}; + +// 图片 视频 保存到本地 +export const saveMediaToGallery = async (mediaUrl: string) => { + // 声明 fileUri 变量以便在 finally 块中使用 + let fileUri: string | null = null; + + try { + // 首先请求权限 + const { status } = await MediaLibrary.requestPermissionsAsync(); + + if (status !== 'granted') { + Alert.alert('需要相册权限', '请允许应用访问相册以保存媒体文件'); + return false; + } + + // 获取文件扩展名 + const fileExtension = mediaUrl.split('.').pop()?.toLowerCase() || 'mp4'; + const isVideo = ['mp4', 'mov', 'avi', 'mkv'].includes(fileExtension); + const fileName = `temp_${Date.now()}.${fileExtension}`; + fileUri = `${FileSystem.documentDirectory}${fileName}`; + + // 下载文件 + console.log('开始下载文件:', mediaUrl); + const downloadResumable = FileSystem.createDownloadResumable( + mediaUrl, + fileUri, + {}, + (downloadProgress) => { + const progress = downloadProgress.totalBytesWritten / (downloadProgress.totalBytesExpectedToWrite || 1); + console.log(`下载进度: ${Math.round(progress * 100)}%`); + } + ); + + const downloadResult = await downloadResumable.downloadAsync(); + + if (!downloadResult) { + throw new Error('下载失败: 下载被取消或发生错误'); + } + + const { uri } = downloadResult; + console.log('文件下载完成,准备保存到相册:', uri); + + // 保存到相册 + const asset = await MediaLibrary.createAssetAsync(uri); + await MediaLibrary.createAlbumAsync( + 'Memowake', + asset, + false + ); + + Alert.alert( + '保存成功', + isVideo ? '视频已保存到相册' : '图片已保存到相册' + ); + return true; + } catch (error) { + console.error('保存失败:', error); + Alert.alert( + '保存失败', + error instanceof Error ? error.message : '保存媒体文件时出错,请重试' + ); + return false; + } finally { + // 清理临时文件 + try { + if (fileUri) { + await FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(console.warn); + } + } catch (cleanupError) { + console.warn('清理临时文件时出错:', cleanupError); + } + } }; \ No newline at end of file