Files
digital-pilates/store/userSlice.ts
richarjiang e3e2f1b8c6 feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
2025-08-14 09:57:13 +08:00

204 lines
6.8 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, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type Gender = 'male' | 'female' | '';
export type UserProfile = {
name?: string;
email?: string;
gender?: Gender;
age?: number; // 个人中心是字符串展示
weight?: number;
height?: number;
avatar?: string | null;
dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等)
dailyCaloriesGoal?: number; // 每日卡路里消耗目标
pilatesPurposes?: string[]; // 普拉提目的(多选)
};
export type UserState = {
token: string | null;
profile: UserProfile;
loading: boolean;
error: string | null;
};
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
const initialState: UserState = {
token: null,
profile: {
name: DEFAULT_MEMBER_NAME,
},
loading: false,
error: null,
};
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 [token, profileStr] = await Promise.all([
loadPersistedToken(),
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
]);
await setAuthToken(token);
let profile: UserProfile = {};
if (profileStr) {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
}
return { token, profile } as { token: string | null; profile: UserProfile };
});
export const logout = createAsyncThunk('user/logout', async () => {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
]);
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);
}
});
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.token = action.payload.token;
state.profile = action.payload.profile;
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 = {};
});
},
});
export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
export default userSlice.reducer;