664cae654867b528ea339f187ebe290cb04d6fcf..37496dcec47bc98dc545d94287f39b33eb181bca
4 天以前 xyc
docs: 关机交接——菜单布局修复(2bd663b)与实测结论、/import 主线程卡死新发现、sessionStorage 强制登录待办
37496d 对比 | 目录
4 天以前 xyc
fix: 菜单/布局统一——企业解释重复 components 键导致 app-header 未注册;规则管理/用户管理改为统一上下布局(顶部系统栏+...
2bd663 对比 | 目录
4 天以前 xyc
docs: 超时保护完成记录 + 用户反驳线程池假设(3用户场景),根因改为待 access log 取证
a26d11 对比 | 目录
4 天以前 xyc
feat: 所有前端 API 请求加超时保护,超时/网络异常优雅提示,不再无限 pending
41487b 对比 | 目录
4 天以前 xyc
docs: 崩溃根因实锤(Tomcat 线程池被导入占满→batches pending)+ 修复记录
5ee822 对比 | 目录
4 天以前 xyc
fix: 数据导入页进入时 batches 请求合并为 1 个 + Tomcat 线程池 500,缓解导入占满线程导致接口 pending(浏览器无响应)
728b37 对比 | 目录
4 天以前 xyc
docs: 崩溃排查进展——请求未达后端(卡静态资源层)、access log 已配置、本地压测无异常
bfa183 对比 | 目录
4 天以前 xyc
feat: 开启 Tomcat 访问日志(basedir 固定 jar 同级,记录静态资源与 API 请求耗时),便于定位客户端崩溃卡点
306399 对比 | 目录
4 天以前 xyc
chore: gitignore 忽略打包静态目录 web/
3c3f04 对比 | 目录
4 天以前 xyc
docs: 交接更新——验证码跳过开关、目录导入加固、崩溃排查进展
e4a550 对比 | 目录
4 天以前 xyc
fix: 数据导入目录浏览/批量导入数量上限,防浏览器渲染与请求爆炸
72a8ea 对比 | 目录
4 天以前 xyc
feat: 登录行为验证码支持配置跳过(app.captcha.required,测试阶段关闭,正式发布开启)
a36de2 对比 | 目录
4 天以前 xyc
docs: 复核数据导入崩溃——batches 接口数据极小非根因,指向客户端环境
cf57c2 对比 | 目录
4 天以前 xyc
docs: 记录新需求——经济指标试算功能(货运是大头,领导常要求压数据)
5a3afe 对比 | 目录
16个文件已修改
275 ■■■■ 已修改文件
.gitignore 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/application.yml.example 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/api/index.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AiChatDialog.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/Captcha3D.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 41 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Login.vue 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/RuleManage.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/UserManage.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -64,4 +64,6 @@
verify_city*.txt
deploy-test/
# ==== packaged static dir (copied from dist, not committed) ====
web/
traffic-audit-server/web/
HANDOFF.md
@@ -3,6 +3,54 @@
> **用法**:新对话开始后,先读本文件 + 项目根目录 `AGENTS.md`,即可无缝接力。
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
## 今日(2026-08-28)
### 1. 登录验证码:测试阶段可跳过(已完成并验证)
- 后端新增 `app.captcha.required` 开关(缺省 `true`=启用);本机 application.yml 与 example 现为 `false`(测试阶段跳过,**正式发布前改回 `true`**)。
- LoginController:`captchaRequired=false` 时登录不校验验证码;新增公开接口 `GET /api/auth/captcha-config` 返回 `{required}`。
- Login.vue:启动时拉取该配置;`required=false` 时隐藏验证码区域并显示「测试模式:行为验证码已跳过」提示,登录跳过验证;配置请求失败时默认按启用处理(安全兜底)。
- 已实测:captcha-config 返回 required=false;admin/admin123 不带验证码直接登录 200;后端 mvn compile ✅、前端 npm run build ✅。
- ⚠️ 需重新打包才在测试环境生效;正式发布时把 `app.captcha.required` 改回 `true`。
### 2. 数据导入浏览器崩溃排查(进行中,用户线索更新)
- 用户补充:共 3 台机器出现(对方开发机 + 昨天打包测试 1 台 + 今天 1 台);对方记录的「迅雷/驱动」是其主观判断;现象时好时坏、每台约 1/3 用户崩。
- 已排除:/import/batches(limit≤200、81 条约 17KB);进入页面无轮询/定时器。
- 用户怀疑「目录导入(浏览/扫描目录)」→ 已加固(见下)。注意:该功能需用户主动打开目录浏览/点目录导入才触发,与「点数据导入菜单就崩」现象不完全吻合,**待用户确认崩溃时点的具体按钮**。
- 加固(已完成,编译/构建通过):后端 `listDirs` 子目录/文件列表各限 300(新增 `dirsTruncated`/`filesTruncated` 标记,`excelCount` 仍为真实总数);前端「从目录批量导入」每批限 60 个文件(超限提示分批);目录浏览弹窗显示截断提示。
- 待办:让崩溃机器用户在 Chrome 开 `chrome://crashes` 记下原因码(OOM / GPU / STATUS_ACCESS_VIOLATION 等),并确认崩溃时点的是侧边栏「数据导入」菜单还是页面上某个按钮;据此决定下一步。
- **重大进展(08-28 下午)**:
  - ~~请求根本没到后端~~(旧结论,已被推翻):用户后来确认 **chunk JS 4ms/7ms 正常,pending 的是 `/api/import/batches` 接口**(页面已进入,loadBatches+loadOverview 两个 batches 请求全部 pending);后端无日志=请求卡在 Tomcat 连接/线程层、未执行到 SQL。
  - 用户描述补充:点侧边栏「数据导入」菜单 → 无反应、停留主页 → 浏览器弹「应用程序无反应,是等待还是退出」= 渲染/浏览器进程 hang,非 crash。
  - 发布机器才是用户运行环境;本机 D:\trafficAudit 仅打包测试(17:39 后停止,日志正常)。
  - **本地打包形态压测(headless Chrome,20 轮菜单切换)**:导航 17~89ms、0 JS 错误、DOM 恒 949 节点无累积、堆内存 11~37MB 无泄漏、最长长任务 80ms → 应用 JS 层无死循环/大计算/泄漏。
  - **已加 Tomcat 访问日志**(`server.tomcat.basedir: .` + accesslog,jar 同级 `logs/access.*.log`,pattern 含 `%D` 耗时,静态资源也记录)——本地实测 chunk 请求 3ms。**重新打包发布后**,下次崩溃看 access log:chunk 请求是否到达、耗时多少 → 直接定位「服务器慢」还是「客户端没发出请求」。
  - 待用户:重新打包发布后,崩溃时看 ①服务器 `logs/access.*.log` ②客户端 F12 Network 中 `chunk-*.js` 请求状态(pending/stalled/耗时)。
  - **机制可复现(本机实验,非实锤)**:Tomcat 9 NIO 下慢连接不占线程(slowloris 无效,已测),但**慢速 multipart 上传会占线程**——240 个慢上传占满线程池后,chunk 与 batches 请求全部 pending/超时(与用户现象一致)。**数据导入是同步长任务**(4 万行导入占 1 个线程 5 分钟,前端超时 600s),多用户同时导入/目录导入串行 → 200 线程耗尽 → 新请求(batches、静态文件)排队 pending → 浏览器等 →「无响应」弹窗。**只崩数据导入页**因为它进页即发 2 个 batches 请求且用户多在导入场景操作;其他页面在同样时刻也会 pending 只是未被注意。
  - **修复(commit 728b372,已验证)**:① `server.tomcat.threads.max: 500`(application.yml + example);② 数据导入页 `loadBatches`+`loadOverview` 合并为 `loadData`(进页/刷新/导入后只发 1 个 limit=200 请求,表格取前 50,概览复用同批数据)。**修复验证**:同样 240 慢上传压力下,batches 0.2s、chunk 0.1s 正常。
  - **遗留(建议下一步根治)**:导入异步化(@Async 独立线程池执行导入、前端轮询进度),彻底不占 Tomcat 请求线程;或 nginx 托管静态资源。对方 dev 机单用户也 pending,需看对方后端日志(可能 DB 连接/锁,或对方当时在跑导入),与本修复不冲突。
  - **用户反驳(08-28 晚)→ 线程池耗尽不是当前场景实锤根因**:崩溃时仅 3 个用户(1 人已登入、1 人未登入、1 人登入后即出问题),时间上不可能占满 200 线程。线程池 500 + batches 合并的修复仍有效(降低同类风险),但不要对用户宣称根因已实锤。真正待取证:重新打包发布后崩溃时看 ①服务器 `logs/access.*.log`(chunk/batches 是否到达、%D 耗时)②对方 dev 机(单用户也 pending 的复现机)后端日志(是否 DB 连接/锁/SQL 卡住)。
### 3. 前端 API 全局超时保护(已完成并推送 commit 41487b9)
- 背景:用户要求所有向后台的 API 请求都加超时保护,超时优雅提示(网络/服务器异常),不要无限 pending。
- 实现:`src/api/index.js` axios 实例默认 `timeout: 15000`;响应拦截器里 `ECONNABORTED` → 弹「请求超时:服务器繁忙或无响应,请稍后重试」、无 `response` → 弹「网络异常:无法连接服务器」,并给 error 打 `_toast` 标记,页面 catch 里 `if (e && e._toast) return` 避免重复弹;401 跳登录逻辑不变。
- 长耗时请求显式覆盖:文件上传 600s/300s(原有)、`/audit/execute` 120s、`/audit/review` `/audit/markAudited` `/explain/save` 60s、`/llm/chat` 120s、报表导出 120s、报表预览/就绪 60s、验证码接口 10s、登录 15s。
- 覆盖文件 11 个(api/index.js、AiChatDialog、Captcha3D、AuditResult、DataImport、DataView、ExplainReview、Login、ReportExport、RuleManage、UserManage),node24 构建通过,已提交推送。
### 4. 菜单/布局统一修复(commit 2bd663b,已推送)
- 用户反馈 4 个问题:①点「审核结果」菜单不高亮;②企业解释/用户管理顶部没有统一横栏;③某些页面退出登录不显示;④(新增)不点退出直接关标签页/浏览器,重开后跳过登录直接进主页,要求重开时强制登录。
- 已修复(②③):ExplainReview.vue 有重复 `components` 键(`{ AppHeader }` 被后面的 `{ AiChatDialog }` 覆盖)→ app-header 未注册、企业解释页顶部横栏丢失;已合并为 `{ AppHeader, AiChatDialog }`。RuleManage.vue / UserManage.vue 还是旧版布局(`<el-container>` 无 direction、`<el-header>` 无用户/登出、退出登录藏在菜单里且 RuleManage 根本没有 logout 方法)→ 统一改为 `<app-header>` 上下布局、删菜单内退出项,与其余 6 页一致。
- 已实测(puppeteer headless,admin/admin123 登录后逐路由访问):/explain /rules /users 均有 app-header + 退出按钮;/audit 直接访问时菜单高亮「审核结果」正常 → 问题① 不在 AuditResult 自身。
- **新发现(重要)**:/import(数据导入)页面挂载后 **JS 主线程卡死**(puppeteer evaluate 3s 超时,goto 已到 #/import 但页面脚本长时间不响应)——怀疑这就是「点审核结果不高亮」及此前「数据导入菜单一点就浏览器无响应」的根因方向(页面 JS 阻塞 → 点击事件/高亮更新被卡住)。下一步:profiling /import 页 loadData(batches 响应后 overview 构造 / 200 行表格渲染)找主线程长任务。
- 问题④ 未做(下次优先):把认证信息(token/username/realName/modules)从 localStorage 改存 **sessionStorage**(关标签页即清空 → 强制登录),涉及 Login.vue、router/index.js、api/index.js、main.js、AppHeader.vue、ReportExport.vue;DataImport 的 `dirLast_*`、ReportExport 的 `report_note_*` 属偏好记忆保留 localStorage。注意:同标签页 F5 刷新保持登录、新开标签页需重登。
- 测试脚本:`D:\Codex\_tools\pptr-core\menu_test3.js`(点击遍历)、`menu_test4.js`(逐路由访问,推荐)、`login_debug.js`;node24 运行(`C:\Users\jcxiong\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe`)。
### 5. 待办不变
- `docs/打包部署说明.docx` 第六节「外置 application.yml 需包含完整配置」改为「属性级合并」后重新导出 docx(未做)。
- 崩溃根因确认后:修正 commit `cf57c2b` 中「指向客户端环境」的旧结论。
- /import 页 JS 主线程卡死待调查(相信为问题①高亮及浏览器无响应的根因)。
- 认证信息 localStorage → sessionStorage(强制登录),见上面第 4 节。
## 〇、关机交接(2026-08-27 深夜,用户关机前)
- **当前运行状态**:前后端应用服务全部已停止(开发服务、打包版均未运行);仅数据库在跑
  (MySQL80 服务=3305,本项目用;MySQL57=3306 勿动);8080 上 IIS 站点仍在但本项目已不再依赖 IIS。
@@ -87,6 +135,8 @@
- 后续扩展:H203-1 公路旅客、W203 水路、P203 港口、H204 能源等(需求文档 v2/v3 计划),先把 H203-2 演示做好。
- 【新需求·仅记录未实现·2026-08-23】登录后首页新增「全省交通运输态势感知图」:把货运、客运、巡游出租、网约车、投资、能耗等方面的变化态势可视化展示。
- 【新需求·仅记录未实现·2026-08-23】扩展现有 AI 分析为「AI 助手」:以本地数据库数据为基础建立知识库,做 RAG,支持自然语言提问/聊天,灵活查看与挖掘数据。
- **2026-08-28 复核「点数据导入浏览器崩溃」(打包测试环境,部分用户)**:重点排查 `GET /api/import/batches`——后端 limit 上限 200;全表 81 条、error_detail 最长 2.6KB、全量响应约 17KB(前端只取 50 条);前端表格仅 8 列短字段(errorDetail 不渲染)、无轮询/定时器。**结论:接口不是崩溃根因**(若接口有问题,所有用户都会崩,而非"部分用户")。现象符合客户端环境差异(第三方扩展注入/旧显卡驱动/低配内存,与 08-27 迅雷扩展+Intel 旧驱动结论一致)。建议对崩溃用户依次验证:无痕模式→换 Edge→禁扩展,并看 chrome://crashes。
- 【新需求·仅记录未实现·2026-08-28】增加**经济指标试算功能**:领导非常关注经济指标,**货运是大头**;常驻现场的所有人都要会计算货运相关数据(货运量/周转量等)。领导经常要求把某个数据「压一压」,今后考虑在软件里增加试算功能——可按需要调整/测算指标数值(如模拟下调某企业/某口径的货运量后看汇总与趋势影响),辅助现场人员快速试算与应对。
## 八、每日交接约定
1. 新对话第一步:读 `AGENTS.md` + `HANDOFF.md`;涉及 AI 分析/审核时参考 `docs/` 与数据库表结构。
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java
@@ -2369,10 +2369,13 @@
        result.put("parent", cur.getParent() == null ? "" : cur.getParent());
        List<Map<String, Object>> dirs = new ArrayList<>();
        File[] subs = cur.listFiles(File::isDirectory);
        int dirTotal = 0;
        if (subs != null) {
            Arrays.sort(subs, Comparator.comparing(File::getName));
            for (File d : subs) {
                if (d.isHidden()) continue;
                dirTotal++;
                if (dirs.size() >= 300) continue;
                Map<String, Object> m = new LinkedHashMap<>();
                m.put("name", d.getName());
                m.put("path", d.getAbsolutePath());
@@ -2380,17 +2383,21 @@
            }
        }
        result.put("dirs", dirs);
        result.put("dirsTruncated", dirTotal > dirs.size());
        List<String> excelFiles = new ArrayList<>();
        File[] files = cur.listFiles((d, n) -> {
            String lower = n.toLowerCase();
            return lower.endsWith(".xlsx") || lower.endsWith(".xls");
        });
        int excelTotal = files == null ? 0 : files.length;
        if (files != null) {
            Arrays.sort(files, Comparator.comparing(File::getName));
            for (File f : files) excelFiles.add(f.getName());
            int take = Math.min(files.length, 300);
            for (int i = 0; i < take; i++) excelFiles.add(files[i].getName());
        }
        result.put("excelCount", excelFiles.size());
        result.put("excelCount", excelTotal);
        result.put("files", excelFiles);
        result.put("filesTruncated", excelTotal > excelFiles.size());
        return result;
    }
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
@@ -9,6 +9,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.HashMap;
@@ -31,6 +32,9 @@
    @Resource
    private PasswordEncoder passwordEncoder;
    @Value("${app.captcha.required:true}")
    private boolean captchaRequired;
    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody Map<String, Object> params) {
        String username = (String) params.get("username");
@@ -38,15 +42,17 @@
        String captchaId = (String) params.get("captchaId");
        List<String> clickIds = strList(params.get("captchaClickIds"));
        CaptchaService.ConsumeResult cr = captchaService.consume(captchaId, clickIds);
        if (cr == CaptchaService.ConsumeResult.NOT_FOUND) {
            return Result.error(4002, "验证码不存在或已过期,请刷新重试");
        }
        if (cr == CaptchaService.ConsumeResult.NOT_VERIFIED) {
            return Result.error(4003, "请先完成行为验证");
        }
        if (cr == CaptchaService.ConsumeResult.WRONG) {
            return Result.error(4001, "行为验证未通过,请重新验证");
        if (captchaRequired) {
            CaptchaService.ConsumeResult cr = captchaService.consume(captchaId, clickIds);
            if (cr == CaptchaService.ConsumeResult.NOT_FOUND) {
                return Result.error(4002, "验证码不存在或已过期,请刷新重试");
            }
            if (cr == CaptchaService.ConsumeResult.NOT_VERIFIED) {
                return Result.error(4003, "请先完成行为验证");
            }
            if (cr == CaptchaService.ConsumeResult.WRONG) {
                return Result.error(4001, "行为验证未通过,请重新验证");
            }
        }
        if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) {
@@ -77,6 +83,13 @@
        return Result.ok(data);
    }
    @GetMapping("/captcha-config")
    public Result<Map<String, Object>> captchaConfig() {
        Map<String, Object> data = new HashMap<>();
        data.put("required", captchaRequired);
        return Result.ok(data);
    }
    @SuppressWarnings("unchecked")
    private List<String> strList(Object o) {
        if (o instanceof List) {
traffic-audit-server/src/main/resources/application.yml.example
@@ -1,6 +1,16 @@
# 复制为 application.yml 并替换占位符后使用(真实密钥不入库)
server:
  port: 8090
  tomcat:
    basedir: .
    threads:
      max: 500
    accesslog:
      enabled: true
      directory: logs
      prefix: access
      suffix: .log
      pattern: '%h %l %u %t "%r" %s %b %D'
spring:
  datasource:
@@ -55,3 +65,8 @@
  api-key: ${DEEPSEEK_API_KEY:your-deepseek-api-key}
  api-url: https://api.deepseek.com/v1/chat/completions
  model: deepseek-chat
# Behavior captcha on login. Set to false to skip verification during test phase; enable before production release.
app:
  captcha:
    required: false
traffic-audit-web/src/api/index.js
@@ -1,8 +1,9 @@
import axios from 'axios'
import { Message } from 'element-ui'
const api = axios.create({
  baseURL: '/api',
  timeout: 30000
  timeout: 15000
})
api.interceptors.request.use(config => {
@@ -17,6 +18,14 @@
    if (error.response && error.response.status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
      return Promise.reject(error)
    }
    if (error.code === 'ECONNABORTED') {
      error._toast = true
      Message.error('请求超时:服务器繁忙或无响应,请稍后重试')
    } else if (!error.response) {
      error._toast = true
      Message.error('网络异常:无法连接服务器,请检查网络后重试')
    }
    return Promise.reject(error)
  }
traffic-audit-web/src/components/AiChatDialog.vue
@@ -113,7 +113,7 @@
      api.post('/llm/chat', {
        messages: [{ role: 'user', content: '请用结构化 Markdown(小节标题、加粗、列表)分析该企业的解释是否合理,先给结论,再列数据依据,最后给出处置建议。' }],
        context: this.context
      })
      }, { timeout: 120000 })
        .then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
        .catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
        .finally(() => { this.$nextTick(() => this.scrollToBottom()) })
@@ -147,7 +147,7 @@
      this.userScrolledUp = false
      this.chatLoading = true
      this.$nextTick(() => this.scrollToBottom())
      api.post('/llm/chat', { messages: this.chatMessages, context: this.context })
      api.post('/llm/chat', { messages: this.chatMessages, context: this.context }, { timeout: 120000 })
        .then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
        .catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
        .finally(() => { this.$nextTick(() => this.scrollToBottom()) })
traffic-audit-web/src/components/Captcha3D.vue
@@ -119,7 +119,7 @@
      this.selectedIds = []
      this.errorMsg = ''
      this.$emit('reset')
      axios.get('/api/auth/captcha')
      axios.get('/api/auth/captcha', { timeout: 10000 })
        .then(res => {
          this.loading = false
          if (res.data.code === 200) {
@@ -176,7 +176,7 @@
      axios.post('/api/auth/captcha/verify', {
        captchaId: this.challenge.captchaId,
        captchaClickIds: this.selectedIds.slice()
      })
      }, { timeout: 10000 })
        .then(res => {
          this.loading = false
          const d = res.data
traffic-audit-web/src/views/AuditResult.vue
@@ -336,7 +336,7 @@
      const fd = new FormData()
      fd.append('period', this.period)
      fd.append('reportType', this.reportType)
      api.post('/audit/execute', fd)
      api.post('/audit/execute', fd, { timeout: 120000 })
        .then(res => {
          const count = (res.data || []).length
          return api.get('/audit/ruleStats', { params: { period: this.period, reportType: this.reportType } })
@@ -361,7 +361,7 @@
        .then(() => api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } }))
        .then(res => { this.applyResults(res.data) })
        .then(() => this.loadRuleStats())
        .catch(() => { this.$message.error('审核执行失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('审核执行失败') })
        .finally(() => { this.loading = false })
    },
    markAudited() {
@@ -370,7 +370,7 @@
      const fd = new FormData()
      fd.append('period', this.period)
      fd.append('reportType', this.reportType)
      api.post('/audit/markAudited', fd)
      api.post('/audit/markAudited', fd, { timeout: 60000 })
        .then(res => {
          if (res.data) {
            this.$message.success('已标记该期「' + this.reportType + '」为已审核,报表导出不再提示未审核')
@@ -380,7 +380,7 @@
          return api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } })
        })
        .then(res => { this.applyResults(res.data) })
        .catch(() => { this.$message.error('标记失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('标记失败') })
        .finally(() => { this.marking = false })
    },
    queryResults() {
@@ -389,7 +389,7 @@
      api.get('/audit/results', { params: { period: this.period, reportType: this.reportType } })
        .then(res => { this.applyResults(res.data) })
        .then(() => this.loadRuleStats())
        .catch(() => { this.$message.error('查询失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('查询失败') })
        .finally(() => { this.loading = false })
    },
    toggleExplain(reportId) {
@@ -412,13 +412,13 @@
      fd.append('reportType', this.reportType)
      fd.append('reportId', reportId)
      fd.append('explanation', explanation)
      api.post('/explain/save', fd)
      api.post('/explain/save', fd, { timeout: 60000 })
        .then(() => {
          this.$message.success('企业解释已保存')
          this.$set(this.explainDrafts, reportId, explanation)
          this.results.forEach(r => { if (r.reportId === reportId) r.verifyExplanation = explanation })
        })
        .catch(e => { this.$message.error((e.response && e.response.data && e.response.data.message) || '保存失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error((e.response && e.response.data && e.response.data.message) || '保存失败') })
        .finally(() => { this.savingExplainId = null })
    },
    resetDraft(reportId) {
@@ -450,9 +450,9 @@
      fd.append('id', row.id)
      fd.append('status', status)
      if (comment) fd.append('comment', comment)
      api.post('/audit/review', fd)
      api.post('/audit/review', fd, { timeout: 60000 })
        .then(() => { row.status = status; this.$message.success(status === 'CONFIRMED' ? '已确认' : '已忽略') })
        .catch(() => { this.$message.error('操作失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('操作失败') })
    },
    updateBatchSelection() { this.batchSelection = this.filteredResults.filter(r => r._selected) },
    toggleSelectAll(value) {
@@ -478,9 +478,9 @@
        fd.append('id', item.id)
        fd.append('status', status)
        if (comment) fd.append('comment', comment)
        api.post('/audit/review', fd)
        api.post('/audit/review', fd, { timeout: 60000 })
          .then(() => { item.status = status; done++; if (done === list.length) this.$message.success('已' + (status === 'CONFIRMED' ? '确认' : '忽略') + ' ' + done + ' 条') })
          .catch(() => { this.$message.error('部分操作失败') })
          .catch(e => { if (e && e._toast) return; this.$message.error('部分操作失败') })
      })
    },
    clearFilters() { this.enterpriseFilter = ''; this.ruleFilter = '' },
traffic-audit-web/src/views/DataImport.vue
@@ -26,7 +26,7 @@
          <el-card shadow="hover" class="import-card" style="margin-bottom:15px">
            <div class="card-head">本月导入进度({{ period || '未选择报表期' }})
              <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadOverview">刷新</el-button>
              <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadData">刷新</el-button>
            </div>
            <div v-if="!period" class="tip">请先在上方选择报表期</div>
            <div v-else class="overview-grid">
@@ -158,6 +158,8 @@
              <div class="tip">单击文件夹进入子文件夹;<b>双击目标文件夹立即导入</b>其中全部 Excel;也可进入后点下方按钮。导入时会按文件名自动识别月份。</div>
              <div v-if="dirExcelCount === 0" class="tip">当前文件夹内没有 Excel 文件(.xlsx/.xls)</div>
              <div v-else class="tip">当前文件夹内 Excel:{{ dirExcelCount }} 个 —— {{ dirFiles.slice(0, 6).join('、') }}{{ dirFiles.length > 6 ? ' 等' : '' }}(仅导入直接位于该文件夹的文件)</div>
              <div v-if="dirDirsTruncated" class="tip">子文件夹过多,仅显示前 300 个</div>
              <div v-if="dirFilesTruncated" class="tip">Excel 文件过多(共 {{ dirExcelCount }} 个),仅显示前 300 个</div>
            </div>
            <span slot="footer">
              <el-button size="small" @click="dirDialog = false">取消</el-button>
@@ -168,7 +170,7 @@
          <el-card shadow="never" style="margin-top:15px" class="batch-card">
            <div slot="header" class="card-head">
              导入记录({{ period || '全部报表期' }})
              <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadBatches">刷新</el-button>
              <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadData">刷新</el-button>
            </div>
            <el-table :data="batches" border size="small" v-loading="batchLoading" style="width:100%">
              <el-table-column prop="importType" label="导入类型" width="150">
@@ -243,7 +245,9 @@
      dirCurrent: '',
      dirParent: '',
      dirDirs: [],
      dirDirsTruncated: false,
      dirExcelCount: 0,
      dirFilesTruncated: false,
      dirFiles: [],
      dirLoading: false,
      dirImporting: false,
@@ -390,7 +394,7 @@
      this.importType = this.moduleSel[key] || ''
      this.dirPath = (this.importType && localStorage.getItem('dirLast_' + this.importType)) || ''
    },
    onPeriodChange() { this.loadOverview(); this.loadBatches() },
    onPeriodChange() { this.loadData() },
    endpoint(t) {
      const map = {
        h2032: '/import/h2032',
@@ -428,10 +432,12 @@
          this.dirCurrent = d.current || ''
          this.dirParent = d.parent || ''
          this.dirDirs = d.dirs || []
          this.dirDirsTruncated = !!d.dirsTruncated
          this.dirExcelCount = d.excelCount || 0
          this.dirFiles = d.files || []
          this.dirFilesTruncated = !!d.filesTruncated
        })
        .catch(e => { this.$message.error((e.response && e.response.data && e.response.data.message) || '目录加载失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error((e.response && e.response.data && e.response.data.message) || '目录加载失败') })
        .finally(() => { this.dirLoading = false })
    },
    goDirParent() { if (this.dirParent) this.loadDirs(this.dirParent) },
@@ -458,6 +464,11 @@
          const d = (res && res.data) || {}
          const files = (d.files || []).filter(n => /\.(xlsx|xls)$/i.test(n))
          if (!files.length) { this.$message.warning('该目录没有 Excel 文件(.xlsx/.xls):' + dir); this.dirImporting = false; return }
          if (files.length > 60) {
            this.dirImporting = false
            this.$message.warning('目录内 Excel 文件过多(' + files.length + ' 个),为避免浏览器卡顿请分批导入(每批不超过 60 个)')
            return
          }
          localStorage.setItem('dirLast_' + type, dir)
          this.dirPath = dir
          this.dirImportFiles = files.map(n => ({ name: n, status: 'pending', period: '', msg: '' }))
@@ -494,6 +505,7 @@
        })
        .catch(e => {
          this.dirImporting = false
          if (e && e._toast) return
          this.$message.error((e.response && e.response.data && e.response.data.message) || '目录不存在或无法访问:' + dir)
        })
    },
@@ -574,6 +586,7 @@
          this.afterImport()
        })
        .catch(e => {
          if (e && e._toast) return
          this.$message.error((e.response && e.response.data && e.response.data.message) || '批量导入失败')
        })
        .finally(() => { this.cityDirLoading = false })
@@ -644,13 +657,17 @@
        run(0)
      })
    },
    afterImport() { this.loadOverview(); this.loadBatches() },
    loadOverview() {
      if (!this.period) return
      api.get('/import/batches', { params: { period: this.period, limit: 200 } })
    afterImport() { this.loadData() },
    loadData() {
      if (this.batchLoading) return
      this.batchLoading = true
      const params = { limit: 200 }
      if (this.period) params.period = this.period
      api.get('/import/batches', { params })
        .then(res => {
          const map = {}
          const records = (res.data && res.data.records) || []
          this.batches = records.slice(0, 50)
          const map = {}
          records.forEach(r => {
            const key = this.batchKeyToType(r.importType)
            if (!key) return
@@ -661,7 +678,8 @@
          })
          this.overview = map
        })
        .catch(() => {})
        .catch(e => { if (!e || !e._toast) this.$message.error('导入记录加载失败,请刷新重试') })
        .finally(() => { this.batchLoading = false })
    },
    batchKeyToType(k) {
      if (BATCH_TYPE_MAP[k]) return BATCH_TYPE_MAP[k]
@@ -713,8 +731,7 @@
    }
  },
  mounted() {
    this.loadBatches()
    this.loadOverview()
    this.loadData()
  }
}
</script>
traffic-audit-web/src/views/DataView.vue
@@ -102,7 +102,8 @@
          this.page = 1
          this.loadData()
        }
      }).catch(() => {
      }).catch(e => {
        if (e && e._toast) return
        this.$message.error('数据查询失败')
      }).finally(() => {
        this.loading = false
traffic-audit-web/src/views/ExplainReview.vue
@@ -129,9 +129,8 @@
  CITY_TAXI: 'city_taxi_monthly'
}
export default {
  components: { AppHeader },
  components: { AppHeader, AiChatDialog },
  name: 'ExplainReview',
  components: { AiChatDialog },
  data() {
    return {
      reportType: 'H2032',
@@ -166,7 +165,8 @@
          this.page = 1
          this.loadData()
        }
      }).catch(() => {
      }).catch(e => {
        if (e && e._toast) return
        this.$message.error('解释查询失败')
      }).finally(() => {
        this.loading = false
traffic-audit-web/src/views/Login.vue
@@ -9,8 +9,11 @@
        <el-form-item prop="password">
          <el-input v-model="form.password" type="password" placeholder="密码" @keyup.enter.native="login"></el-input>
        </el-form-item>
        <el-form-item>
        <el-form-item v-if="captchaRequired">
          <captcha3-d ref="captcha" @verified="onCaptchaVerified" @reset="onCaptchaReset"></captcha3-d>
        </el-form-item>
        <el-form-item v-else>
          <div class="captcha-skip-tip">测试模式:行为验证码已跳过,可直接点击登录(正式发布后将启用)</div>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :loading="loggingIn" @click="login" style="width:100%">登 录</el-button>
@@ -36,8 +39,17 @@
      captchaVerified: false,
      captchaId: '',
      clickIds: [],
      loggingIn: false
      loggingIn: false,
      captchaRequired: true
    }
  },
  created() {
    axios.get('/api/auth/captcha-config', { timeout: 10000 })
      .then(res => {
        const d = res.data
        if (d && d.code === 200) this.captchaRequired = d.data.required !== false
      })
      .catch(() => {})
  },
  methods: {
    onCaptchaVerified(payload) {
@@ -53,7 +65,7 @@
    login() {
      this.$refs.form.validate(valid => {
        if (!valid) return
        if (!this.captchaVerified) {
        if (this.captchaRequired && !this.captchaVerified) {
          this.$message.warning('请先完成行为验证')
          return
        }
@@ -63,7 +75,7 @@
          password: this.form.password,
          captchaId: this.captchaId,
          captchaClickIds: this.clickIds
        }).then(res => {
        }, { timeout: 15000 }).then(res => {
          this.loggingIn = false
          const d = res.data
          if (d.code === 200) {
@@ -75,16 +87,17 @@
            this.$router.push('/dashboard')
          } else if (d.code === 401) {
            this.$message.error(d.message || '用户名或密码错误')
            this.$refs.captcha.load()
            if (this.$refs.captcha) this.$refs.captcha.load()
          } else if (d.code === 4001 || d.code === 4002 || d.code === 4003) {
            this.$message.error(d.message || '验证未通过,请重新验证')
            this.$refs.captcha.load()
            if (this.$refs.captcha) this.$refs.captcha.load()
          } else {
            this.$message.error(d.message || '登录失败,请重试')
            this.$refs.captcha.load()
            if (this.$refs.captcha) this.$refs.captcha.load()
          }
        }).catch(() => {
        }).catch(e => {
          this.loggingIn = false
          if (e && e.code === 'ECONNABORTED') { this.$message.error('登录请求超时,请重试'); return }
          this.$message.error('网络错误,登录失败')
        })
      })
@@ -94,6 +107,7 @@
</script>
<style scoped>
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px 0; box-sizing: border-box; background: #f0f2f5; }
.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-card .el-form-item { margin-bottom: 16px; }
traffic-audit-web/src/views/ReportExport.vue
@@ -304,7 +304,7 @@
      const m = this.curModule
      if (!m || !this.period) return
      const params = Object.assign(this.buildParams(), { types: m.items.map(i => i.key).join(',') })
      api.get('/report/ready', { params })
      api.get('/report/ready', { params, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          list.forEach(r => {
@@ -349,7 +349,7 @@
      if (!items.length) { this.$message.warning('请选择要预览的报表'); return }
      this.loading = 'preview-' + m.key
      const params = Object.assign(this.buildParams(), { types: items.map(i => i.key).join(',') })
      api.get('/report/preview', { params })
      api.get('/report/preview', { params, timeout: 60000 })
        .then(res => {
          const list = (res && res.data && res.data.items) || []
          this.previewItems = list.map(it => {
@@ -387,18 +387,18 @@
      const params = this.buildParams()
      const doExport = () => {
        if (items.length === 1) {
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob' })
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, items[0].fileName); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
          return
        }
        const zipParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') })
        api.get('/report/export/selected', { params: zipParams, responseType: 'blob' })
        api.get('/report/export/selected', { params: zipParams, responseType: 'blob', timeout: 120000 })
          .then(res => { this.downloadBlob(res, '生成_所选报表_' + this.period + '.zip'); this.$message.success('已导出 ' + items.length + ' 张报表(zip)'); this.loading = '' })
          .catch(err => this.failWith(err))
      }
      const readyParams = Object.assign({}, params, { types: items.map(i => i.key).join(',') })
      api.get('/report/ready', { params: readyParams })
      api.get('/report/ready', { params: readyParams, timeout: 60000 })
        .then(rd => {
          const list = (rd && rd.data && rd.data.items) || []
          const missing = list.filter(i => !i.ready)
@@ -420,6 +420,7 @@
        .catch(() => doExport())
    },
    failWith(err) {
      if (err && err._toast) { this.loading = ''; return }
      let msg = ''
      const d = err && err.response && err.response.data
      if (d && typeof d === 'object' && d.message) msg = d.message
traffic-audit-web/src/views/RuleManage.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 规则管理</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 规则管理"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/rules">
@@ -12,8 +12,7 @@
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item index="/login" @click="logout">退出登录</el-menu-item>
</el-menu>
        </el-menu>
      </el-aside>
      <el-main>
        <h3>审核规则管理</h3>
@@ -70,6 +69,7 @@
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
const TYPE_CN = {
  DIFF: '差值对比', RATIO: '比例对比', MOM: '环比新增', NULL_CHECK: '必填/一致性',
  OUT_RANGE: '范围检查', MOM_EQ_ZERO: '环比为0', MOM_PCT: '环比百分比',
@@ -81,6 +81,7 @@
  AVG_DIST_VS_H2032: '平均运距对比', LOADED_TON_RATIO: '载货吨位比值'
}
export default {
  components: { AppHeader },
  name: 'RuleManage',
  data() {
    return { reportType: 'H2032', rules: [], loading: false }
@@ -91,24 +92,21 @@
      this.loading = true
      api.get('/rules/list', { params: { reportType: this.reportType } })
        .then(res => { this.rules = res.data || [] })
        .catch(() => { this.$message.error('规则加载失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('规则加载失败') })
        .finally(() => { this.loading = false })
    },
    toggleRule(row) {
      api.post('/rules/toggle', null, { params: { id: row.id, enabled: row.isEnabled } })
        .then(() => { this.$message.success((row.isEnabled ? '已启用:' : '已停用:') + row.ruleCode) })
        .catch(() => { row.isEnabled = row.isEnabled ? 0 : 1; this.$message.error('操作失败') })
        .catch(e => { if (e && e._toast) return; row.isEnabled = row.isEnabled ? 0 : 1; this.$message.error('操作失败') })
    },
    saveThreshold(row) {
      api.post('/rules/update', {
        id: row.id, threshold: row.threshold, alertLevel: row.alertLevel
      }).then(() => { this.$message.success('已保存:' + row.ruleCode) })
        .catch(() => { this.$message.error('保存失败') })
        .catch(e => { if (e && e._toast) return; this.$message.error('保存失败') })
    }
  },
  mounted() { this.loadRules() }
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
traffic-audit-web/src/views/UserManage.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 用户管理</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 用户管理"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router :default-active="activeMenu">
@@ -12,7 +12,6 @@
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item index="/login" @click="logout">退出登录</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
@@ -89,6 +88,7 @@
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
const MODULE_OPTIONS = [
  { key: 'freight', label: '货运报表' },
  { key: 'passenger', label: '公路旅客报表' },
@@ -97,6 +97,7 @@
  { key: 'cityPassenger', label: '城市客运报表' }
]
export default {
  components: { AppHeader },
  name: 'UserManage',
  data() {
    return {
@@ -116,15 +117,11 @@
  },
  mounted() { this.loadList() },
  methods: {
    logout() {
      localStorage.removeItem('token'); localStorage.removeItem('username'); localStorage.removeItem('realName'); localStorage.removeItem('modules')
      this.$router.push('/login')
    },
    loadList() {
      this.loading = true
      api.get('/user/list', { params: { keyword: this.keyword } })
        .then(res => { this.list = res.data || [] })
        .catch(() => this.$message.error('用户列表加载失败'))
        .catch(e => { if (e && e._toast) return; this.$message.error('用户列表加载失败') })
        .finally(() => { this.loading = false })
    },
    moduleKeys(modules) {
@@ -164,6 +161,7 @@
            this.loadList()
          })
          .catch(err => {
            if (err && err._toast) return
            const d = err && err.response && err.response.data
            this.$message.error((d && d.message) || '保存失败')
          })