refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义
This commit is contained in:
450
components/coach/ChatMessage.tsx
Normal file
450
components/coach/ChatMessage.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
import { ChatMessage, MessageAttachment } from './types';
|
||||
|
||||
interface MessageAttachmentComponentProps {
|
||||
attachment: MessageAttachment;
|
||||
onPreview?: (uri: string) => void;
|
||||
}
|
||||
|
||||
const MessageAttachmentComponent: React.FC<MessageAttachmentComponentProps> = ({
|
||||
attachment,
|
||||
onPreview
|
||||
}) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'light'];
|
||||
|
||||
if (attachment.type === 'image') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.imageAttachment}
|
||||
onPress={() => onPreview?.(attachment.url)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: attachment.url }}
|
||||
style={styles.attachmentImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
{attachment.uploadProgress !== undefined && attachment.uploadProgress < 1 && (
|
||||
<View style={styles.attachmentProgressOverlay}>
|
||||
<Text style={styles.attachmentProgressText}>
|
||||
{Math.round(attachment.uploadProgress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{attachment.uploadError && (
|
||||
<View style={styles.attachmentErrorOverlay}>
|
||||
<Text style={styles.attachmentErrorText}>上传失败</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (attachment.type === 'video') {
|
||||
return (
|
||||
<View style={styles.videoAttachment}>
|
||||
<View style={styles.videoPlaceholder}>
|
||||
<Ionicons name="play-circle" size={32} color="#fff" />
|
||||
{attachment.filename && (
|
||||
<Text style={styles.videoFilename}>{attachment.filename}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (attachment.type === 'file') {
|
||||
return (
|
||||
<View style={styles.fileAttachment}>
|
||||
<Ionicons name="document" size={20} color={theme.text} />
|
||||
<Text style={[styles.fileFilename, { color: theme.text }]}>
|
||||
{attachment.filename || '未知文件'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ChatMessageComponentProps {
|
||||
message: ChatMessage;
|
||||
onPreviewImage?: (uri: string) => void;
|
||||
onChoiceSelect?: (messageId: string, choiceId: string) => void;
|
||||
selectedChoices?: Record<string, string>;
|
||||
pendingChoiceConfirmation?: Record<string, boolean>;
|
||||
isStreaming?: boolean;
|
||||
onCancelStream?: () => void;
|
||||
}
|
||||
|
||||
const ChatMessageComponent: React.FC<ChatMessageComponentProps> = ({
|
||||
message,
|
||||
onPreviewImage,
|
||||
onChoiceSelect,
|
||||
selectedChoices,
|
||||
pendingChoiceConfirmation,
|
||||
isStreaming,
|
||||
onCancelStream
|
||||
}) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'light'];
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isAssistant = message.role === 'assistant';
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isUser && styles.userRow]}>
|
||||
{isAssistant && (
|
||||
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
|
||||
<Text style={styles.avatarText}>AI</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[
|
||||
styles.bubble,
|
||||
{
|
||||
backgroundColor: isUser ? theme.primary : theme.surface,
|
||||
alignSelf: isUser ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
}
|
||||
]}>
|
||||
{/* 文本内容 */}
|
||||
{message.content ? (
|
||||
isUser ? (
|
||||
<Text style={[styles.bubbleText, { color: theme.onPrimary }]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
) : (
|
||||
<Markdown
|
||||
style={{
|
||||
body: { color: theme.text, fontSize: 15, lineHeight: 22 },
|
||||
paragraph: { marginTop: 2, marginBottom: 2 },
|
||||
bullet_list: { marginVertical: 4 },
|
||||
ordered_list: { marginVertical: 4 },
|
||||
list_item: { flexDirection: 'row' },
|
||||
code_inline: {
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
|
||||
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
|
||||
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
|
||||
link: { color: '#246BFD' },
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* 附件 */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<View style={styles.attachmentsContainer}>
|
||||
{message.attachments.map((attachment) => (
|
||||
<MessageAttachmentComponent
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onPreview={onPreviewImage}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 选择选项 */}
|
||||
{message.choices && message.choices.length > 0 && (
|
||||
<View style={styles.choicesContainer}>
|
||||
{message.choices.map((choice) => {
|
||||
const isSelected = selectedChoices?.[message.id] === choice.id;
|
||||
const isLoading = pendingChoiceConfirmation?.[message.id];
|
||||
const isDisabled = isLoading || (selectedChoices?.[message.id] && !isSelected) || false;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={choice.id}
|
||||
style={[
|
||||
styles.choiceButton,
|
||||
!!choice.recommended && styles.choiceButtonRecommended,
|
||||
isSelected && styles.choiceButtonSelected,
|
||||
isDisabled && styles.choiceButtonDisabled,
|
||||
]}
|
||||
onPress={() => onChoiceSelect?.(message.id, choice.id)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<View style={styles.choiceContent}>
|
||||
<Text style={[
|
||||
styles.choiceLabel,
|
||||
!!choice.recommended && styles.choiceLabelRecommended,
|
||||
isSelected && styles.choiceLabelSelected,
|
||||
isDisabled && styles.choiceLabelDisabled,
|
||||
]}>
|
||||
{choice.emoji ? `${choice.emoji} ` : ''}{choice.label}
|
||||
</Text>
|
||||
|
||||
<View style={styles.choiceStatusContainer}>
|
||||
{!!choice.recommended && !isSelected && (
|
||||
<View style={styles.recommendedBadge}>
|
||||
<Text style={styles.recommendedText}>推荐</Text>
|
||||
</View>
|
||||
)}
|
||||
{isSelected && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text style={styles.selectedText}>已选择</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 流式回复控制 */}
|
||||
{isAssistant && isStreaming && !message.content && (
|
||||
<View style={styles.streamingContainer}>
|
||||
<Text style={[styles.bubbleText, { color: theme.text }]}>正在思考...</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelStreamBtn}
|
||||
onPress={onCancelStream}
|
||||
>
|
||||
<Ionicons name="stop" size={12} color="#FF4444" />
|
||||
<Text style={styles.cancelStreamText}>停止</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
marginVertical: 6,
|
||||
},
|
||||
userRow: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
avatar: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
color: '#192126',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
bubble: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
},
|
||||
bubbleText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
attachmentsContainer: {
|
||||
marginTop: 8,
|
||||
gap: 6,
|
||||
},
|
||||
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,
|
||||
},
|
||||
choicesContainer: {
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
},
|
||||
choiceButton: {
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#7a5af84d',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
},
|
||||
choiceButtonRecommended: {
|
||||
borderColor: '#7a5af899',
|
||||
backgroundColor: '#7a5af81a',
|
||||
},
|
||||
choiceButtonSelected: {
|
||||
borderColor: '#19b36e',
|
||||
backgroundColor: '#19b36e33',
|
||||
borderWidth: 2,
|
||||
},
|
||||
choiceButtonDisabled: {
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
opacity: 0.5,
|
||||
},
|
||||
choiceContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
choiceLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
choiceLabelRecommended: {
|
||||
color: '#19b36e',
|
||||
},
|
||||
choiceLabelSelected: {
|
||||
color: '#19b36e',
|
||||
fontWeight: '700',
|
||||
},
|
||||
choiceLabelDisabled: {
|
||||
color: '#687076',
|
||||
},
|
||||
choiceStatusContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
recommendedBadge: {
|
||||
backgroundColor: '#7a5af8cc',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
recommendedText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#19b36e',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: '#19b36e',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
streamingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
cancelStreamBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255,68,68,0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,68,68,0.3)',
|
||||
},
|
||||
cancelStreamText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#FF4444',
|
||||
},
|
||||
});
|
||||
|
||||
export default ChatMessageComponent;
|
||||
Reference in New Issue
Block a user