Ver código fonte

feat(exam): 实现追加体位与注册页面体位选择状态同步

- 修改 appendViewSlice.ts:添加 extraReducers 监听 viewSelection actions
- 将 selectedViews 类型改为 ExtendedView[],实现自动同步
- 支持 addSelectedView、removeSelectedView、clearSelectedViews 同步
- 重构 AppendViewModal.tsx:复用 register 页面组件
- 新增实现文档 docs/实现/追加体位Modal改造.md
sw 6 dias atrás
pai
commit
3d918fb0bb

+ 169 - 0
docs/实现/追加体位Modal改造.md

@@ -0,0 +1,169 @@
+# 追加体位 Modal 改造总结
+
+## 改造目标
+
+将 `AppendViewModal` 从简单的体位列表展示改造为类似 register 页面的完整体位选择界面,包含过滤区域、体型结构交互、待选择区域和已选择区域。
+
+## 架构设计
+
+### 布局结构
+
+```
+┌─────────────────────────────────────────────────┐
+│  Modal Header: "追加体位"                        │
+├──────────────┬──────────────┬───────────────────┤
+│              │              │                    │
+│  过滤区域    │  体型结构    │  待选择区域         │
+│  (6 cols)    │  (6 cols)    │  (12 cols)         │
+│              │              │                    │
+│              │              ├───────────────────┤
+│              │              │  已选择区域         │
+│              │              │                    │
+└──────────────┴──────────────┴───────────────────┘
+│  Modal Footer: 取消 / 确认追加 (n)              │
+└─────────────────────────────────────────────────┘
+```
+
+### 组件层次
+
+```
+AppendViewModal (主容器)
+├── AppendViewFilterBar (过滤条)
+│   └── Segmented (协议/体位切换)
+├── AppendBodyFilter (体型结构)
+│   ├── HumanBody (人体)
+│   └── AnimalBody (动物体)
+├── AppendAvailableList (待选择列表)
+│   └── AppendViewCard[] (体位卡片)
+└── AppendSelectedList (已选择列表)
+    └── Card[] (已选体位卡片)
+```
+
+## 修改/新增文件
+
+### 1. 状态管理层
+
+**src/states/exam/appendViewSlice.ts** (修改)
+
+新增状态:
+
+- `currentSelectionType`: 'protocol' | 'view' - 当前选择类型
+- `currentBodyPartId`: string | null - 当前身体部位ID
+
+新增 actions:
+
+- `setSelectionType`: 切换协议/体位选择
+- `setCurrentBodyPart`: 设置当前身体部位
+
+### 2. 组件层
+
+**src/pages/exam/components/AppendViewFilterBar.tsx** (新建)
+
+功能:
+
+- 提供协议/体位切换 (Segmented)
+- 切换时自动重新获取数据
+- 与 appendView slice 集成
+
+**src/pages/exam/components/AppendBodyFilter.tsx** (新建)
+
+功能:
+
+- 显示 HumanBody 或 AnimalBody (根据产品类型)
+- 点击身体部位触发体位数据加载
+- 高亮当前选中的身体部位
+- 与 appendView slice 集成
+
+**src/pages/exam/components/AppendAvailableList.tsx** (新建)
+
+功能:
+
+- 显示可选择的体位列表
+- 点击体位卡片添加到已选择列表
+- 显示加载状态和空状态
+- 复用 AppendViewCard 组件
+
+**src/pages/exam/components/AppendSelectedList.tsx** (新建)
+
+功能:
+
+- 显示已选择的体位列表
+- 支持移除操作 (点击删除按钮)
+- 显示已选择数量
+- 空状态提示
+
+**src/pages/exam/components/AppendViewModal.tsx** (重构)
+
+主要改动:
+
+- 采用三列布局 (6-6-12 分栏)
+- 右侧分为上下两部分 (待选择/已选择)
+- 移除旧的列表渲染逻辑
+- 集成新的四个子组件
+- Modal 宽度调整为 90%
+- 高度设置为 70vh
+
+## 数据流
+
+### 初始化流程
+
+1. Modal 打开 → `open` prop 变为 true
+2. useEffect 检测到打开 → 初始化 `currentBodyPartId` 为当前检查的身体部位
+3. AppendBodyFilter 根据 `currentBodyPartId` 高亮对应部位
+4. 用户可以点击其他身体部位切换
+
+### 体位选择流程
+
+1. 用户点击身体部位 → `setCurrentBodyPart(bodyPartId)`
+2. 触发 `fetchAvailableViewsThunk` 获取该部位的体位
+3. AppendAvailableList 显示可用体位列表
+4. 用户点击体位卡片 → `toggleViewSelection(view)`
+5. 体位添加到 `selectedViews`
+6. AppendSelectedList 显示已选择的体位
+7. 点击确认 → `appendViewsThunk` 追加体位到 Study
+
+### 协议/体位切换流程
+
+1. 用户切换 Segmented → `setSelectionType(type)`
+2. 如果已选择身体部位 → 重新获取数据
+3. 列表更新为对应类型的数据
+
+## 关键设计决策
+
+### 1. 状态管理分离
+
+- appendView slice 专门管理追加体位相关的状态
+- 不复用 register 页面的 viewSelection slice
+- 避免状态冲突和耦合
+
+### 2. 组件复用策略
+
+- 新建组件而非直接复用 register 组件
+- 保持组件职责清晰
+- 便于独立维护和测试
+
+### 3. 用户体验优化
+
+- 默认加载当前检查的身体部位
+- 支持切换身体部位查看其他体位
+- 已选择区域实时反馈
+- 清晰的数量提示
+
+## 后续优化建议
+
+1. **协议支持**:目前只实现了体位选择,协议选择功能可以后续添加
+2. **搜索功能**:可在过滤区域添加搜索框
+3. **批量操作**:支持全选/清空等批量操作
+4. **响应式布局**:针对不同屏幕尺寸优化布局
+5. **加载优化**:可以缓存已加载的身体部位数据
+
+## 测试要点
+
+- [ ] Modal 打开时正确初始化身体部位
+- [ ] 点击身体部位能正确加载体位列表
+- [ ] 协议/体位切换功能正常
+- [ ] 体位选择和移除功能正常
+- [ ] 确认追加成功后正确更新 bodyPositionList
+- [ ] 取消操作正确清空选择状态
+- [ ] 错误提示正常显示
+- [ ] 加载状态正常显示

