feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
@@ -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>
|
||||
|
||||
{/* 今日报告 标题 */}
|
||||
{/* 打卡入口 */}
|
||||
<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,保持静默刷新提升体验 */}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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
122
app/checkin/calendar.tsx
Normal 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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
// 从本地存储加载(身高/体重等本地字段)
|
||||
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: '',
|
||||
weightKg: undefined,
|
||||
heightCm: undefined,
|
||||
weight: undefined,
|
||||
height: 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?.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 { }
|
||||
}
|
||||
setProfile(next);
|
||||
setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : '');
|
||||
setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : '');
|
||||
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('发生错误', '选择头像失败,请重试');
|
||||
|
||||
@@ -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) /
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
46
services/aiCoachSession.ts
Normal file
46
services/aiCoachSession.ts
Normal 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 { }
|
||||
}
|
||||
|
||||
|
||||
144
services/api.ts
144
services/api.ts
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
23
services/users.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user