import { api, setAuthToken, STORAGE_KEYS } from '@/services/api'; import { pushNotificationManager } from '@/services/pushNotificationManager'; import { BodyMeasurementsDto, updateBodyMeasurements, updateUser, UpdateUserDto } from '@/services/users'; import AsyncStorage from '@/utils/kvStore'; import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import dayjs from 'dayjs'; /** * 同步加载用户数据(在 Redux store 初始化时立即执行) * 使用 getItemSync 确保数据在 store 创建前就已加载 */ function loadUserDataSync() { try { const profileStr = AsyncStorage.getItemSync(STORAGE_KEYS.userProfile); const token = AsyncStorage.getItemSync(STORAGE_KEYS.authToken); const onboardingCompletedStr = AsyncStorage.getItemSync(STORAGE_KEYS.onboardingCompleted); let profile: UserProfile = { memberNumber: 0 }; if (profileStr) { try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = { memberNumber: 0 }; } } const onboardingCompleted = onboardingCompletedStr === 'true'; // 如果有 token,需要异步设置到 API 客户端(但不阻塞初始化) if (token) { setAuthToken(token).catch(err => { console.error('设置 auth token 失败:', err); }); } return { token, profile, onboardingCompleted }; } catch (error) { console.error('同步加载用户数据失败:', error); return { token: null, profile: { memberNumber: 0 }, onboardingCompleted: false }; } } // 在模块加载时立即同步加载用户数据 const preloadedUserData = loadUserDataSync(); export type Gender = 'male' | 'female' | ''; export type UserProfile = { id?: string; name?: string; email?: string; gender?: Gender; memberNumber: number birthDate?: string; weight?: string; height?: string; initialWeight?: string; // 初始体重 targetWeight?: string; // 目标体重 avatar?: string | null; dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) dailyCaloriesGoal?: number; // 每日卡路里消耗目标 pilatesPurposes?: string[]; // 普拉提目的(多选) isVip?: boolean; freeUsageCount?: number; maxUsageCount?: number; membershipExpiration?: string | null; vipPlanName?: string; chestCircumference?: number; // 胸围 waistCircumference?: number; // 腰围 upperHipCircumference?: number; // 上臀围 armCircumference?: number; // 臂围 thighCircumference?: number; // 大腿围 calfCircumference?: number; // 小腿围 }; export type WeightHistoryItem = { id: string; // 添加 id 字段用于删除和更新操作 weight: string; source: string; createdAt: string; }; export type ActivityHistoryItem = { date: string; level: number; }; export type UserState = { token: string | null; profile: UserProfile; loading: boolean; error: string | null; weightHistory: WeightHistoryItem[]; activityHistory: ActivityHistoryItem[]; onboardingCompleted: boolean; // 是否完成引导流程 }; export const DEFAULT_MEMBER_NAME = '朋友'; const getInitialState = (): UserState => { // 使用模块加载时同步加载的数据 console.log('初始化 Redux state,使用预加载数据:', preloadedUserData); return { token: preloadedUserData.token, profile: { name: DEFAULT_MEMBER_NAME, isVip: false, freeUsageCount: 3, maxUsageCount: 5, ...preloadedUserData.profile, // 合并预加载的用户资料(包含 memberNumber) }, loading: false, error: null, weightHistory: [], activityHistory: [], onboardingCompleted: preloadedUserData.onboardingCompleted, // 引导完成状态 }; }; const initialState: UserState = getInitialState(); export type LoginPayload = Record & { // 可扩展:用户名密码、Apple 身份、短信验证码等 username?: string; password?: string; appleIdentityToken?: string; }; export const login = createAsyncThunk( 'user/login', async (payload: LoginPayload, { rejectWithValue }) => { try { 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).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'); // 先持久化到本地存储 await AsyncStorage.setItem(STORAGE_KEYS.authToken, token); await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {})); // 再设置到 API 客户端(内部会同步更新 AsyncStorage) await setAuthToken(token); return { token, profile } as { token: string; profile: UserProfile }; } catch (err: any) { const message = err?.message ?? '登录失败'; return rejectWithValue(message); } } ); export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => { await AsyncStorage.setItem(STORAGE_KEYS.privacyAgreed, 'true'); return true; }); // 设置 onboarding 完成状态 export const setOnboardingCompleted = createAsyncThunk('user/setOnboardingCompleted', async () => { await AsyncStorage.setItem(STORAGE_KEYS.onboardingCompleted, 'true'); return true; }); export const logout = createAsyncThunk('user/logout', async () => { // 先清除 API 客户端的 token(内部会清除 AsyncStorage) await setAuthToken(null); // 再清除其他本地存储数据 await Promise.all([ AsyncStorage.removeItem(STORAGE_KEYS.userProfile), AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed), ]); 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 profileStr = await AsyncStorage.getItem(STORAGE_KEYS.userProfile), profile = JSON.parse(profileStr ?? '{}'); return profile // const message = err?.message ?? '获取用户信息失败'; // return rejectWithValue(message); } }); // 获取用户体重历史记录 export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', async (_, { rejectWithValue }) => { try { const data: WeightHistoryItem[] = await api.get('/api/users/weight-history'); return data; } catch (err: any) { return rejectWithValue(err?.message ?? '获取用户体重历史记录失败'); } }); // 获取用户活动历史记录 export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory', async (_, { rejectWithValue }) => { try { const data: ActivityHistoryItem[] = await api.get('/api/users/activity-history'); return data; } catch (err: any) { return rejectWithValue(err?.message ?? '获取用户活动历史记录失败'); } }); // 更新用户资料(包括体重) export const updateUserProfile = createAsyncThunk( 'user/updateProfile', async (updateDto: UpdateUserDto, { rejectWithValue }) => { try { const data = await updateUser(updateDto); console.log('updateUserProfile', data); return data; } catch (err: any) { return rejectWithValue(err?.message ?? '更新用户资料失败'); } } ); // 删除体重记录 export const deleteWeightRecord = createAsyncThunk( 'user/deleteWeightRecord', async (recordId: string, { rejectWithValue }) => { try { await api.delete(`/api/users/weight-records/${recordId}`); return recordId; } catch (err: any) { return rejectWithValue(err?.message ?? '删除体重记录失败'); } } ); // 更新体重记录 export const updateWeightRecord = createAsyncThunk( 'user/updateWeightRecord', async ({ id, weight }: { id: string; weight: number }, { rejectWithValue }) => { try { const data = await api.put(`/api/users/weight-records/${id}`, { weight }); return data; } catch (err: any) { return rejectWithValue(err?.message ?? '更新体重记录失败'); } } ); // 更新用户围度信息 export const updateUserBodyMeasurements = createAsyncThunk( 'user/updateBodyMeasurements', async (measurementsDto: BodyMeasurementsDto, { rejectWithValue }) => { try { await updateBodyMeasurements(measurementsDto); return measurementsDto; } catch (err: any) { return rejectWithValue(err?.message ?? '更新围度信息失败'); } } ); const userSlice = createSlice({ name: 'user', initialState, reducers: { updateProfile(state, action: PayloadAction>) { state.profile = { ...(state.profile ?? {}), ...action.payload }; }, setDailyStepsGoal(state, action: PayloadAction) { state.profile = { ...(state.profile ?? {}), dailyStepsGoal: action.payload }; }, setDailyCaloriesGoal(state, action: PayloadAction) { state.profile = { ...(state.profile ?? {}), dailyCaloriesGoal: action.payload }; }, setPilatesPurposes(state, action: PayloadAction) { state.profile = { ...(state.profile ?? {}), pilatesPurposes: action.payload }; }, }, extraReducers: (builder) => { builder .addCase(login.pending, (state) => { state.loading = true; state.error = null; }) .addCase(login.fulfilled, (state, action) => { state.loading = false; state.token = action.payload.token; state.profile = action.payload.profile; if (!state.profile?.name || !state.profile.name.trim()) { state.profile.name = DEFAULT_MEMBER_NAME; } // 登录成功后,更新推送通知token的用户ID绑定 pushNotificationManager.onUserLogin().catch((error: any) => { console.error('登录后更新推送通知token用户ID绑定失败:', error); }); }) .addCase(login.rejected, (state, action) => { state.loading = false; state.error = (action.payload as string) ?? '登录失败'; }) .addCase(fetchMyProfile.fulfilled, (state, action) => { state.profile = action.payload || {}; if (!state.profile?.name || !state.profile.name.trim()) { state.profile.name = DEFAULT_MEMBER_NAME; } }) .addCase(fetchMyProfile.rejected, (state, action) => { // 不覆盖现有资料,仅记录错误 state.error = (action.payload as string) ?? '获取用户信息失败'; }) .addCase(logout.fulfilled, (state) => { state.token = null; state.profile = { memberNumber: 0 }; }) .addCase(setPrivacyAgreed.fulfilled, (state) => { }) .addCase(setOnboardingCompleted.fulfilled, (state) => { state.onboardingCompleted = true; }) .addCase(fetchWeightHistory.fulfilled, (state, action) => { state.weightHistory = action.payload; }) .addCase(fetchWeightHistory.rejected, (state, action) => { state.error = (action.payload as string) ?? '获取用户体重历史记录失败'; }) .addCase(fetchActivityHistory.fulfilled, (state, action) => { state.activityHistory = action.payload; }) .addCase(fetchActivityHistory.rejected, (state, action) => { state.error = (action.payload as string) ?? '获取用户活动历史记录失败'; }) .addCase(updateUserProfile.fulfilled, (state, action) => { state.profile = { ...state.profile, ...action.payload }; }) .addCase(updateUserProfile.rejected, (state, action) => { state.error = (action.payload as string) ?? '更新用户资料失败'; }) .addCase(deleteWeightRecord.fulfilled, (state, action) => { state.weightHistory = state.weightHistory.filter(record => record.id !== action.payload && record.createdAt !== action.payload ); }) .addCase(deleteWeightRecord.rejected, (state, action) => { state.error = (action.payload as string) ?? '删除体重记录失败'; }) .addCase(updateWeightRecord.fulfilled, (state, action) => { const index = state.weightHistory.findIndex(record => record.id === action.payload.id || record.createdAt === action.payload.id ); if (index !== -1) { state.weightHistory[index] = { ...state.weightHistory[index], ...action.payload }; } }) .addCase(updateWeightRecord.rejected, (state, action) => { state.error = (action.payload as string) ?? '更新体重记录失败'; }) .addCase(updateUserBodyMeasurements.fulfilled, (state, action) => { console.log('action.payload', action.payload); state.profile = { ...state.profile, ...action.payload }; }) .addCase(updateUserBodyMeasurements.rejected, (state, action) => { state.error = (action.payload as string) ?? '更新围度信息失败'; }); }, }); export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions; // Selectors export const selectUserProfile = (state: { user: UserState }) => state.user.profile; // 计算用户年龄的 selector export const selectUserAge = createSelector( [selectUserProfile], (profile) => { if (!profile?.birthDate) return null; const birthDate = dayjs(profile.birthDate); const today = dayjs(); // 计算精确年龄(考虑月份和日期) let age = today.year() - birthDate.year(); // 如果今年的生日还没到,年龄减1 if (today.month() < birthDate.month() || (today.month() === birthDate.month() && today.date() < birthDate.date())) { age--; } return age; } ); export default userSlice.reducer;