refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义
This commit is contained in:
@@ -1,25 +1,20 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Modal,
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Markdown from 'react-native-markdown-display';
|
|
||||||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -38,86 +33,29 @@ import { Image } from 'expo-image';
|
|||||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
import { HistoryModal } from '../../components/model/HistoryModal';
|
||||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
import { ActionSheet } from '../../components/ui/ActionSheet';
|
||||||
|
|
||||||
type Role = 'user' | 'assistant';
|
// 导入新的 coach 组件
|
||||||
|
import {
|
||||||
// 附件类型枚举
|
CardType,
|
||||||
type AttachmentType = 'image' | 'video' | 'file';
|
ChatComposer,
|
||||||
|
ChatMessage as ChatMessageComponent,
|
||||||
// 附件数据结构
|
DietInputCard,
|
||||||
type MessageAttachment = {
|
DietOptionsCard,
|
||||||
id: string;
|
DietPlanCard,
|
||||||
type: AttachmentType;
|
WeightInputCard,
|
||||||
url: string;
|
type QuickChip,
|
||||||
localUri?: string; // 本地URI,用于上传中的显示
|
type SelectedImage
|
||||||
filename?: string;
|
} from '@/components/coach';
|
||||||
size?: number;
|
import { AiChoiceOption, AttachmentType, ChatMessage, Role } from '@/components/coach/types';
|
||||||
duration?: number; // 视频时长(秒)
|
|
||||||
thumbnail?: string; // 视频缩略图
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
uploadProgress?: number; // 上传进度 0-1
|
|
||||||
uploadError?: string; // 上传错误信息
|
|
||||||
};
|
|
||||||
|
|
||||||
// AI选择选项数据结构
|
|
||||||
type AiChoiceOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: any;
|
|
||||||
recommended?: boolean;
|
|
||||||
emoji?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 餐次类型
|
|
||||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
|
||||||
|
|
||||||
// 食物确认选项数据结构(暂未使用,预留给未来功能扩展)
|
|
||||||
// type FoodConfirmationOption = {
|
|
||||||
// id: string;
|
|
||||||
// label: string;
|
|
||||||
// foodName: string;
|
|
||||||
// portion: string;
|
|
||||||
// calories: number;
|
|
||||||
// mealType: MealType;
|
|
||||||
// nutritionData: {
|
|
||||||
// proteinGrams?: number;
|
|
||||||
// carbohydrateGrams?: number;
|
|
||||||
// fatGrams?: number;
|
|
||||||
// fiberGrams?: number;
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
// AI响应数据结构
|
// AI响应数据结构
|
||||||
type AiResponseData = {
|
type AiResponseData = {
|
||||||
content: string;
|
content: string;
|
||||||
choices?: AiChoiceOption[];
|
choices?: any[];
|
||||||
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
||||||
pendingData?: any;
|
pendingData?: any;
|
||||||
context?: any;
|
context?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重构后的消息数据结构
|
|
||||||
type ChatMessage = {
|
|
||||||
id: string;
|
|
||||||
role: Role;
|
|
||||||
content: string; // 文本内容
|
|
||||||
attachments?: MessageAttachment[]; // 附件列表
|
|
||||||
choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息)
|
|
||||||
interactionType?: string; // 交互类型
|
|
||||||
pendingData?: any; // 待确认数据
|
|
||||||
context?: any; // 上下文信息
|
|
||||||
};
|
|
||||||
|
|
||||||
// 卡片类型常量定义
|
|
||||||
const CardType = {
|
|
||||||
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
|
|
||||||
DIET_INPUT: '__DIET_INPUT_CARD__',
|
|
||||||
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
|
|
||||||
DIET_PLAN: '__DIET_PLAN_CARD__',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type CardType = typeof CardType[keyof typeof CardType];
|
|
||||||
|
|
||||||
|
|
||||||
// 定义路由参数类型
|
// 定义路由参数类型
|
||||||
type CoachScreenParams = {
|
type CoachScreenParams = {
|
||||||
@@ -136,7 +74,6 @@ export default function CoachScreen() {
|
|||||||
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
|
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const theme = Colors[colorScheme ?? 'light'];
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
const botName = (params?.name || 'Seal').toString();
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
@@ -158,16 +95,7 @@ export default function CoachScreen() {
|
|||||||
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||||||
const [headerHeight, setHeaderHeight] = useState<number>(60);
|
const [headerHeight, setHeaderHeight] = useState<number>(60);
|
||||||
const pendingAssistantIdRef = useRef<string | null>(null);
|
const pendingAssistantIdRef = useRef<string | null>(null);
|
||||||
const [selectedImages, setSelectedImages] = useState<{
|
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
||||||
id: string;
|
|
||||||
localUri: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
progress: number;
|
|
||||||
uploadedKey?: string;
|
|
||||||
uploadedUrl?: string;
|
|
||||||
error?: string;
|
|
||||||
}[]>([]);
|
|
||||||
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
|
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
|
||||||
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
|
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
|
||||||
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
|
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
|
||||||
@@ -195,23 +123,23 @@ export default function CoachScreen() {
|
|||||||
pilatesPurposes: userProfile.pilatesPurposes
|
pilatesPurposes: userProfile.pilatesPurposes
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
const chips = useMemo(() => [
|
const chips: QuickChip[] = useMemo(() => [
|
||||||
// { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
|
// { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
|
||||||
// { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
|
// { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
|
||||||
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
||||||
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
|
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
|
||||||
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
|
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
|
||||||
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
|
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
|
||||||
{
|
// {
|
||||||
key: 'mood',
|
// key: 'mood',
|
||||||
label: '#记心情',
|
// label: '#记心情',
|
||||||
action: () => {
|
// action: () => {
|
||||||
if (Platform.OS === 'ios') {
|
// if (Platform.OS === 'ios') {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
// Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}
|
// }
|
||||||
pushIfAuthedElseLogin('/mood/calendar');
|
// pushIfAuthedElseLogin('/mood/calendar');
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
], [planDraft, checkin]);
|
], [planDraft, checkin]);
|
||||||
|
|
||||||
const scrollToEnd = useCallback(() => {
|
const scrollToEnd = useCallback(() => {
|
||||||
@@ -1082,132 +1010,12 @@ 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 (
|
|
||||||
<Animated.View
|
|
||||||
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
|
|
||||||
layout={Layout.springify().damping(18)}
|
|
||||||
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.bubble,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.card, // 16% opacity
|
|
||||||
borderTopLeftRadius: isUser ? 16 : 6,
|
|
||||||
borderTopRightRadius: isUser ? 6 : 16,
|
|
||||||
maxWidth: isUser ? '82%' : '90%',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{renderBubbleContent(item)}
|
|
||||||
{renderAttachments(item.attachments || [], isUser)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBubbleContent(item: ChatMessage) {
|
|
||||||
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
|
|
||||||
return (
|
|
||||||
<View style={styles.streamingContainer}>
|
|
||||||
<Text style={[styles.bubbleText, { color: '#687076' }]}>正在思考…</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={cancelCurrentRequest}
|
|
||||||
style={styles.cancelStreamBtn}
|
|
||||||
>
|
|
||||||
<Ionicons name="stop-circle" size={20} color="#FF4444" />
|
|
||||||
<Text style={styles.cancelStreamText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 处理特殊卡片类型
|
||||||
if (item.content?.startsWith(CardType.WEIGHT_INPUT)) {
|
if (item.content?.startsWith(CardType.WEIGHT_INPUT)) {
|
||||||
const cardId = item.id;
|
const cardId = item.id;
|
||||||
const preset = (() => {
|
const preset = (() => {
|
||||||
@@ -1220,377 +1028,109 @@ export default function CoachScreen() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 初始化输入值(如果还没有的话)
|
|
||||||
const currentValue = weightInputs[cardId] ?? preset;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 8 }}>
|
<Animated.View
|
||||||
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}>记录今日体重</Text>
|
entering={FadeInDown.springify().damping(18)}
|
||||||
<View style={styles.weightRow}>
|
layout={Layout.springify().damping(18)}
|
||||||
<TextInput
|
style={[styles.row, { justifyContent: 'flex-start' }]}
|
||||||
placeholder="例如 60.5"
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
value={currentValue}
|
|
||||||
placeholderTextColor={'#687076'}
|
|
||||||
style={styles.weightInput}
|
|
||||||
onChangeText={(text) => setWeightInputs(prev => ({ ...prev, [cardId]: text }))}
|
|
||||||
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)}
|
|
||||||
returnKeyType="done"
|
|
||||||
submitBehavior="blurAndSubmit"
|
|
||||||
/>
|
|
||||||
<Text style={styles.weightUnit}>kg</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={styles.weightSaveBtn}
|
|
||||||
onPress={() => handleSubmitWeight(currentValue, cardId)}
|
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#192126', fontWeight: '700' }}>记录</Text>
|
<WeightInputCard
|
||||||
</TouchableOpacity>
|
cardId={cardId}
|
||||||
</View>
|
weightInputs={weightInputs}
|
||||||
<Text style={{ color: '#687076', fontSize: 12 }}>按回车或点击保存,即可将该体重同步到账户并发送到对话。</Text>
|
onWeightInputChange={(id, value) => setWeightInputs(prev => ({ ...prev, [id]: value }))}
|
||||||
</View>
|
onSaveWeight={(id) => handleSubmitWeight(weightInputs[id], id)}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.content?.startsWith(CardType.DIET_INPUT)) {
|
if (item.content?.startsWith(CardType.DIET_INPUT)) {
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 12 }}>
|
<Animated.View
|
||||||
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}>记录今日饮食</Text>
|
entering={FadeInDown.springify().damping(18)}
|
||||||
<Text style={{ color: '#687076', fontSize: 14 }}>请选择记录方式:</Text>
|
layout={Layout.springify().damping(18)}
|
||||||
|
style={[styles.row, { justifyContent: 'flex-start' }]}
|
||||||
<View style={styles.dietOptionsContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={styles.dietOptionBtn}
|
|
||||||
onPress={() => handleDietTextInput(item.id)}
|
|
||||||
>
|
>
|
||||||
<View style={styles.dietOptionIconContainer}>
|
<DietOptionsCard
|
||||||
<Ionicons name="create-outline" size={20} color="#192126" />
|
cardId={item.id}
|
||||||
</View>
|
onSelectOption={(cardId, optionId) => {
|
||||||
<View style={styles.dietOptionTextContainer}>
|
if (optionId === 'text') {
|
||||||
<Text style={styles.dietOptionTitle}>文字记录</Text>
|
handleDietTextInput(cardId);
|
||||||
<Text style={styles.dietOptionDesc}>输入吃了什么、大概多少克</Text>
|
} else if (optionId === 'photo') {
|
||||||
</View>
|
handleDietPhotoInput(cardId);
|
||||||
</TouchableOpacity>
|
}
|
||||||
|
}}
|
||||||
<TouchableOpacity
|
/>
|
||||||
accessibilityRole="button"
|
</Animated.View>
|
||||||
style={styles.dietOptionBtn}
|
|
||||||
onPress={() => handleDietPhotoInput(item.id)}
|
|
||||||
>
|
|
||||||
<View style={styles.dietOptionIconContainer}>
|
|
||||||
<Ionicons name="camera-outline" size={20} color="#192126" />
|
|
||||||
</View>
|
|
||||||
<View style={styles.dietOptionTextContainer}>
|
|
||||||
<Text style={styles.dietOptionTitle}>拍照识别</Text>
|
|
||||||
<Text style={styles.dietOptionDesc}>拍摄食物照片进行AI分析</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={{ color: '#687076', fontSize: 12 }}>选择合适的方式记录您的饮食,Seal会根据您的饮食情况给出专业的营养建议。</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) {
|
if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) {
|
||||||
const cardId = item.content.split('\n')?.[1] || '';
|
const cardId = item.content.split('\n')?.[1] || '';
|
||||||
const currentText = dietTextInputs[cardId] || '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 8 }}>
|
<Animated.View
|
||||||
<View style={styles.dietInputHeader}>
|
entering={FadeInDown.springify().damping(18)}
|
||||||
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}>文字记录饮食</Text>
|
layout={Layout.springify().damping(18)}
|
||||||
<TouchableOpacity
|
style={[styles.row, { justifyContent: 'flex-start' }]}
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => handleBackToDietOptions(cardId)}
|
|
||||||
style={styles.dietBackBtn}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="arrow-back" size={16} color="#687076" />
|
<DietInputCard
|
||||||
</TouchableOpacity>
|
cardId={cardId}
|
||||||
</View>
|
dietTextInputs={dietTextInputs}
|
||||||
|
onDietTextInputChange={(id, value) => setDietTextInputs(prev => ({ ...prev, [id]: value }))}
|
||||||
<TextInput
|
onSubmitDietText={(id) => handleSubmitDietText(dietTextInputs[id], id)}
|
||||||
placeholder="例如:午餐吃了一碗米饭(150g)、红烧肉(100g)、青菜(80g)"
|
onBackToDietOptions={handleBackToDietOptions}
|
||||||
placeholderTextColor={'#687076'}
|
onShowDietPhotoActionSheet={(id) => {
|
||||||
style={styles.dietTextInput}
|
setCurrentCardId(id);
|
||||||
multiline
|
setShowDietPhotoActionSheet(true);
|
||||||
numberOfLines={3}
|
}}
|
||||||
value={currentText}
|
|
||||||
onChangeText={(text) => setDietTextInputs(prev => ({ ...prev, [cardId]: text }))}
|
|
||||||
returnKeyType="done"
|
|
||||||
/>
|
/>
|
||||||
|
</Animated.View>
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={[styles.dietSubmitBtn, { opacity: currentText.trim() ? 1 : 0.5 }]}
|
|
||||||
disabled={!currentText.trim()}
|
|
||||||
onPress={() => handleSubmitDietText(currentText, cardId)}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#192126', fontWeight: '700' }}>发送记录</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Text style={{ color: '#687076', fontSize: 12 }}>详细描述您的饮食内容和分量,有助于Seal给出更精准的营养分析和建议。</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.content?.startsWith(CardType.DIET_PLAN)) {
|
if (item.content?.startsWith(CardType.DIET_PLAN)) {
|
||||||
const cardId = item.content.split('\n')?.[1] || '';
|
|
||||||
|
|
||||||
// 获取用户数据
|
|
||||||
const weight = userProfile?.weight ? Number(userProfile.weight) : 58;
|
|
||||||
const height = userProfile?.height ? Number(userProfile.height) : 160;
|
|
||||||
|
|
||||||
// 计算年龄
|
|
||||||
const calculateAge = (birthday?: string): number => {
|
|
||||||
if (!birthday) return 25; // 默认年龄
|
|
||||||
try {
|
|
||||||
const birthDate = new Date(birthday);
|
|
||||||
const today = new Date();
|
|
||||||
let age = today.getFullYear() - birthDate.getFullYear();
|
|
||||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
|
||||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
|
||||||
age--;
|
|
||||||
}
|
|
||||||
return age > 0 ? age : 25;
|
|
||||||
} catch {
|
|
||||||
return 25;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const age = calculateAge(userProfile?.birthDate);
|
|
||||||
const gender = userProfile?.gender || 'female';
|
|
||||||
const name = userProfile?.name || '用户';
|
|
||||||
|
|
||||||
// 计算相关数据
|
|
||||||
const bmi = calculateBMI(weight, height);
|
|
||||||
const bmiStatus = getBMIStatus(bmi);
|
|
||||||
const dailyCalories = calculateDailyCalories(weight, height, age, gender);
|
|
||||||
const nutrition = calculateNutritionDistribution(dailyCalories);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.dietPlanContainer}>
|
<Animated.View
|
||||||
{/* 标题部分 */}
|
entering={FadeInDown.springify().damping(18)}
|
||||||
<View style={styles.dietPlanHeader}>
|
layout={Layout.springify().damping(18)}
|
||||||
<View style={styles.dietPlanTitleContainer}>
|
style={[styles.row, { justifyContent: 'flex-start' }]}
|
||||||
<Ionicons name="restaurant-outline" size={20} color={theme.success} />
|
>
|
||||||
<Text style={styles.dietPlanTitle}>我的饮食方案</Text>
|
<DietPlanCard
|
||||||
</View>
|
onGeneratePlan={() => {
|
||||||
<Text style={styles.dietPlanSubtitle}>MY DIET PLAN</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 我的档案数据 */}
|
|
||||||
<View style={styles.profileSection}>
|
|
||||||
<Text style={styles.sectionTitle}>我的档案数据</Text>
|
|
||||||
<View style={styles.profileDataRow}>
|
|
||||||
<View style={styles.avatarContainer}>
|
|
||||||
<View style={styles.avatar}>
|
|
||||||
<Text style={styles.avatarText}>{name.charAt(0)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.profileStats}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{age}</Text>
|
|
||||||
<Text style={styles.statLabel}>年龄/岁</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{height.toFixed(1)}</Text>
|
|
||||||
<Text style={styles.statLabel}>身高/CM</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{weight.toFixed(1)}</Text>
|
|
||||||
<Text style={styles.statLabel}>体重/KG</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* BMI部分 */}
|
|
||||||
<View style={styles.bmiSection}>
|
|
||||||
<View style={styles.bmiHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>当前BMI</Text>
|
|
||||||
<View style={[styles.bmiStatusBadge, { backgroundColor: bmiStatus.color }]}>
|
|
||||||
<Text style={styles.bmiStatusText}>{bmiStatus.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.bmiValue}>{bmi.toFixed(1)}</Text>
|
|
||||||
<View style={styles.bmiScale}>
|
|
||||||
<View style={[styles.bmiBar, { backgroundColor: '#87CEEB' }]} />
|
|
||||||
<View style={[styles.bmiBar, { backgroundColor: '#90EE90' }]} />
|
|
||||||
<View style={[styles.bmiBar, { backgroundColor: '#FFD700' }]} />
|
|
||||||
<View style={[styles.bmiBar, { backgroundColor: '#FFA07A' }]} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.bmiLabels}>
|
|
||||||
<Text style={styles.bmiLabel}>偏瘦</Text>
|
|
||||||
<Text style={styles.bmiLabel}>正常</Text>
|
|
||||||
<Text style={styles.bmiLabel}>偏胖</Text>
|
|
||||||
<Text style={styles.bmiLabel}>肥胖</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 饮食目标 */}
|
|
||||||
<View style={styles.collapsibleSection}>
|
|
||||||
<View style={styles.collapsibleHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>饮食目标</Text>
|
|
||||||
<Ionicons name="chevron-down" size={16} color="#687076" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 目标体重 */}
|
|
||||||
<View style={styles.collapsibleSection}>
|
|
||||||
<View style={styles.collapsibleHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>目标体重</Text>
|
|
||||||
<Ionicons name="chevron-down" size={16} color="#687076" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 每日推荐摄入 */}
|
|
||||||
<View style={styles.caloriesSection}>
|
|
||||||
<View style={styles.caloriesHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>每日推荐摄入</Text>
|
|
||||||
<Text style={styles.caloriesValue}>{dailyCalories}千卡</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.nutritionGrid}>
|
|
||||||
<View style={styles.nutritionItem}>
|
|
||||||
<Text style={styles.nutritionValue}>{nutrition.carbs}g</Text>
|
|
||||||
<View style={styles.nutritionLabelRow}>
|
|
||||||
<Ionicons name="nutrition-outline" size={16} color="#687076" />
|
|
||||||
<Text style={styles.nutritionLabel}>碳水</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.nutritionItem}>
|
|
||||||
<Text style={styles.nutritionValue}>{nutrition.protein}g</Text>
|
|
||||||
<View style={styles.nutritionLabelRow}>
|
|
||||||
<Ionicons name="fitness-outline" size={16} color="#687076" />
|
|
||||||
<Text style={styles.nutritionLabel}>蛋白质</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.nutritionItem}>
|
|
||||||
<Text style={styles.nutritionValue}>{nutrition.fat}g</Text>
|
|
||||||
<View style={styles.nutritionLabelRow}>
|
|
||||||
<Ionicons name="water-outline" size={16} color="#687076" />
|
|
||||||
<Text style={styles.nutritionLabel}>脂肪</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.nutritionNote}>
|
|
||||||
根据您的基础代谢率,这到理想体重所需要的热量缺口计算得出每日的热量推荐值。
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dietPlanButton}
|
|
||||||
onPress={() => {
|
|
||||||
// 这里可以添加跳转到详细饮食方案页面的逻辑
|
|
||||||
console.log('跳转到饮食方案详情');
|
console.log('跳转到饮食方案详情');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Ionicons name="restaurant" size={16} color="#FFFFFF" />
|
</Animated.View>
|
||||||
<Text style={styles.dietPlanButtonText}>饮食方案</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在流式回复过程中显示取消按钮
|
// 普通消息使用 ChatMessageComponent
|
||||||
if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 8 }}>
|
<Animated.View
|
||||||
<Markdown style={markdownStyles} mergeStyle>
|
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
|
||||||
{item.content}
|
layout={Layout.springify().damping(18)}
|
||||||
</Markdown>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={cancelCurrentRequest}
|
|
||||||
style={styles.cancelStreamBtn}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="stop-circle" size={16} color="#FF4444" />
|
<ChatMessageComponent
|
||||||
<Text style={styles.cancelStreamText}>停止生成</Text>
|
message={item}
|
||||||
</TouchableOpacity>
|
onPreviewImage={setPreviewImageUri}
|
||||||
</View>
|
onChoiceSelect={(messageId, choiceId) => {
|
||||||
);
|
const choice = item.choices?.find(c => c.id === choiceId);
|
||||||
}
|
if (choice) {
|
||||||
|
|
||||||
// 检查是否有选择选项需要显示
|
|
||||||
if (item.choices && item.choices.length > 0 && (item.interactionType === 'food_confirmation' || item.interactionType === 'selection')) {
|
|
||||||
return (
|
|
||||||
<View style={{ gap: 12, width: '100%' }}>
|
|
||||||
<Markdown style={markdownStyles} mergeStyle>
|
|
||||||
{item.content || ''}
|
|
||||||
</Markdown>
|
|
||||||
<View style={styles.choicesContainer}>
|
|
||||||
{item.choices.map((choice) => {
|
|
||||||
const isSelected = selectedChoices[item.id] === choice.id;
|
|
||||||
const isAnySelected = selectedChoices[item.id] != null;
|
|
||||||
const isPending = pendingChoiceConfirmation[item.id];
|
|
||||||
const isDisabled = isAnySelected && !isSelected;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={choice.id}
|
|
||||||
accessibilityRole="button"
|
|
||||||
disabled={isDisabled || isPending}
|
|
||||||
style={[
|
|
||||||
styles.choiceButton,
|
|
||||||
choice.recommended && styles.choiceButtonRecommended,
|
|
||||||
isSelected && styles.choiceButtonSelected,
|
|
||||||
isDisabled && styles.choiceButtonDisabled,
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
if (!isDisabled && !isPending) {
|
|
||||||
Haptics.selectionAsync();
|
|
||||||
handleChoiceSelection(choice, item);
|
handleChoiceSelection(choice, item);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
selectedChoices={selectedChoices}
|
||||||
<View style={styles.choiceContent}>
|
pendingChoiceConfirmation={pendingChoiceConfirmation}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 }}>
|
isStreaming={isStreaming && pendingAssistantIdRef.current === item.id}
|
||||||
{choice.emoji && (
|
onCancelStream={cancelCurrentRequest}
|
||||||
<Text style={{ fontSize: 16 }}>{choice.emoji}</Text>
|
/>
|
||||||
)}
|
</Animated.View>
|
||||||
<Text style={[
|
|
||||||
styles.choiceLabel,
|
|
||||||
choice.recommended && styles.choiceLabelRecommended,
|
|
||||||
isSelected && styles.choiceLabelSelected,
|
|
||||||
isDisabled && styles.choiceLabelDisabled,
|
|
||||||
]}>
|
|
||||||
{choice.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.choiceStatusContainer}>
|
|
||||||
{choice.recommended && !isSelected && (
|
|
||||||
<View style={styles.recommendedBadge}>
|
|
||||||
<Text style={styles.recommendedText}>推荐</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{isSelected && isPending && (
|
|
||||||
<ActivityIndicator size="small" color={theme.success} />
|
|
||||||
)}
|
|
||||||
{isSelected && !isPending && (
|
|
||||||
<View style={styles.selectedBadge}>
|
|
||||||
<Text style={styles.selectedText}>已选择</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<Markdown style={markdownStyles} mergeStyle>
|
|
||||||
{item.content || ''}
|
|
||||||
</Markdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertWeightInputCard() {
|
function insertWeightInputCard() {
|
||||||
const id = `wcard_${Date.now()}`;
|
const id = `wcard_${Date.now()}`;
|
||||||
@@ -1970,122 +1510,33 @@ export default function CoachScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<BlurView
|
<View
|
||||||
intensity={18}
|
style={[{
|
||||||
tint={'light'}
|
position: 'absolute',
|
||||||
style={[styles.composerWrap, { paddingBottom: getTabBarBottomPadding() + 10, bottom: keyboardOffset }]}
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: keyboardOffset,
|
||||||
|
paddingBottom: getTabBarBottomPadding() + 10
|
||||||
|
}]}
|
||||||
onLayout={(e) => {
|
onLayout={(e) => {
|
||||||
const h = e.nativeEvent.layout.height;
|
const h = e.nativeEvent.layout.height;
|
||||||
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
|
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ChatComposer
|
||||||
horizontal
|
input={input}
|
||||||
showsHorizontalScrollIndicator={false}
|
onInputChange={setInput}
|
||||||
decelerationRate="fast"
|
onSend={() => send(input)}
|
||||||
snapToAlignment="start"
|
onPickImages={pickImages}
|
||||||
style={styles.chipsRowScroll}
|
onCancelRequest={cancelCurrentRequest}
|
||||||
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
|
selectedImages={selectedImages}
|
||||||
>
|
onRemoveImage={removeSelectedImage}
|
||||||
{chips.map((c) => (
|
onPreviewImage={setPreviewImageUri}
|
||||||
<TouchableOpacity
|
isSending={isSending}
|
||||||
key={c.key}
|
isStreaming={isStreaming}
|
||||||
style={[
|
chips={chips}
|
||||||
styles.chip,
|
|
||||||
{
|
|
||||||
borderColor: c.key === 'mood' ? `${theme.success}40` : `${theme.primary}40`,
|
|
||||||
backgroundColor: c.key === 'mood' ? `${theme.success}15` : `${theme.primary}15`
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
onPress={c.action}
|
|
||||||
>
|
|
||||||
<Text style={[styles.chipText, { color: c.key === 'mood' ? theme.success : theme.text }]}>{c.label}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{!!selectedImages.length && (
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
style={styles.imagesRow}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
|
|
||||||
>
|
|
||||||
{selectedImages.map((img) => (
|
|
||||||
<View key={img.id} style={styles.imageThumbWrap}>
|
|
||||||
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
|
|
||||||
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
{!!(img.progress > 0 && img.progress < 1) && (
|
|
||||||
<View style={styles.imageProgressOverlay}>
|
|
||||||
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{img.error && (
|
|
||||||
<View style={styles.imageErrorOverlay}>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => uploadImage(img)}
|
|
||||||
style={styles.imageRetryBtn}
|
|
||||||
>
|
|
||||||
<Ionicons name="refresh" size={12} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
|
|
||||||
<Ionicons name="close" size={12} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[styles.inputRow]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={pickImages}
|
|
||||||
style={[styles.mediaBtn, { backgroundColor: `${theme.primary}20` }]}
|
|
||||||
>
|
|
||||||
<Ionicons name="image-outline" size={18} color={theme.text} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TextInput
|
|
||||||
placeholder="问我任何健康相关的问题,如营养、健身、生活管理等..."
|
|
||||||
placeholderTextColor={theme.textMuted}
|
|
||||||
style={[styles.input, { color: theme.text }]}
|
|
||||||
value={input}
|
|
||||||
onChangeText={setInput}
|
|
||||||
multiline
|
|
||||||
onSubmitEditing={() => send(input)}
|
|
||||||
submitBehavior="blurAndSubmit"
|
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
disabled={(!input.trim() && selectedImages.length === 0) && !isSending}
|
|
||||||
onPress={() => {
|
|
||||||
if (isSending || isStreaming) {
|
|
||||||
cancelCurrentRequest();
|
|
||||||
} else {
|
|
||||||
send(input);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={[
|
|
||||||
styles.sendBtn,
|
|
||||||
{
|
|
||||||
backgroundColor: (isSending || isStreaming) ? theme.danger : theme.primary,
|
|
||||||
opacity: ((input.trim() || selectedImages.length > 0) || (isSending || isStreaming)) ? 1 : 0.5
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<Ionicons name="stop" size={18} color="#fff" />
|
|
||||||
) : isStreaming ? (
|
|
||||||
<Ionicons name="stop" size={18} color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
|
||||||
|
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
|
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||||
|
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { fetchWeightHistory, updateUserProfile, WeightHistoryItem } from '@/store/userSlice';
|
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
|
|
||||||
export default function WeightRecordsPage() {
|
export default function WeightRecordsPage() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
const [showWeightPicker, setShowWeightPicker] = useState(false);
|
const [showWeightPicker, setShowWeightPicker] = useState(false);
|
||||||
const [pickerType, setPickerType] = useState<'current' | 'initial' | 'target'>('current');
|
const [pickerType, setPickerType] = useState<'current' | 'initial' | 'target' | 'edit'>('current');
|
||||||
const [inputWeight, setInputWeight] = useState('');
|
const [inputWeight, setInputWeight] = useState('');
|
||||||
|
const [editingRecord, setEditingRecord] = useState<WeightHistoryItem | null>(null);
|
||||||
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const themeColors = Colors[colorScheme ?? 'light'];
|
const themeColors = Colors[colorScheme ?? 'light'];
|
||||||
@@ -74,6 +76,23 @@ export default function WeightRecordsPage() {
|
|||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditWeightRecord = (record: WeightHistoryItem) => {
|
||||||
|
setPickerType('edit');
|
||||||
|
setEditingRecord(record);
|
||||||
|
initializeInput(parseFloat(record.weight));
|
||||||
|
setShowWeightPicker(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWeightRecord = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await dispatch(deleteWeightRecord(id) as any);
|
||||||
|
await loadWeightHistory();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除体重记录失败:', error);
|
||||||
|
Alert.alert('错误', '删除体重记录失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleWeightSave = async () => {
|
const handleWeightSave = async () => {
|
||||||
const weight = parseFloat(inputWeight);
|
const weight = parseFloat(inputWeight);
|
||||||
if (isNaN(weight) || weight <= 0 || weight > 500) {
|
if (isNaN(weight) || weight <= 0 || weight > 500) {
|
||||||
@@ -88,20 +107,45 @@ export default function WeightRecordsPage() {
|
|||||||
} else if (pickerType === 'initial') {
|
} else if (pickerType === 'initial') {
|
||||||
// Update initial weight in profile
|
// Update initial weight in profile
|
||||||
console.log('更新初始体重');
|
console.log('更新初始体重');
|
||||||
|
|
||||||
await dispatch(updateUserProfile({ initialWeight: weight }) as any);
|
await dispatch(updateUserProfile({ initialWeight: weight }) as any);
|
||||||
} else if (pickerType === 'target') {
|
} else if (pickerType === 'target') {
|
||||||
// Update target weight in profile
|
// Update target weight in profile
|
||||||
await dispatch(updateUserProfile({ targetWeight: weight }) as any);
|
await dispatch(updateUserProfile({ targetWeight: weight }) as any);
|
||||||
|
} else if (pickerType === 'edit' && editingRecord) {
|
||||||
|
await dispatch(updateWeightRecord({ id: editingRecord.id, weight }) as any);
|
||||||
}
|
}
|
||||||
setShowWeightPicker(false);
|
setShowWeightPicker(false);
|
||||||
setInputWeight('');
|
setInputWeight('');
|
||||||
|
setEditingRecord(null);
|
||||||
await loadWeightHistory();
|
await loadWeightHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存体重失败:', error);
|
console.error('保存体重失败:', error);
|
||||||
|
Alert.alert('错误', '保存体重失败,请重试');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNumberPress = (number: string) => {
|
||||||
|
setInputWeight(prev => {
|
||||||
|
// 防止输入多个0开头
|
||||||
|
if (prev === '0' && number === '0') return prev;
|
||||||
|
// 如果当前是0,输入非0数字时替换
|
||||||
|
if (prev === '0' && number !== '0') return number;
|
||||||
|
return prev + number;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePress = () => {
|
||||||
|
setInputWeight(prev => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecimalPress = () => {
|
||||||
|
setInputWeight(prev => {
|
||||||
|
if (prev.includes('.')) return prev;
|
||||||
|
// 如果没有输入任何数字,自动添加0
|
||||||
|
if (!prev) return '0.';
|
||||||
|
return prev + '.';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Process weight history data
|
// Process weight history data
|
||||||
const sortedHistory = [...weightHistory]
|
const sortedHistory = [...weightHistory]
|
||||||
@@ -210,38 +254,13 @@ export default function WeightRecordsPage() {
|
|||||||
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
|
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={`${record.createdAt}-${recordIndex}`} style={styles.recordCard}>
|
<WeightRecordCard
|
||||||
<View style={styles.recordHeader}>
|
key={`${record.createdAt}-${recordIndex}`}
|
||||||
<Text style={styles.recordDateTime}>
|
record={record}
|
||||||
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
|
onPress={handleEditWeightRecord}
|
||||||
</Text>
|
onDelete={handleDeleteWeightRecord}
|
||||||
{/* <TouchableOpacity style={styles.recordEditButton}>
|
weightChange={weightChange}
|
||||||
<Ionicons name="create-outline" size={16} color="#FF9500" />
|
|
||||||
</TouchableOpacity> */}
|
|
||||||
</View>
|
|
||||||
<View style={styles.recordContent}>
|
|
||||||
<Text style={styles.recordWeightLabel}>体重:</Text>
|
|
||||||
<Text style={styles.recordWeightValue}>{record.weight}kg</Text>
|
|
||||||
{Math.abs(weightChange) > 0 && (
|
|
||||||
<View style={[
|
|
||||||
styles.weightChangeTag,
|
|
||||||
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
|
|
||||||
]}>
|
|
||||||
<Ionicons
|
|
||||||
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
|
|
||||||
size={12}
|
|
||||||
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
|
|
||||||
/>
|
/>
|
||||||
<Text style={[
|
|
||||||
styles.weightChangeText,
|
|
||||||
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
|
|
||||||
]}>
|
|
||||||
{Math.abs(weightChange).toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -263,6 +282,7 @@ export default function WeightRecordsPage() {
|
|||||||
transparent
|
transparent
|
||||||
onRequestClose={() => setShowWeightPicker(false)}
|
onRequestClose={() => setShowWeightPicker(false)}
|
||||||
>
|
>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.modalBackdrop}
|
style={styles.modalBackdrop}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
@@ -278,28 +298,25 @@ export default function WeightRecordsPage() {
|
|||||||
{pickerType === 'current' && '记录体重'}
|
{pickerType === 'current' && '记录体重'}
|
||||||
{pickerType === 'initial' && '编辑初始体重'}
|
{pickerType === 'initial' && '编辑初始体重'}
|
||||||
{pickerType === 'target' && '编辑目标体重'}
|
{pickerType === 'target' && '编辑目标体重'}
|
||||||
|
{pickerType === 'edit' && '编辑体重记录'}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ width: 24 }} />
|
<View style={{ width: 24 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.modalContent}>
|
<ScrollView
|
||||||
{/* Weight Input Section */}
|
style={styles.modalContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Weight Display Section */}
|
||||||
<View style={styles.inputSection}>
|
<View style={styles.inputSection}>
|
||||||
<View style={styles.weightInputContainer}>
|
<View style={styles.weightInputContainer}>
|
||||||
<View style={styles.weightIcon}>
|
<View style={styles.weightIcon}>
|
||||||
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
<TextInput
|
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
|
||||||
style={[styles.weightInput, { color: themeColors.text }]}
|
{inputWeight || '输入体重'}
|
||||||
placeholder="输入体重"
|
</Text>
|
||||||
placeholderTextColor={themeColors.textSecondary}
|
|
||||||
value={inputWeight}
|
|
||||||
onChangeText={setInputWeight}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
autoFocus={true}
|
|
||||||
selectTextOnFocus={true}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -333,7 +350,17 @@ export default function WeightRecordsPage() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Custom Number Keyboard */}
|
||||||
|
<NumberKeyboard
|
||||||
|
onNumberPress={handleNumberPress}
|
||||||
|
onDeletePress={handleDeletePress}
|
||||||
|
onDecimalPress={handleDecimalPress}
|
||||||
|
hasDecimal={inputWeight.includes('.')}
|
||||||
|
maxLength={6}
|
||||||
|
currentValue={inputWeight}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<View style={styles.modalFooter}>
|
<View style={styles.modalFooter}>
|
||||||
@@ -349,6 +376,7 @@ export default function WeightRecordsPage() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</View>
|
</View>
|
||||||
@@ -486,62 +514,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
},
|
},
|
||||||
recordCard: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 20,
|
|
||||||
marginBottom: 12,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.06,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
recordHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
recordDateTime: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#687076',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
recordEditButton: {
|
|
||||||
padding: 6,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'rgba(255, 149, 0, 0.1)',
|
|
||||||
},
|
|
||||||
recordContent: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
recordWeightLabel: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#687076',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
recordWeightValue: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#192126',
|
|
||||||
marginLeft: 4,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
weightChangeTag: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginLeft: 12,
|
|
||||||
},
|
|
||||||
weightChangeText: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -562,6 +535,9 @@ const styles = StyleSheet.create({
|
|||||||
color: '#687076',
|
color: '#687076',
|
||||||
},
|
},
|
||||||
// Modal Styles
|
// Modal Styles
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
@@ -573,7 +549,8 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 20,
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 20,
|
||||||
maxHeight: '80%',
|
maxHeight: '85%',
|
||||||
|
minHeight: 500,
|
||||||
},
|
},
|
||||||
modalHeader: {
|
modalHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -588,8 +565,8 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
modalContent: {
|
modalContent: {
|
||||||
|
flex: 1,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 10,
|
|
||||||
},
|
},
|
||||||
inputSection: {
|
inputSection: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
@@ -619,11 +596,12 @@ const styles = StyleSheet.create({
|
|||||||
borderBottomColor: '#E5E7EB',
|
borderBottomColor: '#E5E7EB',
|
||||||
paddingBottom: 6,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
weightInput: {
|
weightDisplay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
unitLabel: {
|
unitLabel: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -637,7 +615,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
quickSelectionSection: {
|
quickSelectionSection: {
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
marginBottom: 8,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
quickSelectionTitle: {
|
quickSelectionTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -675,7 +653,8 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
modalFooter: {
|
modalFooter: {
|
||||||
padding: 20,
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
paddingBottom: 25,
|
paddingBottom: 25,
|
||||||
},
|
},
|
||||||
saveButton: {
|
saveButton: {
|
||||||
|
|||||||
147
components/NumberKeyboard.tsx
Normal file
147
components/NumberKeyboard.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
interface NumberKeyboardProps {
|
||||||
|
onNumberPress: (number: string) => void;
|
||||||
|
onDeletePress: () => void;
|
||||||
|
onDecimalPress: () => void;
|
||||||
|
hasDecimal?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
currentValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const keyWidth = (width - 80) / 3; // 减去左右边距和间隙
|
||||||
|
|
||||||
|
export default function NumberKeyboard({
|
||||||
|
onNumberPress,
|
||||||
|
onDeletePress,
|
||||||
|
onDecimalPress,
|
||||||
|
hasDecimal = false,
|
||||||
|
maxLength = 6,
|
||||||
|
currentValue = '',
|
||||||
|
}: NumberKeyboardProps) {
|
||||||
|
const handleNumberPress = (number: string) => {
|
||||||
|
if (currentValue.length >= maxLength) return;
|
||||||
|
// 防止输入多个0开头
|
||||||
|
if (currentValue === '0' && number === '0') return;
|
||||||
|
// 如果当前是0,输入非0数字时替换
|
||||||
|
if (currentValue === '0' && number !== '0') {
|
||||||
|
// 这里不需要replace,直接传递number即可
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNumberPress(number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecimalPress = () => {
|
||||||
|
if (hasDecimal || currentValue.includes('.')) return;
|
||||||
|
onDecimalPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderKey = (
|
||||||
|
value: string,
|
||||||
|
onPress: () => void,
|
||||||
|
style?: any,
|
||||||
|
textStyle?: any,
|
||||||
|
disabled?: boolean
|
||||||
|
) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.key,
|
||||||
|
{ width: keyWidth },
|
||||||
|
style,
|
||||||
|
disabled && styles.keyDisabled
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{value === 'delete' ? (
|
||||||
|
<Ionicons name="backspace-outline" size={24} color="#374151" />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.keyText, textStyle, disabled && styles.keyTextDisabled]}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{renderKey('1', () => handleNumberPress('1'))}
|
||||||
|
{renderKey('2', () => handleNumberPress('2'))}
|
||||||
|
{renderKey('3', () => handleNumberPress('3'))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{renderKey('4', () => handleNumberPress('4'))}
|
||||||
|
{renderKey('5', () => handleNumberPress('5'))}
|
||||||
|
{renderKey('6', () => handleNumberPress('6'))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{renderKey('7', () => handleNumberPress('7'))}
|
||||||
|
{renderKey('8', () => handleNumberPress('8'))}
|
||||||
|
{renderKey('9', () => handleNumberPress('9'))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{renderKey(
|
||||||
|
'.',
|
||||||
|
handleDecimalPress,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
hasDecimal || currentValue.includes('.')
|
||||||
|
)}
|
||||||
|
{renderKey('0', () => handleNumberPress('0'))}
|
||||||
|
{renderKey('delete', onDeletePress)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
keyDisabled: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
keyText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
keyTextDisabled: {
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
});
|
||||||
246
components/coach/ChatComposer.tsx
Normal file
246
components/coach/ChatComposer.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import QuickChips from './QuickChips';
|
||||||
|
import { QuickChip, SelectedImage } from './types';
|
||||||
|
|
||||||
|
interface ChatComposerProps {
|
||||||
|
input: string;
|
||||||
|
onInputChange: (text: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onPickImages: () => void;
|
||||||
|
onCancelRequest: () => void;
|
||||||
|
selectedImages: SelectedImage[];
|
||||||
|
onRemoveImage: (id: string) => void;
|
||||||
|
onPreviewImage: (uri: string) => void;
|
||||||
|
isSending: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
chips: QuickChip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatComposer: React.FC<ChatComposerProps> = ({
|
||||||
|
input,
|
||||||
|
onInputChange,
|
||||||
|
onSend,
|
||||||
|
onPickImages,
|
||||||
|
onCancelRequest,
|
||||||
|
selectedImages,
|
||||||
|
onRemoveImage,
|
||||||
|
onPreviewImage,
|
||||||
|
isSending,
|
||||||
|
isStreaming,
|
||||||
|
chips
|
||||||
|
}) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
|
const hasContent = input.trim() || selectedImages.length > 0;
|
||||||
|
const isActive = isSending || isStreaming;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlurView intensity={95} tint={colorScheme} style={styles.composerWrap}>
|
||||||
|
{/* 快捷操作按钮 */}
|
||||||
|
<QuickChips chips={chips} />
|
||||||
|
|
||||||
|
{/* 选中的图片预览 */}
|
||||||
|
{selectedImages.length > 0 && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.imagesRow}
|
||||||
|
contentContainerStyle={styles.imagesRowContent}
|
||||||
|
>
|
||||||
|
{selectedImages.map((img) => (
|
||||||
|
<View key={img.id} style={styles.imageThumbWrap}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onPreviewImage(img.localUri)}
|
||||||
|
style={styles.imageThumb}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: img.localUri }}
|
||||||
|
style={styles.imageThumb}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 上传进度 */}
|
||||||
|
{img.progress > 0 && img.progress < 1 && (
|
||||||
|
<View style={styles.imageProgressOverlay}>
|
||||||
|
<Text style={styles.imageProgressText}>
|
||||||
|
{Math.round(img.progress * 100)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传错误 */}
|
||||||
|
{img.error && (
|
||||||
|
<View style={styles.imageErrorOverlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {/* 重试上传逻辑 */ }}
|
||||||
|
style={styles.imageRetryBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={12} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemoveImage(img.id)}
|
||||||
|
style={styles.imageRemoveBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={12} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPickImages}
|
||||||
|
style={[styles.mediaBtn, { backgroundColor: `${theme.primary}20` }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="image-outline" size={18} color={theme.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="问我任何健康相关的问题,如营养、健身、生活管理等..."
|
||||||
|
placeholderTextColor={theme.textMuted}
|
||||||
|
style={[styles.input, { color: theme.text }]}
|
||||||
|
value={input}
|
||||||
|
onChangeText={onInputChange}
|
||||||
|
multiline
|
||||||
|
onSubmitEditing={onSend}
|
||||||
|
submitBehavior="blurAndSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!hasContent && !isActive}
|
||||||
|
onPress={isActive ? onCancelRequest : onSend}
|
||||||
|
style={[
|
||||||
|
styles.sendBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: isActive ? theme.danger : theme.primary,
|
||||||
|
opacity: (hasContent || isActive) ? 1 : 0.5
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<Ionicons name="stop" size={18} color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
composerWrap: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
imagesRow: {
|
||||||
|
maxHeight: 92,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
imagesRowContent: {
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
imageThumbWrap: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.08)',
|
||||||
|
},
|
||||||
|
imageThumb: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
imageRemoveBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
top: 4,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||||
|
},
|
||||||
|
imageProgressOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
imageProgressText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
imageErrorOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(255,0,0,0.35)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
imageRetryBtn: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
},
|
||||||
|
inputRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
mediaBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
maxHeight: 120,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
textAlignVertical: 'center',
|
||||||
|
},
|
||||||
|
sendBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChatComposer;
|
||||||
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;
|
||||||
128
components/coach/DietInputCard.tsx
Normal file
128
components/coach/DietInputCard.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface DietInputCardProps {
|
||||||
|
cardId: string;
|
||||||
|
dietTextInputs: Record<string, string>;
|
||||||
|
onDietTextInputChange: (cardId: string, value: string) => void;
|
||||||
|
onSubmitDietText: (cardId: string) => void;
|
||||||
|
onBackToDietOptions: (cardId: string) => void;
|
||||||
|
onShowDietPhotoActionSheet: (cardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DietInputCard: React.FC<DietInputCardProps> = ({
|
||||||
|
cardId,
|
||||||
|
dietTextInputs,
|
||||||
|
onDietTextInputChange,
|
||||||
|
onSubmitDietText,
|
||||||
|
onBackToDietOptions,
|
||||||
|
onShowDietPhotoActionSheet
|
||||||
|
}) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
|
||||||
|
<View style={styles.dietInputHeader}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dietBackBtn}
|
||||||
|
onPress={() => onBackToDietOptions(cardId)}
|
||||||
|
>
|
||||||
|
<Ionicons name="arrow-back" size={16} color={theme.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.dietInputTitle, { color: theme.text }]}>记录饮食</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.photoBtn, { backgroundColor: `${theme.primary}20` }]}
|
||||||
|
onPress={() => onShowDietPhotoActionSheet(cardId)}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera" size={16} color={theme.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={[styles.dietTextInput, {
|
||||||
|
color: theme.text,
|
||||||
|
borderColor: theme.border,
|
||||||
|
backgroundColor: theme.background
|
||||||
|
}]}
|
||||||
|
placeholder="描述你吃了什么,比如:早餐吃了一个苹果、一杯牛奶..."
|
||||||
|
placeholderTextColor={theme.textMuted}
|
||||||
|
value={dietTextInputs[cardId] || ''}
|
||||||
|
onChangeText={(text) => onDietTextInputChange(cardId, text)}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.dietSubmitBtn, { backgroundColor: theme.primary }]}
|
||||||
|
onPress={() => onSubmitDietText(cardId)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.submitButtonText, { color: theme.onPrimary }]}>提交</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
bubble: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 16,
|
||||||
|
maxWidth: '85%',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
minWidth: 300
|
||||||
|
},
|
||||||
|
dietInputHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
dietBackBtn: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
},
|
||||||
|
dietInputTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
photoBtn: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dietTextInput: {
|
||||||
|
minHeight: 80,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
dietSubmitBtn: {
|
||||||
|
height: 40,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DietInputCard;
|
||||||
123
components/coach/DietOptionsCard.tsx
Normal file
123
components/coach/DietOptionsCard.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface DietOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DietOptionsCardProps {
|
||||||
|
cardId: string;
|
||||||
|
onSelectOption: (cardId: string, optionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DietOptionsCard: React.FC<DietOptionsCardProps> = ({
|
||||||
|
cardId,
|
||||||
|
onSelectOption
|
||||||
|
}) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
|
const dietOptions: DietOption[] = [
|
||||||
|
{
|
||||||
|
id: 'photo',
|
||||||
|
title: '拍照记录',
|
||||||
|
description: '拍摄食物照片,AI自动识别',
|
||||||
|
icon: 'camera',
|
||||||
|
action: () => onSelectOption(cardId, 'photo')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text',
|
||||||
|
title: '文字描述',
|
||||||
|
description: '用文字描述你吃了什么',
|
||||||
|
icon: 'create',
|
||||||
|
action: () => onSelectOption(cardId, 'text')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>选择记录方式</Text>
|
||||||
|
<View style={styles.dietOptionsContainer}>
|
||||||
|
{dietOptions.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.id}
|
||||||
|
style={[styles.dietOptionBtn, {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
borderColor: `${theme.primary}4d`
|
||||||
|
}]}
|
||||||
|
onPress={option.action}
|
||||||
|
>
|
||||||
|
<View style={[styles.dietOptionIconContainer, {
|
||||||
|
backgroundColor: `${theme.primary}33`
|
||||||
|
}]}>
|
||||||
|
<Ionicons name={option.icon as any} size={20} color={theme.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dietOptionTextContainer}>
|
||||||
|
<Text style={[styles.dietOptionTitle, { color: theme.text }]}>
|
||||||
|
{option.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.dietOptionDesc, { color: theme.textMuted }]}>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
bubble: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 16,
|
||||||
|
maxWidth: '85%',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
minWidth: 300
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
dietOptionsContainer: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
dietOptionBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
dietOptionIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
dietOptionTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dietOptionTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
dietOptionDesc: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DietOptionsCard;
|
||||||
425
components/coach/DietPlanCard.tsx
Normal file
425
components/coach/DietPlanCard.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { selectUserAge } from '@/store/userSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface DietPlanCardProps {
|
||||||
|
onGeneratePlan: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DietPlanCard: React.FC<DietPlanCardProps> = ({ onGeneratePlan }) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const userProfile = useAppSelector((s) => s.user?.profile);
|
||||||
|
const userAge = useAppSelector(selectUserAge);
|
||||||
|
|
||||||
|
// 计算BMI
|
||||||
|
const calculateBMI = () => {
|
||||||
|
if (!userProfile?.weight || !userProfile?.height) return null;
|
||||||
|
const weight = Number(userProfile.weight);
|
||||||
|
const height = Number(userProfile.height) / 100; // 转换为米
|
||||||
|
return weight / (height * height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bmi = calculateBMI();
|
||||||
|
|
||||||
|
// 获取BMI状态
|
||||||
|
const getBMIStatus = (bmi: number) => {
|
||||||
|
if (bmi < 18.5) return { text: '偏瘦', color: '#3B82F6' };
|
||||||
|
if (bmi < 24) return { text: '正常', color: '#10B981' };
|
||||||
|
if (bmi < 28) return { text: '超重', color: '#F59E0B' };
|
||||||
|
return { text: '肥胖', color: '#EF4444' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const bmiStatus = bmi ? getBMIStatus(bmi) : null;
|
||||||
|
|
||||||
|
// 估算基础代谢率 (BMR)
|
||||||
|
const calculateBMR = () => {
|
||||||
|
if (!userProfile?.weight || !userProfile?.height || userAge === null) return null;
|
||||||
|
|
||||||
|
const weight = Number(userProfile.weight);
|
||||||
|
const height = Number(userProfile.height);
|
||||||
|
const age = userAge;
|
||||||
|
const gender = userProfile.gender;
|
||||||
|
|
||||||
|
// 使用 Mifflin-St Jeor 公式
|
||||||
|
if (gender === 'male') {
|
||||||
|
return Math.round(10 * weight + 6.25 * height - 5 * age + 5);
|
||||||
|
} else {
|
||||||
|
return Math.round(10 * weight + 6.25 * height - 5 * age - 161);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bmr = calculateBMR();
|
||||||
|
const dailyCalories = bmr ? Math.round(bmr * 1.4) : null; // 轻度活动系数
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.dietPlanContainer, {
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
borderColor: `${theme.primary}33`
|
||||||
|
}]}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<View style={styles.dietPlanHeader}>
|
||||||
|
<View style={styles.dietPlanTitleContainer}>
|
||||||
|
<Ionicons name="nutrition" size={24} color={theme.primary} />
|
||||||
|
<Text style={[styles.dietPlanTitle, { color: theme.text }]}>个性化饮食方案</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.dietPlanSubtitle, { color: theme.textMuted }]}>
|
||||||
|
基于你的身体数据定制
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 用户资料概览 */}
|
||||||
|
{userProfile && (
|
||||||
|
<View style={styles.profileSection}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.text }]}>个人资料</Text>
|
||||||
|
<View style={styles.profileDataRow}>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: theme.primary }]}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{userProfile.name?.charAt(0) || 'U'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.profileStats}>
|
||||||
|
{userProfile.weight && (
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statValue, { color: theme.text }]}>
|
||||||
|
{userProfile.weight}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.statLabel, { color: theme.textMuted }]}>kg</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{userProfile.height && (
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statValue, { color: theme.text }]}>
|
||||||
|
{userProfile.height}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.statLabel, { color: theme.textMuted }]}>cm</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{userAge !== null && (
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statValue, { color: theme.text }]}>
|
||||||
|
{userAge}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.statLabel, { color: theme.textMuted }]}>岁</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BMI 部分 */}
|
||||||
|
{bmi && bmiStatus && (
|
||||||
|
<View style={styles.bmiSection}>
|
||||||
|
<View style={styles.bmiHeader}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.text }]}>BMI 指数</Text>
|
||||||
|
<View style={[styles.bmiStatusBadge, { backgroundColor: bmiStatus.color }]}>
|
||||||
|
<Text style={styles.bmiStatusText}>{bmiStatus.text}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.bmiValue, { color: theme.text }]}>
|
||||||
|
{bmi.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* BMI 刻度条 */}
|
||||||
|
<View style={styles.bmiScale}>
|
||||||
|
<View style={[styles.bmiBar, { backgroundColor: '#3B82F6' }]} />
|
||||||
|
<View style={[styles.bmiBar, { backgroundColor: '#10B981' }]} />
|
||||||
|
<View style={[styles.bmiBar, { backgroundColor: '#F59E0B' }]} />
|
||||||
|
<View style={[styles.bmiBar, { backgroundColor: '#EF4444' }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.bmiLabels}>
|
||||||
|
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}>偏瘦</Text>
|
||||||
|
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}>正常</Text>
|
||||||
|
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}>超重</Text>
|
||||||
|
<Text style={[styles.bmiLabel, { color: theme.textMuted }]}>肥胖</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 可折叠的详细信息 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.collapsibleSection}
|
||||||
|
onPress={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<View style={styles.collapsibleHeader}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.text }]}>营养需求分析</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={20}
|
||||||
|
color={theme.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
{/* 卡路里需求 */}
|
||||||
|
{dailyCalories && (
|
||||||
|
<View style={styles.caloriesSection}>
|
||||||
|
<View style={styles.caloriesHeader}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.text }]}>每日卡路里需求</Text>
|
||||||
|
<Text style={[styles.caloriesValue, { color: '#10B981' }]}>
|
||||||
|
{dailyCalories} kcal
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 营养素分配 */}
|
||||||
|
<View style={styles.nutritionGrid}>
|
||||||
|
<View style={styles.nutritionItem}>
|
||||||
|
<Text style={[styles.nutritionValue, { color: theme.text }]}>55%</Text>
|
||||||
|
<View style={styles.nutritionLabelRow}>
|
||||||
|
<View style={[styles.nutritionDot, { backgroundColor: '#3B82F6' }]} />
|
||||||
|
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}>碳水</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.nutritionItem}>
|
||||||
|
<Text style={[styles.nutritionValue, { color: theme.text }]}>20%</Text>
|
||||||
|
<View style={styles.nutritionLabelRow}>
|
||||||
|
<View style={[styles.nutritionDot, { backgroundColor: '#10B981' }]} />
|
||||||
|
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}>蛋白质</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.nutritionItem}>
|
||||||
|
<Text style={[styles.nutritionValue, { color: theme.text }]}>25%</Text>
|
||||||
|
<View style={styles.nutritionLabelRow}>
|
||||||
|
<View style={[styles.nutritionDot, { backgroundColor: '#F59E0B' }]} />
|
||||||
|
<Text style={[styles.nutritionLabel, { color: theme.textMuted }]}>脂肪</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.nutritionNote, { color: theme.textMuted }]}>
|
||||||
|
* 营养素比例基于一般健康成人推荐标准,具体需求因人而异
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 生成方案按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.dietPlanButton, { backgroundColor: '#10B981' }]}
|
||||||
|
onPress={onGeneratePlan}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles" size={16} color="#FFFFFF" />
|
||||||
|
<Text style={styles.dietPlanButtonText}>生成个性化饮食方案</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 使用次数提示 */}
|
||||||
|
<View style={styles.usageCountContainer}>
|
||||||
|
<Ionicons name="information-circle" size={16} color={theme.primary} />
|
||||||
|
<Text style={[styles.usageText, { color: theme.primary }]}>
|
||||||
|
AI 将根据你的身体数据和健康目标制定专属方案
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
dietPlanContainer: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
gap: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
maxWidth: '85%',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
minWidth: 300
|
||||||
|
},
|
||||||
|
dietPlanHeader: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
dietPlanTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
dietPlanTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
dietPlanSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
profileSection: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
profileDataRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
profileStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
bmiSection: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
bmiHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
bmiStatusBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
bmiStatusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
bmiValue: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
bmiScale: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
bmiBar: {
|
||||||
|
flex: 1,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
bmiLabels: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
bmiLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
collapsibleSection: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
},
|
||||||
|
collapsibleHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
caloriesSection: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
caloriesHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
caloriesValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
nutritionGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
nutritionItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
nutritionValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
nutritionLabelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
nutritionDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
nutritionLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
nutritionNote: {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
dietPlanButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
dietPlanButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
usageCountContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.08)',
|
||||||
|
},
|
||||||
|
usageText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DietPlanCard;
|
||||||
66
components/coach/QuickChips.tsx
Normal file
66
components/coach/QuickChips.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { QuickChip } from './types';
|
||||||
|
|
||||||
|
interface QuickChipsProps {
|
||||||
|
chips: QuickChip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickChips: React.FC<QuickChipsProps> = ({ chips }) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.chipsRowScroll}
|
||||||
|
contentContainerStyle={styles.chipsRow}
|
||||||
|
>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={chip.key}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{
|
||||||
|
borderColor: theme.primary,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={chip.action}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, { color: theme.primary }]}>
|
||||||
|
{chip.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
chipsRowScroll: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
chipsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default QuickChips;
|
||||||
83
components/coach/WeightInputCard.tsx
Normal file
83
components/coach/WeightInputCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface WeightInputCardProps {
|
||||||
|
cardId: string;
|
||||||
|
weightInputs: Record<string, string>;
|
||||||
|
onWeightInputChange: (cardId: string, value: string) => void;
|
||||||
|
onSaveWeight: (cardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeightInputCard: React.FC<WeightInputCardProps> = ({
|
||||||
|
cardId,
|
||||||
|
weightInputs,
|
||||||
|
onWeightInputChange,
|
||||||
|
onSaveWeight
|
||||||
|
}) => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const theme = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.bubble, { backgroundColor: theme.surface }]}>
|
||||||
|
<View style={styles.weightRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.weightInput, { color: theme.text, borderColor: theme.border }]}
|
||||||
|
placeholder="输入体重"
|
||||||
|
placeholderTextColor={theme.textMuted}
|
||||||
|
value={weightInputs[cardId] || ''}
|
||||||
|
onChangeText={(text) => onWeightInputChange(cardId, text)}
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.weightUnit, { color: theme.text }]}>kg</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.weightSaveBtn, { backgroundColor: theme.primary }]}
|
||||||
|
onPress={() => onSaveWeight(cardId)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.saveButtonText, { color: theme.onPrimary }]}>保存</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
bubble: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 16,
|
||||||
|
maxWidth: '85%',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
minWidth: 250
|
||||||
|
},
|
||||||
|
weightRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
weightInput: {
|
||||||
|
flex: 1,
|
||||||
|
height: 36,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
},
|
||||||
|
weightUnit: {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
weightSaveBtn: {
|
||||||
|
height: 36,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WeightInputCard;
|
||||||
11
components/coach/index.ts
Normal file
11
components/coach/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Coach 组件导出
|
||||||
|
export { default as ChatComposer } from './ChatComposer';
|
||||||
|
export { default as ChatMessage } from './ChatMessage';
|
||||||
|
export { default as DietInputCard } from './DietInputCard';
|
||||||
|
export { default as DietOptionsCard } from './DietOptionsCard';
|
||||||
|
export { default as DietPlanCard } from './DietPlanCard';
|
||||||
|
export { default as QuickChips } from './QuickChips';
|
||||||
|
export { default as WeightInputCard } from './WeightInputCard';
|
||||||
|
|
||||||
|
// 类型导出
|
||||||
|
export * from './types';
|
||||||
88
components/coach/types.ts
Normal file
88
components/coach/types.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type Role = 'user' | 'assistant';
|
||||||
|
|
||||||
|
// 附件类型枚举
|
||||||
|
export type AttachmentType = 'image' | 'video' | 'file';
|
||||||
|
|
||||||
|
// 附件数据结构
|
||||||
|
export 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; // 上传错误信息
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI选择选项数据结构
|
||||||
|
export type AiChoiceOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
recommended?: boolean;
|
||||||
|
emoji?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI响应数据结构
|
||||||
|
export type AiResponseData = {
|
||||||
|
content: string;
|
||||||
|
choices?: AiChoiceOption[];
|
||||||
|
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
||||||
|
pendingData?: any;
|
||||||
|
context?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重构后的消息数据结构
|
||||||
|
export type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
content: string; // 文本内容
|
||||||
|
attachments?: MessageAttachment[]; // 附件列表
|
||||||
|
choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息)
|
||||||
|
interactionType?: string; // 交互类型
|
||||||
|
pendingData?: any; // 待确认数据
|
||||||
|
context?: any; // 上下文信息
|
||||||
|
};
|
||||||
|
|
||||||
|
// 卡片类型常量定义
|
||||||
|
export const CardType = {
|
||||||
|
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
|
||||||
|
DIET_INPUT: '__DIET_INPUT_CARD__',
|
||||||
|
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
|
||||||
|
DIET_PLAN: '__DIET_PLAN_CARD__',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CardType = typeof CardType[keyof typeof CardType];
|
||||||
|
|
||||||
|
// 定义路由参数类型
|
||||||
|
export type CoachScreenParams = {
|
||||||
|
name?: string;
|
||||||
|
action?: 'diet' | 'weight' | 'mood' | 'workout';
|
||||||
|
subAction?: 'record' | 'photo' | 'text' | 'card';
|
||||||
|
meal?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 快捷操作按钮类型
|
||||||
|
export type QuickChip = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选中的图片类型
|
||||||
|
export type SelectedImage = {
|
||||||
|
id: string;
|
||||||
|
localUri: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
progress: number;
|
||||||
|
uploadedKey?: string;
|
||||||
|
uploadedUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
@@ -327,16 +327,6 @@ export function WeightHistoryCard() {
|
|||||||
color={Colors.light.primary}
|
color={Colors.light.primary}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.addButton}
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigateToCoach();
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Ionicons name="add" size={16} color={Colors.light.primary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
183
components/weight/WeightRecordCard.tsx
Normal file
183
components/weight/WeightRecordCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { WeightHistoryItem } from '@/store/userSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Swipeable } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
|
interface WeightRecordCardProps {
|
||||||
|
record: WeightHistoryItem;
|
||||||
|
onPress?: (record: WeightHistoryItem) => void;
|
||||||
|
onDelete?: (recordId: string) => void;
|
||||||
|
weightChange?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||||
|
record,
|
||||||
|
onPress,
|
||||||
|
onDelete,
|
||||||
|
weightChange = 0
|
||||||
|
}) => {
|
||||||
|
const swipeableRef = useRef<Swipeable>(null);
|
||||||
|
|
||||||
|
// 处理删除操作
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
`确定要删除这条体重记录吗?此操作无法撤销。`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '取消',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
const recordId = record.id || record.createdAt;
|
||||||
|
onDelete?.(recordId);
|
||||||
|
swipeableRef.current?.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染删除按钮
|
||||||
|
const renderRightActions = () => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={handleDelete}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.deleteButtonText}>删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeableRef}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
rightThreshold={40}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.recordCard}
|
||||||
|
onPress={() => onPress?.(record)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.recordHeader}>
|
||||||
|
<Text style={styles.recordDateTime}>
|
||||||
|
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.recordEditButton}
|
||||||
|
onPress={() => onPress?.(record)}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={16} color="#FF9500" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.recordContent}>
|
||||||
|
<Text style={styles.recordWeightLabel}>体重:</Text>
|
||||||
|
<Text style={styles.recordWeightValue}>{record.weight}kg</Text>
|
||||||
|
{Math.abs(weightChange) > 0 && (
|
||||||
|
<View style={[
|
||||||
|
styles.weightChangeTag,
|
||||||
|
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
|
||||||
|
]}>
|
||||||
|
<Ionicons
|
||||||
|
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
|
||||||
|
size={12}
|
||||||
|
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.weightChangeText,
|
||||||
|
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
|
||||||
|
]}>
|
||||||
|
{Math.abs(weightChange).toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
recordCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
recordHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
recordDateTime: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#687076',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordEditButton: {
|
||||||
|
padding: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(255, 149, 0, 0.1)',
|
||||||
|
},
|
||||||
|
recordContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
recordWeightLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#687076',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordWeightValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginLeft: 4,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
weightChangeTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
weightChangeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 80,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
75
docs/weight-records-edit-delete.md
Normal file
75
docs/weight-records-edit-delete.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 体重记录编辑和删除功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
为体重记录页面添加了编辑和删除功能,用户可以:
|
||||||
|
|
||||||
|
1. **点击体重记录卡片**:唤出编辑弹窗,修改体重值
|
||||||
|
2. **左滑体重记录卡片**:显示删除按钮,支持删除记录
|
||||||
|
|
||||||
|
## 实现细节
|
||||||
|
|
||||||
|
### 1. 新增 Redux 操作
|
||||||
|
|
||||||
|
在 `store/userSlice.ts` 中添加了:
|
||||||
|
|
||||||
|
- `deleteWeightRecord`: 删除体重记录
|
||||||
|
- `updateWeightRecord`: 更新体重记录
|
||||||
|
- 为 `WeightHistoryItem` 类型添加了可选的 `id` 字段
|
||||||
|
|
||||||
|
### 2. 新增组件
|
||||||
|
|
||||||
|
创建了 `components/WeightRecordCard.tsx` 组件:
|
||||||
|
|
||||||
|
- 使用 `react-native-gesture-handler` 的 `Swipeable` 实现左滑删除
|
||||||
|
- 支持点击编辑功能
|
||||||
|
- 显示体重变化趋势(上升/下降箭头)
|
||||||
|
- 参考 `GoalCard` 的交互设计
|
||||||
|
|
||||||
|
### 3. 更新体重记录页面
|
||||||
|
|
||||||
|
在 `app/weight-records.tsx` 中:
|
||||||
|
|
||||||
|
- 集成新的 `WeightRecordCard` 组件
|
||||||
|
- 添加编辑体重记录的处理逻辑
|
||||||
|
- 添加删除体重记录的处理逻辑
|
||||||
|
- 扩展弹窗支持编辑现有记录
|
||||||
|
|
||||||
|
## 用户交互流程
|
||||||
|
|
||||||
|
### 编辑体重记录
|
||||||
|
|
||||||
|
1. 用户点击任意体重记录卡片
|
||||||
|
2. 弹出编辑弹窗,标题显示"编辑体重记录"
|
||||||
|
3. 输入框预填充当前体重值
|
||||||
|
4. 用户修改体重值并确认
|
||||||
|
5. 调用 API 更新记录,刷新列表
|
||||||
|
|
||||||
|
### 删除体重记录
|
||||||
|
|
||||||
|
1. 用户左滑体重记录卡片
|
||||||
|
2. 显示红色删除按钮
|
||||||
|
3. 点击删除按钮,弹出确认对话框
|
||||||
|
4. 用户确认后调用 API 删除记录
|
||||||
|
5. 从列表中移除该记录
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
需要后端支持以下接口:
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/users/weight-history/:recordId
|
||||||
|
PUT /api/users/weight-history/:recordId
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 由于原始数据结构中没有 `id` 字段,目前使用 `createdAt` 作为备用标识符
|
||||||
|
2. 建议后端返回的体重记录包含唯一的 `id` 字段
|
||||||
|
3. 删除和编辑操作都会重新加载体重历史数据以确保数据一致性
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- `react-native-gesture-handler`: 已安装,用于左滑手势
|
||||||
|
- 现有的 Redux 状态管理
|
||||||
|
- 现有的 API 服务层
|
||||||
@@ -18,16 +18,32 @@
|
|||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
||||||
<rect key="frame" x="90" y="295" width="260" height="367"/>
|
<rect key="frame" x="155" y="340" width="130" height="209"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" secondItem="EXPO-SplashScreen" secondAttribute="width" id="logo-height-constraint"/>
|
<constraint firstAttribute="height" secondItem="EXPO-SplashScreen" secondAttribute="width" id="logo-height-constraint"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</imageView>
|
</imageView>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" misplaced="YES" text="海豹健康" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-AppName" userLabel="AppName">
|
||||||
|
<rect key="frame" x="127" y="525" width="186" height="62"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="32"/>
|
||||||
|
<color key="textColor" red="0.20000000000000001" green="0.20000000000000001" blue="0.20000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" misplaced="YES" text="你的 AI 健康帮手" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-Subtitle" userLabel="Subtitle">
|
||||||
|
<rect key="frame" x="127" y="595" width="186" height="30"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
||||||
|
<color key="textColor" red="0.50000000000000000" green="0.50000000000000000" blue="0.50000000000000000" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
||||||
<color key="backgroundColor" name="SplashScreenBackground"/>
|
<color key="backgroundColor" name="SplashScreenBackground"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
|
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" constant="-50" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
|
||||||
|
<constraint firstItem="EXPO-AppName" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="app-name-center-x"/>
|
||||||
|
<constraint firstItem="EXPO-AppName" firstAttribute="top" secondItem="EXPO-SplashScreen" secondAttribute="bottom" constant="30" id="app-name-top-spacing"/>
|
||||||
|
<constraint firstItem="EXPO-Subtitle" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="subtitle-center-x"/>
|
||||||
|
<constraint firstItem="EXPO-Subtitle" firstAttribute="top" secondItem="EXPO-AppName" secondAttribute="bottom" constant="10" id="subtitle-top-spacing"/>
|
||||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
|
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
|
||||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="width" secondItem="EXPO-ContainerView" secondAttribute="width" multiplier="0.6" id="logo-width-constraint"/>
|
<constraint firstItem="EXPO-SplashScreen" firstAttribute="width" secondItem="EXPO-ContainerView" secondAttribute="width" multiplier="0.6" id="logo-width-constraint"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
@@ -35,13 +51,13 @@
|
|||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="-0.76335877862595414" y="0.0"/>
|
<point key="canvasLocation" x="-1.3636363636363638" y="0.0"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="SplashScreenLogo" width="682.66668701171875" height="682.66668701171875"/>
|
<image name="SplashScreenLogo" width="682.66668701171875" height="682.66668701171875"/>
|
||||||
<namedColor name="SplashScreenBackground">
|
<namedColor name="SplashScreenBackground">
|
||||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.96" green="0.96" blue="0.96" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
||||||
import { updateUser, UpdateUserDto } from '@/services/users';
|
import { updateUser, UpdateUserDto } from '@/services/users';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export type Gender = 'male' | 'female' | '';
|
export type Gender = 'male' | 'female' | '';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export type UserProfile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WeightHistoryItem = {
|
export type WeightHistoryItem = {
|
||||||
|
id: string; // 添加 id 字段用于删除和更新操作
|
||||||
weight: string;
|
weight: string;
|
||||||
source: string;
|
source: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -211,6 +213,32 @@ export const updateUserProfile = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 删除体重记录
|
||||||
|
export const deleteWeightRecord = createAsyncThunk(
|
||||||
|
'user/deleteWeightRecord',
|
||||||
|
async (recordId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/users/weight-records/${recordId}`);
|
||||||
|
return recordId;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '删除体重记录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新体重记录
|
||||||
|
export const updateWeightRecord = createAsyncThunk(
|
||||||
|
'user/updateWeightRecord',
|
||||||
|
async ({ id, weight }: { id: string; weight: number }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await api.put<WeightHistoryItem>(`/api/users/weight-records/${id}`, { weight });
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
return rejectWithValue(err?.message ?? '更新体重记录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const userSlice = createSlice({
|
const userSlice = createSlice({
|
||||||
name: 'user',
|
name: 'user',
|
||||||
initialState,
|
initialState,
|
||||||
@@ -286,9 +314,54 @@ const userSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(updateUserProfile.rejected, (state, action) => {
|
.addCase(updateUserProfile.rejected, (state, action) => {
|
||||||
state.error = (action.payload as string) ?? '更新用户资料失败';
|
state.error = (action.payload as string) ?? '更新用户资料失败';
|
||||||
|
})
|
||||||
|
.addCase(deleteWeightRecord.fulfilled, (state, action) => {
|
||||||
|
state.weightHistory = state.weightHistory.filter(record =>
|
||||||
|
record.id !== action.payload && record.createdAt !== action.payload
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.addCase(deleteWeightRecord.rejected, (state, action) => {
|
||||||
|
state.error = (action.payload as string) ?? '删除体重记录失败';
|
||||||
|
})
|
||||||
|
.addCase(updateWeightRecord.fulfilled, (state, action) => {
|
||||||
|
const index = state.weightHistory.findIndex(record =>
|
||||||
|
record.id === action.payload.id || record.createdAt === action.payload.id
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.weightHistory[index] = { ...state.weightHistory[index], ...action.payload };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateWeightRecord.rejected, (state, action) => {
|
||||||
|
state.error = (action.payload as string) ?? '更新体重记录失败';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
|
export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
export const selectUserProfile = (state: { user: UserState }) => state.user.profile;
|
||||||
|
|
||||||
|
// 计算用户年龄的 selector
|
||||||
|
export const selectUserAge = createSelector(
|
||||||
|
[selectUserProfile],
|
||||||
|
(profile) => {
|
||||||
|
if (!profile?.birthDate) return null;
|
||||||
|
|
||||||
|
const birthDate = dayjs(profile.birthDate);
|
||||||
|
const today = dayjs();
|
||||||
|
|
||||||
|
// 计算精确年龄(考虑月份和日期)
|
||||||
|
let age = today.year() - birthDate.year();
|
||||||
|
|
||||||
|
// 如果今年的生日还没到,年龄减1
|
||||||
|
if (today.month() < birthDate.month() ||
|
||||||
|
(today.month() === birthDate.month() && today.date() < birthDate.date())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default userSlice.reducer;
|
export default userSlice.reducer;
|
||||||
Reference in New Issue
Block a user