2个文件已添加
17个文件已修改
619 ■■■■■ 已修改文件
HANDOFF.md 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/install-autostart.ps1 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/install-service.ps1 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend-service.cmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend.cmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend.sh 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/trafficAudit.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pack-deploy.ps1 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-deploy.py 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/pom.xml 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 230 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/app-version.properties 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AppHeader.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Login.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,7 +4,68 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
## æœ€æ–°ï¼ˆ2026-10-04)
## æœ€æ–°ï¼ˆ2026-10-05)
### 10-05 ä¸Šåˆï¼šå…³æœºå¿«ç…§ï¼ˆå½“前状态)
- **已完成**:v1.0.0 æ­£å¼å‘布包 `deploy/traffic-audit-deploy-20261005-v1.0.0/`(zip **77.78 MB**)已生成,去密自检、版本自检、结构核对与真实启动冒烟均通过,待上传服务器。版本管理代码与文档均已提交推送(`main` = `93b36c8`)。
- **运行状态**:关机前本机 dev æœåŠ¡ä»åœ¨è¿è¡Œï¼ˆåŽç«¯ 8090 java PID 64112、前端 8080 node PID 46472),随关机停止,无需处理;打包冒烟用的 8095 å®žä¾‹å·²åœæ­¢ã€ç«¯å£å·²é‡Šæ”¾ã€‚
- **工作区**:除若干本地临时文件(`_tmp_*`)与一份专项统计 xls å¤–,git å·¥ä½œåŒºä¸Žè¿œç«¯ `main` ä¸€è‡´ï¼›`docs/工作日结/`、`docs/功能测试报告/` ç­‰æŒ‰çº¦å®šä¸å…¥åº“。
- **下次接手**:先读根目录 `AGENTS.md` + æœ¬æ–‡ä»¶ï¼›å¾…办见第七节(数值精度统一改造、态势感知图、AI åŠ©æ‰‹ RAG、经济指标试算等),首要事项是跟进正式包在服务器的部署结果。
### 10-05 ä¸Šåˆï¼šæ­£å¼å‘布包按新版本管理重新生成(v1.0.0,单端口瘦包)
- **背景**:`8b71049` å·²æŠŠæ‰“包脚本改为固定 jar å + ç‰ˆæœ¬è‡ªæ£€ï¼ˆ`traffic-audit-server.jar`、包名带 `v{version}`),但正式发布包还没按新脚本重新生成;版本改造前产出的 `deploy/traffic-audit-deploy-20261005-2/` ä»æ˜¯æ—§ jar å `traffic-audit-server-1.0.0-SNAPSHOT.jar`。
- **重新生成**:`powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper`(后端 clean package é‡å»ºã€å‰ç«¯ `npm run build` é‡å»ºï¼‰ã€‚
- **产物**:`deploy/traffic-audit-deploy-20261005-v1.0.0/`(351 æ–‡ä»¶ / 94.58 MB),zip **77.78 MB**。
- **校验**:去密自检 + ç‰ˆæœ¬è‡ªæ£€é€šè¿‡ï¼ˆpom / `app-version.properties` / `MANIFEST.MF` ä¸‰è€…均 `1.0.0`);包内 `backend\traffic-audit-server.jar` ä¸ºå›ºå®šåï¼›æ—  `frontend/`、无 `trafficAudit.exe/.xml/install-service*`;`backend\web` 44 æ–‡ä»¶ä¸Ž `frontend\dist` é€æ–‡ä»¶ SHA-256 ä¸€è‡´ï¼›`README-部署说明.txt` å¤´éƒ¨ä¸ºã€Œ20261005,版本 v1.0.0」并含旧服务器一次性升级说明。
- **启动冒烟(真实包、只读)**:用本地开发库配置在 8095 ç«¯å£å¯åŠ¨åŒ…å†… jar,`GET /api/version` è¿”回 `{"version":"1.0.0",...}`,`GET /` è¿”回登录页 200;验证后已按 PID 58652 ç²¾ç¡®åœæ­¢å†’烟进程。
- **状态**:待用户上传服务器。`deploy/traffic-audit-deploy-20261005-2/`(及更早的 `20261005/`)作废勿用。旧服务器首次升级按 README:先改 `trafficAudit.xml` çš„ `<arguments>` ä¸º `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"` å†æ¢ jar,以后只替换 jar。
### 10-05:版本管理采用方案 A(固定 jar åï¼Œç”¨æˆ·éªŒæ”¶é€šè¿‡ï¼‰
- **用户决策**:采用方案 A。`traffic-audit-server/pom.xml` ä¸ºå”¯ä¸€ç‰ˆæœ¬æºï¼Œå½“前对外版本 `1.0.0`。
- **构建改造**:`<finalName>traffic-audit-server</finalName>`,产物固定为 `target/traffic-audit-server.jar`;Maven èµ„源过滤生成 `app-version.properties`(Spring Boot çˆ¶ POM ä½¿ç”¨ `@project.version@` åˆ†éš”符),`maven-jar-plugin` å†™å…¥ `Implementation-Version`。
- **页面显示**:新增免登录 `GET /api/version`;登录页和页头显示 `V1.0.0`。同时修复可执行 jar ä¸‹ `buildTime` ä¸º `null` çš„嵌套 URI è§£æžé—®é¢˜ã€‚
- **发布链路**:服务 XML、启动脚本、打包脚本全部改用固定 jar åï¼›`pack-deploy.ps1` ä¼šæ ¡éªŒ pom / `app-version.properties` / `MANIFEST.MF` ä¸‰è€…版本一致,发布包名为 `traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`,并输出带 `{version}` çš„部署 README。
- **旧服务器首次升级(仅一次)**:停止服务后,把 `trafficAudit.xml` çš„ `<arguments>` æ”¹ä¸º `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"`,替换 jar åŽå¯åŠ¨ã€‚ä»¥åŽå‘ç‰ˆåªæ›¿æ¢ jar å¹¶é‡å¯ï¼Œä¸å†æ”¹ XML。
- **验证**:`mvn -o test`、`mvn -o clean package -DskipTests`、固定名 jar å®žé™…启动并访问 `/api/version`、前端 `npm run build`、本地浏览器登录页显示 `V1.0.0`、`pack-deploy.ps1 -SkipFrontend -NoFrontendDist -SkipServiceWrapper` ç»„装/去密/版本自检全部通过;负向验证确认版本不匹配和 `SNAPSHOT` éƒ½ä¼šåœ¨æ‰“包前中止。测试报告:本地 `docs/功能测试报告/功能测试报告_版本管理方案A_2026-10-05.md`(目录不入库)。
- **状态**:版本管理代码提交为 `8b71049`,`main` å·²åŒæ­¥ï¼›`v1.0.0` å·²æŽ¨é€åˆ°å…¬å¸ Gitblit并指向 `21a5bf7`。2026-10-05 ç”¨æˆ·ç¡®è®¤æœ¬åœ°ç™»å½•页及所有登录后页面均正确显示 `V1.0.0`。正式发布包已于 10-05 ä¸Šåˆç”¨æ–°ç‰ˆæ‰“包脚本重新生成(见上方 10-05 ä¸Šåˆä¸€èŠ‚ï¼‰ã€‚
### 10-04 æ·±å¤œï¼šæŠ˜çº¿æ•°å€¼æ ‡ç­¾å›žåˆ°è´´è¿‘本点(v35,待用户验收)
- **用户反馈**:v31「避开折线」版本不如上一版 v27 â€”— è™½ç„¶æ ‡ç­¾ä¸åŽ‹çº¿äº†ï¼Œä½†å¾ˆå¤šæ ‡ç­¾ç¦»è‡ªå·±çš„æ•°æ®ç‚¹å¤ªè¿œï¼Œä¸€çœ¼çœ‹ä¸å‡ºæ ‡çš„æ˜¯å“ªä¸ªç‚¹ã€‚è¦æ±‚å›žåˆ°è´´è¿‘æœ¬ç‚¹çš„æ•ˆæžœã€‚
- **新问题与旧需求的冲突**:更早的原始诉求是「数字压线看不清」;这次诉求是「数字必须贴近本点」。两者并存,必须同时满足。
- **实现(`HolidayTrendChartService`)**:
  - æ ‡ç­¾æŽ’版**完整回退到 v27 ç®—法**:按同日点位距离做代价(`ownDistance Ã—10`),候选含近/远的上、下、左、右及四角,动态规划保证同日标签与点位上下顺序一致(`lineLabelCost`、`lineLabelCandidates`)。v31 çš„线条掩膜、Chamfer è·ç¦»åœºã€é¢œè‰²äº²å’Œé¡¹å…¨éƒ¨ç§»é™¤ã€‚
  - **新增粗白描边(halo)** è§£å†³ã€ŒåŽ‹çº¿çœ‹ä¸æ¸…ã€ï¼š`drawLabelWithHalo` ç”¨ `TextLayout` å–字形轮廓,先以白色 `HALO_OUTLINE_WIDTH=4.0f` åœ†è§’描边,再用本色填充。数字即使落在折线上也清晰可读,从而允许标签贴回本点。折线图、堆叠图外置标签、饼图标签统一走该方法。
  - é¡ºå¸¦æ¸…理调试遗留:删除 `LABEL_DEBUG`/`CHART_SEQ`/`TRENDDBG` æ‰“印,以及不再使用的 `lineNormal(...)` ä¸Ž `DayLabel.normalX/normalY`。
- **量化验证(RapidOCR æ ‡ç­¾åŒ…围框中心 â†” çœŸå®žæ•°æ®ç‚¹åæ ‡ï¼‰**:标签到本点平均距离 v27=`36.8px`、v31=`53.2px`、v35=`36.7px`(越小越贴近本点)。v35 ä¸Žç”¨æˆ·è®¤å¯çš„ v27 åŸºæœ¬ä¸€è‡´ï¼Œæ˜Žæ˜¾ä¼˜äºŽè¢«å¦çš„ v31。
- **其余验证**:`mvn compile`/`mvn test` é€šè¿‡ï¼ˆæ— ç¼–译告警遗留);Word COM 8 é¡µ 15 å›¾ï¼ˆ1+2×7);RapidOCR å¤æ£€ 12 å¼ æŠ˜çº¿å›¾æ•°å€¼æ ‡ç­¾åŒ…围框重叠数 = 0;`label_affinity` å¤æ£€å›¾ 11 æ¯ä¸ªæ•°æ®æ ‡ç­¾åˆ°æœ¬çº¿ 3~8px ä¸”本线是最近线(无「CLOSER TO å…¶ä»–色」);放大 6נ复核图 11 å·¦ä¸Šæ ‡ç­¾ç¡®ä¸º `630.99`(低分辨率 OCR æ›¾è¯¯è¯»ä¸º `1630.99`)。v27↔v35 åƒç´ å·®çº¦ 1%,集中在标签处,即白描边生效且未破坏折线。
- **产物与状态**:`_tmp_holiday_verify/holiday_trend_2026-10-04_v35.docx`;下载副本 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数字贴近折线-白边清晰).docx`。未导入、覆盖或重导任何数据。待用户验收;验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` ä»æ˜¯ v24 è¡Œä¸ºï¼ŒéªŒæ”¶åŽéœ€é‡å»ºã€‚
### 10-05:走势图 v35 éªŒæ”¶é€šè¿‡ + å•端口发布包(-NoFrontendDist -SkipServiceWrapper)
- **验收**:v35(折线数值标签回到贴近本点 + ç™½æè¾¹ï¼‰2026-10-05 ç”¨æˆ·éªŒæ”¶é€šè¿‡ã€‚
- **打包需求(用户澄清)**:服务器单端口,只需要 `backend\web`,不要独立的 `frontend/dist`;为保证包完整,`backend\web` + jar + docs éƒ½è¦ã€‚服务器非首次发布、服务已装,可加 `-SkipServiceWrapper` åŽ»æŽ‰æœåŠ¡æ³¨å†Œå™¨ã€‚
- **打包工具修正**:先前加的 `-NoFrontend` è¯­ä¹‰é”™äº†ï¼ˆæŠŠ `backend\web` ä¹ŸåŽ»æŽ‰äº†ï¼‰ï¼ŒçŽ°æ”¹ä¸º `-NoFrontendDist`。它只跳过独立 `frontend\` ç›®å½•(不复制 dist / nginx ç¤ºä¾‹ï¼‰ï¼Œ**仍构建(或要求)dist å¹¶åŒæ­¥ `backend\web`**(单端口必需,否则页面 404)。`-SkipFrontend` ä»ä¸ºâ€œä¸é‡å»ºã€å¤ç”¨çŽ°æœ‰ dist”。
- **发布包**:`deploy/traffic-audit-deploy-20261005-2/`(351 æ–‡ä»¶ / 94.55 MB;zip **77.78 MB**)。已核对:无 `frontend/`、无 `trafficAudit.exe/.xml/install-service*`(服务包装器已排除);`backend\web` 44 æ–‡ä»¶ä¸Ž `frontend\dist` é€æ–‡ä»¶å“ˆå¸Œä¸€è‡´ï¼›jar å†…置配置全占位符(去密自检通过);包内 `HolidayTrendChartService.class` ä¸Ž `target/classes` ä¸€è‡´ï¼ˆå« v35 ä»£ç ï¼‰ã€‚较上一完整包(110.49 MB / zip 83.77 MB)省约 16 MB è§£åŽ‹ / 6 MB åŽ‹ç¼©ã€‚
- **作废**:`deploy/traffic-audit-deploy-20261005/`(错误语义下的产物,缺 `backend\web`)已被 `-2` å–代,勿用。
- **部署注意**:覆盖 jar(及按需 `application.yml`)即可;`backend\web` å·²éšåŒ…提供。服务已装,无需重新注册。
- **状态**:已打包,待用户上传到服务器;该包为版本改造前产物,已由 10-05 ä¸Šåˆçš„ `traffic-audit-deploy-20261005-v1.0.0` å–代(勿用)。
### 10-04 æ·±å¤œï¼šæŠ˜çº¿æ•°å€¼æ ‡ç­¾é¿å¼€æŠ˜çº¿å¹¶è´´è¿‘本线(v31,待用户验收)
- **用户反馈(领导看后,两轮)**:① å„年份数字离本线有远有近,希望贴近各自色线且不错乱;② ä»æœ‰æ•°å­—压在折线上看不清(第一轮只是“贴近”,标签会落在线上)。用户建议:调纵轴刻度拉开折线、或加左右避让让标签不压线。
- **实现(`HolidayTrendChartService`)**:
  - æ ‡ç­¾æŽ’版改为「动态规划 + çº¿æ¡åƒç´ é¿è®©ã€ã€‚图表先画折线和标记,再把所有系列颜色像素(容差 45)做成掩膜,对每个候选标签位置统计其覆盖的线条像素数,`×700` é‡ç½šâ€”—数字不再压线。
  - ä¸ºæ¯æ¡ç³»åˆ—建立 Chamfer è·ç¦»åœºï¼ˆåˆ°è¯¥è‰²æœ€è¿‘像素的距离)。代价新增两项:`max(0, è‡ªå·±é‚£æ¡çº¿çš„距离-3)×40`(把标签拉回本线旁,防止飘远)与颜色亲和项 `max(0, è‡ªå·±çº¿è· - åˆ«çš„线距 + 4)×200`(要求本线是最近的线,避免贴到别的系列)。
  - æ—§çš„「离别的同日点位更近」惩罚由 `×5000` é™åˆ° `×400`,避免它压倒亲和项;`ITEM_LABEL_GAP` 12→10;候选含近/远的上、下、左、右及四角。
  - ä¿ç•™åŒæ—¥æ ‡ç­¾ä¸Žæ•°æ®ç‚¹ä¸€è‡´çš„上下顺序,避免标签与折线上下反转。
- **调参过程提示**:v27(只按点位距离排序)→ æ ‡ç­¾åŽ‹çº¿ï¼›v28(只加覆盖像素惩罚)→ æ ‡ç­¾ç¦»æœ¬çº¿ã€è´´åˆ°åˆ«çš„色线旁;v31(覆盖惩罚 + æœ¬çº¿è·ç¦» + é¢œè‰²äº²å’Œï¼‰â†’ ä¸¤è€…兼顾。验证标签是否“贴错线”不能只看 OCR åŒ…围框到异色的距离:相邻的异色**标签**会被误判为异色**线**,必须肉眼核对整图。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v31.docx`;Word COM 8 é¡µ 15 å›¾ï¼ˆ1+2×7)。RapidOCR å¤æ£€ 15 å¼ å›¾æ•°å€¼æ ‡ç­¾åŒ…围框重叠数为 0。肉眼核对图 1(跨区域人员流动量)、图 2(累计)、图 4(自驾)、图 6(水路)、图 9(高速)、图 11(车流总量):数值标签均离开折线、位于各自色线旁、颜色对应正确;图 11 çš„ 630.99/601.04/571.31 åˆ†åˆ«ç´§è´´è“/红/绿折线。
- **产物与状态**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数值标签避开折线).docx`。未导入、覆盖或重导任何数据。待用户验收,验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` ä»æ˜¯ v24 è¡Œä¸ºï¼Œéœ€éªŒæ”¶åŽé‡å»ºã€‚
### 10-04 æ™šï¼šèµ°åŠ¿å›¾ 3 é¡¹åæ ‡è½´è°ƒæ•´ï¼ˆv24,用户已验收)
- **用户修改项**:① åæ ‡è½´å•位(万人次、万辆次、公里/小时、拥挤度、日期)改成和刻度一样的颜色;② ä¸¤ä¸ªå †å å›¾çš„坐标轴改成与其他图一样的黑色;③ â€œä¸‡äººæ¬¡/占比”继续放在堆叠图右上角。
deploy-templates/README-²¿Êð˵Ã÷.txt
@@ -1,4 +1,4 @@
TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp})
TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp},版本 v{version})
========================================================
【服务器上只要做这 3 æ­¥ã€‘(其它文件都不用动)
@@ -18,7 +18,7 @@
【目录结构】
  backend/
    traffic-audit-server-1.0.0-SNAPSHOT.jar  åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**)
    traffic-audit-server.jar                 åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**,固定文件名)
    docs/                                    æŠ¥è¡¨æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ç›®å½•(必需,勿删)
    application.yml                          å¤–部配置(改这里即可,无需重新打包)
    start-backend.cmd / start-backend.sh     å‰å°å¯åŠ¨è„šæœ¬ï¼ˆäººå·¥è°ƒè¯•ç”¨ï¼Œå…³äº†çª—å£å°±åœï¼‰
