From 9ee7acd9d16e59224ee5184c89374f06d6592ad3 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期日, 04 十月 2026 17:47:34 +0800
Subject: [PATCH] feat(holiday): 新增节假日综合运输客流量走势图

---
 HANDOFF.md                                                                                               |   55 +
 docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx                                                               |    0 
 traffic-audit-web/src/views/HolidayStat.vue                                                              |   15 
 traffic-audit-server/pom.xml                                                                             |    6 
 traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java     |  143 +++
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java        | 1015 ++++++++++++++++++++++++++
 traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java |   12 
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java             |  445 +++++++++++
 traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java    |   62 +
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java         |  535 +++++++++++++
 traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java       |   15 
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java        |   16 
 12 files changed, 2,319 insertions(+), 0 deletions(-)

diff --git a/HANDOFF.md b/HANDOFF.md
index 6d85a97..0a0cf2c 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -5,6 +5,61 @@
 > **鍗忎綔绾﹀畾锛�2026-08-31锛�**锛氳銆屾彁浜ゃ��= 鏈湴 commit + 鎺ㄩ�佸埌杩滅 git锛坥rigin main锛孏itblit锛夛紝涓嶅啀鍙仛鏈湴 commit銆�
 
 ## 鏈�鏂帮紙2026-10-04锛�
