PatientPortraitFloat.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { getPortraitUrl, hasPortrait } from '@/API/patient/portraitActions';
  3. import { CloseOutlined, LoadingOutlined } from '@ant-design/icons';
  4. import { Spin } from 'antd';
  5. import { Task } from '@/domain/work';
  6. /**
  7. * 患者照片浮动显示组件 Props
  8. */
  9. interface PatientPortraitFloatProps {
  10. /** 患者数据,直接从 handleRowClick 传入 */
  11. patient: Task | null;
  12. /** 关闭回调 */
  13. onClose?: () => void;
  14. }
  15. /**
  16. * 患者照片浮动显示组件
  17. *
  18. * 功能特性:
  19. * 1. 浮动在页面右上角(可拖拽调整位置)
  20. * 2. 显示后3秒自动关闭,带动态倒计时
  21. * 3. 提供手动关闭按钮
  22. * 4. 只在有患者且有照片时显示
  23. * 5. 支持加载状态和错误处理
  24. */
  25. const PatientPortraitFloat: React.FC<PatientPortraitFloatProps> = ({
  26. patient,
  27. onClose
  28. }) => {
  29. const [isVisible, setIsVisible] = useState(false);
  30. const [imageLoading, setImageLoading] = useState(false);
  31. const [imageError, setImageError] = useState(false);
  32. const [imageUrl, setImageUrl] = useState<string>('');
  33. const [countdown, setCountdown] = useState(3); // 倒计时状态
  34. // 拖拽相关状态
  35. const [position, setPosition] = useState({ x: 20, y: 20 }); // 初始位置:右上角
  36. const [isDragging, setIsDragging] = useState(false);
  37. const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
  38. const floatRef = useRef<HTMLDivElement>(null);
  39. // 定时器引用
  40. const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
  41. // 关闭组件
  42. const handleClose = () => {
  43. setIsVisible(false);
  44. if (countdownIntervalRef.current) {
  45. clearInterval(countdownIntervalRef.current);
  46. }
  47. onClose?.(); // 调用父组件的关闭回调
  48. };
  49. // 监听患者变化
  50. useEffect(() => {
  51. if (patient && hasPortrait(patient.portrait_status)) {
  52. // 有照片,显示组件
  53. setIsVisible(true);
  54. setImageLoading(true);
  55. setImageError(false);
  56. setCountdown(3); // 重置倒计时
  57. const url = getPortraitUrl(patient.portrait_file || '');
  58. console.log(`得到的照片路径是: ${url}`);
  59. setImageUrl(url);
  60. // 清除旧的定时器
  61. if (countdownIntervalRef.current) {
  62. clearInterval(countdownIntervalRef.current);
  63. }
  64. // 启动倒计时定时器,每秒更新一次
  65. countdownIntervalRef.current = setInterval(() => {
  66. setCountdown((prev) => {
  67. if (prev <= 1) {
  68. handleClose();
  69. return 0;
  70. }
  71. return prev - 1;
  72. });
  73. }, 1000);
  74. } else {
  75. // 无照片或未选中,隐藏组件
  76. setIsVisible(false);
  77. setImageUrl('');
  78. if (countdownIntervalRef.current) {
  79. clearInterval(countdownIntervalRef.current);
  80. }
  81. }
  82. return () => {
  83. if (countdownIntervalRef.current) {
  84. clearInterval(countdownIntervalRef.current);
  85. }
  86. };
  87. }, [patient, onClose]);
  88. // 图片加载完成
  89. const handleImageLoad = () => {
  90. setImageLoading(false);
  91. setImageError(false);
  92. };
  93. // 图片加载失败
  94. const handleImageError = () => {
  95. setImageLoading(false);
  96. setImageError(true);
  97. };
  98. // 拖拽开始
  99. const handleMouseDown = (e: React.MouseEvent) => {
  100. // 如果点击的是关闭按钮,不触发拖拽
  101. if ((e.target as HTMLElement).closest('.close-btn')) {
  102. return;
  103. }
  104. setIsDragging(true);
  105. setDragStart({
  106. x: e.clientX - position.x,
  107. y: e.clientY - position.y,
  108. });
  109. e.preventDefault();
  110. };
  111. // 拖拽中
  112. useEffect(() => {
  113. const handleMouseMove = (e: MouseEvent) => {
  114. if (isDragging) {
  115. const newX = e.clientX - dragStart.x;
  116. const newY = e.clientY - dragStart.y;
  117. // 限制在窗口范围内
  118. const maxX = window.innerWidth - (floatRef.current?.offsetWidth || 300);
  119. const maxY = window.innerHeight - (floatRef.current?.offsetHeight || 300);
  120. setPosition({
  121. x: Math.max(0, Math.min(newX, maxX)),
  122. y: Math.max(0, Math.min(newY, maxY)),
  123. });
  124. }
  125. };
  126. const handleMouseUp = () => {
  127. setIsDragging(false);
  128. };
  129. if (isDragging) {
  130. document.addEventListener('mousemove', handleMouseMove);
  131. document.addEventListener('mouseup', handleMouseUp);
  132. }
  133. return () => {
  134. document.removeEventListener('mousemove', handleMouseMove);
  135. document.removeEventListener('mouseup', handleMouseUp);
  136. };
  137. }, [isDragging, dragStart]);
  138. if (!isVisible) return null;
  139. return (
  140. <div
  141. ref={floatRef}
  142. className="fixed z-[1000] bg-white rounded-lg shadow-2xl"
  143. style={{
  144. top: `${position.y}px`,
  145. right: `${position.x}px`,
  146. width: '280px',
  147. maxWidth: '90vw',
  148. cursor: isDragging ? 'grabbing' : 'grab',
  149. transition: isDragging ? 'none' : 'all 0.3s ease-in-out',
  150. userSelect: 'none',
  151. }}
  152. onMouseDown={handleMouseDown}
  153. >
  154. {/* 标题栏 */}
  155. <div className="flex items-center justify-between p-2 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-blue-100 rounded-t-lg">
  156. <div className="flex items-center space-x-2">
  157. <span className="text-xs font-semibold text-gray-700">患者照片</span>
  158. {patient && (
  159. <span className="text-xs text-gray-500">
  160. {patient.PatientName}
  161. </span>
  162. )}
  163. <span className="text-xs font-bold text-blue-600">
  164. {countdown}秒
  165. </span>
  166. </div>
  167. <button
  168. className="close-btn w-5 h-5 flex items-center justify-center rounded-full hover:bg-gray-200 transition-colors"
  169. onClick={handleClose}
  170. title="关闭"
  171. >
  172. <CloseOutlined className="text-gray-600" style={{ fontSize: '10px' }} />
  173. </button>
  174. </div>
  175. {/* 照片区域 */}
  176. <div className="p-4">
  177. <div className="relative w-full aspect-[3/4] bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center">
  178. {imageLoading && (
  179. <div className="absolute inset-0 flex items-center justify-center bg-gray-50">
  180. <Spin indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
  181. </div>
  182. )}
  183. {imageError && !imageLoading && (
  184. <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 text-gray-400">
  185. <svg
  186. className="w-16 h-16 mb-2"
  187. fill="none"
  188. stroke="currentColor"
  189. viewBox="0 0 24 24"
  190. >
  191. <path
  192. strokeLinecap="round"
  193. strokeLinejoin="round"
  194. strokeWidth={1.5}
  195. d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
  196. />
  197. </svg>
  198. <span className="text-sm">照片加载失败</span>
  199. </div>
  200. )}
  201. {imageUrl && !imageError && (
  202. <img
  203. src={imageUrl}
  204. alt="患者照片"
  205. className="w-full h-full object-cover"
  206. onLoad={handleImageLoad}
  207. onError={handleImageError}
  208. style={{ display: imageLoading ? 'none' : 'block' }}
  209. />
  210. )}
  211. </div>
  212. </div>
  213. </div>
  214. );
  215. };
  216. export default PatientPortraitFloat;