ContentAreaLarge.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. import {
  2. Row,
  3. Col,
  4. Select,
  5. InputNumber,
  6. Button,
  7. Switch,
  8. Divider,
  9. Tooltip,
  10. message,
  11. Flex,
  12. Modal,
  13. } from 'antd';
  14. import ErrorMessage from '@/components/ErrorMessage';
  15. import { patientSizes, PatientSize } from '../../states/patientSize';
  16. import { WorkstationTypeLabels, WorkstationType } from '../../states/workstation';
  17. import { FormattedMessage, useIntl } from 'react-intl';
  18. import { useSelector, useDispatch, useStore } from 'react-redux';
  19. import { deleteBodyPosition } from '../../API/patient/viewActions';
  20. import { copyPositionThunk } from '../../states/exam/examWorksCacheSlice';
  21. import {
  22. removeBodyPositionBySopInstanceUid,
  23. setByIndex,
  24. judgeImageThunk,
  25. } from '../../states/exam/bodyPositionListSlice';
  26. import { RootState } from '../../states/store';
  27. import {
  28. setAprConfig,
  29. setBodysize,
  30. setWorkstation,
  31. setThickness,
  32. setIsAECEnabled,
  33. updateAprParamsThunk,
  34. } from '../../states/exam/aprSlice';
  35. import { workstationIdFromWorkstation } from '../../states/workstation';
  36. import { AprParamsUpdateRequest } from '../../API/exam/APRActions';
  37. import BodyPositionList from './components/BodyPositionList';
  38. import BodyPositionDetail from './components/BodyPositionDetail';
  39. import { AppDispatch } from '@/states/store';
  40. import { useRef } from 'react';
  41. import Icon from '@/components/Icon';
  42. import ParaSettingCoordinator from '@/domain/exam/paraSettingCoordinator';
  43. import { resetDevices } from '@/states/device/deviceSlice';
  44. import { openCamera, closeCamera } from '@/states/exam/cameraSlice';
  45. import CameraModal from '@/components/CameraModal';
  46. const ContentAreaLarge = () => {
  47. const intl = useIntl();
  48. const dispatch = useDispatch<AppDispatch>();
  49. const isResetting = useSelector(
  50. (state: RootState) => state.device.status === 'loading'
  51. );
  52. const store = useStore<RootState>();
  53. const aprConfig = useSelector((state: RootState) => state.apr.aprConfig);
  54. const bodysize = useSelector((state: RootState) => state.apr.bodysize);
  55. const workstation = useSelector((state: RootState) => state.apr.workstation);
  56. const thickness = useSelector((state: RootState) => state.apr.thickness);
  57. const isAECEnabled = useSelector(
  58. (state: RootState) => state.apr.isAECEnabled
  59. );
  60. const currentExposureMode = useSelector(
  61. (state: RootState) => state.apr.currentExposureMode
  62. );
  63. const productName = useSelector(
  64. (state: RootState) => state.product.productName
  65. );
  66. const handleBodysizeChange = (key: string) => {
  67. const value = patientSizes[key as PatientSize]; // 获取对应的显示文本
  68. console.log('体型 key:', key); // 例如: 'small'
  69. console.log('体型 value:', value); // 例如: 'Small'
  70. dispatch(setBodysize(value));
  71. };
  72. const handleWorkstationChange = (value: string) => {
  73. dispatch(setWorkstation(value));
  74. };
  75. const handleThicknessChange = (value: number | null) => {
  76. if (value !== null) {
  77. dispatch(setThickness(value));
  78. }
  79. };
  80. const handleAECChange = (checked: boolean) => {
  81. dispatch(setIsAECEnabled(checked));
  82. };
  83. const handleExposureModeChange = (value: string) => {
  84. ParaSettingCoordinator.setExposureMode(value);
  85. };
  86. const handleResetParameters = async () => {
  87. try {
  88. await dispatch(resetDevices());
  89. message.success(`重置高压发生器成功`);
  90. } catch (error) {
  91. console.error('Error resetting devices:', error);
  92. message.error(`重置高压发生器失败 ${error}`);
  93. }
  94. };
  95. /**
  96. * 处理打开摄像头按钮点击事件
  97. */
  98. const handleOpenCamera = () => {
  99. // 获取当前选中体位的 study_id
  100. const currentStudyId = selectedBodyPosition?.study_id;
  101. if (!currentStudyId) {
  102. message.warning('请先选择一个体位');
  103. return;
  104. }
  105. console.log('[handleOpenCamera] 打开摄像头,Study ID:', currentStudyId);
  106. // dispatch openCamera action,打开摄像头
  107. dispatch(openCamera(currentStudyId));
  108. };
  109. /**
  110. * 处理拒绝图像按钮点击事件
  111. */
  112. const handleReject = async () => {
  113. if (!selectedBodyPosition) {
  114. message.warning('请先选择一个体位');
  115. return;
  116. }
  117. Modal.confirm({
  118. title: '确认拒绝',
  119. content: `确定要拒绝选中的图像吗?`,
  120. okText: '确认拒绝',
  121. cancelText: '取消',
  122. okButtonProps: {
  123. danger: true,
  124. 'data-testid': 'modal-confirm-delete'
  125. },
  126. cancelButtonProps: {
  127. 'data-testid': 'modal-cancel-delete'
  128. },
  129. centered: true,
  130. onOk: async () => {
  131. try {
  132. await dispatch(
  133. judgeImageThunk({
  134. sopInstanceUid: selectedBodyPosition.sop_instance_uid,
  135. accept: false,
  136. })
  137. ).unwrap();
  138. message.success('图像已拒绝');
  139. } catch (err) {
  140. console.error('拒绝图像失败:', err);
  141. message.error('拒绝图像失败');
  142. }
  143. },
  144. });
  145. };
  146. /**
  147. * 处理恢复图像按钮点击事件
  148. */
  149. const handleRestore = async () => {
  150. if (!selectedBodyPosition) {
  151. message.warning('请先选择一个体位');
  152. return;
  153. }
  154. try {
  155. await dispatch(
  156. judgeImageThunk({
  157. sopInstanceUid: selectedBodyPosition.sop_instance_uid,
  158. accept: true,
  159. })
  160. ).unwrap();
  161. message.success('图像已恢复');
  162. } catch (err) {
  163. console.error('恢复图像失败:', err);
  164. message.error('恢复图像失败');
  165. }
  166. };
  167. /**
  168. * 处理保存参数按钮点击事件
  169. */
  170. const handleSaveParams = async () => {
  171. if (!selectedBodyPosition) {
  172. message.warning('请先选择一个体位');
  173. return;
  174. }
  175. try {
  176. // 获取工作站ID - 将字符串转换为WorkstationType枚举
  177. const workstationType = workstation as WorkstationType;
  178. const workStationId = workstationIdFromWorkstation(
  179. workstationType,
  180. productName
  181. );
  182. // 构建请求参数
  183. const request: AprParamsUpdateRequest = {
  184. work_station_id: workStationId,
  185. patient_size: bodysize,
  186. kV: aprConfig.kV,
  187. mA: aprConfig.mA,
  188. ms: aprConfig.ms,
  189. mAs: aprConfig.mAs,
  190. };
  191. // 调用 updateAprParamsThunk
  192. await dispatch(
  193. updateAprParamsThunk({
  194. id: selectedBodyPosition.view_id,
  195. request,
  196. })
  197. ).unwrap();
  198. message.success('参数保存成功');
  199. } catch (err) {
  200. console.error('保存参数失败:', err);
  201. message.error('保存参数失败');
  202. }
  203. };
  204. // 1. 正常在顶层用 useSelector 订阅
  205. const selectedBodyPosition = useSelector(
  206. (state: RootState) => state.bodyPositionList.selectedBodyPosition
  207. );
  208. // 2. 用 ref 保存最新值(每次渲染都会更新)
  209. const positionRef = useRef(selectedBodyPosition);
  210. positionRef.current = selectedBodyPosition;
  211. // 3. 订阅 camera 状态
  212. const cameraIsOpen = useSelector((state: RootState) => state.camera.isOpen);
  213. const cameraStudyId = useSelector(
  214. (state: RootState) => state.camera.currentStudyId
  215. );
  216. // 4. 计算按钮可见性
  217. const loading = useSelector(
  218. (state: RootState) => state.bodyPositionList.loading
  219. );
  220. const exposeStatus = selectedBodyPosition?.dview.expose_status;
  221. const judgedStatus = selectedBodyPosition?.dview.judged_status || '';
  222. const isExposed = exposeStatus === 'Exposed';
  223. const isUnexposed = exposeStatus === 'Unexposed';
  224. // 按钮可见性逻辑
  225. const showRejectButton = isExposed && judgedStatus !== 'Reject';
  226. const showRestoreButton = isExposed && judgedStatus === 'Reject';
  227. const showSaveParamsButton = isUnexposed;
  228. const showDeleteButton = isUnexposed;
  229. return (
  230. <Row className="w-full p-1" style={{ height: '100%', display: 'flex' }}>
  231. <Col span={20} style={{ display: 'flex', flexDirection: 'column' }} className='h-full'>
  232. <Row gutter={16} style={{ flex: 1, minHeight: 0, display: 'flex' }} className='h-full'>
  233. <Col span={4} className="flex flex-col h-full" >
  234. <BodyPositionList layout="vertical"></BodyPositionList>
  235. </Col>
  236. <Col span={20}>
  237. <BodyPositionDetail />
  238. </Col>
  239. </Row>
  240. </Col>
  241. <Col
  242. span={4}
  243. style={{ height: '100%', overflowY: 'auto', flexShrink: 0 }}
  244. >
  245. <Row gutter={16} align="middle">
  246. <Col span={productName === 'DROS' ? 9 : 12}>
  247. <Select
  248. placeholder={<FormattedMessage id="exam.bodysize.placeholder" />}
  249. style={{ width: '100%', marginBottom: 8 }}
  250. value={!!bodysize ? intl.formatMessage({ id: bodysize }) : undefined}
  251. onChange={handleBodysizeChange}
  252. >
  253. {Object.entries(patientSizes).map(
  254. ([key, value]: [string, string]) => (
  255. <Select.Option key={key} value={key}>
  256. <FormattedMessage id={value} />
  257. </Select.Option>
  258. )
  259. )}
  260. </Select>
  261. </Col>
  262. <Col span={productName === 'DROS' ? 9 : 12}>
  263. <label
  264. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  265. >
  266. <FormattedMessage
  267. id="exam.thickness.label"
  268. defaultMessage="厚度 (cm)"
  269. />
  270. </label>
  271. <InputNumber
  272. placeholder={intl.formatMessage({
  273. id: 'exam.thickness.placeholder',
  274. })}
  275. style={{ width: '100%', marginBottom: 8 }}
  276. value={thickness || undefined}
  277. min={1}
  278. max={50}
  279. step={1}
  280. onChange={handleThicknessChange}
  281. onStep={(value, info) => {
  282. if (info.type === 'up') {
  283. ParaSettingCoordinator.increaseThickness();
  284. } else {
  285. ParaSettingCoordinator.decreaseThickness();
  286. }
  287. }}
  288. />
  289. </Col>
  290. {productName === 'DROS' && (
  291. <Col span={15}>
  292. <Select
  293. placeholder="选择工作位"
  294. style={{ width: '100%', marginBottom: 8 }}
  295. value={workstation}
  296. onChange={handleWorkstationChange}
  297. >
  298. {Object.entries(WorkstationTypeLabels).map(
  299. ([key, value]: [string, string]) => (
  300. <Select.Option key={key} value={value}>
  301. <FormattedMessage
  302. id={`workstation.${key.toLowerCase()}`}
  303. />
  304. </Select.Option>
  305. )
  306. )}
  307. </Select>
  308. </Col>
  309. )}
  310. </Row>
  311. <div>
  312. <div>
  313. <label
  314. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  315. >
  316. mA
  317. </label>
  318. <Tooltip
  319. title={
  320. currentExposureMode === 'mAs'
  321. ? '当前为 mAs 模式,mA 和 ms 不可调整'
  322. : ''
  323. }
  324. >
  325. <InputNumber
  326. disabled={currentExposureMode === 'mAs'}
  327. placeholder={currentExposureMode === 'mAs' ? '--' : 'mA'}
  328. style={{ width: '100%', marginBottom: 8 }}
  329. value={
  330. currentExposureMode === 'mAs'
  331. ? null
  332. : (aprConfig.mA ?? undefined)
  333. }
  334. onStep={(value, info) => {
  335. if (info.type === 'up') {
  336. ParaSettingCoordinator.increaseMA();
  337. } else {
  338. ParaSettingCoordinator.decreaseMA();
  339. }
  340. return false;
  341. }}
  342. />
  343. </Tooltip>
  344. </div>
  345. <div>
  346. <label
  347. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  348. >
  349. ms
  350. </label>
  351. <Tooltip
  352. title={
  353. currentExposureMode === 'mAs'
  354. ? '当前为 mAs 模式,mA 和 ms 不可调整'
  355. : ''
  356. }
  357. >
  358. <InputNumber
  359. disabled={currentExposureMode === 'mAs'}
  360. placeholder={currentExposureMode === 'mAs' ? '--' : 'ms'}
  361. style={{ width: '100%', marginBottom: 8 }}
  362. value={
  363. currentExposureMode === 'mAs'
  364. ? null
  365. : (aprConfig.ms ?? undefined)
  366. }
  367. onStep={(value, info) => {
  368. if (info.type === 'up') {
  369. ParaSettingCoordinator.increaseMS();
  370. } else {
  371. ParaSettingCoordinator.decreaseMS();
  372. }
  373. }}
  374. />
  375. </Tooltip>
  376. </div>
  377. <div>
  378. <label
  379. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  380. >
  381. mAs
  382. </label>
  383. <Tooltip
  384. title={
  385. currentExposureMode === 'time'
  386. ? '当前为 time 模式,mAs 不可调整'
  387. : ''
  388. }
  389. >
  390. <InputNumber
  391. disabled={currentExposureMode === 'time'}
  392. placeholder={currentExposureMode === 'time' ? '--' : 'mAs'}
  393. style={{ width: '100%', marginBottom: 8 }}
  394. value={
  395. currentExposureMode === 'time'
  396. ? null
  397. : (aprConfig.mAs ?? undefined)
  398. }
  399. onStep={(value, info) => {
  400. if (info.type === 'up') {
  401. ParaSettingCoordinator.increaseMAS();
  402. } else {
  403. ParaSettingCoordinator.decreaseMAS();
  404. }
  405. }}
  406. />
  407. </Tooltip>
  408. </div>
  409. <div>
  410. <label
  411. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  412. >
  413. KV
  414. </label>
  415. <InputNumber
  416. placeholder="KV"
  417. style={{ width: '100%', marginBottom: 8 }}
  418. value={aprConfig.kV ?? undefined}
  419. // onChange={(value) =>
  420. // dispatch(setAprConfig({ ...aprConfig, kV: value ?? 0 }))
  421. // }
  422. onStep={(value, info) => {
  423. if (info.type === 'up') {
  424. ParaSettingCoordinator.increaseKV();
  425. } else {
  426. ParaSettingCoordinator.decreaseKV();
  427. }
  428. }}
  429. />
  430. </div>
  431. {/* <div>
  432. <label
  433. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  434. >
  435. density
  436. </label>
  437. <InputNumber
  438. placeholder="density"
  439. style={{ width: '100%', marginBottom: 8 }}
  440. value={aprConfig.AECDensity ?? undefined}
  441. onChange={(value) =>
  442. dispatch(setAprConfig({ ...aprConfig, AECDensity: value ?? 0 }))
  443. }
  444. onStep={(value, info) => {
  445. if (info.type === 'up') {
  446. ParaSettingCoordinator.increaseDensity();
  447. } else {
  448. ParaSettingCoordinator.decreaseDensity();
  449. }
  450. }}
  451. />
  452. </div> */}
  453. </div>
  454. <div>
  455. <label
  456. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  457. >
  458. <FormattedMessage id="exam.exposureMode.label" />
  459. </label>
  460. <Select
  461. placeholder={<FormattedMessage id="exam.exposureMode.placeholder" />}
  462. value={currentExposureMode}
  463. onChange={handleExposureModeChange}
  464. style={{ width: '100%', marginBottom: 8 }}
  465. >
  466. <Select.Option value="mAs">mAs</Select.Option>
  467. <Select.Option value="time">time</Select.Option>
  468. </Select>
  469. </div>
  470. <div>
  471. <label
  472. style={{ display: 'block', marginBottom: 4, fontSize: '12px' }}
  473. >
  474. AEC
  475. </label>
  476. <Switch
  477. checkedChildren={<FormattedMessage id="exam.aec.enabled" />}
  478. unCheckedChildren={<FormattedMessage id="exam.aec.disabled" />}
  479. checked={isAECEnabled}
  480. onChange={handleAECChange}
  481. style={{ marginBottom: 8 }}
  482. />
  483. </div>
  484. <Flex align="center" justify="start" gap="middle" wrap>
  485. <Button
  486. data-testid="reset-generator-btn"
  487. style={{ width: '1.5rem', height: '1.5rem' }}
  488. icon={
  489. <Icon
  490. module="module-exam"
  491. name="btn_ResetGenerator"
  492. userId="base"
  493. theme="default"
  494. size="2x"
  495. state="normal"
  496. />
  497. }
  498. title={intl.formatMessage({ id: 'exam.action.resetParams' })}
  499. onClick={handleResetParameters}
  500. disabled={isResetting}
  501. />
  502. </Flex>
  503. <Divider />
  504. <Flex wrap gap="small">
  505. {showDeleteButton && (
  506. <Tooltip title={intl.formatMessage({ id: 'exam.action.deletePosition' })}>
  507. <Button
  508. style={{ width: '1.5rem', height: '1.5rem' }}
  509. icon={
  510. <Icon
  511. module="module-exam"
  512. name="btn_DeleteView"
  513. userId="base"
  514. theme="default"
  515. size="2x"
  516. state="normal"
  517. />
  518. }
  519. onClick={async () => {
  520. const state = store.getState().bodyPositionList;
  521. const selectedBodyPosition = state.selectedBodyPosition;
  522. const bodyPositions = state.bodyPositions;
  523. // 检查体位数量,至少保留一个
  524. if (bodyPositions.length <= 1) {
  525. message.warning('至少需要保留一个体位,无法删除');
  526. return;
  527. }
  528. console.log(
  529. `选中的体位:${JSON.stringify(selectedBodyPosition)}`
  530. );
  531. if (
  532. selectedBodyPosition &&
  533. selectedBodyPosition.sop_instance_uid
  534. ) {
  535. try {
  536. await deleteBodyPosition(
  537. selectedBodyPosition.sop_instance_uid
  538. );
  539. dispatch(
  540. removeBodyPositionBySopInstanceUid(
  541. selectedBodyPosition.sop_instance_uid
  542. )
  543. );
  544. dispatch(setByIndex(0));
  545. } catch (error) {
  546. console.error('Error deleting body position:', error);
  547. message.error('Failed to delete body position');
  548. }
  549. }
  550. }}
  551. />
  552. </Tooltip>
  553. )}
  554. <Tooltip title={intl.formatMessage({ id: 'exam.action.copyPosition' })}>
  555. <Button
  556. style={{ width: '1.5rem', height: '1.5rem' }}
  557. icon={
  558. <Icon
  559. module="module-exam"
  560. name="btn_Copy"
  561. userId="base"
  562. theme="default"
  563. size="2x"
  564. state="normal"
  565. />
  566. }
  567. onClick={() => {
  568. const instanceUid =
  569. store.getState().bodyPositionList.selectedBodyPosition
  570. ?.study_instance_uid ?? '';
  571. console.log('Copying position for instance UID:', instanceUid);
  572. console.log(
  573. `${store.getState().bodyPositionList.selectedBodyPosition}`
  574. );
  575. dispatch(copyPositionThunk({ instanceUid }));
  576. }}
  577. />
  578. </Tooltip>
  579. {showSaveParamsButton && (
  580. <Tooltip title={intl.formatMessage({ id: 'exam.action.saveParams' })}>
  581. <Button
  582. style={{ width: '1.5rem', height: '1.5rem' }}
  583. icon={
  584. <Icon
  585. module="module-exam"
  586. name="btn_Save"
  587. userId="base"
  588. theme="default"
  589. size="2x"
  590. state="normal"
  591. />
  592. }
  593. onClick={handleSaveParams}
  594. />
  595. </Tooltip>
  596. )}
  597. <Tooltip title={intl.formatMessage({ id: 'exam.action.toggleCamera' })}>
  598. <Button
  599. style={{ width: '1.5rem', height: '1.5rem' }}
  600. icon={
  601. <Icon
  602. module="module-exam"
  603. name="btn_OpenCamera"
  604. userId="base"
  605. theme="default"
  606. size="2x"
  607. state="normal"
  608. />
  609. }
  610. onClick={handleOpenCamera}
  611. />
  612. </Tooltip>
  613. {showRejectButton && (
  614. <Tooltip title={intl.formatMessage({ id: 'exam.action.reject' })}>
  615. <Button
  616. style={{ width: '1.5rem', height: '1.5rem' }}
  617. loading={loading}
  618. icon={
  619. <Icon
  620. module="module-exam"
  621. name="btn_RejectImage"
  622. userId="base"
  623. theme="default"
  624. size="2x"
  625. state="normal"
  626. />
  627. }
  628. onClick={handleReject}
  629. />
  630. </Tooltip>
  631. )}
  632. {showRestoreButton && (
  633. <Tooltip title={intl.formatMessage({ id: 'exam.action.restore' })}>
  634. <Button
  635. style={{ width: '1.5rem', height: '1.5rem' }}
  636. loading={loading}
  637. icon={
  638. <Icon
  639. module="module-exam"
  640. name="btn_RestoreImage"
  641. userId="base"
  642. theme="default"
  643. size="2x"
  644. state="normal"
  645. />
  646. }
  647. onClick={handleRestore}
  648. />
  649. </Tooltip>
  650. )}
  651. </Flex>
  652. <ErrorMessage />
  653. </Col>
  654. {/* 摄像头 Modal */}
  655. {cameraIsOpen && cameraStudyId && (
  656. <CameraModal
  657. visible={cameraIsOpen}
  658. studyId={cameraStudyId}
  659. onClose={() => dispatch(closeCamera())}
  660. />
  661. )}
  662. </Row>
  663. );
  664. };
  665. export default ContentAreaLarge;