Compare commits

...

7 Commits

Author SHA1 Message Date
521a4d0a51 feat: userInfo 2025-07-17 15:27:00 +08:00
006db2af07 feat: 登录交互 2025-07-17 14:05:07 +08:00
6c270302f5 feat: 消息推送 2025-07-17 11:36:42 +08:00
153838aec0 feat: 对话样式 2025-07-17 10:21:10 +08:00
125da0e660 feat: 对话详情 2025-07-16 19:51:30 +08:00
b8d00ef850 feat: navbar图标 2025-07-16 16:30:25 +08:00
1c02968071 feat: chat 交互 2025-07-16 16:10:38 +08:00
29 changed files with 927 additions and 334 deletions

View File

@ -1,14 +1,163 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { Platform } from 'react-native';
import { HapticTab } from '@/components/HapticTab';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchApi } from '@/lib/server-api-util';
import * as Notifications from 'expo-notifications';
import { Tabs } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
interface PollingData {
title: string;
id: string;
content: string;
extra: any;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
const [pollingData, setPollingData] = useState<PollingData[]>([]);
const pollingInterval = useRef<NodeJS.Timeout | number>(null);
const tokenInterval = useRef<NodeJS.Timeout | number>(null);
const isMounted = useRef(true);
const [token, setToken] = useState('');
const sendNotification = async (item: PollingData) => {
// 请求通知权限
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
alert('请先允许通知权限');
return;
}
// 调度本地通知
await Notifications.scheduleNotificationAsync({
content: {
title: item.title,
body: item.content,
data: { screen: 'ask', extra: item.extra, id: item.id },
priority: 'high', // 关键:设置 high 或 max
},
trigger: {
seconds: 2, // 延迟2秒显示
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL // 添加 type 字段
}, // 延迟2秒显示
});
};
// 监听通知点击事件
useEffect(() => {
const notificationListener = Notifications.addNotificationResponseReceivedListener(response => {
const data = response.notification.request.content.data;
console.log('通知被点击,数据:', data);
pollingData?.filter((item) => item.id !== data.id);
});
// 清理监听器
return () => {
Notifications.removeNotificationSubscription(notificationListener);
};
}, []);
// 轮询获取推送消息
const startPolling = useCallback(async (interval: number = 5000) => {
// 设置轮询
pollingInterval.current = setInterval(async () => {
if (isMounted.current) {
await getMessageData();
}
}, interval);
// 返回清理函数
return () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
};
}, []);
// 获取推送消息
const getMessageData = async () => {
try {
const response = await fetchApi<PollingData[]>("/notice/push/message", {
method: "POST"
});
setPollingData((prev) => ([...prev, ...response]));
} catch (error) {
console.error('获取轮询数据时出错:', error);
}
};
// 获取认证token
const getAuthToken = async (): Promise<string> => {
let tokenValue = '';
if (Platform.OS === 'web') {
tokenValue = localStorage.getItem('token') || '';
} else {
tokenValue = (await SecureStore.getItemAsync('token')) || '';
}
setToken(tokenValue); // 只在获取到新token时更新状态
return tokenValue;
};
useEffect(() => {
const checkAuthStatus = async () => {
try {
if (token) {
// 启动轮询
startPolling(5000);
}
} catch (error) {
console.error('获取推送消息出错:', error);
}
};
checkAuthStatus();
return () => {
// 清理函数
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
isMounted.current = false;
};
}, [token]);
// 本地推送
useEffect(() => {
pollingData?.map((item) => {
sendNotification(item)
})
}, [pollingData])
// 轮询获取token
useEffect(() => {
// 如果已经有token直接返回
if (token) {
if (tokenInterval.current) {
clearInterval(tokenInterval.current);
}
return;
}
if (!tokenInterval.current) return;
// 设置轮询
tokenInterval.current = setInterval(async () => {
if (isMounted.current) {
const currentToken = await getAuthToken();
// 如果获取到token清除定时器
if (currentToken && tokenInterval.current) {
clearInterval(tokenInterval.current);
}
}
}, 5000);
// 返回清理函数
return () => {
if (tokenInterval.current) {
clearInterval(tokenInterval.current);
}
};
}, [token]); // 添加token作为依赖
return (
<Tabs
@ -120,6 +269,16 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 对话详情页 */}
<Tabs.Screen
name="chat-details"
options={{
title: 'chat-details',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
</Tabs>
);
}

View File

