364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { api, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
||
import { updateUser, UpdateUserDto } from '@/services/users';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||
import dayjs from 'dayjs';
|
||
|
||
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 initialState: UserState = {
|
||
token: null,
|
||
profile: {
|
||
name: DEFAULT_MEMBER_NAME,
|
||
isVip: false,
|
||
freeUsageCount: 3,
|
||
maxUsageCount: 5,
|
||
},
|
||
loading: false,
|
||
error: null,
|
||
privacyAgreed: false,
|
||
weightHistory: [],
|
||
activityHistory: [],
|
||
};
|
||
|
||
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 rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
|
||
const [profileStr, privacyAgreedStr] = await Promise.all([
|
||
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
|
||
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
|
||
]);
|
||
|
||
let profile: UserProfile = {};
|
||
if (profileStr) {
|
||
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
|
||
}
|
||
const privacyAgreed = privacyAgreedStr === 'true';
|
||
return { profile, privacyAgreed } as { profile: UserProfile; privacyAgreed: boolean };
|
||
});
|
||
|
||
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');
|
||
console.log('fetchMyProfile', data);
|
||
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');
|
||
console.log('fetchActivityHistory', data);
|
||
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;
|
||
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; |