From e3e2f1b8c6f9d3e8b1cca9d8463e0f034ba82d50 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 14 Aug 2025 09:57:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20AI=20=E6=95=99?= =?UTF-8?q?=E7=BB=83=E8=81=8A=E5=A4=A9=E5=92=8C=E6=89=93=E5=8D=A1=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互 --- app/(tabs)/explore.tsx | 11 ++- app/(tabs)/index.tsx | 7 +- app/(tabs)/personal.tsx | 21 ++++- app/_layout.tsx | 3 + app/ai-coach-chat.tsx | 174 ++++++++++++++++++++++++++++------ app/checkin/calendar.tsx | 122 ++++++++++++++++++++++++ app/checkin/index.tsx | 7 +- app/profile/edit.tsx | 186 +++++++++++++++++++++++++++---------- app/profile/goals.tsx | 102 ++++++++++++++++++-- components/ProgressBar.tsx | 9 +- hooks/useCosUpload.ts | 2 +- services/aiCoachSession.ts | 46 +++++++++ services/api.ts | 144 ++++++++++++++++++++++++++++ services/checkins.ts | 15 +++ services/cos.ts | 85 ++++++++++++++--- services/users.ts | 23 +++++ store/checkinSlice.ts | 69 +++++++++++++- store/userSlice.ts | 9 +- 18 files changed, 918 insertions(+), 117 deletions(-) create mode 100644 app/checkin/calendar.tsx create mode 100644 services/aiCoachSession.ts create mode 100644 services/users.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 746d108..9dcba7a 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -10,6 +10,7 @@ import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { SafeAreaView, @@ -22,6 +23,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function ExploreScreen() { + const router = useRouter(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; @@ -143,8 +145,13 @@ export default function ExploreScreen() { })} - {/* 今日报告 标题 */} - 今日报告 + {/* 打卡入口 */} + + 今日报告 + router.push('/checkin/calendar')} accessibilityRole="button"> + 查看打卡日历 + + {/* 取消卡片内 loading,保持静默刷新提升体验 */} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 539e609..1055551 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -172,11 +172,12 @@ export default function HomeScreen() { level="初学者" progress={0} /> - pushIfAuthedElseLogin('/challenge')}> + {/* 原“每周打卡”改为进入打卡日历 */} + router.push('/checkin/calendar')}> diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 3e8c2bb..ca4e546 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -72,15 +72,26 @@ export default function PersonalScreen() { useEffect(() => { load(); }, []); useFocusEffect(React.useCallback(() => { - // 每次聚焦时从后端拉取最新用户资料 + // 聚焦时只拉后端,避免与本地 load 循环触发 dispatch(fetchMyProfile()); - load(); return () => { }; }, [dispatch])); useEffect(() => { - if (userProfileFromRedux) { - setProfile(userProfileFromRedux); - } + const r = userProfileFromRedux as any; + if (!r) return; + setProfile((prev) => { + const next = { ...prev } as any; + const nameNext = (r.name && String(r.name)) || prev.name; + const genderNext = (r.gender === 'male' || r.gender === 'female') ? r.gender : (prev.gender ?? ''); + const avatarUriNext = typeof r.avatar === 'string' && (r.avatar.startsWith('http') || r.avatar.startsWith('data:')) + ? r.avatar + : prev.avatarUri; + let changed = false; + if (next.name !== nameNext) { next.name = nameNext; changed = true; } + if (next.gender !== genderNext) { next.gender = genderNext; changed = true; } + if (next.avatarUri !== avatarUriNext) { next.avatarUri = avatarUriNext; changed = true; } + return changed ? next : prev; + }); }, [userProfileFromRedux]); const formatHeight = () => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 5438663..587dd5c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,6 +6,7 @@ import 'react-native-reanimated'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { store } from '@/store'; import { rehydrateUser } from '@/store/userSlice'; import React from 'react'; @@ -15,6 +16,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); React.useEffect(() => { dispatch(rehydrateUser()); + // 冷启动时清空 AI 教练会话缓存 + clearAiCoachSessionCache(); }, [dispatch]); return <>{children}; } diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index 499b578..eb404cf 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -21,6 +21,8 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; +import { api, getAuthToken, postTextStream } from '@/services/api'; import type { CheckinRecord } from '@/store/checkinSlice'; type Role = 'user' | 'assistant'; @@ -43,6 +45,8 @@ export default function AICoachChatScreen() { 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', @@ -82,6 +86,31 @@ export default function AICoachChatScreen() { // 初次进入或恢复时,保持最新消息可见 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 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发 @@ -93,39 +122,128 @@ export default function AICoachChatScreen() { } }, [composerHeight, isAtBottom, scrollToEnd]); - async function fakeStreamResponse(prompt: string): Promise { - // 占位实现:模拟AI逐字输出(可替换为真实后端流式接口) - const canned = - prompt.includes('训练计划') || prompt.includes('制定') - ? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一:核心激活与呼吸(20-25分钟)\n- 周三:下肢稳定与髋部灵活(25-30分钟)\n- 周五:全身整合与平衡(30分钟)\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。' - : '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。'; - await new Promise((r) => setTimeout(r, 500)); - return canned; + 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 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: '' }]); + + 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); + 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; + // 流式失败时的降级:尝试一次性非流式 + 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 (!text.trim() || isSending) return; - const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() }; - // 标记:这次是新增消息,等内容真正渲染并触发 onContentSizeChange 时再滚动 - shouldAutoScrollRef.current = isAtBottom; - setMessages((m) => [...m, userMsg]); + const trimmed = text.trim(); setInput(''); - setIsSending(true); - // 立即滚动改为延后到 onContentSizeChange,避免突兀 - - try { - const replyText = await fakeStreamResponse(text.trim()); - const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText }; - // 同理:AI 消息到达时,与内容变化同步滚动 - shouldAutoScrollRef.current = isAtBottom; - setMessages((m) => [...m, aiMsg]); - } catch (e) { - const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' }; - shouldAutoScrollRef.current = isAtBottom; - setMessages((m) => [...m, aiMsg]); - } finally { - setIsSending(false); - } + await sendStream(trimmed); } function handleQuickPlan() { diff --git a/app/checkin/calendar.tsx b/app/checkin/calendar.tsx new file mode 100644 index 0000000..3a4f5bf --- /dev/null +++ b/app/checkin/calendar.tsx @@ -0,0 +1,122 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins'; +import { getDailyCheckins, loadMonthCheckins, setCurrentDate } from '@/store/checkinSlice'; +import { getMonthDaysZh } from '@/utils/date'; +import dayjs from 'dayjs'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +function formatDate(d: Date) { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export default function CheckinCalendarScreen() { + const router = useRouter(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const dispatch = useAppDispatch(); + const checkin = useAppSelector((s) => (s as any).checkin); + const insets = useSafeAreaInsets(); + + const [cursor, setCursor] = useState(dayjs()); + const days = useMemo(() => getMonthDaysZh(cursor), [cursor]); + const monthTitle = useMemo(() => `${cursor.format('YYYY年M月')} 打卡`, [cursor]); + const [statusMap, setStatusMap] = useState>({}); + + useEffect(() => { + dispatch(loadMonthCheckins({ year: cursor.year(), month1Based: cursor.month() + 1 })); + const y = cursor.year(); + const m = cursor.month() + 1; + const pad = (n: number) => `${n}`.padStart(2, '0'); + const startDate = `${y}-${pad(m)}-01`; + const endDate = `${y}-${pad(m)}-${pad(new Date(y, m, 0).getDate())}`; + fetchDailyStatusRange(startDate, endDate) + .then((list: DailyStatusItem[]) => { + const next: Record = {}; + for (const it of list) { + if (typeof it?.date === 'string') next[it.date] = !!it?.checkedIn; + } + setStatusMap(next); + }) + .catch(() => setStatusMap({})); + }, [cursor, dispatch]); + + const goPrevMonth = () => setCursor((c) => c.subtract(1, 'month')); + const goNextMonth = () => setCursor((c) => c.add(1, 'month')); + + return ( + + + router.back()} withSafeTop={false} transparent /> + + 上一月 + {monthTitle} + 下一月 + + + item.date.format('YYYY-MM-DD')} + numColumns={5} + columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }} + contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: insets.bottom + 20 }} + renderItem={({ item }) => { + const d = item.date.toDate(); + const dateStr = formatDate(d); + const hasAny = statusMap[dateStr] ?? !!(checkin?.byDate?.[dateStr]?.items?.length); + const isToday = formatDate(new Date()) === dateStr; + return ( + { + dispatch(setCurrentDate(dateStr)); + await dispatch(getDailyCheckins(dateStr)); + router.push('/checkin'); + }} + activeOpacity={0.8} + style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]} + > + {item.dayOfMonth} + {hasAny && } + + ); + }} + /> + + + ); +} + +const { width } = Dimensions.get('window'); +const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4 + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#F7F8FA' }, + container: { flex: 1, backgroundColor: '#F7F8FA' }, + headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 6 }, + monthTitle: { fontSize: 18, fontWeight: '800' }, + monthBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999 }, + monthBtnText: { fontWeight: '700' }, + dayCell: { + width: cellSize, + height: cellSize, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3, + position: 'relative', + }, + dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' }, + dayCellToday: { borderWidth: 1, borderColor: '#BBF246' }, + dayNumber: { fontWeight: '800', fontSize: 16 }, + dot: { position: 'absolute', top: 6, right: 6, width: 8, height: 8, borderRadius: 4, backgroundColor: '#10B981' }, +}); + + diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx index ef119a8..a41cba4 100644 --- a/app/checkin/index.tsx +++ b/app/checkin/index.tsx @@ -3,7 +3,7 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import type { CheckinExercise } from '@/store/checkinSlice'; -import { getDailyCheckins, removeExercise, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice'; +import { getDailyCheckins, loadMonthCheckins, removeExercise, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; @@ -32,6 +32,9 @@ export default function CheckinHome() { dispatch(getDailyCheckins(today)).unwrap().catch((err: any) => { Alert.alert('获取打卡失败', err?.message || '请稍后重试'); }); + // 预取本月数据(用于日历视图点亮) + const now = new Date(); + dispatch(loadMonthCheckins({ year: now.getFullYear(), month1Based: now.getMonth() + 1 })); }, [dispatch, today]); useFocusEffect( @@ -52,7 +55,7 @@ export default function CheckinHome() { - router.back()} withSafeTop={false} transparent /> + router.back()} withSafeTop={false} transparent /> {today} 请选择动作并记录完成情况 diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index a728d44..a211806 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -1,11 +1,16 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useCosUpload } from '@/hooks/useCosUpload'; +import { updateUser as updateUserApi } from '@/services/users'; +import { fetchMyProfile } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useFocusEffect } from '@react-navigation/native'; import * as ImagePicker from 'expo-image-picker'; import { router } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Alert, Image, @@ -30,9 +35,10 @@ interface UserProfile { gender?: 'male' | 'female' | ''; age?: string; // 存储为字符串,方便非必填 // 以公制为基准存储 - weightKg?: number; // kg - heightCm?: number; // cm + weight?: number; // kg + height?: number; // cm avatarUri?: string | null; + avatarBase64?: string | null; // 兼容旧逻辑(不再上报) } const STORAGE_KEY = '@user_profile'; @@ -41,13 +47,24 @@ export default function EditProfileScreen() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; const insets = useSafeAreaInsets(); + const dispatch = useAppDispatch(); + const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any); + const userId: string | undefined = useMemo(() => { + return ( + accountProfile?.userId || + accountProfile?.id || + accountProfile?._id || + accountProfile?.uid || + undefined + ) as string | undefined; + }, [accountProfile]); const [profile, setProfile] = useState({ name: '', gender: '', age: '', - weightKg: undefined, - heightCm: undefined, + weight: undefined, + height: undefined, avatarUri: null, }); @@ -56,72 +73,128 @@ export default function EditProfileScreen() { // 输入框字符串 - useEffect(() => { - (async () => { - try { - // 读取已保存资料;兼容引导页的个人信息 - const [p, fromOnboarding] = await Promise.all([ - AsyncStorage.getItem(STORAGE_KEY), - AsyncStorage.getItem('@user_personal_info'), - ]); + // 从本地存储加载(身高/体重等本地字段) + const loadLocalProfile = async () => { + try { + const [p, fromOnboarding] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEY), + AsyncStorage.getItem('@user_personal_info'), + ]); + let next: UserProfile = { + name: '', + gender: '', + age: '', + weight: undefined, + height: undefined, + avatarUri: null, + }; + if (fromOnboarding) { + try { + const o = JSON.parse(fromOnboarding); - let next: UserProfile = { - name: '', - gender: '', - age: '', - weightKg: undefined, - heightCm: undefined, - avatarUri: null, - }; - - if (fromOnboarding) { - try { - const o = JSON.parse(fromOnboarding); - if (o?.weight) next.weightKg = parseFloat(o.weight) || undefined; - if (o?.height) next.heightCm = parseFloat(o.height) || undefined; - if (o?.age) next.age = String(o.age); - if (o?.gender) next.gender = o.gender; - } catch { } - } - - if (p) { - try { - const parsed: UserProfile = JSON.parse(p); - next = { ...next, ...parsed }; - } catch { } - } - setProfile(next); - setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : ''); - setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : ''); - } catch (e) { - console.warn('读取资料失败', e); + if (o?.weight) next.weight = parseFloat(o.weight) || undefined; + if (o?.height) next.height = parseFloat(o.height) || undefined; + if (o?.age) next.age = String(o.age); + if (o?.gender) next.gender = o.gender; + } catch { } } - })(); + if (p) { + try { + const parsed: UserProfile = JSON.parse(p); + next = { ...next, ...parsed }; + } catch { } + } + console.log('loadLocalProfile', next); + setProfile((prev) => ({ ...next, avatarUri: prev.avatarUri ?? next.avatarUri ?? null })); + setWeightInput(next.weight != null ? String(round(next.weight, 1)) : ''); + setHeightInput(next.height != null ? String(Math.round(next.height)) : ''); + } catch (e) { + console.warn('读取资料失败', e); + } + }; + + useEffect(() => { + loadLocalProfile(); }, []); + // 页面聚焦时拉取最新用户信息,并刷新本地 UI + useFocusEffect( + React.useCallback(() => { + let cancelled = false; + (async () => { + try { + await dispatch(fetchMyProfile() as any); + if (!cancelled) { + // 拉取完成后,再次从本地存储同步身高/体重等字段 + await loadLocalProfile(); + } + } catch { } + })(); + return () => { cancelled = true; }; + }, [dispatch]) + ); + + // 当全局 profile 更新时,用后端字段覆盖页面 UI 的对应字段(不影响本地身高/体重) + useEffect(() => { + if (!accountProfile) return; + setProfile((prev) => ({ + ...prev, + name: accountProfile?.name ?? prev.name ?? '', + gender: (accountProfile?.gender === 'male' || accountProfile?.gender === 'female') ? accountProfile.gender : (prev.gender ?? ''), + avatarUri: accountProfile?.avatar && typeof accountProfile.avatar === 'string' + ? (accountProfile.avatar.startsWith('http') || accountProfile.avatar.startsWith('data:') ? accountProfile.avatar : prev.avatarUri) + : prev.avatarUri, + weight: accountProfile?.weight ?? prev.weight ?? undefined, + height: accountProfile?.height ?? prev.height ?? undefined, + })); + }, [accountProfile]); + const textColor = colors.text; const placeholderColor = colors.icon; const handleSave = async () => { try { + if (!userId) { + Alert.alert('未登录', '请先登录后再尝试保存'); + return; + } const next: UserProfile = { ...profile }; // 将当前输入同步为公制(固定 kg/cm) const w = parseFloat(weightInput); if (!isNaN(w)) { - next.weightKg = w; + next.weight = w; } else { - next.weightKg = undefined; + next.weight = undefined; } const h = parseFloat(heightInput); if (!isNaN(h)) { - next.heightCm = h; + next.height = h; } else { - next.heightCm = undefined; + next.height = undefined; } await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + + // 同步到后端(仅更新后端需要的字段) + try { + await updateUserApi({ + userId, + name: next.name || undefined, + gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined, + // 头像采用已上传的 URL(若有) + avatar: next.avatarUri || undefined, + weight: next.weight || undefined, + height: next.height || undefined, + }); + // 拉取最新用户信息,刷新全局状态 + await dispatch(fetchMyProfile() as any); + } catch (e: any) { + // 接口失败不阻断本地保存 + console.warn('更新用户信息失败', e?.message || e); + } + Alert.alert('已保存', '个人资料已更新。'); router.back(); } catch (e) { @@ -131,6 +204,8 @@ export default function EditProfileScreen() { // 不再需要单位切换 + const { upload, uploading } = useCosUpload(); + const pickAvatarFromLibrary = async () => { try { const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -144,9 +219,22 @@ export default function EditProfileScreen() { quality: 0.9, aspect: [1, 1], mediaTypes: ImagePicker.MediaTypeOptions.Images, + base64: false, }); if (!result.canceled) { - setProfile((p) => ({ ...p, avatarUri: result.assets?.[0]?.uri ?? null })); + const asset = result.assets?.[0]; + if (!asset?.uri) return; + // 直接上传到 COS,成功后写入 URL + try { + const { url } = await upload( + { uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' }, + { prefix: 'avatars/', userId } + ); + setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null })); + } catch (e) { + console.warn('上传头像失败', e); + Alert.alert('上传失败', '头像上传失败,请重试'); + } } } catch (e) { Alert.alert('发生错误', '选择头像失败,请重试'); diff --git a/app/profile/goals.tsx b/app/profile/goals.tsx index 238a441..5fd738e 100644 --- a/app/profile/goals.tsx +++ b/app/profile/goals.tsx @@ -17,8 +17,10 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ProgressBar } from '@/components/ProgressBar'; -import { useAppDispatch } from '@/hooks/redux'; -import { setDailyCaloriesGoal, setDailyStepsGoal, setPilatesPurposes } from '@/store/userSlice'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { updateUser as updateUserApi } from '@/services/users'; +import { fetchMyProfile } from '@/store/userSlice'; +import { useFocusEffect } from '@react-navigation/native'; const STORAGE_KEYS = { calories: '@goal_calories_burn', @@ -29,16 +31,39 @@ const STORAGE_KEYS = { const CALORIES_RANGE = { min: 100, max: 1500, step: 50 }; const STEPS_RANGE = { min: 2000, max: 20000, step: 500 }; +function arraysEqualUnordered(a?: string[], b?: string[]): boolean { + if (!Array.isArray(a) && !Array.isArray(b)) return true; + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + for (let i = 0; i < sa.length; i += 1) { + if (sa[i] !== sb[i]) return false; + } + return true; +} + export default function GoalsScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colors = Colors[theme]; const dispatch = useAppDispatch(); + const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any); + const userId: string | undefined = useMemo(() => { + return ( + accountProfile?.userId || + accountProfile?.id || + accountProfile?._id || + accountProfile?.uid || + undefined + ) as string | undefined; + }, [accountProfile]); const [calories, setCalories] = useState(400); const [steps, setSteps] = useState(8000); const [purposes, setPurposes] = useState([]); + const lastSentRef = React.useRef<{ calories?: number; steps?: number; purposes?: string[] }>({}); useEffect(() => { const load = async () => { @@ -67,20 +92,81 @@ export default function GoalsScreen() { load(); }, []); + // 页面聚焦时,从后端拉取并用全局 profile 的值覆盖 UI,保证是最新 + useFocusEffect( + React.useCallback(() => { + (async () => { + try { + await dispatch(fetchMyProfile() as any); + const latest = (accountProfile ?? {}) as any; + if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal); + if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal); + if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string')); + } catch { } + })(); + }, [dispatch]) + ); + + // 当全局 profile 有变化时,同步覆盖 UI + useEffect(() => { + const latest = (accountProfile ?? {}) as any; + if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal); + if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal); + if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string')); + }, [accountProfile]); + + // 当全局 profile 变化(例如刚拉完或保存后刷新)时,将“已发送基线”对齐为后端值,避免重复上报 + useEffect(() => { + const latest = (accountProfile ?? {}) as any; + if (typeof latest?.dailyCaloriesGoal === 'number') { + lastSentRef.current.calories = latest.dailyCaloriesGoal; + } + if (typeof latest?.dailyStepsGoal === 'number') { + lastSentRef.current.steps = latest.dailyStepsGoal; + } + if (Array.isArray(latest?.pilatesPurposes)) { + lastSentRef.current.purposes = [...latest.pilatesPurposes]; + } + }, [accountProfile]); + useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { }); - dispatch(setDailyCaloriesGoal(calories)); - }, [calories]); + if (!userId) return; + if (lastSentRef.current.calories === calories) return; + lastSentRef.current.calories = calories; + (async () => { + try { + await updateUserApi({ userId, dailyCaloriesGoal: calories }); + await dispatch(fetchMyProfile() as any); + } catch { } + })(); + }, [calories, userId]); useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { }); - dispatch(setDailyStepsGoal(steps)); - }, [steps]); + if (!userId) return; + if (lastSentRef.current.steps === steps) return; + lastSentRef.current.steps = steps; + (async () => { + try { + await updateUserApi({ userId, dailyStepsGoal: steps }); + await dispatch(fetchMyProfile() as any); + } catch { } + })(); + }, [steps, userId]); useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { }); - dispatch(setPilatesPurposes(purposes)); - }, [purposes]); + if (!userId) return; + if (arraysEqualUnordered(lastSentRef.current.purposes, purposes)) return; + lastSentRef.current.purposes = [...purposes]; + (async () => { + try { + await updateUserApi({ userId, pilatesPurposes: purposes }); + await dispatch(fetchMyProfile() as any); + } catch { } + })(); + }, [purposes, userId]); const caloriesPercent = useMemo(() => (Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) / diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx index e536fe1..a3ea27e 100644 --- a/components/ProgressBar.tsx +++ b/components/ProgressBar.tsx @@ -41,8 +41,15 @@ function ProgressBarImpl({ }).start(); }, [clamped, animated, animatedValue]); + const lastWidthRef = useRef(0); const onLayout = (e: LayoutChangeEvent) => { - setTrackWidth(e.nativeEvent.layout.width); + const w = e.nativeEvent.layout.width || 0; + // 仅在宽度发生明显变化时才更新,避免渲染-布局循环 + const next = Math.round(w); + if (next > 0 && next !== lastWidthRef.current) { + lastWidthRef.current = next; + setTrackWidth(next); + } }; const fillWidth = Animated.multiply(animatedValue, trackWidth || 1); diff --git a/hooks/useCosUpload.ts b/hooks/useCosUpload.ts index c98cf97..3b83eb6 100644 --- a/hooks/useCosUpload.ts +++ b/hooks/useCosUpload.ts @@ -44,7 +44,7 @@ export function useCosUpload(defaultOptions?: UseCosUploadOptions) { signal: controller.signal, onProgress: ({ percent }) => setProgress(percent), }); - const url = buildPublicUrl(res.key); + const url = (res as any).publicUrl || buildPublicUrl(res.key); return { key: res.key, url }; } finally { setUploading(false); diff --git a/services/aiCoachSession.ts b/services/aiCoachSession.ts new file mode 100644 index 0000000..8b7ef76 --- /dev/null +++ b/services/aiCoachSession.ts @@ -0,0 +1,46 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export type AiCoachChatMessage = { + id: string; + role: 'user' | 'assistant'; + content: string; +}; + +export type AiCoachSessionCache = { + conversationId?: string; + messages: AiCoachChatMessage[]; + updatedAt: number; +}; + +const STORAGE_KEY = '@ai_coach_session_v1'; + +export async function loadAiCoachSessionCache(): Promise { + try { + const s = await AsyncStorage.getItem(STORAGE_KEY); + if (!s) return null; + const obj = JSON.parse(s) as AiCoachSessionCache; + if (!obj || !Array.isArray(obj.messages)) return null; + return obj; + } catch { + return null; + } +} + +export async function saveAiCoachSessionCache(cache: AiCoachSessionCache): Promise { + try { + const payload: AiCoachSessionCache = { + conversationId: cache.conversationId, + messages: cache.messages?.slice?.(-200) ?? [], // 限制最多缓存 200 条,避免无限增长 + updatedAt: Date.now(), + }; + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { } +} + +export async function clearAiCoachSessionCache(): Promise { + try { + await AsyncStorage.removeItem(STORAGE_KEY); + } catch { } +} + + diff --git a/services/api.ts b/services/api.ts index 23190ac..93f5dd2 100644 --- a/services/api.ts +++ b/services/api.ts @@ -85,4 +85,148 @@ export async function loadPersistedToken(): Promise { } } +// 流式文本 POST(基于 XMLHttpRequest),支持增量 onChunk 回调与取消 +export type TextStreamCallbacks = { + onChunk: (chunkText: string) => void; + onEnd?: (conversationId?: string) => void; + onError?: (error: any) => void; +}; + +export type TextStreamOptions = { + headers?: Record; + timeoutMs?: number; + signal?: AbortSignal; +}; + +export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { + const url = buildApiUrl(path); + const token = getAuthToken(); + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + if (token) { + requestHeaders['Authorization'] = `Bearer ${token}`; + } + + const xhr = new XMLHttpRequest(); + let lastReadIndex = 0; + let resolved = false; + let conversationIdFromHeader: string | undefined = undefined; + + const abort = () => { + try { xhr.abort(); } catch { } + }; + + const cleanup = () => { + resolved = true; + }; + + // 日志:请求开始 + try { + console.log('[AI_CHAT][stream] start', { url, hasToken: !!token, body }); + } catch { } + + xhr.open('POST', url, true); + // 设置超时(可选) + if (typeof options.timeoutMs === 'number') { + xhr.timeout = options.timeoutMs; + } + // 设置请求头 + Object.entries(requestHeaders).forEach(([k, v]) => { + try { xhr.setRequestHeader(k, v); } catch { } + }); + + // 进度事件:读取新增的响应文本 + xhr.onprogress = () => { + try { + const text = xhr.responseText ?? ''; + if (text.length > lastReadIndex) { + const nextChunk = text.substring(lastReadIndex); + lastReadIndex = text.length; + // 首次拿到响应头时尝试解析会话ID + if (!conversationIdFromHeader) { + try { + const rawHeaders = xhr.getAllResponseHeaders?.() || ''; + const matched = /^(.*)$/m.test(rawHeaders) ? rawHeaders : rawHeaders; // 保底,避免 TS 报错 + const headerLines = String(matched).split('\n'); + for (const line of headerLines) { + const [hk, ...rest] = line.split(':'); + if (hk && hk.toLowerCase() === 'x-conversation-id') { + conversationIdFromHeader = rest.join(':').trim(); + break; + } + } + } catch { } + } + try { + callbacks.onChunk(nextChunk); + } catch (err) { + console.warn('[AI_CHAT][stream] onChunk error', err); + } + try { + console.log('[AI_CHAT][stream] chunk', { length: nextChunk.length, preview: nextChunk.slice(0, 50) }); + } catch { } + } + } catch (err) { + console.warn('[AI_CHAT][stream] onprogress error', err); + } + }; + + xhr.onreadystatechange = () => { + if (xhr.readyState === xhr.DONE) { + try { console.log('[AI_CHAT][stream] done', { status: xhr.status }); } catch { } + if (!resolved) { + cleanup(); + if (xhr.status >= 200 && xhr.status < 300) { + try { callbacks.onEnd?.(conversationIdFromHeader); } catch { } + } else { + const error = new Error(`HTTP ${xhr.status}`); + try { callbacks.onError?.(error); } catch { } + } + } + } + }; + + xhr.onerror = (e) => { + try { console.warn('[AI_CHAT][stream] xhr error', e); } catch { } + if (!resolved) { + cleanup(); + try { callbacks.onError?.(e); } catch { } + } + }; + + xhr.ontimeout = () => { + const err = new Error('Request timeout'); + try { console.warn('[AI_CHAT][stream] timeout'); } catch { } + if (!resolved) { + cleanup(); + try { callbacks.onError?.(err); } catch { } + } + }; + + // AbortSignal 支持 + if (options.signal) { + const onAbort = () => { + try { console.log('[AI_CHAT][stream] aborted'); } catch { } + abort(); + }; + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener('abort', onAbort, { once: true }); + } + + try { + const payload = body != null ? JSON.stringify(body) : undefined; + xhr.send(payload); + } catch (err) { + try { console.warn('[AI_CHAT][stream] send error', err); } catch { } + if (!resolved) { + cleanup(); + try { callbacks.onError?.(err); } catch { } + } + } + + return { abort }; +} + diff --git a/services/checkins.ts b/services/checkins.ts index 18211bd..aa4e820 100644 --- a/services/checkins.ts +++ b/services/checkins.ts @@ -55,4 +55,19 @@ export async function fetchDailyCheckins(date?: string): Promise { return Array.isArray(data) ? data : []; } +// 优先尝试按区间批量获取(若后端暂未实现将抛错,由调用方回退到逐日请求) +export async function fetchCheckinsInRange(startDate: string, endDate: string): Promise { + const path = `/api/checkins/range?start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`; + const data = await api.get(path); + return Array.isArray(data) ? data : []; +} + +// 获取时间范围内每日是否已打卡(仅返回日期+布尔) +export type DailyStatusItem = { date: string; checkedIn: boolean }; +export async function fetchDailyStatusRange(startDate: string, endDate: string): Promise { + const path = `/api/checkins/range/daily-status?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`; + const data = await api.get(path); + return Array.isArray(data) ? data : []; +} + diff --git a/services/cos.ts b/services/cos.ts index 96d6577..13c2ccf 100644 --- a/services/cos.ts +++ b/services/cos.ts @@ -1,6 +1,18 @@ -import { COS_BUCKET, COS_REGION } from '@/constants/Cos'; +import { COS_BUCKET, COS_REGION, buildPublicUrl } from '@/constants/Cos'; import { api } from '@/services/api'; +type ServerCosToken = { + tmpSecretId: string; + tmpSecretKey: string; + sessionToken: string; + startTime?: number; + expiredTime?: number; + bucket?: string; + region?: string; + prefix?: string; + cdnDomain?: string; +}; + type CosCredential = { credentials: { tmpSecretId: string; @@ -9,6 +21,10 @@ type CosCredential = { }; startTime?: number; expiredTime?: number; + bucket?: string; + region?: string; + prefix?: string; + cdnDomain?: string; }; type UploadOptions = { @@ -23,23 +39,63 @@ let CosSdk: any | null = null; async function ensureCosSdk(): Promise { if (CosSdk) return CosSdk; - // 动态导入避免影响首屏 - const mod = await import('cos-js-sdk-v5'); - CosSdk = mod.default ?? mod; + // RN 兼容:SDK 在初始化时会访问 navigator.userAgent + const g: any = globalThis as any; + if (!g.navigator) g.navigator = {}; + if (!g.navigator.userAgent) g.navigator.userAgent = 'react-native'; + // 动态导入避免影响首屏,并加入 require 回退,兼容打包差异 + let mod: any = null; + try { + mod = await import('cos-js-sdk-v5'); + } catch (_) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + mod = require('cos-js-sdk-v5'); + } catch { } + } + const Candidate = mod?.COS || mod?.default || mod; + if (!Candidate) { + throw new Error('cos-js-sdk-v5 加载失败'); + } + CosSdk = Candidate; return CosSdk; } async function fetchCredential(): Promise { - return await api.get('/users/cos-token'); + // 后端返回 { code, message, data },api.get 会提取 data + const data = await api.get('/api/users/cos/upload-token'); + return { + credentials: { + tmpSecretId: data.tmpSecretId, + tmpSecretKey: data.tmpSecretKey, + sessionToken: data.sessionToken, + }, + startTime: data.startTime, + expiredTime: data.expiredTime, + bucket: data.bucket, + region: data.region, + prefix: data.prefix, + cdnDomain: data.cdnDomain, + }; } -export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record }> { +export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record; publicUrl?: string }> { const { key, body, contentType, onProgress, signal } = options; - if (!COS_BUCKET || !COS_REGION) { - throw new Error('未配置 COS_BUCKET / COS_REGION'); - } const COS = await ensureCosSdk(); const cred = await fetchCredential(); + const bucket = COS_BUCKET || cred.bucket; + const region = COS_REGION || cred.region; + if (!bucket || !region) { + throw new Error('未配置 COS_BUCKET / COS_REGION,且服务端未返回 bucket/region'); + } + + // 确保对象键以服务端授权的前缀开头 + const finalKey = ((): string => { + const prefix = (cred.prefix || '').replace(/^\/+|\/+$/g, ''); + if (!prefix) return key.replace(/^\//, ''); + const normalizedKey = key.replace(/^\//, ''); + return normalizedKey.startsWith(prefix + '/') ? normalizedKey : `${prefix}/${normalizedKey}`; + })(); const controller = new AbortController(); if (signal) { @@ -62,9 +118,9 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string const task = cos.putObject( { - Bucket: COS_BUCKET, - Region: COS_REGION, - Key: key, + Bucket: bucket, + Region: region, + Key: finalKey, Body: body, ContentType: contentType, onProgress: (progressData: any) => { @@ -76,7 +132,10 @@ export async function uploadToCos(options: UploadOptions): Promise<{ key: string }, (err: any, data: any) => { if (err) return reject(err); - resolve({ key, etag: data && data.ETag, headers: data && data.headers }); + const publicUrl = cred.cdnDomain + ? `${String(cred.cdnDomain).replace(/\/$/, '')}/${finalKey.replace(/^\//, '')}` + : buildPublicUrl(finalKey); + resolve({ key: finalKey, etag: data && data.ETag, headers: data && data.headers, publicUrl }); } ); diff --git a/services/users.ts b/services/users.ts new file mode 100644 index 0000000..2797316 --- /dev/null +++ b/services/users.ts @@ -0,0 +1,23 @@ +import { api } from '@/services/api'; + +export type Gender = 'male' | 'female'; + +export type UpdateUserDto = { + userId: string; + name?: string; + avatar?: string; // base64 字符串 + gender?: Gender; + birthDate?: number; // 时间戳(秒) + dailyStepsGoal?: number; + dailyCaloriesGoal?: number; + pilatesPurposes?: string[]; + weight?: number; + height?: number; +}; + +export async function updateUser(dto: UpdateUserDto): Promise> { + // 固定使用后端文档接口:PUT /api/users/update + return await api.put('/api/users/update', dto); +} + + diff --git a/store/checkinSlice.ts b/store/checkinSlice.ts index e7d919a..b8219ad 100644 --- a/store/checkinSlice.ts +++ b/store/checkinSlice.ts @@ -1,4 +1,4 @@ -import { createCheckin, fetchDailyCheckins, updateCheckin } from '@/services/checkins'; +import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, updateCheckin } from '@/services/checkins'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; export type CheckinExercise = { @@ -21,11 +21,13 @@ export type CheckinRecord = { export type CheckinState = { byDate: Record; currentDate: string | null; + monthLoaded: Record; // key: YYYY-MM, 标记该月数据是否已加载 }; const initialState: CheckinState = { byDate: {}, currentDate: null, + monthLoaded: {}, }; function ensureRecord(state: CheckinState, date: string): CheckinRecord { @@ -105,6 +107,14 @@ const checkinSlice = createSlice({ items: mergedItems, note, }; + }) + .addCase(loadMonthCheckins.fulfilled, (state, action) => { + const monthKey = action.payload.monthKey; + const merged = action.payload.byDate; + for (const d of Object.keys(merged)) { + state.byDate[d] = merged[d]; + } + state.monthLoaded[monthKey] = true; }); }, }); @@ -140,4 +150,61 @@ export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date return { date, list } as { date?: string; list: any[] }; }); +// 按月加载:优先使用区间接口,失败则逐日回退 +export const loadMonthCheckins = createAsyncThunk( + 'checkin/loadMonth', + async (payload: { year: number; month1Based: number }, { getState }) => { + const { year, month1Based } = payload; + const pad = (n: number) => `${n}`.padStart(2, '0'); + const monthKey = `${year}-${pad(month1Based)}`; + + const start = `${year}-${pad(month1Based)}-01`; + const endDate = new Date(year, month1Based, 0).getDate(); + const end = `${year}-${pad(month1Based)}-${pad(endDate)}`; + + try { + const list = await fetchCheckinsInRange(start, end); + const byDate: Record = {}; + for (const rec of list) { + const date = rec?.checkinDate || rec?.date; + if (!date) continue; + const id = rec?.id ? String(rec.id) : `rec_${date}`; + const items = (rec?.metrics?.items ?? rec?.items) ?? []; + const note = typeof rec?.notes === 'string' ? rec.notes : undefined; + byDate[date] = { id, date, items, note }; + } + return { monthKey, byDate } as { monthKey: string; byDate: Record }; + } catch { + // 回退逐日请求(并行) + const endNum = new Date(year, month1Based, 0).getDate(); + const dates = Array.from({ length: endNum }, (_, i) => `${year}-${pad(month1Based)}-${pad(i + 1)}`); + const results = await Promise.all( + dates.map(async (d) => ({ d, list: await fetchDailyCheckins(d) })) + ); + const byDate: Record = {}; + for (const { d, list } of results) { + let items: CheckinExercise[] = []; + let note: string | undefined; + let id: string | undefined; + for (const rec of list) { + if (rec?.id && !id) id = String(rec.id); + const metricsItems = rec?.metrics?.items ?? rec?.items; + if (Array.isArray(metricsItems)) items = metricsItems as CheckinExercise[]; + if (typeof rec?.notes === 'string') note = rec.notes as string; + } + byDate[d] = { id: id || `rec_${d}`, date: d, items, note }; + } + return { monthKey, byDate } as { monthKey: string; byDate: Record }; + } + }, + { + condition: (payload, { getState }) => { + const state = getState() as any; + const pad = (n: number) => `${n}`.padStart(2, '0'); + const monthKey = `${payload.year}-${pad(payload.month1Based)}`; + return !state?.checkin?.monthLoaded?.[monthKey]; + }, + } +); + diff --git a/store/userSlice.ts b/store/userSlice.ts index 088753e..dfbe2a4 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -8,10 +8,10 @@ export type UserProfile = { name?: string; email?: string; gender?: Gender; - age?: string; // 个人中心是字符串展示 - weightKg?: number; - heightCm?: number; - avatarUri?: string | null; + age?: number; // 个人中心是字符串展示 + weight?: number; + height?: number; + avatar?: string | null; dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) dailyCaloriesGoal?: number; // 每日卡路里消耗目标 pilatesPurposes?: string[]; // 普拉提目的(多选) @@ -131,6 +131,7 @@ export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_, try { // 固定使用后端文档的接口:/api/users/info const data: any = await api.get('/api/users/info'); + console.log('fetchMyProfile', data); const profile: UserProfile = (data as any).profile ?? (data as any).user ?? (data as any).account ?? (data as any); await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {})); return profile;