Files
digital-pilates/store/userSlice.ts
richarjiang 63ed820e93 feat(ui): 统一健康卡片标题图标并优化语音录音稳定性
- 为所有健康数据卡片添加对应功能图标,提升视觉一致性
- 将“小鱼干”文案统一为“能量值”,并更新获取说明
- 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑
- 个人页支持毛玻璃按钮样式,默认用户名置空
- 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源
- 升级 react-native-purchases 至 9.4.3
- 移除 useAuthGuard 调试日志
2025-09-16 09:35:50 +08:00

442 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 { 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;
} | null = null;
// 预加载用户数据的函数
export async function preloadUserData() {
try {
const [profileStr, privacyAgreedStr, token] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
AsyncStorage.getItem(STORAGE_KEYS.authToken),
]);
let profile: UserProfile = {};
if (profileStr) {
try {
profile = JSON.parse(profileStr) as UserProfile;
} catch {
profile = {};
}
}
const privacyAgreed = privacyAgreedStr === 'true';
// 如果有 token需要设置到 API 客户端
if (token) {
await setAuthToken(token);
}
preloadedUserData = { token, profile, privacyAgreed };
return preloadedUserData;
} catch (error) {
console.error('预加载用户数据失败:', error);
preloadedUserData = { token: null, profile: {}, privacyAgreed: false };
return preloadedUserData;
}
}
// 获取预加载的用户数据
function getPreloadedUserData() {
return preloadedUserData || { token: null, profile: {}, privacyAgreed: false };
}
export type Gender = 'male' | 'female' | '';
export type UserProfile = {
name?: string;
email?: string;
gender?: Gender;
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;
};
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;
privacyAgreed: boolean;
weightHistory: WeightHistoryItem[];
activityHistory: ActivityHistoryItem[];
};
export const DEFAULT_MEMBER_NAME = '';
const getInitialState = (): UserState => {
const preloaded = getPreloadedUserData();
return {
token: preloaded.token,
profile: {
name: DEFAULT_MEMBER_NAME,
isVip: false,
freeUsageCount: 3,
maxUsageCount: 5,
...preloaded.profile, // 合并预加载的用户资料
},
loading: false,
error: null,
privacyAgreed: preloaded.privacyAgreed,
weightHistory: [],
activityHistory: [],
};
};
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);
}
}
);
// 同步重新hydrate用户数据避免异步状态更新
export const rehydrateUserSync = createAsyncThunk('user/rehydrateSync', async () => {
// 立即从预加载的数据获取,如果没有则异步获取
if (preloadedUserData) {
return preloadedUserData;
}
// 如果预加载的数据不存在,则执行正常的异步加载
return await preloadUserData();
});
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
const [profileStr, privacyAgreedStr, token] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
AsyncStorage.getItem(STORAGE_KEYS.authToken),
]);
let profile: UserProfile = {};
if (profileStr) {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
}
const privacyAgreed = privacyAgreedStr === 'true';
// 如果有 token需要设置到 API 客户端
if (token) {
await setAuthToken(token);
}
return { profile, privacyAgreed, token } as { profile: UserProfile; privacyAgreed: boolean; token: string | null };
});
export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => {
await AsyncStorage.setItem(STORAGE_KEYS.privacyAgreed, '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 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 ?? '更新体重记录失败');
}
}
);
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.profile = action.payload.profile;
state.privacyAgreed = action.payload.privacyAgreed;
state.token = action.payload.token;
if (!state.profile?.name || !state.profile.name.trim()) {
state.profile.name = DEFAULT_MEMBER_NAME;
}
})
.addCase(rehydrateUserSync.fulfilled, (state, action) => {
state.profile = action.payload.profile;
state.privacyAgreed = action.payload.privacyAgreed;
state.token = action.payload.token;
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 = {};
state.privacyAgreed = false;
})
.addCase(setPrivacyAgreed.fulfilled, (state) => {
state.privacyAgreed = 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) ?? '更新体重记录失败';
});
},
});
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;