- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议 - 在训练计划中集成排课功能,允许用户选择和安排训练动作 - 更新个人信息页面,添加出生日期字段,支持用户完善个人资料 - 优化训练计划卡片样式,提升用户体验 - 更新相关依赖,确保项目兼容性和功能完整性
204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
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;
|
||
|
||
|