Files
digital-pilates/store/userSlice.ts
richarjiang 8cffbb990a refactor(init): 优化应用初始化流程,将权限请求延迟到引导完成后
- 将服务初始化拆分为基础服务和权限相关服务两个阶段
- 基础服务(用户数据、HealthKit初始化、快捷动作等)在应用启动时立即执行
- 权限相关服务(通知、HealthKit权限请求)仅在用户完成引导流程后才执行
- 在Redux store中添加onboardingCompleted状态管理
- 引导页面完成时通过Redux更新状态而非直接操作AsyncStorage
- 启动页面从预加载数据中读取引导完成状态,避免重复读取存储
- 使用ref防止权限服务重复初始化
2025-11-14 14:10:52 +08:00

460 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';
// 预加载的用户数据存储
let preloadedUserData: {
token: string | null;
profile: UserProfile;
privacyAgreed: boolean;
onboardingCompleted: boolean;
} | null = null;
// 预加载用户数据的函数
export async function preloadUserData() {
try {
const [profileStr, privacyAgreedStr, token, onboardingCompletedStr] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
AsyncStorage.getItem(STORAGE_KEYS.authToken),
AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted),
]);
let profile: UserProfile = {
memberNumber: 0
};
if (profileStr) {
try {
profile = JSON.parse(profileStr) as UserProfile;
} catch {
profile = {
memberNumber: 0
};
}
}
const privacyAgreed = privacyAgreedStr === 'true';
const onboardingCompleted = onboardingCompletedStr === 'true';
// 如果有 token需要设置到 API 客户端
if (token) {
await setAuthToken(token);
}
preloadedUserData = { token, profile, privacyAgreed, onboardingCompleted };
return preloadedUserData;
} catch (error) {
console.error('预加载用户数据失败:', error);
preloadedUserData = {
token: null,
profile: {
memberNumber: 0
},
privacyAgreed: false,
onboardingCompleted: false
};
return preloadedUserData;
}
}
// 获取预加载的用户数据
function getPreloadedUserData() {
return preloadedUserData || { token: null, profile: {}, privacyAgreed: false, onboardingCompleted: false };
}
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 => {
const preloaded = getPreloadedUserData();
return {
token: preloaded.token,
profile: {
name: DEFAULT_MEMBER_NAME,
isVip: false,
freeUsageCount: 3,
memberNumber: 0,
maxUsageCount: 5,
...preloaded.profile, // 合并预加载的用户资料
},
loading: false,
error: null,
weightHistory: [],
activityHistory: [],
onboardingCompleted: preloaded.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 ?? {}));
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 () => {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
]);
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');
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;