Ver Fonte

feat (1.50.0 -> 1.51.0): 新增 SVG 交互式人体模型组件

- 新增 HumanBodySvg 组件,基于 SVG 实现可交互的人体模型
- 添加 human.svg 人体模型矢量图资源
- 配置 webpack SVGR 支持 VirtualHuman 目录的 SVG 加载
- 在 bodyPositionFilter 中替换为新的 SVG 人体模型组件
- 优化 RegisterAvailableFilterBar 布局样式

改动文件:
- src/components/HumanBodySvg.tsx
- src/assets/imgs/VirtualHuman/human.svg
- config/index.ts
- src/pages/patient/components/bodyPositionFilter.tsx
- src/pages/patient/components/RegisterAvailableFilterBar.tsx
- CHANGELOG.md
- package.json
dengdx há 1 semana atrás
pai
commit
cb3c06b6ea

+ 18 - 0
CHANGELOG.md

@@ -1,6 +1,24 @@
 # 变更日志 (Changelog)
 
 本项目的所有重要变更都将记录在此文件中。
+## [1.51.0] - 2026-01-05 21:38
+
+### 新增 (Added)
+
+- **新增 SVG 交互式人体模型组件** - 使用 SVG 技术重构人体部位选择组件,支持点击交互和高亮反馈
+  - 新增 HumanBodySvg 组件,基于 SVG 实现可交互的人体模型
+  - 添加 human.svg 人体模型矢量图资源
+  - 配置 webpack SVGR 支持 VirtualHuman 目录的 SVG 加载
+  - 在 bodyPositionFilter 中替换为新的 SVG 人体模型组件
+  - 优化 RegisterAvailableFilterBar 布局样式
+
+**改动文件:**
+- src/components/HumanBodySvg.tsx
+- src/assets/imgs/VirtualHuman/human.svg
+- config/index.ts
+- src/pages/patient/components/bodyPositionFilter.tsx
+- src/pages/patient/components/RegisterAvailableFilterBar.tsx
+
 ## [1.50.0] - 2026-01-05 19:30
 ## [1.49.7] - 2026-01-05 19:30
 

+ 1 - 0
config/index.ts

