- 将服务初始化拆分为基础服务和权限相关服务两个阶段 - 基础服务(用户数据、HealthKit初始化、快捷动作等)在应用启动时立即执行 - 权限相关服务(通知、HealthKit权限请求)仅在用户完成引导流程后才执行 - 在Redux store中添加onboardingCompleted状态管理 - 引导页面完成时通过Redux更新状态而非直接操作AsyncStorage - 启动页面从预加载数据中读取引导完成状态,避免重复读取存储 - 使用ref防止权限服务重复初始化
460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
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;
|