@@ -64,6 +64,16 @@
  - è„šæœ¬è¦æ±‚ï¼šinstall-service.ps1 åœ¨ Windows PowerShell 2.0+ ä¸‹å¯ç”¨ï¼ˆWin7 è‡ªå¸¦ 2.0,已做兼容);
    3.2 çš„计划任务脚本用到了 New-ScheduledTask* å‘½ä»¤ï¼Œéœ€è¦ Win8 / Server 2012 åŠä»¥ä¸Šã€‚
【从本版起:后端 jar ä½¿ç”¨å›ºå®šæ–‡ä»¶åã€‘
  backend\traffic-audit-server.jar
  - æ–°æœåŠ¡å™¨ï¼šæŒ‰ä¸‹é¢çš„æ™®é€šéƒ¨ç½²æ­¥éª¤æ‰§è¡Œå³å¯ã€‚
  - å·²å®‰è£…旧版服务的服务器(首次升级到本版):
      1) åœæ­¢æœåŠ¡ï¼šStop-Service TrafficAuditBackend
      2) å¤‡ä»½å¹¶ç¼–辑 backend\trafficAudit.xml,把 <arguments> é‡Œçš„æ—§ jar åæ”¹æˆ traffic-audit-server.jar
      3) æ›¿æ¢ backend\traffic-audit-server.jar,再执行 Start-Service TrafficAuditBackend
  - ä»¥åŽæ¯æ¬¡å‡çº§ï¼šåªæ›¿æ¢ backend\traffic-audit-server.jar,再重启服务;不再需要修改 trafficAudit.xml。
  - é¡µé¢ç™»å½•页和右上角显示的版本号与本发布包名称中的版本号一致。
