Sfoglia il codice sorgente

feat: 初步实现在检查中追加体位的功能

新增文件:
- src/API/exam/appendBodyPosition.ts - 追加体位的 API 调用
- src/states/exam/appendViewSlice.ts - 追加体位的状态管理
- src/pages/exam/components/AppendViewCard.tsx - 体位卡片组件
- src/pages/exam/components/AppendViewModal.tsx - 体位选择模态框

修改文件:
- src/pages/exam/components/BodyPositionList.tsx - 集成追加体位功能
- src/states/store.ts - 注册 appendView reducer
dengdx 2 settimane fa
parent
commit
3c698ed4fc

+ 45 - 0
src/API/exam/appendBodyPosition.ts

@@ -0,0 +1,45 @@
+import axiosInstance from '../interceptor';
+import { Series } from '@/domain/series';
+
+export interface AppendViewRequest {
+  study_id: string;
+  views: {
+    view_id: string;
+    procedure_id: string;
+  }[];
+}
+
+export interface AppendViewResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: {
+    '@type': string;
+    series: Series[];
+  };
+}
+
+/**
+ * 追加体位到现有Study
+ * @param request - 包含 study_id 和要添加的 views 列表
+ * @returns 返回新创建的 Series 和 Images 信息
+ */
+export const appendBodyPositionToStudy = async (
+  request: AppendViewRequest
+): Promise<AppendViewResponse> => {
+  try {
+    console.log(
+      `[appendBodyPosition] Appending views to study:`,
+      JSON.stringify(request, null, 2)
+    );
+    const response = await axiosInstance.post('/auth/image', request);
+    console.log(
+      `[appendBodyPosition] Response:`,
+      JSON.stringify(response.data, null, 2)
+    );
+    return response.data;
+  } catch (error) {
+    console.error('[appendBodyPosition] Error appending body position:', error);
+    throw error;
+  }
+};

+ 62 - 0
src/pages/exam/components/AppendViewCard.tsx

@@ -0,0 +1,62 @@
+import React from 'react';
+import { Card, Image, Badge } from 'antd';
+import { View } from '@/API/patient/viewActions';
+import { getViewIconUrl } from '@/API/bodyPosition';
+
+interface AppendViewCardProps {
+  view: View;
+  isSelected: boolean;
+  onClick: (view: View) => void;
+}
+
+const AppendViewCard: React.FC<AppendViewCardProps> = ({
+  view,
+  isSelected,
+  onClick,
+}) => {
+  return (
+    <Badge.Ribbon
+      text="已选"
+      color="green"
+      style={{ display: isSelected ? 'block' : 'none' }}
+    >
+      <Card
+        hoverable
+        onClick={() => onClick(view)}
+        className={`cursor-pointer transition-all ${
+          isSelected
+            ? 'border-4 border-[var(--color-primary)] shadow-lg'
+            : 'border-2 border-gray-200'
+        }`}
+        cover={
+          <Image
+            alt={view.view_name}
+            src={getViewIconUrl(view.view_icon_name || view.view_coach_name)}
+            preview={false}
+            style={{
+              width: '100%',
+              height: '120px',
+              objectFit: 'contain',
+              padding: '10px',
+            }}
+          />
+        }
+      >
+        <Card.Meta
+          title={
+            <div className="text-center text-sm font-medium truncate">
+              {view.view_name}
+            </div>
+          }
+          description={
+            <div className="text-center text-xs text-gray-500 truncate">
+              {view.view_description}
+            </div>
+          }
+        />
+      </Card>
+    </Badge.Ribbon>
+  );
+};
+
+export default AppendViewCard;

+ 164 - 0
src/pages/exam/components/AppendViewModal.tsx

