HANDOFF.md
@@ -5,6 +5,61 @@ > **åä½çº¦å®ï¼2026-08-31ï¼**ï¼è¯´ãæäº¤ã= æ¬å° commit + æ¨éå°è¿ç«¯ gitï¼origin mainï¼Gitblitï¼ï¼ä¸ååªåæ¬å° commitã ## ææ°ï¼2026-10-04ï¼ ### 10-04 æï¼èµ°å¿å¾ 3 é¡¹åæ è½´è°æ´ï¼v24ï¼ç¨æ·å·²éªæ¶ï¼ - **ç¨æ·ä¿®æ¹é¡¹**ï¼â åæ è½´åä½ï¼ä¸äººæ¬¡ãä¸è¾æ¬¡ãå ¬é/å°æ¶ãæ¥æ¤åº¦ãæ¥æï¼æ¹æåå»åº¦ä¸æ ·çé¢è²ï¼â¡ 两个å å å¾çåæ è½´æ¹æä¸å ¶ä»å¾ä¸æ ·çé»è²ï¼â¢ âä¸äººæ¬¡/å æ¯âç»§ç»æ¾å¨å å å¾å³ä¸è§ã - **å®ç°**ï¼`HolidayTrendChartService` æ `drawUnitLabel`ã`drawUnitLabelTopRight`ã`drawDateLabel` çæåè²ç±é»è²æ¹ä¸ºå»åº¦ç° `(102,102,102)`ï¼æ¨ªåå å å¾å¨æ°æ®åºåºè¾¹è¡¥ç» `2.2px` 纯é»åºçº¿ï¼è¦ç JFreeChart é»è®¤åç°ç 1px 轴线åæé¯é½¿è¾¹ç¼ï¼ä¸æ·»å ç®å¤´ãæçº¿å¾é»è²åæ è½´é»è¾æªæ¹ï¼å å å¾åä½ä»ç»å¶å¨å³ä¸è§ã - **éªè¯**ï¼`mvn test -f traffic-audit-server\pom.xml` éè¿ãçå®åºåªè¯»çæ `_tmp_holiday_verify/holiday_trend_2026-10-04_v24.docx`ï¼Word COM 宿µ 8 页ã15 å¼ å¾ãåç´ æ£æ¥ï¼æçº¿å¾å·¦ä¸åä½ãæ¥æåå å å¾å³ä¸åä½å为 `(102,102,102)`ï¼å¾ 12/13 åºè½´å¨ y=529 为纯é»è¿ç»çº¿ï¼æ®éæçº¿å¾åºè½´ä»ä¸ºçº¯é»ã - **产ç©ä¸ç¶æ**ï¼`C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹ç-ææ°ï¼.docx` 彿¶è¢« Word/WPS å ç¨ï¼å·²å¦å为 `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹ç-ææ°-åæ è½´è°æ´ï¼.docx`ï¼åâææ°âæä»¶æªè¦çãæªå¯¼å ¥ãè¦çæé坼任使°æ®ã2026-10-04 ç¨æ·å·²éªæ¶éè¿ï¼éæ¬æ¬¡æäº¤æ¨éã ### 10-04 æï¼èµ°å¿å¾ 4 项修æ¹ï¼v23ï¼å¾ ç¨æ·éªæ¶ï¼ - **ç¨æ·ä¿®æ¹é¡¹**ï¼â æçº¿æ°å¼æ ç¾ä¸æçº¿ä¸ä¸é¡ºåºåäºï¼ç´¯è®¡æµå¨éå¾ï¼è线å¨ä¸ãæ ç¾å´å¨ä¸ï¼ç¬¬ä¸å¼ å¾é¦æ¥æé«æ¯èç¹ãæè¿çæ ç¾å´æ¯æ£è²ï¼ï¼â¡ å ¬è·¯åºè¡ç»æå¾ä¾æèªé©¾æ¾åé¢ï¼æ£è²ï¼ï¼ä¸æ¯æ¥å®¢æµææä¸è´ï¼â¢ æ¨ªåæ åºä¸ºä¸äººæ¬¡/å æ¯ï¼æ°å¼è½´æ¾å°åºé¨ï¼ãå»æçºµè½´çº¿ä¸ç®å¤´ï¼â£ æ¯æ¥å®¢æµæææ¢å¤ 5 æ¡ï¼èªé©¾/éè·¯/éè·¯/æ°èª/æ°´è·¯ï¼ï¼å æ¯å°çæ°åæ å¨è²åå³è¾¹ãç¨é¢è²åºåã - **å®ç°**ï¼ - `HolidayTrendChartService.drawXYItemLabels` éåï¼å æå䏿¥æåç³»åæç¹ä½ y ä»ä¸å°ä¸æåºï¼æ ç¾åæè¯¥é¡ºåºä»ä¸å¾ä¸æï¼ä¸ç为åä¸ä¸ªæ ç¾åºé¨ +3ï¼ï¼**ä»èä¸ä¼åºç°æ ç¾ä¸æçº¿ä¸ä¸å转**ï¼é¿è®©æéæ¹ä¸ºãæ ç¾éå à 1000 + æ°æ®ç¹éå à 1ãï¼å¹¶å é¤åæ¥ä¼ææ ç¾ä¸è·¯æ¨å°å¾è¡¨ä¸æ¹çéç½åç´ æ©ç½é¡¹ï¼`nonWhiteRatio`ï¼ã - 横åå å å¾ï¼`plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT)` ææ°å¼è½´æ¾å°åºé¨ï¼ä¸äººæ¬¡ / å æ¯ å°±æ¯æ¨ªåæ ï¼ï¼`domainAxis.setAxisLineVisible(false)` + `setTickMarksVisible(false)` å»æçºµè½´çº¿åå»åº¦ï¼`render` 对横åå å å¾ä¸åç»åæ è½´ç®å¤´ï¼å使¹å°å³ä¸è§ï¼`drawUnitLabelTopRight`ï¼ã - å å å¾åå æ¾ä¸ä¸çå°æ°åï¼æ¹ä¸ºå¨è²åå³ä¾§ç¨åè²æåæ æ³¨ï¼åä¸è¡æè²åä½ç½®ææä¸åï¼å x åè¯¥è¡æå³è²å端 +8ï¼å¹¶éå¶å¨é¢ç 74px å³è¾¹è·å ï¼ï¼æ¾å¾ä¸ä»ç¨ç½ååå¨åå ã - `HolidayTrendService.roadStructureSeries` é¡ºåºæ¹ä¸º èªé©¾åºè¡éï¼æ£ï¼ãé路客è¿éï¼èï¼ï¼`dailyShareSeries` æ¢å¤ èªé©¾/éè·¯/éè·¯/æ°èª/æ°´è·¯ 5 æ¡ï¼å é¤ä¸ä¸è½®ä¸´æ¶åå¹¶ç¨ç `sumNullableLists`ã - **éªè¯**ï¼`mvn test -f traffic-audit-server\pom.xml` éè¿ï¼v23 Word COM 宿µ 8 页ï¼1+2Ã7ï¼15 å¼ å¾ï¼ãRapidOCR 夿£ 12 å¼ æçº¿å¾æ æ ç¾éå ï¼éæ¥æ¯å¯¹æ ç¾é¢è²é¡ºåºä¸æçº¿ç¹ä½é¡ºåºä¸è´ï¼ç´¯è®¡å¾ 10æ4æ¥ ç¹ä½ 2025 å¨ä¸/2024 å¨ä¸ï¼æ ç¾åæ · 2025 å¨ä¸ã2024 å¨ä¸ï¼ä¿®æ¹å v20 æ°å¥½ç¸åï¼ï¼é¦å¾ 10æ1æ¥æé«ç¹ï¼èè² 2025ï¼çæ°å¼æ ç¾å°±å¨è¯¥ç¹ä½æ£ä¸æ¹ãå¾ 12 å¾ä¾ä¸ºãèªé©¾åºè¡é·é路客è¿éãï¼æ°å¼è½´å¨åºé¨ 0â12,000ï¼åä½ä¸äººæ¬¡å¨å³ä¸è§ï¼æ ç®å¤´ãæ 纵轴线ï¼452/228 以èåæ å¨èåå³ä¾§ï¼648 ç½åå¨åå ãå¾ 13 å¾ä¾æ¢å¤ 5 项ï¼87.8% çå¤§å æ¯ç½åå¨åå ï¼éè·¯/æ°èª/æ°´è·¯çå°å æ¯ä»¥åè²æ°åæ å¨å³ä¾§ä¸åã - **æ°æ®æä½ä¸ç¶æ**ï¼æªå¯¼å ¥ãè¦çæé坼任使°æ®ãæç»æä»¶å·²è¦çå° `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹ç-ææ°ï¼.docx`ï¼å¾ ç¨æ·éªæ¶ï¼éªæ¶åä¸æäº¤ã䏿¨éã ### 10-04 åæï¼èµ°å¿å¾ 5 项微è°ï¼v20ï¼å¾ ç¨æ·éªæ¶ï¼ - **ç¨æ·ä¿®æ¹é¡¹**ï¼â 纵轴åä½ä»ä¸çºµè½´éå ï¼ç»§ç»ä¸ç§»ï¼â¡ 第 1 页餿 é¢å¤å ¨ç©ºç½ï¼éæ¾ 1 å¼ å¾å°ç¬¬ 1 页ï¼â¢ æ¨ªçºµåæ å»åº¦ä¸å¾ä¾åé»ï¼ç¨å¾®è°æ·¡ï¼â£ 饼徿 ç¾é¢è²æ¹ä¸ºä¸æåºä¸è´ï¼â¤ 两个å å 徿æä»ä¸å¥½ï¼ååå¤§ãæ°ååè¿åéã - **å®ç°**ï¼`HolidayTrendChartService` ä¸ºåæ ç±»å¾è¡¨é¢ç `UNIT_LABEL_BAND=58px` çé¡¶é¨ç©ºç½ï¼`reserveUnitLabelBand` è° `plot.setInsets`ï¼ï¼å使 ç¾åºçº¿æ¹ä¸º `dataArea.getY()-22`ï¼å½»åºè±ç¦»è½´çº¿ä¸æé«å»åº¦ï¼å»åº¦/å¾ä¾æåè²ç± (70,70,70) è°æ·¡ä¸º (102,102,102)ï¼åæ 轴线ä¸ç®å¤´ä»ä¸ºé»è²ï¼é¥¼å¾æ ç¾ï¼å«å¼çº¿æ¡å æåï¼éè¿ `TextFragment` åå°æ¹ä¸ºå¯¹åºæåºè²ï¼å å å¾ç»´ææ¨ªåï¼`categoryMargin 0.08->0.05`ã`itemMargin 0.05` 让è²åååï¼åå æ¾å¾ä¸å°±ç¨ 17px ç½ååå¨åå ï¼æ¾ä¸ä¸æç¨åè²å¼çº¿æå°åå¤ï¼æçº¿æ°æ®æ ç¾å·¦ä¾§æå°çç½ `dataArea.minX+16`ï¼é¿å 紧贴纵轴å»åº¦ã - **éªè¯**ï¼`mvn test -f traffic-audit-server\pom.xml` éè¿ãçå®åºåªè¯»çæ `_tmp_holiday_verify/holiday_trend_2026-10-04_v20.docx`ï¼571 KBï¼15 å¼ å¾ï¼ï¼Word COM 宿µ 8 页ï¼ç¬¬ 1 页æ é¢+1 å¼ å¾ï¼ç¬¬ 2-8 页å 2 å¼ å¾ï¼æ 空ç½é¡µãRapidOCR 夿£ 12 å¼ æçº¿å¾æåå å´æ¡éå æ°ä¸º 0ï¼åä½âä¸äººæ¬¡/å æ¯â䏿é«å»åº¦ã轴线已å离ï¼å¾ 1 åä½ y=17..44ãé¡¶é¨å»åº¦ y=53..77ï¼ï¼å å å¾ 12 ç 452/8,595ã648/10,410ã228/3,962 ä¸å å å¾ 13 ç 87.8%/7.4% åå¨åå ï¼ä¸ªå«è¿çªç 4.8% ç¨åè²å¼çº¿æå°åå¤ï¼é¥¼å¾æ ç¾é¢è²ä¸æåºä¸è´ï¼éè·¯ç»¿ãæ°èªç´«ãéè·¯èãæ°´è·¯éï¼ã - **å£å¾ååæé**ï¼å¾ 13ãæ¯æ¥å®¢æµææååãï¼éå½ä¸ï¼ç±åå 5 æ¡ï¼èªé©¾/éè·¯/éè·¯/æ°èª/æ°´è·¯ï¼å并为 3 æ¡ï¼èªé©¾/éè·¯/å ¶ä»æ¹å¼=éè·¯+æ°èª+æ°´è·¯ï¼ï¼ç®çæ¯è®©è²åè¶³å¤å®½ãæ°åè½åè¿åå ï¼æ£æ 5 ç§è¿è¾æ¹å¼èµ°å¿å¾æªåå½±åã - **æ°æ®æä½ä¸ç¶æ**ï¼æªå¯¼å ¥ãè¦çæé坼任使°æ®ãæç»æä»¶å·²è¦çå° `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹ç-ææ°ï¼.docx`ï¼å¾ ç¨æ·éªæ¶ï¼éªæ¶åä¸æäº¤ã䏿¨éã ### 10-04 ä¸åï¼3ï¼ï¼èµ°å¿å¾ 8 项修æ¹ï¼å¾ ç¨æ·éªæ¶ï¼ - **ç¨æ·ä¿®æ¹é¡¹**ï¼çºµè½´åä½ä¸ç§»é¿å¼ç®å¤´ï¼å é¤éå½äºåæ¯èµ°å¿ï¼åæ¥æçä¸å¹´æçº¿ç¹æ¢å¤åç´å¯¹é½ï¼å¾è¡¨æ é¢å»æâ2026å¹´âï¼âè§æµç«âæ¹ä¸ºâæ®éå½çéè§æµç«âï¼å å å¾å°å æ¯æ¹ä¸ºæ±å¤å¼çº¿æ æ³¨ï¼ææ¡£æ¹ä¸ºæ¯é¡µ 2 å¾ï¼å¾ä¾âéè·¯è¥ä¸æ§å®¢è¿éâæ¹ä¸ºâé路客è¿éâãâéè¥ä¸æ§èªé©¾æç®å®¢æµâæ¹ä¸ºâèªé©¾åºè¡éâã - **å®ç°**ï¼`HolidayTrendChartService` æ¢å¤ææå¹´ä»½åä¸ x åæ ï¼çºµè½´åä½ç»å¶å¨æ°æ®åºä¸æ¹ï¼å å å¾å°å æ¯ä½¿ç¨åè²å¼çº¿æå°æ±å¤ï¼æçº¿æ°å¼æ ç¾æ¹ä¸ºå¯¹å ¶ä»æ°å¼æ ç¾è®¾ç½®æé«é¿è®©ä¼å 级ï¼ç½æè¾¹ä¿çã`HolidayTrendService` å é¤éå½äºåæ¯ 2 å¾ï¼åéå½ä¸é¡ºå»¶ä¸ºéå½äºï¼å¹¶æ´æ°æ é¢åå¾ä¾æåï¼å½åå ± 15 å¼ å¾ã`HolidayTrendWordService` å°é¢å强å¶å页ï¼åæ¶ç« èæ é¢åçå¤ä½ç©ºæ®µï¼åææ¯ 2 å¾èªå¨å页ã - **éªè¯**ï¼`mvn test -f traffic-audit-server\pom.xml` éè¿ãçå®åºåªè¯»çæ `_tmp_holiday_verify/holiday_trend_2026-10-04_v17.docx`ï¼Word COM 宿µ 9 页ï¼å°é¢åç¬ç¬¬ 1 页ï¼ç¬¬ 2â8 页å为 2 å¼ å¾ï¼æå 1 å¼ å¾åç¬ç¬¬ 9 é¡µï¼æ 空ç½é¡µãRapidOCR 夿£ 12 å¼ æçº¿å¾çæ°å¼æ ç¾éå æ°ä¸º 0ï¼å å å¾å¾ 12ã13 çå°å æ¯æ°ååå¯è§ï¼å¾ 3âæ°´è·¯ 0.23%âä»å¨å³ä¾§ï¼å¾ä¾å·²è¯å«ä¸ºâé路客è¿éââèªé©¾åºè¡éâï¼å¾è¡¨æ é¢ä¸ä¸ååºç°â2026å¹´âã - **æ°æ®æä½ä¸ç¶æ**ï¼æªå¯¼å ¥ãè¦çæé坼任使°æ®ãæç»éªæ¶æä»¶å·²å¤å¶å° `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹ç-ææ°ï¼.docx`ï¼åâä¿®æ¹çâæä»¶å½æ¶è¢« WPS å ç¨ï¼æªè¦çï¼ï¼å¾ ç¨æ·éªæ¶ï¼éªæ¶åä¸æäº¤ã䏿¨éã ### 10-04 ä¸åï¼2ï¼ï¼èµ°å¿å¾æ ç¾ãæçº¿é´è·ä¸æ¨ªåæ åç¡®æ§ä¿®æ£ï¼å¾ ç¨æ·éªæ¶ï¼ - **ç¨æ·åé¦**ï¼é¥¼å¾âæ°´è·¯âå¼çº¿é¾è¾¨è®¤éåºå®å°å³ä¾§ï¼å å å¾è¦æ¾ç¤ºæ°å¼ï¼å¾ä¾æ¹é»è²æ¾å¤§ï¼æ¨ªè½´âæ¥æâä¸ç§»ï¼çºµè½´å使¾å°ç®å¤´ä¸æ¹ï¼åæ è½´åå»åº¦æ¹é»è²å¹¶æ¾å¤§ï¼æçº¿å ç²ãå°½éæå¼ï¼ä¿®å¤æ¨ªè½´éä½ä¸å¤©ï¼æçº¿æ°å¼æ ç¾è¦ä¸æçº¿åè²ï¼å¹¶æ£æ¥æ ç¾éå ã - **ä¿®å¤**ï¼`HolidayTrendChartService` 饼å¾åç±»æ¹ä¸ºå¨ JFreeChart å·²å好ç左峿 ç¾éåä¸æâæ°´è·¯âä»å·¦éåç§»å°å³éåï¼æçº¿å®½åº¦ç± 4px è°è³ 6pxï¼å¤åºåæ ±0.08 å轻微横åéå¼ï¼å ³é JFreeChart é»è®¤æçº¿æ ç¾ï¼æ¹ä¸ºéç¹èªå®ä¹æçï¼æä¸/ä¸/è¿ä¸/è¿ä¸/å·¦/å³åéä½ç½®é¿è®©å·²æ¾æ ç¾åæ°æ®ç¹ï¼å¹¶å¯¹åä½åºåçç©ºï¼æ ç¾ä½¿ç¨å¯¹åºæçº¿é¢è²å¹¶å ç½è²æè¾¹ã横轴é使 ¹å æ¯å `XYSeries.add(i + 1, ...)` ä¸ `SymbolAxis` å®é æ´æ°ä½ç½® `0..7` ä¸ä¸è´ï¼å·²æ¹ä¸º `add(i, ...)`ã - **éªè¯**ï¼`mvn test -f traffic-audit-server\pom.xml` éè¿ï¼çå®åºåªè¯»çæ `_tmp_holiday_verify/holiday_trend_2026-10-04_v14.docx`ãRapidOCR 夿£ 14 å¼ æçº¿å¾ï¼æ°å¼æ ç¾å å´æ¡éå æ°ä¸º 0ï¼æªåè¯å«åºä¸¤ä¸ªæ°å¼ç²è¿ï¼ä¸å¹´æçº¿æ¯å¾åè¯å«åº 2026 å¹´ 3 个ã2025 å¹´ 8 个ã2024 å¹´ 7 个ï¼å ± 18 个æ°å¼æ ç¾ï¼é¢è²è®¡æ°ä¸ºçº¢ 3ãè 8ã绿 7ã饼å¾âæ°´è·¯ 0.23%âä½äºå³ä¾§ï¼OCR ä¸å¿ xâ801.5ï¼ï¼å ¶ä»å°å æ¯æ ç¾å¨å·¦ä¾§ï¼å å 徿°å¼å¯è§ã10 æ 1 æ¥è½´å»åº¦ xâ123.5ï¼é¦æ¥æ°æ®ç¹å·²è½å¨ç¬¬ä¸æ ¼ã - **æ°æ®æä½ä¸ç¶æ**ï¼æªå¯¼å ¥ãè¦çæé坼任使°æ®ãæ°çå·²å¤å¶å° `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹çï¼.docx`ï¼å¾ ç¨æ·éªæ¶ï¼éªæ¶åä¸æäº¤ã䏿¨éã ### 10-04 ä¸åï¼èµ°å¿å¾å¤è§ææ¨¡æ¿éå¶ï¼å¾ ç¨æ·éªæ¶ï¼ - **ç¨æ·åé¦ç 7 项é®é¢**ï¼ç« èæ é¢éå¤ã徿¯æ¨¡æ¿å¤§ãåå°çº¿ç»ãç½æ ¼åæµ ç°èæ¯ãåæ è½´æ ç®å¤´ä¸åä½ä½ç½®ä¸ç¬¦ã纵轴èå´ä¸åéã饼å¾å°å æ¯æ ç¾åºæå°åå¤ã - **ä¿®å¤**ï¼`HolidayTrendWordService` å éç« èæ¶ç§»é¤æ¨¡æ¿èªå¨ç¼å·ï¼æ¯å¼ å¾ææ¨¡æ¿å¯¹åºå°ºå¯¸è®¾ç½® `wp:extent` åå é¨ `a:ext`ï¼å¹¶ç§»é¤æ¨¡æ¿æ§ `srcRect`ã`HolidayTrendChartService` æ¹ä¸ºçº¯ç½èæ¯ãæ ç½æ ¼/æ 交æ¿ç°è²å¸¦ãé»è²åæ 轴带ç®å¤´ã左䏿¹æ°´å¹³åä½ãå³ä¸è§æ¥æï¼æçº¿åå·æ Word çº¸å¼ å®é æ¾ç¤ºå°ºå¯¸æ¾å¤§ãå ç²çº¿å®½åç¹å¾ï¼çºµè½´ä¸å¼ºå¶ä» 0 å¼å§å¹¶ææ°æ®ä¸ä¸çç½ï¼ç¾åæ¯å 积å¾åºå® 0%-100%ï¼é¥¼å¾å ³é `simpleLabels`ï¼ä½¿ç¨å¤é¨æ ç¾åå¼çº¿ã - **éªè¯**ï¼`mvn test` å ¨é¨éè¿ï¼æ°å¢å¾è¡¨æµ ç°èæ¯ä¸ Word ç¼å·/尺寸å彿è¨ï¼å端 `npm run build` éè¿ï¼ä» åæ bundle ä½ç§¯è¦åï¼ãçå®åºçææç»ç¨¿ `C:\Users\jcxiong\Downloads\2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾ï¼ä¿®æ¹çï¼.docx`ï¼Word COM 宿µ 9 页ã17 å¼ å¾ï¼ç¨åºæ£æ¥å ¨é¨ `gray243=0` ä¸ 6 ä¸ªç« èæ®µ `numPr=false`ï¼å¾çå°ºå¯¸ä¸æ¨¡æ¿ 7 ç§ç®æ æ¡ä¸è´ãæç»ç PNG ææ¨¡æ¿æ¾ç¤ºæ¡å®½é«æ¯è¾åºï¼ä¸å被 Word æä¼¸åå½¢ï¼ï¼å»åº¦/å¾ä¾ 18pxãæ°æ®æ ç¾ 20pxãåä½ 22pxï¼çº¿å®½ 4pxã - **æ°æ®æä½**ï¼å ¨ç¨åªè¯»ï¼æªå¯¼å ¥ãè¦çæé坼任使°æ®ãç¶æï¼å¾ ç¨æ·éªæ¶ï¼éªæ¶ååæäº¤/æ¨éã ### 10-04 ä¸åï¼æ°å¢èåæ¥ç»¼åè¿è¾å®¢æµéèµ°å¿å¾åçï¼å¤è§å·²æåé¦éå¶ï¼ - **éæ±å£å¾ï¼ç¨æ· 2026-10-04 确认ï¼**ï¼èµ°å¿å¾å 12 å¼ æ£æ + éå½ï¼é¢å¯¼åè¯ä¸ºâåç§è¿è¾æ¹å¼å®¢æµéèµ°å¿å¾ï¼é«éåå½çé车æµéèµ°å¿å¾âãæç»æ£æå为ä¸é¨åï¼`ä¸ãæ»ä½æ åµ`ï¼å½æ¥æ»å®¢æµã累计æ»å®¢æµãè¿è¾æ¹å¼å æ¯ï¼ï¼`äºãåç§è¿è¾æ¹å¼å®¢æµéèµ°å¿`ï¼èªé©¾ãéè·¯ãæ°´è·¯ãæ°èªãéè·¯ï¼å ± 5 ç§æ¹å¼ï¼ï¼`ä¸ã车æµéèµ°å¿`ï¼é«éãæ®éå½çéãè½¦æµæ»éãå ¬è·¯åºè¡ç»æï¼ãéå½åä¸ºæ¯æ¥å®¢æµææååãåæ¯èµ°å¿ãè§æµç«è·¯ç½è¿è¡ãèªé©¾ä½ä¸ºè¿è¾æ¹å¼ä¹ä¸ï¼ä½¿ç¨éè¥ä¸æ§èªé©¾æç®å®¢æµï¼åä½ä¸äººæ¬¡ï¼é«é/æ®éå½çé/è½¦æµæ»éåä½ä¸è¾æ¬¡ã - **å®ç°**ï¼æ°å¢ `HolidayTrendService`ã`HolidayTrendChartService`ã`HolidayTrendWordService` å `ObservationDailyOperation`ï¼ä½¿ç¨ JFreeChart çæ 17 å¼ PNGï¼Word ä»ä»¥ `docs/ä¸é¡¹ç»è®¡/éè¦è忥/2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx` 为模æ¿ï¼éå»ºæ£æãä¿ç模æ¿é¡µç页èåå¾çå°ºå¯¸ï¼æ¿æ¢/追å åªä½å¹¶é建å¾çå ³ç³»ãæ°å¢å端æ¥å£ `GET /api/holiday/trend?year=2026&holidayType=NATIONAL_DAY`ï¼å端è忥ç»è®¡é¡µæ°å¢âä¸è½½èµ°å¿å¾âæé®ã - **æ°æ®ä¸å£å¾**ï¼2024/2025/2026 ä¸å¹´ï¼æ¨ªè½´åºå® 8 天ï¼2024ã2026 æ 第 8 å¤©æ°æ®æ¶ç空ï¼2025 æ¾ç¤ºç¬¬ 8 天ã累计æ²çº¿åªè¿æ¥å®é å·²ææ¥æï¼ç¼ºæ¥ä¸å»¶ç»æå累计å¼ãè§æµç«é彿 2026-10-01 è³ 10-08 æ¥è¯¢æ¯æ¥å¹³åæºå¨è½¦é度å平忥æ¤åº¦ï¼2026 å½åè¦ç 10-01/02/03ã - **éªè¯**ï¼`mvn test` å ¨éè¿ï¼å端 `npm run build` æåï¼ä» bundle ä½ç§¯åæè¦åï¼ãçå®åºæ°æ®çæ `_tmp_holiday_verify/holiday_trend_2026-10-04_v3.docx`ï¼Word ç»è®¡ 12 页ã17 å¼ å¾ï¼ä¸è½½æ¥å£è¿å约 399 KB DOCXãæªæ§è¡ä»»ä½æ°æ®å¯¼å ¥ãè¦çæé导ã - **æªè·è¸ªé¡¹**ï¼èµ°å¿å¾æ¨¡æ¿å `_tmp_holiday_verify/` ä»ä¸ºæªè·è¸ªæä»¶ï¼æ¬å°å¯è¿è¡ï¼è¥åç»æäº¤/é¨ç½²éææ¨¡æ¿çº³å ¥é¨ç½²å æä»åºãç¶æï¼å¤è§é®é¢å·²ç±æ¬æ¥ä¸å段修å¤ï¼æç»ç¨¿å¾ ç¨æ·éªæ¶ã ### 10-04 ä¸åï¼10 æ 3 æ¥æ°æ®å¯¼å ¥å¹¶çæå¿«æ¥ - **æºæä»¶**ï¼`D:\03_å ¬å¸\02_项ç®\27_æ¹åçéè·¯è¿è¾äºä¸åå±ä¸å¿\10_ä¸å¡\99_ä¸é¡¹ç»è®¡\01_éè¦è忥\2026\åä¸\10æ3æ¥\2026å¹´å䏿°æ®10æ3æ¥.xlsx`ï¼2026-10-04 10:11 æ´æ°ï¼ï¼è§æµç«åå§æ°æ®ä¸ºåç®å½ `è§æµç«åæ¥è°æ¥æ°æ®2026.10.3.xls`ã docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/2026Äêʮһ¼ÙÆÚȫʡ×ÛºÏÔËÊä¿ÍÁ÷Á¿×ßÊÆÍ¼.docxBinary files differ
traffic-audit-server/pom.xml
@@ -19,6 +19,7 @@ <hutool.version>5.4.0</hutool.version> <easyexcel.version>4.0.3</easyexcel.version> <jjwt.version>0.9.1</jjwt.version> <jfreechart.version>1.5.4</jfreechart.version> </properties> <dependencies> <dependency> @@ -59,6 +60,11 @@ <version>${hutool.version}</version> </dependency> <dependency> <groupId>org.jfree</groupId> <artifactId>jfreechart</artifactId> <version>${jfreechart.version}</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
@@ -2,6 +2,7 @@ import com.trafficaudit.common.Result; import com.trafficaudit.holiday.service.HolidayBulletinService; import com.trafficaudit.holiday.service.HolidayTrendService; import com.trafficaudit.holiday.service.HolidayStatService; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -26,6 +27,9 @@ private HolidayStatService statService; @Resource private HolidayBulletinService bulletinService; @Resource private HolidayTrendService trendService; @GetMapping("/overview") public Result<Map<String, Object>> overview(@RequestParam("year") int year, @@ -74,6 +78,18 @@ .body(data); } @GetMapping("/trend") public ResponseEntity<byte[]> trend(@RequestParam("year") int year, @RequestParam("holidayType") String holidayType) throws Exception { byte[] data = trendService.generate(year, holidayType); String fileName = year + "å¹´" + holidayTypeFileLabel(holidayType) + "åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx"; String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded) .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")) .body(data); } private String holidayTypeFileLabel(String holidayType) { if ("SPRING_FESTIVAL".equals(holidayType)) return "æ¥è"; if ("LABOR_DAY".equals(holidayType)) return "äºä¸"; traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
New file @@ -0,0 +1,1015 @@ package com.trafficaudit.holiday.service; import lombok.Data; import org.jfree.chart.ChartFactory; import org.jfree.chart.JFreeChart; import org.jfree.chart.StandardChartTheme; import org.jfree.chart.axis.AxisLocation; import org.jfree.chart.axis.CategoryAxis; import org.jfree.chart.axis.NumberAxis; import org.jfree.chart.axis.SymbolAxis; import org.jfree.chart.axis.ValueAxis; import org.jfree.chart.labels.StandardPieSectionLabelGenerator; import org.jfree.chart.plot.CategoryPlot; import org.jfree.chart.plot.PiePlot; import org.jfree.chart.plot.Plot; import org.jfree.chart.plot.PieLabelRecord; import org.jfree.chart.plot.PiePlotState; import org.jfree.chart.text.TextFragment; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.plot.XYPlot; import org.jfree.chart.renderer.category.StackedBarRenderer; import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; import org.jfree.chart.ui.RectangleEdge; import org.jfree.chart.ui.RectangleInsets; import org.jfree.chart.ui.TextAnchor; import org.jfree.data.DefaultKeyedValues; import org.jfree.data.KeyedValues; import org.jfree.data.category.CategoryDataset; import org.jfree.data.category.DefaultCategoryDataset; import org.jfree.data.general.DefaultPieDataset; import org.jfree.data.xy.XYDataset; import org.jfree.data.xy.XYSeries; import org.jfree.data.xy.XYSeriesCollection; import org.springframework.stereotype.Service; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.Paint; import java.awt.Shape; import java.awt.image.BufferedImage; import java.awt.geom.Ellipse2D; import java.awt.geom.Path2D; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.io.ByteArrayOutputStream; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @Service public class HolidayTrendChartService { private static final Font BASE_FONT = new Font("Microsoft YaHei", Font.PLAIN, 20); private static final Font TICK_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f); private static final Font LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f); private static final Font ITEM_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 20f); private static final Font LEGEND_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f); private static final Font STACK_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 17f); private static final Color AXIS_COLOR = Color.BLACK; private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102); private static final double UNIT_LABEL_BAND = 58.0; private static final double RIGHT_LABEL_BAND = 74.0; private static final double ITEM_LABEL_GAP = 12.0; private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55); private static final Color[] SERIES_COLORS = { new Color(192, 80, 77), new Color(79, 129, 189), new Color(155, 187, 89), new Color(128, 100, 162), new Color(75, 172, 198) }; static { System.setProperty("java.awt.headless", "true"); } public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis) { return lineChart(yAxisLabel, series, percentAxis, 1100, 590); } public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis, int width, int height) { XYSeriesCollection dataset = new XYSeriesCollection(); for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) { ChartSeries item = series.get(seriesIndex); XYSeries xySeries = new XYSeries(item.getName(), false, false); for (int i = 0; i < item.getValues().size(); i++) { BigDecimal value = item.getValues().get(i); if (value != null) { xySeries.add(i, value.doubleValue()); } } dataset.addSeries(xySeries); } JFreeChart chart = ChartFactory.createXYLineChart( null, null, null, dataset, PlotOrientation.VERTICAL, true, false, false); applyTheme(chart); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); chart.getLegend().setPosition(RectangleEdge.BOTTOM); chart.getLegend().setItemFont(LEGEND_FONT); chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0)); chart.getLegend().setBackgroundPaint(Color.WHITE); chart.getLegend().setItemPaint(MUTED_TEXT_COLOR); XYPlot plot = chart.getXYPlot(); stylePlot(plot); plot.setDomainGridlinesVisible(false); plot.setRangeGridlinesVisible(false); plot.setDomainTickBandPaint(null); plot.setRangeTickBandPaint(null); SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0])); domainAxis.setGridBandPaint(new Color(0, 0, 0, 0)); domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0)); domainAxis.setLowerMargin(0.04); domainAxis.setUpperMargin(0.04); plot.setDomainAxis(domainAxis); styleDomainAxis(plot.getDomainAxis()); NumberAxis rangeAxis = new NumberAxis(); rangeAxis.setAutoRangeIncludesZero(false); rangeAxis.setLowerMargin(0.12); rangeAxis.setUpperMargin(0.16); rangeAxis.setTickLabelFont(TICK_FONT); rangeAxis.setLabelFont(LABEL_FONT); rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR); rangeAxis.setLabelPaint(AXIS_COLOR); rangeAxis.setAxisLinePaint(AXIS_COLOR); rangeAxis.setTickMarkPaint(AXIS_COLOR); if (percentAxis) { rangeAxis.setRange(0.0, 1.0); rangeAxis.setAutoRangeIncludesZero(true); } NumberFormat valueFormat = percentAxis ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##"); valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2); rangeAxis.setNumberFormatOverride(valueFormat); plot.setRangeAxis(rangeAxis); plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0)); XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(true, true); renderer.setDefaultShapesVisible(true); renderer.setDefaultShapesFilled(true); renderer.setDefaultLinesVisible(true); renderer.setDefaultItemLabelsVisible(false); for (int i = 0; i < series.size(); i++) { renderer.setSeriesPaint(i, seriesColor(i)); renderer.setSeriesFillPaint(i, seriesColor(i)); renderer.setSeriesShape(i, seriesShape(i)); renderer.setSeriesStroke(i, new BasicStroke(6.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); } plot.setRenderer(renderer); return render(chart, width, height, yAxisLabel, true, percentAxis); } public byte[] pieChart(String valueSuffix, List<ChartCategory> categories, int width, int height) { DefaultPieDataset<String> dataset = new DefaultPieDataset<>(); for (ChartCategory category : categories) { dataset.setValue(category.getName(), category.getValue()); } PiePlot<String> piePlot = new WaterRightPiePlot<>(dataset); JFreeChart chart = new JFreeChart(null, JFreeChart.DEFAULT_TITLE_FONT, piePlot, true); applyTheme(chart); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); chart.getLegend().setPosition(RectangleEdge.BOTTOM); chart.getLegend().setItemFont(LEGEND_FONT); chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0)); chart.getLegend().setBackgroundPaint(Color.WHITE); chart.getLegend().setItemPaint(MUTED_TEXT_COLOR); PiePlot plot = piePlot; plot.setBackgroundPaint(Color.WHITE); plot.setOutlineVisible(false); plot.setShadowPaint(null); plot.setLabelFont(ITEM_LABEL_FONT); plot.setLabelPaint(ITEM_LABEL_COLOR); plot.setLabelBackgroundPaint(Color.WHITE); plot.setLabelOutlinePaint(new Color(160, 160, 160)); plot.setLabelShadowPaint(null); plot.setLabelLinkPaint(Color.BLACK); plot.setLabelLinkStroke(new BasicStroke(2.0f)); plot.setLabelGap(0.02); plot.setMaximumLabelWidth(0.24); plot.setSimpleLabels(false); plot.setInteriorGap(0.06); plot.setStartAngle(90.0); for (ChartCategory category : categories) { plot.setSectionOutlinePaint(category.getName(), Color.WHITE); plot.setSectionOutlineStroke(category.getName(), new BasicStroke(1.2f)); } plot.setLabelGenerator(new StandardPieSectionLabelGenerator( "{0} {2}", new DecimalFormat("0"), new DecimalFormat("0.00%"))); int index = 0; for (ChartCategory category : categories) { plot.setSectionPaint(category.getName(), seriesColor(index++)); } return render(chart, width, height, valueSuffix, false, false); } public byte[] stackedBarChart(String yAxisLabel, List<String> categories, List<ChartSeries> series, boolean percentAxis, int width, int height) { DefaultCategoryDataset dataset = new DefaultCategoryDataset(); for (ChartSeries item : series) { for (int i = 0; i < categories.size() && i < item.getValues().size(); i++) { BigDecimal value = item.getValues().get(i); if (value != null) { dataset.addValue(value, item.getName(), categories.get(i)); } } } JFreeChart chart = ChartFactory.createStackedBarChart( null, null, null, dataset, PlotOrientation.HORIZONTAL, true, false, false); applyTheme(chart); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); chart.getLegend().setPosition(RectangleEdge.BOTTOM); chart.getLegend().setItemFont(LEGEND_FONT); chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0)); chart.getLegend().setBackgroundPaint(Color.WHITE); chart.getLegend().setItemPaint(MUTED_TEXT_COLOR); CategoryPlot plot = chart.getCategoryPlot(); plot.setBackgroundPaint(Color.WHITE); plot.setDomainGridlinesVisible(false); plot.setRangeGridlinesVisible(false); plot.setOutlineVisible(false); plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0)); // Horizontal stacked bars: value axis along the bottom, no vertical axis line. plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT); CategoryAxis domainAxis = plot.getDomainAxis(); domainAxis.setAxisLineVisible(false); domainAxis.setTickMarksVisible(false); styleDomainAxis(domainAxis); if (series.size() >= 3) { RectangleInsets insets = plot.getInsets(); plot.setInsets(new RectangleInsets(insets.getTop(), insets.getLeft(), insets.getBottom(), insets.getRight() + RIGHT_LABEL_BAND)); } NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis(); rangeAxis.setTickLabelFont(TICK_FONT); rangeAxis.setLabelFont(LABEL_FONT); rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR); rangeAxis.setLabelPaint(AXIS_COLOR); rangeAxis.setAxisLinePaint(AXIS_COLOR); rangeAxis.setTickMarkPaint(AXIS_COLOR); rangeAxis.setAutoRangeIncludesZero(true); rangeAxis.setLowerMargin(0.05); rangeAxis.setUpperMargin(0.12); if (percentAxis) { rangeAxis.setRange(0.0, 1.0); } NumberFormat valueFormat = percentAxis ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##"); valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2); rangeAxis.setNumberFormatOverride(valueFormat); StackedBarRenderer renderer = new StackedBarRenderer(); renderer.setDefaultItemLabelsVisible(false); renderer.setBarPainter(new org.jfree.chart.renderer.category.StandardBarPainter()); renderer.setShadowVisible(false); renderer.setItemMargin(0.05); for (int i = 0; i < series.size(); i++) { renderer.setSeriesPaint(i, seriesColor(i)); } plot.setRenderer(renderer); return render(chart, width, height, yAxisLabel, true, percentAxis); } private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> { private DefaultKeyedValues<K> waterKeys; private WaterRightPiePlot(org.jfree.data.general.PieDataset<K> dataset) { super(dataset); } @Override protected void drawLeftLabels(KeyedValues<K> leftKeys, Graphics2D g2, Rectangle2D plotArea, Rectangle2D linkArea, float maxLabelWidth, PiePlotState state) { DefaultKeyedValues<K> filtered = new DefaultKeyedValues<>(); waterKeys = new DefaultKeyedValues<>(); for (int i = 0; i < leftKeys.getItemCount(); i++) { K key = leftKeys.getKey(i); if ("æ°´è·¯".equals(String.valueOf(key))) { waterKeys.addValue(key, leftKeys.getValue(i)); } else { filtered.addValue(key, leftKeys.getValue(i)); } } super.drawLeftLabels(filtered, g2, plotArea, linkArea, maxLabelWidth, state); } @Override protected void drawRightLabels(KeyedValues<K> rightKeys, Graphics2D g2, Rectangle2D plotArea, Rectangle2D linkArea, float maxLabelWidth, PiePlotState state) { DefaultKeyedValues<K> merged = new DefaultKeyedValues<>(); if (waterKeys != null) { for (int i = 0; i < waterKeys.getItemCount(); i++) { merged.addValue(waterKeys.getKey(i), waterKeys.getValue(i)); } } for (int i = 0; i < rightKeys.getItemCount(); i++) { merged.addValue(rightKeys.getKey(i), rightKeys.getValue(i)); } super.drawRightLabels(merged, g2, plotArea, linkArea, maxLabelWidth, state); } @Override protected void drawLeftLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) { recolorLabel(record); super.drawLeftLabel(g2, state, record); } @Override protected void drawRightLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) { recolorLabel(record); super.drawRightLabel(g2, state, record); } private void recolorLabel(PieLabelRecord record) { Paint paint = getSectionPaint(record.getKey()); Object label = record.getLabel(); if (paint == null || label == null) return; recolorPaintFields(label, paint, new IdentityHashMap<Object, Boolean>()); } private void recolorPaintFields(Object node, Paint paint, Map<Object, Boolean> visited) { if (node == null || visited.put(node, Boolean.TRUE) != null) return; if (node instanceof Paint || node instanceof String || node instanceof Number || node instanceof Font) { return; } if (node instanceof Iterable) { for (Object child : (Iterable<?>) node) { recolorPaintFields(child, paint, visited); } return; } if (!node.getClass().getName().startsWith("org.jfree.chart.text")) return; Class<?> type = node.getClass(); while (type != null && type != Object.class) { for (Field field : type.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers())) continue; try { field.setAccessible(true); Object value = field.get(node); if (value instanceof Paint && node instanceof TextFragment) { field.set(node, paint); } else if (value instanceof Iterable || value != null && value.getClass().getName() .startsWith("org.jfree.chart.text")) { recolorPaintFields(value, paint, visited); } } catch (IllegalAccessException ignored) { // Keep the default label paint if a JFreeChart field cannot be read. } } type = type.getSuperclass(); } } } private Shape seriesShape(int seriesIndex) { if (seriesIndex % 3 == 1) { return new Rectangle2D.Double(-6.0, -6.0, 12.0, 12.0); } if (seriesIndex % 3 == 2) { Path2D.Double diamond = new Path2D.Double(); diamond.moveTo(0.0, -7.0); diamond.lineTo(7.0, 0.0); diamond.lineTo(0.0, 7.0); diamond.lineTo(-7.0, 0.0); diamond.closePath(); return diamond; } return new Ellipse2D.Double(-6.0, -6.0, 12.0, 12.0); } private void stylePlot(XYPlot plot) { plot.setBackgroundPaint(Color.WHITE); plot.setOutlineVisible(false); plot.setDomainGridlinesVisible(false); plot.setRangeGridlinesVisible(false); } private void styleDomainAxis(org.jfree.chart.axis.CategoryAxis axis) { axis.setTickLabelFont(TICK_FONT); axis.setLabelFont(LABEL_FONT); axis.setTickLabelPaint(MUTED_TEXT_COLOR); axis.setLabelPaint(AXIS_COLOR); axis.setAxisLinePaint(AXIS_COLOR); axis.setTickMarkPaint(AXIS_COLOR); axis.setCategoryMargin(0.05); } private void styleDomainAxis(org.jfree.chart.axis.ValueAxis axis) { axis.setTickLabelFont(TICK_FONT); axis.setLabelFont(LABEL_FONT); axis.setTickLabelPaint(MUTED_TEXT_COLOR); axis.setLabelPaint(AXIS_COLOR); axis.setAxisLinePaint(AXIS_COLOR); axis.setTickMarkPaint(AXIS_COLOR); } private void applyTheme(JFreeChart chart) { StandardChartTheme theme = new StandardChartTheme("CN"); theme.setExtraLargeFont(BASE_FONT.deriveFont(Font.BOLD, 22f)); theme.setLargeFont(BASE_FONT.deriveFont(Font.BOLD, 18f)); theme.setRegularFont(BASE_FONT); theme.setSmallFont(TICK_FONT); theme.setChartBackgroundPaint(Color.WHITE); theme.setPlotBackgroundPaint(Color.WHITE); theme.setAxisLabelPaint(AXIS_COLOR); theme.setTickLabelPaint(MUTED_TEXT_COLOR); theme.setLegendItemPaint(MUTED_TEXT_COLOR); theme.setLegendBackgroundPaint(Color.WHITE); theme.apply(chart); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); } private Color seriesColor(int index) { return SERIES_COLORS[index % SERIES_COLORS.length]; } private byte[] render(JFreeChart chart, int width, int height, String unitLabel, boolean axisChart, boolean percentItemLabels) { try { reserveUnitLabelBand(chart, axisChart, unitLabel); ByteArrayOutputStream out = new ByteArrayOutputStream(); org.jfree.chart.ChartRenderingInfo info = new org.jfree.chart.ChartRenderingInfo(); java.awt.image.BufferedImage image = chart.createBufferedImage(width, height, info); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON); if (axisChart && isHorizontalStackedChart(chart)) { drawUnitLabelTopRight(graphics, unitLabel, info.getPlotInfo().getDataArea()); drawStackedValueAxis(graphics, info.getPlotInfo().getDataArea()); } else if (axisChart) { drawAxisArrows(graphics, info.getPlotInfo().getDataArea()); drawUnitLabel(graphics, unitLabel, info.getPlotInfo().getDataArea()); drawDateLabel(graphics, info.getPlotInfo().getDataArea()); } drawXYItemLabels(graphics, image, chart, info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel); drawStackedBarLabels(graphics, image, chart, info.getPlotInfo().getDataArea(), percentItemLabels); } finally { graphics.dispose(); } javax.imageio.ImageIO.write(image, "png", out); return out.toByteArray(); } catch (Exception e) { throw new IllegalArgumentException("èµ°å¿å¾å¾ççæå¤±è´¥: " + e.getMessage(), e); } } private void drawAxisArrows(Graphics2D graphics, Rectangle2D dataArea) { if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return; graphics.setColor(AXIS_COLOR); graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); int x = (int) Math.round(dataArea.getX()); int y = (int) Math.round(dataArea.getY()); int right = (int) Math.round(dataArea.getMaxX()); int bottom = (int) Math.round(dataArea.getMaxY()); graphics.drawLine(x, y, x, bottom); graphics.drawLine(x, bottom, right, bottom); drawArrowHead(graphics, right, bottom, 1, 0); drawArrowHead(graphics, x, y, 0, -1); } private void drawArrowHead(Graphics2D graphics, int x, int y, int dx, int dy) { int length = 11; int width = 5; if (dx != 0) { graphics.drawLine(x, y, x - length, y - width); graphics.drawLine(x, y, x - length, y + width); } else { graphics.drawLine(x, y, x - width, y + length); graphics.drawLine(x, y, x + width, y + length); } } private void drawUnitLabel(Graphics2D graphics, String unitLabel, Rectangle2D dataArea) { if (unitLabel == null || unitLabel.isEmpty()) return; graphics.setFont(LABEL_FONT); graphics.setColor(MUTED_TEXT_COLOR); java.awt.FontMetrics metrics = graphics.getFontMetrics(); int centerX = (int) Math.round(dataArea.getX()); int x = Math.max(4, centerX - metrics.stringWidth(unitLabel) / 2); int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0)); graphics.drawString(unitLabel, x, y); } private void drawUnitLabelTopRight(Graphics2D graphics, String unitLabel, Rectangle2D dataArea) { if (unitLabel == null || unitLabel.isEmpty() || dataArea == null) return; graphics.setFont(LABEL_FONT); graphics.setColor(MUTED_TEXT_COLOR); FontMetrics metrics = graphics.getFontMetrics(); int x = (int) Math.round(dataArea.getMaxX() - metrics.stringWidth(unitLabel)); int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0)); graphics.drawString(unitLabel, x, y); } private void reserveUnitLabelBand(JFreeChart chart, boolean axisChart, String unitLabel) { if (!axisChart || unitLabel == null || unitLabel.isEmpty()) return; Plot plot = chart.getPlot(); RectangleInsets insets = plot.getInsets(); plot.setInsets(new RectangleInsets(Math.max(insets.getTop(), UNIT_LABEL_BAND), insets.getLeft(), insets.getBottom(), insets.getRight())); } private boolean isHorizontalStackedChart(JFreeChart chart) { return chart.getPlot() instanceof CategoryPlot && PlotOrientation.HORIZONTAL.equals( ((CategoryPlot) chart.getPlot()).getOrientation()); } private void drawDateLabel(Graphics2D graphics, Rectangle2D dataArea) { if (dataArea == null || dataArea.getWidth() <= 0) return; graphics.setFont(LABEL_FONT); graphics.setColor(MUTED_TEXT_COLOR); int right = (int) Math.round(dataArea.getMaxX()); int bottom = (int) Math.round(dataArea.getMaxY()); graphics.drawString("æ¥æ", right - 50, bottom - 28); } private void drawStackedValueAxis(Graphics2D graphics, Rectangle2D dataArea) { if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return; graphics.setColor(AXIS_COLOR); graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); int left = (int) Math.round(dataArea.getX()); int right = (int) Math.round(dataArea.getMaxX()); int bottom = (int) Math.round(dataArea.getMaxY()); graphics.drawLine(left, bottom, right, bottom); } private void drawStackedBarLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart, Rectangle2D dataArea, boolean percentAxis) { if (!(chart.getPlot() instanceof CategoryPlot) || dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) { return; } CategoryPlot plot = (CategoryPlot) chart.getPlot(); CategoryDataset dataset = plot.getDataset(); CategoryAxis domainAxis = plot.getDomainAxis(); ValueAxis rangeAxis = plot.getRangeAxis(); if (dataset == null || domainAxis == null || rangeAxis == null) return; graphics.setFont(STACK_LABEL_FONT); FontMetrics metrics = graphics.getFontMetrics(); int columns = dataset.getColumnCount(); int rows = dataset.getRowCount(); boolean horizontal = PlotOrientation.HORIZONTAL.equals(plot.getOrientation()); List<List<StackCallout>> callouts = new ArrayList<>(); for (int column = 0; column < columns; column++) callouts.add(new ArrayList<>()); List<HorizontalStackCallout> horizontalCallouts = new ArrayList<>(); List<Rectangle2D> occupied = new ArrayList<>(); for (int column = 0; column < columns; column++) { double cumulative = 0.0; for (int row = 0; row < rows; row++) { Number number = dataset.getValue(row, column); if (number == null) continue; double value = number.doubleValue(); if (value == 0.0) continue; String text = formatStackLabel(value, percentAxis); if (horizontal) { double startX = rangeAxis.valueToJava2D(cumulative, dataArea, plot.getRangeAxisEdge()); double endX = rangeAxis.valueToJava2D(cumulative + value, dataArea, plot.getRangeAxisEdge()); double segmentWidth = Math.abs(endX - startX); double centerY = domainAxis.getCategoryMiddle(column, columns, dataArea, plot.getDomainAxisEdge()); int width = metrics.stringWidth(text); if (segmentWidth >= width + 4.0) { int textX = (int) Math.round((startX + endX - width) / 2.0); int baseline = (int) Math.round(centerY + (metrics.getAscent() - metrics.getDescent()) / 2.0); graphics.setColor(Color.WHITE); graphics.drawString(text, textX, baseline); occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(), width, metrics.getHeight())); } else { horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX), Math.max(startX, endX), centerY, text, seriesColor(row))); } } else { double top = rangeAxis.valueToJava2D(cumulative + value, dataArea, plot.getRangeAxisEdge()); double bottom = rangeAxis.valueToJava2D(cumulative, dataArea, plot.getRangeAxisEdge()); double segmentHeight = Math.abs(bottom - top); double x = domainAxis.getCategoryMiddle(column, columns, dataArea, plot.getDomainAxisEdge()); if (segmentHeight >= metrics.getHeight() + 4.0) { int width = metrics.stringWidth(text); int y = (int) Math.round(Math.min(top, bottom) + (segmentHeight - metrics.getHeight()) / 2.0 + metrics.getAscent()); graphics.setColor(Color.WHITE); graphics.drawString(text, (int) Math.round(x - width / 2.0), y); occupied.add(new Rectangle2D.Double(x - width / 2.0, y - metrics.getAscent(), width, metrics.getHeight())); } else if (estimatePercentShare(value, percentAxis) <= 0.03 || !percentAxis) { callouts.get(column).add(new StackCallout(x, (top + bottom) / 2.0, text, seriesColor(row))); } } cumulative += value; } } if (horizontal) { drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied); } else { for (int column = 0; column < columns; column++) { drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns, dataArea, metrics, occupied); } } } private void drawHorizontalStackCallouts(Graphics2D graphics, List<HorizontalStackCallout> items, Rectangle2D dataArea, FontMetrics metrics, List<Rectangle2D> occupied) { if (items.isEmpty()) return; Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>(); for (HorizontalStackCallout item : items) { Long key = Long.valueOf(Math.round(item.getY())); List<HorizontalStackCallout> row = rows.get(key); if (row == null) { row = new ArrayList<>(); rows.put(key, row); } row.add(item); } for (List<HorizontalStackCallout> row : rows.values()) { Collections.sort(row, new Comparator<HorizontalStackCallout>() { @Override public int compare(HorizontalStackCallout first, HorizontalStackCallout second) { return Double.compare(first.getStartX(), second.getStartX()); } }); int widest = 0; double columnX = 0.0; for (HorizontalStackCallout item : row) { widest = Math.max(widest, metrics.stringWidth(item.getText())); columnX = Math.max(columnX, item.getEndX() + 8.0); } // Keep the label column inside the reserved right margin. columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest); int height = metrics.getHeight(); double step = height + 3.0; double centerY = row.get(0).getY(); double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0; for (int i = 0; i < row.size(); i++) { HorizontalStackCallout item = row.get(i); int width = metrics.stringWidth(item.getText()); double x = columnX; double y = top + i * step; graphics.setColor(item.getColor()); graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()), (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0)); drawLabelWithHalo(graphics, item.getText(), new Rectangle2D.Double(x, y, width, height), metrics, item.getColor()); occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0, width + 4.0, height + 4.0)); } } } private void drawStackCalloutColumn(Graphics2D graphics, BufferedImage image, List<StackCallout> items, int column, int columnCount, Rectangle2D dataArea, FontMetrics metrics, List<Rectangle2D> occupied) { if (items.isEmpty()) return; Collections.sort(items, new Comparator<StackCallout>() { @Override public int compare(StackCallout first, StackCallout second) { return Double.compare(first.getY(), second.getY()); } }); double categoryWidth = dataArea.getWidth() / Math.max(1, columnCount); double barHalf = Math.min(80.0, Math.max(18.0, categoryWidth * 0.30)); double spacing = metrics.getHeight() + 4.0; double totalHeight = (items.size() - 1) * spacing; double averageY = 0.0; for (StackCallout item : items) averageY += item.getY(); averageY /= items.size(); double startY = averageY - totalHeight / 2.0 - metrics.getHeight() / 2.0; double maxStartY = dataArea.getMaxY() - metrics.getHeight() - 3.0 - totalHeight; startY = Math.max(dataArea.getMinY() + 3.0, Math.min(startY, maxStartY)); double centerX = items.get(0).getX(); double maxWidth = 0.0; for (StackCallout item : items) { maxWidth = Math.max(maxWidth, metrics.stringWidth(item.getText())); } double rightLeft = centerX + barHalf + 14.0; double leftLeft = centerX - barHalf - 14.0 - maxWidth; double rightPenalty = stackLanePenalty(rightLeft, startY, items, metrics, occupied, dataArea); double leftPenalty = stackLanePenalty(leftLeft, startY, items, metrics, occupied, dataArea); boolean rightSide = rightPenalty <= leftPenalty; double laneLeft = rightSide ? rightLeft : leftLeft; for (int i = 0; i < items.size(); i++) { StackCallout item = items.get(i); int width = metrics.stringWidth(item.getText()); int height = metrics.getHeight(); int y = (int) Math.round(startY + i * spacing); int textX = (int) Math.round(rightSide ? laneLeft : laneLeft + maxWidth - width); int textY = y + metrics.getAscent(); double lineStartX = centerX + (rightSide ? barHalf : -barHalf); double lineEndX = rightSide ? laneLeft : laneLeft + maxWidth; graphics.setColor(item.getColor()); graphics.setStroke(new BasicStroke(1.8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); graphics.drawLine((int) Math.round(lineStartX), (int) Math.round(item.getY()), (int) Math.round(lineEndX), (int) Math.round(y + height / 2.0)); drawLabelWithHalo(graphics, item.getText(), new Rectangle2D.Double(textX, y, width, height), metrics, item.getColor()); occupied.add(new Rectangle2D.Double(textX - 2.0, y - 2.0, width + 4.0, height + 4.0)); } } private double stackLanePenalty(double laneLeft, double startY, List<StackCallout> items, FontMetrics metrics, List<Rectangle2D> occupied, Rectangle2D dataArea) { double penalty = 0.0; for (int i = 0; i < items.size(); i++) { double width = metrics.stringWidth(items.get(i).getText()); double height = metrics.getHeight(); Rectangle2D rectangle = new Rectangle2D.Double(laneLeft, startY + i * (height + 4.0), width, height); if (rectangle.getMinX() < dataArea.getMinX() || rectangle.getMaxX() > dataArea.getMaxX() || rectangle.getMaxY() > dataArea.getMaxY()) { penalty += 10000.0; } penalty += overlapArea(rectangle, occupied) * 100.0; } return penalty; } private double estimatePercentShare(double value, boolean percentAxis) { return percentAxis ? value : value / 1.0; } private String formatStackLabel(double value, boolean percentAxis) { if (percentAxis) { NumberFormat formatter = NumberFormat.getPercentInstance(); formatter.setMaximumFractionDigits(1); return formatter.format(value); } return new DecimalFormat("#,##0").format(value); } private static class HorizontalStackCallout { private final double startX; private final double endX; private final double y; private final String text; private final Color color; private HorizontalStackCallout(double startX, double endX, double y, String text, Color color) { this.startX = startX; this.endX = endX; this.y = y; this.text = text; this.color = color; } private double getStartX() { return startX; } private double getEndX() { return endX; } private double getY() { return y; } private String getText() { return text; } private Color getColor() { return color; } } private static class StackCallout { private final double x; private final double y; private final String text; private final Color color; private StackCallout(double x, double y, String text, Color color) { this.x = x; this.y = y; this.text = text; this.color = color; } private double getX() { return x; } private double getY() { return y; } private String getText() { return text; } private Color getColor() { return color; } } private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart, Rectangle2D dataArea, boolean percentItemLabels, String unitLabel) { if (!(chart.getPlot() instanceof XYPlot) || dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) { return; } XYPlot plot = (XYPlot) chart.getXYPlot(); XYDataset dataset = plot.getDataset(); if (dataset == null) return; graphics.setFont(ITEM_LABEL_FONT); FontMetrics metrics = graphics.getFontMetrics(); List<Rectangle2D> placedLabels = new ArrayList<>(); List<Rectangle2D> markers = new ArrayList<>(); if (unitLabel != null && !unitLabel.isEmpty()) { placedLabels.add(new Rectangle2D.Double(0.0, 0.0, dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0)); } int itemCount = 0; for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) { itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex)); for (int itemIndex = 0; itemIndex < dataset.getItemCount(seriesIndex); itemIndex++) { Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea); if (point != null) { markers.add(new Rectangle2D.Double(point.getX() - 9.0, point.getY() - 9.0, 18.0, 18.0)); } } } for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) { List<DayLabel> labels = new ArrayList<>(); for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) { if (itemIndex >= dataset.getItemCount(seriesIndex)) continue; Number yValue = dataset.getY(seriesIndex, itemIndex); Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea); if (yValue == null || point == null) continue; labels.add(new DayLabel(seriesIndex, point, formatLineLabel(yValue, percentItemLabels))); } // A day's labels are stacked in the same top-to-bottom order as its data // points, so a label never looks attached to a neighbouring line. Collections.sort(labels, new Comparator<DayLabel>() { @Override public int compare(DayLabel first, DayLabel second) { return Double.compare(first.getPoint().getY(), second.getPoint().getY()); } }); double previousBottom = Double.NEGATIVE_INFINITY; for (DayLabel label : labels) { int width = metrics.stringWidth(label.getText()); int height = metrics.getHeight(); double x = label.getPoint().getX() - width / 2.0; double top = Math.max(label.getPoint().getY() - ITEM_LABEL_GAP - height, previousBottom + 3.0); Rectangle2D chosen = null; double bestPenalty = Double.MAX_VALUE; for (int step = 0; step <= 4; step++) { Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double( x, top + step * (height + 4.0), width, height), dataArea); if (candidate == null) continue; double penalty = overlapArea(candidate, placedLabels) * 1000.0 + overlapArea(candidate, markers); if (penalty < bestPenalty) { bestPenalty = penalty; chosen = candidate; } if (penalty <= 0.0) break; } if (chosen == null) continue; drawLabelWithHalo(graphics, label.getText(), chosen, metrics, seriesColor(label.getSeriesIndex())); placedLabels.add(new Rectangle2D.Double(chosen.getX() - 5.0, chosen.getY() - 5.0, chosen.getWidth() + 10.0, chosen.getHeight() + 10.0)); previousBottom = chosen.getMaxY(); } } } private static class DayLabel { private final int seriesIndex; private final Point2D point; private final String text; private DayLabel(int seriesIndex, Point2D point, String text) { this.seriesIndex = seriesIndex; this.point = point; this.text = text; } private int getSeriesIndex() { return seriesIndex; } private Point2D getPoint() { return point; } private String getText() { return text; } } private Point2D itemPoint(XYPlot plot, XYDataset dataset, int seriesIndex, int itemIndex, Rectangle2D dataArea) { Number yValue = dataset.getY(seriesIndex, itemIndex); if (yValue == null || Double.isNaN(yValue.doubleValue()) || Double.isInfinite(yValue.doubleValue())) { return null; } double x = plot.getDomainAxis().valueToJava2D(dataset.getXValue(seriesIndex, itemIndex), dataArea, plot.getDomainAxisEdge()); double y = plot.getRangeAxis().valueToJava2D(yValue.doubleValue(), dataArea, plot.getRangeAxisEdge()); return new Point2D.Double(x, y); } private void addLabelCandidate(List<Rectangle2D> result, double x, double y, int width, int height, double dx, double dy) { result.add(new Rectangle2D.Double(x + dx - width / 2.0, y + dy - height / 2.0, width, height)); } private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) { double minX = dataArea.getMinX() + 16.0; double minY = dataArea.getMinY() + 2.0; double maxX = dataArea.getMaxX() - rectangle.getWidth() - 2.0; double maxY = dataArea.getMaxY() - rectangle.getHeight() - 2.0; if (maxX < minX || maxY < minY) return null; double x = Math.max(minX, Math.min(rectangle.getX(), maxX)); double y = Math.max(minY, Math.min(rectangle.getY(), maxY)); return new Rectangle2D.Double(x, y, rectangle.getWidth(), rectangle.getHeight()); } private double overlapArea(Rectangle2D rectangle, List<Rectangle2D> occupied) { double total = 0.0; for (Rectangle2D other : occupied) { double width = Math.min(rectangle.getMaxX(), other.getMaxX()) - Math.max(rectangle.getMinX(), other.getMinX()); double height = Math.min(rectangle.getMaxY(), other.getMaxY()) - Math.max(rectangle.getMinY(), other.getMinY()); if (width > 0.0 && height > 0.0) total += width * height; } return total; } private String formatLineLabel(Number value, boolean percentItemLabels) { if (percentItemLabels) { NumberFormat formatter = NumberFormat.getPercentInstance(); formatter.setMaximumFractionDigits(1); return formatter.format(value.doubleValue()); } return new DecimalFormat("#,##0.##").format(value.doubleValue()); } private void drawLabelWithHalo(Graphics2D graphics, String text, Rectangle2D rectangle, FontMetrics metrics, Color color) { int x = (int) Math.round(rectangle.getX()); int y = (int) Math.round(rectangle.getY() + metrics.getAscent()); graphics.setColor(Color.WHITE); for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { if (dx != 0 || dy != 0) graphics.drawString(text, x + dx, y + dy); } } graphics.setColor(color); graphics.drawString(text, x, y); } public static final List<String> DAY_LABELS = Arrays.asList( "10æ1æ¥", "10æ2æ¥", "10æ3æ¥", "10æ4æ¥", "10æ5æ¥", "10æ6æ¥", "10æ7æ¥", "10æ8æ¥"); public static List<ChartSeries> emptySeries(int size) { List<ChartSeries> result = new ArrayList<>(); for (int i = 0; i < size; i++) { result.add(new ChartSeries()); } return result; } @Data public static class ChartSeries { private String name; private List<BigDecimal> values = new ArrayList<>(); } @Data public static class ChartCategory { private String name; private BigDecimal value; } } traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
New file @@ -0,0 +1,445 @@ package com.trafficaudit.holiday.service; import com.trafficaudit.holiday.HolidayConstants; import com.trafficaudit.holiday.HolidayNumberUtils; import com.trafficaudit.holiday.entity.HolidayCalendar; import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics; import com.trafficaudit.observation.dto.ObservationDailyOperation; import com.trafficaudit.observation.mapper.ObservationStationFlowMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @Service public class HolidayTrendService { private static final BigDecimal HUNDRED = new BigDecimal("100"); @Value("${holiday.trend-template:docs/ä¸é¡¹ç»è®¡/éè¦è忥/2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx}") private String trendTemplatePath; @Resource private HolidayStatService statService; @Resource private HolidayConfigService configService; @Resource private HolidayTrendChartService chartService; @Resource private HolidayTrendWordService wordService; @Resource private ObservationStationFlowMapper observationStationFlowMapper; public byte[] generate(int year, String holidayType) { String holidayLabel = HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType); if (holidayLabel == null) { throw new IllegalArgumentException("䏿¯æçèåæ¥ç±»å"); } List<Integer> years = Arrays.asList(year, year - 1, year - 2); Map<Integer, YearData> yearData = new LinkedHashMap<>(); for (Integer item : years) { yearData.put(item, buildYearData(item, holidayType)); } YearData selected = yearData.get(year); if (selected == null || !selected.hasAnyData()) { throw new IllegalArgumentException("æé年份没æè¶³å¤çèµ°å¿å¾æ°æ®"); } String prefix = "â" + holidayLabel + "âåæ"; List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>(); blocks.add(new HolidayTrendWordService.SectionBlock("ä¸ãæ»ä½æ åµ")); blocks.add(new HolidayTrendWordService.ChartBlock( prefix + "å ¨çè·¨åºåäººåæµå¨éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.totalDaily), false, 1100, 715), new HolidayTrendWordService.ImageSize(5268595L, 3424555L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæç´¯è®¡è·¨åºåäººåæµå¨éèµ°å¿", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.totalCumulative), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæå交éè¿è¾æ¹å¼å®¢è¿éå æ¯æ åµ", chartService.pieChart("人次", selected.modalShare(), 1000, 607), true, new HolidayTrendWordService.ImageSize(5106670L, 3097530L))); blocks.add(new HolidayTrendWordService.SectionBlock("äºãåç§è¿è¾æ¹å¼å®¢æµéèµ°å¿")); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæèªé©¾åºè¡éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.selfDrivingPersonFlow), false, 1100, 522), new HolidayTrendWordService.ImageSize(5232400L, 2484120L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæé路客è¿éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.rail), false, 1100, 445), new HolidayTrendWordService.ImageSize(5264785L, 2129155L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåææ°´è·¯å®¢è¿éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.water), false, 1100, 433), new HolidayTrendWordService.ImageSize(5274310L, 2075815L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåææ°èªå®¢è¿éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.civilAviation), false, 1100, 521), new HolidayTrendWordService.ImageSize(5271135L, 2496185L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæé路客è¿éæ åµ", chartService.lineChart("ä¸äººæ¬¡", series(years, yearData, item -> item.road), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.SectionBlock("ä¸ã车æµéèµ°å¿")); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæé«éå ¬è·¯è½¦æµéæ åµ", chartService.lineChart("ä¸è¾æ¬¡", series(years, yearData, item -> item.expressway), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåææ®éå½çé车æµéæ åµ", chartService.lineChart("ä¸è¾æ¬¡", series(years, yearData, item -> item.nationalProvincial), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæå ¨çè½¦æµæ»éæ åµ", chartService.lineChart("ä¸è¾æ¬¡", series(years, yearData, item -> item.totalVehicleFlow), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåæå ¬è·¯åºè¡ç»æ", chartService.stackedBarChart("ä¸äººæ¬¡", Arrays.asList((year - 2) + "å¹´", (year - 1) + "å¹´", year + "å¹´"), roadStructureSeries(years, yearData), false, 1000, 607), true, new HolidayTrendWordService.ImageSize(5106670L, 3097530L))); blocks.add(new HolidayTrendWordService.SectionBlock("éå½ä¸ æ¯æ¥å®¢æµææåå")); blocks.add(new HolidayTrendWordService.ChartBlock( "â" + holidayLabel + "âåææ¯æ¥å®¢æµææåå", chartService.stackedBarChart("å æ¯", HolidayTrendChartService.DAY_LABELS, dailyShareSeries(selected), true, 1000, 607), true, new HolidayTrendWordService.ImageSize(5106670L, 3097530L))); blocks.add(new HolidayTrendWordService.SectionBlock("éå½äº æ®éå½çéè§æµç«è·¯ç½è¿è¡")); ObservationData observation = observationData(year, holidayType); blocks.add(new HolidayTrendWordService.ChartBlock( prefix + "æ®éå½çéè§æµç«å¹³åæºå¨è½¦é度", chartService.lineChart("å ¬é/å°æ¶", Collections.singletonList(series("å¹³åé度", observation.speed)), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); blocks.add(new HolidayTrendWordService.ChartBlock( prefix + "æ®éå½çéè§æµç«å¹³åæ¥æ¤åº¦", chartService.lineChart("æ¥æ¤åº¦", Collections.singletonList(series("平忥æ¤åº¦", observation.congestion)), false, 1100, 590), new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); File template = resolveTemplateFile(); if (!template.exists() || !template.isFile()) { throw new IllegalArgumentException("èµ°å¿å¾æ¨¡æ¿æä»¶ä¸åå¨: " + template.getAbsolutePath()); } try (InputStream input = new FileInputStream(template)) { return wordService.generate(readAll(input), year, blocks); } catch (Exception e) { throw new IllegalArgumentException("èµ°å¿å¾çæå¤±è´¥: " + e.getMessage(), e); } } private YearData buildYearData(int year, String holidayType) { List<DailyMetrics> metrics = new ArrayList<>(); for (int day = 1; day <= HolidayTrendChartService.DAY_LABELS.size(); day++) { metrics.add(statService.dailyMetrics(year, holidayType, day)); } YearData data = new YearData(); data.year = year; data.rail = values(metrics, DailyMetrics::getRail); data.civilAviation = values(metrics, DailyMetrics::getCivilAviation); data.road = values(metrics, DailyMetrics::getRoad); data.water = values(metrics, DailyMetrics::getWater); data.expressway = values(metrics, DailyMetrics::getExpressway); data.nationalProvincial = values(metrics, DailyMetrics::getNationalProvincial); data.selfDrivingPersonFlow = values(metrics, DailyMetrics::getNonBusinessPersonFlow); data.selfDrivingVehicleFlow = combine(metrics, DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial); data.totalVehicleFlow = combine(metrics, DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial); data.totalDaily = values(metrics, DailyMetrics::getTotalPersonFlow); data.totalCumulative = cumulative(data.totalDaily); data.railCumulative = cumulative(data.rail); data.civilAviationCumulative = cumulative(data.civilAviation); data.roadCumulative = cumulative(data.road); data.waterCumulative = cumulative(data.water); data.selfDrivingPersonFlowCumulative = cumulative(data.selfDrivingPersonFlow); return data; } private List<HolidayTrendChartService.ChartSeries> series( List<Integer> years, Map<Integer, YearData> data, Function<YearData, List<BigDecimal>> selector) { List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>(); for (Integer year : years) { YearData item = data.get(year); result.add(series(year + "å¹´", selector.apply(item))); } return result; } private List<HolidayTrendChartService.ChartSeries> yoySeries( String label, List<Integer> years, Map<Integer, YearData> data, Function<YearData, List<BigDecimal>> selector) { List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>(); YearData selected = data.get(years.get(0)); List<BigDecimal> selectedValues = selector.apply(selected); for (int i = 1; i < years.size(); i++) { YearData comparison = data.get(years.get(i)); result.add(series(label + "忝" + comparison.year, percentageChanges(selectedValues, selector.apply(comparison)))); } return result; } private List<HolidayTrendChartService.ChartSeries> dailyShareSeries(YearData data) { List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>(); result.add(series("èªé©¾", share(data.selfDrivingPersonFlow, data))); result.add(series("éè·¯", share(data.road, data))); result.add(series("éè·¯", share(data.rail, data))); result.add(series("æ°èª", share(data.civilAviation, data))); result.add(series("æ°´è·¯", share(data.water, data))); return result; } private List<BigDecimal> share(List<BigDecimal> values, YearData data) { List<BigDecimal> result = new ArrayList<>(); for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) { BigDecimal total = dayTotal(data, i); BigDecimal value = valueAt(values, i); result.add(total == null || total.signum() == 0 ? null : HolidayNumberUtils.divide(value == null ? BigDecimal.ZERO : value, total)); } return result; } private BigDecimal dayTotal(YearData data, int index) { BigDecimal total = BigDecimal.ZERO; boolean hasValue = false; for (List<BigDecimal> values : Arrays.asList( data.selfDrivingPersonFlow, data.road, data.rail, data.civilAviation, data.water)) { BigDecimal value = valueAt(values, index); if (value != null) { total = total.add(value); hasValue = true; } } return hasValue ? HolidayNumberUtils.normalize(total) : null; } private List<HolidayTrendChartService.ChartSeries> roadStructureSeries( List<Integer> years, Map<Integer, YearData> data) { List<BigDecimal> road = new ArrayList<>(); List<BigDecimal> selfDriving = new ArrayList<>(); for (int i = years.size() - 1; i >= 0; i--) { YearData item = data.get(years.get(i)); road.add(sum(item.road)); selfDriving.add(sum(item.selfDrivingPersonFlow)); } return Arrays.asList( series("èªé©¾åºè¡é", selfDriving), series("é路客è¿é", road)); } private ObservationData observationData(int year, String holidayType) { List<HolidayCalendar> days = configService.findHolidayDays(year, holidayType); if (days.isEmpty()) { return new ObservationData(nulls(), nulls()); } LocalDate start = days.get(0).getHolidayDate(); LocalDate end = start.plusDays(HolidayTrendChartService.DAY_LABELS.size() - 1L); List<ObservationDailyOperation> rows = observationStationFlowMapper.selectDailyOperations(start, end); Map<LocalDate, ObservationDailyOperation> byDate = new LinkedHashMap<>(); for (ObservationDailyOperation row : rows) { byDate.put(row.getObsDate(), row); } List<BigDecimal> speed = new ArrayList<>(); List<BigDecimal> congestion = new ArrayList<>(); for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) { ObservationDailyOperation row = byDate.get(start.plusDays(i)); speed.add(row == null ? null : row.getAverageSpeed()); congestion.add(row == null ? null : row.getAverageCongestion()); } return new ObservationData(speed, congestion); } private HolidayTrendChartService.ChartSeries series(String name, List<BigDecimal> values) { HolidayTrendChartService.ChartSeries result = new HolidayTrendChartService.ChartSeries(); result.setName(name); result.setValues(values == null ? nulls() : values); return result; } private List<BigDecimal> values(List<DailyMetrics> metrics, Function<DailyMetrics, BigDecimal> selector) { List<BigDecimal> result = new ArrayList<>(); for (DailyMetrics item : metrics) { result.add(item == null ? null : selector.apply(item)); } return result; } private List<BigDecimal> combine(List<DailyMetrics> metrics, Function<DailyMetrics, BigDecimal> left, Function<DailyMetrics, BigDecimal> right) { List<BigDecimal> result = new ArrayList<>(); for (DailyMetrics item : metrics) { result.add(item == null ? null : HolidayNumberUtils.sumNullable(left.apply(item), right.apply(item))); } return result; } private List<BigDecimal> cumulative(List<BigDecimal> values) { List<BigDecimal> result = new ArrayList<>(); BigDecimal total = null; for (BigDecimal value : values) { if (value != null) total = HolidayNumberUtils.sumNullable(total, value); result.add(value == null ? null : total); } return result; } private List<BigDecimal> percentageChanges(List<BigDecimal> current, List<BigDecimal> previous) { List<BigDecimal> result = new ArrayList<>(); for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) { BigDecimal currentValue = valueAt(current, i); BigDecimal previousValue = valueAt(previous, i); BigDecimal ratio = HolidayNumberUtils.divide(currentValue, previousValue); result.add(ratio == null ? null : HolidayNumberUtils.multiply( ratio.subtract(BigDecimal.ONE), HUNDRED)); } return result; } private BigDecimal sum(List<BigDecimal> values) { BigDecimal total = null; for (BigDecimal value : values) { if (value != null) total = HolidayNumberUtils.sumNullable(total, value); } return total; } private BigDecimal valueAt(List<BigDecimal> values, int index) { return values == null || index >= values.size() ? null : values.get(index); } private List<BigDecimal> nulls() { List<BigDecimal> result = new ArrayList<>(); for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) result.add(null); return result; } private File resolveTemplateFile() { List<File> candidates = new ArrayList<>(); File configured = new File(trendTemplatePath); candidates.add(configured); if (!configured.isAbsolute()) { candidates.add(new File(System.getProperty("user.dir"), trendTemplatePath)); candidates.add(new File(System.getProperty("user.dir"), "../" + trendTemplatePath)); } for (File candidate : candidates) { try { if (candidate.exists() && candidate.isFile()) return candidate.getCanonicalFile(); } catch (Exception ignored) { // Keep checking. } } return configured; } private byte[] readAll(InputStream input) throws Exception { java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int length; while ((length = input.read(buffer)) > 0) { output.write(buffer, 0, length); } return output.toByteArray(); } private static class YearData { private int year; private List<BigDecimal> rail; private List<BigDecimal> civilAviation; private List<BigDecimal> road; private List<BigDecimal> water; private List<BigDecimal> expressway; private List<BigDecimal> nationalProvincial; private List<BigDecimal> selfDrivingPersonFlow; private List<BigDecimal> selfDrivingVehicleFlow; private List<BigDecimal> totalVehicleFlow; private List<BigDecimal> totalDaily; private List<BigDecimal> totalCumulative; private List<BigDecimal> railCumulative; private List<BigDecimal> civilAviationCumulative; private List<BigDecimal> roadCumulative; private List<BigDecimal> waterCumulative; private List<BigDecimal> selfDrivingPersonFlowCumulative; private boolean hasAnyData() { return hasValue(totalDaily) || hasValue(rail) || hasValue(road) || hasValue(expressway) || hasValue(nationalProvincial); } private boolean hasValue(List<BigDecimal> values) { if (values == null) return false; for (BigDecimal value : values) { if (value != null) return true; } return false; } private List<HolidayTrendChartService.ChartCategory> modalShare() { List<HolidayTrendChartService.ChartCategory> result = new ArrayList<>(); result.add(category("èªé©¾", sum(selfDrivingPersonFlow))); result.add(category("éè·¯", sum(road))); result.add(category("éè·¯", sum(rail))); result.add(category("æ°èª", sum(civilAviation))); result.add(category("æ°´è·¯", sum(water))); return result; } private BigDecimal sum(List<BigDecimal> values) { BigDecimal total = null; for (BigDecimal value : values) { if (value != null) total = HolidayNumberUtils.sumNullable(total, value); } return total; } private HolidayTrendChartService.ChartCategory category(String name, BigDecimal value) { HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory(); category.setName(name); category.setValue(value == null ? BigDecimal.ZERO : value); return category; } } private static class ObservationData { private final List<BigDecimal> speed; private final List<BigDecimal> congestion; private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion) { this.speed = speed; this.congestion = congestion; } } } traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
New file @@ -0,0 +1,535 @@ package com.trafficaudit.holiday.service; import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; @Service public class HolidayTrendWordService { private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"; private static final String WORD_DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; private static final String REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; private static final String PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; public byte[] generate(byte[] templateBytes, int year, List<DocumentBlock> blocks) { try { byte[] documentXml = readEntry(templateBytes, "word/document.xml"); Document document = parse(documentXml); Element body = firstElement(document.getDocumentElement(), "body"); if (body == null) throw new IllegalArgumentException("èµ°å¿å¾æ¨¡æ¿ç¼ºå°æ£æ"); TemplateParts parts = TemplateParts.from(document, body); Map<String, byte[]> media = new LinkedHashMap<>(); List<ImageReference> imageReferences = new ArrayList<>(); int imageIndex = 1; for (DocumentBlock block : blocks) { if (!(block instanceof ChartBlock)) continue; String imageName = "image" + imageIndex++ + ".png"; imageReferences.add(new ImageReference("rId" + (100 + imageIndex - 2), "media/" + imageName, ((ChartBlock) block).getPng())); media.put("word/media/" + imageName, ((ChartBlock) block).getPng()); } Element sectPr = directChild(body, "sectPr"); List<Node> removals = directChildren(body); for (Node node : removals) { if (node != sectPr) body.removeChild(node); } appendClone(body, parts.titleLine, year + "å¹´âåä¸âåæå ¨ç综åè¿è¾å®¢æµé", sectPr); appendClone(body, parts.subtitleLine, null, sectPr); appendClone(body, parts.titleBlank, null, sectPr); int chartCursor = 0; int chartsOnPage = 0; for (int blockIndex = 0; blockIndex < blocks.size(); blockIndex++) { DocumentBlock block = blocks.get(blockIndex); if (block instanceof PageBreakBlock) { appendPageBreak(body, sectPr); chartsOnPage = 0; } else if (block instanceof SectionBlock) { appendClone(body, parts.sectionTemplate, ((SectionBlock) block).getText(), sectPr, true); } else if (block instanceof ChartBlock) { ChartBlock chart = (ChartBlock) block; Element titleTemplate = chart.isCompact() ? parts.compactTitleTemplate : parts.lineTitleTemplate; Element imageTemplate = chart.isCompact() ? parts.compactImageTemplate : parts.lineImageTemplate; appendClone(body, titleTemplate, chart.getTitle(), sectPr); Element imageParagraph = clone(imageTemplate); ImageReference imageReference = imageReferences.get(chartCursor++); bindImage(imageParagraph, imageReference, chart.getTitle(), chartCursor, ((ChartBlock) block).getImageSize()); body.insertBefore(imageParagraph, sectPr); appendClone(body, parts.chartBlank, null, sectPr); chartsOnPage++; boolean firstChartPageBreak = chartCursor == 1 && needsAutomaticPageBreak(blocks, blockIndex + 1); if (firstChartPageBreak || chartsOnPage >= 2 && needsAutomaticPageBreak(blocks, blockIndex + 1)) { appendPageBreak(body, sectPr); chartsOnPage = 0; } } } byte[] updatedDocument = serialize(document); byte[] updatedRels = buildRelationships(templateBytes, imageReferences); Map<String, byte[]> replacements = new HashMap<>(); replacements.put("word/document.xml", updatedDocument); replacements.put("word/_rels/document.xml.rels", updatedRels); replacements.putAll(media); return rewriteZip(templateBytes, replacements); } catch (Exception e) { throw new IllegalArgumentException("èµ°å¿å¾ææ¡£çæå¤±è´¥: " + e.getMessage(), e); } } private byte[] buildRelationships(byte[] templateBytes, List<ImageReference> imageReferences) throws Exception { Document rels = parse(readEntry(templateBytes, "word/_rels/document.xml.rels")); Element root = rels.getDocumentElement(); NodeList children = root.getChildNodes(); List<Node> removals = new ArrayList<>(); for (int i = 0; i < children.getLength(); i++) { Node node = children.item(i); if (node instanceof Element) { Element element = (Element) node; if ("Relationship".equals(element.getLocalName()) && element.getAttribute("Type").endsWith("/image")) { removals.add(node); } } } for (Node node : removals) root.removeChild(node); for (ImageReference reference : imageReferences) { Element relationship = rels.createElementNS(PACKAGE_REL_NS, "Relationship"); relationship.setAttribute("Id", reference.getRelationshipId()); relationship.setAttribute("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"); relationship.setAttribute("Target", reference.getTarget()); root.appendChild(relationship); } return serialize(rels); } private void bindImage(Element paragraph, ImageReference reference, String title, int index, ImageSize imageSize) { Element blip = firstElement(paragraph, "blip"); if (blip == null) throw new IllegalArgumentException("èµ°å¿å¾æ¨¡æ¿å¾çèç¹ç¼ºå¤±"); blip.setAttributeNS(REL_NS, "r:embed", reference.getRelationshipId()); Element srcRect = firstElement(paragraph, "srcRect"); if (srcRect != null) srcRect.getParentNode().removeChild(srcRect); resizeDrawing(paragraph, imageSize); Element docPr = firstElement(paragraph, "docPr"); if (docPr != null) { docPr.setAttribute("id", String.valueOf(1000 + index)); docPr.setAttribute("name", title); docPr.setAttribute("descr", title); } } private void resizeDrawing(Element paragraph, ImageSize imageSize) { if (imageSize == null) return; NodeList extents = paragraph.getElementsByTagNameNS(WORD_DRAWING_NS, "extent"); for (int i = 0; i < extents.getLength(); i++) { Element extent = (Element) extents.item(i); extent.setAttribute("cx", String.valueOf(imageSize.getWidth())); extent.setAttribute("cy", String.valueOf(imageSize.getHeight())); } NodeList transforms = paragraph.getElementsByTagNameNS(DRAWING_NS, "xfrm"); for (int i = 0; i < transforms.getLength(); i++) { Element transform = (Element) transforms.item(i); Element extent = firstElement(transform, "ext"); if (extent == null) continue; extent.setAttribute("cx", String.valueOf(imageSize.getWidth())); extent.setAttribute("cy", String.valueOf(imageSize.getHeight())); } } private void appendClone(Element body, Element template, String text, Element before) { appendClone(body, template, text, before, false); } private void appendClone(Element body, Element template, String text, Element before, boolean removeNumbering) { if (template == null) return; Element copy = clone(template); if (removeNumbering) removeNumbering(copy); if (text != null) setParagraphText(copy, text); body.insertBefore(copy, before); } private void removeNumbering(Element paragraph) { NodeList numbered = paragraph.getElementsByTagNameNS(WORD_NS, "numPr"); while (numbered.getLength() > 0) { Node node = numbered.item(0); node.getParentNode().removeChild(node); } } private boolean needsAutomaticPageBreak(List<DocumentBlock> blocks, int nextIndex) { if (nextIndex >= blocks.size()) return false; return !(blocks.get(nextIndex) instanceof PageBreakBlock); } private void appendPageBreak(Element body, Element before) { Document document = body.getOwnerDocument(); Element paragraph = document.createElementNS(WORD_NS, "w:p"); Element run = document.createElementNS(WORD_NS, "w:r"); Element br = document.createElementNS(WORD_NS, "w:br"); br.setAttributeNS(WORD_NS, "w:type", "page"); run.appendChild(br); paragraph.appendChild(run); body.insertBefore(paragraph, before); } private void setParagraphText(Element paragraph, String text) { Element pPr = firstDirectChild(paragraph, "pPr"); Element firstRun = firstDirectChild(paragraph, "r"); Element rPr = firstRun == null ? null : firstDirectChild(firstRun, "rPr"); List<Node> removals = new ArrayList<>(); Node child = paragraph.getFirstChild(); while (child != null) { Node next = child.getNextSibling(); if (child != pPr) removals.add(child); child = next; } for (Node node : removals) paragraph.removeChild(node); Element run = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:r"); if (rPr != null) run.appendChild(rPr.cloneNode(true)); Element t = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:t"); t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve"); t.setTextContent(text); run.appendChild(t); paragraph.appendChild(run); } private Element clone(Element source) { return (Element) source.cloneNode(true); } private byte[] rewriteZip(byte[] source, Map<String, byte[]> replacements) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); Map<String, Boolean> seen = new HashMap<>(); try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(source)); ZipOutputStream zipOutput = new ZipOutputStream(output)) { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = input.getNextEntry()) != null) { seen.put(entry.getName(), Boolean.TRUE); byte[] replacement = replacements.get(entry.getName()); if (isGeneratedChartImage(entry.getName()) && replacement == null) continue; zipOutput.putNextEntry(new ZipEntry(entry.getName())); if (replacement != null) { zipOutput.write(replacement); } else { int length; while ((length = input.read(buffer)) > 0) { zipOutput.write(buffer, 0, length); } } zipOutput.closeEntry(); } for (Map.Entry<String, byte[]> replacement : replacements.entrySet()) { if (Boolean.TRUE.equals(seen.get(replacement.getKey()))) continue; zipOutput.putNextEntry(new ZipEntry(replacement.getKey())); zipOutput.write(replacement.getValue()); zipOutput.closeEntry(); } } return output.toByteArray(); } private boolean isGeneratedChartImage(String entryName) { return entryName != null && entryName.matches("word/media/image[0-9]+\\.png"); } private byte[] readEntry(byte[] zipBytes, String entryName) throws Exception { try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { ZipEntry entry; while ((entry = input.getNextEntry()) != null) { if (entryName.equals(entry.getName())) return readAll(input); } } throw new IllegalArgumentException("模æ¿ä¸ç¼ºå° " + entryName); } private Document parse(byte[] xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); } private byte[] serialize(Document document) throws Exception { Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); ByteArrayOutputStream output = new ByteArrayOutputStream(); transformer.transform(new DOMSource(document), new StreamResult(output)); return output.toByteArray(); } private byte[] readAll(InputStream input) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int length; while ((length = input.read(buffer)) > 0) { output.write(buffer, 0, length); } return output.toByteArray(); } private Element firstElement(Element parent, String localName) { NodeList nodes = parent.getElementsByTagNameNS("*", localName); if (nodes.getLength() > 0) return (Element) nodes.item(0); if (DRAWING_NS.equals(parent.getNamespaceURI()) && parent.getLocalName() != null && localName.equals(parent.getLocalName())) return parent; NodeList children = parent.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { Node node = children.item(i); if (node instanceof Element) { Element found = firstElement((Element) node, localName); if (found != null) return found; } } return null; } private Element firstDirectChild(Element parent, String localName) { Node node = parent.getFirstChild(); while (node != null) { if (node instanceof Element && localName.equals(localName(node))) return (Element) node; node = node.getNextSibling(); } return null; } private Element directChild(Element parent, String localName) { return firstDirectChild(parent, localName); } private List<Node> directChildren(Element parent) { List<Node> result = new ArrayList<>(); Node node = parent.getFirstChild(); while (node != null) { result.add(node); node = node.getNextSibling(); } return result; } private String localName(Node node) { return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName(); } public static class DocumentBlock { } public static class SectionBlock extends DocumentBlock { private final String text; public SectionBlock(String text) { this.text = text; } public String getText() { return text; } } public static class PageBreakBlock extends DocumentBlock { } public static class ChartBlock extends DocumentBlock { private final String title; private final byte[] png; private final boolean compact; private final ImageSize imageSize; public ChartBlock(String title, byte[] png) { this(title, png, false); } public ChartBlock(String title, byte[] png, boolean compact) { this(title, png, compact, null); } public ChartBlock(String title, byte[] png, boolean compact, ImageSize imageSize) { this.title = title; this.png = png; this.compact = compact; this.imageSize = imageSize; } public ChartBlock(String title, byte[] png, ImageSize imageSize) { this(title, png, false, imageSize); } public String getTitle() { return title; } public byte[] getPng() { return png; } public boolean isCompact() { return compact; } public ImageSize getImageSize() { return imageSize; } } public static class ImageSize { private final long width; private final long height; public ImageSize(long width, long height) { this.width = width; this.height = height; } public long getWidth() { return width; } public long getHeight() { return height; } } private static class ImageReference { private final String relationshipId; private final String target; private final byte[] image; private ImageReference(String relationshipId, String target, byte[] image) { this.relationshipId = relationshipId; this.target = target; this.image = image; } private String getRelationshipId() { return relationshipId; } private String getTarget() { return target; } private byte[] getImage() { return image; } } private static class TemplateParts { private Element titleLine; private Element subtitleLine; private Element titleBlank; private Element sectionTemplate; private Element sectionBlank; private Element lineTitleTemplate; private Element lineImageTemplate; private Element compactTitleTemplate; private Element compactImageTemplate; private Element chartBlank; private static TemplateParts from(Document document, Element body) { TemplateParts parts = new TemplateParts(); List<Element> paragraphs = directParagraphs(body); parts.titleLine = byText(paragraphs, "2026å¹´âåä¸âåæå ¨ç综åè¿è¾å®¢æµé"); parts.subtitleLine = byText(paragraphs, "èµ° å¿ å¾"); parts.sectionTemplate = byText(paragraphs, "æ»ä½æ åµ"); parts.lineTitleTemplate = byText(paragraphs, "2026å¹´âåä¸âåæå ¨çè·¨åºåäººåæµå¨éæ åµ"); parts.lineImageTemplate = imageAfter(paragraphs, parts.lineTitleTemplate); parts.compactTitleTemplate = byText(paragraphs, "âåä¸âåæå交éè¿è¾æ¹å¼å®¢è¿éå æ¯æ åµ"); parts.compactImageTemplate = imageAfter(paragraphs, parts.compactTitleTemplate); parts.titleBlank = nextParagraph(paragraphs, parts.subtitleLine); parts.sectionBlank = nextParagraph(paragraphs, parts.sectionTemplate); parts.chartBlank = afterParagraph(paragraphs, parts.lineImageTemplate); if (parts.titleLine == null || parts.subtitleLine == null || parts.sectionTemplate == null || parts.lineTitleTemplate == null || parts.lineImageTemplate == null || parts.compactTitleTemplate == null || parts.compactImageTemplate == null) { throw new IllegalArgumentException("èµ°å¿å¾æ¨¡æ¿ç»æä¸å®æ´"); } return parts; } private static List<Element> directParagraphs(Element body) { List<Element> result = new ArrayList<>(); Node node = body.getFirstChild(); while (node != null) { if (node instanceof Element && "p".equals(((Element) node).getLocalName())) { result.add((Element) node); } node = node.getNextSibling(); } return result; } private static Element byText(List<Element> paragraphs, String text) { for (Element paragraph : paragraphs) { if (text.equals(paragraphText(paragraph))) return paragraph; } return null; } private static Element imageAfter(List<Element> paragraphs, Element anchor) { int index = paragraphs.indexOf(anchor); if (index < 0) return null; for (int i = index + 1; i < paragraphs.size(); i++) { if (firstDescendant(paragraphs.get(i), "blip") != null) return paragraphs.get(i); } return null; } private static Element nextParagraph(List<Element> paragraphs, Element anchor) { int index = paragraphs.indexOf(anchor); return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null; } private static Element afterParagraph(List<Element> paragraphs, Element anchor) { int index = paragraphs.indexOf(anchor); return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null; } private static Element firstDescendant(Element parent, String localName) { NodeList nodes = parent.getElementsByTagNameNS("*", localName); return nodes.getLength() == 0 ? null : (Element) nodes.item(0); } private static String paragraphText(Element paragraph) { StringBuilder builder = new StringBuilder(); NodeList nodes = paragraph.getElementsByTagNameNS(WORD_NS, "t"); for (int i = 0; i < nodes.getLength(); i++) { builder.append(nodes.item(i).getTextContent()); } return builder.toString(); } } } traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
New file @@ -0,0 +1,15 @@ package com.trafficaudit.observation.dto; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; /** Daily observation-station operation averages used by the holiday trend report. */ @Data public class ObservationDailyOperation { private LocalDate obsDate; private BigDecimal averageSpeed; private BigDecimal averageCongestion; private Integer stationCount; } traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
@@ -2,11 +2,14 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.trafficaudit.observation.dto.ObservationDateSummary; import com.trafficaudit.observation.dto.ObservationDailyOperation; import com.trafficaudit.observation.entity.ObservationStationFlow; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; import java.time.LocalDate; @Mapper public interface ObservationStationFlowMapper extends BaseMapper<ObservationStationFlow> { @@ -16,4 +19,13 @@ + "MAX(updated_at) AS imported_at " + "FROM observation_station_flow GROUP BY obs_date ORDER BY obs_date ASC") List<ObservationDateSummary> selectDateSummary(); @Select("SELECT obs_date, COUNT(*) AS station_count, " + "AVG(vehicle_speed) AS average_speed, " + "AVG(congestion_degree) AS average_congestion " + "FROM observation_station_flow " + "WHERE obs_date BETWEEN #{startDate} AND #{endDate} " + "GROUP BY obs_date ORDER BY obs_date ASC") List<ObservationDailyOperation> selectDailyOperations(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); } traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
New file @@ -0,0 +1,62 @@ package com.trafficaudit.holiday.service; import org.junit.jupiter.api.Test; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class HolidayTrendChartServiceTest { @Test void rendersLinePieAndStackedChartsAsPng() throws Exception { HolidayTrendChartService service = new HolidayTrendChartService(); HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries(); first.setName("2026å¹´"); first.setValues(java.util.Arrays.asList( java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, null)); HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries(); second.setName("2025å¹´"); second.setValues(java.util.Arrays.asList( java.math.BigDecimal.valueOf(2), java.math.BigDecimal.valueOf(8), java.math.BigDecimal.valueOf(4))); byte[] line = service.lineChart("ä¸äººæ¬¡", java.util.Arrays.asList(first, second), false); assertPng(line); assertNoLargeLightGrayBackground(line); HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory(); category.setName("éè·¯"); category.setValue(java.math.BigDecimal.TEN); byte[] pie = service.pieChart("人次", java.util.Collections.singletonList(category), 400, 300); assertPng(pie); byte[] stacked = service.stackedBarChart("å æ¯", java.util.Arrays.asList("10æ1æ¥", "10æ2æ¥"), java.util.Arrays.asList(first, second), true, 500, 320); assertPng(stacked); assertNoLargeLightGrayBackground(stacked); } private void assertNoLargeLightGrayBackground(byte[] data) throws Exception { BufferedImage image = ImageIO.read(new ByteArrayInputStream(data)); int matches = 0; for (int y = 0; y < image.getHeight(); y++) { for (int x = 0; x < image.getWidth(); x++) { int rgb = image.getRGB(x, y) & 0xFFFFFF; if (rgb == 0xF3F4F3) matches++; } } assertTrue(matches < image.getWidth() * image.getHeight() / 20, "chart still contains a large light-gray background area"); } private void assertPng(byte[] data) { assertNotNull(data); assertTrue(data.length > 100); assertTrue(data[0] == (byte) 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47); } } traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
New file @@ -0,0 +1,143 @@ package com.trafficaudit.holiday.service; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class HolidayTrendWordServiceTest { @Test void rebuildsTemplateWithSectionsChartsAndAppendix() throws Exception { Path template = resolveTemplate(); byte[] templateBytes = Files.readAllBytes(template); HolidayTrendWordService service = new HolidayTrendWordService(); List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>(); blocks.add(new HolidayTrendWordService.SectionBlock("ä¸ãæ»ä½æ åµ")); blocks.add(new HolidayTrendWordService.ChartBlock("å®¢æµæ»é", png(1), new HolidayTrendWordService.ImageSize(5000000L, 3000000L))); blocks.add(new HolidayTrendWordService.ChartBlock("客æµå æ¯", png(2), true, new HolidayTrendWordService.ImageSize(4000000L, 2500000L))); blocks.add(new HolidayTrendWordService.PageBreakBlock()); blocks.add(new HolidayTrendWordService.SectionBlock("éå½ä¸ æ¯æ¥å®¢æµææåå")); blocks.add(new HolidayTrendWordService.ChartBlock("æ¯æ¥ææ", png(3), true)); byte[] result = service.generate(templateBytes, 2026, blocks); List<String> entries = entries(result); assertTrue(entries.contains("word/media/image1.png")); assertTrue(entries.contains("word/media/image2.png")); assertTrue(entries.contains("word/media/image3.png")); assertEquals(3, entries.stream().filter(name -> name.endsWith(".png")).count()); String documentXml = readEntry(result, "word/document.xml"); assertTrue(documentXml.contains("ä¸ãæ»ä½æ åµ")); assertTrue(documentXml.contains("éå½ä¸ æ¯æ¥å®¢æµææåå")); assertTrue(documentXml.contains("å®¢æµæ»é")); assertTrue(documentXml.contains("æ¯æ¥ææ")); assertTrue(!paragraphHasNumbering(documentXml, "ä¸ãæ»ä½æ åµ")); assertTrue(!paragraphHasNumbering(documentXml, "éå½ä¸ æ¯æ¥å®¢æµææåå")); assertTrue(documentXml.contains("cx=\"5000000\" cy=\"3000000\"")); assertTrue(documentXml.contains("cx=\"4000000\" cy=\"2500000\"")); assertEquals(2, pageBreakCount(documentXml)); String rels = readEntry(result, "word/_rels/document.xml.rels"); assertTrue(rels.contains("rId100")); assertTrue(rels.contains("rId102")); } private Path resolveTemplate() { Path direct = Paths.get("docs/ä¸é¡¹ç»è®¡/éè¦è忥/2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx"); if (Files.exists(direct)) return direct; return Paths.get("..", "docs", "ä¸é¡¹ç»è®¡", "éè¦è忥", "2026å¹´åä¸åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx"); } private boolean paragraphHasNumbering(String documentXml, String text) throws Exception { javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); org.w3c.dom.Document document = factory.newDocumentBuilder().parse( new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8"))); org.w3c.dom.NodeList paragraphs = document.getElementsByTagNameNS( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "p"); for (int i = 0; i < paragraphs.getLength(); i++) { org.w3c.dom.Element paragraph = (org.w3c.dom.Element) paragraphs.item(i); StringBuilder builder = new StringBuilder(); org.w3c.dom.NodeList texts = paragraph.getElementsByTagNameNS( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "t"); for (int j = 0; j < texts.getLength(); j++) { builder.append(texts.item(j).getTextContent()); } if (text.equals(builder.toString())) { return paragraph.getElementsByTagNameNS( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "numPr") .getLength() > 0; } } throw new AssertionError("Paragraph not found: " + text); } private int pageBreakCount(String documentXml) throws Exception { javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); org.w3c.dom.Document document = factory.newDocumentBuilder().parse( new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8"))); org.w3c.dom.NodeList breaks = document.getElementsByTagNameNS( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "br"); int count = 0; for (int i = 0; i < breaks.getLength(); i++) { org.w3c.dom.Element br = (org.w3c.dom.Element) breaks.item(i); if ("page".equals(br.getAttributeNS( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "type"))) { count++; } } return count; } private byte[] png(int seed) { byte[] bytes = new byte[256]; Arrays.fill(bytes, (byte) seed); bytes[0] = (byte) 0x89; bytes[1] = 0x50; bytes[2] = 0x4e; bytes[3] = 0x47; return bytes; } private List<String> entries(byte[] zipBytes) throws Exception { List<String> result = new ArrayList<>(); try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { ZipEntry entry; while ((entry = input.getNextEntry()) != null) result.add(entry.getName()); } return result; } private String readEntry(byte[] zipBytes, String name) throws Exception { try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { ZipEntry entry; while ((entry = input.getNextEntry()) != null) { if (name.equals(entry.getName())) { java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int length; while ((length = input.read(buffer)) > 0) out.write(buffer, 0, length); return out.toString("UTF-8"); } } } return ""; } } traffic-audit-web/src/views/HolidayStat.vue
@@ -52,6 +52,10 @@ <el-date-picker v-model="bulletinDate" type="date" value-format="yyyy-MM-dd" style="width:160px" placeholder="éæ©æ¥æ"></el-date-picker> <el-button type="warning" icon="el-icon-document" :loading="bulletinLoading" @click="downloadBulletin">ä¸è½½å¿«æ¥</el-button> </div> <div class="operation-item"> <span class="label">èµ°å¿å¾</span> <el-button type="primary" icon="el-icon-picture-outline" :loading="trendLoading" @click="downloadTrend">ä¸è½½èµ°å¿å¾</el-button> </div> </div> </el-card> @@ -169,6 +173,7 @@ importing: false, exporting: false, bulletinLoading: false, trendLoading: false, selectedFile: null, fileList: [], importMessage: '', @@ -320,6 +325,16 @@ .catch(err => this.handleBlobError(err, 'å¿«æ¥çæå¤±è´¥')) .finally(() => { this.bulletinLoading = false }) }, downloadTrend() { this.trendLoading = true api.get('/holiday/trend', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 180000 }) .then(res => { this.downloadBlob(res, this.year + 'å¹´' + this.holidayTypeLabel + 'åæå ¨ç综åè¿è¾å®¢æµéèµ°å¿å¾.docx') this.$message.success('èµ°å¿å¾ä¸è½½æå') }) .catch(err => this.handleBlobError(err, 'èµ°å¿å¾çæå¤±è´¥')) .finally(() => { this.trendLoading = false }) }, downloadBlob(data, fileName) { const blob = data instanceof Blob ? data : new Blob([data]) const url = window.URL.createObjectURL(blob)