worklist.tsx 12 KB

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