xyc
3 天以前 9ee7acd9d16e59224ee5184c89374f06d6592ad3
feat(holiday): 新增节假日综合运输客流量走势图
7个文件已添加
5个文件已修改
2319 ■■■■■ 已修改文件
HANDOFF.md 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/pom.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 1015 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 445 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java 535 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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Äêʮһ¼ÙÆÚȫʡ×ÛºÏÔËÊä¿ÍÁ÷Á¿×ßÊÆÍ¼.docx
Binary 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)