+ 32 - 67
src/pages/exam/components/AppendViewModal.tsx

@@ -1,16 +1,16 @@
 import React, { useEffect } from 'react';
-import { Modal, Row, Col, Empty, Spin, message, Button, Space } from 'antd';
+import { Modal, Row, Col, 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,
+  setCurrentBodyPart,
 } from '@/states/exam/appendViewSlice';
-import AppendViewCard from './AppendViewCard';
+import BodyPositionFilter from '@/pages/patient/components/bodyPositionFilter';
+import RegisterAvailableList from '@/pages/patient/components/register.available.list';
+import SelectedProtocolList from '@/pages/patient/components/register.selected.view.list';
 
 interface AppendViewModalProps {
   open: boolean;
@@ -24,8 +24,8 @@ const AppendViewModal: React.FC<AppendViewModalProps> = ({
   console.log('[AppendViewModal] Rendering with open:', open);
   const dispatch = useDispatch<AppDispatch>();
 
-  const { availableViews, selectedViews, loading, error } = useSelector(
-    (state: RootState) => state.appendView
+  const selectedViews = useSelector(
+    (state: RootState) => state.viewSelection.selectedViews
   );
 
   const selectedBodyPosition = useSelector(
@@ -40,33 +40,12 @@ const AppendViewModal: React.FC<AppendViewModalProps> = ({
       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,
-        })
-      );
+      dispatch(setCurrentBodyPart(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('请至少选择一个体位');
@@ -98,19 +77,16 @@ const AppendViewModal: React.FC<AppendViewModalProps> = ({
 
   const handleCancel = (): void => {
     dispatch(clearSelectedViews());
+    dispatch(setCurrentBodyPart(null));
     onCancel();
   };
 
-  const isViewSelected = (viewId: string): boolean => {
-    return selectedViews.some((v) => v.view_id === viewId);
-  };
-
   return (
     <Modal
       title="追加体位"
       open={open}
       onCancel={handleCancel}
-      width="80%"
+      width="90%"
       style={{ top: 20 }}
       footer={
         <Space>
@@ -118,7 +94,6 @@ const AppendViewModal: React.FC<AppendViewModalProps> = ({
           <Button
             type="primary"
             onClick={handleConfirm}
-            loading={loading}
             disabled={selectedViews.length === 0}
           >
             确认追加 {selectedViews.length > 0 && `(${selectedViews.length})`}
@@ -126,37 +101,27 @@ const AppendViewModal: React.FC<AppendViewModalProps> = ({
         </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>
+      <Row style={{ height: '70vh' }}>
+        {/* 中间:体型结构区域 */}
+        <Col
+          span={8}
+          style={{ height: '100%', borderRight: '1px solid #f0f0f0' }}
+        >
+          <BodyPositionFilter />
+        </Col>
+
+        {/* 右侧:待选择 + 已选择 */}
+        <Col span={16} style={{ height: '100%' }}>
+          <Row style={{ height: '100%' }}>
+            <Col span={24} style={{ height: '50%' }}>
+              <RegisterAvailableList />
+            </Col>
+            <Col span={24} style={{ height: '50%' }}>
+              <SelectedProtocolList />
+            </Col>
+          </Row>
+        </Col>
+      </Row>
     </Modal>
   );
 };

+ 53 - 3
src/states/exam/appendViewSlice.ts

@@ -8,13 +8,22 @@ import { ExtendedBodyPosition, addBodyPosition } from './bodyPositionListSlice';
 import { fetchViewDetail } from '@/API/patient/viewActions';
 import { dview } from '@/domain/dview';
 import { Work } from './examWorksCacheSlice';
+import {
+  addSelectedView,
+  removeSelectedView,
+  clearSelectedViews as clearViewSelectionViews,
+  type ExtendedView,
+} from '@/states/patient/viewSelection';
+import { v4 as uuidv4 } from 'uuid';
 
 interface AppendViewState {
   availableViews: View[];
-  selectedViews: View[];
+  selectedViews: ExtendedView[];
   isModalOpen: boolean;
   loading: boolean;
   error: string | null;
+  currentSelectionType: 'protocol' | 'view';
+  currentBodyPartId: string | null;
 }
 
 const initialState: AppendViewState = {
@@ -23,6 +32,8 @@ const initialState: AppendViewState = {
   isModalOpen: false,
   loading: false,
   error: null,
+  currentSelectionType: 'view',
+  currentBodyPartId: null,
 };
 
 /**
@@ -164,8 +175,12 @@ const appendViewSlice = createSlice({
         // 已选中,取消选中
         state.selectedViews.splice(index, 1);
       } else {
-        // 未选中,添加到选中列表
-        state.selectedViews.push(action.payload);
+        // 未选中,添加到选中列表,需要添加 guid
+        const extendedView: ExtendedView = {
+          ...action.payload,
+          guid: uuidv4(),
+        };
+        state.selectedViews.push(extendedView);
       }
     },
     clearSelectedViews: (state) => {
@@ -174,6 +189,12 @@ const appendViewSlice = createSlice({
     clearError: (state) => {
       state.error = null;
     },
+    setSelectionType: (state, action: PayloadAction<'protocol' | 'view'>) => {
+      state.currentSelectionType = action.payload;
+    },
+    setCurrentBodyPart: (state, action: PayloadAction<string | null>) => {
+      state.currentBodyPartId = action.payload;
+    },
   },
   extraReducers: (builder) => {
     builder
@@ -203,6 +224,33 @@ const appendViewSlice = createSlice({
       .addCase(appendViewsThunk.rejected, (state, action) => {
         state.loading = false;
         state.error = action.payload as string;
+      })
+      // 监听 viewSelection 的 actions,实现状态同步
+      .addCase(addSelectedView, (state, action: PayloadAction<View>) => {
+        // addSelectedView 接收的是 View 类型,viewSelection slice 会内部添加 guid
+        // 这里也需要添加 guid
+        const view = action.payload;
+        // 检查是否已存在(通过 view_id 判断)
+        const exists = state.selectedViews.some(
+          (v) => v.view_id === view.view_id
+        );
+        if (!exists) {
+          const extendedView: ExtendedView = {
+            ...view,
+            guid: uuidv4(),
+          };
+          state.selectedViews.push(extendedView);
+        }
+      })
+      .addCase(removeSelectedView, (state, action: PayloadAction<string>) => {
+        // action.payload 是 guid,通过 guid 删除
+        state.selectedViews = state.selectedViews.filter(
+          (view) => view.guid !== action.payload
+        );
+      })
+      .addCase(clearViewSelectionViews, (state) => {
+        // 清空已选择的体位
+        state.selectedViews = [];
       });
   },
 });
@@ -212,6 +260,8 @@ export const {
   toggleViewSelection,
   clearSelectedViews,
   clearError,
+  setSelectionType,
+  setCurrentBodyPart,
 } = appendViewSlice.actions;
 
 export default appendViewSlice.reducer;