@@ -0,0 +1,164 @@
+import React, { useEffect } from 'react';
+import { Modal, Row, Col, Empty, Spin, message, Button, Space } from 'antd';
+import { useSelector, useDispatch } from 'react-redux';
+import { RootState, AppDispatch } from '@/states/store';
+import { View } from '@/API/patient/viewActions';
+import {
+  fetchAvailableViewsThunk,
+  toggleViewSelection,
+  clearSelectedViews,
+  setModalOpen,
+  appendViewsThunk,
+} from '@/states/exam/appendViewSlice';
+import AppendViewCard from './AppendViewCard';
+
+interface AppendViewModalProps {
+  open: boolean;
+  onCancel: () => void;
+}
+
+const AppendViewModal: React.FC<AppendViewModalProps> = ({
+  open,
+  onCancel,
+}) => {
+  console.log('[AppendViewModal] Rendering with open:', open);
+  const dispatch = useDispatch<AppDispatch>();
+
+  const { availableViews, selectedViews, loading, error } = useSelector(
+    (state: RootState) => state.appendView
+  );
+
+  const selectedBodyPosition = useSelector(
+    (state: RootState) => state.bodyPositionList.selectedBodyPosition
+  );
+
+  useEffect(() => {
+    console.log(
+      '[AppendViewModal] useEffect triggered, open:',
+      open,
+      'selectedBodyPosition:',
+      selectedBodyPosition
+    );
+    if (open && selectedBodyPosition) {
+      // 打开模态框时,获取可用体位列表
+      const patientType = selectedBodyPosition.work.PatientType;
+      const bodyPartId = selectedBodyPosition.body_part_id;
+
+      console.log(
+        `[AppendViewModal] Fetching available views for patient_type: ${patientType}, body_part_id: ${bodyPartId}`
+      );
+
+      dispatch(
+        fetchAvailableViewsThunk({
+          patient_type: patientType,
+          body_part_id: bodyPartId,
+        })
+      );
+    }
+  }, [open, selectedBodyPosition, dispatch]);
+
+  useEffect(() => {
+    if (error) {
+      message.error(error);
+    }
+  }, [error]);
+
+  const handleViewClick = (view: View): void => {
+    dispatch(toggleViewSelection(view));
+  };
+
+  const handleConfirm = async (): Promise<void> => {
+    if (selectedViews.length === 0) {
+      message.warning('请至少选择一个体位');
+      return;
+    }
+
+    if (!selectedBodyPosition) {
+      message.error('未找到当前检查信息');
+      return;
+    }
+
+    try {
+      await dispatch(
+        appendViewsThunk({
+          study_id: selectedBodyPosition.work.StudyID,
+          views: selectedViews,
+          currentWork: selectedBodyPosition.work,
+        })
+      ).unwrap();
+
+      message.success(`成功追加 ${selectedViews.length} 个体位`);
+      dispatch(setModalOpen(false));
+      onCancel();
+    } catch (error) {
+      message.error('追加体位失败');
+      console.error('[AppendViewModal] Error appending views:', error);
+    }
+  };
+
+  const handleCancel = (): void => {
+    dispatch(clearSelectedViews());
+    onCancel();
+  };
+
+  const isViewSelected = (viewId: string): boolean => {
+    return selectedViews.some((v) => v.view_id === viewId);
+  };
+
+  return (
+    <Modal
+      title="追加体位"
+      open={open}
+      onCancel={handleCancel}
+      width="80%"
+      style={{ top: 20 }}
+      footer={
+        <Space>
+          <Button onClick={handleCancel}>取消</Button>
+          <Button
+            type="primary"
+            onClick={handleConfirm}
+            loading={loading}
+            disabled={selectedViews.length === 0}
+          >
+            确认追加 {selectedViews.length > 0 && `(${selectedViews.length})`}
+          </Button>
+        </Space>
+      }
+    >
+      <div style={{ maxHeight: '70vh', overflowY: 'auto', padding: '16px 0' }}>
+        {loading ? (
+          <div className="flex justify-center items-center h-64">
+            <Spin size="large" tip="加载中..." />
+          </div>
+        ) : availableViews.length === 0 ? (
+          <Empty description="暂无可用体位" />
+        ) : (
+          <>
+            <div className="mb-4 text-gray-600">
+              <span>共 {availableViews.length} 个可用体位</span>
+              {selectedViews.length > 0 && (
+                <span className="ml-4 text-blue-600 font-medium">
+                  已选择 {selectedViews.length} 个
+                </span>
+              )}
+            </div>
+            <Row gutter={[16, 16]}>
+              {availableViews.map((view) => (
+                <Col key={view.view_id} xs={24} sm={12} md={8} lg={6} xl={4}>
+                  <AppendViewCard
+                    view={view}
+                    isSelected={isViewSelected(view.view_id)}
+                    onClick={handleViewClick}
+                  />
+                </Col>
+              ))}
+            </Row>
+          </>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default AppendViewModal;

+ 32 - 13
src/pages/exam/components/BodyPositionList.tsx

@@ -1,8 +1,8 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
 import { useSelector, useDispatch } from 'react-redux';
 import { ExtendedBodyPosition } from '../../../states/exam/bodyPositionListSlice';
 import { RootState, AppDispatch } from '../../../states/store';
-import { Button, Image, message } from 'antd';
+import { message } from 'antd';
 import AppendViewIcon from '@/assets/imgs/append-view.svg';
 import ImageViewer from './ImageViewer';
 import { getExposedImageUrl, getViewIconUrl } from '../../../API/bodyPosition';
@@ -10,6 +10,7 @@ import {
   manualSelectBodyPosition,
   autoSelectFirstBodyPosition,
 } from '@/domain/exam/bodyPositionSelection';
+import AppendViewModal from './AppendViewModal';
 
 interface BodyPositionListProps {
   layout: 'horizontal' | 'vertical';
@@ -53,12 +54,24 @@ const BodyPositionList: React.FC<BodyPositionListProps> = ({
     }
   }, [bodyPositions]);
 
+  const [isAppendModalOpen, setIsAppendModalOpen] = useState(false);
+
   const addBodyPositionClick = (): void => {
-    // dispatch(addBodyPosition({
-    //   view_name: 'New View',
-    //   view_description: 'Description of the new view',
-    //   view_icon_name: 'icon-name'
-    // }));
+    console.log('[BodyPositionList] Add button clicked');
+    console.log(
+      '[BodyPositionList] selectedBodyPosition:',
+      selectedBodyPosition
+    );
+    if (!selectedBodyPosition) {
+      message.warning('请先选择一个体位');
+      return;
+    }
+    console.log('[BodyPositionList] Opening append modal');
+    setIsAppendModalOpen(true);
+  };
+
+  const handleModalClose = (): void => {
+    setIsAppendModalOpen(false);
   };
 
   return (
@@ -86,15 +99,21 @@ const BodyPositionList: React.FC<BodyPositionListProps> = ({
       </div>
 
       {showAddButton && (
-        <Image
-          src={AppendViewIcon}
-          preview={false}
+        <div
+          className="mx-auto cursor-pointer"
           style={{ width: '50%' }}
-          className="mx-auto"
+          onClick={addBodyPositionClick}
         >
-          <Button onClick={addBodyPositionClick} className="add-button" />
-        </Image>
+          <img
+            src={AppendViewIcon}
+            alt="Append View"
+            style={{ width: '50%', display: 'block' }}
+            className="mx-auto mt-2 mb-4 hover:opacity-80"
+          />
+        </div>
       )}
+
+      <AppendViewModal open={isAppendModalOpen} onCancel={handleModalClose} />
     </div>
   );
 };

+ 217 - 0
src/states/exam/appendViewSlice.ts

@@ -0,0 +1,217 @@
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { View, fetchViews } from '@/API/patient/viewActions';
+import {
+  appendBodyPositionToStudy,
+  AppendViewRequest,
+} from '@/API/exam/appendBodyPosition';
+import { ExtendedBodyPosition, addBodyPosition } from './bodyPositionListSlice';
+import { fetchViewDetail } from '@/API/patient/viewActions';
+import { dview } from '@/domain/dview';
+import { Work } from './examWorksCacheSlice';
+
+interface AppendViewState {
+  availableViews: View[];
+  selectedViews: View[];
+  isModalOpen: boolean;
+  loading: boolean;
+  error: string | null;
+}
+
+const initialState: AppendViewState = {
+  availableViews: [],
+  selectedViews: [],
+  isModalOpen: false,
+  loading: false,
+  error: null,
+};
+
+/**
+ * 获取可用体位列表
+ */
+export const fetchAvailableViewsThunk = createAsyncThunk(
+  'appendView/fetchAvailableViews',
+  async (
+    params: {
+      patient_type: string;
+      body_part_id: string;
+    },
+    { rejectWithValue }
+  ) => {
+    try {
+      const response = await fetchViews(
+        params.patient_type,
+        params.body_part_id,
+        true,
+        null
+      );
+      return response.data.views;
+    } catch (error) {
+      return rejectWithValue(
+        error.message || 'Failed to fetch available views'
+      );
+    }
+  }
+);
+
+/**
+ * 追加体位到Study并更新bodyPositionList
+ */
+export const appendViewsThunk = createAsyncThunk(
+  'appendView/appendViews',
+  async (
+    params: {
+      study_id: string;
+      views: View[];
+      currentWork: Work;
+    },
+    { dispatch, rejectWithValue }
+  ) => {
+    try {
+      // 1. 构建请求数据
+      const request: AppendViewRequest = {
+        study_id: params.study_id,
+        views: params.views.map((view) => ({
+          view_id: view.view_id,
+          procedure_id: view.procedure_id || '',
+        })),
+      };
+
+      // 2. 调用API追加体位
+      const response = await appendBodyPositionToStudy(request);
+
+      // 3. 将返回的 Series.images 转换为 ExtendedBodyPosition
+      const newBodyPositions: ExtendedBodyPosition[] = [];
+
+      for (const series of response.data.series) {
+        for (const image of series.images) {
+          // 获取完整的 view 详情
+          const viewDetail = await fetchViewDetail(image.view_id);
+
+          const dviewData: dview = {
+            view_id: image.view_id,
+            series_instance_uid: image.series_instance_uid,
+            study_instance_uid: image.study_instance_uid,
+            study_id: image.study_id,
+            procedure_id: series.procedure_id,
+            view_description: image.view_description,
+            view_type: image.view_id,
+            PrimarySopUID: image.sop_instance_uid,
+            thumbnail_file: image.image_file_path,
+            image_file: image.image_file_path,
+            image_file_path: image.image_file_path,
+            expose_status: 'Unexposed',
+          };
+
+          const extendedBodyPosition: ExtendedBodyPosition = {
+            ...viewDetail,
+            collimator_length:
+              viewDetail.config_object.DX?.CollimatorSizeLength ||
+              viewDetail.config_object?.Common?.CollimatorSizeLength ||
+              0,
+            collimator_width:
+              viewDetail.config_object.DX?.CollimatorSizeWidth ||
+              viewDetail.config_object?.Common?.CollimatorSizeWidth ||
+              0,
+            sid: '',
+            patient_name: params.currentWork.PatientName,
+            patient_id: params.currentWork.PatientID,
+            registration_number: params.currentWork.AccessionNumber,
+            study_description: params.currentWork.StudyDescription,
+            body_position_image: viewDetail.view_icon_name,
+            work: params.currentWork,
+            study_instance_uid: params.currentWork.StudyInstanceUID,
+            sop_instance_uid: image.sop_instance_uid,
+            series_instance_uid: image.series_instance_uid,
+            secondary_sop_uid: image.secondary_sop_uid,
+            study_id: image.study_id,
+            dview: dviewData,
+          };
+
+          newBodyPositions.push(extendedBodyPosition);
+        }
+      }
+
+      // 4. 将新体位添加到 bodyPositionList
+      for (const bodyPosition of newBodyPositions) {
+        dispatch(addBodyPosition(bodyPosition));
+      }
+
+      return newBodyPositions;
+    } catch (error) {
+      return rejectWithValue(
+        error.message || 'Failed to append body positions'
+      );
+    }
+  }
+);
+
+const appendViewSlice = createSlice({
+  name: 'appendView',
+  initialState,
+  reducers: {
+    setModalOpen: (state, action: PayloadAction<boolean>) => {
+      state.isModalOpen = action.payload;
+      if (!action.payload) {
+        // 关闭模态框时清空选中
+        state.selectedViews = [];
+      }
+    },
+    toggleViewSelection: (state, action: PayloadAction<View>) => {
+      const index = state.selectedViews.findIndex(
+        (v) => v.view_id === action.payload.view_id
+      );
+      if (index >= 0) {
+        // 已选中,取消选中
+        state.selectedViews.splice(index, 1);
+      } else {
+        // 未选中,添加到选中列表
+        state.selectedViews.push(action.payload);
+      }
+    },
+    clearSelectedViews: (state) => {
+      state.selectedViews = [];
+    },
+    clearError: (state) => {
+      state.error = null;
+    },
+  },
+  extraReducers: (builder) => {
+    builder
+      // fetchAvailableViewsThunk
+      .addCase(fetchAvailableViewsThunk.pending, (state) => {
+        state.loading = true;
+        state.error = null;
+      })
+      .addCase(fetchAvailableViewsThunk.fulfilled, (state, action) => {
+        state.loading = false;
+        state.availableViews = action.payload;
+      })
+      .addCase(fetchAvailableViewsThunk.rejected, (state, action) => {
+        state.loading = false;
+        state.error = action.payload as string;
+      })
+      // appendViewsThunk
+      .addCase(appendViewsThunk.pending, (state) => {
+        state.loading = true;
+        state.error = null;
+      })
+      .addCase(appendViewsThunk.fulfilled, (state) => {
+        state.loading = false;
+        state.isModalOpen = false;
+        state.selectedViews = [];
+      })
+      .addCase(appendViewsThunk.rejected, (state, action) => {
+        state.loading = false;
+        state.error = action.payload as string;
+      });
+  },
+});
+
+export const {
+  setModalOpen,
+  toggleViewSelection,
+  clearSelectedViews,
+  clearError,
+} = appendViewSlice.actions;
+
+export default appendViewSlice.reducer;

+ 2 - 0
src/states/store.ts

@@ -11,6 +11,7 @@ import systemModeReducer from './systemModeSlice';
 import examWorksCacheReducer from './exam/examWorksCacheSlice';
 import bodyPositionListReducer from './exam/bodyPositionListSlice';
 import bodyPositionDetailReducer from './exam/bodyPositionDetailSlice';
+import appendViewReducer from './exam/appendViewSlice';
 import aprReducer from './exam/aprSlice';
 import bodyPositionListenerMiddleware from './exam/bodyPositionListener';
 import { aprMiddleware } from './exam/aprSlice';
@@ -70,6 +71,7 @@ const store = configureStore({
     examWorksCache: examWorksCacheReducer,
     bodyPositionList: bodyPositionListReducer,
     bodyPositionDetail: bodyPositionDetailReducer,
+    appendView: appendViewReducer,
     apr: aprReducer,
     functionArea: functionAreaReducer,
     measurementPanel: measurementPanelReducer,