【部署步骤】
  1. åˆå§‹åŒ–数据库(4 ä¸ªè„šæœ¬éƒ½è¦æ‰§è¡Œï¼Œé¡ºåºå¦‚ä¸‹ï¼‰
       mysql --default-character-set=utf8mb4 -u root -p < db/init.sql
deploy-templates/backend/install-autostart.ps1
@@ -65,7 +65,7 @@
Assert-Admin
if (-not (Test-Path $launcher)) { throw "Missing launcher: $launcher" }
if (-not (Test-Path (Join-Path $root 'traffic-audit-server-1.0.0-SNAPSHOT.jar'))) {
if (-not (Test-Path (Join-Path $root 'traffic-audit-server.jar'))) {
  Write-Host "WARN: jar not found in $root - the task will be registered, but startup will fail until the jar is there."
}
deploy-templates/backend/install-service.ps1
@@ -1,8 +1,8 @@
<#
<#
  Install / update / remove the TrafficAudit backend as a Windows service.
  The service is a WinSW (Windows Service Wrapper) host: trafficAudit.exe reads
  trafficAudit.xml and runs "java -jar traffic-audit-server-<version>.jar".
  trafficAudit.xml and runs "java -jar traffic-audit-server.jar".
  Because it is a real Windows service it starts at boot with no user logged on,
  is listed in services.msc / Get-Service, and is restarted by the Service
  Control Manager when the java process dies.
deploy-templates/backend/start-backend-service.cmd
@@ -14,7 +14,7 @@
setlocal
cd /d "%~dp0"
set "APP_JAR=traffic-audit-server-1.0.0-SNAPSHOT.jar"
set "APP_JAR=traffic-audit-server.jar"
set "LOG=%~dp0app.log"
netstat -ano | findstr /C:":8090" | findstr /I "LISTENING" >nul 2>nul
deploy-templates/backend/start-backend.cmd
@@ -22,5 +22,5 @@
echo  å‰ç«¯å·²å†…嵌,直接访问 8090 å³å¯ï¼ˆä¸éœ€è¦ nginx)
echo  æ—¥å¿—显示在本窗口,关闭窗口即停止服务
echo ============================================
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
java -jar traffic-audit-server.jar
pause
deploy-templates/backend/start-backend.sh
@@ -7,4 +7,4 @@
  echo "[WARN] application.yml é‡Œ spring.datasource.password è¿˜æ˜¯å ä½æ–‡å­—,请先改成真实 MySQL å¯†ç "
fi
echo "TrafficAudit å¯åŠ¨ä¸­ï¼šhttp://localhost:8090 ï¼ˆCtrl+C åœæ­¢ï¼‰"
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
java -jar traffic-audit-server.jar 2>&1 | tee app.log
deploy-templates/backend/trafficAudit.xml
@@ -20,7 +20,7 @@
  <description>TrafficAudit freight statistics audit backend (Spring Boot, HTTP port 8090).</description>
  <executable>java.exe</executable>
  <arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server-1.0.0-SNAPSHOT.jar"</arguments>
  <arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
  <workingdirectory>%BASE%</workingdirectory>
  <!-- Start at boot, but delayed so a local MySQL service is ready first. -->
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -1,6 +1,6 @@
# äº¤é€šç»Ÿè®¡æŠ¥è¡¨å®¡æ ¸ç³»ç»Ÿ â€” æ‰“包与部署说明
> é€‚用:main åˆ†æ”¯ï¼ˆæœ€æ–°æ›´æ–° 2026-09-13)。后端 Spring Boot 2.7(Java 8),前端 Vue 2 + Element UI。
> é€‚用:main åˆ†æ”¯ï¼ˆæœ€æ–°æ›´æ–° 2026-10-05)。后端 Spring Boot 2.7(Java 8),前端 Vue 2 + Element UI。
> æœ¬æ–‡æ¡£é¢å‘开发与部署同事,命令均在 Windows ä¸‹éªŒè¯ã€‚
## ä¸€ã€é¡¹ç›®ç»“æž„
@@ -34,8 +34,8 @@
1. `traffic-audit-web` ä¸‹ `npm run build` â†’ `dist`;
2. æŠŠ `dist` å¤åˆ¶è¿› `traffic-audit-server/src/main/resources/static`,使 **jar è‡ªå¸¦å‰ç«¯**,再 `mvn clean package -DskipTests`(跑完自动清空 static,保持仓库干净);
3. ç»„装 `deploy/traffic-audit-deploy-YYYYMMDD/`;
4. åŽ‹ç¼©ä¸º `traffic-audit-deploy-YYYYMMDD.zip`(约 70 MB)。
3. ç»„装 `deploy/traffic-audit-deploy-YYYYMMDD-v1.0.0/`;
4. åŽ‹ç¼©ä¸º `traffic-audit-deploy-YYYYMMDD-v1.0.0.zip`(约 70 MB)。
手工等价命令(不想用脚本时):
@@ -47,12 +47,41 @@
> æ³¨æ„ï¼šæ‰‹å·¥æ–¹å¼è·‘完记得删掉 `traffic-audit-server/src/main/resources/static` ä¸‹çš„内容,否则会污染源码目录。
### ç‰ˆæœ¬ç®¡ç†ä¸Žå‘布(2026-10-05 èµ·ï¼‰
采用方案 A:后端 jar ä½¿ç”¨å›ºå®šæ–‡ä»¶åï¼Œç‰ˆæœ¬å·ç”± `pom.xml` å•一来源生成,发布包名和 git tag å¸¦ç‰ˆæœ¬ã€‚
- å•一版本源:`traffic-audit-server/pom.xml` çš„ `<version>`,例如 `1.0.0`
- æž„建产物固定为 `traffic-audit-server/target/traffic-audit-server.jar`
- æž„建时自动把版本写进:
  - `BOOT-INF/classes/app-version.properties`
  - `META-INF/MANIFEST.MF` çš„ `Implementation-Version`
