feat: 更新教练页面和消息结构,支持附件功能

- 在教练页面中引入附件类型,支持图片、视频和文件的上传和展示
- 重构消息数据结构,确保消息包含附件信息
- 优化消息发送逻辑,支持发送包含图片的消息
- 更新界面样式,提升附件展示效果
- 删除不再使用的旧图标,替换为新的应用图标和启动画面
This commit is contained in:
richarjiang
2025-08-18 15:07:32 +08:00
parent 849447c5da
commit 27267c2f7f
17 changed files with 235 additions and 22 deletions

View File

@@ -5,9 +5,9 @@ import { Text, TouchableOpacity, View } from 'react-native';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { ROUTES } from '@/constants/Routes';
export default function TabLayout() { export default function TabLayout() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
@@ -16,6 +16,7 @@ export default function TabLayout() {
return ( return (
<Tabs <Tabs
initialRouteName="coach"
screenOptions={({ route }) => { screenOptions={({ route }) => {
const routeName = route.name; const routeName = route.name;
const isSelected = (routeName === 'index' && pathname === ROUTES.TAB_HOME) || const isSelected = (routeName === 'index' && pathname === ROUTES.TAB_HOME) ||

View File

@@ -35,10 +35,31 @@ import { ActionSheet } from '../../components/ui/ActionSheet';
type Role = 'user' | 'assistant'; type Role = 'user' | 'assistant';
// 附件类型枚举
type AttachmentType = 'image' | 'video' | 'file';
// 附件数据结构
type MessageAttachment = {
id: string;
type: AttachmentType;
url: string;
localUri?: string; // 本地URI用于上传中的显示
filename?: string;
size?: number;
duration?: number; // 视频时长(秒)
thumbnail?: string; // 视频缩略图
width?: number;
height?: number;
uploadProgress?: number; // 上传进度 0-1
uploadError?: string; // 上传错误信息
};
// 重构后的消息数据结构
type ChatMessage = { type ChatMessage = {
id: string; id: string;
role: Role; role: Role;
content: string; content: string; // 文本内容
attachments?: MessageAttachment[]; // 附件列表
}; };
// 卡片类型常量定义 // 卡片类型常量定义
@@ -256,7 +277,15 @@ export default function CoachScreen() {
const cached = await loadAiCoachSessionCache(); const cached = await loadAiCoachSessionCache();
if (isMounted && cached && Array.isArray(cached.messages) && cached.messages.length > 0) { if (isMounted && cached && Array.isArray(cached.messages) && cached.messages.length > 0) {
setConversationId(cached.conversationId); setConversationId(cached.conversationId);
setMessages(cached.messages.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content) as ChatMessage[]); // 确保缓存的消息符合新的 ChatMessage 结构
const validMessages = cached.messages
.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content)
.map(msg => ({
...msg,
// 确保 attachments 字段存在,对于旧的缓存消息可能没有这个字段
attachments: (msg as any).attachments || undefined,
})) as ChatMessage[];
setMessages(validMessages);
setTimeout(() => { setTimeout(() => {
if (isMounted) scrollToEnd(); if (isMounted) scrollToEnd();
}, 100); }, 100);
@@ -463,7 +492,14 @@ export default function CoachScreen() {
} }
const mapped: ChatMessage[] = (detail.messages || []) const mapped: ChatMessage[] = (detail.messages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant') .filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' })); .map((m, idx) => ({
id: `${m.role}_${idx}_${Date.now()}`,
role: m.role as Role,
content: m.content || '',
// 对于历史消息,暂时不包含附件信息,因为服务器端可能还没有返回附件数据
// 如果将来服务器支持返回附件信息,可以在这里添加映射逻辑
attachments: undefined,
}));
setConversationId(detail.conversationId); setConversationId(detail.conversationId);
setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]); setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]);
setHistoryVisible(false); setHistoryVisible(false);
@@ -493,9 +529,9 @@ export default function CoachScreen() {
]); ]);
} }
async function sendStream(text: string) { async function sendStream(text: string, imageUrls: string[] = []) {
const tokenExists = !!getAuthToken(); const tokenExists = !!getAuthToken();
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { } try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls }); } catch { }
// 终止上一次未完成的流 // 终止上一次未完成的流
if (streamAbortRef.current) { if (streamAbortRef.current) {
@@ -510,6 +546,7 @@ export default function CoachScreen() {
const body = { const body = {
conversationId: cid, conversationId: cid,
messages: [...historyForServer, { role: 'user' as const, content: text }], messages: [...historyForServer, { role: 'user' as const, content: text }],
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
stream: true, stream: true,
}; };
@@ -517,7 +554,19 @@ export default function CoachScreen() {
const assistantId = `a_${Date.now()}`; const assistantId = `a_${Date.now()}`;
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text }; // 构建包含附件的用户消息
const attachments = imageUrls.map((url, index) => ({
id: `img_${Date.now()}_${index}`,
type: 'image' as AttachmentType,
url,
}));
const userMsg: ChatMessage = {
id: userMsgId,
role: 'user',
content: text,
attachments: attachments.length > 0 ? attachments : undefined,
};
shouldAutoScrollRef.current = isAtBottom; shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId; pendingAssistantIdRef.current = assistantId;
@@ -614,12 +663,10 @@ export default function CoachScreen() {
} }
try { try {
const urls = selectedImages.map(img => img.uploadedUrl).filter(Boolean); const imageUrls = selectedImages.map(img => img.uploadedUrl).filter(Boolean) as string[];
const mdImages = urls.map((u) => `![image](${u})`).join('\n\n');
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
setInput(''); setInput('');
setSelectedImages([]); setSelectedImages([]);
await sendStream(composed); await sendStream(trimmed, imageUrls);
} catch (e: any) { } catch (e: any) {
Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试'); Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试');
} }
@@ -706,6 +753,88 @@ export default function CoachScreen() {
setSelectedImages((prev) => prev.filter((it) => it.id !== id)); setSelectedImages((prev) => prev.filter((it) => it.id !== id));
}, []); }, []);
// 渲染单个附件
function renderAttachment(attachment: MessageAttachment, isUser: boolean) {
const { type, url, localUri, uploadProgress, uploadError, width, height, filename } = attachment;
if (type === 'image') {
const imageUri = url || localUri;
if (!imageUri) return null;
return (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity
accessibilityRole="imagebutton"
onPress={() => setPreviewImageUri(imageUri)}
style={styles.imageAttachment}
>
<Image
source={{ uri: imageUri }}
style={[
styles.attachmentImage,
width && height ? { aspectRatio: width / height } : {}
]}
resizeMode="cover"
/>
{uploadProgress !== undefined && uploadProgress < 1 && (
<View style={styles.attachmentProgressOverlay}>
<Text style={styles.attachmentProgressText}>
{Math.round(uploadProgress * 100)}%
</Text>
</View>
)}
{uploadError && (
<View style={styles.attachmentErrorOverlay}>
<Text style={styles.attachmentErrorText}></Text>
</View>
)}
</TouchableOpacity>
</View>
);
}
if (type === 'video') {
// 视频附件的实现
return (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity style={styles.videoAttachment}>
<View style={styles.videoPlaceholder}>
<Ionicons name="play-circle" size={48} color="rgba(255,255,255,0.9)" />
<Text style={styles.videoFilename}>{filename || '视频文件'}</Text>
</View>
</TouchableOpacity>
</View>
);
}
if (type === 'file') {
// 文件附件的实现
return (
<View key={attachment.id} style={styles.attachmentContainer}>
<TouchableOpacity style={styles.fileAttachment}>
<Ionicons name="document-outline" size={24} color="#687076" />
<Text style={styles.fileFilename} numberOfLines={1}>
{filename || 'unknown_file'}
</Text>
</TouchableOpacity>
</View>
);
}
return null;
}
// 渲染所有附件
function renderAttachments(attachments: MessageAttachment[], isUser: boolean) {
if (!attachments || attachments.length === 0) return null;
return (
<View style={styles.attachmentsContainer}>
{attachments.map(attachment => renderAttachment(attachment, isUser))}
</View>
);
}
function renderItem({ item }: { item: ChatMessage }) { function renderItem({ item }: { item: ChatMessage }) {
const isUser = item.role === 'user'; const isUser = item.role === 'user';
return ( return (
@@ -726,6 +855,7 @@ export default function CoachScreen() {
]} ]}
> >
{renderBubbleContent(item)} {renderBubbleContent(item)}
{renderAttachments(item.attachments || [], isUser)}
</View> </View>
</Animated.View> </Animated.View>
@@ -900,7 +1030,7 @@ export default function CoachScreen() {
// 在对话中插入"确认消息"并发送给教练 // 在对话中插入"确认消息"并发送给教练
const textMsg = `#记体重:\n\n${val} kg`; const textMsg = `#记体重:\n\n${val} kg`;
await send(textMsg); await sendStream(textMsg);
} catch (e: any) { } catch (e: any) {
console.error('[AI_CHAT] Error handling weight submission:', e); console.error('[AI_CHAT] Error handling weight submission:', e);
Alert.alert('保存失败', e?.message || '请稍后重试'); Alert.alert('保存失败', e?.message || '请稍后重试');
@@ -992,9 +1122,9 @@ export default function CoachScreen() {
// 移除饮食选择卡片 // 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId)); setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
// 发送包含图片的饮食记录消息 // 发送包含图片的饮食记录消息,图片通过 imageUrls 参数传递
const dietMsg = `#记饮食:\n\n![食物照片](${url})`; const dietMsg = `#记饮食:请分析这张食物照片的营养成分和热量`;
await send(dietMsg); await sendStream(dietMsg, [url]);
} catch (uploadError) { } catch (uploadError) {
console.error('[DIET] 图片上传失败:', uploadError); console.error('[DIET] 图片上传失败:', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试'); Alert.alert('上传失败', '图片上传失败,请重试');
@@ -1031,7 +1161,7 @@ export default function CoachScreen() {
// 发送饮食记录消息 // 发送饮食记录消息
const dietMsg = `记录了今日饮食:${trimmedText}`; const dietMsg = `记录了今日饮食:${trimmedText}`;
await send(dietMsg); await sendStream(dietMsg);
} catch (e: any) { } catch (e: any) {
console.error('[DIET] 提交饮食记录失败:', e); console.error('[DIET] 提交饮食记录失败:', e);
Alert.alert('提交失败', e?.message || '提交失败,请重试'); Alert.alert('提交失败', e?.message || '提交失败,请重试');
@@ -1278,7 +1408,6 @@ export default function CoachScreen() {
options={[ options={[
{ id: 'camera', title: '拍照', onPress: handleCameraPhoto }, { id: 'camera', title: '拍照', onPress: handleCameraPhoto },
{ id: 'library', title: '从相册选择', onPress: handleLibraryPhoto }, { id: 'library', title: '从相册选择', onPress: handleLibraryPhoto },
{ id: 'cancel', title: '取消', onPress: () => setShowDietPhotoActionSheet(false) }
]} ]}
/> />
</View> </View>
@@ -1663,6 +1792,87 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
// 附件相关样式
attachmentsContainer: {
marginTop: 8,
gap: 6,
},
attachmentContainer: {
marginBottom: 4,
},
imageAttachment: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
attachmentImage: {
width: '100%',
minHeight: 120,
maxHeight: 200,
borderRadius: 12,
},
attachmentProgressOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentProgressText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
attachmentErrorOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentErrorText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
videoAttachment: {
borderRadius: 12,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.1)',
},
videoPlaceholder: {
height: 120,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
videoFilename: {
color: '#fff',
fontSize: 12,
marginTop: 4,
textAlign: 'center',
},
fileAttachment: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
gap: 8,
},
fileFilename: {
flex: 1,
fontSize: 14,
color: '#192126',
},
}); });
const markdownStyles = { const markdownStyles = {

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "logo.png", "filename" : "未命名项目 (1).jpeg",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View File

@@ -1,17 +1,17 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "logo.png", "filename" : "未命名项目 (1).jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"filename" : "logo 1.png", "filename" : "未命名项目.jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "logo 2.png", "filename" : "未命名项目 1.jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

View File

@@ -1,15 +1,17 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "logo.png", "filename" : "未命名项目 (1).jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"filename" : "未命名项目.jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "未命名项目 1.jpeg",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB