DailyMedicineQuantityChart.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import React, { useEffect, useState, useRef } from 'react';
  2. import * as echarts from 'echarts';
  3. import styled from 'styled-components';
  4. import { apiGet } from '../../utils/request';
  5. import { useDefaultDate, getSelectedMonthRange } from '../../App';
  6. import useGlobalRefresh from '../../hooks/useGlobalRefresh';
  7. // 组件容器样式 - 与第一个模块保持一致
  8. const ChartWrapper = styled.div`
  9. width: 100%;
  10. height: 100%;
  11. position: relative;
  12. overflow: hidden;
  13. border: 1px solid #2980B9;
  14. `;
  15. // 组件标题 - 使用与其他模块相同的样式
  16. const PanelHeader = styled.div`
  17. position: relative;
  18. width: 95%;
  19. height: 1.6vw;
  20. padding-left: 5.5%;
  21. text-align: left;
  22. line-height: 1.6vw;
  23. font-size: 0.8vw;
  24. color: #E6F2FF;
  25. font-family: 'DingTalk JinBuTi', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
  26. background: url('/component_header_bg.png') no-repeat;
  27. background-size: 100% 100%;
  28. `;
  29. // 图表容器 - 统一与其他模块一致的布局
  30. const ChartContainer = styled.div`
  31. width: 100%;
  32. height: calc(100% - 1.6vw);
  33. padding: 0.5vw;
  34. box-sizing: border-box;
  35. overflow: hidden;
  36. `;
  37. // 数据接口类型 - 根据新接口字段定义
  38. interface WeeklyDrugData {
  39. weekDay: number; // 星期(数字)
  40. weekDayLabel: string; // 星期(文本)
  41. totalCount: number; // 日均发药盒数
  42. machineCount: number; // 日均机发盒数
  43. directSendRate: number; // 机发率
  44. }
  45. // 这个函数已不再需要,将在fetchData中直接使用getSelectedMonthRange
  46. const DailyMedicineQuantityChart: React.FC = () => {
  47. const chartRef = useRef<HTMLDivElement>(null);
  48. const chartInstance = useRef<echarts.ECharts | null>(null);
  49. const [data, setData] = useState<WeeklyDrugData[]>([]);
  50. const [loading, setLoading] = useState(true);
  51. const { defaultDateTime, selectedMonth } = useDefaultDate();
  52. // 获取数据
  53. const fetchData = async () => {
  54. try {
  55. setLoading(true);
  56. const { startTime, endTime } = getSelectedMonthRange(defaultDateTime, selectedMonth);
  57. const response = await apiGet('/stageStats/weeklyDrugOverview', {
  58. params: {
  59. startTime,
  60. endTime
  61. }
  62. });
  63. if (response.data && response.data.data && Array.isArray(response.data.data)) {
  64. setData(response.data.data);
  65. } else {
  66. console.warn('每周药品概况 - 接口返回格式不符合预期:', response.data);
  67. // 设置为空数组,不使用模拟数据
  68. setData([]);
  69. }
  70. } catch (error) {
  71. console.error('获取每周药品概况数据失败:', error);
  72. // 设置为空数组,不使用模拟数据
  73. setData([]);
  74. } finally {
  75. setLoading(false);
  76. }
  77. };
  78. // 初始化图表 - 双柱状图+折线图
  79. const initChart = () => {
  80. if (!chartRef.current || data.length === 0) return;
  81. // 销毁已存在的图表实例
  82. if (chartInstance.current) {
  83. chartInstance.current.dispose();
  84. }
  85. // 创建新的图表实例
  86. chartInstance.current = echarts.init(chartRef.current);
  87. const option = {
  88. legend: {
  89. data: ['日均发药盒数', '日均机发盒数', '机发率'],
  90. top: '2%',
  91. left: 'center',
  92. textStyle: {
  93. color: '#CCE6FF',
  94. fontSize: '0.6vw'
  95. },
  96. itemWidth: 10,
  97. itemHeight: 8,
  98. itemGap: 20
  99. },
  100. grid: {
  101. left: '0%',
  102. right: '0%',
  103. top: '15%',
  104. bottom: '5%',
  105. containLabel: true
  106. },
  107. xAxis: {
  108. type: 'category',
  109. data: data.map(item => item.weekDayLabel),
  110. axisLine: {
  111. lineStyle: {
  112. color: '#1F324D'
  113. }
  114. },
  115. axisLabel: {
  116. color: '#CCE6FF',
  117. fontSize: '0.6vw',
  118. margin: 5
  119. },
  120. axisTick: {
  121. show: false
  122. }
  123. },
  124. yAxis: [
  125. {
  126. type: 'value',
  127. position: 'left',
  128. max: function() {
  129. // 获取两个柱状图数据的最大值
  130. const maxValue = Math.max(
  131. ...data.map(item => Math.max(item.totalCount, item.machineCount))
  132. );
  133. // 如果最大值很小(小于100),设置max为最大值的1.5倍
  134. if (maxValue < 100) {
  135. return Math.ceil(maxValue * 1.5);
  136. }
  137. // 如果最大值较大,设置为1.2倍并向上取整到合适的数值
  138. const scaledMax = maxValue * 1.2;
  139. // 根据数值大小选择合适的取整方式
  140. if (scaledMax < 1000) {
  141. return Math.ceil(scaledMax / 100) * 100;
  142. } else if (scaledMax < 10000) {
  143. return Math.ceil(scaledMax / 500) * 500;
  144. } else {
  145. return Math.ceil(scaledMax / 1000) * 1000;
  146. }
  147. },
  148. axisLine: {
  149. show: false
  150. },
  151. axisTick: {
  152. show: false
  153. },
  154. axisLabel: {
  155. color: '#CCE6FF',
  156. fontSize: '0.55vw',
  157. },
  158. splitLine: {
  159. lineStyle: {
  160. color: '#1F324D',
  161. type: 'solid'
  162. }
  163. }
  164. },
  165. {
  166. type: 'value',
  167. position: 'right',
  168. min: function() {
  169. // 接口返回的是小数(0.65表示65%),动态计算显示范围
  170. const values = data.map(item => item.directSendRate);
  171. const minValue = Math.min(...values);
  172. const maxValue = Math.max(...values);
  173. const range = maxValue - minValue;
  174. // 如果数据范围很小,扩大显示范围
  175. if (range < 0.2) {
  176. return Math.max(0, minValue - 0.1);
  177. } else {
  178. return Math.max(0, minValue - range * 0.2);
  179. }
  180. },
  181. max: function() {
  182. const values = data.map(item => item.directSendRate);
  183. const minValue = Math.min(...values);
  184. const maxValue = Math.max(...values);
  185. const range = maxValue - minValue;
  186. // 如果数据范围很小,扩大显示范围
  187. if (range < 0.2) {
  188. return Math.min(1, maxValue + 0.1);
  189. } else {
  190. return Math.min(1, maxValue + range * 0.2);
  191. }
  192. },
  193. axisLine: {
  194. show: false
  195. },
  196. axisTick: {
  197. show: false
  198. },
  199. axisLabel: {
  200. color: '#CCE6FF',
  201. fontSize: '0.55vw',
  202. formatter: function(value: number) {
  203. return (value * 100).toFixed(0) + '%';
  204. }
  205. },
  206. splitLine: {
  207. show: false
  208. }
  209. }
  210. ],
  211. series: [
  212. {
  213. name: '日均发药盒数',
  214. type: 'bar',
  215. yAxisIndex: 0,
  216. data: data.map(item => item.totalCount),
  217. barWidth: '20%',
  218. barGap: '10%',
  219. itemStyle: {
  220. borderRadius: [3, 3, 0, 0],
  221. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  222. { offset: 0, color: '#66B3FF' },
  223. { offset: 1, color: 'rgba(102,179,255,0)' }
  224. ]),
  225. },
  226. emphasis: {
  227. itemStyle: {
  228. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  229. { offset: 0, color: '#66B3FF' },
  230. { offset: 1, color: 'rgba(102,179,255,0.2)' }
  231. ])
  232. }
  233. },
  234. label: {
  235. show: false
  236. }
  237. },
  238. {
  239. name: '日均机发盒数',
  240. type: 'bar',
  241. yAxisIndex: 0,
  242. data: data.map(item => item.machineCount),
  243. barWidth: '20%',
  244. itemStyle: {
  245. borderRadius: [3, 3, 0, 0],
  246. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  247. { offset: 0, color: '#F3F9FF' },
  248. { offset: 1, color: 'rgba(243,249,255,0)' }
  249. ]),
  250. },
  251. emphasis: {
  252. itemStyle: {
  253. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  254. { offset: 0, color: '#F3F9FF' },
  255. { offset: 1, color: 'rgba(243,249,255,0.2)' }
  256. ])
  257. }
  258. },
  259. label: {
  260. show: false
  261. }
  262. },
  263. {
  264. name: '机发率',
  265. type: 'line',
  266. yAxisIndex: 1,
  267. data: data.map(item => item.directSendRate),
  268. lineStyle: {
  269. color: '#99FFAA',
  270. width: 2
  271. },
  272. itemStyle: {
  273. color: '#99FFAA'
  274. },
  275. symbol: 'none',
  276. smooth: true
  277. }
  278. ],
  279. tooltip: {
  280. trigger: 'axis',
  281. backgroundColor: 'rgba(15, 35, 85, 0.95)',
  282. borderColor: 'rgba(0, 212, 255, 0.6)',
  283. borderWidth: 1,
  284. textStyle: {
  285. color: '#FFFFFF',
  286. fontFamily: 'DingTalk JinBuTi',
  287. fontSize: '0.6vw'
  288. },
  289. formatter: (params: any) => {
  290. const totalData = params.find((p: any) => p.seriesName === '日均发药盒数');
  291. const machineData = params.find((p: any) => p.seriesName === '日均机发盒数');
  292. const lineData = params.find((p: any) => p.seriesType === 'line');
  293. const dataItem = data[totalData.dataIndex];
  294. return `<div style="padding: 0.3vw; font-size: 0.6vw; line-height: 1.4;">
  295. <div style="color: #00D4FF; font-weight: bold; margin-bottom: 0.3vw; font-size: 0.65vw;">${totalData.name}</div>
  296. <div style="margin-bottom: 0.2vw; display: flex; align-items: center;">
  297. <span style="color: #66B3FF; margin-right: 0.3vw; font-size: 0.8vw;">●</span>
  298. <span>日均发药盒数: </span>
  299. <span style="color: #FFFFFF; font-weight: bold; margin-left: 0.2vw;">${dataItem.totalCount}</span>
  300. </div>
  301. <div style="margin-bottom: 0.2vw; display: flex; align-items: center;">
  302. <span style="color: #F3F9FF; margin-right: 0.3vw; font-size: 0.8vw;">●</span>
  303. <span>日均机发盒数: </span>
  304. <span style="color: #FFFFFF; font-weight: bold; margin-left: 0.2vw;">${dataItem.machineCount}</span>
  305. </div>
  306. <div style="display: flex; align-items: center;">
  307. <span style="color: #99FFAA; margin-right: 0.3vw; font-size: 0.8vw;">●</span>
  308. <span>机发率: </span>
  309. <span style="color: #FFFFFF; font-weight: bold; margin-left: 0.2vw;">${(dataItem.directSendRate * 100).toFixed(1)}%</span>
  310. </div>
  311. </div>`;
  312. }
  313. }
  314. };
  315. chartInstance.current.setOption(option);
  316. };
  317. // 监听数据变化,重新渲染图表
  318. useEffect(() => {
  319. if (data.length > 0) {
  320. initChart();
  321. }
  322. }, [data]);
  323. // 组件挂载时获取数据
  324. useEffect(() => {
  325. fetchData();
  326. }, [defaultDateTime, selectedMonth]);
  327. // 监听窗口大小变化,确保图表自适应
  328. useEffect(() => {
  329. const handleResize = () => {
  330. if (chartInstance.current) {
  331. chartInstance.current.resize();
  332. }
  333. };
  334. window.addEventListener('resize', handleResize);
  335. // 使用ResizeObserver监听容器大小变化
  336. const resizeObserver = new ResizeObserver(() => {
  337. if (chartInstance.current) {
  338. chartInstance.current.resize();
  339. }
  340. });
  341. if (chartRef.current) {
  342. resizeObserver.observe(chartRef.current);
  343. }
  344. return () => {
  345. window.removeEventListener('resize', handleResize);
  346. resizeObserver.disconnect();
  347. if (chartInstance.current) {
  348. chartInstance.current.dispose();
  349. }
  350. };
  351. }, []);
  352. // 使用刷新Hook,配置对应的模块代码
  353. useGlobalRefresh(fetchData, '34');
  354. return (
  355. <ChartWrapper>
  356. <PanelHeader>每周药品概况</PanelHeader>
  357. <ChartContainer>
  358. <div
  359. ref={chartRef}
  360. style={{
  361. width: '100%',
  362. height: '100%',
  363. opacity: loading ? 0.5 : 1,
  364. transition: 'opacity 0.3s ease'
  365. }}
  366. />
  367. </ChartContainer>
  368. </ChartWrapper>
  369. );
  370. };
  371. export default DailyMedicineQuantityChart;