- å‘布包命名:`traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`
- git tag å‘½åï¼š`v1.0.0`
- é¡µé¢ç‰ˆæœ¬ï¼šåŽç«¯ `GET /api/version`,登录页和页头显示 `V1.0.0`
- `pack-deploy.ps1` ä¼šæ ¡éªŒ `pom.xml`、jar å†… `app-version.properties`、jar æ¸…单三者版本一致,不一致直接中止打包
发新版本流程:
1. å…ˆæ”¹ `traffic-audit-server/pom.xml` çš„ `<version>`,例如 `1.0.1`
2. è¿è¡Œ `pack-deploy.ps1` æ‰“包
3. æäº¤ä»£ç å¹¶æ‰“ git tag:`git tag -a v1.0.1 -m "v1.0.1"`
4. æœåŠ¡å™¨åœæ­¢æœåŠ¡ï¼Œæ›¿æ¢ `traffic-audit-server.jar`,再启动服务
旧服务器首次升级(只需做一次):已有 `trafficAudit.xml` é€šå¸¸ä»å†™æ­»æ—§ jar å `traffic-audit-server-1.0.0-SNAPSHOT.jar`,把 `<arguments>` æ”¹æˆå›ºå®šæ–‡ä»¶åï¼š
```xml
<arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
```
以后每次发布只替换 jar å¹¶é‡å¯æœåŠ¡ï¼Œä¸å†ä¿®æ”¹ `trafficAudit.xml`。
## å››ã€éƒ¨ç½²åŒ…内容
```
traffic-audit-deploy-YYYYMMDD/
traffic-audit-deploy-YYYYMMDD-v1.0.0/
├─ backend/
│  â”œâ”€ traffic-audit-server-1.0.0-SNAPSHOT.jar   åŽç«¯ + å†…嵌前端(约 55 MB)
│  â”œâ”€ traffic-audit-server.jar                   åŽç«¯ + å†…嵌前端(约 55 MB,固定文件名)
│  â”œâ”€ docs/                                     æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ï¼ˆå¿…需,勿删)
│  â”œâ”€ application.yml                           å¤–部配置(改这里即可,无需重打包)
│  â”œâ”€ start-backend.cmd / start-backend.sh      å¯åŠ¨è„šæœ¬ï¼ˆäººå·¥åŒå‡»/命令行)
@@ -161,7 +190,7 @@
```powershell
# Windows:双击 start-backend.cmd;或
cd backend ; java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
cd backend ; java -jar traffic-audit-server.jar
```
```bash
@@ -270,6 +299,7 @@
## åä¸€ã€å˜æ›´è®°å½•
- **2026-10-05**:版本管理采用方案 A。`pom.xml` ä¸ºå”¯ä¸€ç‰ˆæœ¬æºï¼Œjar å›ºå®šåä¸º `traffic-audit-server.jar`,构建自动注入 `app-version.properties` ä¸Ž `MANIFEST.MF`;发布包名带版本,新增 `/api/version` é¡µé¢æ˜¾ç¤ºï¼Œé¦–次升级旧服务器需一次性修改 `trafficAudit.xml`,以后只替换 jar å¹¶é‡å¯ã€‚
- **2026-10-03**:`pack-deploy.ps1` è¡¥é½å¤–ç½® `application.yml`/`docs`/`db`/启动脚本并同步 `backend\web`(修 `Access denied ... (using password: NO)`);模板端口 8080→8090;新增 Windows æœåŠ¡æ–¹å¼å¼€æœºè‡ªå¯ï¼ˆWinSW 2.12.0 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`,自动/延迟启动、失败重启、日志滚动),计划任务方式降为备选;补操作系统兼容性说明。
- **2026-09-13**:新增一键打包脚本 `package-deploy.py`;jar æ”¹ä¸ºå†…嵌前端(部署机不再需要 nginx);删除 Redis ç›¸å…³è¯´æ˜Žï¼ˆä»£ç æœªä½¿ç”¨ï¼‰ï¼›è¡¥ `sql_wyc.sql` ä¸Ž `summary.template-dir`;补 `jwt.secret` å–值规则;补部署后验证清单。
- 2026-08-27:首版(zip éƒ¨ç½²åŒ… + nginx æ–¹æ¡ˆï¼‰ã€‚
@@ -306,7 +336,7 @@
# 1) ç¡®è®¤è¿› jar çš„配置没有真实密钥(应只看到 ${...})
Select-String -Path traffic-audit-server\src\main\resources\application.yml -Pattern "password:|api-key:|secret:"
# 2) æ‰“完包后再验证一次 jar å†…部
python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server-1.0.0-SNAPSHOT.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
```
@@ -321,7 +351,7 @@
1. ç”¨ `deploy-templates/jar-application.yml`(全占位符)临时替换源码 `application.yml`,再 `mvn clean package -DskipTests`,构建后自动还原开发机真实配置;
2. **解压 jar è‡ªæ£€**:检查 `BOOT-INF/classes/application.yml` æ˜¯å¦å…¨ä¸ºå ä½ç¬¦â€”—一旦发现 `sk-` æˆ–明文口令**直接中止打包**;
3. `npm run build` é‡å»ºå‰ç«¯ `dist`;
4. æŒ‰ `deploy-templates/` ç»„装完整部署包(backend / frontend / db / README-部署说明.txt)——`backend/` å« jar、`application.yml`、启动脚本、`docs/`、`web/`(由 dist è‡ªåŠ¨åŒæ­¥ï¼Œä¾› 8090 å•端口模式),以及 Windows æœåС䏉件套 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`,输出到 `<OutRoot>\traffic-audit-deploy-<yyyyMMdd>`;
4. æŒ‰ `deploy-templates/` ç»„装完整部署包(backend / frontend / db / README-部署说明.txt)——`backend/` å« jar、`application.yml`、启动脚本、`docs/`、`web/`(由 dist è‡ªåŠ¨åŒæ­¥ï¼Œä¾› 8090 å•端口模式),以及 Windows æœåС䏉件套 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`,输出到 `<OutRoot>\traffic-audit-deploy-<yyyyMMdd>-v<version>`;
   WinSW ä¼˜å…ˆç”¨ `deploy-templates\tools\winsw.exe` ç¼“存(校验 SHA-256),缓存缺失或哈希不符时自动从 GitHub å®˜æ–¹å‘布页下载 v2.12.0 x64(该产物未做 Authenticode ç­¾åï¼Œå› æ­¤ä»¥å›ºå®š SHA-256 ä½œå®Œæ•´æ€§æ ¡éªŒï¼‰ï¼›
5. å¯¹ç»„装后的整包再扫一遍明文密钥。
@@ -344,7 +374,7 @@
### éƒ¨ç½²æœºä¸ŠåŠ /换 DeepSeek key
1. æ‰“包上传后,编辑 **jar åŒçº§**的 `backend/application.yml`:`deepseek.api-key: "你的新key"`(留空则页面 AI åˆ†æžä¸å¯ç”¨ï¼Œå…¶ä½™åŠŸèƒ½æ­£å¸¸ï¼‰ï¼›
2. é‡å¯åŽç«¯ï¼š`start-backend.cmd`(或 `java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar`);
2. é‡å¯åŽç«¯ï¼š`start-backend.cmd`(或 `java -jar traffic-audit-server.jar`);
3. é¡µé¢é‡Œç‚¹ä¸€æ¬¡ AI åˆ†æžç¡®è®¤å¯ç”¨ï¼›
4. **不要**去改 jar å†…部的配置(改了下次发包就丢),也不要把 key æäº¤è¿›ä»»ä½•仓库文件。
pack-deploy.ps1
@@ -3,13 +3,16 @@
  ç”¨æ³•:
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -OutRoot "E:\试验" -SkipFrontend
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper
      â€”— å•端口发布:不生成独立的 frontend\dist ç›®å½•(仍构建并同步 backend\web,单端口必需);
         æœåŠ¡å™¨å·²è£…æœåŠ¡æ—¶é…åˆ -SkipServiceWrapper åŽ»æŽ‰çº¦ 17 MB çš„ WinSW,可显著减小包体积。
  åšäº”件事:
    1) ç”¨ deploy-templates\jar-application.yml(全占位符)临时替换源码 application.yml,
       æ‰§è¡Œ mvn clean package;无论成败都还原开发机真实配置
    2) è§£åŽ‹ jar æ£€æŸ¥å†…ç½® application.yml:必须全是占位符(禁止 sk- å¯†é’¥ / æ˜Žæ–‡å£ä»¤ï¼‰ï¼Œä¸åˆæ ¼ç›´æŽ¥ä¸­æ­¢
    3) npm run build é‡å»ºå‰ç«¯ dist(可 -SkipFrontend)
    4) ç»„装完整部署包:backend(jar + application.yml + å¯åŠ¨è„šæœ¬ + æœåŠ¡åŒ…è£…å™¨ + docs + web) / frontend/dist / db / README
    4) ç»„装完整部署包:backend(jar + application.yml + å¯åŠ¨è„šæœ¬ + æœåŠ¡åŒ…è£…å™¨ + docs + web) / frontend/dist(可选,-NoFrontendDist ä¸æ‰“) / db / README
       Windows æœåŠ¡åŒ…è£…å™¨ WinSW:优先用 deploy-templates\tools\winsw.exe ç¼“存(校验 SHA-256),
       ç¼ºå¤±æ—¶è‡ªåŠ¨ä¸‹è½½ v2.12.0 x64 åˆ°è¯¥ç¼“存目录;随包输出为 backend\trafficAudit.exe
    5) å¯¹ç»„装后的整包再扫一遍明文密钥
