diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 60182a6..a43183f 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,8 +1,8 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; @@ -14,6 +14,7 @@ import { Alert, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function PersonalScreen() { + const dispatch = useAppDispatch(); const insets = useSafeAreaInsets(); const tabBarHeight = useBottomTabBarHeight(); const bottomPadding = useMemo(() => { @@ -69,7 +70,12 @@ export default function PersonalScreen() { }; useEffect(() => { load(); }, []); - useFocusEffect(React.useCallback(() => { load(); return () => { }; }, [])); + useFocusEffect(React.useCallback(() => { + // 每次聚焦时从后端拉取最新用户资料 + dispatch(fetchMyProfile()); + load(); + return () => { }; + }, [dispatch])); useEffect(() => { if (userProfileFromRedux) { setProfile(userProfileFromRedux); diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index 122b9f5..499b578 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -1,7 +1,7 @@ import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -49,6 +49,10 @@ export default function AICoachChatScreen() { content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, }]); const listRef = useRef>(null); + const [isAtBottom, setIsAtBottom] = useState(true); + const didInitialScrollRef = useRef(false); + const [composerHeight, setComposerHeight] = useState(80); + const shouldAutoScrollRef = useRef(false); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => (s as any).checkin); @@ -59,11 +63,35 @@ export default function AICoachChatScreen() { { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, ], [router, planDraft, checkin]); - function scrollToEnd() { + const scrollToEnd = useCallback(() => { requestAnimationFrame(() => { listRef.current?.scrollToEnd({ animated: true }); }); - } + }, []); + + const handleScroll = useCallback((e: any) => { + try { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {}; + const paddingToBottom = 60; + const distanceFromBottom = (contentSize?.height || 0) - ((layoutMeasurement?.height || 0) + (contentOffset?.y || 0)); + setIsAtBottom(distanceFromBottom <= paddingToBottom); + } catch { } + }, []); + + useEffect(() => { + // 初次进入或恢复时,保持最新消息可见 + scrollToEnd(); + }, [scrollToEnd]); + + // 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发 + + useEffect(() => { + // 输入区高度变化时,若用户在底部则轻柔跟随一次 + if (isAtBottom) { + const id = setTimeout(scrollToEnd, 0); + return () => clearTimeout(id); + } + }, [composerHeight, isAtBottom, scrollToEnd]); async function fakeStreamResponse(prompt: string): Promise { // 占位实现:模拟AI逐字输出(可替换为真实后端流式接口) @@ -78,18 +106,22 @@ export default function AICoachChatScreen() { 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]); setInput(''); setIsSending(true); - scrollToEnd(); + // 立即滚动改为延后到 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]); - scrollToEnd(); } catch (e) { const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' }; + shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, aiMsg]); } finally { setIsSending(false); @@ -195,13 +227,46 @@ export default function AICoachChatScreen() { data={messages} keyExtractor={(m) => m.id} renderItem={renderItem} - contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }} - onContentSizeChange={scrollToEnd} + onLayout={() => { + // 确保首屏布局后也尝试滚动 + if (!didInitialScrollRef.current) { + didInitialScrollRef.current = true; + setTimeout(scrollToEnd, 0); + requestAnimationFrame(scrollToEnd); + } + }} + contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }} + ListFooterComponent={() => ( + + )} + onContentSizeChange={() => { + // 首次内容变化强制滚底,其余仅在接近底部时滚动 + if (!didInitialScrollRef.current) { + didInitialScrollRef.current = true; + setTimeout(scrollToEnd, 0); + requestAnimationFrame(scrollToEnd); + return; + } + if (shouldAutoScrollRef.current) { + shouldAutoScrollRef.current = false; + setTimeout(scrollToEnd, 0); + } + }} + onScroll={handleScroll} + scrollEventThrottle={16} showsVerticalScrollIndicator={false} /> - - + + { + const h = e.nativeEvent.layout.height; + if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); + }} + > {chips.map((c) => ( @@ -239,6 +304,16 @@ export default function AICoachChatScreen() { + + {!isAtBottom && ( + + + + )} ); } @@ -347,6 +422,20 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + scrollToBottomFab: { + position: 'absolute', + right: 16, + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 2, + }, }); diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 72652f9..2c9bfb2 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -106,6 +106,9 @@ export default function LoginScreen() { ], }); const identityToken = (credential as any)?.identityToken; + if (!identityToken || typeof identityToken !== 'string') { + throw new Error('未获取到 Apple 身份令牌'); + } await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); // 登录成功后处理重定向 const to = searchParams?.redirectTo as string | undefined; diff --git a/app/checkin/index.tsx b/app/checkin/index.tsx index d4cbb08..78f9ebf 100644 --- a/app/checkin/index.tsx +++ b/app/checkin/index.tsx @@ -3,9 +3,10 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice'; +import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo } from 'react'; -import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; function formatDate(d: Date) { const y = d.getFullYear(); @@ -63,10 +64,32 @@ export default function CheckinHome() { {item.category} 组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} - dispatch(toggleExerciseCompleted({ date: today, key: item.key }))}> - {item.completed ? '已完成' : '完成'} + dispatch(toggleExerciseCompleted({ date: today, key: item.key }))} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + - dispatch(removeExercise({ date: today, key: item.key }))}> + + Alert.alert('确认移除', '确定要移除该动作吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '移除', + style: 'destructive', + onPress: () => dispatch(removeExercise({ date: today, key: item.key })), + }, + ]) + } + > 移除 @@ -93,17 +116,14 @@ const styles = StyleSheet.create({ actionRow: { paddingHorizontal: 20, marginTop: 8 }, primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' }, primaryBtnText: { color: '#FFFFFF', fontWeight: '800' }, - emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 20 }, + emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 }, emptyText: { color: '#6B7280' }, - card: { marginTop: 12, marginHorizontal: 20, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, + card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 }, cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' }, cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' }, removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, removeBtnText: { color: '#111827', fontWeight: '700' }, - doneBtn: { backgroundColor: '#E5E7EB', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, marginRight: 8 }, - doneBtnActive: { backgroundColor: '#10B981' }, - doneBtnText: { color: '#111827', fontWeight: '700' }, - doneBtnTextActive: { color: '#FFFFFF', fontWeight: '800' }, + doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 }, }); diff --git a/constants/Api.ts b/constants/Api.ts index 20bb724..150bc00 100644 --- a/constants/Api.ts +++ b/constants/Api.ts @@ -1,4 +1,4 @@ -export const API_ORIGIN = 'https://plate.richarjiang.com'; +export const API_ORIGIN = 'https://pilate.richarjiang.com'; export const API_BASE_PATH = '/api'; export function buildApiUrl(path: string): string { diff --git a/store/userSlice.ts b/store/userSlice.ts index 078532d..908ae79 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -46,15 +46,49 @@ export const login = createAsyncThunk( 'user/login', async (payload: LoginPayload, { rejectWithValue }) => { try { - // 后端路径允许传入 '/api/login' 或 'login' - const data = await api.post<{ token?: string; profile?: UserProfile } | (UserProfile & { token?: string })>( - '/api/login', - payload, - ); + let data: any = null; + // 若为 Apple 登录,走新的后端接口 /api/users/auth/apple/login + if (payload.appleIdentityToken) { + const appleToken = payload.appleIdentityToken; + // 智能尝试不同 DTO 键名(identityToken / idToken)以提升兼容性 + const candidates = [ + { identityToken: appleToken }, + { idToken: appleToken }, + ]; + let lastError: any = null; + for (const body of candidates) { + try { + data = await api.post('/api/users/auth/apple/login', body); + lastError = null; + break; + } catch (err: any) { + lastError = err; + // 若为 4xx 尝试下一个映射;其他错误直接抛出 + const status = (err as any)?.status; + if (status && status >= 500) throw err; + } + } + if (!data && lastError) throw lastError; + } else { + // 其他登录(如游客/用户名密码)保持原有 '/api/login' + data = await api.post('/api/login', payload); + } - // 兼容两种返回结构 - const token = (data as any).token ?? (data as any)?.profile?.token ?? null; - const profile: UserProfile | null = (data as any).profile ?? (data as any); + // 兼容多种返回结构 + const token = + (data as any).token ?? + (data as any).accessToken ?? + (data as any).access_token ?? + (data as any).jwt ?? + (data as any)?.profile?.token ?? + (data as any)?.user?.token ?? + null; + + const profile: UserProfile | null = + (data as any).profile ?? + (data as any).user ?? + (data as any).account ?? + (data as any); if (!token) throw new Error('登录响应缺少 token'); @@ -92,6 +126,20 @@ export const logout = createAsyncThunk('user/logout', async () => { return true; }); +// 拉取最新用户信息 +export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_, { rejectWithValue }) => { + try { + // 固定使用后端文档的接口:/api/users/info + const data: any = await api.get('/api/users/info'); + 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; + } catch (err: any) { + const message = err?.message ?? '获取用户信息失败'; + return rejectWithValue(message); + } +}); + const userSlice = createSlice({ name: 'user', initialState, @@ -131,6 +179,16 @@ const userSlice = createSlice({ state.profile.fullName = DEFAULT_MEMBER_NAME; } }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.profile = action.payload || {}; + if (!state.profile?.fullName || !state.profile.fullName.trim()) { + state.profile.fullName = DEFAULT_MEMBER_NAME; + } + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + // 不覆盖现有资料,仅记录错误 + state.error = (action.payload as string) ?? '获取用户信息失败'; + }) .addCase(logout.fulfilled, (state) => { state.token = null; state.profile = {};