import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { View as BodyPosition } from '../../API/patient/viewActions'; import { Work } from './examWorksCacheSlice'; import { dview } from '@/domain/dview'; import emitter from '@/utils/eventEmitter'; import store from '@/states/store'; import { MqttMessage } from '@/domain/mqttService'; import { fetchViewDetail } from '@/API/patient/viewActions'; import { judgeImage } from '@/API/exam/judgeImage'; import { executeSaveImageAs } from '@/domain/exam/saveImageAs'; /** * ExtendedBodyPosition即包含体位模块信息,又包含体位实例信息,也包括study(work)信息 */ export interface ExtendedBodyPosition extends BodyPosition { patient_name: string; patient_id: string; registration_number: string; study_description: string; body_position_image: string; collimator_length: number | string; collimator_width: number | string; sid: string; work: Work; sop_instance_uid: string; series_instance_uid?: string; study_instance_uid?: string; secondary_sop_uid?: string; study_id?: string; dview: dview; } interface BodyPositionListState { bodyPositions: ExtendedBodyPosition[]; selectedBodyPosition: ExtendedBodyPosition | null; exposureStatus: 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null; loading: boolean; error: string | null; } const initialState: BodyPositionListState = { bodyPositions: [], selectedBodyPosition: null, exposureStatus: null, loading: false, error: null, }; /** * 图像判断状态类型 */ type ImageJudgedStatus = 'Accept' | 'Reject' | ''; /** * 判断图像 Thunk * 调用 API 接受或拒绝图像,成功后更新本地状态 */ export const judgeImageThunk = createAsyncThunk( 'bodyPositionList/judgeImage', async (payload: { sopInstanceUid: string; accept: boolean }) => { await judgeImage(payload.sopInstanceUid, payload.accept); return { sopInstanceUid: payload.sopInstanceUid, judgedStatus: (payload.accept ? 'Accept' : 'Reject') as ImageJudgedStatus, }; } ); /** * 图像另存为 Thunk * 调用 domain 层业务逻辑执行另存为,成功后更新体位列表 */ export const saveImageAsThunk = createAsyncThunk( 'bodyPositionList/saveImageAs', async (payload: { sopInstanceUid: string; studyId: string }) => { const result = await executeSaveImageAs( payload.sopInstanceUid, payload.studyId ); if (!result.success) { throw new Error(result.error || '图像另存为失败'); } return { updatedBodyPositions: result.updatedBodyPositions, }; } ); emitter.on('TASK_SUCCESS', (message: MqttMessage) => { console.log( `[bodyPositionListSlice] TASK_SUCCESS received with message:`, message ); const { sop } = message; if (sop) { store.dispatch( bodyPositionListSlice.actions.updateBodyPositionDview({ sopInstanceUid: sop, msg: message, }) ); } }); export const transformWorksToBodyPositions = async ( works: Work[] ): Promise => { return await Promise.all( works.flatMap((work) => work.Views.map(async (view) => { const viewDetail = await fetchViewDetail(view.view_id); return { ...viewDetail, collimator_length: viewDetail.config_object.DX?.CollimatorSizeLength || viewDetail.config_object?.Common?.CollimatorSizeLength || 0, // viewDetail.config_object.Common.CollimatorSizeLength || 0, 用于动物 collimator_width: viewDetail.config_object.DX?.CollimatorSizeWidth || viewDetail.config_object?.Common?.CollimatorSizeWidth || 0, // viewDetail.config_object.Common.CollimatorSizeWidth || 0, 用于动物 sid: '', // todo 这里还缺少值的来源 patient_name: work.PatientName, patient_id: work.PatientID, registration_number: work.AccessionNumber, study_description: work.StudyDescription, body_position_image: viewDetail.view_icon_name, work: work, study_instance_uid: work.StudyInstanceUID, sop_instance_uid: view.PrimarySopUID, study_id:work.StudyID, dview: view, }; }) ) ); }; const bodyPositionListSlice = createSlice({ name: 'bodyPositionList', initialState, reducers: { setExposureStatus: ( state, action: PayloadAction< 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null > ) => { state.exposureStatus = action.payload; }, addBodyPosition: (state, action: PayloadAction) => { state.bodyPositions.push(action.payload); }, setBodyPositions: ( state, action: PayloadAction ) => { state.bodyPositions = action.payload; }, setSelectedBodyPosition: ( state, action: PayloadAction ) => { state.selectedBodyPosition = action.payload; console.log( `[bodyPositionListSlice] setSelectedBodyPosition:`, action.payload ); }, setByIndex: (state, action: PayloadAction) => { if (action.payload >= 0 && action.payload < state.bodyPositions.length) { state.selectedBodyPosition = state.bodyPositions[action.payload]; } }, addViewsToBodyPosition: ( state, action: PayloadAction<{ instanceUid: string; dviews: dview[] }> ) => { const [dview] = action.payload.dviews; if (dview && state.selectedBodyPosition) { const newBodyPosition: ExtendedBodyPosition = { ...state.selectedBodyPosition, work: { ...state.selectedBodyPosition.work, Views: [...state.selectedBodyPosition.work.Views, dview], }, sop_instance_uid: action.payload.instanceUid, }; state.bodyPositions.push(newBodyPosition); } }, removeBodyPositionBySopInstanceUid: ( state, action: PayloadAction ) => { state.bodyPositions = state.bodyPositions.filter( (bodyPosition) => bodyPosition.sop_instance_uid !== action.payload ); }, updateBodyPositionDview: ( state, action: PayloadAction<{ sopInstanceUid: string; msg: MqttMessage }> ) => { const { sopInstanceUid, msg } = action.payload; const index = state.bodyPositions.findIndex( (bp) => bp.sop_instance_uid === sopInstanceUid ); if (index === -1) { console.warn( `[bodyPositionListSlice] updateBodyPositionDview: No body position found for SOP Instance UID ${sopInstanceUid}` ); return; } console.log( `[bodyPositionListSlice] updateBodyPositionDview: Updating body position for SOP Instance UID ${sopInstanceUid} with message ${JSON.stringify(msg)} ` ); const updatedBodyPosition = { ...state.bodyPositions[index], sop_instance_uid: sopInstanceUid, dview: { ...state.bodyPositions[index].dview, PrimarySopUID: sopInstanceUid, thumbnail_file: msg.thumbnail ?? '', image_file: msg.dcm ?? '', expose_status: 'Exposed', judged_status: state.bodyPositions[index].dview.judged_status || '', } satisfies dview, }; state.bodyPositions[index] = updatedBodyPosition; console.log( `[bodyPositionListSlice] updateBodyPositionDview: Updated body position:`, updatedBodyPosition ); // Ensure the selectedBodyPosition is updated if it matches the updated bodyPosition if (state.selectedBodyPosition?.sop_instance_uid === sopInstanceUid) { state.selectedBodyPosition = updatedBodyPosition; } }, }, extraReducers: (builder) => { builder // 处理 judgeImageThunk .addCase(judgeImageThunk.pending, (state) => { state.loading = true; state.error = null; }) .addCase(judgeImageThunk.fulfilled, (state, action) => { state.loading = false; const position = state.bodyPositions.find( (bp) => bp.sop_instance_uid === action.payload.sopInstanceUid ); if (position?.dview) { position.dview.judged_status = action.payload.judgedStatus; } // 同步更新 selectedBodyPosition if ( state.selectedBodyPosition?.sop_instance_uid === action.payload.sopInstanceUid && state.selectedBodyPosition.dview ) { state.selectedBodyPosition.dview.judged_status = action.payload.judgedStatus; } }) .addCase(judgeImageThunk.rejected, (state, action) => { state.loading = false; state.error = action.error.message || 'Failed to judge image'; }) // 处理 saveImageAsThunk .addCase(saveImageAsThunk.pending, (state) => { state.loading = true; state.error = null; }) .addCase(saveImageAsThunk.fulfilled, (state, action) => { state.loading = false; // 更新体位列表 state.bodyPositions = action.payload.updatedBodyPositions; console.log( `[bodyPositionListSlice] Save as completed, updated ${action.payload.updatedBodyPositions.length} body positions` ); }) .addCase(saveImageAsThunk.rejected, (state, action) => { state.loading = false; state.error = action.error.message || '图像另存为失败'; }) // 处理曝光状态变化 .addMatcher( (action) => action.type === setBodyPositions.type || action.type === addViewsToBodyPosition.type || action.type === removeBodyPositionBySopInstanceUid.type || action.type === updateBodyPositionDview.type, (state) => { const newExposureStatus = determineExposureStatus(state.bodyPositions); state.exposureStatus = newExposureStatus; // bodyPositionListSlice.caseReducers.setExposureStatus(state, { // payload: newExposureStatus, // type: 'setExposureStatus', // }); } ); }, }); const determineExposureStatus = ( bodyPositions: ExtendedBodyPosition[] ): 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null => { if (bodyPositions.length === 0) return null; const allExposed = bodyPositions.every( (bp) => bp.dview.expose_status === 'Exposed' ); const allUnExposed = bodyPositions.every( (bp) => bp.dview.expose_status === 'Unexposed' ); if (allExposed) { return 'Fully Exposed'; } else if (allUnExposed) { return 'Not Exposed'; } else { return 'Half Exposed'; } }; export const { addBodyPosition, setBodyPositions, setSelectedBodyPosition, setByIndex, addViewsToBodyPosition, removeBodyPositionBySopInstanceUid, updateBodyPositionDview, } = bodyPositionListSlice.actions; export default bodyPositionListSlice.reducer;