feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
332
store/familyHealthSlice.ts
Normal file
332
store/familyHealthSlice.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 家庭健康管理 Redux Slice
|
||||
*/
|
||||
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import * as healthProfileApi from '@/services/healthProfile';
|
||||
import { RootState } from './index';
|
||||
|
||||
// ==================== State 类型定义 ====================
|
||||
|
||||
export interface FamilyHealthState {
|
||||
// 家庭组信息
|
||||
familyGroup: healthProfileApi.FamilyGroup | null;
|
||||
|
||||
// 家庭成员列表
|
||||
members: healthProfileApi.FamilyMember[];
|
||||
|
||||
// 邀请码信息
|
||||
inviteCode: healthProfileApi.InviteCode | null;
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
membersLoading: boolean;
|
||||
inviteLoading: boolean;
|
||||
|
||||
// 错误信息
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ==================== 初始状态 ====================
|
||||
|
||||
const initialState: FamilyHealthState = {
|
||||
familyGroup: null,
|
||||
members: [],
|
||||
inviteCode: null,
|
||||
loading: false,
|
||||
membersLoading: false,
|
||||
inviteLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 获取用户所属家庭组
|
||||
*/
|
||||
export const fetchFamilyGroup = createAsyncThunk(
|
||||
'familyHealth/fetchGroup',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.getFamilyGroup();
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取家庭组失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 创建家庭组
|
||||
*/
|
||||
export const createFamilyGroup = createAsyncThunk(
|
||||
'familyHealth/createGroup',
|
||||
async (name: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.createFamilyGroup(name);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '创建家庭组失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 生成邀请码
|
||||
*/
|
||||
export const generateInviteCode = createAsyncThunk(
|
||||
'familyHealth/generateInvite',
|
||||
async (expiresInHours: number = 24, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.generateInviteCode(expiresInHours);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '生成邀请码失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加入家庭组
|
||||
*/
|
||||
export const joinFamilyGroup = createAsyncThunk(
|
||||
'familyHealth/joinGroup',
|
||||
async (
|
||||
{ inviteCode, relationship }: { inviteCode: string; relationship: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const data = await healthProfileApi.joinFamilyGroup(inviteCode, relationship);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '加入家庭组失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取家庭成员列表
|
||||
*/
|
||||
export const fetchFamilyMembers = createAsyncThunk(
|
||||
'familyHealth/fetchMembers',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.getFamilyMembers();
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取家庭成员失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新家庭成员权限
|
||||
*/
|
||||
export const updateFamilyMember = createAsyncThunk(
|
||||
'familyHealth/updateMember',
|
||||
async (
|
||||
{
|
||||
memberId,
|
||||
permissions,
|
||||
}: {
|
||||
memberId: string;
|
||||
permissions: healthProfileApi.UpdateMemberPermissionsRequest;
|
||||
},
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const data = await healthProfileApi.updateFamilyMember(memberId, permissions);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '更新成员权限失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 移除家庭成员
|
||||
*/
|
||||
export const removeFamilyMember = createAsyncThunk(
|
||||
'familyHealth/removeMember',
|
||||
async (memberId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await healthProfileApi.removeFamilyMember(memberId);
|
||||
return memberId;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '移除成员失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 退出家庭组
|
||||
*/
|
||||
export const leaveFamilyGroup = createAsyncThunk(
|
||||
'familyHealth/leaveGroup',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
await healthProfileApi.leaveFamilyGroup();
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '退出家庭组失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice ====================
|
||||
|
||||
const familyHealthSlice = createSlice({
|
||||
name: 'familyHealth',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 清除错误
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
|
||||
// 清除邀请码
|
||||
clearInviteCode: (state) => {
|
||||
state.inviteCode = null;
|
||||
},
|
||||
|
||||
// 重置状态(用于登出时)
|
||||
resetFamilyHealth: () => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// 获取家庭组
|
||||
.addCase(fetchFamilyGroup.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchFamilyGroup.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.familyGroup = action.payload;
|
||||
})
|
||||
.addCase(fetchFamilyGroup.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 创建家庭组
|
||||
.addCase(createFamilyGroup.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(createFamilyGroup.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.familyGroup = action.payload;
|
||||
})
|
||||
.addCase(createFamilyGroup.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 生成邀请码
|
||||
.addCase(generateInviteCode.pending, (state) => {
|
||||
state.inviteLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(generateInviteCode.fulfilled, (state, action) => {
|
||||
state.inviteLoading = false;
|
||||
state.inviteCode = action.payload;
|
||||
})
|
||||
.addCase(generateInviteCode.rejected, (state, action) => {
|
||||
state.inviteLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 加入家庭组
|
||||
.addCase(joinFamilyGroup.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(joinFamilyGroup.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.familyGroup = action.payload;
|
||||
})
|
||||
.addCase(joinFamilyGroup.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 获取家庭成员
|
||||
.addCase(fetchFamilyMembers.pending, (state) => {
|
||||
state.membersLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchFamilyMembers.fulfilled, (state, action) => {
|
||||
state.membersLoading = false;
|
||||
state.members = action.payload;
|
||||
})
|
||||
.addCase(fetchFamilyMembers.rejected, (state, action) => {
|
||||
state.membersLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 更新成员权限
|
||||
.addCase(updateFamilyMember.fulfilled, (state, action) => {
|
||||
const updatedMember = action.payload;
|
||||
const index = state.members.findIndex((m) => m.id === updatedMember.id);
|
||||
if (index !== -1) {
|
||||
state.members[index] = updatedMember;
|
||||
}
|
||||
})
|
||||
.addCase(updateFamilyMember.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 移除成员
|
||||
.addCase(removeFamilyMember.fulfilled, (state, action) => {
|
||||
const memberId = action.payload;
|
||||
state.members = state.members.filter((m) => m.id !== memberId);
|
||||
if (state.familyGroup) {
|
||||
state.familyGroup.memberCount = Math.max(0, state.familyGroup.memberCount - 1);
|
||||
}
|
||||
})
|
||||
.addCase(removeFamilyMember.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// 退出家庭组
|
||||
.addCase(leaveFamilyGroup.fulfilled, (state) => {
|
||||
state.familyGroup = null;
|
||||
state.members = [];
|
||||
state.inviteCode = null;
|
||||
})
|
||||
.addCase(leaveFamilyGroup.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
export const { clearError, clearInviteCode, resetFamilyHealth } = familyHealthSlice.actions;
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectFamilyGroup = (state: RootState) => state.familyHealth.familyGroup;
|
||||
export const selectFamilyMembers = (state: RootState) => state.familyHealth.members;
|
||||
export const selectInviteCode = (state: RootState) => state.familyHealth.inviteCode;
|
||||
export const selectFamilyHealthLoading = (state: RootState) => state.familyHealth.loading;
|
||||
export const selectFamilyMembersLoading = (state: RootState) => state.familyHealth.membersLoading;
|
||||
export const selectInviteLoading = (state: RootState) => state.familyHealth.inviteLoading;
|
||||
export const selectFamilyHealthError = (state: RootState) => state.familyHealth.error;
|
||||
|
||||
// 判断当前用户是否是家庭组 owner
|
||||
export const selectIsOwner = (state: RootState) => {
|
||||
const currentUserId = state.user.profile?.id;
|
||||
const familyGroup = state.familyHealth.familyGroup;
|
||||
return currentUserId && familyGroup && familyGroup.ownerId === currentUserId;
|
||||
};
|
||||
|
||||
// 判断当前用户是否是管理员(owner 或 admin)
|
||||
export const selectIsAdmin = (state: RootState) => {
|
||||
const currentUserId = state.user.profile?.id;
|
||||
const members = state.familyHealth.members;
|
||||
const currentMember = members.find((m) => m.userId === currentUserId);
|
||||
return currentMember && (currentMember.role === 'owner' || currentMember.role === 'admin');
|
||||
};
|
||||
|
||||
export default familyHealthSlice.reducer;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import * as healthProfileApi from '@/services/healthProfile';
|
||||
import { AppDispatch, RootState } from './index';
|
||||
|
||||
// 健康数据类型定义
|
||||
@@ -22,10 +23,28 @@ export interface HealthData {
|
||||
standHoursGoal: number;
|
||||
}
|
||||
|
||||
// 健康史数据类型定义
|
||||
export interface HistoryItemDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
date?: string; // ISO Date string
|
||||
isRecommendation?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryData {
|
||||
[key: string]: {
|
||||
hasHistory: boolean | null;
|
||||
items: HistoryItemDetail[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthState {
|
||||
// 按日期存储的历史数据
|
||||
dataByDate: Record<string, HealthData>;
|
||||
|
||||
// 健康史数据
|
||||
historyData: HistoryData;
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -37,6 +56,12 @@ export interface HealthState {
|
||||
// 初始状态
|
||||
const initialState: HealthState = {
|
||||
dataByDate: {},
|
||||
historyData: {
|
||||
allergy: { hasHistory: null, items: [] },
|
||||
disease: { hasHistory: null, items: [] },
|
||||
surgery: { hasHistory: null, items: [] },
|
||||
familyDisease: { hasHistory: null, items: [] },
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
@@ -82,6 +107,96 @@ const healthSlice = createSlice({
|
||||
state.error = null;
|
||||
state.lastUpdateTime = null;
|
||||
},
|
||||
|
||||
// 更新健康史数据(本地更新,用于乐观更新或离线模式)
|
||||
updateHistoryData: (state, action: PayloadAction<{
|
||||
type: string;
|
||||
data: {
|
||||
hasHistory: boolean | null;
|
||||
items: HistoryItemDetail[];
|
||||
};
|
||||
}>) => {
|
||||
const { type, data } = action.payload;
|
||||
state.historyData[type] = data;
|
||||
state.lastUpdateTime = new Date().toISOString();
|
||||
},
|
||||
|
||||
// 设置完整的健康史数据(从服务端同步)
|
||||
setHistoryData: (state, action: PayloadAction<HistoryData>) => {
|
||||
state.historyData = action.payload;
|
||||
state.lastUpdateTime = new Date().toISOString();
|
||||
},
|
||||
|
||||
// 清除健康史数据
|
||||
clearHistoryData: (state) => {
|
||||
state.historyData = {
|
||||
allergy: { hasHistory: null, items: [] },
|
||||
disease: { hasHistory: null, items: [] },
|
||||
surgery: { hasHistory: null, items: [] },
|
||||
familyDisease: { hasHistory: null, items: [] },
|
||||
};
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// 获取健康史
|
||||
.addCase(fetchHealthHistory.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchHealthHistory.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
// 转换服务端数据格式到本地格式
|
||||
const serverData = action.payload;
|
||||
const categories = ['allergy', 'disease', 'surgery', 'familyDisease'] as const;
|
||||
|
||||
categories.forEach(category => {
|
||||
if (serverData[category]) {
|
||||
state.historyData[category] = {
|
||||
hasHistory: serverData[category].hasHistory,
|
||||
items: serverData[category].items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
date: item.diagnosisDate,
|
||||
isRecommendation: item.isRecommendation,
|
||||
})),
|
||||
};
|
||||
}
|
||||
});
|
||||
state.lastUpdateTime = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchHealthHistory.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// 保存健康史分类
|
||||
.addCase(saveHealthHistoryCategory.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(saveHealthHistoryCategory.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
const { category, data } = action.payload;
|
||||
// 更新对应分类的数据
|
||||
state.historyData[category] = {
|
||||
hasHistory: data.hasHistory,
|
||||
items: data.items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
date: item.diagnosisDate,
|
||||
isRecommendation: item.isRecommendation,
|
||||
})),
|
||||
};
|
||||
state.lastUpdateTime = new Date().toISOString();
|
||||
})
|
||||
.addCase(saveHealthHistoryCategory.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// 获取健康史进度
|
||||
.addCase(fetchHealthHistoryProgress.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,6 +207,9 @@ export const {
|
||||
setHealthData,
|
||||
clearHealthDataForDate,
|
||||
clearAllHealthData,
|
||||
updateHistoryData,
|
||||
setHistoryData,
|
||||
clearHistoryData,
|
||||
} = healthSlice.actions;
|
||||
|
||||
// Thunk action to fetch and set health data for a specific date
|
||||
@@ -112,10 +230,84 @@ export const fetchHealthDataForDate = (date: Date) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 健康史 API Thunks ====================
|
||||
|
||||
/**
|
||||
* 从服务端获取完整健康史数据
|
||||
*/
|
||||
export const fetchHealthHistory = createAsyncThunk(
|
||||
'health/fetchHistory',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.getHealthHistory();
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取健康史失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 保存健康史分类到服务端
|
||||
*/
|
||||
export const saveHealthHistoryCategory = createAsyncThunk(
|
||||
'health/saveHistoryCategory',
|
||||
async (
|
||||
{
|
||||
category,
|
||||
data,
|
||||
}: {
|
||||
category: healthProfileApi.HealthHistoryCategory;
|
||||
data: healthProfileApi.UpdateHealthHistoryRequest;
|
||||
},
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const result = await healthProfileApi.updateHealthHistory(category, data);
|
||||
return { category, data: result };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '保存健康史失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取健康史完成进度
|
||||
*/
|
||||
export const fetchHealthHistoryProgress = createAsyncThunk(
|
||||
'health/fetchHistoryProgress',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await healthProfileApi.getHealthHistoryProgress();
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取健康史进度失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Selectors
|
||||
export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date];
|
||||
export const selectHealthLoading = (state: RootState) => state.health.loading;
|
||||
export const selectHealthError = (state: RootState) => state.health.error;
|
||||
export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime;
|
||||
export const selectHistoryData = (state: RootState) => state.health.historyData;
|
||||
|
||||
// 计算健康史完成度的 selector
|
||||
export const selectHealthHistoryProgress = (state: RootState) => {
|
||||
const historyData = state.health.historyData;
|
||||
const categories = ['allergy', 'disease', 'surgery', 'familyDisease'];
|
||||
|
||||
let answeredCount = 0;
|
||||
categories.forEach(category => {
|
||||
const data = historyData[category];
|
||||
// 只要回答了是否有历史(hasHistory !== null),就算已完成
|
||||
if (data && data.hasHistory !== null) {
|
||||
answeredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.round((answeredCount / categories.length) * 100);
|
||||
};
|
||||
|
||||
export default healthSlice.reducer;
|
||||
@@ -5,6 +5,7 @@ import challengesReducer from './challengesSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import circumferenceReducer from './circumferenceSlice';
|
||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||
import familyHealthReducer from './familyHealthSlice';
|
||||
import fastingReducer, {
|
||||
clearActiveSchedule,
|
||||
completeActiveSchedule,
|
||||
@@ -101,6 +102,7 @@ export const store = configureStore({
|
||||
checkin: checkinReducer,
|
||||
circumference: circumferenceReducer,
|
||||
health: healthReducer,
|
||||
familyHealth: familyHealthReducer,
|
||||
mood: moodReducer,
|
||||
nutrition: nutritionReducer,
|
||||
trainingPlan: trainingPlanReducer,
|
||||
|
||||
Reference in New Issue
Block a user