bodyPositionListSlice.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
  2. import { View as BodyPosition } from '../../API/patient/viewActions';
  3. import { Work } from './examWorksCacheSlice';
  4. import { dview } from '@/domain/dview';
  5. import emitter from '@/utils/eventEmitter';
  6. import store from '@/states/store';
  7. import { MqttMessage } from '@/domain/mqttService';
  8. import { fetchViewDetail } from '@/API/patient/viewActions';
  9. import { judgeImage } from '@/API/exam/judgeImage';
  10. import { executeSaveImageAs } from '@/domain/exam/saveImageAs';
  11. /**
  12. * ExtendedBodyPosition即包含体位模块信息,又包含体位实例信息,也包括study(work)信息
  13. */
  14. export interface ExtendedBodyPosition extends BodyPosition {
  15. patient_name: string;
  16. patient_id: string;
  17. registration_number: string;
  18. study_description: string;
  19. body_position_image: string;
  20. collimator_length: number | string;
  21. collimator_width: number | string;
  22. sid: string;
  23. work: Work;
  24. sop_instance_uid: string;
  25. series_instance_uid?: string;
  26. study_instance_uid?: string;
  27. secondary_sop_uid?: string;
  28. study_id?: string;
  29. dview: dview;
  30. }
  31. interface BodyPositionListState {
  32. bodyPositions: ExtendedBodyPosition[];
  33. selectedBodyPosition: ExtendedBodyPosition | null;
  34. exposureStatus: 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null;
  35. loading: boolean;
  36. error: string | null;
  37. }
  38. const initialState: BodyPositionListState = {
  39. bodyPositions: [],
  40. selectedBodyPosition: null,
  41. exposureStatus: null,
  42. loading: false,
  43. error: null,
  44. };
  45. /**
  46. * 图像判断状态类型
  47. */
  48. type ImageJudgedStatus = 'Accept' | 'Reject' | '';
  49. /**
  50. * 判断图像 Thunk
  51. * 调用 API 接受或拒绝图像,成功后更新本地状态
  52. */
  53. export const judgeImageThunk = createAsyncThunk(
  54. 'bodyPositionList/judgeImage',
  55. async (payload: { sopInstanceUid: string; accept: boolean }) => {
  56. await judgeImage(payload.sopInstanceUid, payload.accept);
  57. return {
  58. sopInstanceUid: payload.sopInstanceUid,
  59. judgedStatus: (payload.accept ? 'Accept' : 'Reject') as ImageJudgedStatus,
  60. };
  61. }
  62. );
  63. /**
  64. * 图像另存为 Thunk
  65. * 调用 domain 层业务逻辑执行另存为,成功后更新体位列表
  66. */
  67. export const saveImageAsThunk = createAsyncThunk(
  68. 'bodyPositionList/saveImageAs',
  69. async (payload: { sopInstanceUid: string; studyId: string }) => {
  70. const result = await executeSaveImageAs(
  71. payload.sopInstanceUid,
  72. payload.studyId
  73. );
  74. if (!result.success) {
  75. throw new Error(result.error || '图像另存为失败');
  76. }
  77. return {
  78. updatedBodyPositions: result.updatedBodyPositions,
  79. };
  80. }
  81. );
  82. emitter.on('TASK_SUCCESS', (message: MqttMessage) => {
  83. console.log(
  84. `[bodyPositionListSlice] TASK_SUCCESS received with message:`,
  85. message
  86. );
  87. const { sop } = message;
  88. if (sop) {
  89. store.dispatch(
  90. bodyPositionListSlice.actions.updateBodyPositionDview({
  91. sopInstanceUid: sop,
  92. msg: message,
  93. })
  94. );
  95. }
  96. });
  97. export const transformWorksToBodyPositions = async (
  98. works: Work[]
  99. ): Promise<ExtendedBodyPosition[]> => {
  100. return await Promise.all(
  101. works.flatMap((work) =>
  102. work.Views.map(async (view) => {
  103. const viewDetail = await fetchViewDetail(view.view_id);
  104. return {
  105. ...viewDetail,
  106. collimator_length:
  107. viewDetail.config_object.DX?.CollimatorSizeLength ||
  108. viewDetail.config_object?.Common?.CollimatorSizeLength ||
  109. 0,
  110. // viewDetail.config_object.Common.CollimatorSizeLength || 0, 用于动物
  111. collimator_width:
  112. viewDetail.config_object.DX?.CollimatorSizeWidth ||
  113. viewDetail.config_object?.Common?.CollimatorSizeWidth ||
  114. 0,
  115. // viewDetail.config_object.Common.CollimatorSizeWidth || 0, 用于动物
  116. sid: '', // todo 这里还缺少值的来源
  117. patient_name: work.PatientName,
  118. patient_id: work.PatientID,
  119. registration_number: work.AccessionNumber,
  120. study_description: work.StudyDescription,
  121. body_position_image: viewDetail.view_icon_name,
  122. work: work,
  123. study_instance_uid: work.StudyInstanceUID,
  124. sop_instance_uid: view.PrimarySopUID,
  125. study_id:work.StudyID,
  126. dview: view,
  127. };
  128. })
  129. )
  130. );
  131. };
  132. const bodyPositionListSlice = createSlice({
  133. name: 'bodyPositionList',
  134. initialState,
  135. reducers: {
  136. setExposureStatus: (
  137. state,
  138. action: PayloadAction<
  139. 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null
  140. >
  141. ) => {
  142. state.exposureStatus = action.payload;
  143. },
  144. addBodyPosition: (state, action: PayloadAction<ExtendedBodyPosition>) => {
  145. state.bodyPositions.push(action.payload);
  146. },
  147. setBodyPositions: (
  148. state,
  149. action: PayloadAction<ExtendedBodyPosition[]>
  150. ) => {
  151. state.bodyPositions = action.payload;
  152. },
  153. setSelectedBodyPosition: (
  154. state,
  155. action: PayloadAction<ExtendedBodyPosition | null>
  156. ) => {
  157. state.selectedBodyPosition = action.payload;
  158. console.log(
  159. `[bodyPositionListSlice] setSelectedBodyPosition:`,
  160. action.payload
  161. );
  162. },
  163. setByIndex: (state, action: PayloadAction<number>) => {
  164. if (action.payload >= 0 && action.payload < state.bodyPositions.length) {
  165. state.selectedBodyPosition = state.bodyPositions[action.payload];
  166. }
  167. },
  168. addViewsToBodyPosition: (
  169. state,
  170. action: PayloadAction<{ instanceUid: string; dviews: dview[] }>
  171. ) => {
  172. const [dview] = action.payload.dviews;
  173. if (dview && state.selectedBodyPosition) {
  174. const newBodyPosition: ExtendedBodyPosition = {
  175. ...state.selectedBodyPosition,
  176. work: {
  177. ...state.selectedBodyPosition.work,
  178. Views: [...state.selectedBodyPosition.work.Views, dview],
  179. },
  180. sop_instance_uid: action.payload.instanceUid,
  181. };
  182. state.bodyPositions.push(newBodyPosition);
  183. }
  184. },
  185. removeBodyPositionBySopInstanceUid: (
  186. state,
  187. action: PayloadAction<string>
  188. ) => {
  189. state.bodyPositions = state.bodyPositions.filter(
  190. (bodyPosition) => bodyPosition.sop_instance_uid !== action.payload
  191. );
  192. },
  193. updateBodyPositionDview: (
  194. state,
  195. action: PayloadAction<{ sopInstanceUid: string; msg: MqttMessage }>
  196. ) => {
  197. const { sopInstanceUid, msg } = action.payload;
  198. const index = state.bodyPositions.findIndex(
  199. (bp) => bp.sop_instance_uid === sopInstanceUid
  200. );
  201. if (index === -1) {
  202. console.warn(
  203. `[bodyPositionListSlice] updateBodyPositionDview: No body position found for SOP Instance UID ${sopInstanceUid}`
  204. );
  205. return;
  206. }
  207. console.log(
  208. `[bodyPositionListSlice] updateBodyPositionDview: Updating body position for SOP Instance UID ${sopInstanceUid} with message ${JSON.stringify(msg)} `
  209. );
  210. const updatedBodyPosition = {
  211. ...state.bodyPositions[index],
  212. sop_instance_uid: sopInstanceUid,
  213. dview: {
  214. ...state.bodyPositions[index].dview,
  215. PrimarySopUID: sopInstanceUid,
  216. thumbnail_file: msg.thumbnail ?? '',
  217. image_file: msg.dcm ?? '',
  218. expose_status: 'Exposed',
  219. judged_status: state.bodyPositions[index].dview.judged_status || '',
  220. } satisfies dview,
  221. };
  222. state.bodyPositions[index] = updatedBodyPosition;
  223. console.log(
  224. `[bodyPositionListSlice] updateBodyPositionDview: Updated body position:`,
  225. updatedBodyPosition
  226. );
  227. // Ensure the selectedBodyPosition is updated if it matches the updated bodyPosition
  228. if (state.selectedBodyPosition?.sop_instance_uid === sopInstanceUid) {
  229. state.selectedBodyPosition = updatedBodyPosition;
  230. }
  231. },
  232. },
  233. extraReducers: (builder) => {
  234. builder
  235. // 处理 judgeImageThunk
  236. .addCase(judgeImageThunk.pending, (state) => {
  237. state.loading = true;
  238. state.error = null;
  239. })
  240. .addCase(judgeImageThunk.fulfilled, (state, action) => {
  241. state.loading = false;
  242. const position = state.bodyPositions.find(
  243. (bp) => bp.sop_instance_uid === action.payload.sopInstanceUid
  244. );
  245. if (position?.dview) {
  246. position.dview.judged_status = action.payload.judgedStatus;
  247. }
  248. // 同步更新 selectedBodyPosition
  249. if (
  250. state.selectedBodyPosition?.sop_instance_uid ===
  251. action.payload.sopInstanceUid &&
  252. state.selectedBodyPosition.dview
  253. ) {
  254. state.selectedBodyPosition.dview.judged_status =
  255. action.payload.judgedStatus;
  256. }
  257. })
  258. .addCase(judgeImageThunk.rejected, (state, action) => {
  259. state.loading = false;
  260. state.error = action.error.message || 'Failed to judge image';
  261. })
  262. // 处理 saveImageAsThunk
  263. .addCase(saveImageAsThunk.pending, (state) => {
  264. state.loading = true;
  265. state.error = null;
  266. })
  267. .addCase(saveImageAsThunk.fulfilled, (state, action) => {
  268. state.loading = false;
  269. // 更新体位列表
  270. state.bodyPositions = action.payload.updatedBodyPositions;
  271. console.log(
  272. `[bodyPositionListSlice] Save as completed, updated ${action.payload.updatedBodyPositions.length} body positions`
  273. );
  274. })
  275. .addCase(saveImageAsThunk.rejected, (state, action) => {
  276. state.loading = false;
  277. state.error = action.error.message || '图像另存为失败';
  278. })
  279. // 处理曝光状态变化
  280. .addMatcher(
  281. (action) =>
  282. action.type === setBodyPositions.type ||
  283. action.type === addViewsToBodyPosition.type ||
  284. action.type === removeBodyPositionBySopInstanceUid.type ||
  285. action.type === updateBodyPositionDview.type,
  286. (state) => {
  287. const newExposureStatus = determineExposureStatus(state.bodyPositions);
  288. state.exposureStatus = newExposureStatus;
  289. // bodyPositionListSlice.caseReducers.setExposureStatus(state, {
  290. // payload: newExposureStatus,
  291. // type: 'setExposureStatus',
  292. // });
  293. }
  294. );
  295. },
  296. });
  297. const determineExposureStatus = (
  298. bodyPositions: ExtendedBodyPosition[]
  299. ): 'Half Exposed' | 'Fully Exposed' | 'Not Exposed' | null => {
  300. if (bodyPositions.length === 0) return null;
  301. const allExposed = bodyPositions.every(
  302. (bp) => bp.dview.expose_status === 'Exposed'
  303. );
  304. const allUnExposed = bodyPositions.every(
  305. (bp) => bp.dview.expose_status === 'Unexposed'
  306. );
  307. if (allExposed) {
  308. return 'Fully Exposed';
  309. } else if (allUnExposed) {
  310. return 'Not Exposed';
  311. } else {
  312. return 'Half Exposed';
  313. }
  314. };
  315. export const {
  316. addBodyPosition,
  317. setBodyPositions,
  318. setSelectedBodyPosition,
  319. setByIndex,
  320. addViewsToBodyPosition,
  321. removeBodyPositionBySopInstanceUid,
  322. updateBodyPositionDview,
  323. } = bodyPositionListSlice.actions;
  324. export default bodyPositionListSlice.reducer;