worklist.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import React, { useState, useEffect } from 'react';
  2. import { Row, Col, Button, Drawer } from 'antd';
  3. import { SettingOutlined } from '@ant-design/icons';
  4. import { FormattedMessage } from 'react-intl';
  5. import { useSelector, useDispatch } from 'react-redux';
  6. import { useRisAutoSync } from '@/hooks/useRisAutoSync';
  7. import {
  8. fetchWorkThunk,
  9. workSelectionSlice,
  10. workPaginationSlice,
  11. workFiltersSlice,
  12. } from '../../states/patient/worklist/slices/workSlice';
  13. import { updateThumbnailsFromWorkSelection } from '../../states/patient/worklist/slices/thumbnailListSlice';
  14. import WorklistTable from './components/WorklistTable';
  15. import OperationPanel from './components/OperationPanel';
  16. import ThumbnailList from './components/ThumbnailList';
  17. import GenericPagination from '../../components/GenericPagination';
  18. import PatientPortraitFloat from './components/PatientPortraitFloat';
  19. import { RootState, AppDispatch } from '../../states/store';
  20. import { Task } from '@/domain/work';
  21. import worklistToExam from '../../domain/patient/worklistToExam';
  22. import { saveRisData } from '../../domain/patient/risSaveLogic';
  23. import { columnConfigService } from '@/config/tableColumns';
  24. import { ColumnConfig } from '@/config/tableColumns/types/columnConfig';
  25. import { useMultiSelection } from '@/hooks/useMultiSelection';
  26. import { getExposureProgress, ExposureProgress } from '@/domain/patient/getExposureProgress';
  27. import AppendViewModal from '../exam/components/AppendViewModal';
  28. import useEffectiveBreakpoint from '../../hooks/useEffectiveBreakpoint';
  29. const WorklistPage: React.FC = () => {
  30. const screens = useEffectiveBreakpoint();
  31. const [drawerVisible, setDrawerVisible] = useState(false);
  32. const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>([]); // 新增:列配置状态
  33. const [exposureProgressMap, setExposureProgressMap] = useState<Record<string, ExposureProgress>>({}); // 曝光进度映射
  34. const [selectedPatientForPortrait, setSelectedPatientForPortrait] = useState<Task | null>(null); // 照片显示用的选中患者
  35. const [isAppendModalOpen, setIsAppendModalOpen] = useState(false); // 追加体位模态框状态
  36. const [currentStudy, setCurrentStudy] = useState<Task | null>(null); // 当前保存的study信息
  37. // 启用RIS自动同步(在后台静默运行)
  38. useRisAutoSync();
  39. const dispatch: AppDispatch = useDispatch();
  40. const filters = useSelector((state: RootState) => state.workFilters);
  41. const page = useSelector((state: RootState) => state.workPagination.page);
  42. const pageSize = useSelector(
  43. (state: RootState) => state.workPagination.pageSize
  44. );
  45. const selectedIds = useSelector(
  46. (state: RootState) => state.workSelection.selectedIds
  47. );
  48. const selectedSecondaryIds = useSelector( // 获取次要选中ID列表
  49. (state: RootState) => state.workSelection.selectedSecondaryIds
  50. );
  51. const worklistData = useSelector(
  52. (state: RootState) => state.workEntities.data
  53. );
  54. const productName = useSelector(
  55. (state: RootState) => state.product.productName
  56. );
  57. // 新增:获取列配置
  58. useEffect(() => {
  59. columnConfigService
  60. .getColumnConfig('worklist')
  61. .then((config) => {
  62. setColumnConfig(config.columns);
  63. })
  64. .catch((error) => {
  65. console.error('Failed to load worklist column config:', error);
  66. // 失败时使用空配置,表格会显示所有列
  67. setColumnConfig([]);
  68. });
  69. }, []);
  70. // 批量获取曝光进度
  71. useEffect(() => {
  72. const fetchExposureProgress = async () => {
  73. if (worklistData.length === 0) return;
  74. const progressMap: Record<string, ExposureProgress> = {};
  75. // 并发获取曝光进度,但限制并发数量避免过多请求
  76. const batchSize = 5;
  77. for (let i = 0; i < worklistData.length; i += batchSize) {
  78. const batch = worklistData.slice(i, i + batchSize);
  79. await Promise.all(
  80. batch.map(async (task) => {
  81. // 跳过没有 StudyID 的记录(主要是 RIS 数据)
  82. if (!task.StudyID || task.StudyID.trim() === '') {
  83. console.warn(`[getExposureProgress] Skipping task without StudyID:`, task);
  84. return;
  85. }
  86. try {
  87. console.log(`[getExposureProgress] Fetching progress for StudyID: ${task.StudyID}`);
  88. const progress = await getExposureProgress(task.StudyID);
  89. progressMap[task.StudyID] = progress;
  90. console.log(`[getExposureProgress] Got progress for ${task.StudyID}:`, progress);
  91. } catch (error) {
  92. console.error(`[getExposureProgress] Failed to get progress for ${task.StudyID}:`, error);
  93. progressMap[task.StudyID] = { exposedCount: 0, totalCount: 0 };
  94. }
  95. })
  96. );
  97. }
  98. setExposureProgressMap({ ...progressMap }); // 使用展开运算符创建新对象
  99. };
  100. fetchExposureProgress();
  101. }, [worklistData]);
  102. // 同步分页状态到过滤器
  103. useEffect(() => {
  104. console.log('[worklist] Syncing pagination to filters:', {
  105. page,
  106. pageSize,
  107. });
  108. dispatch(
  109. workFiltersSlice.actions.setFilters({
  110. page,
  111. page_size: pageSize,
  112. })
  113. );
  114. }, [dispatch, page, pageSize]);
  115. useEffect(() => {
  116. console.log(
  117. '[worklist] Fetching worklist data with filters:',
  118. filters,
  119. 'page:',
  120. page,
  121. 'pageSize:',
  122. pageSize
  123. );
  124. dispatch(fetchWorkThunk({ page, pageSize, filters }));
  125. }, [dispatch, page, pageSize, filters]);
  126. // 使用多选 Hook
  127. const { handleRowClick } = useMultiSelection({
  128. selectedIds,
  129. selectedSecondaryIds,
  130. onSelectionChange: (newIds, secondNewIds) => {
  131. console.log('Selected IDs changed:', newIds, secondNewIds);
  132. // 如果只有一个选中项,设置选中患者用于显示照片
  133. if (newIds.length === 1) {
  134. const selectedRecord = worklistData.find(item =>
  135. !item.entry_id && // 只处理本地数据(不是RIS数据)
  136. item.StudyID === newIds[0] && // StudyID匹配
  137. newIds[0] // 确保StudyID不为空
  138. );
  139. if (selectedRecord) {
  140. setSelectedPatientForPortrait(selectedRecord);
  141. }
  142. } else {
  143. // 多选时清空患者照片
  144. setSelectedPatientForPortrait(null);
  145. }
  146. // 更新 Redux 状态(用于其他功能)
  147. dispatch(workSelectionSlice.actions.setSelectedIds(newIds));
  148. dispatch(workSelectionSlice.actions.setSelectedSecondaryIds(secondNewIds ?? []));
  149. dispatch(updateThumbnailsFromWorkSelection(newIds));
  150. },
  151. enableMultiSelect: true,
  152. });
  153. // 处理行点击事件(兼容现有功能)
  154. const handleRowClickInternal = (record: Task, event?: React.MouseEvent) => {
  155. handleRowClick(record, event || {} as React.MouseEvent);
  156. };
  157. const handleRowDoubleClick = (record: Task) => {
  158. console.log(
  159. '[WorklistTable] Row double-clicked:',
  160. JSON.stringify(record, null, 2)
  161. );
  162. // 判断是否为RIS数据
  163. if (record.entry_id) {
  164. // RIS数据:触发保存到本地,保存成功后打开追加体位对话框
  165. saveRisData(record.entry_id, (task) => {
  166. console.log('[WorklistPage] RIS数据保存成功,Study ID:', task.StudyID, '打开追加体位对话框');
  167. setCurrentStudy(task);
  168. setIsAppendModalOpen(true);
  169. });
  170. } else {
  171. // 本地study数据:进入检查
  172. worklistToExam(record);
  173. }
  174. };
  175. return (
  176. <div className="h-full">
  177. {/* 患者照片浮动组件 */}
  178. <PatientPortraitFloat
  179. patient={selectedPatientForPortrait}
  180. onClose={() => setSelectedPatientForPortrait(null)}
  181. />
  182. {screens.xs ? (
  183. <>
  184. <div className="flex-1 overflow-auto">
  185. <WorklistTable
  186. productName={productName}
  187. columnConfig={columnConfig}
  188. exposureProgressMap={exposureProgressMap}
  189. worklistData={worklistData}
  190. filters={filters}
  191. page={page}
  192. pageSize={pageSize}
  193. selectedIds={selectedIds}
  194. selectedSecondaryIds={selectedSecondaryIds} // 新增
  195. handleRowClick={handleRowClickInternal}
  196. handleRowDoubleClick={handleRowDoubleClick}
  197. />
  198. </div>
  199. <GenericPagination
  200. paginationSelector={(state) => state.workPagination}
  201. entitiesSelector={(state) => state.workEntities}
  202. paginationActions={workPaginationSlice.actions}
  203. className="border-t"
  204. />
  205. <Button
  206. type="primary"
  207. shape="circle"
  208. icon={<SettingOutlined />}
  209. className="fixed bottom-6 right-6 z-50"
  210. onClick={() => setDrawerVisible(true)}
  211. />
  212. <Drawer
  213. title={
  214. <FormattedMessage
  215. id="worklist.operationPanel"
  216. defaultMessage="worklist.operationPanel"
  217. />
  218. }
  219. placement="left"
  220. onClose={() => setDrawerVisible(false)}
  221. open={drawerVisible}
  222. width={300}
  223. >
  224. <OperationPanel />
  225. </Drawer>
  226. </>
  227. ) : (
  228. <Row className="h-full">
  229. <Col
  230. span={screens.lg ? 18 : screens.md ? 20 : 24}
  231. className="h-full flex flex-col"
  232. >
  233. <div className="flex flex-col h-[80%]">
  234. <div className="overflow-auto flex flex-col">
  235. <WorklistTable
  236. productName={productName}
  237. columnConfig={columnConfig}
  238. exposureProgressMap={exposureProgressMap}
  239. worklistData={worklistData}
  240. filters={filters}
  241. page={page}
  242. pageSize={pageSize}
  243. selectedIds={selectedIds}
  244. selectedSecondaryIds={selectedSecondaryIds} // 新增
  245. handleRowClick={handleRowClickInternal}
  246. handleRowDoubleClick={handleRowDoubleClick}
  247. className='flex-1 overflow-auto'
  248. />
  249. </div>
  250. <GenericPagination
  251. paginationSelector={(state) => state.workPagination}
  252. entitiesSelector={(state) => state.workEntities}
  253. paginationActions={workPaginationSlice.actions}
  254. className="border-t"
  255. />
  256. </div>
  257. <div className=" overflow-auto">
  258. <ThumbnailList />
  259. </div>
  260. </Col>
  261. <Col
  262. span={screens.lg ? 6 : screens.md ? 4 : 0}
  263. className="h-full overflow-auto"
  264. >
  265. <OperationPanel />
  266. </Col>
  267. </Row>
  268. )}
  269. {/* 追加体位模态框 */}
  270. <AppendViewModal
  271. open={isAppendModalOpen}
  272. onCancel={() => {
  273. setIsAppendModalOpen(false);
  274. setCurrentStudy(null); // 关闭时清空study信息
  275. }}
  276. studyId={currentStudy?.StudyID}
  277. currentWork={currentStudy}
  278. onSuccess={() => {
  279. setIsAppendModalOpen(false);
  280. // 追加成功后选中该study,然后进入检查
  281. if (currentStudy) {
  282. const studyId = currentStudy.StudyID;
  283. const entryId = currentStudy.entry_id;
  284. // 更新选中状态
  285. dispatch(workSelectionSlice.actions.setSelectedIds([studyId]));
  286. dispatch(workSelectionSlice.actions.setSelectedSecondaryIds(entryId ? [entryId] : []));
  287. console.log('[WorklistPage] 追加成功,选中study:', studyId, '进入检查');
  288. worklistToExam(currentStudy);
  289. }
  290. }}
  291. />
  292. </div>
  293. );
  294. };
  295. export default WorklistPage;