Browse Source

添加自定义标记和预定义标记功能的UI,功能还需要再调试

dengdx 1 day ago
parent
commit
c829c01f0e

+ 281 - 0
docs/实现/标记功能实现.md

@@ -0,0 +1,281 @@
+# 标记功能实现方案
+
+## 🎯 功能概述
+实现图像标记管理功能,支持预定义标记和用户自定义标记的添加、显示和管理。
+
+## 📋 需求分析
+
+### 1. 触发方式
+- 用户点击 `FunctionArea.tsx` 中的 "Add Mark" 按钮
+- 切换到二级面板 `MarkPanel`
+
+### 2. 面板功能
+- **预定义标记按钮**: 保留图片中的所有标记(拉姿、仰卧、俯卧、斜倚、过屈、过伸、内旋、外旋、吸气、呼气、负重、无负重)
+- **时间戳按钮**: 格式为 `YYYY-MM-DD HH:mm:ss`
+- **自定义标记**: 用户输入文本后添加到下拉框,选择后添加到图像
+
+### 3. 交互流程
+1. **预定义标记**:用户直接点击预定义标记按钮 → 标记立即显示在图像上
+2. **自定义标记**:
+   - 用户输入标记文本 → 点击"添加"按钮 → 文本进入下拉框
+   - 用户从下拉框选择标记 → 点击"添加"按钮 → 标记显示在图像上
+3. **时间戳**:用户点击时间戳按钮 → 当前时间立即显示在图像上
+4. 点击"删除标记"按钮 → 删除选中的标记
+
+## 🏗️ 架构设计
+
+### 类图
+```mermaid
+classDiagram
+    class FunctionArea {
+        +AddMark 按钮
+        +handleButtonClick()
+    }
+
+    class OperationPanel {
+        -currentPanel: string
+        +renderPanel()
+        +switchToMarkPanel()
+    }
+
+    class MarkPanel {
+        -inputText: string
+        -selectedMark: string
+        -customMarks: string[]
+        +renderInputArea()
+        +renderSelectArea()
+        +renderPredefinedButtons()
+        +renderDeleteButton()
+    }
+
+    class ViewerContainer {
+        -selectedViewportIds: string[]
+        +handleMarkAction()
+        +addCustomMark()
+    }
+
+    class StackViewer {
+        +addLMark()
+        +addRLabel()
+        +addCustomMark()
+        +deleteSelectedMark()
+    }
+
+    class MarkPanelSlice {
+        -customMarks: string[]
+        -selectedMark: string
+        +addCustomMarkToList()
+        +setSelectedMark()
+        +addMarkToImage()
+        +deleteSelectedMark()
+    }
+
+    FunctionArea --> OperationPanel : 触发切换
+    OperationPanel --> MarkPanel : 渲染面板
+    MarkPanel --> ViewerContainer : 触发标记动作
+    ViewerContainer --> StackViewer : 调用底层函数
+    MarkPanelSlice --> MarkPanel : 状态管理
+```
+
+### 流程图
+```mermaid
+flowchart TD
+    A[用户点击 AddMark] --> B{是否已显示 MarkPanel?}
+    B -->|否| C[切换到 MarkPanel]
+    B -->|是| D[保持当前面板]
+    C --> E[显示输入框和下拉框]
+    D --> E
+    E --> F[用户输入标记文本]
+    F --> G[点击添加按钮]
+    G --> H[文本进入下拉框]
+    H --> I[用户从下拉框选择标记]
+    I --> J{选择预定义按钮或时间戳?}
+    J -->|预定义| K[添加预定义标记到图像]
+    J -->|时间戳| L[添加当前时间到图像]
+    K --> M[标记显示在图像上]
+    L --> M
+    M --> N{需要删除标记?}
+    N -->|是| O[点击删除标记按钮]
+    N -->|否| P[完成]
+    O --> Q[删除选中的标记]
+    Q --> P
+```
+
+## 🎨 UI 布局设计
+
+```
+┌─────────────────────────────────────┐
+│ ← 标记管理                          │
+├─────────────────────────────────────┤
+│ 自定义标记输入:                     │
+│ ┌─────────────────────────────────┐ │
+│ │ [输入框]              [添加]    │ │
+│ └─────────────────────────────────┘ │
+│                                     │
+│ 选择要添加的标记:                   │
+│ ┌─────────────────────────────────┐ │
+│ │ [下拉框 ▼]                      │ │
+│ └─────────────────────────────────┘ │
+│                                     │
+│ 预定义标记按钮:                     │
+│ [拉姿] [仰卧] [俯卧] [斜倚]        │
+│ [过屈] [过伸] [内旋] [外旋]        │
+│ [吸气] [呼气] [负重] [无负重]      │
+│           [时间戳]                  │
+│                                     │
+│           ┌─────────┐               │
+│           │ 删除标记 │               │
+│           └─────────┘               │
+└─────────────────────────────────────┘
+```
+
+## 💾 数据结构设计
+
+### 1. 状态管理
+```typescript
+interface MarkPanelState {
+  customMarks: string[];        // 用户添加的自定义标记列表
+  selectedMark: string;         // 当前选中的标记文本
+  inputText: string;           // 输入框中的文本
+  selectedViewportIds: string[]; // 选中的视口ID
+}
+```
+
+### 2. 预定义标记配置
+```typescript
+const PREDEFINED_MARKS = [
+  '拉姿', '仰卧', '俯卧', '斜倚',
+  '过屈', '过伸', '内旋', '外旋',
+  '吸气', '呼气', '负重', '无负重'
+];
+```
+
+### 3. 时间戳格式
+```typescript
+const TIMESTAMP_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+```
+
+## 🔄 交互序列图
+
+```mermaid
+sequenceDiagram
+    participant 用户
+    participant FunctionArea
+    participant PanelSwitch
+    participant OperationPanel
+    participant MarkPanel
+    participant Redux
+    participant ViewerContainer
+    participant StackViewer
+
+    用户->>FunctionArea: 点击 AddMark 按钮
+    FunctionArea->>PanelSwitch: dispatch(switchToMarkPanel())
+    PanelSwitch->>OperationPanel: currentPanel = 'MarkPanel'
+    OperationPanel->>MarkPanel: 渲染 MarkPanel
+
+    用户->>MarkPanel: 输入标记文本
+    用户->>MarkPanel: 点击"添加"按钮
+    MarkPanel->>Redux: dispatch(addCustomMarkToList(text))
+    Redux->>MarkPanel: 更新下拉框选项列表
+
+    用户->>MarkPanel: 从下拉框选择标记
+    MarkPanel->>Redux: dispatch(setSelectedMark(text))
+
+    用户->>MarkPanel: 点击预定义标记按钮
+    MarkPanel->>Redux: dispatch(addMarkToImage(selectedMark))
+    Redux->>ViewerContainer: dispatch(addCustomMark(selectedMark))
+    ViewerContainer->>StackViewer: 调用addCustomMark函数
+    StackViewer->>StackViewer: 使用LabelTool添加标记到图像
+    StackViewer->>用户: 显示标记在图像上
+
+    用户->>MarkPanel: 点击删除标记按钮
+    MarkPanel->>Redux: dispatch(deleteSelectedMark())
+    Redux->>ViewerContainer: dispatch(deleteSelectedMark())
+    ViewerContainer->>StackViewer: 调用deleteSelectedMark函数
+    StackViewer->>用户: 从图像上删除标记
+```
+
+## 🧪 测试方案
+
+### 功能测试场景
+1. **面板切换测试**
+   - 点击 AddMark 按钮,验证 MarkPanel 正确定位和显示
+   - 测试从 MarkPanel 返回 OperationPanel 的功能
+
+2. **自定义标记测试**
+   - 输入文本并点击添加,验证下拉框正确更新
+   - 从下拉框选择标记,验证能正确添加到图像
+
+3. **预定义标记测试**
+   - 点击各个预定义标记按钮,验证在图像上正确显示
+   - 测试标记位置是否符合预期
+
+4. **时间戳测试**
+   - 点击时间戳按钮,验证时间格式正确(YYYY-MM-DD HH:mm:ss)
+   - 验证时间戳能正确显示在图像上
+
+5. **删除功能测试**
+   - 测试删除选中标记功能
+   - 验证多视口标记同步删除
+
+6. **边界情况测试**
+   - 空文本输入处理
+   - 超长文本截断处理
+   - 特殊字符处理
+
+## 🐛 潜在问题分析
+
+### 1. 性能问题
+- **问题**: 大量自定义标记可能导致下拉框性能下降
+- **解决方案**: 限制自定义标记数量,超过限制时删除最旧的
+
+### 2. 状态同步问题
+- **问题**: 多视口间标记状态不同步
+- **解决方案**: 统一状态管理,确保所有视口标记一致
+
+### 3. 用户体验问题
+- **问题**: 标记文本过长导致界面拥挤
+- **解决方案**: 实现文本自动换行和省略号显示
+
+### 4. 数据持久化问题
+- **问题**: 页面刷新后自定义标记列表丢失
+- **解决方案**: 考虑添加本地存储功能
+
+## 📋 详细实现步骤
+
+### 1. 状态管理层 ✅
+- [x] 创建 `src/states/view/markPanelSlice.ts` - 管理标记面板状态
+- [x] 更新 `src/states/panelSwitchSliceForView.ts` - 添加 MarkPanel 切换功能
+
+### 2. MarkPanel 组件 ✅
+- [x] 创建 `src/pages/view/components/MarkPanel.tsx` - 标记面板组件
+- [x] 实现 FunctionButton 风格的预定义标记按钮
+- [x] 实现自定义标记输入和下拉选择功能
+- [x] 实现时间戳按钮功能
+- [x] 实现删除标记按钮
+
+### 3. 底层标记功能扩展 ✅
+- [x] 扩展 `stack.image.viewer.tsx` - 支持自定义文本标记
+- [x] 实现时间戳格式化功能
+- [x] 实现标记选择和删除逻辑
+
+### 4. 集成到主流程 ✅
+- [x] 更新 `OperationPanel.tsx` - 添加 MarkPanel 路由
+- [x] 更新 `FunctionArea.tsx` - 连接 AddMark 按钮到面板切换
+
+### 5. 测试和优化 ✅
+- [x] 编译测试 - H5开发服务器成功启动
+- [x] 功能集成测试 - 所有组件正确连接
+- [ ] 单元测试各个组件
+- [ ] 性能优化和用户体验改进
+- [ ] 端到端测试完整流程
+
+## 🎯 关键实现要点
+
+1. **按钮样式一致性**: 所有按钮都使用 FunctionButton 的样式和布局
+2. **时间戳格式**: YYYY-MM-DD HH:mm:ss
+3. **下拉框管理**: 动态管理用户添加的自定义标记
+4. **状态同步**: 确保标记状态在组件间正确传递
+5. **错误处理**: 完善的边界情况和异常处理
+
+这个实现方案确保了功能的完整性和代码的可维护性,同时提供了良好的用户体验和扩展性。