@@ -22,17 +25,24 @@
param(
  [string]$OutRoot = '',
  [switch]$SkipFrontend,
  [switch]$NoFrontendDist,  # å•端口发布:不打独立的 frontend\dist ç›®å½•,但仍构建/同步 backend\web(单端口必需)
  [switch]$SkipBackend,     # å¤ç”¨å·²å­˜åœ¨çš„ jar(不重编译、不动源码配置),仅用于补全/重组发布包
  [switch]$SkipServiceWrapper   # ä¸æŠŠ WinSW æœåŠ¡åŒ…è£…å™¨æ‰“è¿›åŒ…ï¼ˆå³ä¸æä¾›â€œæ³¨å†Œæˆ Windows æœåŠ¡â€çš„å¼€æœºè‡ªå¯ï¼‰
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$pomPath = Join-Path $root 'traffic-audit-server\pom.xml'
if (-not (Test-Path $pomPath)) { throw "Missing Maven project: $pomPath" }
[xml]$pom = Get-Content -LiteralPath $pomPath -Raw -Encoding UTF8
$productVersion = [string]$pom.project.version
if (-not $productVersion) { throw 'Missing <version> in traffic-audit-server\pom.xml' }
if ($productVersion -match '(?i)-SNAPSHOT$') { throw "Release builds must not use a SNAPSHOT version: $productVersion" }
if (-not $OutRoot) { $OutRoot = Join-Path $root 'deploy' }      # é»˜è®¤è¾“出到仓库 deploy\(已 gitignore)
$tplDir    = Join-Path $root 'deploy-templates'                  # æ‰“包模板件(已入库)
$tplJarCfg = Join-Path $tplDir 'jar-application.yml'             # è¿› jar çš„占位符配置
$srcCfg    = Join-Path $root 'traffic-audit-server\src\main\resources\application.yml'
$jarRel    = 'traffic-audit-server\target\traffic-audit-server-1.0.0-SNAPSHOT.jar'
$jarRel    = 'traffic-audit-server\target\traffic-audit-server.jar'
$jar       = Join-Path $root $jarRel
$webDist   = Join-Path $root 'traffic-audit-web\dist'
@@ -82,6 +92,18 @@
  $sr = New-Object System.IO.StreamReader($entry.Open())
  $text = $sr.ReadToEnd()
  $sr.Close()
  $versionEntry = $zip.Entries | Where-Object { $_.FullName -eq 'BOOT-INF/classes/app-version.properties' }
  if (-not $versionEntry) { throw 'jar å†…缺少 BOOT-INF/classes/app-version.properties' }
  $vr = New-Object System.IO.StreamReader($versionEntry.Open())
  $versionText = $vr.ReadToEnd()
  $vr.Close()
  $manifestEntry = $zip.Entries | Where-Object { $_.FullName -eq 'META-INF/MANIFEST.MF' }
  if (-not $manifestEntry) { throw 'jar å†…缺少 META-INF/MANIFEST.MF' }
  $mr = New-Object System.IO.StreamReader($manifestEntry.Open())
  $manifestText = $mr.ReadToEnd()
  $mr.Close()
} finally { $zip.Dispose() }
function Get-SecretHits([string]$ymlText) {
@@ -105,7 +127,21 @@
}
Write-Host '      é€šè¿‡ï¼šjar å†…置配置全部为占位符,无明文密钥/口令。'
$builtVersion = ''
if ($versionText -match '(?m)^\s*version\s*=\s*(.+?)\s*$') { $builtVersion = $matches[1].Trim() }
if ($builtVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, app-version.properties=$builtVersion"
}
$manifestVersion = ''
if ($manifestText -match '(?m)^Implementation-Version:\s*(.+?)\s*$') { $manifestVersion = $matches[1].Trim() }
if ($manifestVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, MANIFEST.MF=$manifestVersion"
}
Write-Host "      ç‰ˆæœ¬è‡ªæ£€é€šè¿‡ï¼š$productVersion (pom / app-version.properties / MANIFEST.MF)"
# ---------- [3/5] å‰ç«¯æž„建 ----------
# å•端口部署必须有 backend\web,所以即使 -NoFrontendDist ä¹Ÿéœ€è¦ dist;只有 -SkipFrontend æ‰å¤ç”¨çŽ°æœ‰ dist。
Write-Host '[3/5] æž„建前端(npm run build,可 -SkipFrontend è·³è¿‡ï¼‰...'
if (-not $SkipFrontend) {
  Push-Location (Join-Path $root 'traffic-audit-web')
@@ -157,10 +193,14 @@
# ---------- [4/5] ç»„装完整部署目录 ----------
Write-Host '[4/5] ç»„装部署目录...'
$stamp = Get-Date -Format 'yyyyMMdd'
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp)
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion)
$n = 2
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-" + $n); $n++ }
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion + "-" + $n); $n++ }
if ($NoFrontendDist) {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'db') | Out-Null
} else {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
}
Copy-Item $jar (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\application.yml') (Join-Path $dest 'backend') -Force
@@ -175,11 +215,14 @@
  Copy-Item (Join-Path $tplDir 'backend\install-service.ps1') (Join-Path $dest 'backend') -Force
  Copy-Item (Join-Path $tplDir 'backend\install-service.cmd') (Join-Path $dest 'backend') -Force
}
Copy-Item $webDist (Join-Path $dest 'frontend') -Recurse -Force
Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
if (-not $NoFrontendDist) {
  Copy-Item $webDist (Join-Path $dest 'frontend') -Recurse -Force
  Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
}
Copy-Item (Join-Path $tplDir 'db\*.sql') (Join-Path $dest 'db') -Force
# å•端口 8090 æ¨¡å¼ï¼šåŽç«¯ä»Ž jar åŒçº§ web\ è¯»å‰ç«¯é™æ€æ–‡ä»¶ï¼Œå¿…须和 dist å†…容一致
# å•端口 8090 æ¨¡å¼ï¼šåŽç«¯ä»Ž jar åŒçº§ web\ è¯»å‰ç«¯é™æ€æ–‡ä»¶ï¼Œå¿…须和 dist å†…容一致。
# å•端口发布(-NoFrontendDist)同样必须同步,否则页面 404。
Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
# docs:运行时模板 + ç”¨æˆ·æ–‡æ¡£ï¼›æŽ’除开发过程文档与缓存/备份(减小体积、避免把内部清单带出去)
@@ -192,7 +235,7 @@
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
$readme = $readme.Replace('{stamp}', $stamp)
$readme = $readme.Replace('{stamp}', $stamp).Replace('{version}', $productVersion)
Set-Content -LiteralPath (Join-Path $dest 'README-部署说明.txt') -Value $readme -Encoding UTF8
# ---------- [5/5] æ•´åŒ…明文密钥扫描 + æ±‡æ€» ----------
package-deploy.py
@@ -2,16 +2,21 @@
"""TrafficAudit ä¸€é”®æ‰“包脚本:前端构建 -> å†…嵌进后端 jar -> ç»„装部署包 -> æ‰“ zip
用法:python package-deploy.py [--no-build]
"""
import io, os, sys, shutil, zipfile, subprocess, datetime
import io, os, sys, shutil, zipfile, subprocess, datetime, xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.join(ROOT, "traffic-audit-web")
SERVER = os.path.join(ROOT, "traffic-audit-server")
STATIC = os.path.join(SERVER, "src", "main", "resources", "static")
_pom_tree = ET.parse(os.path.join(SERVER, "pom.xml"))
_pom_ns = {"m": "http://maven.apache.org/POM/4.0.0"}
PRODUCT_VERSION = (_pom_tree.getroot().findtext("m:version", namespaces=_pom_ns) or "").strip()
if not PRODUCT_VERSION:
    raise SystemExit("Missing <version> in traffic-audit-server/pom.xml")
