feat: 图片终于吐出来了

This commit is contained in:
jinyaqiu 2025-07-30 15:40:57 +08:00
parent 301c818a66
commit 56b0cff7ec
4 changed files with 410 additions and 279 deletions

View File

@ -1,16 +1,11 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import FolderSvg from "@/assets/icons/svg/folder.svg";
import MoreSvg from "@/assets/icons/svg/more.svg";
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
import YesSvg from "@/assets/icons/svg/yes.svg";
import { Message, Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { TFunction } from "i18next";
import React from 'react';
import {
FlatList,
Image,
Modal,
Pressable,
StyleSheet,
Text,
@ -18,9 +13,10 @@ import {
View
} from 'react-native';
import { ThemedText } from "../ThemedText";
import SelectModel from "./selectModel";
import SingleContentModel from "./singleContentModel";
import TypewriterText from "./typewriterText";
import { mergeArrays } from "./utils";
import VideoPlayer from "./VideoPlayer";
interface RenderMessageProps {
insets: { top: number };
@ -28,8 +24,8 @@ interface RenderMessageProps {
sessionId: string;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>;
modalDetailsVisible: boolean;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
modalDetailsVisible: { visible: boolean, content: any };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
@ -37,9 +33,6 @@ interface RenderMessageProps {
const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const isUser = item.role === 'User';
const isVideo = (data: Video | MaterialItem): data is Video => {
return 'video' in data;
};
return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
@ -92,7 +85,7 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
setModalDetailsVisible(true);
setModalDetailsVisible({ visible: true, content: item.content });
}}>
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
<View className="bg-white rounded-full p-2">
@ -116,152 +109,17 @@ const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible
))}
</View>
)} */}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
<Modal
animationType="fade"
visible={modalDetailsVisible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible(false);
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible(false)}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ overflow: 'scroll', height: "100%" }}>
<FlatList
data={mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.flatListContent}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
windowSize={10}
removeClippedSubviews={true}
renderItem={({ item }) => {
return (
<TouchableOpacity
style={detailsStyles.gridItemContainer}
key={item.id}
>
<View style={detailsStyles.gridItem}>
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages?.map((image, index) => {
if (image === item.id || image === item.video?.id) {
return index + 1
}
})}
</ThemedText>
<Image
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
<TouchableOpacity
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
onPress={() => {
setSelectedImages((prev) => {
if (prev.includes(item?.id || item?.video?.id)) {
return prev.filter((id) => id !== (item.id || item?.video?.id));
} else {
return [...prev, item.id || item.video?.id];
}
});
}}
>
{selectedImages.includes(item?.id || item?.video?.id) ? <YesSvg width={16} height={16} /> : ""}
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
if (selectedImages?.length < 0) {
setSelectedImages(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.map((item) => {
return item.id || item.video?.id
}))
}
setModalDetailsVisible(false)
}}
activeOpacity={0.8}
>
<Text style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
{/* 单个图片弹窗 */}
<SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
{/* 全部图片详情弹窗 */}
<SelectModel
modalDetailsVisible={modalDetailsVisible}
setModalDetailsVisible={setModalDetailsVisible}
insets={insets}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
t={t}
/>
</View>
</View>
);
@ -352,123 +210,3 @@ const styles = StyleSheet.create({
},
});
const detailsStyles = StyleSheet.create({
gridItemContainer: {
flex: 1, // 使用 flex 布局使项目平均分配空间
maxWidth: '33.33%', // 每行最多4个项目
aspectRatio: 1, // 保持1:1的宽高比
},
flatListContent: {
paddingBottom: 100, // 为底部按钮留出更多空间
paddingHorizontal: 8, // 添加水平内边距
paddingTop: 8,
},
headerText: {
fontSize: 20,
fontWeight: 'bold',
color: "#4C320C"
},
container: {
flex: 1,
padding: 0,
margin: 0,
backgroundColor: '#fff',
width: '100%',
height: '100%',
position: 'relative',
},
imageNumber: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
position: 'absolute',
top: 10,
left: 10,
zIndex: 10, // 确保数字显示在图片上方
},
imageNumberText: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
},
numberText: {
position: 'absolute',
top: 10,
left: 10,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(0, 122, 255, 0.9)', // 使用半透明蓝色背景
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
gridItem: {
flex: 1, // 填充父容器
overflow: 'hidden',
backgroundColor: '#f5f5f5',
borderWidth: 1,
borderColor: '#eee',
height: '100%', // 确保高度填满容器
position: 'relative',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
circleMarker: {
position: 'absolute',
top: 10,
right: 10,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: '#fff',
},
circleMarkerSelected: {
backgroundColor: '#FFB645',
},
markerText: {
fontSize: 16,
fontWeight: 'bold',
color: '#000',
},
footer: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
paddingHorizontal: 16,
zIndex: 10,
paddingVertical: 10,
},
continueButton: {
backgroundColor: '#E2793F',
borderRadius: 32,
padding: 16,
alignItems: 'center',
width: '100%',
zIndex: 10,
},
continueButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
}
});