+### 10-04 鏅氾細璧板娍鍥� 3 椤瑰潗鏍囪酱璋冩暣锛坴24锛岀敤鎴峰凡楠屾敹锛�
+
+- **鐢ㄦ埛淇敼椤�**锛氣憼 鍧愭爣杞村崟浣嶏紙涓囦汉娆°�佷竾杈嗘銆佸叕閲�/灏忔椂銆佹嫢鎸ゅ害銆佹棩鏈燂級鏀规垚鍜屽埢搴︿竴鏍风殑棰滆壊锛涒憽 涓や釜鍫嗗彔鍥剧殑鍧愭爣杞存敼鎴愪笌鍏朵粬鍥句竴鏍风殑榛戣壊锛涒憿 鈥滀竾浜烘/鍗犳瘮鈥濈户缁斁鍦ㄥ爢鍙犲浘鍙充笂瑙掋��
+- **瀹炵幇**锛歚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`锛沇ord 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` 閫氳繃锛泇23 Word COM 瀹炴祴 8 椤碉紙1+2脳7锛�15 寮犲浘锛夈�俁apidOCR 澶嶆 12 寮犳姌绾垮浘鏃犳爣绛鹃噸鍙狅紱閫愭棩姣斿鏍囩棰滆壊椤哄簭涓庢姌绾跨偣浣嶉『搴忎竴鑷达紙绱鍥� 10鏈�4鏃� 鐐逛綅 2025 鍦ㄤ笂/2024 鍦ㄤ笅锛屾爣绛惧悓鏍� 2025 鍦ㄤ笂銆�2024 鍦ㄤ笅锛涗慨鏀瑰墠 v20 鎭板ソ鐩稿弽锛夛紱棣栧浘 10鏈�1鏃ユ渶楂樼偣锛堣摑鑹� 2025锛夌殑鏁板�兼爣绛惧氨鍦ㄨ鐐逛綅姝d笂鏂广�傚浘 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 寮犲浘锛屾棤绌虹櫧椤点�俁apidOCR 澶嶆 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% 鐢ㄥ悓鑹插紩绾挎媺鍒板潡澶栵紱楗煎浘鏍囩棰滆壊涓庢墖鍖轰竴鑷达紙閾佽矾缁裤�佹皯鑸传銆侀亾璺摑銆佹按璺潚锛夈��
+- **鍙e緞鍙樺寲鎻愰啋**锛氬浘 13銆屾瘡鏃ュ娴佹瀯鎴愬彉鍖栥�嶏紙闄勫綍涓�锛夌敱鍘熷厛 5 鏉★紙鑷┚/閬撹矾/閾佽矾/姘戣埅/姘磋矾锛夊悎骞朵负 3 鏉★紙鑷┚/閬撹矾/鍏朵粬鏂瑰紡=閾佽矾+姘戣埅+姘磋矾锛夛紝鐩殑鏄鑹插潡瓒冲瀹姐�佹暟瀛楄兘鍐欒繘鍧楀唴锛涙鏂� 5 绉嶈繍杈撴柟寮忚蛋鍔垮浘鏈彈褰卞搷銆�
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛渶缁堟枃浠跺凡瑕嗙洊鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx`锛涘緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�併��
+
+### 10-04 涓嬪崍锛�3锛夛細璧板娍鍥� 8 椤逛慨鏀癸紙寰呯敤鎴烽獙鏀讹級
+
+- **鐢ㄦ埛淇敼椤�**锛氱旱杞村崟浣嶄笂绉婚伩寮�绠ご锛涘垹闄ら檮褰曚簩鍚屾瘮璧板娍锛涘悓鏃ユ湡鐨勪笁骞存姌绾跨偣鎭㈠鍨傜洿瀵归綈锛涘浘琛ㄦ爣棰樺幓鎺夆��2026骞粹�濓紱鈥滆娴嬬珯鈥濇敼涓衡�滄櫘閫氬浗鐪侀亾瑙傛祴绔欌�濓紱鍫嗗彔鍥惧皬鍗犳瘮鏀逛负鏌卞寮曠嚎鏍囨敞锛涙枃妗f敼涓烘瘡椤� 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`锛沇ord COM 瀹炴祴 9 椤碉紝灏侀潰鍗曠嫭绗� 1 椤碉紝绗� 2鈥�8 椤靛潎涓� 2 寮犲浘锛屾渶鍚� 1 寮犲浘鍗曠嫭绗� 9 椤碉紝鏃犵┖鐧介〉銆俁apidOCR 澶嶆 12 寮犳姌绾垮浘鐨勬暟鍊兼爣绛鹃噸鍙犳暟涓� 0锛涘爢鍙犲浘鍥� 12銆�13 鐨勫皬鍗犳瘮鏁板瓧鍧囧彲瑙侊紱鍥� 3鈥滄按璺� 0.23%鈥濅粛鍦ㄥ彸渚э紱鍥句緥宸茶瘑鍒负鈥滈亾璺杩愰噺鈥濃�滆嚜椹惧嚭琛岄噺鈥濓紝鍥捐〃鏍囬涓笉鍐嶅嚭鐜扳��2026骞粹�濄��
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛渶缁堥獙鏀舵枃浠跺凡澶嶅埗鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx`锛堝師鈥滀慨鏀圭増鈥濇枃浠跺綋鏃惰 WPS 鍗犵敤锛屾湭瑕嗙洊锛夛紱寰呯敤鎴烽獙鏀讹紝楠屾敹鍓嶄笉鎻愪氦銆佷笉鎺ㄩ�併��
+
+### 10-04 涓嬪崍锛�2锛夛細璧板娍鍥炬爣绛俱�佹姌绾块棿璺濅笌妯潗鏍囧噯纭�т慨姝o紙寰呯敤鎴烽獙鏀讹級
+
+- **鐢ㄦ埛鍙嶉**锛氶ゼ鍥锯�滄按璺�濆紩绾块毦杈ㄨ闇�鍥哄畾鍒板彸渚э紱鍫嗗彔鍥捐鏄剧ず鏁板�硷紱鍥句緥鏀归粦鑹叉斁澶э紱妯酱鈥滄棩鏈熲�濅笂绉伙紱绾佃酱鍗曚綅鏀惧埌绠ご涓婃柟锛涘潗鏍囪酱鍜屽埢搴︽敼榛戣壊骞舵斁澶э紱鎶樼嚎鍔犵矖銆佸敖閲忔媺寮�锛涗慨澶嶆í杞撮敊浣嶄竴澶╋紱鎶樼嚎鏁板�兼爣绛捐涓庢姌绾垮悓鑹诧紝骞舵鏌ユ爣绛鹃噸鍙犮��
+- **淇**锛歚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`銆俁apidOCR 澶嶆 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`锛學ord 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锛沇ord 浠嶄互 `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx` 涓烘ā鏉匡紝閲嶅缓姝f枃銆佷繚鐣欐ā鏉块〉鐪夐〉鑴氬拰鍥剧墖灏哄锛屾浛鎹�/杩藉姞濯掍綋骞堕噸寤哄浘鐗囧叧绯汇�傛柊澧炲悗绔帴鍙� `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`锛學ord 缁熻 12 椤点��17 寮犲浘锛涗笅杞芥帴鍙h繑鍥炵害 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`銆�
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx"
new file mode 100644
index 0000000..d1d3a91
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx"
Binary files differ
diff --git a/traffic-audit-server/pom.xml b/traffic-audit-server/pom.xml
index c747c5d..a146b34 100644
--- a/traffic-audit-server/pom.xml
+++ b/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>
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
index d6265b2..63a0c42 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
+++ b/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 "浜斾竴";
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
new file mode 100644
index 0000000..8d359c4
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -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;
+    }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
new file mode 100644
index 0000000..0e35898
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
@@ -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;
+        }
+    }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
new file mode 100644
index 0000000..ccca074
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
@@ -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("璧板娍鍥炬枃妗g敓鎴愬け璐�: " + 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();
+        }
+    }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
new file mode 100644
index 0000000..c5804b0
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
@@ -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;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
index 51cfee3..c8055d5 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
+++ b/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);
 }
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
new file mode 100644
index 0000000..e705637
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -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);
+    }
+}
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
new file mode 100644
index 0000000..d973be0
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
@@ -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 "";
+    }
+}
diff --git a/traffic-audit-web/src/views/HolidayStat.vue b/traffic-audit-web/src/views/HolidayStat.vue
index aeceadc..799267a 100644
--- a/traffic-audit-web/src/views/HolidayStat.vue
+++ b/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)

--
Gitblit v1.9.1