@@ -125,6 +125,7 @@ export default defineConfig<'webpack5'>(async (merge) => {
           .include.add(
             path.resolve(__dirname, '../src/assets/Icons')
           )
+          .add(path.resolve(__dirname, '../src/assets/imgs/VirtualHuman'))
           .end()
           .use('svgr')
           .loader('@svgr/webpack')

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "zsis",
-  "version": "1.50.0",
+  "version": "1.51.0",
   "private": true,
   "description": "医学成像系统",
   "main": "main.js",

Diff do ficheiro suprimidas por serem muito extensas
+ 47 - 0
src/assets/imgs/VirtualHuman/human.svg


+ 179 - 0
src/components/HumanBodySvg.tsx

@@ -0,0 +1,179 @@
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import type { AppDispatch, RootState } from '@/states/store';
+import { fetchViewsOrProtocols } from '@/states/patient/viewSelection';
+import { setCurrentBodyPart } from '@/states/bodyPartSlice';
+
+// ✅ 独立 SVG 文件(不内联)
+// import { ReactComponent as HumanSvg } from '@/assets/imgs/VirtualHuman/human.svg';
+import HumanSvg from '@/assets/imgs/VirtualHuman/human.svg';
+
+
+interface HumanBodySvgProps {
+  // 容器样式(覆盖默认的 .human-container)
+  containerStyle?: React.CSSProperties;
+  
+  // SVG 样式(覆盖默认的 .human-svg)
+  svgStyle?: React.CSSProperties;
+  
+  // 自定义类名(追加到默认类名)
+  className?: string;
+  
+  // 完全自定义的内联样式
+  style?: React.CSSProperties;
+}
+
+
+const HumanBody: React.FC<HumanBodySvgProps> = ({
+    containerStyle,
+    svgStyle,
+    className,
+    style
+}) => {
+    const [activePart, setActivePart] = useState<string | null>(null);
+    const dispatch = useDispatch<AppDispatch>();
+
+    const currentPatientType = useSelector(
+        (state: RootState) => state.patientType.current
+    );
+
+    const bodyPartsState = useSelector(
+        (state: RootState) => state.bodyPart.byPatientType
+    );
+
+    const selected = useSelector(
+        (state: RootState) => state.selection.selected
+    );
+
+    const bringToFront = (el: SVGElement) => {
+        const parent = el.parentNode;
+        if (!parent) return;
+        parent.appendChild(el);
+    };
+
+    const handleMouseOver = (e: React.MouseEvent<SVGSVGElement>) => {
+        const target = e.target as Element;
+        const partEl = target.closest('[data-part]') as SVGElement | null;
+        if (partEl) {
+            bringToFront(partEl);
+        }
+    };
+
+    /**
+     * SVG 统一点击入口(事件委托)
+     */
+    const handleSvgClick = (e: React.MouseEvent<SVGSVGElement>) => {
+        const target = e.target as Element;
+
+        // 从当前节点向上找 data-part
+        const partEl = target.closest('[data-part]') as SVGElement | null;
+
+        if (!partEl) return;
+
+        const serverName = partEl.getAttribute('data-part');
+        if (!serverName) return;
+
+        console.log('点击身体部位:', serverName);
+
+
+        setActivePart(serverName);
+
+        const selectedBodyPart =
+            bodyPartsState.find(
+                (item) => item.body_part_id === serverName
+            ) || null;
+
+        dispatch(setCurrentBodyPart(selectedBodyPart));
+
+        dispatch(
+            fetchViewsOrProtocols({
+                selection: selected,
+                patientType: currentPatientType?.patient_type_id ?? null,
+                bodyPart: serverName,
+            })
+        );
+    };
+
+    /**
+     * 控制 SVG 高亮状态
+     * 不修改 SVG 文件本身
+     */
+    useEffect(() => {
+        const svg = document.querySelector(
+            '.human-svg'
+        ) as SVGSVGElement | null;
+
+        if (!svg) return;
+
+        // 清空所有状态
+        svg.querySelectorAll('[id]').forEach((el) => {
+            el.classList.remove('active');
+            el.classList.add('inactive');
+        });
+
+        if (activePart) {
+            const activeEl = svg.querySelector(
+                `#${CSS.escape(activePart)}`
+            );
+            activeEl?.classList.remove('inactive');
+            activeEl?.classList.add('active');
+        }
+    }, [activePart]);
+
+    return (
+        <>
+            {/* ===== 样式(集中在一个文件内)===== */}
+            <style>
+                {`
+        .human-container {
+          width: 294px;
+          // height: 594px;
+          user-select: none;
+        }
+
+        .human-svg {
+          width: 100%;
+          height: 100%;
+          opacity: 0.95;
+        }
+
+        /* 默认状态 */
+        .human-svg [data-part] {
+          cursor: pointer;
+          // opacity: 0.35;
+          transition: opacity 0.2s ease, filter 0.2s ease;
+        }
+
+        /* hover */
+        .human-svg [data-part]:hover {
+          //opacity: 0.2;
+          filter: drop-shadow(0 0 16px red);
+        }
+
+        /* 激活状态 */
+        .human-svg .active {
+          opacity: 1;
+          filter: drop-shadow(0 0 6px #409eff);
+        }
+
+        /* 非激活 */
+        .human-svg .inactive {
+          opacity: 0.25;
+        }
+        `}
+            </style>
+
+            {/* ===== SVG 容器 ===== */}
+            <div className="human-container" style={containerStyle}>
+                <HumanSvg
+                    className="human-svg"
+                    style={svgStyle}
+                    onMouseOver={handleMouseOver}
+                    onClick={handleSvgClick}
+                />
+            </div>
+        </>
+    );
+}
+
+export default HumanBody;

+ 1 - 1
src/pages/patient/components/RegisterAvailableFilterBar.tsx

@@ -63,7 +63,7 @@ const RegisterAvailableFilterBar: React.FC<Props> = ({
   });
   return (
     <div
-      className="absolute top-0 left-0 right-0 z-10"
+      className="z-10"
       style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}
     >
       <Row gutter={[16, 16]}>

+ 5 - 2
src/pages/patient/components/bodyPositionFilter.tsx

@@ -7,6 +7,7 @@ import { AppDispatch, RootState } from '@/states/store';
 import { Flex, message } from 'antd';
 import { setCurrentBodyPart } from '@/states/bodyPartSlice';
 import { fetchViewsOrProtocols } from '@/states/patient/viewSelection';
+import HumanBodySvg from '@/components/HumanBodySvg';
 
 const BodyPositionFilter: React.FC = () => {
   const [bodyPart, setBodyPart] = useState<string | undefined>(undefined);
@@ -42,12 +43,14 @@ const BodyPositionFilter: React.FC = () => {
       />
       {productName === 'DROS' ? (
         <Flex
-          flex={1}
+          flex={0}
+          style={{minHeight: '0'}}
           data-testid="human-body-container"
           justify="center"
           align="center"
         >
-          <HumanBody />
+          {/* <HumanBody /> */}
+          <HumanBodySvg containerStyle={{ height: '100%' }} />
         </Flex>
       ) : (
         <Flex

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff