Files
digital-pilates/store/userSlice.ts
richarjiang f95401c1ce feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议
- 在训练计划中集成排课功能,允许用户选择和安排训练动作
- 更新个人信息页面,添加出生日期字段,支持用户完善个人资料
- 优化训练计划卡片样式,提升用户体验
- 更新相关依赖,确保项目兼容性和功能完整性
2025-08-15 10:45:37 +08:00

204 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type Gender = 'male' | 'female' | '';
export type UserProfile = {
name?: string;
email?: string;
gender?: Gender;
birthDate?: string;
weight?: number;
height?: number;
avatar?: string | null;
dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等)
dailyCaloriesGoal?: number; // 每日卡路里消耗目标
pilatesPurposes?: string[]; // 普拉提目的(多选)
};
export type UserState = {
token: string | null;
profile: UserProfile;
loading: boolean;
error: string | null;
};
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
const initialState: UserState = {
token: null,
profile: {
name: DEFAULT_MEMBER_NAME,
},
loading: false,
error: null,
};
export type LoginPayload = Record<string, any> & {
// 可扩展用户名密码、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 ?? {}));
await setAuthToken(token);
return { token, profile } as { token: string; profile: UserProfile };
} catch (err: any) {
const message = err?.message ?? '登录失败';
return rejectWithValue(message);
}
}
);
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
const [token, profileStr] = await Promise.all([
loadPersistedToken(),
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
]);
await setAuthToken(token);
let profile: UserProfile = {};
if (profileStr) {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
}
return { token, profile } as { token: string | null; profile: UserProfile };
});
export const logout = createAsyncThunk('user/logout', async () => {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
]);
await setAuthToken(null);
return true;
});
// 拉取最新用户信息
export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_, { rejectWithValue }) => {
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;
} catch (err: any) {
const message = err?.message ?? '获取用户信息失败';
return rejectWithValue(message);
}
});
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
updateProfile(state, action: PayloadAction<Partial<UserProfile>>) {
state.profile = { ...(state.profile ?? {}), ...action.payload };
},
setDailyStepsGoal(state, action: PayloadAction<number>) {
state.profile = { ...(state.profile ?? {}), dailyStepsGoal: action.payload };
},
setDailyCaloriesGoal(state, action: PayloadAction<number>) {
state.profile = { ...(state.profile ?? {}), dailyCaloriesGoal: action.payload };
},
setPilatesPurposes(state, action: PayloadAction<string[]>) {
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;
}
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = (action.payload as string) ?? '登录失败';
})
.addCase(rehydrateUser.fulfilled, (state, action) => {
state.token = action.payload.token;
state.profile = action.payload.profile;
if (!state.profile?.name || !state.profile.name.trim()) {
state.profile.name = DEFAULT_MEMBER_NAME;
}
})
.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 = {};
});
},
});
export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
export default userSlice.reducer;