feat: 优化 AI 教练聊天和打卡功能

- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-14 09:57:13 +08:00
parent 7ad26590e5
commit e3e2f1b8c6
18 changed files with 918 additions and 117 deletions

View File

@@ -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() {
})}
</ScrollView>
{/* 今日报告 标题 */}
<Text style={styles.sectionTitle}></Text>
{/* 打卡入口 */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity onPress={() => router.push('/checkin/calendar')} accessibilityRole="button">
<Text style={{ color: '#6B7280', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
{/* 取消卡片内 loading保持静默刷新提升体验 */}

View File

@@ -172,11 +172,12 @@ export default function HomeScreen() {
level="初学者"
progress={0}
/>
<Pressable onPress={() => pushIfAuthedElseLogin('/challenge')}>
{/* 原“每周打卡”改为进入打卡日历 */}
<Pressable onPress={() => router.push('/checkin/calendar')}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="每周打卡"
subtitle="养成训练习惯,练出好身材"
title="打卡日历"
subtitle="查看每日打卡记录(点亮日期)"
progress={0.75}
/>
</Pressable>

View File

@@ -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 = () => {

View File

@@ -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}</>;
}

View File

@@ -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<string | undefined>(undefined);
const [messages, setMessages] = useState<ChatMessage[]>([{
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<ReturnType<typeof setTimeout> | 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<string> {
// 占位实现模拟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() {

122
app/checkin/calendar.tsx Normal file
View File

@@ -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<Record<string, boolean>>({});
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<string, boolean> = {};
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 (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<HeaderBar title="打卡日历" onBack={() => router.back()} withSafeTop={false} transparent />
<View style={styles.headerRow}>
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goPrevMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}></Text></TouchableOpacity>
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goNextMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}></Text></TouchableOpacity>
</View>
<FlatList
data={days}
keyExtractor={(item) => 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 (
<TouchableOpacity
onPress={async () => {
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]}
>
<Text style={[styles.dayNumber, { color: colorTokens.text }]}>{item.dayOfMonth}</Text>
{hasAny && <View style={styles.dot} />}
</TouchableOpacity>
);
}}
/>
</View>
</SafeAreaView>
);
}
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' },
});

View File

@@ -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() {
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
</View>
<HeaderBar title="日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<HeaderBar title="日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
<Text style={[styles.title, { color: colorTokens.text }]}>{today}</Text>
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}></Text>

View File

@@ -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<UserProfile>({
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('发生错误', '选择头像失败,请重试');

View File

@@ -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<number>(400);
const [steps, setSteps] = useState<number>(8000);
const [purposes, setPurposes] = useState<string[]>([]);
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) /

View File

@@ -41,8 +41,15 @@ function ProgressBarImpl({
}).start();
}, [clamped, animated, animatedValue]);
const lastWidthRef = useRef<number>(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);

View File

@@ -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);

View File

@@ -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<AiCoachSessionCache | null> {
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<void> {
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<void> {
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch { }
}

View File

@@ -85,4 +85,148 @@ export async function loadPersistedToken(): Promise<string | null> {
}
}
// 流式文本 POST基于 XMLHttpRequest支持增量 onChunk 回调与取消
export type TextStreamCallbacks = {
onChunk: (chunkText: string) => void;
onEnd?: (conversationId?: string) => void;
onError?: (error: any) => void;
};
export type TextStreamOptions = {
headers?: Record<string, string>;
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<string, string> = {
'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 };
}

View File

@@ -55,4 +55,19 @@ export async function fetchDailyCheckins(date?: string): Promise<any[]> {
return Array.isArray(data) ? data : [];
}
// 优先尝试按区间批量获取(若后端暂未实现将抛错,由调用方回退到逐日请求)
export async function fetchCheckinsInRange(startDate: string, endDate: string): Promise<any[]> {
const path = `/api/checkins/range?start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
const data = await api.get<any[]>(path);
return Array.isArray(data) ? data : [];
}
// 获取时间范围内每日是否已打卡(仅返回日期+布尔)
export type DailyStatusItem = { date: string; checkedIn: boolean };
export async function fetchDailyStatusRange(startDate: string, endDate: string): Promise<DailyStatusItem[]> {
const path = `/api/checkins/range/daily-status?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`;
const data = await api.get<DailyStatusItem[]>(path);
return Array.isArray(data) ? data : [];
}

View File

@@ -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<any> {
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<CosCredential> {
return await api.get<CosCredential>('/users/cos-token');
// 后端返回 { code, message, data }api.get 会提取 data
const data = await api.get<ServerCosToken>('/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<string, string> }> {
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string>; 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 });
}
);

23
services/users.ts Normal file
View File

@@ -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<Record<string, any>> {
// 固定使用后端文档接口PUT /api/users/update
return await api.put('/api/users/update', dto);
}

View File

@@ -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<string, CheckinRecord>;
currentDate: string | null;
monthLoaded: Record<string, boolean>; // 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<string, CheckinRecord> = {};
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<string, CheckinRecord> };
} 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<string, CheckinRecord> = {};
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<string, CheckinRecord> };
}
},
{
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];
},
}
);

View File

@@ -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;