Files
digital-pilates/store/userSlice.ts
2025-09-22 10:58:23 +08:00

422 lines
14 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 { 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;
} | 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 = {
id?: string;
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;
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[];
};
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,
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);
}
}
);
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 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;
}
})
.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 = {};
})
.addCase(setPrivacyAgreed.fulfilled, (state) => {
})
.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;