Files
digital-pilates/store/medicationsSlice.ts

799 lines
26 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.

/**
* 药物管理 Redux Slice
*/
import * as medicationsApi from '@/services/medications';
import type {
DailyMedicationStats,
Medication,
MedicationRecord,
MedicationStatus,
} from '@/types/medication';
import { convertMedicationDataToWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
import type { RootState } from './index';
// ==================== 状态接口 ====================
interface MedicationsState {
// 药物列表
medications: Medication[];
// 激活的药物列表(快速访问)
activeMedications: Medication[];
// 按日期存储的服药记录 { 'YYYY-MM-DD': MedicationRecord[] }
medicationRecords: Record<string, MedicationRecord[]>;
// 每日统计 { 'YYYY-MM-DD': DailyMedicationStats }
dailyStats: Record<string, DailyMedicationStats>;
// 总体统计
overallStats: {
totalMedications: number;
totalRecords: number;
completionRate: number;
streak: number;
} | null;
// 当前选中的日期
selectedDate: string;
// 加载状态
loading: {
medications: boolean;
records: boolean;
stats: boolean;
create: boolean;
update: boolean;
delete: boolean;
takeMedication: boolean;
};
// 错误信息
error: string | null;
}
// ==================== 初始状态 ====================
const initialState: MedicationsState = {
medications: [],
activeMedications: [],
medicationRecords: {},
dailyStats: {},
overallStats: null,
selectedDate: dayjs().format('YYYY-MM-DD'),
loading: {
medications: false,
records: false,
stats: false,
create: false,
update: false,
delete: false,
takeMedication: false,
},
error: null,
};
// ==================== 异步 Thunks ====================
/**
* 获取药物列表
*/
export const fetchMedications = createAsyncThunk(
'medications/fetchMedications',
async (params?: medicationsApi.GetMedicationsParams) => {
return await medicationsApi.getMedications(params);
}
);
/**
* 获取指定日期的服药记录
*/
export const fetchMedicationRecords = createAsyncThunk(
'medications/fetchMedicationRecords',
async (params: { date: string }) => {
const records = await medicationsApi.getMedicationRecords(params);
return { date: params.date, records };
}
);
/**
* 获取今日服药记录
*/
export const fetchTodayMedicationRecords = createAsyncThunk(
'medications/fetchTodayMedicationRecords',
async () => {
const records = await medicationsApi.getTodayMedicationRecords();
const today = dayjs().format('YYYY-MM-DD');
return { date: today, records };
}
);
/**
* 获取日期范围内的服药记录
*/
export const fetchMedicationRecordsByDateRange = createAsyncThunk(
'medications/fetchMedicationRecordsByDateRange',
async (params: { startDate: string; endDate: string }) => {
const records = await medicationsApi.getMedicationRecords(params);
return { params, records };
}
);
/**
* 获取每日统计
*/
export const fetchDailyStats = createAsyncThunk(
'medications/fetchDailyStats',
async (date: string) => {
const stats = await medicationsApi.getDailyStats(date);
return { date, stats };
}
);
/**
* 获取总体统计
*/
export const fetchOverallStats = createAsyncThunk(
'medications/fetchOverallStats',
async () => {
return await medicationsApi.getOverallStats();
}
);
/**
* 创建新药物
*/
export const createMedicationAction = createAsyncThunk(
'medications/createMedication',
async (dto: medicationsApi.CreateMedicationDto) => {
return await medicationsApi.createMedication(dto);
}
);
/**
* 更新药物信息
*/
export const updateMedicationAction = createAsyncThunk(
'medications/updateMedication',
async (dto: medicationsApi.UpdateMedicationDto) => {
return await medicationsApi.updateMedication(dto);
}
);
/**
* 删除药物
*/
export const deleteMedicationAction = createAsyncThunk(
'medications/deleteMedication',
async (id: string) => {
await medicationsApi.deleteMedication(id);
return id;
}
);
/**
* 停用药物
*/
export const deactivateMedicationAction = createAsyncThunk(
'medications/deactivateMedication',
async (id: string) => {
return await medicationsApi.deactivateMedication(id);
}
);
/**
* 服用药物
*/
export const takeMedicationAction = createAsyncThunk(
'medications/takeMedication',
async (params: { recordId: string; actualTime?: string }) => {
return await medicationsApi.takeMedication(params.recordId, params.actualTime);
}
);
/**
* 跳过药物
*/
export const skipMedicationAction = createAsyncThunk(
'medications/skipMedication',
async (params: { recordId: string; note?: string }) => {
return await medicationsApi.skipMedication(params.recordId, params.note);
}
);
/**
* 更新服药记录
*/
export const updateMedicationRecordAction = createAsyncThunk(
'medications/updateMedicationRecord',
async (dto: medicationsApi.UpdateMedicationRecordDto) => {
return await medicationsApi.updateMedicationRecord(dto);
}
);
// ==================== Slice ====================
const medicationsSlice = createSlice({
name: 'medications',
initialState,
reducers: {
/**
* 设置选中的日期
*/
setSelectedDate: (state, action: PayloadAction<string>) => {
state.selectedDate = action.payload;
},
/**
* 清除错误信息
*/
clearError: (state) => {
state.error = null;
},
/**
* 清除所有药物数据
*/
clearMedicationsData: (state) => {
state.medications = [];
state.activeMedications = [];
state.medicationRecords = {};
state.dailyStats = {};
state.overallStats = null;
state.error = null;
},
/**
* 清除服药记录
*/
clearMedicationRecords: (state) => {
state.medicationRecords = {};
state.dailyStats = {};
},
/**
* 本地更新记录状态(用于乐观更新)
*/
updateRecordStatusLocally: (
state,
action: PayloadAction<{
recordId: string;
status: MedicationStatus;
date: string;
actualTime?: string;
}>
) => {
const { recordId, status, date, actualTime } = action.payload;
const records = state.medicationRecords[date];
if (records) {
const record = records.find((r) => r.id === recordId);
if (record) {
record.status = status;
if (actualTime) {
record.actualTime = actualTime;
}
}
}
// 更新统计数据
const stats = state.dailyStats[date];
if (stats) {
if (status === 'taken') {
stats.taken += 1;
stats.upcoming = Math.max(0, stats.upcoming - 1);
} else if (status === 'missed') {
stats.missed += 1;
stats.upcoming = Math.max(0, stats.upcoming - 1);
} else if (status === 'skipped') {
stats.upcoming = Math.max(0, stats.upcoming - 1);
}
stats.completionRate = stats.totalScheduled > 0
? (stats.taken / stats.totalScheduled) * 100
: 0;
}
},
/**
* 添加本地服药记录(用于离线场景)
*/
addLocalMedicationRecord: (state, action: PayloadAction<MedicationRecord>) => {
const record = action.payload;
const date = dayjs(record.scheduledTime).format('YYYY-MM-DD');
if (!state.medicationRecords[date]) {
state.medicationRecords[date] = [];
}
// 检查是否已存在相同ID的记录
const existingIndex = state.medicationRecords[date].findIndex(
(r) => r.id === record.id
);
if (existingIndex >= 0) {
state.medicationRecords[date][existingIndex] = record;
} else {
state.medicationRecords[date].push(record);
}
},
},
extraReducers: (builder) => {
// ==================== fetchMedications ====================
builder
.addCase(fetchMedications.pending, (state) => {
state.loading.medications = true;
state.error = null;
})
.addCase(fetchMedications.fulfilled, (state, action) => {
console.log('action', action);
state.loading.medications = false;
state.medications = action.payload;
state.activeMedications = action.payload.filter((m) => m.isActive);
})
.addCase(fetchMedications.rejected, (state, action) => {
state.loading.medications = false;
state.error = action.error.message || '获取药物列表失败';
});
// ==================== fetchMedicationRecords ====================
builder
.addCase(fetchMedicationRecords.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchMedicationRecords.fulfilled, (state, action) => {
state.loading.records = false;
const { date, records } = action.payload;
state.medicationRecords[date] = records;
// 如果是今天的记录,同步到小组件
const today = dayjs().format('YYYY-MM-DD');
if (date === today) {
const medicationData = convertMedicationDataToWidget(records, state.medications, date);
syncMedicationDataToWidget(medicationData).catch(error => {
console.error('Failed to sync medication data to widget:', error);
});
}
})
.addCase(fetchMedicationRecords.rejected, (state, action) => {
state.loading.records = false;
state.error = action.error.message || '获取服药记录失败';
});
// ==================== fetchTodayMedicationRecords ====================
builder
.addCase(fetchTodayMedicationRecords.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchTodayMedicationRecords.fulfilled, (state, action) => {
state.loading.records = false;
const { date, records } = action.payload;
state.medicationRecords[date] = records;
// 同步今天的记录到小组件
const medicationData = convertMedicationDataToWidget(records, state.medications, date);
syncMedicationDataToWidget(medicationData).catch(error => {
console.error('Failed to sync medication data to widget:', error);
});
})
.addCase(fetchTodayMedicationRecords.rejected, (state, action) => {
state.loading.records = false;
state.error = action.error.message || '获取今日服药记录失败';
});
// ==================== fetchMedicationRecordsByDateRange ====================
builder
.addCase(fetchMedicationRecordsByDateRange.pending, (state) => {
state.loading.records = true;
state.error = null;
})
.addCase(fetchMedicationRecordsByDateRange.fulfilled, (state, action) => {
state.loading.records = false;
const { records } = action.payload;
// 按日期分组存储记录
records.forEach((record) => {
const date = dayjs(record.scheduledTime).format('YYYY-MM-DD');
if (!state.medicationRecords[date]) {
state.medicationRecords[date] = [];
}
// 检查是否已存在相同ID的记录
const existingIndex = state.medicationRecords[date].findIndex(
(r) => r.id === record.id
);
if (existingIndex >= 0) {
state.medicationRecords[date][existingIndex] = record;
} else {
state.medicationRecords[date].push(record);
}
});
// 同步今天的记录到小组件
const today = dayjs().format('YYYY-MM-DD');
if (state.medicationRecords[today]) {
const medicationData = convertMedicationDataToWidget(state.medicationRecords[today], state.medications, today);
syncMedicationDataToWidget(medicationData).catch(error => {
console.error('Failed to sync medication data to widget:', error);
});
}
})
.addCase(fetchMedicationRecordsByDateRange.rejected, (state, action) => {
state.loading.records = false;
state.error = action.error.message || '获取服药记录失败';
});
// ==================== fetchDailyStats ====================
builder
.addCase(fetchDailyStats.pending, (state) => {
state.loading.stats = true;
state.error = null;
})
.addCase(fetchDailyStats.fulfilled, (state, action) => {
state.loading.stats = false;
const { date, stats } = action.payload;
state.dailyStats[date] = stats;
})
.addCase(fetchDailyStats.rejected, (state, action) => {
state.loading.stats = false;
state.error = action.error.message || '获取统计数据失败';
});
// ==================== fetchOverallStats ====================
builder
.addCase(fetchOverallStats.pending, (state) => {
state.loading.stats = true;
state.error = null;
})
.addCase(fetchOverallStats.fulfilled, (state, action) => {
state.loading.stats = false;
state.overallStats = action.payload;
})
.addCase(fetchOverallStats.rejected, (state, action) => {
state.loading.stats = false;
state.error = action.error.message || '获取总体统计失败';
});
// ==================== createMedication ====================
builder
.addCase(createMedicationAction.pending, (state) => {
state.loading.create = true;
state.error = null;
})
.addCase(createMedicationAction.fulfilled, (state, action) => {
state.loading.create = false;
const newMedication = action.payload;
state.medications.push(newMedication);
if (newMedication.isActive) {
state.activeMedications.push(newMedication);
}
})
.addCase(createMedicationAction.rejected, (state, action) => {
state.loading.create = false;
state.error = action.error.message || '创建药物失败';
});
// ==================== updateMedication ====================
builder
.addCase(updateMedicationAction.pending, (state) => {
state.loading.update = true;
state.error = null;
})
.addCase(updateMedicationAction.fulfilled, (state, action) => {
state.loading.update = false;
const updated = action.payload;
const index = state.medications.findIndex((m) => m.id === updated.id);
if (index >= 0) {
// 只有当 isActive 状态改变时才更新 activeMedications
const wasActive = state.medications[index].isActive;
const isActiveNow = updated.isActive;
// 更新药品信息
state.medications[index] = updated;
// 优化:只有当 isActive 状态改变时才重新计算 activeMedications
if (wasActive !== isActiveNow) {
if (isActiveNow) {
// 激活药品:添加到 activeMedications如果不在其中
if (!state.activeMedications.some(m => m.id === updated.id)) {
state.activeMedications.push(updated);
}
} else {
// 停用药品:从 activeMedications 中移除
state.activeMedications = state.activeMedications.filter(
(m) => m.id !== updated.id
);
}
} else {
// isActive 状态未改变,只需更新 activeMedications 中的对应项
const activeIndex = state.activeMedications.findIndex((m) => m.id === updated.id);
if (activeIndex >= 0) {
state.activeMedications[activeIndex] = updated;
}
}
}
})
.addCase(updateMedicationAction.rejected, (state, action) => {
state.loading.update = false;
state.error = action.error.message || '更新药物失败';
});
// ==================== deleteMedication ====================
builder
.addCase(deleteMedicationAction.pending, (state) => {
console.log('[MEDICATIONS_SLICE] Delete operation pending');
state.loading.delete = true;
state.error = null;
})
.addCase(deleteMedicationAction.fulfilled, (state, action) => {
console.log('[MEDICATIONS_SLICE] Delete operation fulfilled', { deletedId: action.payload });
state.loading.delete = false;
const deletedId = action.payload;
state.medications = state.medications.filter((m) => m.id !== deletedId);
state.activeMedications = state.activeMedications.filter(
(m) => m.id !== deletedId
);
console.log('[MEDICATIONS_SLICE] Medications after delete', {
totalMedications: state.medications.length,
activeMedications: state.activeMedications.length
});
})
.addCase(deleteMedicationAction.rejected, (state, action) => {
console.log('[MEDICATIONS_SLICE] Delete operation rejected', action.error);
state.loading.delete = false;
state.error = action.error.message || '删除药物失败';
});
// ==================== deactivateMedication ====================
builder
.addCase(deactivateMedicationAction.pending, (state) => {
state.loading.update = true;
state.error = null;
})
.addCase(deactivateMedicationAction.fulfilled, (state, action) => {
state.loading.update = false;
const updated = action.payload;
const index = state.medications.findIndex((m) => m.id === updated.id);
if (index >= 0) {
state.medications[index] = updated;
}
// 从激活列表中移除
state.activeMedications = state.activeMedications.filter(
(m) => m.id !== updated.id
);
})
.addCase(deactivateMedicationAction.rejected, (state, action) => {
state.loading.update = false;
state.error = action.error.message || '停用药物失败';
});
// ==================== takeMedication ====================
builder
.addCase(takeMedicationAction.pending, (state) => {
state.loading.takeMedication = true;
state.error = null;
})
.addCase(takeMedicationAction.fulfilled, (state, action) => {
state.loading.takeMedication = false;
const updated = action.payload;
const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD');
const records = state.medicationRecords[date];
if (records) {
const index = records.findIndex((r) => r.id === updated.id);
if (index >= 0) {
records[index] = updated;
}
}
// 更新统计数据
const stats = state.dailyStats[date];
if (stats) {
stats.taken += 1;
stats.upcoming = Math.max(0, stats.upcoming - 1);
stats.completionRate = stats.totalScheduled > 0
? (stats.taken / stats.totalScheduled) * 100
: 0;
}
// 如果是今天的记录,同步到小组件
const today = dayjs().format('YYYY-MM-DD');
if (date === today && records) {
const medicationData = convertMedicationDataToWidget(records, state.medications, date);
syncMedicationDataToWidget(medicationData).catch(error => {
console.error('Failed to sync medication data to widget:', error);
});
}
})
.addCase(takeMedicationAction.rejected, (state, action) => {
state.loading.takeMedication = false;
state.error = action.error.message || '服药操作失败';
});
// ==================== skipMedication ====================
builder
.addCase(skipMedicationAction.pending, (state) => {
state.loading.takeMedication = true;
state.error = null;
})
.addCase(skipMedicationAction.fulfilled, (state, action) => {
state.loading.takeMedication = false;
const updated = action.payload;
const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD');
const records = state.medicationRecords[date];
if (records) {
const index = records.findIndex((r) => r.id === updated.id);
if (index >= 0) {
records[index] = updated;
}
}
// 更新统计数据
const stats = state.dailyStats[date];
if (stats) {
stats.upcoming = Math.max(0, stats.upcoming - 1);
}
// 如果是今天的记录,同步到小组件
const today = dayjs().format('YYYY-MM-DD');
if (date === today && records) {
const medicationData = convertMedicationDataToWidget(records, state.medications, date);
syncMedicationDataToWidget(medicationData).catch(error => {
console.error('Failed to sync medication data to widget:', error);
});
}
})
.addCase(skipMedicationAction.rejected, (state, action) => {
state.loading.takeMedication = false;
state.error = action.error.message || '跳过操作失败';
});
// ==================== updateMedicationRecord ====================
builder
.addCase(updateMedicationRecordAction.pending, (state) => {
state.loading.update = true;
state.error = null;
})
.addCase(updateMedicationRecordAction.fulfilled, (state, action) => {
state.loading.update = false;
const updated = action.payload;
const date = dayjs(updated.scheduledTime).format('YYYY-MM-DD');
const records = state.medicationRecords[date];
if (records) {
const index = records.findIndex((r) => r.id === updated.id);
if (index >= 0) {
records[index] = updated;
}
}
})
.addCase(updateMedicationRecordAction.rejected, (state, action) => {
state.loading.update = false;
state.error = action.error.message || '更新服药记录失败';
});
},
});
// ==================== Actions ====================
export const {
setSelectedDate,
clearError,
clearMedicationsData,
clearMedicationRecords,
updateRecordStatusLocally,
addLocalMedicationRecord,
} = medicationsSlice.actions;
// ==================== Selectors ====================
export const selectMedicationsState = (state: RootState) => state.medications;
export const selectMedications = (state: RootState) => state.medications.medications;
export const selectActiveMedications = (state: RootState) =>
state.medications.activeMedications;
export const selectSelectedDate = (state: RootState) => state.medications.selectedDate;
export const selectMedicationsLoading = (state: RootState) => state.medications.loading;
export const selectMedicationsError = (state: RootState) => state.medications.error;
export const selectOverallStats = (state: RootState) => state.medications.overallStats;
/**
* 获取指定日期的服药记录
*/
export const selectMedicationRecordsByDate = (date: string) => (state: RootState) => {
return state.medications.medicationRecords[date] || [];
};
/**
* 获取当前选中日期的服药记录
*/
export const selectSelectedDateMedicationRecords = (state: RootState) => {
const selectedDate = state.medications.selectedDate;
return state.medications.medicationRecords[selectedDate] || [];
};
/**
* 获取指定日期的统计数据
*/
export const selectDailyStatsByDate = (date: string) => (state: RootState) => {
return state.medications.dailyStats[date];
};
/**
* 获取当前选中日期的统计数据
*/
export const selectSelectedDateStats = (state: RootState) => {
const selectedDate = state.medications.selectedDate;
return state.medications.dailyStats[selectedDate];
};
/**
* 获取指定日期的展示项列表用于UI渲染
* 将药物记录和药物信息合并为展示项
* 排序规则优先显示未服用的药品upcoming、missed然后是已服用的药品taken、skipped
*/
export const selectMedicationDisplayItemsByDate = (date: string) => (state: RootState) => {
const records = state.medications.medicationRecords[date] || [];
const medications = state.medications.medications;
// 创建药物ID到药物的映射
const medicationMap = new Map<string, Medication>();
medications.forEach((med) => medicationMap.set(med.id, med));
// 转换为展示项
const displayItems = records
.map((record) => {
const medication = medicationMap.get(record.medicationId);
if (!medication) return null;
// 格式化剂量
const dosage = `${medication.dosageValue} ${medication.dosageUnit}`;
// 提取并格式化为当地时间HH:mm格式
// 服务端返回的是UTC时间需要转换为用户本地时间显示
const localTime = dayjs(record.scheduledTime).format('HH:mm');
const scheduledTime = localTime || '00:00';
// 频率描述
const frequency = medication.repeatPattern === 'daily' ? '每日' : '自定义';
return {
id: record.id,
name: medication.name,
dosage,
scheduledTime,
frequency,
status: record.status,
recordId: record.id,
medicationId: medication.id,
image: medication.photoUrl ? { uri: medication.photoUrl } : undefined
} as import('@/types/medication').MedicationDisplayItem;
})
.filter((item): item is import('@/types/medication').MedicationDisplayItem => item !== null);
// 排序未服用的药品upcoming、missed优先已服用的药品taken、skipped其次
// 在同一组内,按计划时间升序排列
return displayItems.sort((a, b) => {
// 定义状态优先级:数值越小优先级越高
const statusPriority: Record<MedicationStatus, number> = {
'missed': 1, // 已错过 - 最高优先级
'upcoming': 2, // 待服用
'taken': 3, // 已服用
'skipped': 4, // 已跳过
};
const priorityA = statusPriority[a.status];
const priorityB = statusPriority[b.status];
// 首先按状态优先级排序
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// 状态相同时,按计划时间升序排列
return a.scheduledTime.localeCompare(b.scheduledTime);
});
};
// ==================== Export ====================
export default medicationsSlice.reducer;