| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- import React, { useState, useEffect, useMemo } from 'react';
- import { Table, TableColumnsType } from 'antd';
- import { FormattedMessage } from 'react-intl';
- import { Task, Task as DataType } from '@/domain/work';
- import type { ResizeCallbackData } from 'react-resizable';
- import { Resizable } from 'react-resizable';
- import { WorkFilter } from '@/states/patient/worklist/types/workfilter';
- import { useTouchDoubleClick } from '@/hooks/useTouchDoubleClick';
- import { useMultiSelection } from '@/hooks/useMultiSelection';
- import { ColumnConfig } from '@/config/tableColumns/types/columnConfig';
- import { ExposureProgress } from '@/domain/patient/getExposureProgress';
- interface TitlePropsType {
- width: number;
- onResize: (
- e: React.SyntheticEvent<Element>,
- data: ResizeCallbackData
- ) => void;
- }
- const ResizableTitle: React.FC<
- Readonly<
- React.HTMLAttributes<HTMLTableCellElement | Resizable> & TitlePropsType
- >
- > = (props) => {
- const { onResize, width, ...restProps } = props;
- if (!width) {
- return <th {...restProps} />;
- }
- return (
- <Resizable
- width={width}
- height={0}
- minConstraints={[50, 0]} // 最小宽度 50px
- // maxConstraints={[500, 0]} // 最大宽度 500px
- handle={
- <span
- style={{
- position: 'absolute',
- right: '-5px', // 或 insetInlineEnd: "-5px"(支持逻辑属性)
- bottom: '0',
- zIndex: 1,
- width: '10px',
- height: '100%',
- cursor: 'col-resize',
- }}
- onClick={(e) => e.stopPropagation()}
- />
- }
- onResize={onResize}
- draggableOpts={{ enableUserSelectHack: false }}
- >
- <th {...restProps} />
- </Resizable>
- );
- };
- interface WorklistTableProps {
- productName?: string; // 新增:产品名称(用于区分人医/宠物医)
- columnConfig?: ColumnConfig[]; // 列配置(可选)
- exposureProgressMap?: Record<string, ExposureProgress>; // 新增:曝光进度映射
- worklistData: Task[];
- filters?: WorkFilter;
- page?: number;
- pageSize?: number;
- selectedIds: string[]; // 必填(保持向后兼容)
- selectedSecondaryIds?: string[]; // 可选(新增)
- handleRowClick: (record: Task, event?: React.MouseEvent) => void;
- handleRowDoubleClick: (record: Task) => void;
- className?: string;
- }
- const WorklistTable: React.FC<WorklistTableProps> = ({
- productName, // 新增
- columnConfig = [], // 接收配置,默认为空数组
- exposureProgressMap = null, // 新增:曝光进度映射
- worklistData,
- // filters,
- // page,
- // pageSize,
- selectedIds,
- selectedSecondaryIds, // 可能为 undefined
- handleRowClick,
- handleRowDoubleClick,
- className,
- }) => {
- // 生成列定义的辅助函数
- const generateColumnsDef = (productName?: string): any[] => [
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyInstanceUID"
- defaultMessage="worklistTable.StudyInstanceUID"
- />
- ),
- dataIndex: 'StudyInstanceUID',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyID"
- defaultMessage="worklistTable.StudyID"
- />
- ),
- dataIndex: 'StudyID',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.SpecificCharacterSet"
- defaultMessage="worklistTable.SpecificCharacterSet"
- />
- ),
- dataIndex: 'SpecificCharacterSet',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.AccessionNumber"
- defaultMessage="worklistTable.AccessionNumber"
- />
- ),
- dataIndex: 'AccessionNumber',
- },
- {
- title: (
- <FormattedMessage
- id={productName === 'VETDROS' ? 'animal.worklistTable.PatientID' : 'worklistTable.PatientID'}
- defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientID' : 'worklistTable.PatientID'}
- />
- ),
- dataIndex: 'PatientID',
- },
- {
- title: (
- <FormattedMessage
- id={productName === 'VETDROS' ? 'animal.worklistTable.PatientName' : 'worklistTable.PatientName'}
- defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientName' : 'worklistTable.PatientName'}
- />
- ),
- dataIndex: 'PatientName',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.DisplayPatientName"
- defaultMessage="worklistTable.DisplayPatientName"
- />
- ),
- dataIndex: 'DisplayPatientName',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.PatientSize"
- defaultMessage="worklistTable.PatientSize"
- />
- ),
- dataIndex: 'PatientSize',
- },
- {
- title: (
- <FormattedMessage
- id={productName === 'VETDROS' ? 'animal.worklistTable.PatientAge' : 'worklistTable.PatientAge'}
- defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientAge' : 'worklistTable.PatientAge'}
- />
- ),
- dataIndex: 'PatientAge',
- },
- {
- title: (
- <FormattedMessage
- id={productName === 'VETDROS' ? 'animal.worklistTable.PatientSex' : 'worklistTable.PatientSex'}
- defaultMessage={productName === 'VETDROS' ? 'animal.worklistTable.PatientSex' : 'worklistTable.PatientSex'}
- />
- ),
- dataIndex: 'PatientSex',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.AdmittingTime"
- defaultMessage="worklistTable.AdmittingTime"
- />
- ),
- dataIndex: 'AdmittingTime',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.RegSource"
- defaultMessage="worklistTable.RegSource"
- />
- ),
- dataIndex: 'RegSource',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyStatus"
- defaultMessage="worklistTable.StudyStatus"
- />
- ),
- dataIndex: 'StudyStatus',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.RequestedProcedureID"
- defaultMessage="worklistTable.RequestedProcedureID"
- />
- ),
- dataIndex: 'RequestedProcedureID',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.PerformedProtocolCodeValue"
- defaultMessage="worklistTable.PerformedProtocolCodeValue"
- />
- ),
- dataIndex: 'PerformedProtocolCodeValue',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.PerformedProtocolCodeMeaning"
- defaultMessage="worklistTable.PerformedProtocolCodeMeaning"
- />
- ),
- dataIndex: 'PerformedProtocolCodeMeaning',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.PerformedProcedureStepID"
- defaultMessage="worklistTable.PerformedProcedureStepID"
- />
- ),
- dataIndex: 'PerformedProcedureStepID',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyDescription"
- defaultMessage="worklistTable.StudyDescription"
- />
- ),
- dataIndex: 'StudyDescription',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyStartDatetime"
- defaultMessage="worklistTable.StudyStartDatetime"
- />
- ),
- dataIndex: 'StudyStartDatetime',
- render: (_: any, record: Task) => {
- if (!record.StudyStartDatetime || typeof record.StudyStartDatetime !== 'object') {
- return '';
- }
- const { seconds, nanos } = record.StudyStartDatetime;
- const date = new Date(seconds * 1000 + Math.floor(nanos / 1000000));
- // 格式化为本地时间字符串
- return date.toLocaleString();
- }
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.ScheduledProcedureStepStartDate"
- defaultMessage="worklistTable.ScheduledProcedureStepStartDate"
- />
- ),
- dataIndex: 'ScheduledProcedureStepStartDate',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyLock"
- defaultMessage="worklistTable.StudyLock"
- />
- ),
- dataIndex: 'StudyLock',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.OperatorID"
- defaultMessage="worklistTable.OperatorID"
- />
- ),
- dataIndex: 'OperatorID',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.Modality"
- defaultMessage="worklistTable.Modality"
- />
- ),
- dataIndex: 'Modality',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.Views"
- defaultMessage="worklistTable.Views"
- />
- ),
- dataIndex: 'Views',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.Thickness"
- defaultMessage="worklistTable.Thickness"
- />
- ),
- dataIndex: 'Thickness',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.PatientType"
- defaultMessage="worklistTable.PatientType"
- />
- ),
- dataIndex: 'PatientType',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.StudyType"
- defaultMessage="worklistTable.StudyType"
- />
- ),
- dataIndex: 'StudyType',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.QRCode"
- defaultMessage="worklistTable.QRCode"
- />
- ),
- dataIndex: 'QRCode',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.IsExported"
- defaultMessage="worklistTable.IsExported"
- />
- ),
- dataIndex: 'IsExported',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.IsEdited"
- defaultMessage="worklistTable.IsEdited"
- />
- ),
- dataIndex: 'IsEdited',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.WorkRef"
- defaultMessage="worklistTable.WorkRef"
- />
- ),
- dataIndex: 'WorkRef',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.IsAppended"
- defaultMessage="worklistTable.IsAppended"
- />
- ),
- dataIndex: 'IsAppended',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.CreationTime"
- defaultMessage="worklistTable.CreationTime"
- />
- ),
- dataIndex: 'CreationTime',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.MappedStatus"
- defaultMessage="worklistTable.MappedStatus"
- />
- ),
- dataIndex: 'MappedStatus',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.IsDelete"
- defaultMessage="worklistTable.IsDelete"
- />
- ),
- dataIndex: 'IsDelete',
- },
- {
- title: (
- <FormattedMessage
- id="worklistTable.ExposureProgress"
- defaultMessage="曝光进度"
- />
- ),
- dataIndex: 'ExposureProgress',
- render: (_: any, record: Task) => {
- // 如果没有 StudyID,说明是 RIS 数据,不需要显示曝光进度
- if (!record.StudyID || record.StudyID.trim() === '') {
- return '-'; // RIS 数据显示 "-"
- }
- const progress = exposureProgressMap?.[record.StudyID];
- if (!progress) {
- return '加载中...'; // 数据还未加载完成
- }
- return `${progress.exposedCount}/${progress.totalCount}`;
- }
- },
- ];
- // 根据 productName 和 exposureProgressMap 生成列定义
- const columnsDef = useMemo(() => {
- const cols = generateColumnsDef(productName);
- return cols;
- }, [productName, exposureProgressMap]); // 依赖 productName 和 exposureProgressMap 的变化
- // 判断是否使用双ID模式
- const useDualIdMode = selectedSecondaryIds !== undefined;
- // 生成 rowKey
- const getRowKey = (record: Task): string => {
- if (useDualIdMode) {
- // 新模式:使用 StudyID + entry_id 组合
- return `${record.StudyID || 'no-study'}_${record.entry_id || 'no-entry'}`;
- }
- // 兼容模式:仅使用 StudyID
- return record.StudyID;
- };
- // 判断记录是否被选中
- const isRecordSelected = (record: Task): boolean => {
- if (useDualIdMode) {
- // 新模式:检查双ID
- if (record.StudyID) {
- const indexByStudyId = selectedIds.indexOf(record.StudyID);
- // 关键修复:检查索引是否有效,避免 undefined === undefined 的误判
- if (indexByStudyId === -1) return false;
- // 修复:保持与 useMultiSelection 一致,使用 ?? '' 处理 undefined
- return (selectedSecondaryIds[indexByStudyId] ?? '') === (record.entry_id ?? '');
- } else if (record.entry_id) {
- const indexByEntryId = selectedSecondaryIds.indexOf(record.entry_id);
- // 关键修复:检查索引是否有效,避免 undefined === undefined 的误判
- if (indexByEntryId === -1) return false;
- // 修复:保持与 useMultiSelection 一致,使用 ?? '' 处理 undefined
- return (selectedIds[indexByEntryId] ?? '') === (record.StudyID ?? '');
- }
- throw new Error('Record must have at least StudyID or entry_id defined in dual ID mode.');
- } else {
- // 兼容模式:仅检查 StudyID
- return selectedIds.includes(record.StudyID);
- }
- };
- // 根据传入的配置过滤和排序列
- const visibleColumns = useMemo(() => {
- // 如果没有配置,显示所有列(保持当前行为)
- if (columnConfig.length === 0) {
- return columnsDef.map((col) => ({
- ...col,
- width: 150,
- }));
- }
- // 根据配置过滤出可见的列
- return columnsDef
- .filter((col) => {
- const config = columnConfig.find((c) => c.key === col.dataIndex);
- return config?.visible ?? false;
- })
- .map((col) => {
- const config = columnConfig.find((c) => c.key === col.dataIndex);
- return {
- ...col,
- width: config?.width ?? 150,
- };
- })
- .sort((a, b) => {
- const orderA =
- columnConfig.find((c) => c.key === a.dataIndex)?.order ?? 999;
- const orderB =
- columnConfig.find((c) => c.key === b.dataIndex)?.order ?? 999;
- return orderA - orderB;
- });
- }, [columnConfig, columnsDef]);
- // 列可调整大小的逻辑
- const [columns, setColumns] =
- useState<TableColumnsType<DataType>>(visibleColumns);
- useEffect(() => {
- setColumns(visibleColumns);
- }, [visibleColumns, exposureProgressMap]);
- const handleResize =
- (index: number) =>
- (_: React.SyntheticEvent<Element>, { size }: ResizeCallbackData) => {
- console.log('Resizing column:', index, size);
- const newColumns = [...columns];
- newColumns[index] = {
- ...newColumns[index],
- width: size.width,
- };
- setColumns(newColumns);
- };
- const mergedColumns = columns.map<TableColumnsType<DataType>[number]>(
- (col, index) => ({
- ...col,
- onHeaderCell: (column: TableColumnsType<DataType>[number]) => ({
- width: column.width,
- onResize: handleResize(index) as React.ReactEventHandler<HTMLElement>,
- style: { whiteSpace: 'nowrap' },
- }),
- onCell: () => ({ style: { whiteSpace: 'nowrap' } }),
- })
- );
- return (
- <Table<DataType>
- bordered
- className={className}
- columns={mergedColumns}
- scroll={{ x: 'max-content' }}
- components={{ header: { cell: ResizableTitle } }}
- dataSource={worklistData}
- rowKey={getRowKey}
- pagination={false}
- onRow={(record, index) => {
- const isSelected = isRecordSelected(record);
- const { handleTouchStart } = useTouchDoubleClick({
- onDoubleClick: () => handleRowDoubleClick(record),
- delay: 300,
- });
- return {
- onClick: (event) => handleRowClick(record, event),
- onDoubleClick: () => handleRowDoubleClick(record),
- onTouchStart: (e) => {
- handleTouchStart(e);
- },
- 'data-testid': `row-${index}`,
- style: {
- cursor: 'pointer',
- userSelect: 'none', // 防止文本选择
- backgroundColor: isSelected
- ? 'rgba(255, 255, 0, 0.3)'
- : 'transparent',
- },
- };
- }}
- rowHoverable={false}
- rowClassName={(record) =>
- isRecordSelected(record)
- ? 'bg-yellow-500 hover:bg-yellow-800'
- : ''
- }
- />
- );
- };
- export default WorklistTable;
|