@ -6,107 +6,152 @@ import { ThemedText } from "@/components/ThemedText";
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const insets = useSafeAreaInsets();
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
// 用于控制是否显示问候页面
const [isHello, setIsHello] = useState(true);
// 获取对话id
const [conversationId, setConversationId] = useState<string | null>(null);
// 用户对话信息收集
const [userMessages, setUserMessages] = useState<Message[]>([]);
// 选择图片
const [selectedImages, setSelectedImages] = useState<string[]>([]);
// 动画值
const fadeAnim = useRef(new Animated.Value(1)).current;
const fadeAnimChat = useRef(new Animated.Value(0)).current;
const createNewConversation = useCallback(async () => {
// TODO 用户未输入时,显示提示信息
setUserMessages([{
content: {
text: "请输入您的问题,寻找,请稍等..."
},
role: 'Assistant',
timestamp: new Date().toISOString()
}]);
const data = await fetchApi<string>("/chat/new", {
method: "POST",
});
setConversationId(data);
}, []);
// 获取路由参数
const { sessionId, newSession } = useLocalSearchParams<{
sessionId: string;
newSession: string;
}>();
// 添加自动滚动到底部的效果
// 处理滚动到底部
useEffect(() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}, [userMessages, isHello]);
// 处理路由参数
useEffect(() => {
if (sessionId) {
setConversationId(sessionId)
setIsHello(false)
setConversationId(sessionId);
setIsHello(false);
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
setUserMessages(res)
})
setUserMessages(res);
});
}
if (newSession) {
setIsHello(false)
createNewConversation()
// if (newSession) {
// setIsHello(false);
// createNewConversation();
// }
}, [sessionId]);
// 动画效果
useEffect(() => {
if (isHello) {
// 显示欢迎页,隐藏聊天页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 0,
duration: 300,
useNativeDriver: true,
})
]).start();
} else {
// 显示聊天页,隐藏欢迎页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
]).start();
}
}, [sessionId, newSession])
}, [isHello, fadeAnim, fadeAnimChat]);
return (
<View style={{ flex: 1, backgroundColor: 'white', paddingTop: insets.top }}>
{/* 导航栏 - 保持在顶部 */}
<View style={isHello ? "" : styles.navbar} className="relative w-full flex flex-row items-center justify-between pb-3 pt-[2rem]">
{/* 点击去memo list 页面 */}
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => {
router.replace('/memo-list');
}}
onPress={() => router.push('/memo-list')}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
<View />
<ThemedText style={styles.title}>MemoWake</ThemedText>
<View style={styles.placeholder} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
onContentSizeChange={() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}}
>
{/* 内容区域 */}
<View className="flex-1">
{isHello ? <AskHello /> : <Chat userMessages={userMessages} sessionId={sessionId} />}
</View>
</ScrollView>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello />
</Animated.View>
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
<View className="w-full px-[1.5rem] mb-[2rem]">
<SendMessage setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} conversationId={conversationId} />
{/* 聊天页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat userMessages={userMessages} sessionId={sessionId} setSelectedImages={setSelectedImages} selectedImages={selectedImages} />
</Animated.View>
</View>
{/* 输入框 */}
<View style={styles.inputContainer}>
<SendMessage
setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
/>
</View>
</KeyboardAvoidingView>
</View>
@ -114,83 +159,66 @@ export default function AskScreen() {
}
const styles = StyleSheet.create({
navbar: {
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
backgroundColor: 'white',
zIndex: 10,
},
container: {
flex: 1,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 60
},
navbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: 'white',
// 使用 border 替代阴影
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
elevation: 1, // Android
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 1,
},
hiddenNavbar: {
shadowOpacity: 0,
elevation: 0,
},
backButton: {
marginLeft: 16,
padding: 12
padding: 8,
marginRight: 8,
},
content: {
flex: 1,
padding: 20,
alignItems: 'center',
justifyContent: 'center',
},
description: {
fontSize: 16,
color: '#666',
title: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 40,
paddingHorizontal: 20,
lineHeight: 24,
flex: 1,
},
chipsContainer: {
width: "100%",
flexDirection: 'row',
flexWrap: 'nowrap',
placeholder: {
width: 40,
},
// 更新 keyboardAvoidingView 和 contentContainer 样式
keyboardAvoidingView: {
flex: 1,
},
contentContainer: {
flex: 1,
justifyContent: 'center',
marginBottom: 40,
display: "flex",
alignItems: "center",
overflow: "scroll",
paddingBottom: 20,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF5E6',
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 20,
margin: 5,
},
chipText: {
marginLeft: 6,
color: '#FF9500',
fontSize: 14,
},
inputContainer: {
flexDirection: 'row',
padding: 16,
paddingBottom: 30,
absoluteView: {
position: 'absolute', // 保持绝对定位
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
},
input: {
flex: 1,
borderColor: '#FF9500',
borderWidth: 1,
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 12,
fontSize: 16,
width: '100%', // 确保输入框宽度撑满
},
voiceButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8, // 添加一点右边距
inputContainer: {
padding: 16,
paddingBottom: 24,
backgroundColor: 'white',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
},
});

View File

@ -3,7 +3,7 @@ import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/
import * as MediaLibrary from 'expo-media-library';
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -15,17 +15,24 @@ export default function HomeScreen() {
const insets = useSafeAreaInsets();
const [isLoading, setIsLoading] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [token, setToken] = useState('');
const tokenInterval = useRef<NodeJS.Timeout | number>(null);
const isMounted = useRef(true);
const getAuthToken = async (): Promise<string> => {
let tokenValue = '';
if (Platform.OS === 'web') {
tokenValue = localStorage.getItem('token') || '';
} else {
tokenValue = (await SecureStore.getItemAsync('token')) || '';
}
setToken(tokenValue); // 只在获取到新token时更新状态
return tokenValue;
};
useEffect(() => {
const checkAuthStatus = async () => {
try {
let token;
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || '';
} else {
token = await SecureStore.getItemAsync('token') || '';
}
const loggedIn = !!token;
setIsLoggedIn(loggedIn);
@ -40,16 +47,46 @@ export default function HomeScreen() {
}
router.replace('/ask');
}
setIsLoading(false);
} catch (error) {
console.error('检查登录状态出错:', error);
setIsLoading(false);
} finally {
setIsLoading(false);
}
};
checkAuthStatus();
}, []);
// 轮询获取token
useEffect(() => {
// 如果已经有token直接返回
if (token) {
if (tokenInterval.current) {
clearInterval(tokenInterval.current);
}
return;
}
if (!tokenInterval.current) return;
// 设置轮询
tokenInterval.current = setInterval(async () => {
if (isMounted.current) {
const currentToken = await getAuthToken();
// 如果获取到token清除定时器
if (currentToken && tokenInterval.current) {
clearInterval(tokenInterval.current);
}
}
}, 5000);
// 返回清理函数
return () => {
if (tokenInterval.current) {
clearInterval(tokenInterval.current);
}
};
}, [token]); // 添加token作为依赖
if (isLoading) {
return (
<View className="flex-1 bg-bgPrimary justify-center items-center">
@ -88,7 +125,7 @@ export default function HomeScreen() {
{"\n"}
{t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text>
{/* <MessagePush /> */}
{/* 唤醒按钮 */}
<TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"

View File

@ -16,7 +16,7 @@ import { fetchApi } from '@/lib/server-api-util';
import { CountData, UserInfoDetails } from '@/types/user';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, View } from 'react-native';
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function OwnerPage() {
const insets = useSafeAreaInsets();
@ -59,54 +59,57 @@ export default function OwnerPage() {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
<FlatList
data={[]} // 空数据,因为我们只需要渲染一次
renderItem={null} // 不需要渲染项目
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ flexGrow: 1, gap: 16, marginHorizontal: 16 }}
>
{/* 用户信息 */}
<UserInfo userInfo={userInfoDetails} />
ListHeaderComponent={
<View style={{ gap: 16 }}>
{/* 用户信息 */}
<UserInfo userInfo={userInfoDetails} />
{/* 设置栏 */}
<AlbumComponent setModalVisible={setModalVisible} />
{/* 设置栏 */}
<AlbumComponent setModalVisible={setModalVisible} />
{/* 资源数据 */}
<View style={styles.resourceContainer}>
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} subtitle={`${countData?.counter?.total_count?.video_count || 0}videos/${countData?.counter?.total_count?.photo_count || 0}photos`} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} style={{ flex: 1 }} isFormatBytes={true} />
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} style={{ flex: 1 }} />
</View>
{/* 数据统计 */}
<CountComponent
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
/>
{/* 资源数据 */}
<View style={styles.resourceContainer}>
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} subtitle={`${countData?.counter?.total_count?.video_count || 0}videos/${countData?.counter?.total_count?.photo_count || 0}photos`} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} style={{ flex: 1 }} isFormatBytes={true} />
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} style={{ flex: 1 }} />
</View>
{/* 数据统计 */}
<CountComponent
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
/>
{/* 分类 */}
<View style={{ height: 145 }}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
return (
<CategoryComponent
key={index}
title={key}
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
bgSvg={value.cover_url}
style={{ aspectRatio: 1, flex: 1 }}
/>
)
})}
</ScrollView>
</View>
{/* 分类 */}
<View style={{ height: 145 }}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
return (
<CategoryComponent
key={index}
title={key}
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
bgSvg={value.cover_url}
style={{ aspectRatio: 1, flex: 1 }}
/>
)
})}
</ScrollView>
</View>
{/* 作品数据 */}
<View className='flex flex-row justify-between gap-[1rem]'>
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
</View>
{/* 排行榜 */}
<Ranking data={userInfoDetails.title_rankings} />
</ScrollView>
{/* 作品数据 */}
<View className='flex flex-row justify-between gap-[1rem]'>
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
</View>
{/* 排行榜 */}
<Ranking data={userInfoDetails.title_rankings} />
</View>
}
/>
{/* 设置弹窗 */}
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
@ -122,6 +125,9 @@ const styles = StyleSheet.create({
backgroundColor: 'white',
paddingBottom: 86,
},
contentContainer: {
paddingHorizontal: 16,
},
resourceContainer: {
flexDirection: 'row',
gap: 16

View File

@ -5,11 +5,12 @@ import UserName from '@/components/user-message.tsx/userName';
import { fetchApi } from '@/lib/server-api-util';
import { FileUploadItem } from '@/types/upload';
import { User } from '@/types/user';
import { useEffect, useState } from 'react';
import { useLocalSearchParams } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
export type Steps = "userName" | "look" | "choice" | "done";
export default function UserMessage() {
// 步骤
// 步骤
const [steps, setSteps] = useState<Steps>("userName")
const [username, setUsername] = useState('')
const [avatar, setAvatar] = useState('')
@ -18,6 +19,10 @@ export default function UserMessage() {
const [userInfo, setUserInfo] = useState<User | null>(null);
const statusBarHeight = StatusBar?.currentHeight ?? 0;
// 获取路由参数
const params = useLocalSearchParams();
const { username: usernameParam } = params;
// 获取用户信息
const getUserInfo = async () => {
const res = await fetchApi<User>("/iam/user-info");
@ -44,7 +49,7 @@ export default function UserMessage() {
useEffect(() => {
getUserInfo();
setSteps("userName")
}, []);
}, [usernameParam]);
return (
<KeyboardAvoidingView

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M4.913 2.658c2.075-.27 4.19-.408 6.337-.408 2.147 0 4.262.139 6.337.408 1.922.25 3.291 1.861 3.405 3.727a4.403 4.403 0 0 0-1.032-.211 50.89 50.89 0 0 0-8.42 0c-2.358.196-4.04 2.19-4.04 4.434v4.286a4.47 4.47 0 0 0 2.433 3.984L7.28 21.53A.75.75 0 0 1 6 21v-4.03a48.527 48.527 0 0 1-1.087-.128C2.905 16.58 1.5 14.833 1.5 12.862V6.638c0-1.97 1.405-3.718 3.413-3.979Z" />
<path d="M15.75 7.5c-1.376 0-2.739.057-4.086.169C10.124 7.797 9 9.103 9 10.609v4.285c0 1.507 1.128 2.814 2.67 2.94 1.243.102 2.5.157 3.768.165l2.782 2.781a.75.75 0 0 0 1.28-.53v-2.39l.33-.026c1.542-.125 2.67-1.433 2.67-2.94v-4.286c0-1.505-1.125-2.811-2.664-2.94A49.392 49.392 0 0 0 15.75 7.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 780 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>

After

Width:  |  Height:  |  Size: 717 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 3V13.8C1 14.9201 1 15.4798 1.21799 15.9076C1.40973 16.2839 1.71547 16.5905 2.0918 16.7822C2.5192 17 3.07899 17 4.19691 17H15.8031C16.921 17 17.48 17 17.9074 16.7822C18.2837 16.5905 18.5905 16.2841 18.7822 15.9078C19.0002 15.48 19.0002 14.9199 19.0002 13.7998L19.0002 6.19978C19.0002 5.07967 19.0002 4.51962 18.7822 4.0918C18.5905 3.71547 18.2839 3.40973 17.9076 3.21799C17.4798 3 16.9201 3 15.8 3H10M1 3H10M1 3C1 1.89543 1.89543 1 3 1H6.67452C7.1637 1 7.40886 1 7.63904 1.05526C7.84311 1.10425 8.03785 1.18526 8.2168 1.29492C8.41857 1.41857 8.59181 1.59182 8.9375 1.9375L10 3" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 775 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

3
assets/icons/svg/yes.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4.00001L4.33357 7L11 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,10 +1,15 @@
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 { useVideoPlayer, VideoView } from 'expo-video';
import React from 'react';
import { useTranslation } from "react-i18next";
import {
FlatList,
Image,
Modal,
Pressable,
@ -20,18 +25,23 @@ import TypewriterText from "./typewriterText";
import { mergeArrays } from "./utils";
interface RenderMessageProps {
insets: { top: number };
item: Message;
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;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
}
const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: RenderMessageProps) => {
const renderMessage = ({ 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;
};
const { t } = useTranslation();
// 创建一个新的 VideoPlayer 组件
const VideoPlayer = ({
videoUrl,
@ -90,25 +100,28 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
}
</Text>
{(item.content.image_material_infos && item.content.image_material_infos.length > 0 || item.content.video_material_infos && item.content.video_material_infos.length > 0) && (
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && (
<View className="relative">
<View className="mt-2 flex flex-row gap-2 w-full">
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image, index, array) => (
<View style={[styles.imageGridContainer, { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }]}>
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => (
<Pressable
key={`${image.role}-${image.timestamp}`}
key={image?.id || image?.video?.id}
onPress={() => {
setModalVisible({ visible: true, data: image });
}}
style={({ pressed }) => [
array.length === 1 ? styles.fullWidthImage : styles.gridImage,
array.length === 2 && { width: '49%' },
array.length >= 3 && { width: '32%' },
{ opacity: pressed ? 0.8 : 1 } // 添加按下效果
]}
style={{
width: '32%',
aspectRatio: 1,
marginBottom: 8,
}}
>
<Image
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
className="rounded-xl w-full h-full"
style={{
width: '100%',
height: '100%',
borderRadius: 12,
}}
resizeMode="cover"
/>
</Pressable>
@ -116,12 +129,14 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
</View>
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <View 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">
&& <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);
}}>
<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">
<MoreSvg />
</View>
</View>
</TouchableOpacity>
}
</View>
)}
@ -194,6 +209,96 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
</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')}
/>
<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>
</View>
</View>
);
@ -202,6 +307,12 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
export default renderMessage;
const styles = StyleSheet.create({
imageGridContainer: {
flexDirection: 'row',
flexWrap: 'nowrap',
width: '100%',
marginTop: 8,
},
video: {
width: '100%',
height: '100%',
@ -276,4 +387,125 @@ const styles = StyleSheet.create({
color: '#000',
fontSize: 16,
},
});
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

@ -1,20 +1,23 @@
import { Message, Video } from '@/types/ask';
import { MaterialItem } from '@/types/personal-info';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import React, { Dispatch, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FlatList,
SafeAreaView
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import renderMessage from "./aiChat";
interface ChatProps {
userMessages: Message[];
sessionId: string;
setSelectedImages: Dispatch<SetStateAction<string[]>>;
selectedImages: string[];
}
function ChatComponent({ userMessages, sessionId }: ChatProps) {
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) {
const flatListRef = useRef<FlatList>(null);
const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
// 使用 useCallback 缓存 keyExtractor 函数
@ -23,6 +26,9 @@ function ChatComponent({ userMessages, sessionId }: ChatProps) {
// 使用 useMemo 缓存样式对象
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
// 详情弹窗
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
// 自动滚动到底部
useEffect(() => {
if (userMessages.length > 0) {
@ -45,7 +51,7 @@ function ChatComponent({ userMessages, sessionId }: ChatProps) {
updateCellsBatchingPeriod={50}
initialNumToRender={10}
windowSize={11}
renderItem={({ item }) => renderMessage({ item, sessionId, modalVisible, setModalVisible })}
renderItem={({ item }) => renderMessage({ setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
/>
</SafeAreaView>
);

View File

@ -1,28 +1,39 @@
import IP from "@/assets/icons/svg/ip.svg";
import { ThemedText } from "@/components/ThemedText";
import { useTranslation } from "react-i18next";
import { View } from 'react-native';
import { ScrollView, 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">
{t('ask.hi', { ns: 'ask' })}
{"\n"}
{t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText>
<View className="justify-center items-center"><IP /></View>
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
{t('ask.ready', { ns: 'ask' })}
{"\n"}
{t('ask.justAsk', { ns: 'ask' })}
</ThemedText>
</View>
<View className="flex-1 bg-white w-full">
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 20
}}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<View className="items-center">
<ThemedText className="text-3xl font-bold text-center">
{t('ask.hi', { ns: 'ask' })}
{"\n"}
{t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText>
<View className="justify-center items-center my-4">
<IP />
</View>
<ThemedText className="!text-textPrimary text-center">
{t('ask.ready', { ns: 'ask' })}
{"\n"}
{t('ask.justAsk', { ns: 'ask' })}
</ThemedText>
</View>
</ScrollView>
</View>
);
}

View File

@ -1,7 +1,8 @@
'use client';
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
import React, { Dispatch, SetStateAction, useCallback, useState } from 'react';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import {
Keyboard,
StyleSheet,
TextInput,
TouchableOpacity,
@ -12,13 +13,15 @@ import { fetchApi } from '@/lib/server-api-util';
import { Message } from '@/types/ask';
interface Props {
setIsHello: (isHello: boolean) => void,
setIsHello: Dispatch<SetStateAction<boolean>>,
conversationId: string | null,
setUserMessages: Dispatch<SetStateAction<Message[]>>;
setConversationId: (conversationId: string) => void,
selectedImages: string[];
setSelectedImages: Dispatch<SetStateAction<string[]>>;
}
export default function SendMessage(props: Props) {
const { setIsHello, conversationId, setUserMessages, setConversationId } = props;
const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props;
// 用户询问
const [inputValue, setInputValue] = useState('');
@ -29,20 +32,23 @@ export default function SendMessage(props: Props) {
method: "POST",
});
setConversationId(data);
await getConversation({ session_id: data, user_text });
await getConversation({ session_id: data, user_text, material_ids: [] });
}, []);
// 获取对话信息
const getConversation = useCallback(async ({ session_id, user_text }: { session_id: string, user_text: string }) => {
const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => {
// 获取对话信息必须要有对话id
if (!session_id) return;
const response = await fetchApi<Message>(`/chat`, {
method: "POST",
body: JSON.stringify({
session_id,
user_text
user_text,
material_ids
})
});
setSelectedImages([]);
setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...'));
}, []);
@ -70,17 +76,37 @@ export default function SendMessage(props: Props) {
// 如果没有对话ID创建新对话并获取消息否则直接获取消息
if (!conversationId) {
createNewConversation(text);
setIsHello(false);
} else {
getConversation({
session_id: conversationId,
user_text: text
user_text: text,
material_ids: selectedImages
});
}
// 将输入框清空
setInputValue('');
}
}
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
'keyboardWillShow',
() => {
console.log('Keyboard will show');
setIsHello(false);
setUserMessages([{
content: {
text: "快来寻找你的记忆吧。。。"
},
role: 'Assistant',
timestamp: new Date().toISOString()
}])
}
);
return () => {
keyboardWillShowListener.remove();
};
}, []);
return (
<View style={styles.container}>

38
components/copy.tsx Normal file
View File

@ -0,0 +1,38 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import * as Clipboard from 'expo-clipboard';
import React, { useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
await Clipboard.setStringAsync(textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<TouchableOpacity onPress={handleCopy} style={styles.button}>
{isCopied ? (
<Ionicons name="checkmark-circle" size={12} color="#FFB645" />
) : (
<Ionicons name="copy-outline" size={12} color="#333" />
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
padding: 4,
},
text: {
marginLeft: 8,
fontSize: 16,
},
});
export default CopyButton;

View File

@ -1,11 +1,18 @@
import NavbarSvg from "@/assets/icons/svg/navbar.svg";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import ChatInSvg from "@/assets/icons/svg/chatIn.svg";
import ChatNotInSvg from "@/assets/icons/svg/chatNotIn.svg";
import PersonInSvg from "@/assets/icons/svg/personIn.svg";
import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg";
import { router, usePathname } from "expo-router";
import React from 'react';
import { Platform, TouchableOpacity, View } from 'react-native';
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native';
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg';
const AskNavbar = () => {
// 获取设备尺寸
const { width } = Dimensions.get('window');
// 获取路由
const pathname = usePathname();
return (
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
shadowColor: '#000',
@ -14,10 +21,11 @@ const AskNavbar = () => {
shadowRadius: 8,
elevation: 10, // For Android
}}>
<NavbarSvg className="w-full h-full" />
{/* <NavbarSvg className="w-[150%] h-full" /> */}
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
<View className="absolute bottom-0 top-0 left-0 right-0 flex flex-row justify-between items-center px-[2rem]">
<TouchableOpacity onPress={() => router.push('/memo-list')} >
<Ionicons name="chatbubbles-outline" size={24} color="#4C320C" />
<TouchableOpacity onPress={() => router.push('/memo-list')} style={{ padding: 16 }}>
{pathname === "/memo-list" ? <ChatInSvg width={24} height={24} /> : <ChatNotInSvg width={24} height={24} />}
</TouchableOpacity>
<TouchableOpacity
@ -27,7 +35,7 @@ const AskNavbar = () => {
params: { newSession: "true" }
});
}}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`}
>
<View style={{
shadowColor: '#FFB645',
@ -67,9 +75,9 @@ const AskNavbar = () => {
</Svg>
</View>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/owner')}>
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
<View>
<Ionicons name="person-outline" size={24} color="#4C320C" />
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
</View>
</TouchableOpacity>

View File

@ -45,9 +45,13 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
body: JSON.stringify(body),
});
login({ ...res, email: res?.account }, res.access_token || '');
router.replace('/user-message');
const userInfo = await fetchApi<User>("/iam/user-info");
if (userInfo?.nickname) {
router.replace('/ask');
} else {
router.replace('/user-message');
}
} catch (error) {
// console.error('Login failed', error);
} finally {
setIsLoading(false);
}

View File

@ -7,6 +7,7 @@ import { useAuth } from "../../contexts/auth-context";
import { fetchApi } from "../../lib/server-api-util";
import { User } from "../../types/user";
import { ThemedText } from "../ThemedText";
import PrivacyModal from "../owner/qualification/privacy";
interface LoginProps {
updateUrlParam: (status: string, value: string) => void;
@ -25,7 +26,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
const [passwordsMatch, setPasswordsMatch] = useState(true);
const [loading, setLoading] = useState(false);
const [checked, setChecked] = useState(false);
const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai');
// 协议弹窗
const [privacyModalVisible, setPrivacyModalVisible] = useState(false);
// 从 URL 参数中获取 task_id 和 steps
const params = useLocalSearchParams<{ task_id?: string; steps?: string }>();
const taskId = params.task_id;
@ -263,10 +266,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agree", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'service' }
} as any)}>
<TouchableOpacity onPress={() => {
setModalType('terms');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.terms", { ns: 'login' })}
</ThemedText>
@ -274,10 +277,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'privacy' }
} as any)}>
<TouchableOpacity onPress={() => {
setModalType('privacy');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
</ThemedText>
@ -285,14 +288,25 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'user' }
} as any)}>
<TouchableOpacity onPress={() => {
setModalType('user');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.userAgreement", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('ai');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.aiAgreement", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agreement", { ns: 'login' })}
</ThemedText>
@ -319,6 +333,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
</ThemedText>
</TouchableOpacity>
</View>
{/* 协议弹窗 */}
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
</View>
}

View File

@ -1,7 +1,7 @@
import { fetchApi } from '@/lib/server-api-util';
import { Policy } from '@/types/personal-info';
import React, { useEffect, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import RenderHtml from 'react-native-render-html';
const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, type: string }) => {
@ -62,12 +62,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
onRequestClose={() => {
setModalVisible(!modalVisible);
}}>
<Pressable
style={styles.centeredView}
onPress={() => setModalVisible(false)}>
<Pressable
style={styles.modalView}
onPress={(e) => e.stopPropagation()}>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<View style={styles.modalHeader}>
<Text style={{ opacity: 0 }}>Settings</Text>
<Text style={styles.modalTitle}>{type === 'ai' ? 'AI Policy' : type === 'terms' ? 'Terms of Service' : type === 'privacy' ? 'Privacy Policy' : 'User Agreement'}</Text>
@ -85,9 +81,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
}}
/>
</ScrollView>
</Pressable>
</Pressable>
</View>
</View>
</Modal>
);
};

View File

@ -125,7 +125,13 @@ const UserInfo = (props: UserInfoProps) => {
}
<TouchableOpacity style={styles.edit} onPress={() => {
setModalVisible(false);
router.push('/user-message')
// 携带参数跳转
router.push({
pathname: '/user-message',
params: {
username: "true"
}
});
}}>
<EditSvg />
</TouchableOpacity>

View File

@ -1,25 +1,26 @@
import UserSvg from '@/assets/icons/svg/ataver.svg';
import { ThemedText } from '@/components/ThemedText';
import { UserInfoDetails } from '@/types/user';
// import { Image } from 'expo-image';
import { useState } from 'react';
import { Image, ScrollView, View } from 'react-native';
export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
// 添加状态来跟踪图片加载状态
const [imageError, setImageError] = useState(false);
return (
<View className='flex flex-row items-center mt-[1rem] gap-[1rem]'>
<View className='flex flex-row justify-between items-center mt-[1rem] gap-[1rem] w-full'>
{/* 用户名 */}
<View className='flex flex-col gap-4 w-[68vw]'>
<View className='flex flex-col gap-4 w-[75%]'>
<View className='flex flex-row items-center justify-between w-full'>
<View className='flex flex-row items-center gap-2'>
<View className='flex flex-row items-center gap-2 w-full'>
<ThemedText
className='max-w-[36vw] !text-textSecondary !font-semibold !text-2xl'
className='max-w-[80%] !text-textSecondary !font-semibold !text-2xl'
numberOfLines={1} // 限制为1行
ellipsizeMode="tail"
>
{userInfo?.user_info?.nickname}
</ThemedText>
<ScrollView
className='max-w-[26vw] '
className='max-w-[20%]'
horizontal // 水平滚动
showsHorizontalScrollIndicator={false} // 隐藏滚动条
contentContainerStyle={{
@ -40,29 +41,39 @@ export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
</ScrollView>
</View>
</View>
<ScrollView
className='max-w-[68vw]'
horizontal // 水平滚动
showsHorizontalScrollIndicator={false} // 隐藏滚动条
contentContainerStyle={{
flexDirection: 'row',
gap: 8 // 间距
}}
>
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID{userInfo?.user_info?.user_id}</ThemedText>
</ScrollView>
<View>
<ScrollView
className='max-w-[85%]'
horizontal // 水平滚动
showsHorizontalScrollIndicator={false} // 隐藏滚动条
contentContainerStyle={{
flexDirection: 'row',
gap: 8 // 间距
}}
>
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID {userInfo?.user_info?.user_id}</ThemedText>
</ScrollView>
{/* <CopyButton textToCopy={userInfo?.user_info?.user_id || ""} /> */}
</View>
</View>
{/* 头像 */}
<View>
{userInfo?.user_info?.avatar_file_url
?
<View className='w-auto'>
{userInfo?.user_info?.avatar_file_url && !imageError ? (
<Image
source={{ uri: userInfo?.user_info?.avatar_file_url }}
source={{ uri: userInfo.user_info.avatar_file_url }}
style={{ width: 80, height: 80, borderRadius: 40 }}
onError={() => {
console.log('图片加载失败:', userInfo.user_info.avatar_file_url);
setImageError(true);
}}
onLoad={() => {
console.log('图片加载成功');
}}
/>
:
) : (
<UserSvg width={80} height={80} />
}
)}
</View>
</View >
);

View File

@ -3,6 +3,8 @@
"hi": "Hi,",
"iAmMemo": "I'm Memo!",
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!"
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
}
}

View File

@ -48,7 +48,8 @@
"codeVaild": "The code you entered is invalid",
"sendAgain": "Didnt receive a code?",
"resend": "Resend",
"goBack": "Go Back"
"goBack": "Go Back",
"aiAgreement": "AI Function Usage Norms"
},
"login": {
"title": "Log in",

View File

@ -3,6 +3,8 @@
"hi": "Hi,",
"iAmMemo": "I'm Memo!",
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!"
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
}
}

View File

@ -48,7 +48,8 @@
"codeValid": "您输入的验证码无效",
"sendAgain": "没有收到验证码?",
"resend": "重新发送",
"goBack": "返回"
"goBack": "返回",
"aiAgreement": "《AI功能使用规范》"
},
"login": {
"title": "登录",

47
package-lock.json generated
View File

@ -18,6 +18,7 @@
"expo-audio": "~0.4.7",
"expo-background-fetch": "^13.1.6",
"expo-blur": "~14.1.5",
"expo-clipboard": "~7.1.5",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1",
"expo-device": "~7.1.4",
@ -3360,18 +3361,6 @@
}
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
@ -7835,6 +7824,17 @@
"react-native": "*"
}
},
"node_modules/expo-clipboard": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz",
"integrity": "sha512-TCANUGOxouoJXxKBW5ASJl2WlmQLGpuZGemDCL2fO5ZMl57DGTypUmagb0CVUFxDl0yAtFIcESd78UsF9o64aw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "17.1.7",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.7.tgz",
@ -9743,15 +9743,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -10819,18 +10810,6 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -15896,4 +15875,4 @@
}
}
}
}
}

View File

@ -29,7 +29,6 @@
"expo-file-system": "~18.1.10",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.3.2",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.5",
@ -69,7 +68,8 @@
"react-native-uuid": "^2.0.3",
"react-native-web": "~0.20.0",
"react-native-webview": "13.13.5",
"react-redux": "^9.2.0"
"react-redux": "^9.2.0",
"expo-clipboard": "~7.1.5"
},
"devDependencies": {
"@babel/core": "^7.25.2",