Bläddra i källkod

feat(report): initially add implementation for diagnostic reports (unfinished)

sw 3 veckor sedan
förälder
incheckning
7f12102d73
28 ändrade filer med 928 tillägg och 0 borttagningar
  1. 57 0
      src/API/patient/DiagnosisReportActions.ts
  2. 183 0
      src/pages/patient/DiagnosticReport/components/BaseInfo.tsx
  3. 34 0
      src/pages/patient/DiagnosticReport/components/DiagnosisSection.tsx
  4. 22 0
      src/pages/patient/DiagnosticReport/components/DiagnosticReportImageViewer.tsx
  5. 35 0
      src/pages/patient/DiagnosticReport/components/FindingsSection.tsx
  6. 42 0
      src/pages/patient/DiagnosticReport/components/ImageList.tsx
  7. 46 0
      src/pages/patient/DiagnosticReport/components/ImageSelection.tsx
  8. 23 0
      src/pages/patient/DiagnosticReport/components/MainContent.tsx
  9. 24 0
      src/pages/patient/DiagnosticReport/components/ReportFooter.tsx
  10. 5 0
      src/pages/patient/DiagnosticReport/components/ReportHeader.tsx
  11. 19 0
      src/pages/patient/DiagnosticReport/components/ReportMain.tsx
  12. 15 0
      src/pages/patient/DiagnosticReport/components/SidePanel.tsx
  13. 2 0
      src/pages/patient/DiagnosticReport/components/StudyFilter.tsx
  14. 2 0
      src/pages/patient/DiagnosticReport/components/TemplatePanel.tsx
  15. 24 0
      src/pages/patient/DiagnosticReport/index.tsx
  16. 37 0
      src/pages/patient/components/ActionPanel.tsx
  17. 80 0
      src/states/patient/DiagnosticReport/baseInfoSlice.ts
  18. 22 0
      src/states/patient/DiagnosticReport/diagnosisSlice.ts
  19. 20 0
      src/states/patient/DiagnosticReport/findingsSlice.ts
  20. 15 0
      src/states/patient/DiagnosticReport/headerSlice.ts
  21. 25 0
      src/states/patient/DiagnosticReport/imageListSlice.ts
  22. 40 0
      src/states/patient/DiagnosticReport/imageSelectionSlice.ts
  23. 15 0
      src/states/patient/DiagnosticReport/previewReportThunk.ts
  24. 31 0
      src/states/patient/DiagnosticReport/saveReportThunk.ts
  25. 62 0
      src/states/patient/DiagnosticReport/slice.ts
  26. 13 0
      src/states/patient/DiagnosticReport/studyFilterSlice.ts
  27. 14 0
      src/states/patient/DiagnosticReport/templateSlice.ts
  28. 21 0
      src/states/store.ts

+ 57 - 0
src/API/patient/DiagnosisReportActions.ts

@@ -0,0 +1,57 @@
+/* eslint-disable */
+import axiosInstance from '../interceptor';
+
+interface DiagnosisReport {
+  headers: {
+    name: string;
+    sex: string;
+    age: string;
+    medical_record_number: string;
+    hospitalization_number: string;
+    bed_number: string;
+    requesting_department: string;
+    inspection_number: string;
+    inspection_method: string;
+  };
+  findings: string;
+  impression: string;
+  radiologist: string;
+  review_physician: string;
+}
+
+interface DiagnosisReportResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: {};// eslint-disable-line @typescript-eslint/ban-types
+}
+
+export const saveDiagnosisReport = async (
+  studyId: string,
+  report: DiagnosisReport
+): Promise<DiagnosisReportResponse> => {
+  const response = await axiosInstance.post(
+    `/auth/study/${studyId}/report`,
+    report
+  );
+  return response.data;
+};
+
+export const previewDiagnosisReport = async (
+  report: DiagnosisReport
+): Promise<DiagnosisReportResponse> => {
+  const response = await axiosInstance.post(
+    `/auth/report/preview`,
+    report
+  );
+  return response.data;
+};
+
+export const fetchDiagnosisReport = async (
+  studyId: string
+): Promise<DiagnosisReportResponse> => {
+  const response = await axiosInstance.get(
+    `/auth/study/${studyId}/report`
+  );
+  return response.data;
+};

+ 183 - 0
src/pages/patient/DiagnosticReport/components/BaseInfo.tsx

