From 849447c5da83f8467a547e2e976b0ee2d9110c42 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 18 Aug 2025 10:05:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=B8=B8=E9=87=8F=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ROUTES 常量文件,集中管理应用路由 - 更新多个页面的导航逻辑,使用 ROUTES 常量替代硬编码路径 - 修改教练页面和今日训练页面的路由,提升代码可维护性 - 优化标签页和登录页面的导航,确保一致性和易用性 --- app/(tabs)/_layout.tsx | 15 +- app/(tabs)/coach.tsx | 425 +++++++++++++++++-------------------- app/(tabs)/index.tsx | 17 +- app/_layout.tsx | 1 + app/auth/login.tsx | 1 + app/onboarding/index.tsx | 7 +- app/workout/today.tsx | 5 +- components/ArticleCard.tsx | 3 +- constants/Routes.ts | 68 ++++++ 9 files changed, 289 insertions(+), 253 deletions(-) create mode 100644 constants/Routes.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 16ad7b8..a936690 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -7,6 +7,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { ROUTES } from '@/constants/Routes'; export default function TabLayout() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; @@ -17,9 +18,9 @@ export default function TabLayout() { { const routeName = route.name; - const isSelected = (routeName === 'index' && pathname === '/') || - (routeName === 'coach' && pathname === '/coach') || - (routeName === 'statistics' && pathname === '/statistics') || + const isSelected = (routeName === 'index' && pathname === ROUTES.TAB_HOME) || + (routeName === 'coach' && pathname === ROUTES.TAB_COACH) || + (routeName === 'statistics' && pathname === ROUTES.TAB_STATISTICS) || pathname.includes(routeName); return { @@ -39,7 +40,7 @@ export default function TabLayout() { const getIconAndTitle = () => { switch (routeName) { case 'index': - return { icon: 'house.fill', title: '首页' } as const; + return { icon: 'magnifyingglass.circle.fill', title: '发现' } as const; case 'coach': return { icon: 'person.3.fill', title: 'Bot' } as const; case 'statistics': @@ -157,12 +158,12 @@ export default function TabLayout() { { const isHomeSelected = pathname === '/' || pathname === '/index'; return ( - + {isHomeSelected && ( - 首页 + 发现 )} diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index 4ea2a82..317bd1a 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -24,16 +24,14 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useCosUpload } from '@/hooks/useCosUpload'; import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; import { api, getAuthToken, postTextStream } from '@/services/api'; -import { updateUser as updateUserApi } from '@/services/users'; -import type { CheckinRecord } from '@/store/checkinSlice'; -import { fetchMyProfile, fetchWeightHistory, updateProfile } from '@/store/userSlice'; import dayjs from 'dayjs'; +import { ActionSheet } from '../../components/ui/ActionSheet'; type Role = 'user' | 'assistant'; @@ -43,6 +41,15 @@ type ChatMessage = { content: string; }; +// 卡片类型常量定义 +const CardType = { + WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__', + DIET_INPUT: '__DIET_INPUT_CARD__', + DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__', +} as const; + +type CardType = typeof CardType[keyof typeof CardType]; + const COACH_AVATAR = require('@/assets/images/logo.png'); export default function CoachScreen() { @@ -84,10 +91,13 @@ export default function CoachScreen() { }>>([]); const [previewImageUri, setPreviewImageUri] = useState(null); const [dietTextInputs, setDietTextInputs] = useState>({}); + const [weightInputs, setWeightInputs] = useState>({}); + const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false); + const [currentCardId, setCurrentCardId] = useState(null); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => s.checkin || {}); - const dispatch = useAppDispatch(); + const userProfile = useAppSelector((s) => s.user?.profile); const { upload } = useCosUpload(); @@ -96,7 +106,7 @@ export default function CoachScreen() { const hour = new Date().getHours(); const name = userProfile?.name || '朋友'; const botName = (params?.name || 'Health Bot').toString(); - + // 时段问候 let timeGreeting = ''; if (hour >= 5 && hour < 9) { @@ -199,8 +209,8 @@ export default function CoachScreen() { // { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, // { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, // { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, - { key: 'weight', label: '记体重', action: () => insertWeightInputCard() }, - { key: 'diet', label: '记饮食', action: () => insertDietInputCard() }, + { key: 'weight', label: '#记体重', action: () => insertWeightInputCard() }, + { key: 'diet', label: '#记饮食', action: () => insertDietInputCard() }, ], [router, planDraft, checkin]); const scrollToEnd = useCallback(() => { @@ -213,7 +223,7 @@ export default function CoachScreen() { try { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {}; if (!contentOffset || !contentSize || !layoutMeasurement) return; - + const paddingToBottom = 60; const distanceFromBottom = (contentSize.height || 0) - ((layoutMeasurement.height || 0) + (contentOffset.y || 0)); setIsAtBottom(distanceFromBottom <= paddingToBottom); @@ -240,7 +250,7 @@ export default function CoachScreen() { // 启动页面时尝试恢复当次应用会话缓存 useEffect(() => { let isMounted = true; - + (async () => { try { const cached = await loadAiCoachSessionCache(); @@ -264,7 +274,7 @@ export default function CoachScreen() { } } })(); - + return () => { isMounted = false; }; @@ -276,21 +286,21 @@ export default function CoachScreen() { if (saveCacheTimerRef.current) { clearTimeout(saveCacheTimerRef.current); } - + // 只有在有实际消息内容时才保存缓存 if (messages.length > 1 || (messages.length === 1 && messages[0].id !== 'm_welcome')) { saveCacheTimerRef.current = setTimeout(() => { const validMessages = messages.filter(msg => msg && msg.role && msg.content); - saveAiCoachSessionCache({ - conversationId, - messages: validMessages, - updatedAt: Date.now() + saveAiCoachSessionCache({ + conversationId, + messages: validMessages, + updatedAt: Date.now() }).catch((error) => { console.warn('[AI_CHAT] Failed to save session cache:', error); }); }, 300); } - + return () => { if (saveCacheTimerRef.current) { clearTimeout(saveCacheTimerRef.current); @@ -314,7 +324,7 @@ export default function CoachScreen() { useEffect(() => { let showSub: any = null; let hideSub: any = null; - + if (Platform.OS === 'ios') { showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => { try { @@ -345,7 +355,7 @@ export default function CoachScreen() { setKeyboardOffset(0); }); } - + return () => { try { showSub?.remove?.(); @@ -370,7 +380,7 @@ export default function CoachScreen() { } catch (error) { console.warn('[AI_CHAT] Error aborting stream on unmount:', error); } - + if (saveCacheTimerRef.current) { clearTimeout(saveCacheTimerRef.current); saveCacheTimerRef.current = null; @@ -419,24 +429,25 @@ export default function CoachScreen() { if (isStreaming) { try { streamAbortRef.current?.abort(); } catch { } } - + // 清理当前会话状态 setConversationId(undefined); setSelectedImages([]); setDietTextInputs({}); - + setWeightInputs({}); + // 创建新的欢迎消息 initializeWelcomeMessage(); - + // 清理本地缓存 - saveAiCoachSessionCache({ - conversationId: undefined, - messages: [], - updatedAt: Date.now() + saveAiCoachSessionCache({ + conversationId: undefined, + messages: [], + updatedAt: Date.now() }).catch((error) => { console.warn('[AI_CHAT] Failed to clear session cache:', error); }); - + setTimeout(scrollToEnd, 100); } @@ -614,103 +625,21 @@ export default function CoachScreen() { } } - function handleQuickPlan() { - const goalMap: Record = { - postpartum_recovery: '产后恢复', - fat_loss: '减脂塑形', - posture_correction: '体态矫正', - core_strength: '核心力量', - flexibility: '柔韧灵活', - rehab: '康复保健', - stress_relief: '释压放松', - }; - const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升'; - const freq = planDraft?.mode === 'sessionsPerWeek' - ? `${planDraft?.sessionsPerWeek ?? 3}次/周` - : (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周'); - const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活'; - const prompt = `请根据我的目标"${goalText}"、频率"${freq}"、${prefer},制定1周的普拉提训练计划,包含每次训练主题、时长、主要动作与注意事项,并给出恢复建议。`; - send(prompt); - } - - function buildTrainingSummary(): string { - try { - const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[]; - if (!entries.length) return ''; - - const recent = entries - .filter(entry => entry && entry.date) // 过滤无效数据 - .sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))) - .slice(0, 14); - - let totalSessions = 0; - let totalExercises = 0; - let totalCompleted = 0; - const categoryCount: Record = {}; - const exerciseCount: Record = {}; - - for (const rec of recent) { - if (!rec?.items?.length) continue; - totalSessions += 1; - for (const it of rec.items) { - if (!it || typeof it !== 'object') continue; - totalExercises += 1; - if (it.completed) totalCompleted += 1; - if (it.category) { - categoryCount[it.category] = (categoryCount[it.category] || 0) + 1; - } - if (it.name) { - exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1; - } - } - } - - const topCategories = Object.entries(categoryCount) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([k, v]) => `${k}×${v}`); - - const topExercises = Object.entries(exerciseCount) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([k, v]) => `${k}×${v}`); - - return [ - `统计周期:最近${recent.length}天(按有记录日计 ${totalSessions} 天)`, - `记录条目:${totalExercises},完成标记:${totalCompleted}`, - topCategories.length ? `高频类别:${topCategories.join(',')}` : '', - topExercises.length ? `高频动作:${topExercises.join(',')}` : '', - ].filter(Boolean).join('\n'); - } catch (error) { - console.warn('[AI_CHAT] Error building training summary:', error); - return ''; - } - } - - function handleAnalyzeRecords() { - const summary = buildTrainingSummary(); - if (!summary) { - send('我还没有可分析的打卡记录,请先在"每日打卡"添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。'); - return; - } - const prompt = `请基于以下我的近期训练记录进行分析,输出:1)整体训练负荷与节奏;2)动作与肌群的均衡性(指出偏多/偏少);3)容易忽视的恢复与热身建议;4)后续一周的优化建议(频次/时长/动作方向)。\n\n${summary}`; - send(prompt); - } const uploadImage = useCallback(async (img: any) => { if (!img?.localUri || !img?.id) { console.warn('[AI_CHAT] Invalid image data for upload:', img); return; } - + try { const { url } = await upload( { uri: img.localUri, name: img.id, type: 'image/jpeg' }, { prefix: 'images/chat' } ); - + if (url) { - setSelectedImages((prev) => prev.map((it) => + setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedUrl: url, progress: 1, error: undefined } : it )); } else { @@ -718,7 +647,7 @@ export default function CoachScreen() { } } catch (e: any) { console.error('[AI_CHAT] Image upload failed:', e); - setSelectedImages((prev) => prev.map((it) => + setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败', progress: 0 } : it )); } @@ -732,15 +661,15 @@ export default function CoachScreen() { selectionLimit: 4, quality: 0.9, } as any); - + if ((result as any).canceled) return; - + const assets = (result as any).assets || []; if (!Array.isArray(assets) || assets.length === 0) { console.warn('[AI_CHAT] No valid assets returned from image picker'); return; } - + const next = assets .filter(a => a && a.uri) // 过滤无效的资源 .map((a: any) => ({ @@ -750,19 +679,19 @@ export default function CoachScreen() { height: a.height, progress: 0, })); - + if (next.length === 0) { Alert.alert('错误', '未选择有效的图片'); return; } - + setSelectedImages((prev) => { const merged = [...prev, ...next]; return merged.slice(0, 4); }); - + setTimeout(scrollToEnd, 100); - + // 立即开始上传新选择的图片 for (const img of next) { uploadImage(img); @@ -785,9 +714,6 @@ export default function CoachScreen() { layout={Layout.springify().damping(18)} style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]} > - {!isUser && ( - - )} 正在思考…; } - - if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) { + + if (item.content?.startsWith(CardType.WEIGHT_INPUT)) { + const cardId = item.id; const preset = (() => { try { const m = item.content.split('\n')?.[1]; @@ -821,7 +748,10 @@ export default function CoachScreen() { return ''; } })(); - + + // 初始化输入值(如果还没有的话) + const currentValue = weightInputs[cardId] ?? preset; + return ( 记录今日体重 @@ -829,36 +759,37 @@ export default function CoachScreen() { handleSubmitWeight(e.nativeEvent.text)} + onChangeText={(text) => setWeightInputs(prev => ({ ...prev, [cardId]: text }))} + onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)} returnKeyType="done" submitBehavior="blurAndSubmit" /> kg - handleSubmitWeight(preset || '')} + handleSubmitWeight(currentValue, cardId)} > - 保存 + 记录 按回车或点击保存,即可将该体重同步到账户并发送到对话。 ); } - - if (item.content?.startsWith('__DIET_INPUT_CARD__')) { + + if (item.content?.startsWith(CardType.DIET_INPUT)) { return ( 记录今日饮食 请选择记录方式: - + - handleDietTextInput(item.id)} > @@ -870,9 +801,9 @@ export default function CoachScreen() { 输入吃了什么、大概多少克 - - handleDietPhotoInput(item.id)} > @@ -885,29 +816,29 @@ export default function CoachScreen() { - + 选择合适的方式记录您的饮食,Health Bot会根据您的饮食情况给出专业的营养建议。 ); } - - if (item.content?.startsWith('__DIET_TEXT_INPUT__')) { + + if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) { const cardId = item.content.split('\n')?.[1] || ''; const currentText = dietTextInputs[cardId] || ''; - + return ( 文字记录饮食 - handleBackToDietOptions(cardId)} style={styles.dietBackBtn} > - + setDietTextInputs(prev => ({ ...prev, [cardId]: text }))} returnKeyType="done" - blurOnSubmit={false} /> - - handleSubmitDietText(currentText, cardId)} > 发送记录 - + 详细描述您的饮食内容和分量,有助于Health Bot给出更精准的营养分析和建议。 ); } - + return ( {item.content || ''} @@ -944,45 +874,32 @@ export default function CoachScreen() { function insertWeightInputCard() { const id = `wcard_${Date.now()}`; const preset = userProfile?.weight ? Number(userProfile.weight) : undefined; - const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`; + const payload = `${CardType.WEIGHT_INPUT}\n${preset ?? ''}`; setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]); setTimeout(scrollToEnd, 100); } - async function handleSubmitWeight(text?: string) { + async function handleSubmitWeight(text?: string, cardId?: string) { const val = parseFloat(String(text ?? '').trim()); if (isNaN(val) || val <= 0 || val > 500) { Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5'); return; } - + try { - // 本地更新 - dispatch(updateProfile({ weight: String(val) })); - - // 后端同步(尝试从不同可能的字段获取用户ID) - try { - // 从后端响应的原始数据中查找可能的用户ID字段 - const rawUserData = await api.get('/api/users/info'); - const userId = rawUserData?.id || rawUserData?.userId || rawUserData?._id || - rawUserData?.user?.id || rawUserData?.user?.userId || rawUserData?.user?._id || - rawUserData?.profile?.id || rawUserData?.profile?.userId || rawUserData?.profile?._id; - - if (userId) { - await updateUserApi({ userId, weight: val }); - await dispatch(fetchMyProfile() as any); - // 刷新体重历史记录 - await dispatch(fetchWeightHistory() as any); - } else { - console.warn('[AI_CHAT] No user ID found for weight sync'); - } - } catch (syncError) { - console.warn('[AI_CHAT] Failed to sync weight to server:', syncError); - // 不阻断对话体验,但可以给用户一个提示 + // 清理该卡片的输入状态 + if (cardId) { + setWeightInputs(prev => { + const { [cardId]: _, ...rest } = prev; + return rest; + }); + + // 移除体重输入卡片 + setMessages((prev) => prev.filter(msg => msg.id !== cardId)); } - + // 在对话中插入"确认消息"并发送给教练 - const textMsg = `记录了今日体重:${val} kg。`; + const textMsg = `#记体重:\n\n${val} kg`; await send(textMsg); } catch (e: any) { console.error('[AI_CHAT] Error handling weight submission:', e); @@ -992,25 +909,30 @@ export default function CoachScreen() { function insertDietInputCard() { const id = `dcard_${Date.now()}`; - const payload = `__DIET_INPUT_CARD__\n${id}`; + const payload = `${CardType.DIET_INPUT}\n${id}`; setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]); setTimeout(scrollToEnd, 100); } function handleDietTextInput(cardId: string) { // 替换当前的饮食选择卡片为文字输入卡片 - const payload = `__DIET_TEXT_INPUT__\n${cardId}`; - setMessages((prev) => prev.map(msg => - msg.id === cardId + const payload = `${CardType.DIET_TEXT_INPUT}\n${cardId}`; + setMessages((prev) => prev.map(msg => + msg.id === cardId ? { ...msg, content: payload } : msg )); setTimeout(scrollToEnd, 100); } - async function handleDietPhotoInput(cardId: string) { + function handleDietPhotoInput(cardId: string) { + console.log('[DIET] handleDietPhotoInput called with cardId:', cardId); + setCurrentCardId(cardId); + setShowDietPhotoActionSheet(true); + } + + async function handleCameraPhoto() { try { - // 使用现有的拍照功能 const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); if (permissionResult.status !== 'granted') { Alert.alert('权限不足', '需要相机权限以拍摄食物照片'); @@ -1025,24 +947,7 @@ export default function CoachScreen() { }); if (!result.canceled && result.assets?.[0]) { - const asset = result.assets[0]; - try { - // 上传图片 - const { url } = await upload( - { uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' }, - { prefix: 'images/diet' } - ); - - // 移除饮食选择卡片 - setMessages((prev) => prev.filter(msg => msg.id !== cardId)); - - // 发送包含图片的饮食记录消息 - const dietMsg = `拍摄了食物照片,请Health Bot帮我分析这餐的营养成分、热量和健康建议:\n\n![食物照片](${url})`; - await send(dietMsg); - } catch (uploadError) { - console.error('[DIET] 图片上传失败:', uploadError); - Alert.alert('上传失败', '图片上传失败,请重试'); - } + await processSelectedImage(result.assets[0]); } } catch (e: any) { console.error('[DIET] 拍照失败:', e); @@ -1050,11 +955,57 @@ export default function CoachScreen() { } } + async function handleLibraryPhoto() { + try { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permissionResult.status !== 'granted') { + Alert.alert('权限不足', '需要相册权限以选择食物照片'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.9, + aspect: [4, 3], + }); + + if (!result.canceled && result.assets?.[0]) { + await processSelectedImage(result.assets[0]); + } + } catch (e: any) { + console.error('[DIET] 选择照片失败:', e); + Alert.alert('选择照片失败', e?.message || '选择照片失败,请重试'); + } + } + + async function processSelectedImage(asset: ImagePicker.ImagePickerAsset) { + if (!currentCardId) return; + + try { + // 上传图片 + const { url } = await upload( + { uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' }, + { prefix: 'images/diet' } + ); + + // 移除饮食选择卡片 + setMessages((prev) => prev.filter(msg => msg.id !== currentCardId)); + + // 发送包含图片的饮食记录消息 + const dietMsg = `#记饮食:\n\n![食物照片](${url})`; + await send(dietMsg); + } catch (uploadError) { + console.error('[DIET] 图片上传失败:', uploadError); + Alert.alert('上传失败', '图片上传失败,请重试'); + } + } + function handleBackToDietOptions(cardId: string) { // 返回到饮食选择界面 - const payload = `__DIET_INPUT_CARD__\n${cardId}`; - setMessages((prev) => prev.map(msg => - msg.id === cardId + const payload = `${CardType.DIET_INPUT}\n${cardId}`; + setMessages((prev) => prev.map(msg => + msg.id === cardId ? { ...msg, content: payload } : msg )); @@ -1071,13 +1022,13 @@ export default function CoachScreen() { try { // 移除饮食输入卡片 setMessages((prev) => prev.filter(msg => msg.id !== cardId)); - + // 清理输入状态 setDietTextInputs(prev => { const { [cardId]: _, ...rest } = prev; return rest; }); - + // 发送饮食记录消息 const dietMsg = `记录了今日饮食:${trimmedText}`; await send(dietMsg); @@ -1090,7 +1041,7 @@ export default function CoachScreen() { return ( {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} - { const h = e.nativeEvent.layout.height; @@ -1099,16 +1050,16 @@ export default function CoachScreen() { > {botName} - - @@ -1117,9 +1068,9 @@ export default function CoachScreen() { {/* 消息列表容器 - 设置固定高度避免输入框重叠 */} - { // 首次内容变化强制滚底,其余仅在接近底部时滚动 @@ -1199,9 +1150,9 @@ export default function CoachScreen() { )} {img.error && ( - uploadImage(img)} + uploadImage(img)} style={styles.imageRetryBtn} > @@ -1232,7 +1183,7 @@ export default function CoachScreen() { onChangeText={setInput} multiline onSubmitEditing={() => send(input)} - blurOnSubmit={false} + submitBehavior="blurAndSubmit" /> @@ -1320,6 +1271,16 @@ export default function CoachScreen() { + setShowDietPhotoActionSheet(false)} + title="选择图片来源" + options={[ + { id: 'camera', title: '拍照', onPress: handleCameraPhoto }, + { id: 'library', title: '从相册选择', onPress: handleLibraryPhoto }, + { id: 'cancel', title: '取消', onPress: () => setShowDietPhotoActionSheet(false) } + ]} + /> ); } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6e89f42..12baaba 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -16,6 +16,7 @@ import { useRouter } from 'expo-router'; import React from 'react'; import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ROUTES, QUERY_PARAMS, ROUTE_PARAMS } from '@/constants/Routes'; // 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片 @@ -76,7 +77,7 @@ export default function HomeScreen() { Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => { if (!dragState.current.moved) { // 切换到教练 tab,并传递name参数 - router.push('/coach?name=Iris' as any); + router.push(`${ROUTES.TAB_COACH}?${QUERY_PARAMS.COACH_NAME}=Iris` as any); } }); }, @@ -113,7 +114,7 @@ export default function HomeScreen() { title: '今日训练', subtitle: '完成一次普拉提训练,记录你的坚持', level: '初学者', - onPress: () => pushIfAuthedElseLogin('/workout/today'), + onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY), }, { type: 'plan', @@ -123,7 +124,7 @@ export default function HomeScreen() { title: '体态评估', subtitle: '评估你的体态,制定训练计划', level: '初学者', - onPress: () => router.push('/ai-posture-assessment'), + onPress: () => router.push(ROUTES.AI_POSTURE_ASSESSMENT), }, ...listRecommendedArticles().map((a) => ({ type: 'article' as const, @@ -185,7 +186,7 @@ export default function HomeScreen() { image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', title: c.title || '今日训练', subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持', - onPress: () => pushIfAuthedElseLogin('/workout/today'), + onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY), }); } } @@ -204,7 +205,7 @@ export default function HomeScreen() { const handlePlanCardPress = () => { if (activePlan) { // 跳转到训练计划页面的锻炼tab,并传递planId参数 - router.push(`/training-plan?planId=${activePlan.id}&tab=schedule` as any); + router.push(`${ROUTES.TRAINING_PLAN}?${ROUTE_PARAMS.TRAINING_PLAN_ID}=${activePlan.id}&${ROUTE_PARAMS.TRAINING_PLAN_TAB}=${QUERY_PARAMS.TRAINING_PLAN_TAB_SCHEDULE}` as any); } }; @@ -275,7 +276,7 @@ export default function HomeScreen() { pushIfAuthedElseLogin('/workout/today')} + onPress={() => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY)} > @@ -290,7 +291,7 @@ export default function HomeScreen() { pushIfAuthedElseLogin('/ai-posture-assessment')} + onPress={() => pushIfAuthedElseLogin(ROUTES.AI_POSTURE_ASSESSMENT)} > pushIfAuthedElseLogin('/training-plan')} + onPress={() => pushIfAuthedElseLogin(ROUTES.TRAINING_PLAN)} > diff --git a/app/_layout.tsx b/app/_layout.tsx index df513ae..cd2f645 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,7 @@ import Toast from 'react-native-toast-message'; import { DialogProvider } from '@/components/ui/DialogProvider'; import { Provider } from 'react-redux'; +import { ROUTES } from '@/constants/Routes'; function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 65f8d7b..edf32f9 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -14,6 +14,7 @@ import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { login } from '@/store/userSlice'; import Toast from 'react-native-toast-message'; +import { ROUTES } from '@/constants/Routes'; export default function LoginScreen() { const router = useRouter(); diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx index 8673ce3..751c1e8 100644 --- a/app/onboarding/index.tsx +++ b/app/onboarding/index.tsx @@ -13,6 +13,7 @@ import { TouchableOpacity, View } from 'react-native'; +import { ROUTES } from '@/constants/Routes'; const { width, height } = Dimensions.get('window'); @@ -23,16 +24,16 @@ export default function WelcomeScreen() { const textColor = useThemeColor({}, 'text'); const handleGetStarted = () => { - router.push('/onboarding/personal-info'); + router.push(ROUTES.ONBOARDING_PERSONAL_INFO); }; const handleSkip = async () => { try { await AsyncStorage.setItem('@onboarding_completed', 'true'); - router.replace('/(tabs)'); + router.replace(ROUTES.TAB_HOME); } catch (error) { console.error('保存引导状态失败:', error); - router.replace('/(tabs)'); + router.replace(ROUTES.TAB_HOME); } }; diff --git a/app/workout/today.tsx b/app/workout/today.tsx index b3e3817..adae40e 100644 --- a/app/workout/today.tsx +++ b/app/workout/today.tsx @@ -22,6 +22,7 @@ import { startWorkoutSession } from '@/store/workoutSlice'; import dayjs from 'dayjs'; +import { ROUTES } from '@/constants/Routes'; // ==================== 工具函数 ==================== @@ -276,7 +277,7 @@ export default function TodayWorkoutScreen() { iconColor: '#10B981', onPress: () => { // 跳转到创建页面选择训练计划 - router.push('/workout/create-session'); + router.push(ROUTES.WORKOUT_CREATE_SESSION); } } ] @@ -478,7 +479,7 @@ export default function TodayWorkoutScreen() { { - router.push(`/workout/session/${item.id}`); + router.push(`${ROUTES.WORKOUT_SESSION}/${item.id}`); }} activeOpacity={0.9} > diff --git a/components/ArticleCard.tsx b/components/ArticleCard.tsx index 5fbbdd5..b5789b0 100644 --- a/components/ArticleCard.tsx +++ b/components/ArticleCard.tsx @@ -2,6 +2,7 @@ import dayjs from 'dayjs'; import { useRouter } from 'expo-router'; import React from 'react'; import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import { ROUTES } from '@/constants/Routes'; type Props = { id: string; @@ -14,7 +15,7 @@ type Props = { export function ArticleCard({ id, title, coverImage, publishedAt, readCount }: Props) { const router = useRouter(); return ( - router.push(`/article/${id}`)} style={styles.card}> + router.push(`${ROUTES.ARTICLE}/${id}`)} style={styles.card}> {title} diff --git a/constants/Routes.ts b/constants/Routes.ts new file mode 100644 index 0000000..ae9e997 --- /dev/null +++ b/constants/Routes.ts @@ -0,0 +1,68 @@ +// 应用路由常量定义 +export const ROUTES = { + // Tab路由 + TAB_HOME: '/', + TAB_COACH: '/coach', + TAB_STATISTICS: '/statistics', + TAB_PERSONAL: '/personal', + + // 训练相关路由 + WORKOUT_TODAY: '/workout/today', + WORKOUT_CREATE_SESSION: '/workout/create-session', + WORKOUT_SESSION: '/workout/session', + + // 训练计划相关路由 + TRAINING_PLAN: '/training-plan', + + // 体态评估路由 + AI_POSTURE_ASSESSMENT: '/ai-posture-assessment', + + // 挑战路由 + CHALLENGE: '/challenge', + CHALLENGE_DAY: '/challenge/day', + + // 文章路由 + ARTICLE: '/article', + + // 用户相关路由 + AUTH_LOGIN: '/auth/login', + PROFILE_EDIT: '/profile/edit', + PROFILE_GOALS: '/profile/goals', + + // 法律相关路由 + LEGAL_USER_AGREEMENT: '/legal/user-agreement', + LEGAL_PRIVACY_POLICY: '/legal/privacy-policy', + + // 引导页路由 + ONBOARDING: '/onboarding', + ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info', +} as const; + +// 路由参数常量 +export const ROUTE_PARAMS = { + // 训练会话参数 + WORKOUT_SESSION_ID: 'id', + + // 训练计划参数 + TRAINING_PLAN_ID: 'planId', + TRAINING_PLAN_TAB: 'tab', + + // 挑战日参数 + CHALLENGE_DAY: 'day', + + // 文章参数 + ARTICLE_ID: 'id', + + // 重定向参数 + REDIRECT_TO: 'redirectTo', + REDIRECT_PARAMS: 'redirectParams', +} as const; + +// 查询参数常量 +export const QUERY_PARAMS = { + // 训练计划查询参数 + TRAINING_PLAN_TAB_SCHEDULE: 'schedule', + + // 教练页面参数 + COACH_NAME: 'name', +} as const; \ No newline at end of file