diff --git a/app.json b/app.json index 645272d..0f4f770 100644 --- a/app.json +++ b/app.json @@ -1,10 +1,10 @@ { "expo": { - "name": "普拉提助手", + "name": "Health Bot", "slug": "digital-pilates", "version": "1.0.4", "orientation": "portrait", - "icon": "./assets/images/logo.jpeg", + "icon": "./assets/images/logo.png", "scheme": "digitalpilates", "userInterfaceStyle": "light", "newArchEnabled": true, @@ -21,7 +21,7 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/images/logo.jpeg", + "foregroundImage": "./assets/images/logo.png", "backgroundColor": "#ffffff" }, "edgeToEdgeEnabled": true, @@ -30,14 +30,14 @@ "web": { "bundler": "metro", "output": "static", - "favicon": "./assets/images/logo.jpeg" + "favicon": "./assets/images/logo.png" }, "plugins": [ "expo-router", [ "expo-splash-screen", { - "image": "./assets/images/logo.jpeg", + "image": "./assets/images/logo.png", "imageWidth": 200, "resizeMode": "contain", "backgroundColor": "#ffffff" diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index cca2d01..b19e314 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -41,7 +41,7 @@ export default function TabLayout() { case 'index': return { icon: 'house.fill', title: '首页' } as const; case 'coach': - return { icon: 'person.3.fill', title: '教练' } as const; + return { icon: 'person.3.fill', title: 'Bot' } as const; case 'explore': return { icon: 'paperplane.fill', title: '探索' } as const; case 'personal': @@ -156,7 +156,7 @@ export default function TabLayout() { { const isCoachSelected = pathname === '/coach'; return ( @@ -173,7 +173,7 @@ export default function TabLayout() { textAlign: 'center', flexShrink: 0, }}> - 教练 + Bot )} diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index 1672693..4ea2a82 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -23,17 +23,16 @@ import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors } from '@/constants/Colors'; -import { buildCosKey, buildPublicUrl } from '@/constants/Cos'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, 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 { uploadWithRetry } from '@/services/cos'; import { updateUser as updateUserApi } from '@/services/users'; import type { CheckinRecord } from '@/store/checkinSlice'; -import { fetchMyProfile, updateProfile } from '@/store/userSlice'; +import { fetchMyProfile, fetchWeightHistory, updateProfile } from '@/store/userSlice'; import dayjs from 'dayjs'; type Role = 'user' | 'assistant'; @@ -44,7 +43,7 @@ type ChatMessage = { content: string; }; -const COACH_AVATAR = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg'; +const COACH_AVATAR = require('@/assets/images/logo.png'); export default function CoachScreen() { const router = useRouter(); @@ -54,16 +53,12 @@ export default function CoachScreen() { const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 const theme = Colors.light; - const coachName = (params?.name || 'Sarah').toString(); + const botName = (params?.name || 'Bot').toString(); const [input, setInput] = useState(''); const [isSending, setIsSending] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [conversationId, setConversationId] = useState(undefined); - const [messages, setMessages] = useState([{ - id: 'm_welcome', - role: 'assistant', - content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, - }]); + const [messages, setMessages] = useState([]); const [historyVisible, setHistoryVisible] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyPage, setHistoryPage] = useState(1); @@ -88,17 +83,124 @@ export default function CoachScreen() { error?: string; }>>([]); const [previewImageUri, setPreviewImageUri] = useState(null); + const [dietTextInputs, setDietTextInputs] = useState>({}); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); - const checkin = useAppSelector((s) => (s as any).checkin); + const checkin = useAppSelector((s) => s.checkin || {}); const dispatch = useAppDispatch(); - const userProfile = useAppSelector((s) => (s as any)?.user?.profile); + const userProfile = useAppSelector((s) => s.user?.profile); + const { upload } = useCosUpload(); + + // 生成个性化欢迎消息 + const generateWelcomeMessage = useCallback(() => { + const hour = new Date().getHours(); + const name = userProfile?.name || '朋友'; + const botName = (params?.name || 'Health Bot').toString(); + + // 时段问候 + let timeGreeting = ''; + if (hour >= 5 && hour < 9) { + timeGreeting = '早上好'; + } else if (hour >= 9 && hour < 12) { + timeGreeting = '上午好'; + } else if (hour >= 12 && hour < 14) { + timeGreeting = '中午好'; + } else if (hour >= 14 && hour < 18) { + timeGreeting = '下午好'; + } else if (hour >= 18 && hour < 22) { + timeGreeting = '晚上好'; + } else { + timeGreeting = '夜深了'; + } + + // 欢迎消息模板 + const welcomeMessages = [ + { + condition: () => hour >= 5 && hour < 9, + messages: [ + `${timeGreeting},${name}!我是${botName},你的专属健康管理助手。新的一天开始了,让我们一起为你的健康目标努力吧!`, + `${timeGreeting}!早晨是制定健康计划的最佳时机,我是${botName},可以帮你管理营养摄入、运动计划和生活作息。`, + `${timeGreeting},${name}!作为你的Health Bot,我很高兴能陪伴你的健康之旅。无论是饮食营养、健身锻炼还是生活管理,我都能为你提供专业建议。` + ] + }, + { + condition: () => hour >= 9 && hour < 12, + messages: [ + `${timeGreeting},${name}!我是${botName},你的智能健康顾问。上午是身体代谢最活跃的时候,有什么健康目标需要我帮你规划吗?`, + `${timeGreeting}!工作忙碌的上午,别忘了关注身体健康。我是${botName},可以为你提供营养建议、运动指导和压力管理方案。`, + `${timeGreeting},${name}!作为你的健康伙伴${botName},我想说:每一个健康的选择都在塑造更好的你。今天想从哪个方面开始呢?` + ] + }, + { + condition: () => hour >= 12 && hour < 14, + messages: [ + `${timeGreeting},${name}!午餐时间很关键呢,合理的营养搭配能为下午提供充足能量。我是${botName},可以为你分析饮食营养和热量管理。`, + `${timeGreeting}!忙碌的上午结束了,该关注一下身体需求啦。我是你的健康助手${botName},无论是饮食调整、运动安排还是休息建议,都可以找我。`, + `${timeGreeting},${name}!午间是调整状态的好时机。作为你的Health Bot,我建议关注饮食均衡和适度放松~` + ] + }, + { + condition: () => hour >= 14 && hour < 18, + messages: [ + `${timeGreeting},${name}!下午是身体活动的黄金时段,适合安排一些运动。我是${botName},可以为你制定个性化的健身计划和身材管理方案。`, + `${timeGreeting}!午后时光,正是关注整体健康的好时机。我是你的健康管家${botName},从营养摄入到运动锻炼,我都能为你提供科学指导。`, + `${timeGreeting},${name}!下午时光,身心健康同样重要。作为你的智能健康顾问${botName},我在这里支持你的每一个健康目标。` + ] + }, + { + condition: () => hour >= 18 && hour < 22, + messages: [ + `${timeGreeting},${name}!忙碌了一天,现在是时候关注身心平衡了。我是${botName},可以为你提供放松建议、营养补充和恢复方案。`, + `${timeGreeting}!夜幕降临,这是一天中最适合总结和调整的时刻。我是你的健康伙伴${botName},让我们一起回顾今天的健康表现,规划明天的目标。`, + `${timeGreeting},${name}!晚间时光属于你自己,也是关爱身体的珍贵时间。作为你的Health Bot,我想陪你聊聊如何更好地管理健康生活。` + ] + }, + { + condition: () => hour >= 22 || hour < 5, + messages: [ + `${timeGreeting},${name}!优质睡眠是健康的基石呢。我是${botName},如果需要睡眠优化建议或放松技巧,随时可以问我。`, + `夜深了,${name}。充足的睡眠对身体恢复和新陈代谢都很重要。我是你的健康助手${botName},有什么关于睡眠健康的问题都可以咨询我。`, + `夜深了,愿你能拥有高质量的睡眠。我是${botName},明天我们继续在健康管理的路上同行。晚安,${name}!` + ] + } + ]; + + // 特殊情况的消息 + const specialMessages = [ + { + condition: () => !userProfile?.weight && !userProfile?.height, + message: `你好,${name}!我是${botName},你的智能健康管理助手。我注意到你还没有完善健康档案,不如先聊聊你的健康目标和身体状况,这样我能为你制定更个性化的健康方案。` + }, + { + condition: () => userProfile && (!userProfile.pilatesPurposes || userProfile.pilatesPurposes.length === 0), + message: `${timeGreeting},${name}!作为你的Health Bot,我想更好地了解你的健康需求。告诉我你希望在营养摄入、身材管理、健身锻炼或生活管理方面实现什么目标吧~` + } + ]; + + // 检查特殊情况 + for (const special of specialMessages) { + if (special.condition()) { + return special.message; + } + } + + // 根据时间选择合适的消息组 + const timeGroup = welcomeMessages.find(group => group.condition()); + if (timeGroup) { + const messages = timeGroup.messages; + return messages[Math.floor(Math.random() * messages.length)]; + } + + // 默认消息 + return `你好,我是${botName},你的智能健康管理助手。可以向我咨询营养摄入、身材管理、健身锻炼、生活管理等各方面的健康问题~`; + }, [userProfile, params?.name]); const chips = useMemo(() => [ - { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, - { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, - { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, + // { 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() }, ], [router, planDraft, checkin]); const scrollToEnd = useCallback(() => { @@ -110,39 +212,91 @@ export default function CoachScreen() { const handleScroll = useCallback((e: any) => { 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)); + const distanceFromBottom = (contentSize.height || 0) - ((layoutMeasurement.height || 0) + (contentOffset.y || 0)); setIsAtBottom(distanceFromBottom <= paddingToBottom); - } catch { } + } catch (error) { + console.warn('[AI_CHAT] Scroll handling error:', error); + } }, []); useEffect(() => { // 初次进入或恢复时,保持最新消息可见 - scrollToEnd(); - }, [scrollToEnd]); + const timer = setTimeout(scrollToEnd, 100); + return () => clearTimeout(timer); + }, []); + // 初始化欢迎消息 + const initializeWelcomeMessage = useCallback(() => { + const welcomeMessage: ChatMessage = { + id: 'm_welcome', + role: 'assistant', + content: generateWelcomeMessage(), + }; + setMessages([welcomeMessage]); + }, [generateWelcomeMessage]); + // 启动页面时尝试恢复当次应用会话缓存 useEffect(() => { + let isMounted = true; + (async () => { try { const cached = await loadAiCoachSessionCache(); - if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) { + if (isMounted && cached && Array.isArray(cached.messages) && cached.messages.length > 0) { setConversationId(cached.conversationId); - setMessages(cached.messages as any); - setTimeout(scrollToEnd, 0); + setMessages(cached.messages.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content) as ChatMessage[]); + setTimeout(() => { + if (isMounted) scrollToEnd(); + }, 100); + } else { + // 没有缓存时显示欢迎消息 + if (isMounted) { + initializeWelcomeMessage(); + } } - } catch { } + } catch (error) { + console.warn('[AI_CHAT] Failed to load session cache:', error); + // 出错时也显示欢迎消息 + if (isMounted) { + initializeWelcomeMessage(); + } + } })(); - }, [scrollToEnd]); + + return () => { + isMounted = false; + }; + }, [initializeWelcomeMessage]); // 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空) const saveCacheTimerRef = useRef | null>(null); useEffect(() => { - if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); - saveCacheTimerRef.current = setTimeout(() => { - saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { }); - }, 150); - return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); }; - // 仅在 messages 或 conversationId 变化时触发 + 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() + }).catch((error) => { + console.warn('[AI_CHAT] Failed to save session cache:', error); + }); + }, 300); + } + + return () => { + if (saveCacheTimerRef.current) { + clearTimeout(saveCacheTimerRef.current); + saveCacheTimerRef.current = null; + } + }; }, [messages, conversationId]); @@ -151,40 +305,76 @@ export default function CoachScreen() { useEffect(() => { // 输入区高度变化时,若用户在底部则轻柔跟随一次 if (isAtBottom) { - const id = setTimeout(scrollToEnd, 0); + const id = setTimeout(scrollToEnd, 100); return () => clearTimeout(id); } - }, [composerHeight, isAtBottom, scrollToEnd]); + }, [composerHeight, isAtBottom]); // 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡 useEffect(() => { let showSub: any = null; let hideSub: any = null; + if (Platform.OS === 'ios') { showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => { try { - const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom); - setKeyboardOffset(height); - } catch { setKeyboardOffset(0); } + if (e?.endCoordinates?.height) { + const height = Math.max(0, e.endCoordinates.height - insets.bottom); + setKeyboardOffset(height); + } + } catch (error) { + console.warn('[KEYBOARD] iOS keyboard event error:', error); + setKeyboardOffset(0); + } + }); + hideSub = Keyboard.addListener('keyboardWillHide', () => { + setKeyboardOffset(0); }); - hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardOffset(0)); } else { showSub = Keyboard.addListener('keyboardDidShow', (e: any) => { - try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); } + try { + if (e?.endCoordinates?.height) { + setKeyboardOffset(Math.max(0, e.endCoordinates.height)); + } + } catch (error) { + console.warn('[KEYBOARD] Android keyboard event error:', error); + setKeyboardOffset(0); + } + }); + hideSub = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardOffset(0); }); - hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0)); } + return () => { - try { showSub?.remove?.(); } catch { } - try { hideSub?.remove?.(); } catch { } + try { + showSub?.remove?.(); + } catch (error) { + console.warn('[KEYBOARD] Error removing keyboard show listener:', error); + } + try { + hideSub?.remove?.(); + } catch (error) { + console.warn('[KEYBOARD] Error removing keyboard hide listener:', error); + } }; }, [insets.bottom]); const streamAbortRef = useRef<{ abort: () => void } | null>(null); + // 组件卸载时清理流式请求和定时器 useEffect(() => { return () => { - try { streamAbortRef.current?.abort(); } catch { } + try { + streamAbortRef.current?.abort(); + } catch (error) { + console.warn('[AI_CHAT] Error aborting stream on unmount:', error); + } + + if (saveCacheTimerRef.current) { + clearTimeout(saveCacheTimerRef.current); + saveCacheTimerRef.current = null; + } }; }, []); @@ -225,6 +415,31 @@ export default function CoachScreen() { } } + function startNewConversation() { + if (isStreaming) { + try { streamAbortRef.current?.abort(); } catch { } + } + + // 清理当前会话状态 + setConversationId(undefined); + setSelectedImages([]); + setDietTextInputs({}); + + // 创建新的欢迎消息 + initializeWelcomeMessage(); + + // 清理本地缓存 + saveAiCoachSessionCache({ + conversationId: undefined, + messages: [], + updatedAt: Date.now() + }).catch((error) => { + console.warn('[AI_CHAT] Failed to clear session cache:', error); + }); + + setTimeout(scrollToEnd, 100); + } + async function handleSelectConversation(id: string) { try { if (isStreaming) { @@ -239,7 +454,7 @@ export default function CoachScreen() { .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' })); setConversationId(detail.conversationId); - setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]); setHistoryVisible(false); setTimeout(scrollToEnd, 0); } catch (e) { @@ -256,7 +471,7 @@ export default function CoachScreen() { await deleteConversation(id); if (conversationId === id) { setConversationId(undefined); - setMessages([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + setMessages([{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]); } await refreshHistory(historyPage); } catch (e) { @@ -373,54 +588,29 @@ export default function CoachScreen() { const trimmed = text.trim(); if (!trimmed && selectedImages.length === 0) return; - async function ensureImagesUploaded(): Promise { - const urls: string[] = []; - for (const img of selectedImages) { - if (img.uploadedUrl) { - urls.push(img.uploadedUrl); - continue; - } - try { - const resp = await fetch(img.localUri); - const blob = await resp.blob(); - const ext = (() => { - const t = (blob.type || '').toLowerCase(); - if (t.includes('png')) return 'png'; - if (t.includes('webp')) return 'webp'; - if (t.includes('heic')) return 'heic'; - if (t.includes('heif')) return 'heif'; - return 'jpg'; - })(); - const key = buildCosKey({ prefix: 'images/chat', ext }); - const res = await uploadWithRetry({ - key, - body: blob, - contentType: blob.type || 'image/jpeg', - onProgress: ({ percent }: { percent?: number }) => { - const p = typeof percent === 'number' ? percent : 0; - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it)); - }, - } as any); - const url = buildPublicUrl(res.key); - urls.push(url); - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it)); - } catch (e: any) { - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it)); - throw e; - } - } - return urls; + // 检查是否有图片还在上传中 + const uploadingImages = selectedImages.filter(img => !img.uploadedUrl && !img.error); + if (uploadingImages.length > 0) { + Alert.alert('请稍等', '图片正在上传中,请等待上传完成后再发送'); + return; + } + + // 检查是否有上传失败的图片 + const failedImages = selectedImages.filter(img => img.error); + if (failedImages.length > 0) { + Alert.alert('上传失败', '部分图片上传失败,请重新选择或删除失败的图片'); + return; } try { - const urls = await ensureImagesUploaded(); + const urls = selectedImages.map(img => img.uploadedUrl).filter(Boolean); const mdImages = urls.map((u) => `![image](${u})`).join('\n\n'); const composed = [trimmed, mdImages].filter(Boolean).join('\n\n'); setInput(''); setSelectedImages([]); await sendStream(composed); } catch (e: any) { - Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试'); + Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试'); } } @@ -444,32 +634,57 @@ export default function CoachScreen() { } function buildTrainingSummary(): string { - const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[]; - if (!entries.length) return ''; - const recent = entries.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) { - totalExercises += 1; - if (it.completed) totalCompleted += 1; - categoryCount[it.category] = (categoryCount[it.category] || 0) + 1; - exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1; + 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 ''; } - 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'); } function handleAnalyzeRecords() { @@ -482,32 +697,81 @@ export default function CoachScreen() { 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) => + it.id === img.id ? { ...it, uploadedUrl: url, progress: 1, error: undefined } : it + )); + } else { + throw new Error('上传返回空URL'); + } + } catch (e: any) { + console.error('[AI_CHAT] Image upload failed:', e); + setSelectedImages((prev) => prev.map((it) => + it.id === img.id ? { ...it, error: e?.message || '上传失败', progress: 0 } : it + )); + } + }, [upload]); + const pickImages = useCallback(async () => { try { const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, + mediaTypes: ['images'], allowsMultipleSelection: true, selectionLimit: 4, quality: 0.9, } as any); + if ((result as any).canceled) return; + const assets = (result as any).assets || []; - const next = assets.map((a: any) => ({ - id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`, - localUri: a.uri, - width: a.width, - height: a.height, - progress: 0, - })); + 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) => ({ + id: `${a.assetId || a.fileName || Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + localUri: a.uri, + width: a.width, + 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, 0); + + setTimeout(scrollToEnd, 100); + + // 立即开始上传新选择的图片 + for (const img of next) { + uploadImage(img); + } } catch (e: any) { + console.error('[AI_CHAT] Image picker error:', e); Alert.alert('错误', e?.message || '选择图片失败'); } - }, [scrollToEnd]); + }, [scrollToEnd, uploadImage]); const removeSelectedImage = useCallback((id: string) => { setSelectedImages((prev) => prev.filter((it) => it.id !== id)); @@ -522,7 +786,7 @@ export default function CoachScreen() { style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]} > {!isUser && ( - + )} {renderBubbleContent(item)} - {false} + ); } @@ -545,12 +810,18 @@ export default function CoachScreen() { if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { return 正在思考…; } + if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) { const preset = (() => { - const m = item.content.split('\n')?.[1]; - const v = parseFloat(m || ''); - return isNaN(v) ? '' : String(v); + try { + const m = item.content.split('\n')?.[1]; + const v = parseFloat(m || ''); + return isNaN(v) ? '' : String(v); + } catch { + return ''; + } })(); + return ( 记录今日体重 @@ -566,7 +837,11 @@ export default function CoachScreen() { submitBehavior="blurAndSubmit" /> kg - handleSubmitWeight((preset || '').toString())}> + handleSubmitWeight(preset || '')} + > 保存 @@ -574,9 +849,94 @@ export default function CoachScreen() { ); } + + if (item.content?.startsWith('__DIET_INPUT_CARD__')) { + return ( + + 记录今日饮食 + 请选择记录方式: + + + handleDietTextInput(item.id)} + > + + + + + 文字记录 + 输入吃了什么、大概多少克 + + + + handleDietPhotoInput(item.id)} + > + + + + + 拍照识别 + 拍摄食物照片进行AI分析 + + + + + 选择合适的方式记录您的饮食,Health Bot会根据您的饮食情况给出专业的营养建议。 + + ); + } + + if (item.content?.startsWith('__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} + {item.content || ''} ); } @@ -586,7 +946,7 @@ export default function CoachScreen() { const preset = userProfile?.weight ? Number(userProfile.weight) : undefined; const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`; setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]); - setTimeout(scrollToEnd, 0); + setTimeout(scrollToEnd, 100); } async function handleSubmitWeight(text?: string) { @@ -595,30 +955,141 @@ export default function CoachScreen() { Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5'); return; } + try { // 本地更新 dispatch(updateProfile({ weight: String(val) })); - // 后端同步(若有 userId 则更稳妥;后端实现容错) + + // 后端同步(尝试从不同可能的字段获取用户ID) try { - const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id; + // 从后端响应的原始数据中查找可能的用户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 (e) { - // 不阻断对话体验 + } catch (syncError) { + console.warn('[AI_CHAT] Failed to sync weight to server:', syncError); + // 不阻断对话体验,但可以给用户一个提示 } + // 在对话中插入"确认消息"并发送给教练 const textMsg = `记录了今日体重:${val} kg。`; await send(textMsg); } catch (e: any) { + console.error('[AI_CHAT] Error handling weight submission:', e); Alert.alert('保存失败', e?.message || '请稍后重试'); } } + function insertDietInputCard() { + const id = `dcard_${Date.now()}`; + const payload = `__DIET_INPUT_CARD__\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 + ? { ...msg, content: payload } + : msg + )); + setTimeout(scrollToEnd, 100); + } + + async function handleDietPhotoInput(cardId: string) { + try { + // 使用现有的拍照功能 + const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); + if (permissionResult.status !== 'granted') { + Alert.alert('权限不足', '需要相机权限以拍摄食物照片'); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.9, + aspect: [4, 3], + }); + + 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('上传失败', '图片上传失败,请重试'); + } + } + } catch (e: any) { + console.error('[DIET] 拍照失败:', e); + Alert.alert('拍照失败', e?.message || '拍照失败,请重试'); + } + } + + function handleBackToDietOptions(cardId: string) { + // 返回到饮食选择界面 + const payload = `__DIET_INPUT_CARD__\n${cardId}`; + setMessages((prev) => prev.map(msg => + msg.id === cardId + ? { ...msg, content: payload } + : msg + )); + setTimeout(scrollToEnd, 100); + } + + async function handleSubmitDietText(text: string, cardId: string) { + const trimmedText = text.trim(); + if (!trimmedText) { + Alert.alert('请输入饮食内容', '请描述您吃了什么食物和大概的分量'); + return; + } + + try { + // 移除饮食输入卡片 + setMessages((prev) => prev.filter(msg => msg.id !== cardId)); + + // 清理输入状态 + setDietTextInputs(prev => { + const { [cardId]: _, ...rest } = prev; + return rest; + }); + + // 发送饮食记录消息 + const dietMsg = `记录了今日饮食:${trimmedText}`; + await send(dietMsg); + } catch (e: any) { + console.error('[DIET] 提交饮食记录失败:', e); + Alert.alert('提交失败', e?.message || '提交失败,请重试'); + } + } + return ( - {/* 顶部标题区域,只显示教练名称和历史按钮 */} + {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} { @@ -626,10 +1097,23 @@ export default function CoachScreen() { if (h && Math.abs(h - headerHeight) > 0.5) setHeaderHeight(h); }} > - 教练 {coachName} - - - + {botName} + + + + + + + + {/* 消息列表容器 - 设置固定高度避免输入框重叠 */} @@ -646,8 +1130,7 @@ export default function CoachScreen() { // 确保首屏布局后也尝试滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; - setTimeout(scrollToEnd, 0); - requestAnimationFrame(scrollToEnd); + setTimeout(scrollToEnd, 100); } }} contentContainerStyle={{ @@ -659,13 +1142,12 @@ export default function CoachScreen() { // 首次内容变化强制滚底,其余仅在接近底部时滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; - setTimeout(scrollToEnd, 0); - requestAnimationFrame(scrollToEnd); + setTimeout(scrollToEnd, 100); return; } if (shouldAutoScrollRef.current) { shouldAutoScrollRef.current = false; - setTimeout(scrollToEnd, 0); + setTimeout(scrollToEnd, 50); } }} onScroll={handleScroll} @@ -715,6 +1197,17 @@ export default function CoachScreen() { {Math.round((img.progress || 0) * 100)}% )} + {img.error && ( + + uploadImage(img)} + style={styles.imageRetryBtn} + > + + + + )} removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> @@ -732,7 +1225,7 @@ export default function CoachScreen() { + {/* 体重历史记录卡片 */} + 健康数据 + + {/* 标题与日期选择 */} {monthTitle} + + diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 8a6fe38..ac2c483 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -8,9 +8,10 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { listRecommendedArticles } from '@/services/articles'; import { fetchRecommendations, RecommendationType } from '@/services/recommendations'; -import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice'; +import { loadPlans } from '@/store/trainingPlanSlice'; // Removed WorkoutCard import since we no longer use the horizontal carousel import { useAuthGuard } from '@/hooks/useAuthGuard'; +import { TrainingPlan } from '@/services/trainingPlanApi'; import { getChineseGreeting } from '@/utils/date'; import { useRouter } from 'expo-router'; import React from 'react'; @@ -29,7 +30,7 @@ export default function HomeScreen() { const { width: windowWidth, height: windowHeight } = useWindowDimensions(); // 训练计划状态 - const { plans, currentId } = useAppSelector((s) => s.trainingPlan); + const { plans } = useAppSelector((s) => s.trainingPlan); const [activePlan, setActivePlan] = React.useState(null); // Draggable coach badge state @@ -148,13 +149,13 @@ export default function HomeScreen() { // 获取激活的训练计划 React.useEffect(() => { - if (isLoggedIn && currentId && plans.length > 0) { - const currentPlan = plans.find(p => p.id === currentId); + if (isLoggedIn && plans.length > 0) { + const currentPlan = plans.find(p => p.isActive); setActivePlan(currentPlan || null); } else { setActivePlan(null); } - }, [isLoggedIn, currentId, plans]); + }, [isLoggedIn, plans]); // 拉取推荐接口(已登录时) React.useEffect(() => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 38d209d..df513ae 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -86,7 +86,7 @@ export default function RootLayout() { - + diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx deleted file mode 100644 index 0ec67c3..0000000 --- a/app/ai-coach-chat.tsx +++ /dev/null @@ -1,1143 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; -import * as ImagePicker from 'expo-image-picker'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - Alert, - FlatList, - Image, - Keyboard, - Modal, - Platform, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import Markdown from 'react-native-markdown-display'; -import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { buildCosKey, buildPublicUrl } from '@/constants/Cos'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; -import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; -import { api, getAuthToken, postTextStream } from '@/services/api'; -import { uploadWithRetry } from '@/services/cos'; -import { updateUser as updateUserApi } from '@/services/users'; -import type { CheckinRecord } from '@/store/checkinSlice'; -import { fetchMyProfile, updateProfile } from '@/store/userSlice'; -import dayjs from 'dayjs'; - -type Role = 'user' | 'assistant'; - -type ChatMessage = { - id: string; - role: Role; - content: string; -}; - -const COACH_AVATAR = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg'; - -export default function AICoachChatScreen() { - const router = useRouter(); - const params = useLocalSearchParams<{ name?: string }>(); - const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme() ?? 'light'; - // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 - const theme = Colors.light; - const coachName = (params?.name || 'Sarah').toString(); - const [input, setInput] = useState(''); - const [isSending, setIsSending] = useState(false); - const [isStreaming, setIsStreaming] = useState(false); - const [conversationId, setConversationId] = useState(undefined); - const [messages, setMessages] = useState([{ - id: 'm_welcome', - role: 'assistant', - content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, - }]); - const [historyVisible, setHistoryVisible] = useState(false); - const [historyLoading, setHistoryLoading] = useState(false); - const [historyPage, setHistoryPage] = useState(1); - const [historyTotal, setHistoryTotal] = useState(0); - const [historyItems, setHistoryItems] = useState([]); - const listRef = useRef>(null); - const [isAtBottom, setIsAtBottom] = useState(true); - const didInitialScrollRef = useRef(false); - const [composerHeight, setComposerHeight] = useState(80); - const shouldAutoScrollRef = useRef(false); - const [keyboardOffset, setKeyboardOffset] = useState(0); - const pendingAssistantIdRef = useRef(null); - const [selectedImages, setSelectedImages] = useState>([]); - const [previewImageUri, setPreviewImageUri] = useState(null); - - const planDraft = useAppSelector((s) => s.trainingPlan?.draft); - const checkin = useAppSelector((s) => (s as any).checkin); - const dispatch = useAppDispatch(); - const userProfile = useAppSelector((s) => (s as any)?.user?.profile); - - const chips = useMemo(() => [ - { 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() }, - ], [router, planDraft, checkin]); - - const scrollToEnd = useCallback(() => { - requestAnimationFrame(() => { - listRef.current?.scrollToEnd({ animated: true }); - }); - }, []); - - const handleScroll = useCallback((e: any) => { - try { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {}; - const paddingToBottom = 60; - const distanceFromBottom = (contentSize?.height || 0) - ((layoutMeasurement?.height || 0) + (contentOffset?.y || 0)); - setIsAtBottom(distanceFromBottom <= paddingToBottom); - } catch { } - }, []); - - useEffect(() => { - // 初次进入或恢复时,保持最新消息可见 - scrollToEnd(); - }, [scrollToEnd]); - // 启动页面时尝试恢复当次应用会话缓存 - useEffect(() => { - (async () => { - try { - const cached = await loadAiCoachSessionCache(); - if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) { - setConversationId(cached.conversationId); - setMessages(cached.messages as any); - setTimeout(scrollToEnd, 0); - } - } catch { } - })(); - }, [scrollToEnd]); - - // 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空) - const saveCacheTimerRef = useRef | null>(null); - useEffect(() => { - if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); - saveCacheTimerRef.current = setTimeout(() => { - saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { }); - }, 150); - return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); }; - // 仅在 messages 或 conversationId 变化时触发 - }, [messages, conversationId]); - - - // 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发 - - useEffect(() => { - // 输入区高度变化时,若用户在底部则轻柔跟随一次 - if (isAtBottom) { - const id = setTimeout(scrollToEnd, 0); - return () => clearTimeout(id); - } - }, [composerHeight, isAtBottom, scrollToEnd]); - - // 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡 - useEffect(() => { - let showSub: any = null; - let hideSub: any = null; - if (Platform.OS === 'ios') { - showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => { - try { - const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom); - setKeyboardOffset(height); - } catch { setKeyboardOffset(0); } - }); - hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardOffset(0)); - } else { - showSub = Keyboard.addListener('keyboardDidShow', (e: any) => { - try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); } - }); - hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0)); - } - return () => { - try { showSub?.remove?.(); } catch { } - try { hideSub?.remove?.(); } catch { } - }; - }, [insets.bottom]); - - const streamAbortRef = useRef<{ abort: () => void } | null>(null); - - useEffect(() => { - return () => { - try { streamAbortRef.current?.abort(); } catch { } - }; - }, []); - - function ensureConversationId(): string { - if (conversationId && conversationId.trim()) return conversationId; - const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - setConversationId(cid); - try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { } - return cid; - } - - function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> { - // 仅映射 user/assistant 消息;系统提示由后端自动注入 - return history - .filter((m) => m.role === 'user' || m.role === 'assistant') - .map((m) => ({ role: m.role, content: m.content })); - } - - async function openHistory() { - if (isStreaming) { - try { streamAbortRef.current?.abort(); } catch { } - } - setHistoryVisible(true); - await refreshHistory(1); - } - - async function refreshHistory(page = 1) { - try { - setHistoryLoading(true); - const resp = await listConversations(page, 20); - setHistoryPage(resp.page); - setHistoryTotal(resp.total); - setHistoryItems(resp.items || []); - } catch (e) { - Alert.alert('错误', (e as any)?.message || '获取会话列表失败'); - } finally { - setHistoryLoading(false); - } - } - - async function handleSelectConversation(id: string) { - try { - if (isStreaming) { - try { streamAbortRef.current?.abort(); } catch { } - } - const detail = await getConversationDetail(id); - if (!detail || !(detail as any).messages) { - Alert.alert('提示', '会话不存在或已删除'); - return; - } - const mapped: ChatMessage[] = (detail.messages || []) - .filter((m) => m.role === 'user' || m.role === 'assistant') - .map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' })); - setConversationId(detail.conversationId); - setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); - setHistoryVisible(false); - setTimeout(scrollToEnd, 0); - } catch (e) { - Alert.alert('错误', (e as any)?.message || '加载会话失败'); - } - } - - function confirmDeleteConversation(id: string) { - Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [ - { text: '取消', style: 'cancel' }, - { - text: '删除', style: 'destructive', onPress: async () => { - try { - await deleteConversation(id); - if (conversationId === id) { - setConversationId(undefined); - setMessages([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); - } - await refreshHistory(historyPage); - } catch (e) { - Alert.alert('错误', (e as any)?.message || '删除失败'); - } - } - } - ]); - } - - async function sendStream(text: string) { - const tokenExists = !!getAuthToken(); - try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { } - - // 终止上一次未完成的流 - if (streamAbortRef.current) { - try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { } - try { streamAbortRef.current.abort(); } catch { } - streamAbortRef.current = null; - } - - // 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文 - const historyForServer = convertToServerMessages(messages); - const cid = ensureConversationId(); - const body = { - conversationId: cid, - messages: [...historyForServer, { role: 'user' as const, content: text }], - stream: true, - }; - - // 在 UI 中先放置占位回答,随后持续增量更新 - const assistantId = `a_${Date.now()}`; - const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - - const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text }; - shouldAutoScrollRef.current = isAtBottom; - setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); - pendingAssistantIdRef.current = assistantId; - - setIsSending(true); - setIsStreaming(true); - - let receivedAnyChunk = false; - - const updateAssistantContent = (delta: string) => { - setMessages((prev) => { - const next = prev.map((msg) => { - if (msg.id === assistantId) { - return { ...msg, content: msg.content + delta }; - } - return msg; - }); - return next; - }); - }; - - const onChunk = (chunk: string) => { - receivedAnyChunk = true; - const atBottomNow = isAtBottom; - updateAssistantContent(chunk); - if (atBottomNow) { - // 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况 - shouldAutoScrollRef.current = true; - setTimeout(scrollToEnd, 0); - } - try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { } - }; - - const onEnd = (cidFromHeader?: string) => { - setIsSending(false); - setIsStreaming(false); - streamAbortRef.current = null; - if (cidFromHeader && !conversationId) setConversationId(cidFromHeader); - pendingAssistantIdRef.current = null; - try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } - }; - - const onError = async (err: any) => { - try { console.warn('[AI_CHAT][api] error', err); } catch { } - setIsSending(false); - setIsStreaming(false); - streamAbortRef.current = null; - pendingAssistantIdRef.current = null; - // 流式失败时的降级:尝试一次性非流式 - try { - const bodyNoStream = { ...body, stream: false }; - try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { } - const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream); - const textCombined = (resp as any)?.text ?? ''; - if ((resp as any)?.conversationId && !conversationId) { - setConversationId((resp as any).conversationId); - } - setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg)); - } catch (e2: any) { - setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg)); - try { console.warn('[AI_CHAT][fallback] non-stream error', e2); } catch { } - } - }; - - try { - const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 }); - streamAbortRef.current = controller; - } catch (e) { - onError(e); - } - } - - async function send(text: string) { - if (isSending) return; - const trimmed = text.trim(); - if (!trimmed && selectedImages.length === 0) return; - - async function ensureImagesUploaded(): Promise { - const urls: string[] = []; - for (const img of selectedImages) { - if (img.uploadedUrl) { - urls.push(img.uploadedUrl); - continue; - } - try { - const resp = await fetch(img.localUri); - const blob = await resp.blob(); - const ext = (() => { - const t = (blob.type || '').toLowerCase(); - if (t.includes('png')) return 'png'; - if (t.includes('webp')) return 'webp'; - if (t.includes('heic')) return 'heic'; - if (t.includes('heif')) return 'heif'; - return 'jpg'; - })(); - const key = buildCosKey({ prefix: 'images/chat', ext }); - const res = await uploadWithRetry({ - key, - body: blob, - contentType: blob.type || 'image/jpeg', - onProgress: ({ percent }: { percent?: number }) => { - const p = typeof percent === 'number' ? percent : 0; - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it)); - }, - } as any); - const url = buildPublicUrl(res.key); - urls.push(url); - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it)); - } catch (e: any) { - setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it)); - throw e; - } - } - return urls; - } - - try { - const urls = await ensureImagesUploaded(); - const mdImages = urls.map((u) => `![image](${u})`).join('\n\n'); - const composed = [trimmed, mdImages].filter(Boolean).join('\n\n'); - setInput(''); - setSelectedImages([]); - await sendStream(composed); - } catch (e: any) { - Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试'); - } - } - - 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 { - const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[]; - if (!entries.length) return ''; - const recent = entries.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) { - totalExercises += 1; - if (it.completed) totalCompleted += 1; - categoryCount[it.category] = (categoryCount[it.category] || 0) + 1; - 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'); - } - - function handleAnalyzeRecords() { - const summary = buildTrainingSummary(); - if (!summary) { - send('我还没有可分析的打卡记录,请先在“每日打卡”添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。'); - return; - } - const prompt = `请基于以下我的近期训练记录进行分析,输出:1)整体训练负荷与节奏;2)动作与肌群的均衡性(指出偏多/偏少);3)容易忽视的恢复与热身建议;4)后续一周的优化建议(频次/时长/动作方向)。\n\n${summary}`; - send(prompt); - } - - const pickImages = useCallback(async () => { - try { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsMultipleSelection: true, - selectionLimit: 4, - quality: 0.9, - } as any); - if ((result as any).canceled) return; - const assets = (result as any).assets || []; - const next = assets.map((a: any) => ({ - id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`, - localUri: a.uri, - width: a.width, - height: a.height, - progress: 0, - })); - setSelectedImages((prev) => { - const merged = [...prev, ...next]; - return merged.slice(0, 4); - }); - setTimeout(scrollToEnd, 0); - } catch (e: any) { - Alert.alert('错误', e?.message || '选择图片失败'); - } - }, [scrollToEnd]); - - const removeSelectedImage = useCallback((id: string) => { - setSelectedImages((prev) => prev.filter((it) => it.id !== id)); - }, []); - - function renderItem({ item }: { item: ChatMessage }) { - const isUser = item.role === 'user'; - return ( - - {!isUser && ( - - )} - - {renderBubbleContent(item)} - - {false} - - ); - } - - function renderBubbleContent(item: ChatMessage) { - if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { - return 正在思考…; - } - if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) { - const preset = (() => { - const m = item.content.split('\n')?.[1]; - const v = parseFloat(m || ''); - return isNaN(v) ? '' : String(v); - })(); - return ( - - 记录今日体重 - - handleSubmitWeight(e.nativeEvent.text)} - returnKeyType="done" - submitBehavior="blurAndSubmit" - /> - kg - handleSubmitWeight((preset || '').toString())}> - 保存 - - - 按回车或点击保存,即可将该体重同步到账户并发送到对话。 - - ); - } - return ( - - {item.content} - - ); - } - - function insertWeightInputCard() { - const id = `wcard_${Date.now()}`; - const preset = userProfile?.weight ? Number(userProfile.weight) : undefined; - const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`; - setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]); - setTimeout(scrollToEnd, 0); - } - - async function handleSubmitWeight(text?: string) { - const val = parseFloat(String(text ?? '').trim()); - if (isNaN(val) || val <= 0 || val > 500) { - Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5'); - return; - } - try { - // 本地更新 - dispatch(updateProfile({ weight: val })); - // 后端同步(若有 userId 则更稳妥;后端实现容错) - try { - const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id; - if (userId) { - await updateUserApi({ userId, weight: val }); - await dispatch(fetchMyProfile() as any); - } - } catch (e) { - // 不阻断对话体验 - } - // 在对话中插入“确认消息”并发送给教练 - const textMsg = `记录了今日体重:${val} kg。`; - await send(textMsg); - } catch (e: any) { - Alert.alert('保存失败', e?.message || '请稍后重试'); - } - } - - return ( - - router.back()} - tone="light" - transparent - right={( - - - - )} - /> - - m.id} - renderItem={renderItem} - onLayout={() => { - // 确保首屏布局后也尝试滚动 - if (!didInitialScrollRef.current) { - didInitialScrollRef.current = true; - setTimeout(scrollToEnd, 0); - requestAnimationFrame(scrollToEnd); - } - }} - contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }} - ListFooterComponent={() => ( - - )} - onContentSizeChange={() => { - // 首次内容变化强制滚底,其余仅在接近底部时滚动 - if (!didInitialScrollRef.current) { - didInitialScrollRef.current = true; - setTimeout(scrollToEnd, 0); - requestAnimationFrame(scrollToEnd); - return; - } - if (shouldAutoScrollRef.current) { - shouldAutoScrollRef.current = false; - setTimeout(scrollToEnd, 0); - } - }} - onScroll={handleScroll} - scrollEventThrottle={16} - showsVerticalScrollIndicator={false} - /> - - { - const h = e.nativeEvent.layout.height; - if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); - }} - > - - {chips.map((c) => ( - - {c.label} - - ))} - - - {!!selectedImages.length && ( - - {selectedImages.map((img) => ( - - setPreviewImageUri(img.uploadedUrl || img.localUri)}> - - - {!!(img.progress > 0 && img.progress < 1) && ( - - {Math.round((img.progress || 0) * 100)}% - - )} - removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> - - - - ))} - - )} - - - - - - send(input)} - blurOnSubmit={false} - /> - send(input)} - style={[ - styles.sendBtn, - { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 } - ]} - > - {isSending ? ( - - ) : ( - - )} - - - - - {!isAtBottom && ( - - - - )} - - setHistoryVisible(false)}> - setHistoryVisible(false)}> - - - 历史会话 - refreshHistory(historyPage)} style={styles.modalRefreshBtn}> - - - - {historyLoading ? ( - - - 加载中... - - ) : ( - - {historyItems.length === 0 ? ( - 暂无会话 - ) : ( - historyItems.map((it) => ( - - handleSelectConversation(it.conversationId)} - > - {it.title || '未命名会话'} - - {dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')} - - - confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}> - - - - )) - )} - - )} - - setHistoryVisible(false)} style={styles.modalCloseBtn}> - 关闭 - - - - - - setPreviewImageUri(null)}> - setPreviewImageUri(null)}> - - {previewImageUri ? ( - - ) : null} - - - - - ); -} - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingBottom: 10, - }, - backButton: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.06)' - }, - headerTitle: { - fontSize: 20, - fontWeight: '800', - }, - row: { - flexDirection: 'row', - alignItems: 'flex-end', - gap: 8, - marginVertical: 6, - }, - avatar: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - }, - avatarText: { - color: '#192126', - fontSize: 12, - fontWeight: '800', - }, - bubble: { - maxWidth: '82%', - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 16, - }, - bubbleText: { - fontSize: 15, - lineHeight: 22, - }, - weightRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - weightInput: { - flex: 1, - height: 36, - borderWidth: 1, - borderColor: 'rgba(0,0,0,0.08)', - borderRadius: 8, - paddingHorizontal: 10, - backgroundColor: 'rgba(255,255,255,0.9)', - color: '#192126', - }, - weightUnit: { - color: '#192126', - fontWeight: '700', - }, - weightSaveBtn: { - height: 36, - paddingHorizontal: 12, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(187,242,70,0.6)' - }, - // markdown 基础样式承载容器的字体尺寸保持与气泡一致 - composerWrap: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - paddingTop: 8, - paddingHorizontal: 10, - borderTopWidth: 0, - }, - chipsRow: { - flexDirection: 'row', - gap: 8, - paddingHorizontal: 6, - marginBottom: 8, - }, - chipsRowScroll: { - marginBottom: 8, - }, - chip: { - paddingHorizontal: 10, - height: 34, - borderRadius: 18, - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'transparent', - }, - chipText: { - fontSize: 13, - fontWeight: '600', - }, - imagesRow: { - maxHeight: 92, - marginBottom: 8, - }, - imageThumbWrap: { - width: 72, - height: 72, - borderRadius: 12, - overflow: 'hidden', - position: 'relative', - backgroundColor: 'rgba(0,0,0,0.06)' - }, - 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' - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - borderWidth: 1, - borderRadius: 16, - backgroundColor: 'rgba(0,0,0,0.04)' - }, - 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', - }, - scrollToBottomFab: { - position: 'absolute', - right: 16, - width: 40, - height: 40, - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.15, - shadowRadius: 4, - elevation: 2, - }, - modalBackdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - padding: 16, - justifyContent: 'flex-end', - }, - modalSheet: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingHorizontal: 12, - paddingTop: 10, - paddingBottom: 12, - }, - modalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 4, - paddingBottom: 8, - }, - modalTitle: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - }, - modalRefreshBtn: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.06)' - }, - historyRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 8, - borderRadius: 10, - }, - historyTitle: { - fontSize: 15, - color: '#192126', - fontWeight: '600', - }, - historyMeta: { - marginTop: 2, - fontSize: 12, - color: '#687076', - }, - historyDeleteBtn: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,68,68,0.08)' - }, - modalFooter: { - paddingTop: 8, - alignItems: 'flex-end', - }, - modalCloseBtn: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 10, - backgroundColor: 'rgba(0,0,0,0.06)' - }, - previewBackdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.85)', - alignItems: 'center', - justifyContent: 'center', - padding: 16, - }, - previewBox: { - width: '100%', - height: '80%', - borderRadius: 12, - overflow: 'hidden', - }, - previewImage: { - width: '100%', - height: '100%', - }, -}); - -const markdownStyles = { - body: { - color: '#192126', - 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' }, -} as const; - - diff --git a/app/ai-posture-result.tsx b/app/ai-posture-result.tsx index e86f83c..372ef97 100644 --- a/app/ai-posture-result.tsx +++ b/app/ai-posture-result.tsx @@ -153,7 +153,7 @@ export default function AIPostureResultScreen() { 完成并返回 - router.push('/ai-coach-chat')}> + router.push('/(tabs)/coach')}> 生成训练建议 diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..d490ca6 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/components/BMICard.tsx b/components/BMICard.tsx index 74f070c..20ea5cf 100644 --- a/components/BMICard.tsx +++ b/components/BMICard.tsx @@ -8,16 +8,18 @@ import { import { Ionicons } from '@expo/vector-icons'; import React, { useState } from 'react'; import { + Dimensions, Modal, Pressable, - ScrollView, StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; import Toast from 'react-native-toast-message'; +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + interface BMICardProps { weight?: number; height?: number; @@ -31,8 +33,6 @@ export function BMICard({ weight, height, style }: BMICardProps) { const canCalculate = canCalculateBMI(weight, height); let bmiResult: BMIResult | null = null; - - if (canCalculate && weight && height) { try { bmiResult = getBMIResult(weight, height); @@ -156,56 +156,88 @@ export function BMICard({ weight, height, style }: BMICardProps) { style={styles.modalBackdrop} onPress={handleHideInfoModal} > - e.stopPropagation()}> - - + e.stopPropagation()} + > + {/* 弹窗头部 */} + + + + + BMI 指数说明 - - - + + + + + + + {/* 内容区域 - 去除滚动,精简设计 */} + + {/* 介绍部分 */} + + + BMI 是评估体重与身高关系的健康指标 + + + + 计算公式:体重(kg) ÷ 身高²(m) + + - - - BMI(身体质量指数)是评估体重与身高关系的常用指标,计算公式为:体重(kg) ÷ 身高²(m) - + {/* BMI 分类标准 - 紧凑设计 */} + + 分类标准 + + {BMI_CATEGORIES.map((category, index) => { + const colors = index === 0 ? { bg: '#FEF3C7', text: '#B45309' } : + index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } : + index === 2 ? { bg: '#FEF3C7', text: '#B45309' } : + { bg: '#FEE2E2', text: '#B91C1C' }; - BMI 分类标准 - {BMI_CATEGORIES.map((category, index) => { - const colors = index === 0 ? { bg: '#FFF4E6', text: '#8B7355' } : - index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } : - index === 2 ? { bg: '#FEF3C7', text: '#B45309' } : - { bg: '#FEE2E2', text: '#B91C1C' }; - - return ( - - - - {category.name} - - - {category.range} + return ( + + + + {category.name} + + + {category.range} + + + + {category.advice} - - {category.advice} - - - ); - })} + ); + })} + + - - * BMI 仅供参考,不能完全反映身体健康状况。如有疑问,请咨询专业医生。 + {/* 健康提示 - 简化版 */} + + + + 健康建议 + + + 保持均衡饮食、规律运动、充足睡眠,定期监测体重变化 - + + + {/* 免责声明 - 精简版 */} + + + + BMI 仅供参考,如有疑问请咨询专业医生 + + @@ -336,8 +368,7 @@ const styles = StyleSheet.create({ padding: 20, }, modalContainer: { - width: '90%', - maxHeight: '85%', + width: screenWidth * 0.92, backgroundColor: '#FFFFFF', borderRadius: 24, overflow: 'hidden', @@ -347,98 +378,161 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 20, }, - modalContent: { - maxHeight: '100%', - }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - padding: 24, + padding: 20, borderBottomWidth: 1, borderBottomColor: '#F3F4F6', backgroundColor: '#FAFAFA', }, + modalTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + modalIconContainer: { + width: 40, + height: 40, + borderRadius: 12, + backgroundColor: '#EFF6FF', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, modalTitle: { - fontSize: 22, + fontSize: 20, fontWeight: '800', color: '#111827', letterSpacing: -0.5, }, closeButton: { - padding: 8, - borderRadius: 20, + width: 36, + height: 36, + borderRadius: 18, backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', }, - modalBody: { - paddingHorizontal: 24, - paddingVertical: 20, + + // 内容区域样式 + modalContent: { + paddingHorizontal: 20, + paddingBottom: 24, + }, + + // 介绍部分 + introSection: { + marginBottom: 20, }, modalDescription: { - fontSize: 16, - color: '#4B5563', - lineHeight: 26, - marginBottom: 28, - textAlign: 'center', - backgroundColor: '#F8FAFC', - padding: 16, - borderRadius: 12, - borderLeftWidth: 4, - borderLeftColor: '#3B82F6', - }, - sectionTitle: { - fontSize: 20, - fontWeight: '800', - color: '#111827', - marginBottom: 16, - letterSpacing: -0.3, - }, - categoryItem: { - borderRadius: 16, - padding: 20, - marginBottom: 16, - borderWidth: 1, - borderColor: 'rgba(0,0,0,0.05)', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, - }, - categoryHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, - }, - categoryName: { - fontSize: 18, - fontWeight: '800', - letterSpacing: -0.2, - }, - categoryRange: { - fontSize: 15, - fontWeight: '700', - backgroundColor: 'rgba(255,255,255,0.8)', - paddingHorizontal: 12, - paddingVertical: 4, - borderRadius: 20, - }, - categoryAdvice: { fontSize: 15, color: '#374151', lineHeight: 22, - fontWeight: '500', + textAlign: 'center', + marginBottom: 12, }, - disclaimer: { + formulaContainer: { + backgroundColor: '#F8FAFC', + borderRadius: 12, + padding: 16, + borderLeftWidth: 4, + borderLeftColor: '#3B82F6', + }, + formulaText: { + fontSize: 15, + color: '#1F2937', + fontWeight: '600', + textAlign: 'center', + }, + + // 分类部分 + categoriesSection: { + marginBottom: 18, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '800', + color: '#111827', + marginBottom: 12, + textAlign: 'center', + }, + categoriesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + categoryCompact: { + flex: 1, + minWidth: '48%', + borderRadius: 12, + padding: 12, + marginBottom: 8, + }, + categoryCompactHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 6, + }, + categoryCompactName: { + fontSize: 14, + fontWeight: '700', + }, + categoryCompactRange: { + fontSize: 12, + fontWeight: '600', + opacity: 0.8, + }, + categoryCompactAdvice: { + fontSize: 11, + lineHeight: 16, + fontWeight: '500', + opacity: 0.9, + }, + + // 健康提示 + healthTips: { + backgroundColor: '#F9FAFB', + borderRadius: 12, + padding: 14, + marginBottom: 16, + borderLeftWidth: 3, + borderLeftColor: '#EF4444', + }, + tipsHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + tipsTitle: { + fontSize: 14, + fontWeight: '700', + color: '#111827', + marginLeft: 6, + }, + tipsContent: { fontSize: 13, + color: '#374151', + lineHeight: 18, + }, + + // 免责声明紧凑版 + disclaimerCompact: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F9FAFB', + borderRadius: 8, + padding: 12, + marginTop: 4, + }, + disclaimerCompactText: { + fontSize: 12, color: '#6B7280', fontStyle: 'italic', - marginTop: 24, - textAlign: 'center', - backgroundColor: '#F9FAFB', - padding: 16, - borderRadius: 12, - lineHeight: 20, + lineHeight: 16, + marginLeft: 6, + flex: 1, }, + }); diff --git a/components/WeightHistoryCard.tsx b/components/WeightHistoryCard.tsx new file mode 100644 index 0000000..0d80c4e --- /dev/null +++ b/components/WeightHistoryCard.tsx @@ -0,0 +1,487 @@ +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { fetchWeightHistory } from '@/store/userSlice'; +import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Dimensions, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; + +const { width: screenWidth } = Dimensions.get('window'); +const CARD_WIDTH = screenWidth - 40; // 减去左右边距 +const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距 +const CHART_HEIGHT = 100; +const PADDING = 10; + +type WeightHistoryItem = { + weight: string; + source: string; + createdAt: string; +}; + +export function WeightHistoryCard() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const userProfile = useAppSelector((s) => s.user.profile); + const weightHistory = useAppSelector((s) => s.user.weightHistory); + const [isLoading, setIsLoading] = useState(false); + const [showChart, setShowChart] = useState(false); + + const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0; + + useEffect(() => { + if (hasWeight) { + loadWeightHistory(); + } + }, [hasWeight]); + + const loadWeightHistory = async () => { + try { + setIsLoading(true); + await dispatch(fetchWeightHistory() as any); + } catch (error) { + console.error('加载体重历史失败:', error); + } finally { + setIsLoading(false); + } + }; + + const navigateToCoach = () => { + router.push('/(tabs)/coach'); + }; + + // 如果正在加载,显示加载状态 + if (isLoading) { + return ( + + + + + + 体重记录 + + + 加载中... + + + ); + } + + // 如果没有体重数据,显示引导卡片 + if (!hasWeight) { + return ( + + + + + + 体重记录 + + + + + + + 开始记录你的体重变化 + + 记录体重变化,追踪你的健康进展 + + + + 去记录体重 + + + + ); + } + + // 处理体重历史数据 + const sortedHistory = [...weightHistory] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .slice(-7); // 只显示最近7条记录 + + if (sortedHistory.length === 0) { + return ( + + + + + + 体重记录 + + + + + 暂无体重记录,点击下方按钮开始记录 + + + + 记录体重 + + + + ); + } + + // 生成图表数据 + const weights = sortedHistory.map(item => parseFloat(item.weight)); + const minWeight = Math.min(...weights); + const maxWeight = Math.max(...weights); + const weightRange = maxWeight - minWeight || 1; + + const points = sortedHistory.map((item, index) => { + const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING); + const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange; + // 减少顶部边距,压缩留白 + const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30); + return { x, y, weight: item.weight, date: item.createdAt }; + }); + + // 生成路径 + const pathData = points.map((point, index) => { + if (index === 0) return `M ${point.x} ${point.y}`; + return `L ${point.x} ${point.y}`; + }).join(' '); + + // 如果只有一个数据点,显示为水平线 + const singlePointPath = points.length === 1 ? + `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` : + pathData; + + return ( + + + + + + 体重记录 + + setShowChart(!showChart)} + activeOpacity={0.8} + > + + + + + + + + + {/* 默认信息显示 */} + {!showChart && sortedHistory.length > 0 && ( + + + + 当前体重 + {userProfile.weight}kg + + + 记录天数 + {sortedHistory.length}天 + + + 变化范围 + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + + + setShowChart(true)} + activeOpacity={0.8} + > + + 查看趋势图 + + + )} + + {/* 图表容器 - 可折叠 */} + {showChart && ( + + + {/* 背景网格线 */} + {[0, 1, 2, 3, 4].map(i => ( + + ))} + + {/* 折线 */} + + + {/* 数据点和标签 */} + {points.map((point, index) => { + const isLastPoint = index === points.length - 1; + const isFirstPoint = index === 0; + const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; + + return ( + + + {/* 体重标签 - 只在关键点显示 */} + {showLabel && ( + <> + + + {point.weight} + + + )} + + ); + })} + + + + + {/* 图表底部信息 */} + + + 当前体重 + {userProfile.weight}kg + + + 记录天数 + {sortedHistory.length}天 + + + 变化范围 + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + + + + {/* 最近记录时间 */} + {sortedHistory.length > 0 && ( + + 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#FFFFFF', + borderRadius: 22, + padding: 18, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 3, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + iconSquare: { + width: 30, + height: 30, + borderRadius: 8, + backgroundColor: '#F0F8E0', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }, + cardTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + flex: 1, + }, + headerButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + chartToggleButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#F0F8E0', + alignItems: 'center', + justifyContent: 'center', + }, + addButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#F0F8E0', + alignItems: 'center', + justifyContent: 'center', + }, + emptyContent: { + alignItems: 'center', + paddingVertical: 20, + }, + emptyIconContainer: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#F0F8E0', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + }, + emptyTitle: { + fontSize: 16, + fontWeight: '700', + color: '#192126', + marginBottom: 6, + }, + emptyDescription: { + fontSize: 14, + color: '#687076', + textAlign: 'center', + marginBottom: 16, + lineHeight: 20, + }, + recordButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#BBF246', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, + gap: 6, + }, + recordButtonText: { + color: '#192126', + fontSize: 14, + fontWeight: '700', + }, + chartContainer: { + alignItems: 'center', + minHeight: 100, + }, + chartInfo: { + flexDirection: 'row', + justifyContent: 'space-around', + width: '100%', + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: '#F0F0F0', + }, + infoItem: { + alignItems: 'center', + }, + infoLabel: { + fontSize: 12, + color: '#687076', + marginBottom: 4, + }, + infoValue: { + fontSize: 14, + fontWeight: '700', + color: '#192126', + }, + lastRecordText: { + fontSize: 12, + color: '#687076', + textAlign: 'center', + marginTop: 8, + }, + summaryInfo: { + paddingVertical: 12, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 12, + }, + summaryItem: { + alignItems: 'center', + }, + summaryLabel: { + fontSize: 12, + color: '#687076', + marginBottom: 4, + }, + summaryValue: { + fontSize: 14, + fontWeight: '700', + color: '#192126', + }, + viewTrendButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F0F8E0', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 16, + gap: 6, + alignSelf: 'center', + }, + viewTrendText: { + fontSize: 13, + fontWeight: '600', + color: '#192126', + }, +}); diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json index ca0c7a1..f434400 100644 --- a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,14 +1,14 @@ { - "images": [ + "images" : [ { - "filename": "logo.jpeg", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" + "filename" : "logo.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], - "info": { - "version": 1, - "author": "expo" + "info" : { + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg deleted file mode 100644 index 3ad7433..0000000 Binary files a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png new file mode 100644 index 0000000..1f9eafc Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png differ diff --git a/ios/digitalpilates/Images.xcassets/Contents.json b/ios/digitalpilates/Images.xcassets/Contents.json index ed285c2..73c0059 100644 --- a/ios/digitalpilates/Images.xcassets/Contents.json +++ b/ios/digitalpilates/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "expo" + "author" : "xcode", + "version" : 1 } } diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json index 8396a3f..4e12055 100644 --- a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "logo.jpeg", + "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "logo 1.jpeg", + "filename" : "logo 1.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "logo 2.jpeg", + "filename" : "logo 2.png", "idiom" : "universal", "scale" : "3x" } diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg deleted file mode 100644 index 3ad7433..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png new file mode 100644 index 0000000..1f9eafc Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg deleted file mode 100644 index 3ad7433..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png new file mode 100644 index 0000000..1f9eafc Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg deleted file mode 100644 index 3ad7433..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png new file mode 100644 index 0000000..1f9eafc Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png differ diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json b/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..5f670ca --- /dev/null +++ b/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png b/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png new file mode 100644 index 0000000..d490ca6 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png differ diff --git a/store/userSlice.ts b/store/userSlice.ts index 0070de0..e919b73 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -23,6 +23,13 @@ export type UserState = { loading: boolean; error: string | null; privacyAgreed: boolean; + weightHistory: WeightHistoryItem[]; +}; + +export type WeightHistoryItem = { + weight: string; + source: string; + createdAt: string; }; export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; @@ -35,6 +42,7 @@ const initialState: UserState = { loading: false, error: null, privacyAgreed: false, + weightHistory: [], }; export type LoginPayload = Record & { @@ -151,6 +159,18 @@ export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_, } }); +// 获取用户体重历史记录 +export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', async (_, { rejectWithValue }) => { + try { + const data: WeightHistoryItem[] = await api.get('/api/users/weight-history'); + console.log('fetchWeightHistory', data); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取用户体重历史记录失败'); + } +}); + + const userSlice = createSlice({ name: 'user', initialState, @@ -208,6 +228,12 @@ const userSlice = createSlice({ }) .addCase(setPrivacyAgreed.fulfilled, (state) => { state.privacyAgreed = true; + }) + .addCase(fetchWeightHistory.fulfilled, (state, action) => { + state.weightHistory = action.payload; + }) + .addCase(fetchWeightHistory.rejected, (state, action) => { + state.error = (action.payload as string) ?? '获取用户体重历史记录失败'; }); }, });