feat(medications): 实现完整的用药管理功能

添加了药物管理的核心功能,包括:
- 药物列表展示和状态管理
- 添加新药物的完整流程
- 服药记录的创建和状态更新
- 药物管理界面,支持激活/停用操作
- Redux状态管理和API服务层
- 相关类型定义和辅助函数

主要文件:
- app/(tabs)/medications.tsx - 主界面,集成Redux数据
- app/medications/add-medication.tsx - 添加药物流程
- app/medications/manage-medications.tsx - 药物管理界面
- store/medicationsSlice.ts - Redux状态管理
- services/medications.ts - API服务层
- types/medication.ts - 类型定义
This commit is contained in:
richarjiang
2025-11-10 10:02:53 +08:00
parent 3aafc50702
commit 25b8e45af8
11 changed files with 3517 additions and 233 deletions

724
store/medicationsSlice.ts Normal file
View File

@@ -0,0 +1,724 @@
/**
* 药物管理 Redux Slice
*/
import * as medicationsApi from '@/services/medications';
import type {
DailyMedicationStats,
Medication,
MedicationRecord,
MedicationStatus,
} from '@/types/medication';
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;
})
.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;
})
.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);
}
});
})
.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) => {
state.loading.delete = true;
state.error = null;
})
.addCase(deleteMedicationAction.fulfilled, (state, action) => {
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
);
})
.addCase(deleteMedicationAction.rejected, (state, action) => {
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;
}
})
.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);
}
})
.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渲染
* 将药物记录和药物信息合并为展示项
*/
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));
// 转换为展示项
return records
.map((record) => {
const medication = record.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,
} as import('@/types/medication').MedicationDisplayItem;
})
.filter((item): item is import('@/types/medication').MedicationDisplayItem => item !== null);
};
// ==================== Export ====================
export default medicationsSlice.reducer;