STAMP = datetime.datetime.now().strftime("%Y%m%d")
STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP)
ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + ".zip")
JAR_NAME = "traffic-audit-server-1.0.0-SNAPSHOT.jar"
STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION)
ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION + ".zip")
JAR_NAME = "traffic-audit-server.jar"
SKIP_DOC_DIRS = {"ai_demo", "usage_screenshots", "功能测试报告", "工作日结"}
def rmtree_force(path):
@@ -97,7 +102,7 @@
    io.open(os.path.join(be, "start-backend.cmd"), "w", encoding="utf-8", newline="\r\n").write(START_CMD)
    io.open(os.path.join(be, "start-backend.sh"), "w", encoding="utf-8", newline="\n").write(START_SH)
    io.open(os.path.join(STAGE, "frontend", "nginx.conf.example"), "w", encoding="utf-8", newline="\n").write(NGINX)
    io.open(os.path.join(STAGE, "README-部署说明.txt"), "w", encoding="utf-8", newline="\r\n").write(README)
    io.open(os.path.join(STAGE, "README-部署说明.txt"), "w", encoding="utf-8", newline="\r\n").write(README.replace("{version}", PRODUCT_VERSION))
    print("[5/6] æ‰“ zip ...")
    if os.path.isfile(ZIP):
