WorklistTable.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. import React, { useState, useEffect, useMemo } from 'react';
  2. import { Table, TableColumnsType } from 'antd';
  3. import { FormattedMessage } from 'react-intl';
  4. import { Task, Task as DataType } from '@/domain/work';
  5. import type { ResizeCallbackData } from 'react-resizable';
  6. import { Resizable } from 'react-resizable';
  7. import { WorkFilter } from '@/states/patient/worklist/types/workfilter';
  8. import { useTouchDoubleClick } from '@/hooks/useTouchDoubleClick';
  9. import { useMultiSelection } from '@/hooks/useMultiSelection';
  10. import { ColumnConfig } from '@/config/tableColumns/types/columnConfig';
  11. import { ExposureProgress } from '@/domain/patient/getExposureProgress';
  12. interface TitlePropsType {
  13. width: number;
  14. onResize: (
  15. e: React.SyntheticEvent<Element>,
  16. data: ResizeCallbackData
  17. ) => void;
  18. }
  19. const ResizableTitle: React.FC<
  20. Readonly<
  21. React.HTMLAttributes<HTMLTableCellElement | Resizable> & TitlePropsType
  22. >
  23. > = (props) => {
  24. const { onResize, width, ...restProps } = props;
  25. if (!width) {
  26. return <th {...restProps} />;
  27. }
  28. return (
  29. <Resizable
  30. width={width}
  31. height={0}
  32. minConstraints={[50, 0]} // 最小宽度 50px
  33. // maxConstraints={[500, 0]} // 最大宽度 500px
  34. handle={
  35. <span
  36. style={{
  37. position: 'absolute',
  38. right: '-5px', // 或 insetInlineEnd: "-5px"(支持逻辑属性)
  39. bottom: '0',
  40. zIndex: 1,
  41. width: '10px',
  42. height: '100%',
  43. cursor: 'col-resize',
  44. }}
  45. onClick={(e) => e.stopPropagation()}
  46. />
  47. }
  48. onResize={onResize}
  49. draggableOpts={{ enableUserSelectHack: false }}
  50. >
  51. <th {...restProps} />
  52. </Resizable>
  53. );
  54. };
  55. interface WorklistTableProps {
  56. productName?: string; // 新增:产品名称(用于区分人医/宠物医)
  57. columnConfig?: ColumnConfig[]; // 列配置(可选)
  58. exposureProgressMap?: Record<string, ExposureProgress>; // 新增:曝光进度映射
  59. worklistData: Task[];
  60. filters?: WorkFilter;
  61. page?: number;
  62. pageSize?: number;
  63. selectedIds: string[]; // 必填(保持向后兼容)
  64. selectedSecondaryIds?: string[]; // 可选(新增)
  65. handleRowClick: (record: Task, event?: React.MouseEvent) => void;
  66. handleRowDoubleClick: (record: Task) => void;
  67. className?: string;
  68. }
  69. const WorklistTable: React.FC<WorklistTableProps> = ({
  70. productName, // 新增
  71. columnConfig = [], // 接收配置,默认为空数组
  72. exposureProgressMap = null, // 新增:曝光进度映射
  73. worklistData,
  74. // filters,
  75. // page,
  76. // pageSize,
  77. selectedIds,
  78. selectedSecondaryIds, // 可能为 undefined
  79. handleRowClick,
  80. handleRowDoubleClick,
  81. className,
  82. }) => {
  83. // 生成列定义的辅助函数
  84. const generateColumnsDef = (productName?: string): any[] => [
  85. {
  86. title: (
  87. <FormattedMessage
  88. id="worklistTable.StudyInstanceUID"
  89. defaultMessage="worklistTable.StudyInstanceUID"
  90. />
  91. ),
  92. dataIndex: 'StudyInstanceUID',
  93. },
  94. {
  95. title: (
  96. <FormattedMessage
  97. id="worklistTable.StudyID"
  98. defaultMessage="worklistTable.StudyID"
  99. />
  100. ),
  101. dataIndex: 'StudyID',
  102. },
  103. {
  104. title: (
  105. <FormattedMessage
  106. id="worklistTable.SpecificCharacterSet"
  107. defaultMessage="worklistTable.SpecificCharacterSet"
  108. />
  109. ),
  110. dataIndex: 'SpecificCharacterSet',
  111. },
  112. {
  113. title: (
  114. <FormattedMessage
  115. id="worklistTable.AccessionNumber"
  116. defaultMessage="worklistTable.AccessionNumber"
  117. />
  118. ),
  119. dataIndex: 'AccessionNumber',
  120. },
  121. {
  122. title: (
  123. <FormattedMessage
  124. id={productName === 'VETDROS' ? 'animal.worklistTable.PatientID' : 'worklistTable.PatientID'}
  125. defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientID' : 'worklistTable.PatientID'}
  126. />
  127. ),
  128. dataIndex: 'PatientID',
  129. },
  130. {
  131. title: (
  132. <FormattedMessage
  133. id={productName === 'VETDROS' ? 'animal.worklistTable.PatientName' : 'worklistTable.PatientName'}
  134. defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientName' : 'worklistTable.PatientName'}
  135. />
  136. ),
  137. dataIndex: 'PatientName',
  138. },
  139. {
  140. title: (
  141. <FormattedMessage
  142. id="worklistTable.DisplayPatientName"
  143. defaultMessage="worklistTable.DisplayPatientName"
  144. />
  145. ),
  146. dataIndex: 'DisplayPatientName',
  147. },
  148. {
  149. title: (
  150. <FormattedMessage
  151. id="worklistTable.PatientSize"
  152. defaultMessage="worklistTable.PatientSize"
  153. />
  154. ),
  155. dataIndex: 'PatientSize',
  156. },
  157. {
  158. title: (
  159. <FormattedMessage
  160. id={productName === 'VETDROS' ? 'animal.worklistTable.PatientAge' : 'worklistTable.PatientAge'}
  161. defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientAge' : 'worklistTable.PatientAge'}
  162. />
  163. ),
  164. dataIndex: 'PatientAge',
  165. },
  166. {
  167. title: (
  168. <FormattedMessage
  169. id={productName === 'VETDROS' ? 'animal.worklistTable.PatientSex' : 'worklistTable.PatientSex'}
  170. defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientSex' : 'worklistTable.PatientSex'}
  171. />
  172. ),
  173. dataIndex: 'PatientSex',
  174. },
  175. {
  176. title: (
  177. <FormattedMessage
  178. id="worklistTable.AdmittingTime"
  179. defaultMessage="worklistTable.AdmittingTime"
  180. />
  181. ),
  182. dataIndex: 'AdmittingTime',
  183. },
  184. {
  185. title: (
  186. <FormattedMessage
  187. id="worklistTable.RegSource"
  188. defaultMessage="worklistTable.RegSource"
  189. />
  190. ),
  191. dataIndex: 'RegSource',
  192. },
  193. {
  194. title: (
  195. <FormattedMessage
  196. id="worklistTable.StudyStatus"
  197. defaultMessage="worklistTable.StudyStatus"
  198. />
  199. ),
  200. dataIndex: 'StudyStatus',
  201. },
  202. {
  203. title: (
  204. <FormattedMessage
  205. id="worklistTable.RequestedProcedureID"
  206. defaultMessage="worklistTable.RequestedProcedureID"
  207. />
  208. ),
  209. dataIndex: 'RequestedProcedureID',
  210. },
  211. {
  212. title: (
  213. <FormattedMessage
  214. id="worklistTable.PerformedProtocolCodeValue"
  215. defaultMessage="worklistTable.PerformedProtocolCodeValue"
  216. />
  217. ),
  218. dataIndex: 'PerformedProtocolCodeValue',
  219. },
  220. {
  221. title: (
  222. <FormattedMessage
  223. id="worklistTable.PerformedProtocolCodeMeaning"
  224. defaultMessage="worklistTable.PerformedProtocolCodeMeaning"
  225. />
  226. ),
  227. dataIndex: 'PerformedProtocolCodeMeaning',
  228. },
  229. {
  230. title: (
  231. <FormattedMessage
  232. id="worklistTable.PerformedProcedureStepID"
  233. defaultMessage="worklistTable.PerformedProcedureStepID"
  234. />
  235. ),
  236. dataIndex: 'PerformedProcedureStepID',
  237. },
  238. {
  239. title: (
  240. <FormattedMessage
  241. id="worklistTable.StudyDescription"
  242. defaultMessage="worklistTable.StudyDescription"
  243. />
  244. ),
  245. dataIndex: 'StudyDescription',
  246. },
  247. {
  248. title: (
  249. <FormattedMessage
  250. id="worklistTable.StudyStartDatetime"
  251. defaultMessage="worklistTable.StudyStartDatetime"
  252. />
  253. ),
  254. dataIndex: 'StudyStartDatetime',
  255. render: (_: any, record: Task) => {
  256. if (!record.StudyStartDatetime || typeof record.StudyStartDatetime !== 'object') {
  257. return '';
  258. }
  259. const { seconds, nanos } = record.StudyStartDatetime;
  260. const date = new Date(seconds * 1000 + Math.floor(nanos / 1000000));
  261. // 格式化为本地时间字符串
  262. return date.toLocaleString();
  263. }
  264. },
  265. {
  266. title: (
  267. <FormattedMessage
  268. id="worklistTable.ScheduledProcedureStepStartDate"
  269. defaultMessage="worklistTable.ScheduledProcedureStepStartDate"
  270. />
  271. ),
  272. dataIndex: 'ScheduledProcedureStepStartDate',
  273. },
  274. {
  275. title: (
  276. <FormattedMessage
  277. id="worklistTable.StudyLock"
  278. defaultMessage="worklistTable.StudyLock"
  279. />
  280. ),
  281. dataIndex: 'StudyLock',
  282. },
  283. {
  284. title: (
  285. <FormattedMessage
  286. id="worklistTable.OperatorID"
  287. defaultMessage="worklistTable.OperatorID"
  288. />
  289. ),
  290. dataIndex: 'OperatorID',
  291. },
  292. {
  293. title: (
  294. <FormattedMessage
  295. id="worklistTable.Modality"
  296. defaultMessage="worklistTable.Modality"
  297. />
  298. ),
  299. dataIndex: 'Modality',
  300. },
  301. {
  302. title: (
  303. <FormattedMessage
  304. id="worklistTable.Views"
  305. defaultMessage="worklistTable.Views"
  306. />
  307. ),
  308. dataIndex: 'Views',
  309. },
  310. {
  311. title: (
  312. <FormattedMessage
  313. id="worklistTable.Thickness"
  314. defaultMessage="worklistTable.Thickness"
  315. />
  316. ),
  317. dataIndex: 'Thickness',
  318. },
  319. {
  320. title: (
  321. <FormattedMessage
  322. id="worklistTable.PatientType"
  323. defaultMessage="worklistTable.PatientType"
  324. />
  325. ),
  326. dataIndex: 'PatientType',
  327. },
  328. {
  329. title: (
  330. <FormattedMessage
  331. id="worklistTable.StudyType"
  332. defaultMessage="worklistTable.StudyType"
  333. />
  334. ),
  335. dataIndex: 'StudyType',
  336. },
  337. {
  338. title: (
  339. <FormattedMessage
  340. id="worklistTable.QRCode"
  341. defaultMessage="worklistTable.QRCode"
  342. />
  343. ),
  344. dataIndex: 'QRCode',
  345. },
  346. {
  347. title: (
  348. <FormattedMessage
  349. id="worklistTable.IsExported"
  350. defaultMessage="worklistTable.IsExported"
  351. />
  352. ),
  353. dataIndex: 'IsExported',
  354. },
  355. {
  356. title: (
  357. <FormattedMessage
  358. id="worklistTable.IsEdited"
  359. defaultMessage="worklistTable.IsEdited"
  360. />
  361. ),
  362. dataIndex: 'IsEdited',
  363. },
  364. {
  365. title: (
  366. <FormattedMessage
  367. id="worklistTable.WorkRef"
  368. defaultMessage="worklistTable.WorkRef"
  369. />
  370. ),
  371. dataIndex: 'WorkRef',
  372. },
  373. {
  374. title: (
  375. <FormattedMessage
  376. id="worklistTable.IsAppended"
  377. defaultMessage="worklistTable.IsAppended"
  378. />
  379. ),
  380. dataIndex: 'IsAppended',
  381. },
  382. {
  383. title: (
  384. <FormattedMessage
  385. id="worklistTable.CreationTime"
  386. defaultMessage="worklistTable.CreationTime"
  387. />
  388. ),
  389. dataIndex: 'CreationTime',
  390. },
  391. {
  392. title: (
  393. <FormattedMessage
  394. id="worklistTable.MappedStatus"
  395. defaultMessage="worklistTable.MappedStatus"
  396. />
  397. ),
  398. dataIndex: 'MappedStatus',
  399. },
  400. {
  401. title: (
  402. <FormattedMessage
  403. id="worklistTable.IsDelete"
  404. defaultMessage="worklistTable.IsDelete"
  405. />
  406. ),
  407. dataIndex: 'IsDelete',
  408. },
  409. {
  410. title: (
  411. <FormattedMessage
  412. id="worklistTable.ExposureProgress"
  413. defaultMessage="曝光进度"
  414. />
  415. ),
  416. dataIndex: 'ExposureProgress',
  417. render: (_: any, record: Task) => {
  418. // 如果没有 StudyID,说明是 RIS 数据,不需要显示曝光进度
  419. if (!record.StudyID || record.StudyID.trim() === '') {
  420. return '-'; // RIS 数据显示 "-"
  421. }
  422. const progress = exposureProgressMap?.[record.StudyID];
  423. if (!progress) {
  424. return '加载中...'; // 数据还未加载完成
  425. }
  426. return `${progress.exposedCount}/${progress.totalCount}`;
  427. }
  428. },
  429. ];
  430. // 根据 productName 和 exposureProgressMap 生成列定义
  431. const columnsDef = useMemo(() => {
  432. const cols = generateColumnsDef(productName);
  433. return cols;
  434. }, [productName, exposureProgressMap]); // 依赖 productName 和 exposureProgressMap 的变化
  435. // 判断是否使用双ID模式
  436. const useDualIdMode = selectedSecondaryIds !== undefined;
  437. // 生成 rowKey
  438. const getRowKey = (record: Task): string => {
  439. if (useDualIdMode) {
  440. // 新模式:使用 StudyID + entry_id 组合
  441. return `${record.StudyID || 'no-study'}_${record.entry_id || 'no-entry'}`;
  442. }
  443. // 兼容模式:仅使用 StudyID
  444. return record.StudyID;
  445. };
  446. // 判断记录是否被选中
  447. const isRecordSelected = (record: Task): boolean => {
  448. if (useDualIdMode) {
  449. // 新模式:检查双ID
  450. if (record.StudyID) {
  451. const indexByStudyId = selectedIds.indexOf(record.StudyID);
  452. // 关键修复:检查索引是否有效,避免 undefined === undefined 的误判
  453. if (indexByStudyId === -1) return false;
  454. // 修复:保持与 useMultiSelection 一致,使用 ?? '' 处理 undefined
  455. return (selectedSecondaryIds[indexByStudyId] ?? '') === (record.entry_id ?? '');
  456. } else if (record.entry_id) {
  457. const indexByEntryId = selectedSecondaryIds.indexOf(record.entry_id);
  458. // 关键修复:检查索引是否有效,避免 undefined === undefined 的误判
  459. if (indexByEntryId === -1) return false;
  460. // 修复:保持与 useMultiSelection 一致,使用 ?? '' 处理 undefined
  461. return (selectedIds[indexByEntryId] ?? '') === (record.StudyID ?? '');
  462. }
  463. throw new Error('Record must have at least StudyID or entry_id defined in dual ID mode.');
  464. } else {
  465. // 兼容模式:仅检查 StudyID
  466. return selectedIds.includes(record.StudyID);
  467. }
  468. };
  469. // 根据传入的配置过滤和排序列
  470. const visibleColumns = useMemo(() => {
  471. // 如果没有配置,显示所有列(保持当前行为)
  472. if (columnConfig.length === 0) {
  473. return columnsDef.map((col) => ({
  474. ...col,
  475. width: 150,
  476. }));
  477. }
  478. // 根据配置过滤出可见的列
  479. return columnsDef
  480. .filter((col) => {
  481. const config = columnConfig.find((c) => c.key === col.dataIndex);
  482. return config?.visible ?? false;
  483. })
  484. .map((col) => {
  485. const config = columnConfig.find((c) => c.key === col.dataIndex);
  486. return {
  487. ...col,
  488. width: config?.width ?? 150,
  489. };
  490. })
  491. .sort((a, b) => {
  492. const orderA =
  493. columnConfig.find((c) => c.key === a.dataIndex)?.order ?? 999;
  494. const orderB =
  495. columnConfig.find((c) => c.key === b.dataIndex)?.order ?? 999;
  496. return orderA - orderB;
  497. });
  498. }, [columnConfig, columnsDef]);
  499. // 列可调整大小的逻辑
  500. const [columns, setColumns] =
  501. useState<TableColumnsType<DataType>>(visibleColumns);
  502. useEffect(() => {
  503. setColumns(visibleColumns);
  504. }, [visibleColumns, exposureProgressMap]);
  505. const handleResize =
  506. (index: number) =>
  507. (_: React.SyntheticEvent<Element>, { size }: ResizeCallbackData) => {
  508. console.log('Resizing column:', index, size);
  509. const newColumns = [...columns];
  510. newColumns[index] = {
  511. ...newColumns[index],
  512. width: size.width,
  513. };
  514. setColumns(newColumns);
  515. };
  516. const mergedColumns = columns.map<TableColumnsType<DataType>[number]>(
  517. (col, index) => ({
  518. ...col,
  519. onHeaderCell: (column: TableColumnsType<DataType>[number]) => ({
  520. width: column.width,
  521. onResize: handleResize(index) as React.ReactEventHandler<HTMLElement>,
  522. style: { whiteSpace: 'nowrap' },
  523. }),
  524. onCell: () => ({ style: { whiteSpace: 'nowrap' } }),
  525. })
  526. );
  527. return (
  528. <Table<DataType>
  529. bordered
  530. className={className}
  531. columns={mergedColumns}
  532. scroll={{ x: 'max-content' }}
  533. components={{ header: { cell: ResizableTitle } }}
  534. dataSource={worklistData}
  535. rowKey={getRowKey}
  536. pagination={false}
  537. onRow={(record, index) => {
  538. const isSelected = isRecordSelected(record);
  539. const { handleTouchStart } = useTouchDoubleClick({
  540. onDoubleClick: () => handleRowDoubleClick(record),
  541. delay: 300,
  542. });
  543. return {
  544. onClick: (event) => handleRowClick(record, event),
  545. onDoubleClick: () => handleRowDoubleClick(record),
  546. onTouchStart: (e) => {
  547. handleTouchStart(e);
  548. },
  549. 'data-testid': `row-${index}`,
  550. style: {
  551. cursor: 'pointer',
  552. userSelect: 'none', // 防止文本选择
  553. backgroundColor: isSelected
  554. ? 'rgba(255, 255, 0, 0.3)'
  555. : 'transparent',
  556. },
  557. };
  558. }}
  559. rowHoverable={false}
  560. rowClassName={(record) =>
  561. isRecordSelected(record)
  562. ? 'bg-yellow-500 hover:bg-yellow-800'
  563. : ''
  564. }
  565. />
  566. );
  567. };
  568. export default WorklistTable;