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;