Files
digital-pilates/store/userSlice.ts
richarjiang 39671ed70f feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
2025-11-26 16:39:01 +08:00

453 lines
15 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, 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<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 ?? {}));
// 再设置到 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<WeightHistoryItem>(`/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<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;
}
// 登录成功后更新推送通知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;