View File

@ -34,7 +34,7 @@ function ChatComponent(
paddingTop: 0,
}), []);
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] });
return (
<SafeAreaView style={{ flex: 1 }}>

View File

@ -0,0 +1,234 @@
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 { ThemedText } from "../ThemedText";
import { mergeArrays } from "./utils";
interface SelectModelProps {
modalDetailsVisible: { visible: boolean, content: any };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
insets: { top: number };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
}
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t }: SelectModelProps) => {
return (
<Modal
animationType="fade"
visible={modalDetailsVisible.visible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible({ visible: false, content: [] });
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible({ visible: false, content: [] })}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ overflow: 'scroll', height: "100%" }}>
<FlatList
data={mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.flatListContent}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
windowSize={10}
removeClippedSubviews={true}
renderItem={({ item }) => {
return (
<TouchableOpacity
style={detailsStyles.gridItemContainer}
key={item.id}
>
<View style={detailsStyles.gridItem}>
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages?.map((image, index) => {
if (image === item.id || image === item.video?.id) {
return index + 1
}
})}
</ThemedText>
<Image
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
<TouchableOpacity
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
onPress={() => {
setSelectedImages((prev) => {
if (prev.includes(item?.id || item?.video?.id)) {
return prev.filter((id) => id !== (item.id || item?.video?.id));
} else {
return [...prev, item.id || item.video?.id];
}
});
}}
>
{selectedImages.includes(item?.id || item?.video?.id) ? <YesSvg width={16} height={16} /> : ""}
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
if (selectedImages?.length < 0) {
setSelectedImages(mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])?.map((item) => {
return item.id || item.video?.id
}))
}
setModalDetailsVisible({ visible: false, content: [] })
}}
activeOpacity={0.8}
>
<ThemedText style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</Modal>
)
}
const detailsStyles = StyleSheet.create({
gridItemContainer: {
width: '33.33%',
aspectRatio: 1,
padding: 1,
},
flatListContent: {
paddingBottom: 100,
paddingHorizontal: 8,
paddingTop: 8,
},
headerText: {
fontSize: 20,
fontWeight: 'bold',
color: "#4C320C"
},
container: {
flex: 1,
padding: 0,
margin: 0,
backgroundColor: '#fff',
width: '100%',
height: '100%',
position: 'relative',
},
imageNumber: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
position: 'absolute',
top: 10,
left: 10,
zIndex: 10,
},
imageNumberText: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
},
numberText: {
position: 'absolute',
top: 10,
left: 10,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(0, 122, 255, 0.9)',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
gridItem: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
image: {
width: '100%',
height: '100%',
},
circleMarker: {
position: 'absolute',
top: 10,
right: 10,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
},
circleMarkerSelected: {
backgroundColor: '#FFB645',
},
markerText: {
fontSize: 16,
fontWeight: 'bold',
color: '#000',
},
footer: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
paddingHorizontal: 16,
zIndex: 10,
paddingVertical: 10,
},
continueButton: {
backgroundColor: '#E2793F',
borderRadius: 32,
padding: 16,
alignItems: 'center',
width: '100%',
zIndex: 10,
},
continueButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
}
});
export default SelectModel

View File

@ -0,0 +1,159 @@
import { Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native";
import VideoPlayer from "./VideoPlayer";
interface SingleContentModelProps {
modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
}
const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => {
const isVideo = (data: Video | MaterialItem): data is Video => {
return 'video' in data;
};
return (
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
imageGridContainer: {
flexDirection: 'row',
flexWrap: 'nowrap',
width: '100%',
marginTop: 8,
},
video: {
width: '100%',
height: '100%',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
width: '100%',
maxHeight: '60%',
},
fullWidthImage: {
width: '100%',
height: "54%",
marginBottom: 8,
},
gridImage: {
aspectRatio: 1,
marginBottom: 8,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)', // 添加半透明黑色背景
},
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modalView: {
borderRadius: 20,
alignItems: 'center',
height: '100%',
width: "100%",
justifyContent: 'center',
alignSelf: 'center',
},
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
userAvatar: {
width: 30,
height: 30,
borderRadius: 15,
},
messageList: {
padding: 16,
},
messageBubble: {
paddingHorizontal: 16,
paddingVertical: 12,
fontWeight: "600"
},
userBubble: {
alignSelf: 'flex-end',
backgroundColor: '#FFB645',
marginLeft: '20%',
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
marginRight: '20%',
borderWidth: 1,
borderColor: '#e5e5ea',
},
userText: {
color: '#4C320C',
fontSize: 16,
},
aiText: {
color: '#000',
fontSize: 16,
},
});
export default SingleContentModel