@@ -0,0 +1,183 @@
+import React from 'react';
+import { Row, Col, Input, Select, DatePicker, Button, Form } from 'antd';
+import { RootState, useAppDispatch } from '@/states/store';
+import {
+  updateField,
+  updateDropdown,
+  BaseInfoState,
+} from '@/states/patient/DiagnosticReport/baseInfoSlice';
+import dayjs from 'dayjs';
+import { useSelector } from 'react-redux';
+
+const { Option } = Select;
+
+export const BaseInfo: React.FC = () => {
+  const data = useSelector((s: RootState) => s.baseInfo);
+  const dispatch = useAppDispatch();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const onChange = (key: keyof BaseInfoState, val: any) => {
+    dispatch(updateField({ key, value: val }));
+  };
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const onDropdownChange = (key: keyof BaseInfoState, val: any) => {
+    dispatch(updateDropdown({ key, value: val }));
+  };
+
+  return (
+    <Form layout="vertical">
+      <Row gutter={12}>
+        <Col span={6}>
+          <Form.Item label="曾用名">
+            <Input
+              value={data.usedName}
+              onChange={(e) => onChange('usedName', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="英文名">
+            <Input
+              value={data.englishName}
+              onChange={(e) => onChange('englishName', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="患者编号">
+            <Input disabled value={data.patientNo} />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="检查单号">
+            <Input disabled value={data.examNo} />
+          </Form.Item>
+        </Col>
+
+        <Col span={4}>
+          <Form.Item label="性别">
+            <Select
+              value={data.gender}
+              onChange={(v) => onDropdownChange('gender', v)}
+            >
+              <Option value="男">男</Option>
+              <Option value="女">女</Option>
+            </Select>
+          </Form.Item>
+        </Col>
+        <Col span={4}>
+          <Form.Item label="年龄">
+            <Input
+              addonAfter="Y"
+              value={data.age}
+              onChange={(e) => onChange('age', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="就诊类型">
+            <Row gutter={4}>
+              <Col flex="auto">
+                <Select
+                  value={data.visitType}
+                  onChange={(v) => onDropdownChange('visitType', v)}
+                >
+                  <Option value="门诊">门诊</Option>
+                  <Option value="住院">住院</Option>
+                  <Option value="体检">体检</Option>
+                </Select>
+              </Col>
+              <Col>
+                <Button>...</Button>
+              </Col>
+            </Row>
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="门诊/住院号">
+            <Input
+              value={data.clinicNo}
+              onChange={(e) => onChange('clinicNo', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="科室">
+            <Row gutter={4}>
+              <Col flex="auto">
+                <Select
+                  value={data.department}
+                  onChange={(v) => onDropdownChange('department', v)}
+                >
+                  <Option value="内科">内科</Option>
+                  <Option value="外科">外科</Option>
+                </Select>
+              </Col>
+              <Col>
+                <Button>...</Button>
+              </Col>
+            </Row>
+          </Form.Item>
+        </Col>
+
+        <Col span={6}>
+          <Form.Item label="检查时间">
+            <DatePicker
+              className="w-full"
+              value={data.examTime ? dayjs(data.examTime) : null}
+              onChange={(d) =>
+                onChange('examTime', d?.format('YYYY-MM-DD') || '')
+              }
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="身体部位">
+            <Select
+              value={data.bodyPart}
+              onChange={(v) => onDropdownChange('bodyPart', v)}
+            >
+              <Option value="腹部">腹部</Option>
+              <Option value="胸部">胸部</Option>
+              <Option value="头部">头部</Option>
+            </Select>
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="检查描述">
+            <Input
+              value={data.examDesc}
+              onChange={(e) => onChange('examDesc', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+        <Col span={6}>
+          <Form.Item label="检查部位">
+            <Input
+              value={data.examPosition}
+              onChange={(e) => onChange('examPosition', e.target.value)}
+            />
+          </Form.Item>
+        </Col>
+
+        <Col span={6}>
+          <Form.Item label="临床诊断">
+            <Row gutter={4}>
+              <Col flex="auto">
+                <Select
+                  value={data.clinicalDiag}
+                  onChange={(v) => onDropdownChange('clinicalDiag', v)}
+                >
+                  <Option value="肺炎">肺炎</Option>
+                  <Option value="肝炎">肝炎</Option>
+                </Select>
+              </Col>
+              <Col>
+                <Button>...</Button>
+              </Col>
+            </Row>
+          </Form.Item>
+        </Col>
+      </Row>
+    </Form>
+  );
+};

+ 34 - 0
src/pages/patient/DiagnosticReport/components/DiagnosisSection.tsx

@@ -0,0 +1,34 @@
+/* eslint-disable */
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { setDiagnosisDescription } from '@/states/patient/DiagnosticReport/diagnosisSlice';
+import { Input, Layout } from 'antd';
+
+const { Header, Content } = Layout;
+
+export const DiagnosisSection: React.FC = () => {
+  const dispatch = useDispatch();
+   
+  const diagnosisDescription = useSelector((state: any) => state.diagnosis.diagnosisDescription);
+
+  const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
+    dispatch(setDiagnosisDescription(event.target.value));
+  };
+
+  return (
+<Layout className="flex flex-col">
+<Header className="flex justify-between items-center p-4 bg-gray-100">
+        <div className="label">影像诊断</div>
+      </Header>
+<Content className="p-4">
+<Input.TextArea
+  className="w-full p-2 border border-gray-300 rounded"
+  value={diagnosisDescription}
+  onChange={handleInputChange}
+  placeholder="由用户输入"
+  rows={4}
+/>
+      </Content>
+    </Layout>
+  );
+};

+ 22 - 0
src/pages/patient/DiagnosticReport/components/DiagnosticReportImageViewer.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import { CloseOutlined } from '@ant-design/icons';
+
+interface Props {
+  src: string;
+  onRemove: () => void;
+}
+
+export const DiagnosticReportImageViewer: React.FC<Props> = ({
+  src,
+  onRemove,
+}) => (
+  <div className="relative w-24 h-24 rounded overflow-hidden group">
+    <img src={src} alt="thumb" className="w-full h-full object-cover" />
+    <div className="absolute inset-0 bg-black/45 hidden group:flex items-center justify-center">
+      <CloseOutlined
+        className="text-white text-lg bg-black/60 rounded-full p-1 cursor-pointer"
+        onClick={onRemove}
+      />
+    </div>
+  </div>
+);

+ 35 - 0
src/pages/patient/DiagnosticReport/components/FindingsSection.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { Button, Input } from 'antd';
+import {
+  saveTemplate,
+  updateInputValue,
+} from '@/states/patient/DiagnosticReport/findingsSlice';
+
+export const FindingsSection: React.FC = () => {
+  const dispatch = useDispatch();
+
+  const handleSaveTemplate = () => {
+    dispatch(saveTemplate());
+  };
+
+  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+    dispatch(updateInputValue(e.target.value));
+  };
+
+  return (
+    <div className="flex flex-col">
+      <div className="flex justify-between items-center">
+        <span>影像所见</span>
+        <Button type="primary" onClick={handleSaveTemplate}>
+          另存为模板
+        </Button>
+      </div>
+      <Input.TextArea
+        placeholder="由用户输入"
+        className="mt-2.5 w-full h-24"
+        onChange={handleInputChange}
+      />
+    </div>
+  );
+};

+ 42 - 0
src/pages/patient/DiagnosticReport/components/ImageList.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import { PlusOutlined } from '@ant-design/icons';
+import { Button, Tooltip, Modal } from 'antd';
+import { ImageSelection } from './ImageSelection';
+import { DiagnosticReportImageViewer } from './DiagnosticReportImageViewer';
+import { useAppSelector, useAppDispatch } from '@/states/store';
+import { removeImage } from '@/states/patient/DiagnosticReport/imageListSlice';
+
+export const ImageList: React.FC = () => {
+  const [isModalVisible, setIsModalVisible] = React.useState(false);
+  const images = useAppSelector((s) => s.imageList.images);
+  const dispatch = useAppDispatch();
+
+  return (
+    <div>
+      <div className="flex items-center gap-2 overflow-x-auto">
+        <Tooltip title="Upload Images">
+          <Button
+            className="w-24 h-24 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-blue-500"
+            icon={<PlusOutlined className="text-gray-400" />}
+            onClick={() => setIsModalVisible(true)}
+          ></Button>
+        </Tooltip>
+        {images.map((src, idx) => (
+          <DiagnosticReportImageViewer
+            key={`${src}_${idx}`}
+            src={src}
+            onRemove={() => dispatch(removeImage(idx))}
+          />
+        ))}
+      </div>
+      <Modal
+        title="Select Images"
+        open={isModalVisible}
+        onCancel={() => setIsModalVisible(false)}
+        footer={null}
+      >
+        <ImageSelection />
+      </Modal>
+    </div>
+  );
+};

+ 46 - 0
src/pages/patient/DiagnosticReport/components/ImageSelection.tsx

@@ -0,0 +1,46 @@
+import React from 'react';
+import { Button, Row } from 'antd';
+import { useAppSelector, useAppDispatch } from '@/states/store';
+import {
+  toggleSelect,
+  confirmSelection,
+  cancelSelection,
+} from '@/states/patient/DiagnosticReport/imageSelectionSlice';
+
+export const ImageSelection: React.FC = () => {
+  const images = useAppSelector((s) => s.imageSelection.candidates);
+  const selected = useAppSelector((s) => s.imageSelection.selected);
+  const dispatch = useAppDispatch();
+
+  return (
+    <div className="flex flex-col gap-3">
+      {/* 图像卡片区域 */}
+      <div className="flex flex-wrap gap-2 overflow-y-auto max-h-80">
+        {images.map((src, idx) => (
+          <div
+            key={`${src}_${idx}`}
+            className={`relative w-28 h-28 rounded overflow-hidden border-2 cursor-pointer ${selected.includes(src) ? 'border-blue-500' : 'border-gray-200'}`}
+            onClick={() => dispatch(toggleSelect(src))}
+          >
+            <img src={src} alt="thumb" className="w-full h-full object-cover" />
+            {selected.includes(src) && (
+              <div className="absolute inset-0 bg-blue-500/20 flex items-center justify-center">
+                <span className="text-white text-xs bg-blue-500 rounded-full px-2 py-0.5">
+                  ✓
+                </span>
+              </div>
+            )}
+          </div>
+        ))}
+      </div>
+
+      {/* 按钮行 */}
+      <Row justify="end" gutter={8}>
+        <Button onClick={() => dispatch(cancelSelection())}>取消</Button>
+        <Button type="primary" onClick={() => dispatch(confirmSelection())}>
+          确定
+        </Button>
+      </Row>
+    </div>
+  );
+};

+ 23 - 0
src/pages/patient/DiagnosticReport/components/MainContent.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { Card } from 'antd';
+import { BaseInfo } from './BaseInfo';
+import { ImageList } from './ImageList';
+import { FindingsSection } from './FindingsSection';
+import { DiagnosisSection } from './DiagnosisSection';
+
+export const MainContent: React.FC = () => (
+  <div className="flex flex-col gap-2">
+    <Card size="small" title="基本信息">
+      <BaseInfo />
+    </Card>
+    <Card size="small" title="图像">
+      <ImageList />
+    </Card>
+    <Card size="small">
+      <FindingsSection />
+    </Card>
+    <Card size="small">
+      <DiagnosisSection />
+    </Card>
+  </div>
+);

+ 24 - 0
src/pages/patient/DiagnosticReport/components/ReportFooter.tsx

@@ -0,0 +1,24 @@
+/* eslint-disable */
+import React from 'react';
+import { Button, Space } from 'antd';
+import { useAppDispatch } from '@/states/store';
+import { finishReport } from '@/states/patient/DiagnosticReport/slice';
+import { previewReportThunk } from '@/states/patient/DiagnosticReport/previewReportThunk';
+import { saveReportThunk } from '@/states/patient/DiagnosticReport/saveReportThunk';
+
+export const ReportFooter: React.FC<{ reportData: any }> = ({ reportData }) => {
+  const dispatch = useAppDispatch();
+  return (
+    <div className="p-4 text-right bg-white border-t">
+      <Space>
+        <Button onClick={() => dispatch(previewReportThunk({ report: reportData }))}>预览</Button>
+        <Button type="primary" onClick={() => dispatch(saveReportThunk(reportData))}>
+          保存
+        </Button>
+        <Button type="primary" onClick={() => dispatch(finishReport())}>
+          完成
+        </Button>
+      </Space>
+    </div>
+  );
+};

+ 5 - 0
src/pages/patient/DiagnosticReport/components/ReportHeader.tsx

@@ -0,0 +1,5 @@
+import React from 'react';
+
+export const ReportHeader: React.FC = () => {
+  return <div title="报告" />;
+};

+ 19 - 0
src/pages/patient/DiagnosticReport/components/ReportMain.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import { Row, Col } from 'antd';
+import { MainContent } from './MainContent';
+import { SidePanel } from './SidePanel';
+
+interface Props {
+  className?: string;
+}
+
+export const ReportMain: React.FC<Props> = ({ className }) => (
+  <Row className={className} gutter={8}>
+    <Col flex="auto">
+      <MainContent />
+    </Col>
+    <Col span={8}>
+      <SidePanel />
+    </Col>
+  </Row>
+);

+ 15 - 0
src/pages/patient/DiagnosticReport/components/SidePanel.tsx

@@ -0,0 +1,15 @@
+import React from 'react';
+import { Card } from 'antd';
+import { StudyFilter } from './StudyFilter';
+import { TemplatePanel } from './TemplatePanel';
+
+export const SidePanel: React.FC = () => (
+  <div className="flex flex-col gap-2">
+    <Card size="small" title="检查过滤">
+      <StudyFilter />
+    </Card>
+    <Card size="small" title="模板" className="flex-1">
+      <TemplatePanel />
+    </Card>
+  </div>
+);

+ 2 - 0
src/pages/patient/DiagnosticReport/components/StudyFilter.tsx

@@ -0,0 +1,2 @@
+import React from 'react';
+export const StudyFilter: React.FC = () => <div>StudyFilter TODO</div>;

+ 2 - 0
src/pages/patient/DiagnosticReport/components/TemplatePanel.tsx

@@ -0,0 +1,2 @@
+import React from 'react';
+export const TemplatePanel: React.FC = () => <div>TemplatePanel TODO</div>;

+ 24 - 0
src/pages/patient/DiagnosticReport/index.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import { useAppDispatch } from '@/states/store';
+import { ReportHeader } from './components/ReportHeader';
+import { ReportMain } from './components/ReportMain';
+import { ReportFooter } from './components/ReportFooter';
+
+const DiagnosticReport: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const reportData = {}; // Define reportData here or fetch it from state or props
+
+  React.useEffect(() => {
+    // dispatch(resetReport());
+  }, [dispatch]);
+
+  return (
+    <div className="flex flex-col h-full">
+      <ReportHeader />
+      <ReportMain className="flex-1 overflow-hidden" />
+      <ReportFooter reportData={reportData} />
+    </div>
+  );
+};
+
+export default DiagnosticReport;

+ 37 - 0
src/pages/patient/components/ActionPanel.tsx

@@ -6,6 +6,9 @@ import { switchToSendPanel } from '@/states/patient/worklist/slices/historyPanel
 import { FormattedMessage } from 'react-intl';
 import { AppDispatch, RootState } from '@/states/store';
 import Icon from '@/components/Icon';
+import DiagnosticReport from '../DiagnosticReport';
+import { Popup } from 'antd-mobile';
+import { setVisible } from '@/states/patient/DiagnosticReport/slice';
 
 interface ActionButtonProps {
   icon: React.ReactNode;
@@ -28,6 +31,9 @@ const ActionPanel: React.FC = () => {
   const selectedIds = useSelector(
     (state: RootState) => state.workSelection.selectedIds
   );
+  const visible = useSelector(
+    (state: RootState) => state.diagnosticReport.visible
+  );
 
   const handleDelete = () => {
     dispatch(deleteWorkThunk(selectedIds)); // Use the selected IDs from the Redux state
@@ -37,6 +43,10 @@ const ActionPanel: React.FC = () => {
     dispatch(switchToSendPanel());
   };
 
+  const handleShowReport = () => {
+    dispatch(setVisible(true));
+  };
+
   return (
     <div className="flex flex-wrap gap-2 w-full">
       <ActionButton
@@ -293,6 +303,33 @@ const ActionPanel: React.FC = () => {
           />
         }
       />
+      <ActionButton
+        icon={
+          <Icon
+            module="module-patient"
+            name="Report"
+            userId="base"
+            theme="default"
+            size="2x"
+            state="normal"
+          />
+        }
+        tooltip={
+          <FormattedMessage
+            id="actionPanel.showReport"
+            defaultMessage="actionPanel.showReport"
+          />
+        }
+        onClick={handleShowReport}
+      />
+      <Popup
+        visible={visible}
+        onMaskClick={() => dispatch(setVisible(false))}
+        position="right"
+        bodyStyle={{ width: '100vw', height: '100vh' }}
+      >
+        <DiagnosticReport />
+      </Popup>
     </div>
   );
 };

+ 80 - 0
src/states/patient/DiagnosticReport/baseInfoSlice.ts

@@ -0,0 +1,80 @@
+/* eslint-disable */
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+export type Gender = '男' | '女';
+export type VisitType = '门诊' | '住院' | '体检';
+export type BodyPart = '腹部' | '胸部' | '头部';
+
+export interface BaseInfoState {
+  usedName: string;
+  englishName: string;
+  patientNo: string;
+  examNo: string;
+  gender: Gender;
+  age: string;
+  visitType: VisitType;
+  clinicNo: string;
+  department: string;
+  examTime: string;
+  bodyPart: BodyPart;
+  examDesc: string;
+  examPosition: string;
+  clinicalDiag: string;
+}
+
+const initialState: BaseInfoState = {
+  usedName: '',
+  englishName: '',
+  patientNo: 'ER_202508251654111',
+  examNo: 'ER_202508251654111',
+  gender: '女',
+  age: '',
+  visitType: '门诊',
+  clinicNo: '',
+  department: '',
+  examTime: '',
+  bodyPart: '腹部',
+  examDesc: '',
+  examPosition: '',
+  clinicalDiag: '',
+};
+
+const baseInfoSlice = createSlice({
+  name: 'baseInfo',
+  initialState,
+  reducers: {
+    updateField(
+      state,
+      action: PayloadAction<{ key: keyof BaseInfoState; value: any }>
+    ) {
+      if (action.payload.key === 'gender') {
+        state.gender = action.payload.value as Gender;
+      } else if (action.payload.key === 'visitType') {
+        state.visitType = action.payload.value as VisitType;
+      } else if (action.payload.key === 'bodyPart') {
+        state.bodyPart = action.payload.value as BodyPart;
+      } else {
+        state[action.payload.key] = action.payload.value as string;
+      }
+    },
+    updateDropdown(
+      state,
+      action: PayloadAction<{ key: keyof BaseInfoState; value: any }>
+    ) {
+      if (action.payload.key === 'gender') {
+        state.gender = action.payload.value as Gender;
+      } else if (action.payload.key === 'visitType') {
+        state.visitType = action.payload.value as VisitType;
+      } else if (action.payload.key === 'bodyPart') {
+        state.bodyPart = action.payload.value as BodyPart;
+      } else {
+        state[action.payload.key] = action.payload.value as string;
+      }
+    },
+    resetBaseInfo: () => initialState,
+  },
+});
+
+export const { updateField, updateDropdown, resetBaseInfo } =
+  baseInfoSlice.actions;
+export default baseInfoSlice.reducer;

+ 22 - 0
src/states/patient/DiagnosticReport/diagnosisSlice.ts

@@ -0,0 +1,22 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface DiagnosisState {
+  diagnosisDescription: string;
+}
+
+const initialState: DiagnosisState = {
+  diagnosisDescription: '',
+};
+
+const diagnosisSlice = createSlice({
+  name: 'diagnosis',
+  initialState,
+  reducers: {
+    setDiagnosisDescription: (state, action: PayloadAction<string>) => {
+      state.diagnosisDescription = action.payload;
+    },
+  },
+});
+
+export const { setDiagnosisDescription } = diagnosisSlice.actions;
+export default diagnosisSlice.reducer;

+ 20 - 0
src/states/patient/DiagnosticReport/findingsSlice.ts

@@ -0,0 +1,20 @@
+/* eslint-disable */
+import { createSlice } from '@reduxjs/toolkit';
+
+const findingsSlice = createSlice({
+  name: 'findings',
+  initialState: {
+    diagnosticDescriptionFromImage: '',
+  },
+  reducers: {
+    saveTemplate: (state) => {
+      // Implement the logic to save the template
+    },
+    updateInputValue: (state, action) => {
+      state.diagnosticDescriptionFromImage = action.payload;
+    },
+  },
+});
+
+export const { saveTemplate, updateInputValue } = findingsSlice.actions;
+export default findingsSlice.reducer;

+ 15 - 0
src/states/patient/DiagnosticReport/headerSlice.ts

@@ -0,0 +1,15 @@
+/* eslint-disable */
+import { createSlice } from '@reduxjs/toolkit';
+
+const headerSlice = createSlice({
+  name: 'header',
+  initialState: {},
+  reducers: {
+    closeReport: (state) => {
+      // Implement close report logic
+    },
+  },
+});
+
+export const { closeReport } = headerSlice.actions;
+export default headerSlice.reducer;

+ 25 - 0
src/states/patient/DiagnosticReport/imageListSlice.ts

@@ -0,0 +1,25 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface ImageListState {
+  images: string[];
+}
+
+const initialState: ImageListState = { images: [] };
+
+const imageListSlice = createSlice({
+  name: 'imageList',
+  initialState,
+  reducers: {
+    addImage(state, action: PayloadAction<string>) {
+      state.images.push(action.payload);
+    },
+    removeImage(state, action: PayloadAction<number>) {
+      URL.revokeObjectURL(state.images[action.payload]);
+      state.images.splice(action.payload, 1);
+    },
+    resetImageList: () => initialState,
+  },
+});
+
+export const { addImage, removeImage, resetImageList } = imageListSlice.actions;
+export default imageListSlice.reducer;

+ 40 - 0
src/states/patient/DiagnosticReport/imageSelectionSlice.ts

@@ -0,0 +1,40 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface ImageSelectionState {
+  candidates: string[]; // 全部待选图
+  selected: string[]; // 已勾选图
+}
+
+const initialState: ImageSelectionState = {
+  candidates: [],
+  selected: [],
+};
+
+const imageSelectionSlice = createSlice({
+  name: 'imageSelection',
+  initialState,
+  reducers: {
+    setCandidates(state, action: PayloadAction<string[]>) {
+      state.candidates = action.payload;
+      state.selected = [];
+    },
+    toggleSelect(state, action: PayloadAction<string>) {
+      const idx = state.selected.indexOf(action.payload);
+      if (idx > -1) state.selected.splice(idx, 1);
+      else state.selected.push(action.payload);
+    },
+    // eslint-disable-next-line
+    confirmSelection(state) {
+      // 后续把 selected 合并到 imageList 等逻辑
+    },
+    cancelSelection: () => initialState,
+  },
+});
+
+export const {
+  setCandidates,
+  toggleSelect,
+  confirmSelection,
+  cancelSelection,
+} = imageSelectionSlice.actions;
+export default imageSelectionSlice.reducer;

+ 15 - 0
src/states/patient/DiagnosticReport/previewReportThunk.ts

@@ -0,0 +1,15 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { previewDiagnosisReport } from '@/API/patient/DiagnosisReportActions';
+
+interface PreviewReportThunkArgs {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  report: any;
+}
+
+export const previewReportThunk = createAsyncThunk(
+  'diagnosticReport/previewReport',
+  async ({ report }: PreviewReportThunkArgs) => {
+    const response = await previewDiagnosisReport(report);
+    return response;
+  }
+);

+ 31 - 0
src/states/patient/DiagnosticReport/saveReportThunk.ts

@@ -0,0 +1,31 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { saveDiagnosisReport } from '../../../API/patient/DiagnosisReportActions';
+import { RootState } from '../../../states/store';
+
+export const saveReportThunk = createAsyncThunk(
+  'diagnosticReport/saveReport',
+  // eslint-disable-next-line
+  async (studyId: string, { getState, dispatch }) => {
+    const state = getState() as RootState;
+    const report = {
+      headers: {
+        name: state.baseInfo.usedName,
+        sex: state.baseInfo.gender,
+        age: state.baseInfo.age,
+        medical_record_number: state.baseInfo.patientNo,
+        hospitalization_number: state.baseInfo.clinicNo,
+        bed_number: state.baseInfo.department,
+        requesting_department: state.baseInfo.department,
+        inspection_number: state.baseInfo.examNo,
+        inspection_method: state.baseInfo.examDesc,
+      },
+      findings: state.diagnosis.diagnosisDescription,
+      impression: state.findings.diagnosticDescriptionFromImage,
+      radiologist: '',
+      review_physician: '',
+    };
+
+    const response = await saveDiagnosisReport(studyId, report);
+    return response.data;
+  }
+);

+ 62 - 0
src/states/patient/DiagnosticReport/slice.ts

@@ -0,0 +1,62 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { saveReportThunk } from './saveReportThunk';
+import { previewReportThunk } from './previewReportThunk';
+
+interface DiagnosticReportState {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  report: any;
+  isPreviewing: boolean;
+  isSaving: boolean;
+  isFinished: boolean;
+  visible: boolean;
+}
+
+const initialState: DiagnosticReportState = {
+  report: null,
+  isPreviewing: false,
+  isSaving: false,
+  isFinished: false,
+  visible: false,
+};
+
+const diagnosticReportSlice = createSlice({
+  name: 'diagnosticReport',
+  initialState,
+  reducers: {
+    finishReport(state) {
+      state.isFinished = true;
+    },
+    setVisible(state, action: PayloadAction<boolean>) {
+      state.visible = action.payload;
+    },
+    toggleVisible(state) {
+      state.visible = !state.visible;
+    },
+  },
+  extraReducers: (builder) => {
+    builder
+      .addCase(saveReportThunk.pending, (state) => {
+        state.isSaving = true;
+      })
+      .addCase(saveReportThunk.fulfilled, (state) => {
+        state.isSaving = false;
+        state.isFinished = true;
+      })
+      .addCase(saveReportThunk.rejected, (state) => {
+        state.isSaving = false;
+      })
+      .addCase(previewReportThunk.pending, (state) => {
+        state.isPreviewing = true;
+      })
+      .addCase(previewReportThunk.fulfilled, (state) => {
+        state.isPreviewing = false;
+      })
+      .addCase(previewReportThunk.rejected, (state) => {
+        state.isPreviewing = false;
+      });
+  },
+});
+
+export const { finishReport, setVisible, toggleVisible } =
+  diagnosticReportSlice.actions;
+export default diagnosticReportSlice.reducer;

+ 13 - 0
src/states/patient/DiagnosticReport/studyFilterSlice.ts

@@ -0,0 +1,13 @@
+/* eslint-disable */
+import { createSlice } from '@reduxjs/toolkit';
+
+const studyFilterSlice = createSlice({
+  name: 'studyFilter',
+  initialState: {},
+  reducers: {
+    // Implement study filter reducers
+  },
+});
+
+export const {} = studyFilterSlice.actions;
+export default studyFilterSlice.reducer;

+ 14 - 0
src/states/patient/DiagnosticReport/templateSlice.ts

@@ -0,0 +1,14 @@
+/* eslint-disable */
+import { createSlice } from '@reduxjs/toolkit';
+
+const templateSlice = createSlice({
+  name: 'template',
+  initialState: {},
+   
+  reducers: {
+    // Implement template reducers
+  },
+});
+
+export const {} = templateSlice.actions;
+export default templateSlice.reducer;

+ 21 - 0
src/states/store.ts

@@ -1,4 +1,5 @@
 import { configureStore } from '@reduxjs/toolkit';
+import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
 import userInfoReducer from './user_info';
 import viewSelectionReducer from './patient/viewSelection';
 import patientTypeReducer from './patientTypeSlice';
@@ -43,6 +44,15 @@ import quotaModalReducer from './security/quotaModalSlice';
 import quotaReducer from './security/quotaSlice';
 import formReducer from './patient/register/formSlice';
 import deviceReducer from './device/deviceSlice';
+import headerReducer from './patient/DiagnosticReport/headerSlice';
+import baseInfoReducer from './patient/DiagnosticReport/baseInfoSlice';
+import imageListReducer from './patient/DiagnosticReport/imageListSlice';
+import findingsReducer from './patient/DiagnosticReport/findingsSlice';
+import diagnosisReducer from './patient/DiagnosticReport/diagnosisSlice';
+import studyFilterReducer from './patient/DiagnosticReport/studyFilterSlice';
+import templateReducer from './patient/DiagnosticReport/templateSlice';
+import imageSelectionReducer from './patient/DiagnosticReport/imageSelectionSlice';
+import diagnosticReportReducer from './patient/DiagnosticReport/slice';
 
 const store = configureStore({
   reducer: {
@@ -79,6 +89,15 @@ const store = configureStore({
     quota: quotaReducer,
     form: formReducer,
     device: deviceReducer,
+    header: headerReducer,
+    baseInfo: baseInfoReducer,
+    imageList: imageListReducer,
+    findings: findingsReducer,
+    diagnosis: diagnosisReducer,
+    studyFilter: studyFilterReducer,
+    template: templateReducer,
+    imageSelection: imageSelectionReducer,
+    diagnosticReport: diagnosticReportReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(
@@ -93,4 +112,6 @@ const store = configureStore({
 
 export type RootState = ReturnType<typeof store.getState>;
 export type AppDispatch = typeof store.dispatch;
+export const useAppDispatch = () => useDispatch<AppDispatch>();
+export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
 export default store;