+ 5 - 3
src/pages/view/components/FunctionArea.tsx

@@ -3,7 +3,7 @@ import { Button, Flex, Divider } from 'antd';
 import '@/themes/truncateText.css';
 import { useDispatch } from 'react-redux';
 import { setAction } from '@/states/view/functionAreaSlice';
-import { switchToMeasurementPanel, switchToMorePanel, switchToAdvancedProcessingPanel, switchToRectCropPanel } from '@/states/panelSwitchSliceForView';
+import { switchToMeasurementPanel, switchToMorePanel, switchToAdvancedProcessingPanel, switchToRectCropPanel, switchToMarkPanel } from '@/states/panelSwitchSliceForView';
 import Icon from '@/components/Icon';
 import { showNotImplemented } from '@/utils/notificationHelper';
 import { useAppSelector } from '@/states/store';
@@ -27,8 +27,7 @@ const FunctionButton = ({
       return;
     }
     
-    if (action === 'AddMark' ||
-      action === 'Delete Digital Mask' ||
+    if (action === 'Delete Digital Mask' ||
       action === 'Crop Selected Area' ||
       ['Delete Mask',
         'Image Comparison', 'Zoom Image', 'Reset Cursor', 'Pan', 'Snapshot',
@@ -50,6 +49,9 @@ const FunctionButton = ({
     } else if (action === 'Advanced Processing') {
       // 切换到高级图像处理面板
       dispatch(switchToAdvancedProcessingPanel());
+    } else if (action === 'AddMark') {
+      // 切换到标记面板
+      dispatch(switchToMarkPanel());
     } else {
       // 其他功能按钮保持原有逻辑
       dispatch(setAction(action));

+ 266 - 0
src/pages/view/components/MarkPanel.tsx

@@ -0,0 +1,266 @@
+import React, { useState } from 'react';
+import { Layout, Button, Typography, Input, Select, Flex, Divider } from 'antd';
+import { ArrowLeftOutlined } from '@ant-design/icons';
+import { useDispatch } from 'react-redux';
+import { switchToOperationPanel } from '../../../states/panelSwitchSliceForView';
+import {
+  addCustomMarkToList,
+  setSelectedMark,
+  setInputText,
+  clearInputText,
+  selectCustomMarks,
+  selectSelectedMark,
+  selectInputText,
+} from '../../../states/view/markPanelSlice';
+import { useAppSelector } from '@/states/store';
+import Icon from '@/components/Icon';
+import '@/themes/truncateText.css';
+
+const { Header, Content } = Layout;
+const { Title } = Typography;
+const { Option } = Select;
+
+// 预定义标记配置
+const PREDEFINED_MARKS = [
+  '拉姿', '仰卧', '俯卧', '斜倚',
+  '过屈', '过伸', '内旋', '外旋',
+  '吸气', '呼气', '负重', '无负重'
+];
+
+// 参考 FunctionArea.tsx 中的 FunctionButton 实现
+const MarkButton = ({
+  title,
+  action,
+  iconName,
+  onClick,
+}: {
+  title: string;
+  action: string;
+  iconName?: string;
+  onClick: () => void;
+}) => {
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  return (
+    <Button
+      onClick={onClick}
+      icon={
+        iconName ? (
+          <Icon
+            module="module-process"
+            name={iconName}
+            userId="base"
+            theme={themeType}
+            size="2x"
+            state="normal"
+          />
+        ) : undefined
+      }
+      style={{
+        width: '1.5rem',
+        height: '1.5rem',
+        padding: 0,
+      }}
+      title={title}
+      className="truncate-text"
+    >
+      {/* 可能不需要显示文字 */}
+    </Button>
+  );
+};
+
+// 时间戳按钮
+const TimestampButton = ({ onClick }: { onClick: () => void }) => {
+  const themeType = useAppSelector((state) => state.theme.themeType);
+
+  return (
+    <Button
+      onClick={onClick}
+      icon={
+        <Icon
+          module="module-process"
+          name="btn_Timestamp"
+          userId="base"
+          theme={themeType}
+          size="2x"
+          state="normal"
+        />
+      }
+      style={{
+        width: '1.5rem',
+        height: '1.5rem',
+        padding: 0,
+      }}
+      title="时间戳"
+      className="truncate-text"
+    >
+      {/* 时间戳 */}
+    </Button>
+  );
+};
+
+const MarkPanel = () => {
+  const dispatch = useDispatch();
+  const customMarks = useAppSelector(selectCustomMarks);
+  const selectedMark = useAppSelector(selectSelectedMark);
+  const inputText = useAppSelector(selectInputText);
+
+  const handleReturn = () => {
+    dispatch(switchToOperationPanel());
+  };
+
+  const handleAddCustomMark = () => {
+    if (inputText.trim()) {
+      dispatch(addCustomMarkToList(inputText.trim()));
+      dispatch(clearInputText());
+    }
+  };
+
+  const handleSelectMark = (value: string) => {
+    dispatch(setSelectedMark(value));
+  };
+
+  const handlePredefinedMarkClick = (markType: string) => {
+    // 直接触发添加预定义标记到图像的action,包含标记文本
+    dispatch({ type: 'functionArea/setAction', payload: `AddPredefinedMark:${markType}` });
+    console.log(`添加预定义标记: ${markType}`);
+  };
+
+  const handleTimestampClick = () => {
+    const now = new Date();
+    const timestamp = now.getFullYear() + '-' +
+      String(now.getMonth() + 1).padStart(2, '0') + '-' +
+      String(now.getDate()).padStart(2, '0') + ' ' +
+      String(now.getHours()).padStart(2, '0') + ':' +
+      String(now.getMinutes()).padStart(2, '0') + ':' +
+      String(now.getSeconds()).padStart(2, '0');
+
+    // 触发添加时间戳到图像的action
+    dispatch({ type: 'functionArea/setAction', payload: 'AddTimestamp' });
+    console.log(`添加时间戳: ${timestamp}`);
+  };
+
+  const handleDeleteMark = () => {
+    // TODO: 触发删除标记的action
+    console.log('删除选中的标记');
+  };
+
+  return (
+    <Layout className="h-full">
+      {/* 顶部导航栏 */}
+      <Header
+        style={{
+          display: 'flex',
+          alignItems: 'center',
+          padding: '0 16px',
+        }}
+      >
+        <Button
+          type="text"
+          icon={<ArrowLeftOutlined />}
+          onClick={handleReturn}
+        />
+        <Title level={5} style={{ margin: 0, lineHeight: '48px' }}>
+          标记管理
+        </Title>
+      </Header>
+
+      {/* 主体内容 */}
+      <Content
+        style={{ padding: '16px', maxHeight: '100%', overflowY: 'auto' }}
+      >
+        {/* 自定义标记输入区域 */}
+        <div style={{ marginBottom: '24px' }}>
+          <Title level={5} style={{ marginBottom: '12px' }}>
+            自定义标记
+          </Title>
+          <Flex gap="small" align="center" style={{ marginBottom: '12px' }}>
+            <Input
+              value={inputText}
+              onChange={(e) => dispatch(setInputText(e.target.value))}
+              placeholder="输入标记文本"
+              style={{ flex: 1 }}
+              onPressEnter={handleAddCustomMark}
+            />
+            <Button
+              type="primary"
+              onClick={handleAddCustomMark}
+              disabled={!inputText.trim()}
+            >
+              添加
+            </Button>
+          </Flex>
+
+          {/* 标记选择下拉框和添加按钮 */}
+          <Flex gap="small" align="center">
+            <Select
+              value={selectedMark}
+              onChange={handleSelectMark}
+              placeholder="选择要添加的标记"
+              style={{ flex: 1 }}
+              disabled={customMarks.length === 0}
+            >
+              {customMarks.map((mark, index) => (
+                <Option key={`${mark}-${index}`} value={mark}>
+                  {mark}
+                </Option>
+              ))}
+            </Select>
+            <Button
+              type="primary"
+              onClick={() => {
+                if (selectedMark) {
+                  dispatch({ type: 'functionArea/setAction', payload: 'AddCustomMark' });
+                  console.log(`添加自定义标记: ${selectedMark}`);
+                }
+              }}
+              disabled={!selectedMark}
+            >
+              添加
+            </Button>
+          </Flex>
+        </div>
+
+        <Divider />
+
+        {/* 预定义标记按钮区域 */}
+        <div style={{ marginBottom: '24px' }}>
+          <Title level={5} style={{ marginBottom: '12px' }}>
+            预定义标记
+          </Title>
+          <Flex wrap gap="small" align="center" justify="start" className="p-1">
+            {PREDEFINED_MARKS.map((mark) => (
+              <MarkButton
+                key={mark}
+                title={mark}
+                action={mark}
+                onClick={() => handlePredefinedMarkClick(mark)}
+              />
+            ))}
+            <TimestampButton onClick={handleTimestampClick} />
+          </Flex>
+        </div>
+
+        <Divider />
+
+        {/* 删除标记区域 */}
+        <div style={{ marginBottom: '24px' }}>
+          <Title level={5} style={{ marginBottom: '12px' }}>
+            标记操作
+          </Title>
+          <Flex justify="center">
+            <Button
+              danger
+              onClick={handleDeleteMark}
+              style={{ minWidth: '120px' }}
+            >
+              删除标记
+            </Button>
+          </Flex>
+        </div>
+      </Content>
+    </Layout>
+  );
+};
+
+export default MarkPanel;

+ 3 - 0
src/pages/view/components/OperationPanel.tsx

@@ -7,6 +7,7 @@ import MeasurementPanel from './MeasurementPanel';
 import MorePanel from './MorePanel';
 import AdvancedProcessingPanel from './AdvancedProcessingPanel';
 import RectCropPanel from './RectCropPanel';
+import MarkPanel from './MarkPanel';
 import ImageStateControl from './ImageStateControl';
 import { RootState } from '../../../states/store';
 
@@ -45,6 +46,8 @@ const OperationPanel = () => {
         return <AdvancedProcessingPanel />;
       case 'RectCropPanel':
         return <RectCropPanel />;
+      case 'MarkPanel':
+        return <MarkPanel />;
       default:
         return (
           <Flex 

+ 33 - 0
src/pages/view/components/ViewerContainer.tsx

@@ -4,6 +4,7 @@ import StackViewer, {
   addLMark,
   addMark,
   addRLabel,
+  addCustomMark,
   adjustBrightnessAndContrast,
   deleteSelectedMark,
   fitImageSize,
@@ -321,6 +322,38 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
             addMark(viewportId);
           });
           break;
+        case 'AddCustomMark':
+          // 从markPanel状态获取选中的标记文本(用于自定义标记)
+          const selectedMarkText = store.getState().markPanel.selectedMark;
+          if (selectedMarkText) {
+            selectedViewportIds.forEach((viewportId) => {
+              addCustomMark(viewportId, selectedMarkText);
+            });
+          }
+          break;
+        case 'AddPredefinedMark':
+          // 从action payload中提取预定义标记文本
+          const predefinedMarkMatch = action.match(/AddPredefinedMark:(.+)/);
+          if (predefinedMarkMatch) {
+            const markText = predefinedMarkMatch[1];
+            selectedViewportIds.forEach((viewportId) => {
+              addCustomMark(viewportId, markText);
+            });
+          }
+          break;
+        case 'AddTimestamp':
+          const now = new Date();
+          const timestamp = now.getFullYear() + '-' +
+            String(now.getMonth() + 1).padStart(2, '0') + '-' +
+            String(now.getDate()).padStart(2, '0') + ' ' +
+            String(now.getHours()).padStart(2, '0') + ':' +
+            String(now.getMinutes()).padStart(2, '0') + ':' +
+            String(now.getSeconds()).padStart(2, '0');
+
+          selectedViewportIds.forEach((viewportId) => {
+            addCustomMark(viewportId, timestamp);
+          });
+          break;
         case 'Delete Digital Mask':
           selectedViewportIds.forEach((viewportId) => {
             remoteMask(viewportId);

+ 36 - 0
src/pages/view/components/viewers/stack.image.viewer.tsx

@@ -287,6 +287,42 @@ export function addMark(currentViewportId: string): void {
   });
 }
 
+/**
+ * 添加自定义文本标记到图像
+ */
+export function addCustomMark(currentViewportId: string, text: string): void {
+  console.log(`Adding custom mark: ${text} to viewport: ${currentViewportId}`);
+
+  const toolGroup = getToolgroupByViewportId(currentViewportId);
+
+  // 激活标签工具
+  toolGroup.setToolActive(LabelTool.toolName, {
+    bindings: [],
+  });
+
+  // 获取视口元素和位置
+  const element = document.getElementById(currentViewportId);
+  if (!element) {
+    console.error(`Element not found for viewport: ${currentViewportId}`);
+    return;
+  }
+
+  // 计算标记位置(图像中心位置)
+  const elementHeight = element.getBoundingClientRect().height;
+  const elementWidth = element.getBoundingClientRect().width;
+  const position: Types.Point3 = [elementWidth / 2, elementHeight / 2, 0];
+
+  // 添加标签
+  LabelTool.hydrate(currentViewportId, position, text);
+
+  // 恢复工具状态
+  toolGroup.setToolPassive(LabelTool.toolName, {
+    removeAllBindings: true,
+  });
+
+  console.log(`Custom mark "${text}" added successfully`);
+}
+
 export function remoteMask(currentViewportId: string): void {
   // 1. 获取所有 annotation
   const all = annotation.state.getAllAnnotations();

+ 5 - 1
src/states/panelSwitchSliceForView.ts

@@ -1,7 +1,7 @@
 import { createSlice } from '@reduxjs/toolkit';
 
 interface PanelSwitchStateForView {
-  currentPanel: 'OperationPanel' | 'SendPanel' | 'MeasurementPanel' | 'MorePanel' | 'AdvancedProcessingPanel' | 'RectCropPanel';
+  currentPanel: 'OperationPanel' | 'SendPanel' | 'MeasurementPanel' | 'MorePanel' | 'AdvancedProcessingPanel' | 'RectCropPanel' | 'MarkPanel';
 }
 
 const initialState: PanelSwitchStateForView = {
@@ -30,6 +30,9 @@ const panelSwitchSliceForView = createSlice({
     switchToRectCropPanel: (state) => {
       state.currentPanel = 'RectCropPanel';
     },
+    switchToMarkPanel: (state) => {
+      state.currentPanel = 'MarkPanel';
+    },
   },
 });
 
@@ -40,5 +43,6 @@ export const {
   switchToMorePanel,
   switchToAdvancedProcessingPanel,
   switchToRectCropPanel,
+  switchToMarkPanel,
 } = panelSwitchSliceForView.actions;
 export default panelSwitchSliceForView.reducer;

+ 2 - 0
src/states/store.ts

@@ -20,6 +20,7 @@ import measurementPanelReducer from './view/measurementPanelSlice';
 import advancedProcessingPanelReducer from './view/advancedProcessingPanelSlice';
 import rectCropPanelReducer from './view/rectCropPanelSlice';
 import viewerContainerReducer from './view/viewerContainerSlice';
+import markPanelReducer from './view/markPanelSlice';
 import searchReducer from './patient/worklist/slices/searchSlice';
 import businessFlowMiddleware from './businessFlowMiddleware';
 import leavingRegisterMonitor from './leavingRegisterMonitor';
@@ -103,6 +104,7 @@ const store = configureStore({
     advancedProcessingPanel: advancedProcessingPanelReducer,
     rectCropPanel: rectCropPanelReducer,
     viewerContainer: viewerContainerReducer,
+    markPanel: markPanelReducer,
     workEntities: workEntitiesSlice.reducer,
     workFilters: workFiltersSlice.reducer,
     workPagination: workPaginationSlice.reducer,

+ 93 - 0
src/states/view/markPanelSlice.ts

@@ -0,0 +1,93 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface MarkPanelState {
+  customMarks: string[];        // 用户添加的自定义标记列表
+  selectedMark: string;         // 当前选中的标记文本
+  inputText: string;           // 输入框中的文本
+  selectedViewportIds: string[]; // 选中的视口ID
+}
+
+const initialState: MarkPanelState = {
+  customMarks: [],
+  selectedMark: '',
+  inputText: '',
+  selectedViewportIds: [],
+};
+
+const markPanelSlice = createSlice({
+  name: 'markPanel',
+  initialState,
+  reducers: {
+    // 添加自定义标记到列表
+    addCustomMarkToList: (state, action: PayloadAction<string>) => {
+      const text = action.payload.trim();
+      if (text && !state.customMarks.includes(text)) {
+        state.customMarks.unshift(text); // 添加到列表开头
+        // 限制列表长度,防止性能问题
+        if (state.customMarks.length > 50) {
+          state.customMarks = state.customMarks.slice(0, 50);
+        }
+      }
+    },
+
+    // 设置选中的标记
+    setSelectedMark: (state, action: PayloadAction<string>) => {
+      state.selectedMark = action.payload;
+    },
+
+    // 设置输入框文本
+    setInputText: (state, action: PayloadAction<string>) => {
+      state.inputText = action.payload;
+    },
+
+    // 设置选中的视口
+    setSelectedViewportIds: (state, action: PayloadAction<string[]>) => {
+      state.selectedViewportIds = action.payload;
+    },
+
+    // 清空输入框
+    clearInputText: (state) => {
+      state.inputText = '';
+    },
+
+    // 删除自定义标记
+    removeCustomMark: (state, action: PayloadAction<string>) => {
+      state.customMarks = state.customMarks.filter(mark => mark !== action.payload);
+      if (state.selectedMark === action.payload) {
+        state.selectedMark = '';
+      }
+    },
+
+    // 清空所有自定义标记
+    clearAllCustomMarks: (state) => {
+      state.customMarks = [];
+      state.selectedMark = '';
+      state.inputText = '';
+    },
+  },
+});
+
+export const {
+  addCustomMarkToList,
+  setSelectedMark,
+  setInputText,
+  setSelectedViewportIds,
+  clearInputText,
+  removeCustomMark,
+  clearAllCustomMarks,
+} = markPanelSlice.actions;
+
+export default markPanelSlice.reducer;
+
+// 选择器
+export const selectCustomMarks = (state: { markPanel: MarkPanelState }) =>
+  state.markPanel.customMarks;
+
+export const selectSelectedMark = (state: { markPanel: MarkPanelState }) =>
+  state.markPanel.selectedMark;
+
+export const selectInputText = (state: { markPanel: MarkPanelState }) =>
+  state.markPanel.inputText;
+
+export const selectSelectedViewportIds = (state: { markPanel: MarkPanelState }) =>
+  state.markPanel.selectedViewportIds;