feat: 优化 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加消息自动滚动功能,提升用户体验 - 更新消息发送逻辑,确保新消息渲染后再滚动 - 在个人信息页面中集成用户资料拉取功能,支持每次聚焦时更新用户信息 - 修改登录页面,增加身份令牌验证,确保安全性 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
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';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function PersonalScreen() {
|
export default function PersonalScreen() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
const bottomPadding = useMemo(() => {
|
const bottomPadding = useMemo(() => {
|
||||||
@@ -69,7 +70,12 @@ export default function PersonalScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
|
useFocusEffect(React.useCallback(() => {
|
||||||
|
// 每次聚焦时从后端拉取最新用户资料
|
||||||
|
dispatch(fetchMyProfile());
|
||||||
|
load();
|
||||||
|
return () => { };
|
||||||
|
}, [dispatch]));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userProfileFromRedux) {
|
if (userProfileFromRedux) {
|
||||||
setProfile(userProfileFromRedux);
|
setProfile(userProfileFromRedux);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
@@ -49,6 +49,10 @@ export default function AICoachChatScreen() {
|
|||||||
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
|
content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`,
|
||||||
}]);
|
}]);
|
||||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const didInitialScrollRef = useRef(false);
|
||||||
|
const [composerHeight, setComposerHeight] = useState<number>(80);
|
||||||
|
const shouldAutoScrollRef = useRef(false);
|
||||||
|
|
||||||
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
||||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
const checkin = useAppSelector((s) => (s as any).checkin);
|
||||||
@@ -59,11 +63,35 @@ export default function AICoachChatScreen() {
|
|||||||
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
{ key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
||||||
], [router, planDraft, checkin]);
|
], [router, planDraft, checkin]);
|
||||||
|
|
||||||
function scrollToEnd() {
|
const scrollToEnd = useCallback(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
listRef.current?.scrollToEnd({ animated: true });
|
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<string> {
|
async function fakeStreamResponse(prompt: string): Promise<string> {
|
||||||
// 占位实现:模拟AI逐字输出(可替换为真实后端流式接口)
|
// 占位实现:模拟AI逐字输出(可替换为真实后端流式接口)
|
||||||
@@ -78,18 +106,22 @@ export default function AICoachChatScreen() {
|
|||||||
async function send(text: string) {
|
async function send(text: string) {
|
||||||
if (!text.trim() || isSending) return;
|
if (!text.trim() || isSending) return;
|
||||||
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
|
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
|
||||||
|
// 标记:这次是新增消息,等内容真正渲染并触发 onContentSizeChange 时再滚动
|
||||||
|
shouldAutoScrollRef.current = isAtBottom;
|
||||||
setMessages((m) => [...m, userMsg]);
|
setMessages((m) => [...m, userMsg]);
|
||||||
setInput('');
|
setInput('');
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
scrollToEnd();
|
// 立即滚动改为延后到 onContentSizeChange,避免突兀
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const replyText = await fakeStreamResponse(text.trim());
|
const replyText = await fakeStreamResponse(text.trim());
|
||||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
|
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
|
||||||
|
// 同理:AI 消息到达时,与内容变化同步滚动
|
||||||
|
shouldAutoScrollRef.current = isAtBottom;
|
||||||
setMessages((m) => [...m, aiMsg]);
|
setMessages((m) => [...m, aiMsg]);
|
||||||
scrollToEnd();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
|
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
|
||||||
|
shouldAutoScrollRef.current = isAtBottom;
|
||||||
setMessages((m) => [...m, aiMsg]);
|
setMessages((m) => [...m, aiMsg]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
@@ -195,13 +227,46 @@ export default function AICoachChatScreen() {
|
|||||||
data={messages}
|
data={messages}
|
||||||
keyExtractor={(m) => m.id}
|
keyExtractor={(m) => m.id}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8, paddingBottom: insets.bottom + 140 }}
|
onLayout={() => {
|
||||||
onContentSizeChange={scrollToEnd}
|
// 确保首屏布局后也尝试滚动
|
||||||
|
if (!didInitialScrollRef.current) {
|
||||||
|
didInitialScrollRef.current = true;
|
||||||
|
setTimeout(scrollToEnd, 0);
|
||||||
|
requestAnimationFrame(scrollToEnd);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
|
||||||
|
ListFooterComponent={() => (
|
||||||
|
<View style={{ height: insets.bottom + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
|
||||||
|
)}
|
||||||
|
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}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={80}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={insets.top}>
|
||||||
<BlurView intensity={18} tint={'light'} style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}>
|
<BlurView
|
||||||
|
intensity={18}
|
||||||
|
tint={'light'}
|
||||||
|
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10 }]}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const h = e.nativeEvent.layout.height;
|
||||||
|
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View style={styles.chipsRow}>
|
<View style={styles.chipsRow}>
|
||||||
{chips.map((c) => (
|
{chips.map((c) => (
|
||||||
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
|
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
|
||||||
@@ -239,6 +304,16 @@ export default function AICoachChatScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{!isAtBottom && (
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={scrollToEnd}
|
||||||
|
style={[styles.scrollToBottomFab, { bottom: insets.bottom + composerHeight + 16, backgroundColor: theme.primary }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -347,6 +422,20 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ export default function LoginScreen() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const identityToken = (credential as any)?.identityToken;
|
const identityToken = (credential as any)?.identityToken;
|
||||||
|
if (!identityToken || typeof identityToken !== 'string') {
|
||||||
|
throw new Error('未获取到 Apple 身份令牌');
|
||||||
|
}
|
||||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||||
// 登录成功后处理重定向
|
// 登录成功后处理重定向
|
||||||
const to = searchParams?.redirectTo as string | undefined;
|
const to = searchParams?.redirectTo as string | undefined;
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice';
|
import { removeExercise, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo } from 'react';
|
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) {
|
function formatDate(d: Date) {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
@@ -63,10 +64,32 @@ export default function CheckinHome() {
|
|||||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{item.category}</Text>
|
||||||
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}</Text>
|
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>组数 {item.sets}{item.reps ? ` · 每组 ${item.reps} 次` : ''}{item.durationSec ? ` · 每组 ${item.durationSec}s` : ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={[styles.doneBtn, { backgroundColor: item.completed ? colorTokens.primary : colorTokens.border }]} onPress={() => dispatch(toggleExerciseCompleted({ date: today, key: item.key }))}>
|
<TouchableOpacity
|
||||||
<Text style={[styles.doneBtnText, { color: item.completed ? colorTokens.onPrimary : colorTokens.text, fontWeight: item.completed ? '800' : '700' }]}>{item.completed ? '已完成' : '完成'}</Text>
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={item.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
|
||||||
|
style={styles.doneIconBtn}
|
||||||
|
onPress={() => dispatch(toggleExerciseCompleted({ date: today, key: item.key }))}
|
||||||
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={item.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||||||
|
size={24}
|
||||||
|
color={item.completed ? colorTokens.primary : colorTokens.textMuted}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[styles.removeBtn, { backgroundColor: colorTokens.border }]} onPress={() => dispatch(removeExercise({ date: today, key: item.key }))}>
|
<TouchableOpacity
|
||||||
|
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('确认移除', '确定要移除该动作吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '移除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => dispatch(removeExercise({ date: today, key: item.key })),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}>移除</Text>
|
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}>移除</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -93,17 +116,14 @@ const styles = StyleSheet.create({
|
|||||||
actionRow: { paddingHorizontal: 20, marginTop: 8 },
|
actionRow: { paddingHorizontal: 20, marginTop: 8 },
|
||||||
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
||||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
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' },
|
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' },
|
cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||||
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||||
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
|
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
|
||||||
removeBtnText: { color: '#111827', fontWeight: '700' },
|
removeBtnText: { color: '#111827', fontWeight: '700' },
|
||||||
doneBtn: { backgroundColor: '#E5E7EB', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, marginRight: 8 },
|
doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 },
|
||||||
doneBtnActive: { backgroundColor: '#10B981' },
|
|
||||||
doneBtnText: { color: '#111827', fontWeight: '700' },
|
|
||||||
doneBtnTextActive: { color: '#FFFFFF', fontWeight: '800' },
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 const API_BASE_PATH = '/api';
|
||||||
|
|
||||||
export function buildApiUrl(path: string): string {
|
export function buildApiUrl(path: string): string {
|
||||||
|
|||||||
@@ -46,15 +46,49 @@ export const login = createAsyncThunk(
|
|||||||
'user/login',
|
'user/login',
|
||||||
async (payload: LoginPayload, { rejectWithValue }) => {
|
async (payload: LoginPayload, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
// 后端路径允许传入 '/api/login' 或 'login'
|
let data: any = null;
|
||||||
const data = await api.post<{ token?: string; profile?: UserProfile } | (UserProfile & { token?: string })>(
|
// 若为 Apple 登录,走新的后端接口 /api/users/auth/apple/login
|
||||||
'/api/login',
|
if (payload.appleIdentityToken) {
|
||||||
payload,
|
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 token =
|
||||||
const profile: UserProfile | null = (data as any).profile ?? (data as any);
|
(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');
|
if (!token) throw new Error('登录响应缺少 token');
|
||||||
|
|
||||||
@@ -92,6 +126,20 @@ export const logout = createAsyncThunk('user/logout', async () => {
|
|||||||
return true;
|
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({
|
const userSlice = createSlice({
|
||||||
name: 'user',
|
name: 'user',
|
||||||
initialState,
|
initialState,
|
||||||
@@ -131,6 +179,16 @@ const userSlice = createSlice({
|
|||||||
state.profile.fullName = DEFAULT_MEMBER_NAME;
|
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) => {
|
.addCase(logout.fulfilled, (state) => {
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.profile = {};
|
state.profile = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user