@@ -183,14 +188,14 @@
echo  å‰ç«¯å·²å†…嵌,直接访问 8090 å³å¯ï¼ˆä¸éœ€è¦ nginx)
echo  æ—¥å¿—显示在本窗口,关闭窗口即停止服务
echo ============================================
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
java -jar traffic-audit-server.jar
pause
"""
START_SH = u"""#!/bin/sh
cd "$(dirname "$0")" || exit 1
echo "TrafficAudit å¯åŠ¨ä¸­ï¼šhttp://localhost:8090 ï¼ˆCtrl+C åœæ­¢ï¼‰"
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
java -jar traffic-audit-server.jar 2>&1 | tee app.log
"""
NGINX = u"""# å‰ç«¯ nginx é…ç½®ç¤ºä¾‹ï¼ˆåªæœ‰åœ¨ä¸æƒ³ç”¨ã€Œjar å†…嵌前端」时才需要)
@@ -210,12 +215,12 @@
}
"""
README = u"""TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp})
README = u"""TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp},版本 v{version})
========================================================
【目录结构】
  backend/
    traffic-audit-server-1.0.0-SNAPSHOT.jar  åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**)
    traffic-audit-server.jar                 åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**,固定文件名)
    docs/                                    æŠ¥è¡¨æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ç›®å½•(必需,勿删)
    application.yml                          å¤–部配置(改这里即可,无需重新打包)
    start-backend.cmd / start-backend.sh     å¯åŠ¨è„šæœ¬
traffic-audit-server/pom.xml
@@ -10,7 +10,7 @@
    </parent>
    <groupId>com.trafficaudit</groupId>
    <artifactId>traffic-audit-server</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>traffic-audit-server</name>
    <properties>
@@ -76,8 +76,36 @@
        </dependency>
    </dependencies>
    <build>
        <finalName>traffic-audit-server</finalName>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
                <excludes>
                    <exclude>app-version.properties</exclude>
                </excludes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>app-version.properties</include>
                </includes>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -74,7 +74,8 @@
    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 double ITEM_LABEL_GAP = 10.0;
    private static final float HALO_OUTLINE_WIDTH = 4.0f;
    private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
    private static final Color[] SERIES_COLORS = {
            new Color(192, 80, 77),
@@ -841,7 +842,6 @@
        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));
@@ -849,13 +849,6 @@
        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++) {
@@ -876,35 +869,95 @@
                    return Double.compare(first.getPoint().getY(), second.getPoint().getY());
                }
            });
            double previousBottom = Double.NEGATIVE_INFINITY;
            List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
            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;
                List<LineLabelCandidate> candidates = lineLabelCandidates(
                        label, metrics, dataArea);
                if (candidates.isEmpty()) {
                    candidateSets.clear();
                    break;
                }
                if (chosen == null) continue;
                candidateSets.add(candidates);
            }
            if (candidateSets.isEmpty()) continue;
            // Dynamic programming keeps each label near its own point while preserving
            // the same top-to-bottom order as the data points for that date.
            List<int[]> backPointers = new ArrayList<>();
            List<LineLabelCandidate> firstCandidates = candidateSets.get(0);
            double[] previousCosts = new double[firstCandidates.size()];
            for (int i = 0; i < firstCandidates.size(); i++) {
                previousCosts[i] = lineLabelCost(firstCandidates.get(i), labels.get(0),
                        labels, placedLabels);
            }
            for (int labelIndex = 1; labelIndex < candidateSets.size(); labelIndex++) {
                List<LineLabelCandidate> previousCandidates = candidateSets.get(labelIndex - 1);
                List<LineLabelCandidate> currentCandidates = candidateSets.get(labelIndex);
                double[] currentCosts = new double[currentCandidates.size()];
                Arrays.fill(currentCosts, Double.MAX_VALUE);
                int[] back = new int[currentCandidates.size()];
                Arrays.fill(back, -1);
                for (int currentIndex = 0; currentIndex < currentCandidates.size(); currentIndex++) {
                    LineLabelCandidate current = currentCandidates.get(currentIndex);
                    double currentCost = lineLabelCost(current, labels.get(labelIndex),
                            labels, placedLabels);
                    for (int previousIndex = 0; previousIndex < previousCandidates.size(); previousIndex++) {
                        if (previousCosts[previousIndex] == Double.MAX_VALUE) continue;
                        LineLabelCandidate previous = previousCandidates.get(previousIndex);
                        if (previous.getRectangle().getCenterY()
                                > current.getRectangle().getCenterY() + 0.1) {
                            continue;
                        }
                        double overlapPenalty = overlapArea(previous.getRectangle(),
                                Collections.singletonList(current.getRectangle())) * 1000000.0;
                        double cost = previousCosts[previousIndex] + currentCost + overlapPenalty;
                        if (cost < currentCosts[currentIndex]) {
                            currentCosts[currentIndex] = cost;
                            back[currentIndex] = previousIndex;
                        }
                    }
                }
                previousCosts = currentCosts;
                backPointers.add(back);
            }
            int bestLastIndex = -1;
            double bestCost = Double.MAX_VALUE;
            for (int i = 0; i < previousCosts.length; i++) {
                if (previousCosts[i] < bestCost) {
                    bestCost = previousCosts[i];
                    bestLastIndex = i;
                }
            }
            if (bestLastIndex < 0) continue;
            int[] chosenIndexes = new int[candidateSets.size()];
            chosenIndexes[chosenIndexes.length - 1] = bestLastIndex;
            for (int labelIndex = chosenIndexes.length - 2; labelIndex >= 0; labelIndex--) {
                int nextIndex = chosenIndexes[labelIndex + 1];
                chosenIndexes[labelIndex] = backPointers.get(labelIndex)[nextIndex];
            }
            for (int labelIndex = 0; labelIndex < chosenIndexes.length; labelIndex++) {
                DayLabel label = labels.get(labelIndex);
                Rectangle2D chosen = candidateSets.get(labelIndex)
                        .get(chosenIndexes[labelIndex]).getRectangle();
                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();
                placedLabels.add(new Rectangle2D.Double(chosen.getX() - 4.0, chosen.getY() - 4.0,
                        chosen.getWidth() + 8.0, chosen.getHeight() + 8.0));
            }
        }
    }
    private static class LineLabelCandidate {
        private final Rectangle2D rectangle;
        private LineLabelCandidate(Rectangle2D rectangle) {
            this.rectangle = rectangle;
        }
        private Rectangle2D getRectangle() { return rectangle; }
    }
    private static class DayLabel {
@@ -937,10 +990,99 @@
        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 List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
                                                         Rectangle2D dataArea) {
        List<LineLabelCandidate> result = new ArrayList<>();
        int width = metrics.stringWidth(label.getText());
        int height = metrics.getHeight();
        double x = label.getPoint().getX();
        double y = label.getPoint().getY();
        double horizontalOffset = ITEM_LABEL_GAP + width / 2.0;
        double farHorizontalOffset = horizontalOffset + 18.0;
        double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
        double farVerticalOffset = verticalOffset + height + 6.0;
        addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, farHorizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, -farHorizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, farVerticalOffset, dataArea);
        return result;
    }
    private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
                                   int width, int height, double dx, double dy,
                                   Rectangle2D dataArea) {
        Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
                x + dx - width / 2.0, y + dy - height / 2.0, width, height), dataArea);
        if (candidate == null) return;
        for (LineLabelCandidate existing : result) {
            if (Math.abs(existing.getRectangle().getX() - candidate.getX()) < 0.5
                    && Math.abs(existing.getRectangle().getY() - candidate.getY()) < 0.5) {
                return;
            }
        }
        result.add(new LineLabelCandidate(candidate));
    }
    private double lineLabelCost(LineLabelCandidate candidate, DayLabel label,
                                List<DayLabel> sameDayLabels,
                                List<Rectangle2D> placedLabels) {
        Rectangle2D rectangle = candidate.getRectangle();
        double dx = rectangle.getCenterX() - label.getPoint().getX();
        double dy = rectangle.getCenterY() - label.getPoint().getY();
        double ownDistance = Math.sqrt(dx * dx + dy * dy);
        double cost = ownDistance * 10.0 + overlapArea(rectangle, placedLabels) * 10000.0;
        Rectangle2D ownMarker = new Rectangle2D.Double(label.getPoint().getX() - 9.0,
                label.getPoint().getY() - 9.0, 18.0, 18.0);
        if (rectangle.intersects(ownMarker)) cost += 3000.0;
        for (DayLabel other : sameDayLabels) {
            if (other == label) continue;
            double otherDx = rectangle.getCenterX() - other.getPoint().getX();
            double otherDy = rectangle.getCenterY() - other.getPoint().getY();
            double otherDistance = Math.sqrt(otherDx * otherDx + otherDy * otherDy);
            if (otherDistance < ownDistance - 1.0) {
                cost += (ownDistance - otherDistance) * 5000.0;
            }
            Rectangle2D otherMarker = new Rectangle2D.Double(other.getPoint().getX() - 9.0,
                    other.getPoint().getY() - 9.0, 18.0, 18.0);
            if (rectangle.intersects(otherMarker)) cost += 2000.0;
        }
        return cost;
    }
    private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) {
@@ -979,14 +1121,18 @@
                                   FontMetrics metrics, Color color) {
        int x = (int) Math.round(rectangle.getX());
        int y = (int) Math.round(rectangle.getY() + metrics.getAscent());
        // A stroked white outline keeps the digit legible even when a line runs
        // underneath it, which lets labels stay right next to their own point.
        java.awt.font.TextLayout layout = new java.awt.font.TextLayout(
                text, graphics.getFont(), graphics.getFontRenderContext());
        java.awt.Shape outline = layout.getOutline(
                java.awt.geom.AffineTransform.getTranslateInstance(x, y));
        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.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        graphics.draw(outline);
        graphics.setColor(color);
        graphics.drawString(text, x, y);
        graphics.fill(outline);
    }
    public static final List<String> DAY_LABELS = Arrays.asList(
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
@@ -35,7 +35,7 @@
            .authorizeRequests()
            // ç™»å½•前必须可用的接口
            .antMatchers("/api/auth/login", "/api/auth/captcha", "/api/auth/captcha/verify",
                         "/api/auth/captcha-config").permitAll()
                         "/api/auth/captcha-config", "/api/version").permitAll()
            // å‰ç«¯é™æ€èµ„源
            .antMatchers("/", "/index.html", "/favicon.ico", "/js/**", "/css/**", "/fonts/**", "/img/**", "/static/**").permitAll()
            .anyRequest().permitAll()
@@ -44,4 +44,4 @@
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
@@ -27,7 +27,8 @@
            "/api/auth/login",
            "/api/auth/captcha",
            "/api/auth/captcha/verify",
            "/api/auth/captcha-config"
            "/api/auth/captcha-config",
            "/api/version"
    );
    @Resource
@@ -61,4 +62,4 @@
        request.setAttribute("loginUser", username);
        chain.doFilter(request, response);
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java
New file
@@ -0,0 +1,83 @@
package com.trafficaudit.system.controller;
import com.trafficaudit.common.Result;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
/**
 * Exposes the product version so the UI (and operators) can confirm which build
 * is actually running on a server. Kept public on purpose: the login page shows
 * the version before any token exists.
 */
@RestController
@RequestMapping("/api")
public class VersionController {
    private static final String VERSION_FILE = "app-version.properties";
    @GetMapping("/version")
    public Result<Map<String, Object>> version() {
        Map<String, Object> info = new LinkedHashMap<>();
        info.put("version", readVersion());
        info.put("buildTime", resolveBuildTime());
        return Result.ok(info);
    }
    private String readVersion() {
        try (InputStream in = new ClassPathResource(VERSION_FILE).getInputStream()) {
            Properties props = new Properties();
            props.load(in);
            String value = props.getProperty("version");
            return value == null || value.trim().isEmpty() ? "unknown" : value.trim();
        } catch (Exception e) {
            return "unknown";
        }
    }
    /**
     * Build time of the running artifact. In a packaged run this is the jar file's
     * last-modified time; when running from exploded classes (mvn spring-boot:run)
     * there is no jar, so the value is null.
     */
    private String resolveBuildTime() {
        try {
            URI location = VersionController.class.getProtectionDomain()
                    .getCodeSource().getLocation().toURI();
            Path path = resolveArtifactPath(location);
            if (path != null && Files.isRegularFile(path)) {
                return Files.getLastModifiedTime(path).toInstant().toString();
            }
        } catch (Exception ignored) {
            // Fall through: build time is optional information.
        }
        return null;
    }
    /**
     * Spring Boot executable jars expose a nested classpath such as
     * "jar:file:/app.jar!/BOOT-INF/classes!/". Strip the nested part before
     * asking the filesystem for the jar timestamp.
     */
    private Path resolveArtifactPath(URI location) {
        String value = location.toString();
        int nested = value.indexOf("!/");
        if (nested >= 0) {
            value = value.substring(0, nested);
        }
        if (value.startsWith("jar:")) {
            value = value.substring(4);
        }
        return Paths.get(URI.create(value));
    }
}
traffic-audit-server/src/main/resources/app-version.properties
New file
@@ -0,0 +1,3 @@
# Supplied from pom.xml during Maven resource filtering.
# Bump <version> in pom.xml on every release; do not hard-code a version here.
version=@project.version@
traffic-audit-web/src/components/AppHeader.vue
@@ -1,6 +1,9 @@
<template>
  <el-header class="app-header">
    <span class="header-title">{{ title }}</span>
    <span class="header-title">
      {{ systemName }}<span class="header-version" v-if="version">V{{ version }}</span>
      <template v-if="pageName"> - {{ pageName }}</template>
    </span>
    <div class="header-right">
      <span class="header-user">
        <i class="el-icon-user"></i> {{ username }}
@@ -43,6 +46,7 @@
  },
  data() {
    return {
      version: '',
      pwdDialogVisible: false,
      pwdSaving: false,
      pwdForm: { oldPassword: '', newPassword: '', confirmPassword: '' },
@@ -65,7 +69,22 @@
  computed: {
    username() {
      return sessionStorage.getItem('username') || 'admin'
    },
    // The system name always comes first so the version can sit right after it.
    systemName() {
      return this.title.split(' - ')[0]
    },
    pageName() {
      const parts = this.title.split(' - ')
      return parts.length > 1 ? parts.slice(1).join(' - ') : ''
    }
  },
  created() {
    api.get('/version')
      .then(res => {
        if (res && res.data && res.data.version) this.version = res.data.version
      })
      .catch(() => {})
  },
  methods: {
    openChangePwd() {
@@ -134,6 +153,14 @@
  line-height: normal;
}
.header-user { opacity: 0.92; }
.header-version {
  font-size: 12px;
  line-height: 1.6;
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.18);
}
.header-link { color: #fff; font-size: 14px; padding: 0; }
.header-link:hover { color: #ffd04b; }
.header-logout { color: #fff; font-size: 14px; padding: 0; }
traffic-audit-web/src/views/Login.vue
@@ -1,7 +1,7 @@
<template>
  <div class="login-container">
    <el-card class="login-card">
      <h2>交通统计报表审核系统</h2>
      <h2>交通统计报表审核系统<span class="login-version" v-if="version">V{{ version }}</span></h2>
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item prop="username">
          <el-input v-model="form.username" placeholder="用户名" @keyup.enter.native="login"></el-input>
@@ -40,10 +40,17 @@
      captchaId: '',
      clickIds: [],
      loggingIn: false,
      captchaRequired: false
      captchaRequired: false,
      version: ''
    }
  },
  created() {
    axios.get('/api/version', { timeout: 10000 })
      .then(res => {
        const d = res.data
        if (d && d.data && d.data.version) this.version = d.data.version
      })
      .catch(() => {})
    // éªŒè¯ç æ˜¯å¦å¯ç”¨ä¸€å¾‹ä»¥åŽç«¯ /api/auth/captcha-config ä¸ºå‡†ï¼š
    // é»˜è®¤ä¸æ˜¾ç¤ºï¼Œåªæœ‰åŽç«¯æ˜Žç¡®è¿”回 required=true æ‰æ¸²æŸ“验证码组件。
    // ä¿®å¤ï¼šæ­¤å‰é»˜è®¤ true ä¸”请求失败时静默忽略,切换网络后该配置读不到,
@@ -114,5 +121,6 @@
.captcha-skip-tip { font-size: 12px; color: #909399; line-height: 1.6; padding: 8px 10px; background: #F5F7FA; border-radius: 4px; }
.login-card { width: 400px; }
.login-card h2 { text-align: center; margin-bottom: 18px; }
.login-version { font-size: 13px; font-weight: normal; color: #909399; margin-left: 8px; vertical-align: 3px; }
.login-card .el-form-item { margin-bottom: 16px; }
</style>