37496dcec47bc98dc545d94287f39b33eb181bca..8319e0c1ba52b6923d06dce02e568d9063bdb055
1 天以前 xyc
docs: 关机交接快照(08-31 下班前)——数据导入页重构交付(791e70f)、运行状态、待办更新
8319e0 对比 | 目录
1 天以前 xyc
feat: 数据导入页重构——选择数据类型/模板下载并排/失败明细表格/导入进度抽屉侧栏
791e70 对比 | 目录
1 天以前 xyc
docs: 关机交接快照(08-31)——今日修复/改密已交付、运行状态、待办(重新打包发布、验证码开关、崩溃取证)
94380f 对比 | 目录
1 天以前 xyc
fix: 菜单高亮间歇性失效根因(el-menu 同路由导航回滚 activeIndex)——default-active 绑定路由+select 后...
b051f0 对比 | 目录
1 天以前 xyc
docs: 交接约定——「提交」= commit + 推送远端 git
079d87 对比 | 目录
1 天以前 xyc
fix: 报表生成页 el-tabs 同样触发布局无限循环风险,改为 el-radio-group 切换(与 /import 修复一致),并清理 Da...
d42a04 对比 | 目录
1 天以前 xyc
docs: 崩溃根因实锤更新——el-tabs 布局无限循环(684da41),sessionStorage 强制登录完成(c62a61c),4 个菜...
f0597c 对比 | 目录
1 天以前 xyc
fix: 数据导入页 el-tabs 触发 Element UI 布局无限循环,挂载卡死导致浏览器无响应/菜单不高亮——改用 el-radio-gro...
684da4 对比 | 目录
1 天以前 xyc
feat: 关闭标签页/浏览器后强制重新登录——认证信息(token/username/realName/modules)改存 sessionStor...
c62a61 对比 | 目录
17个文件已修改
818 ■■■■ 已修改文件
HANDOFF.md 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/utils/JwtUtils.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/UserController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/api/index.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AppHeader.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/main.js 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/router/index.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Dashboard.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 529 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Login.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/RuleManage.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/UserManage.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -2,8 +2,9 @@
> **用法**:新对话开始后,先读本文件 + 项目根目录 `AGENTS.md`,即可无缝接力。
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
## 今日(2026-08-28)
## 今日(2026-08-31,含 08-28 起积累记录)
### 1. 登录验证码:测试阶段可跳过(已完成并验证)
- 后端新增 `app.captcha.required` 开关(缺省 `true`=启用);本机 application.yml 与 example 现为 `false`(测试阶段跳过,**正式发布前改回 `true`**)。
@@ -13,6 +14,7 @@
- ⚠️ 需重新打包才在测试环境生效;正式发布时把 `app.captcha.required` 改回 `true`。
### 2. 数据导入浏览器崩溃排查(进行中,用户线索更新)
- **【08-31 根因实锤更新】真正崩溃根因已找到:数据导入页 `<el-tabs>` 触发 Element UI 布局无限循环(详见下面第 4 节,commit 684da41)。此前「线程池占满/batches pending/慢上传」是另一条可复现机制,但不是用户场景的实锤根因;`/import/batches` pending 只是**表象**——el-tabs 布局风暴阻塞主线程后,页面后续请求(含 batches)全部发不出去/排队,表现为「batches pending + 浏览器无响应」。修复 el-tabs 后 dev 与打包形态均正常。
- 用户补充:共 3 台机器出现(对方开发机 + 昨天打包测试 1 台 + 今天 1 台);对方记录的「迅雷/驱动」是其主观判断;现象时好时坏、每台约 1/3 用户崩。
- 已排除:/import/batches(limit≤200、81 条约 17KB);进入页面无轮询/定时器。
- 用户怀疑「目录导入(浏览/扫描目录)」→ 已加固(见下)。注意:该功能需用户主动打开目录浏览/点目录导入才触发,与「点数据导入菜单就崩」现象不完全吻合,**待用户确认崩溃时点的具体按钮**。
@@ -36,7 +38,15 @@
- 长耗时请求显式覆盖:文件上传 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. 菜单/布局与强制登录全部解决(已推送)
- 用户反馈 4 个问题:①点「审核结果」菜单不高亮;②企业解释/用户管理顶部没有统一横栏;③某些页面退出登录不显示;④关标签页/浏览器后重开要求强制登录。四项均已完成并实测通过!
- ②③(commit 2bd663b):ExplainReview.vue 重复 `components` 键覆盖(`{ AppHeader }` 被 `{ AiChatDialog }` 覆盖)→ 企业解释页 app-header 未注册无横栏;已合并。RuleManage/UserManage 旧版布局(无 app-header、退出藏菜单且失效)→ 统一为 app-header 上下布局。
- ④(commit c62a61c):认证信息(token/username/realName/modules)从 localStorage 改存 **sessionStorage**,关标签页/浏览器后强制重新登录;同标签页 F5 刷新保持登录。偏好记忆(DataImport `dirLast_*`、ReportExport `report_note_*`)保留 localStorage。实测:新标签页直访 #/dashboard 被守卫重定向到 /login ✓;F5 后保持登录 ✓。
- **①根因实锤(commit 684da41,本日重大突破):数据导入页的 `<el-tabs>` 触发 Element UI 布局无限循环(Chrome Trace 实测:主线程每秒 1000+ 次 Layout/UpdateLayoutTree,无 JS FunctionCall,组件挂载未完成→ batches 请求不发出→ `batchLoading` 永 true → el-loading 动画永转)。这就是用户「点数据导入菜单就浏览器无响应」和「点审核结果不高亮」的真正根因(与此前线程池/上传压力无关!)。
- 修复方式:将 el-tabs 改为 `<el-radio-group>` + `<el-radio-button>` 模块切换 + `v-show` 面板(功能等价,回避 el-tabs 触发器)。验证:★ dev + **打包形态(dist + 静态代理 8091)** 均正常:/import 进入 8ms 发出 batches,无长任务;完整点击 8 个菜单高亮全部正常。
- 测试脚本:`D:\Codex\_tools\pptr-core\` 下 import_probe2.js / import_verify.js / menu_final.js / session_test.js / prod_import_test2.js / prod_server.js(打包静态+API 代理,用于本地验证 dist)。trace 分析脚本在 %TEMP%\analyze_trace*.py。
### 5. 待办不变
- 用户反馈 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 自身。
@@ -47,8 +57,8 @@
### 5. 待办不变
- `docs/打包部署说明.docx` 第六节「外置 application.yml 需包含完整配置」改为「属性级合并」后重新导出 docx(未做)。
- 崩溃根因确认后:修正 commit `cf57c2b` 中「指向客户端环境」的旧结论。
- /import 页 JS 主线程卡死待调查(相信为问题①高亮及浏览器无响应的根因)。
- 认证信息 localStorage → sessionStorage(强制登录),见上面第 4 节。
- ~~/import 页 JS 主线程卡死待调查~~ → 已实锤:el-tabs 布局循环,已修复(见上。
- ~~认证信息 localStorage → sessionStorage~~ → 已完成(commit c62a61c)。
## 〇、关机交接(2026-08-27 深夜,用户关机前)
@@ -81,6 +91,30 @@
  h204_*、investment_*、city_bus_monthly、city_taxi_monthly、city_taxi_auth、passenger_individual_monthly、audit_run;
  sys_user 已加 modules 列;admin 密码已重置为 BCrypt(admin/admin123)。
- 无已知阻塞性问题。
### 6. 08-31:菜单高亮间歇性根因修复(实锤)+ 修改密码功能(已完成并验证)
- **菜单高亮根因实锤**:不是 el-tabs 遗留问题,而是 Element UI `el-menu` 的 `handleItemClick` 在**同路由导航**(vue-router NavigationDuplicated)时,`routeToItem` 的 onError 回调把 `activeIndex` 恢复为点击前旧值(其只排除了 push 抛异常路径,未排除同路由回调路径)。触发场景:上一次菜单导航未完成时快速点另一个菜单(如「数据导入」未切完就点「审核结果」)→ 页面已是目标页但高亮停留在旧菜单,**间歇性、时好时坏**(复现:puppeteer 快速连点,puppeteer 用 node24:`C:\Users\jcxiong\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe`)。
- **修复**:8 个页面 el-menu 统一改为 `ref="sideMenu"` + `:default-active="$route.path"` + `@select="onMenuSelect"`;`onMenuSelect` 加在 main.js 全局 mixin(setTimeout 宏任务强制纠正 activeIndex,宏任务晚于 push 的微任务 onError 回调,保证最终高亮正确)。验证:6 轮×3 场景(顺序点/快速连点/同路由往返)全部高亮正确。
- **修改密码**:后端 `POST /api/auth/change-password`(用户改自己密码,需旧密码校验、新密码≥6位、新旧不同)+ `POST /api/user/reset-password`(仅 admin,重置他人为 123456,admin 自身禁止重置);JwtUtils 新增 `resolveUsername(Authorization头)`。前端 AppHeader 公共顶栏加「修改密码」弹窗(旧/新/确认),UserManage 操作列加「重置密码」按钮(`isAdmin` 控制显示,非 admin 隐藏+后端 403 双保险)。API 与 UI(admin/非admin 双视角)均已验证,**测试后数据库密码已恢复原状**(admin/admin123 有效,chenzj 等原 hash 未变)。
- 测试脚本:`D:\Codex\_tools\pptr-core\menu_audit_rounds.js`(高亮回归)、`pwd_ui_test.js`(改密 UI)。
### 7. 08-31 下班前:数据导入页重构(验收通过,commit 791e70f,已推送)
- **需求(用户 4 轮提出,全部验收通过)**:①「选择类型并挂载文件」→「选择数据类型」,删除报表期卡片说明文字;②删除「加入队列」、目录导入(粘贴路径「导入该目录」/「浏览…」/「跳过已导入文件」/目录进度列表)、目录浏览弹窗;③模板名+「下载模板」移到类型选择框旁并排(单行省略),删除各类型说明文字;④「导入记录」→「导入结果」:默认空白(「暂无导入失败记录」),仅失败批次平铺明细表(列:导入类型/报表期/文件名/失败位置/失败原因,「第 X 行(企业名)」来自后端 import_batch.error_detail,无需新接口);⑤「本月导入进度」改为右侧抽屉式边栏(非卡片、左右收起/展开、默认收起;展开后内部滚动 overscroll-behavior: contain,不带动整页滚动)。
- **实现**:仅改 `traffic-audit-web/src/views/DataImport.vue`(累计 +119/-380)。前端 dev 编译 ✅ + puppeteer 双场景验证 ✅(真实无失败数据→空态;拦截 mock 失败数据→明细表渲染;侧栏 34px⇄342px 往返、5 组 18 项、内部滚动独立)。
- **踩坑**:①新方法 `parseFailDetails` 曾误加在 methods 对象**外面**(mounted 前),Vue 不识别 → 展开行渲染时抛 TypeError、el-table 展开行不渲染(现象像 Element UI expand 失效),报错是 [Vue warn] 只在 **console** 出现、pageerror 抓不到(puppeteer 须监听 console 事件);移入 methods 后恢复。②puppeteer 验证 el-table 展开行不要用合成 click 点图标(时灵时不灵),直接拦截接口 mock 数据验证渲染更可靠。
- 测试脚本:`D:\Codex\_tools\pptr-core\_tmp_import_ui_final*.js`(mock 拦截验证)、`_tmp_import_ui_check*.js`;项目根 `_tmp_patch*.py`/`_tmp_probe*.py`/`_tmp_fix*.py`(改造脚本,按约定未删)。
### 8. 关机交接快照(08-31 晚更新,用户关机前)
- **当前运行状态**:dev 前后端均在运行——后端 8090(spring-boot:run,含今日改动)、前端 8080(vue-cli-service serve)。访问 http://localhost:8080,admin/admin123 登录正常。
- **数据库**:本机 MySQL 8.0.29(localhost:3305,traffic_audit)。4 个账号 admin/yangb/wangq/chenzj 密码哈希均未改动(admin 密码经今日改密测试后已恢复 admin123;chenzj 原哈希已恢复)。
- **git**:工作区干净;远端 Gitblit origin main 已同步到 `791e70f`(数据导入页重构)。
- **今日交付(验收通过)**:①菜单高亮间歇性失效根因修复(el-menu 同路由导航回滚,见第 6 节);②修改密码功能(用户自改 + admin 重置他人为 123456,见第 6 节);③数据导入页重构(选择数据类型/模板并排/失败明细表格/进度抽屉侧栏,见第 7 节)。
- **待办**:
  1. **重新打包发布测试环境**——`D:\trafficAudit` 的 app.jar/web 还是 08-27 旧包,不包含:el-tabs 修复(d42a044)、超时保护、菜单高亮修复(b051f00)、修改密码、验证码跳过、**数据导入页重构(791e70f)**。打包方法见 `docs/打包部署说明.md`(SpringBoot 托管 web/ + docs/,单端口 8090)。
  2. **正式发布前**把 `app.captcha.required` 改回 `true`(现在 application.yml 为 false 测试跳过)。
  3. 浏览器崩溃问题若在**新包**上仍出现:看服务器 jar 同级 `logs/access.*.log`(%D 耗时)+ 客户端 F12 Network(chunk/batches 状态),此前 el-tabs 根因(684da41/d42a044)应已消除。
  4. 态势图构想 + AI 助手 RAG 需求仍只记录未实现(见文档归档),后续排期。
- **测试脚本**:`D:\Codex\_tools\pptr-core\` 下 `menu_audit_rounds.js`(菜单高亮回归)、`pwd_ui_test.js`(改密 UI)、`prod_server.js`(打包静态+代理 8091)。puppeteer 用 node24 跑:`C:\Users\jcxiong\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe`。
## 二、快速启动
统一用一键脚本(必须沙箱外/提权运行):
@@ -176,3 +210,6 @@
  sql 的规则 INSERT 改写为 INSERT IGNORE VALUES(兼容 MySQL 5.7/8.0,原派生表写法在 8.0.29 报 Unknown column t.rule_code)。
  ⑥ 验证:后端 mvn compile ✅、前端 npm run build ✅。坑:链接工作区 .git 是文件,robocopy /MIR 会把它删掉导致"not a git
  repository",需重建 gitdir 文件;PowerShell 的 -e "..." 字符串里 $ 会被当变量展开,写 SQL 密码哈希要用 SQL 文件。
- 2026-08-31(下班前):数据导入页重构验收通过(commit 791e70f 已推送,详见第 7 节)。改造全用 Python 替换写文件(apply_patch 本机必败);关键坑:Vue 新方法误放 methods 外 → render TypeError、el-table 展开行不渲染,[Vue warn] 只在 console 不在 pageerror;验证用 puppeteer 拦截 /api/import/batches 返回 mock 失败数据直接验明细表,真实无失败数据验「暂无导入失败记录」空态。
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
@@ -83,6 +83,45 @@
        return Result.ok(data);
    }
    /** 用户修改自己的密码:需提供原密码,新密码至少 6 位 */
    @PostMapping("/change-password")
    public Result<Void> changePassword(@RequestHeader(value = "Authorization", required = false) String auth,
                                       @RequestBody Map<String, Object> params) {
        String username = jwtUtils.resolveUsername(auth);
        if (username == null) {
            return Result.error(401, "未登录或登录已过期");
        }
        String oldPassword = (String) params.get("oldPassword");
        String newPassword = (String) params.get("newPassword");
        if (oldPassword == null || oldPassword.isEmpty()) {
            return Result.error(400, "请输入原密码");
        }
        if (newPassword == null || newPassword.length() < 6) {
            return Result.error(400, "新密码长度不能少于 6 位");
        }
        if (newPassword.equals(oldPassword)) {
            return Result.error(400, "新密码不能与原密码相同");
        }
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username));
        if (user == null) {
            return Result.error(404, "用户不存在");
        }
        String encoded = user.getPassword();
        boolean ok;
        try {
            ok = encoded != null && passwordEncoder.matches(oldPassword, encoded);
        } catch (IllegalArgumentException e) {
            // 库中密码不是合法 BCrypt 时回退为明文对比(与登录一致)
            ok = encoded != null && encoded.equals(oldPassword);
        }
        if (!ok) {
            return Result.error(400, "原密码错误");
        }
        user.setPassword(passwordEncoder.encode(newPassword));
        userMapper.updateById(user);
        return Result.ok(null);
    }
    @GetMapping("/captcha-config")
    public Result<Map<String, Object>> captchaConfig() {
        Map<String, Object> data = new HashMap<>();
traffic-audit-server/src/main/java/com/trafficaudit/security/utils/JwtUtils.java
@@ -45,4 +45,16 @@
            return false;
        }
    }
    /** 从 Authorization: Bearer xxx 请求头解析用户名;无效返回 null */
    public String resolveUsername(String authorizationHeader) {
        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            return null;
        }
        try {
            return getUsernameFromToken(authorizationHeader.substring(7));
        } catch (Exception e) {
            return null;
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/UserController.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.Result;
import com.trafficaudit.security.utils.JwtUtils;
import com.trafficaudit.system.entity.User;
import com.trafficaudit.system.mapper.UserMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -22,6 +23,8 @@
    private UserMapper userMapper;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private JwtUtils jwtUtils;
    @GetMapping("/list")
    public Result<List<Map<String, Object>>> list(@RequestParam(value = "keyword", required = false) String keyword) {
@@ -92,6 +95,34 @@
        return Result.ok(null);
    }
    /** admin 重置他人密码为 123456 */
    @PostMapping("/reset-password")
    public Result<Void> resetPassword(@RequestHeader(value = "Authorization", required = false) String auth,
                                      @RequestBody Map<String, Object> params) {
        String operator = jwtUtils.resolveUsername(auth);
        if (operator == null) {
            return Result.error(401, "未登录或登录已过期");
        }
        if (!"admin".equals(operator)) {
            return Result.error(403, "仅 admin 可以重置其他用户密码");
        }
        Object idObj = params.get("id");
        if (idObj == null) {
            return Result.error(400, "缺少用户 id");
        }
        Long id = Long.valueOf(idObj.toString());
        User u = userMapper.selectById(id);
        if (u == null) {
            return Result.error(404, "用户不存在");
        }
        if ("admin".equals(u.getUsername())) {
            return Result.error(400, "admin 密码不允许重置");
        }
        u.setPassword(passwordEncoder.encode("123456"));
        userMapper.updateById(u);
        return Result.ok(null);
    }
    @PostMapping("/delete")
    public Result<Void> delete(@RequestParam("id") Long id) {
        User u = userMapper.selectById(id);
traffic-audit-web/src/api/index.js
@@ -7,7 +7,7 @@
})
api.interceptors.request.use(config => {
  const token = localStorage.getItem('token')
  const token = sessionStorage.getItem('token')
  if (token) config.headers.Authorization = 'Bearer ' + token
  return config
})
@@ -16,7 +16,7 @@
  response => response.data,
  error => {
    if (error.response && error.response.status === 401) {
      localStorage.removeItem('token')
      sessionStorage.removeItem('token')
      window.location.href = '/login'
      return Promise.reject(error)
    }
traffic-audit-web/src/components/AppHeader.vue
@@ -5,33 +5,107 @@
      <span class="header-user">
        <i class="el-icon-user"></i> {{ username }}
      </span>
      <el-button type="text" class="header-link" @click="openChangePwd">
        <i class="el-icon-key"></i> 修改密码
      </el-button>
      <el-button type="text" class="header-logout" @click="logout">
        <i class="el-icon-switch-button"></i> 退出登录
      </el-button>
    </div>
    <el-dialog title="修改密码" :visible.sync="pwdDialogVisible" width="420px" @closed="resetPwdForm">
      <el-form ref="pwdForm" :model="pwdForm" :rules="pwdRules" label-width="90px">
        <el-form-item label="原密码" prop="oldPassword">
          <el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入原密码"></el-input>
        </el-form-item>
        <el-form-item label="新密码" prop="newPassword">
          <el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="至少 6 位"></el-input>
        </el-form-item>
        <el-form-item label="确认新密码" prop="confirmPassword">
          <el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码"></el-input>
        </el-form-item>
      </el-form>
      <div slot="footer">
        <el-button @click="pwdDialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="pwdSaving" @click="submitPwd">保存</el-button>
      </div>
    </el-dialog>
  </el-header>
</template>
<script>
import api from '@/api'
export default {
  name: 'AppHeader',
  props: {
    title: { type: String, required: true }
  },
  data() {
    return {
      pwdDialogVisible: false,
      pwdSaving: false,
      pwdForm: { oldPassword: '', newPassword: '', confirmPassword: '' },
      pwdRules: {
        oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
        newPassword: [
          { required: true, message: '请输入新密码', trigger: 'blur' },
          { min: 6, message: '新密码长度不能少于 6 位', trigger: 'blur' }
        ],
        confirmPassword: [
          { required: true, message: '请再次输入新密码', trigger: 'blur' },
          {
            validator: (rule, value, cb) => (value === this.pwdForm.newPassword ? cb() : cb(new Error('两次输入的密码不一致'))),
            trigger: 'blur'
          }
        ]
      }
    }
  },
  computed: {
    username() {
      return localStorage.getItem('username') || 'admin'
      return sessionStorage.getItem('username') || 'admin'
    }
  },
  methods: {
    openChangePwd() {
      this.pwdForm = { oldPassword: '', newPassword: '', confirmPassword: '' }
      this.pwdDialogVisible = true
      this.$nextTick(() => this.$refs.pwdForm && this.$refs.pwdForm.clearValidate())
    },
    resetPwdForm() {
      this.$refs.pwdForm && this.$refs.pwdForm.clearValidate()
    },
    submitPwd() {
      this.$refs.pwdForm.validate(valid => {
        if (!valid) return
        this.pwdSaving = true
        api.post('/auth/change-password', {
          oldPassword: this.pwdForm.oldPassword,
          newPassword: this.pwdForm.newPassword
        })
          .then(() => {
            this.$message.success('密码修改成功')
            this.pwdDialogVisible = false
          })
          .catch(err => {
            if (err && err._toast) return
            const d = err && err.response && err.response.data
            this.$message.error((d && d.message) || '密码修改失败')
          })
          .finally(() => { this.pwdSaving = false })
      })
    },
    logout() {
      this.$confirm('确定退出登录吗?', '提示', {
        confirmButtonText: '退出',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        localStorage.removeItem('token')
        localStorage.removeItem('username')
        sessionStorage.removeItem('token')
        sessionStorage.removeItem('username')
        sessionStorage.removeItem('realName')
        sessionStorage.removeItem('modules')
        this.$message.success('已退出登录')
        this.$router.push('/login')
      }).catch(() => {})
@@ -60,6 +134,8 @@
  line-height: normal;
}
.header-user { opacity: 0.92; }
.header-link { color: #fff; font-size: 14px; padding: 0; }
.header-link:hover { color: #ffd04b; }
.header-logout { color: #fff; font-size: 14px; padding: 0; }
.header-logout:hover { color: #ffd04b; }
</style>
</style>
traffic-audit-web/src/main.js
@@ -10,11 +10,19 @@
// 全局登出:清除登录态并回登录页(各页面侧边栏「退出登录」菜单共用)
Vue.mixin({
  methods: {
    // el-menu 同路由导航(NavigationDuplicated)时 Element UI 会把高亮回滚为点击前旧值;
    // 点击后延迟一个宏任务强制纠正(宏任务晚于 push 的微任务回调,保证最终高亮正确)。
    onMenuSelect(index) {
      setTimeout(() => {
        const menu = this.$refs.sideMenu
        if (menu && menu.activeIndex !== index) menu.activeIndex = index
      }, 0)
    },
    logout() {
      localStorage.removeItem('token')
      localStorage.removeItem('username')
      localStorage.removeItem('realName')
      localStorage.removeItem('modules')
      sessionStorage.removeItem('token')
      sessionStorage.removeItem('username')
      sessionStorage.removeItem('realName')
      sessionStorage.removeItem('modules')
      this.$router.push('/login')
    }
  }
traffic-audit-web/src/router/index.js
@@ -19,7 +19,7 @@
})
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  const token = sessionStorage.getItem('token')
  if (to.path === '/login') {
    next(token ? '/dashboard' : undefined)
  } else {
traffic-audit-web/src/views/AuditResult.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 审核结果"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/audit">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
traffic-audit-web/src/views/Dashboard.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 工作台"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/dashboard">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
traffic-audit-web/src/views/DataImport.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 数据导入"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/import">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
@@ -17,190 +17,107 @@
      <el-main>
        <div class="page-scroll">
          <h3>导入业务数据</h3>
          <el-card shadow="hover" class="import-card" style="margin-bottom:15px">
          <div class="import-layout">
            <div class="import-main">          <el-card shadow="hover" class="import-card" style="margin-bottom:15px">
            <div class="card-head">报表期</div>
            <el-date-picker v-model="period" type="month" placeholder="选择月份" value-format="yyyy-MM"
                            style="width:240px" @change="onPeriodChange"></el-date-picker>
            <div class="tip">选择报表期后,下方显示该月各模块的导入进度;各模块下拉互不影响,可先选好类型、挂载文件,再加入待导入队列统一执行。</div>
          </el-card>
          <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="loadData">刷新</el-button>
            <div class="card-head">选择数据类型</div>
            <div class="module-tabs-head">
              <el-radio-group v-model="activeModule" size="small" @change="onModuleSwitch">
                <el-radio-button v-for="g in groups" :key="g.key" :label="g.key">{{ g.name }}</el-radio-button>
              </el-radio-group>
            </div>
            <div v-if="!period" class="tip">请先在上方选择报表期</div>
            <div v-else class="overview-grid">
              <div class="ov-row" v-for="g in groups" :key="g.key">
                <div class="ov-group">{{ g.name }}</div>
                <div class="ov-item" v-for="t in g.types" :key="t.value" :class="overviewState(t.value).cls">
                  <div class="ov-name">{{ t.label }}</div>
                  <div class="ov-status">{{ overviewState(t.value).text }}</div>
                </div>
              </div>
            </div>
          </el-card>
          <el-card shadow="hover" class="import-card" style="margin-bottom:15px">
            <div class="card-head">选择类型并挂载文件
              <span class="tip" style="float:right;line-height:24px">各模块互不影响:先选类型、挂载文件,可「立即导入」或「加入队列」统一执行;也可在下方粘贴目录路径点「导入该目录」,文件夹里多个月的 Excel 会按文件名自动识别月份、分别导入对应报表期</span>
            </div>
            <el-tabs v-model="activeModule" class="module-tabs">
              <el-tab-pane v-for="g in groups" :key="g.key" :label="g.name" :name="g.key">
                <div class="module-sel">
                  <el-select v-model="moduleSel[g.key]" placeholder="选择导入类型" style="width:260px"
            <div v-for="g in groups" :key="g.key" v-show="activeModule === g.key" class="module-tab-pane">
              <div class="module-sel">
                                <div class="module-sel-row">
                  <el-select v-model="moduleSel[g.key]" placeholder="选择数据类型" style="width:260px"
                             @change="onModuleChange(g.key)" clearable>
                    <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
                  </el-select>
                  <div v-if="isCityDirType(moduleSel[g.key])" class="module-file">
                    <div class="tip">无需选文件:自动扫描 docs/投资/输入/…/{{ period ? Number(period.slice(5)) : 'X' }}月 目录下全部市州报表,逐文件容错解析入库(项目主档+月度,含本月完成/建设阶段/形象进度;十五五项目自动标记)。</div>
                    <el-button size="mini" type="primary" icon="el-icon-upload2" :loading="cityDirLoading"
                               @click="importCityDir(moduleSel[g.key])">从目录批量导入</el-button>
                  <div v-if="importType" class="tpl-box">
                    <span class="tpl-name">模板:{{ templateName }}</span>
                    <el-link v-if="templateUrl" type="primary" icon="el-icon-download" :underline="false"
                             @click="downloadTemplate">下载模板</el-link>
                    <span v-if="importType === 'cityTaxiAuth'" class="slow-tip">该类型约 4 万+ 行,预计耗时 5 分钟左右</span>
                  </div>
                  <div v-else-if="moduleSel[g.key]" class="module-file">
                    <div class="file-name" :title="fileNames[moduleSel[g.key]]">{{ fileNames[moduleSel[g.key]] || '未选择文件' }}</div>
                    <div class="file-btns">
                      <el-upload action="#" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls"
                                 :ref="'up' + g.key" :on-change="f => onFilePicked(g.key, f)" style="display:inline-block">
                        <el-button size="mini" type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                      </el-upload>
                      <el-button size="mini" type="primary" icon="el-icon-upload2" :disabled="!files[moduleSel[g.key]]"
                                 @click="importNow(g.key)">立即导入</el-button>
                      <el-button size="mini" type="success" plain icon="el-icon-plus" :disabled="!files[moduleSel[g.key]]"
                                 @click="addToQueue(g.key)">加入队列</el-button>
                    </div>
                    <div class="dir-import-bar">
                      <el-input v-model="dirPath" size="small" placeholder="粘贴目录路径(如 D:\数据\2026),点「导入该目录」" style="width:330px"
                                @keyup.enter.native="quickDirImport(moduleSel[g.key])" clearable></el-input>
                      <el-button size="mini" type="warning" icon="el-icon-folder" :loading="dirImporting"
                                 @click="quickDirImport(moduleSel[g.key])">导入该目录</el-button>
                      <el-button size="mini" type="text" icon="el-icon-search" @click="openDirBrowser(g.key)">浏览…</el-button>
                      <el-checkbox v-model="dirSkipImported" size="mini">跳过已导入文件</el-checkbox>
                    </div>
                    <div class="tip">目录导入:文件夹里的 Excel 会按文件名自动识别月份(支持 2026年7月 / 2026.7 / 2026-07 / 7月),分别导入对应报表期;识别不到月份的文件会报错提示。也可点「浏览…」查找目录。</div>
                    <div v-if="dirImportFiles.length" class="dir-progress">
                      <div class="card-head" style="font-size:13px">目录导入进度</div>
                      <div v-for="f in dirImportFiles" :key="f.name" class="dir-progress-row">
                        <i :class="progIcon(f.status)"></i>
                        <span class="prog-name" :title="f.name">{{ f.name }}</span>
                        <el-tag v-if="f.period" size="mini" type="info">{{ f.period }}</el-tag>
                        <el-tag size="mini" :type="progTag(f.status)">{{ progText(f.status) }}</el-tag>
                        <span v-if="f.msg" class="queue-msg" :style="f.status === 'fail' ? 'color:#F56C6C' : 'color:#67C23A'">{{ f.msg }}</span>
                      </div>
                </div>
                <div v-if="isCityDirType(moduleSel[g.key])" class="module-file">
                  <div class="tip">无需选文件:自动扫描 docs/投资/输入/…/{{ period ? Number(period.slice(5)) : 'X' }}月 目录下全部市州报表,逐文件容错解析入库(项目主档+月度,含本月完成/建设阶段/形象进度;十五五项目自动标记)。</div>
                  <el-button size="mini" type="primary" icon="el-icon-upload2" :loading="cityDirLoading"
                             @click="importCityDir(moduleSel[g.key])">从目录批量导入</el-button>
                </div>
                <div v-else-if="moduleSel[g.key]" class="module-file">
                  <div class="file-name" :title="fileNames[moduleSel[g.key]]">{{ fileNames[moduleSel[g.key]] || '未选择文件' }}</div>
                  <div class="file-btns">
                    <el-upload action="#" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls"
                               :ref="'up' + g.key" :on-change="f => onFilePicked(g.key, f)" style="display:inline-block">
                      <el-button size="mini" type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                    </el-upload>
                    <el-button size="mini" type="primary" icon="el-icon-upload2" :disabled="!files[moduleSel[g.key]]"
                               @click="importNow(g.key)">立即导入</el-button>
                  </div>
                </div>
              </div>
            </div>
          </el-card>
                              <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="loadData">刷新</el-button>
            </div>
            <div v-if="failBatches.length" class="tip" style="margin-bottom:6px">共 {{ failBatches.length }} 个批次存在失败数据,失败明细共 {{ failRowsFlat.length }} 条:</div>
            <el-table v-if="failBatches.length" :data="failRowsFlat" border size="small" v-loading="batchLoading" style="width:100%">
              <el-table-column label="导入类型" width="140">
                <template slot-scope="s">{{ typeLabel(s.row.importType) }}</template>
              </el-table-column>
              <el-table-column prop="reportPeriod" label="报表期" width="90"></el-table-column>
              <el-table-column label="文件名" min-width="200" show-overflow-tooltip>
                <template slot-scope="s">{{ s.row.fileName }}</template>
              </el-table-column>
              <el-table-column label="失败位置" width="230">
                <template slot-scope="s"><span v-if="s.row.position">{{ s.row.position }}</span><span v-else>—</span></template>
              </el-table-column>
              <el-table-column label="失败原因" min-width="300">
                <template slot-scope="s">{{ s.row.reason }}</template>
              </el-table-column>
            </el-table>
            <div v-else class="empty-tip">暂无导入失败记录</div>
          </el-card>
            </div>
                        <div class="import-side" :class="{ 'side-open': sideOpen }">
              <div class="side-toggle" @click="sideOpen = !sideOpen" :title="sideOpen ? '收起进度' : '展开进度'">
                <i :class="sideOpen ? 'el-icon-d-arrow-right' : 'el-icon-d-arrow-left'"></i>
                <span class="side-toggle-text">{{ sideOpen ? '收起进度' : '导入进度' }}</span>
              </div>
              <div v-show="sideOpen" class="side-content">
                <div class="side-head">
                  <span>本月导入进度({{ period || '未选择报表期' }})</span>
                  <el-button type="text" icon="el-icon-refresh" @click="loadData">刷新</el-button>
                </div>
                <div v-if="!period" class="tip">请先在上方选择报表期</div>
                <div v-else class="overview-grid">
                  <div class="ov-row" v-for="g in groups" :key="g.key">
                    <div class="ov-group">{{ g.name }}</div>
                    <div class="ov-item" v-for="t in g.types" :key="t.value" :class="overviewState(t.value).cls">
                      <div class="ov-name">{{ t.label }}</div>
                      <div class="ov-status">{{ overviewState(t.value).text }}</div>
                    </div>
                  </div>
                </div>
              </el-tab-pane>
            </el-tabs>
            <div v-if="importType" class="type-info">
              <div>模板:{{ templateName }}
                <el-link v-if="templateUrl" type="primary" icon="el-icon-download" :underline="false"
                         style="margin-left:8px" @click="downloadTemplate">下载模板</el-link>
              </div>
              <div class="tip">{{ typeDesc }}</div>
              <span v-if="importType === 'cityTaxiAuth'" class="slow-tip">该类型约 4 万+ 行,预计耗时 5 分钟左右</span>
            </div>
          </el-card>
          <el-card v-if="queue.length" ref="queueCard" shadow="hover" class="import-card" style="margin-bottom:15px">
            <div class="card-head">
              待导入队列({{ queue.length }} 项)
              <el-button type="success" size="small" icon="el-icon-video-play" style="float:right"
                         :loading="queueLoading" :disabled="!pendingCount" @click="importQueue">全部导入({{ pendingCount }})</el-button>
            </div>
            <el-table :data="queue" border size="small" style="width:100%">
              <el-table-column label="模块" width="90">
                <template slot-scope="s">{{ s.row.moduleName }}</template>
              </el-table-column>
              <el-table-column label="导入类型" width="150">
                <template slot-scope="s">{{ s.row.typeLabel }}</template>
              </el-table-column>
              <el-table-column label="文件" min-width="200" show-overflow-tooltip>
                <template slot-scope="s">{{ s.row.fileName }}</template>
              </el-table-column>
              <el-table-column label="状态" width="110" align="center">
                <template slot-scope="s">
                  <el-tag v-if="s.row.status === 'pending'" type="info" size="mini">待导入</el-tag>
                  <el-tag v-else-if="s.row.status === 'uploading'" type="warning" size="mini" effect="dark">导入中…</el-tag>
                  <el-tag v-else-if="s.row.status === 'success'" type="success" size="mini">成功</el-tag>
                  <el-tag v-else type="danger" size="mini">失败</el-tag>
                </template>
              </el-table-column>
              <el-table-column label="结果" min-width="240">
                <template slot-scope="s">
                  <span class="queue-msg">{{ s.row.message }}</span>
                </template>
              </el-table-column>
              <el-table-column label="操作" width="130" align="center">
                <template slot-scope="s">
                  <el-button v-if="s.row.status === 'pending'" size="mini" type="primary" plain @click="importOne(s.row)">导入</el-button>
                  <el-button v-if="s.row.status !== 'uploading'" size="mini" type="danger" plain icon="el-icon-delete"
                             @click="removeQueueItem(s.row)"></el-button>
                </template>
              </el-table-column>
            </el-table>
            <div class="tip" style="margin-top:8px">导入为逐个执行;同报表期重复导入会先删除旧数据再写入(幂等,不会重复存两份)。</div>
          </el-card>
          <el-dialog :title="'浏览目录 — ' + (dirTypeLabel || '')" :visible.sync="dirDialog" width="640px" top="6vh" append-to-body>
            <div class="dir-toolbar">
              <el-button size="mini" type="text" icon="el-icon-refresh" :disabled="dirLoading" @click="loadDirs(dirCurrent)">刷新</el-button>
              <el-button size="mini" type="text" icon="el-icon-folder-opened" @click="loadDirs('')">数据目录 docs</el-button>
              <el-button size="mini" type="text" icon="el-icon-back" :disabled="!dirParent" @click="goDirParent">上级目录</el-button>
            </div>
            <div class="dir-path" :title="dirCurrent">{{ dirCurrent || '(加载中…)' }}</div>
            <div class="dir-body" v-loading="dirLoading">
              <div v-if="!dirDirs.length" class="dir-empty">当前目录下没有子文件夹,可直接选择当前文件夹导入</div>
              <div v-for="d in dirDirs" :key="d.path" class="dir-row" :title="d.path + '(双击立即导入该文件夹)'" @click="loadDirs(d.path)" @dblclick="dblImportDir(d)">
                <i class="el-icon-folder"></i><span>{{ d.name }}</span>
              </div>
            </div>
            <div class="dir-files">
              <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>
              <el-button size="small" type="primary" :disabled="dirExcelCount === 0 || dirImporting" :loading="dirImporting" @click="confirmDirImport">导入当前目录</el-button>
            </span>
          </el-dialog>
          <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="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">
                <template slot-scope="s">{{ typeLabel(s.row.importType) }}</template>
              </el-table-column>
              <el-table-column prop="reportPeriod" label="报表期" width="100"></el-table-column>
              <el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip></el-table-column>
              <el-table-column prop="totalRows" label="总行数" width="90"></el-table-column>
              <el-table-column prop="successRows" label="成功" width="80">
                <template slot-scope="s">
                  <span style="color:#67C23A" v-if="s.row.successRows > 0">{{ s.row.successRows }}</span>
                  <span v-else>0</span>
                </template>
              </el-table-column>
              <el-table-column prop="failRows" label="失败" width="70">
                <template slot-scope="s">
                  <el-tag v-if="s.row.failRows > 0" size="mini" type="danger">{{ s.row.failRows }}</el-tag>
                  <span v-else style="color:#909399">0</span>
                </template>
              </el-table-column>
              <el-table-column prop="createdAt" label="导入时间" width="165"></el-table-column>
              <el-table-column label="状态" width="90">
                <template slot-scope="s">
                  <el-tag v-if="s.row.failRows > 0" size="mini" type="warning" effect="plain">部分失败</el-tag>
                  <el-tag v-else-if="s.row.successRows > 0" size="mini" type="success" effect="plain">成功</el-tag>
                  <el-tag v-else size="mini" type="info" effect="plain">无数据</el-tag>
                </template>
              </el-table-column>
            </el-table>
          </el-card>
          </div>
        </div>
      </el-main>
    </el-container>
@@ -237,23 +154,8 @@
    return {
      importType: 'h2032',
      period: (() => { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') })(),
      queue: [],
      queueLoading: false,
      cityDirLoading: false,
      dirDialog: false,
      dirKey: '',
      dirCurrent: '',
      dirParent: '',
      dirDirs: [],
      dirDirsTruncated: false,
      dirExcelCount: 0,
      dirFilesTruncated: false,
      dirFiles: [],
      dirLoading: false,
      dirImporting: false,
      dirPath: '',
      dirImportFiles: [],
      dirSkipImported: true,
      sideOpen: false,
      result: '',
      error: '',
      batches: [],
@@ -306,38 +208,18 @@
    }
  },
  computed: {
    pendingCount() { return this.queue.filter(x => x.status === 'pending').length },
    dirTypeLabel() {
      const t = this.moduleSel[this.dirKey]
      if (!t) return ''
      for (const g of this.groups) {
        const found = g.types.find(x => x.value === t)
        if (found) return g.name + ' / ' + found.label
    failBatches() { return this.batches.filter(b => b.failRows > 0) },
    failRowsFlat() {
      const out = []
      for (const b of this.failBatches) {
        const rows = this.parseFailDetails(b)
        if (!rows.length) {
          out.push({ importType: b.importType, reportPeriod: b.reportPeriod, fileName: b.fileName, position: '', reason: b.errorDetail || '该批次无失败明细' })
        } else {
          rows.forEach(r => out.push({ importType: b.importType, reportPeriod: b.reportPeriod, fileName: b.fileName, position: r.position, reason: r.reason }))
        }
      }
      return t
    },
    typeDesc() {
      const map = {
        h2032: '企业级货运月报:用于规上货运量审核与分市州汇总(17 市州企业明细)。',
        transportAuth: '运政车辆档案:用于车辆信息核对(吨位/车型/燃料类型)。',
        trackMileage: '轨迹里程数据:用于里程与运距校验。',
        scaleSplit: '规上规下拆分:全省+17 市州货运量/周转量拆分结果。',
        freightTurnover: '货运量周转量:全省+17 市州月度合计(今年 1-N 月 + 去年)。',
        passengerMonthly: '公路旅客月报:企业级明细 + 运政车辆数两个 sheet(运政车辆数也可单独导入)。',
        passengerAuth: '旅客运政车辆数:独立模板(企业代码/企业名称/车辆数/载客位数),用于运营车辆核对;也可随 H203-1 月报第二个 sheet 一并导入。',
        passengerIndividual: '个体客运量/周转量:企业线下汇报数据(所属地区/企业代码/企业名称/个体客运量/个体旅客周转量),导入后自动填入分市州表“其中个体”行。',
        energyMonthly: '能耗季报:能耗明细 + 运政车辆信息两个 sheet(运政信息也可单独导入,季度末月生成汇总表)。',
        energyAuth: '能耗车辆运政信息:独立模板(车牌号/车辆类型/燃料类型/标记吨位/准牵引质量),用于能耗审核对比;也可随 H204 季报第二个 sheet 一并导入。',
        investment: '投资月报(客运站场):市州汇总大表,导入后生成 4 张领导报表。',
        investmentLogistics: '投资月报(物流园区):分项目投资完成情况 sheet。',
        investmentSystem: '投资系统导出:用于与月报数据跨表校验。',
        investCityPassenger: '无需选文件:自动扫描 docs/投资/输入/客运(客运站)/X月 目录全部市州报表,容错解析入库(含本月完成/建设阶段/形象进度,十五五项目自动标记)。',
        investCityLogistics: '无需选文件:自动扫描 docs/投资/输入/物流(物流园区)/X月 目录全部市州报表,容错解析入库(含本月完成/建设阶段/形象进度,十五五项目自动标记)。',
        cityBus: '城市公交月报:企业级运营数据,含轨道/轮渡字段。',
        cityTaxi: '巡游出租月报:市州级 17 行运营数据。',
        cityTaxiAuth: '出租车运政车辆:车辆级明细(约 4 万+ 行),用于运营车数对比。'
      }
      return map[this.importType] || ''
      return out
    },
    templateName() {
      const map = {
@@ -392,7 +274,9 @@
    },
    onModuleChange(key) {
      this.importType = this.moduleSel[key] || ''
      this.dirPath = (this.importType && localStorage.getItem('dirLast_' + this.importType)) || ''
    },
    onModuleSwitch() {
      this.importType = this.moduleSel[this.activeModule] || ''
    },
    onPeriodChange() { this.loadData() },
    endpoint(t) {
@@ -416,102 +300,6 @@
      }
      return map[t] || ''
    },
    openDirBrowser(key) {
      const type = this.moduleSel[key]
      if (!type) { this.$message.warning('请先选择导入类型'); return }
      this.dirKey = key
      this.dirDialog = true
      const last = localStorage.getItem('dirLast_' + type)
      this.loadDirs(last || '')
    },
    loadDirs(path) {
      this.dirLoading = true
      api.get('/import/dirs', { params: { path: path || '' } })
        .then(res => {
          const d = (res && res.data) || {}
          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 => { 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) },
    dblImportDir(d) {
      this.dirDialog = false
      this.runDirImport(this.moduleSel[this.dirKey], d.path)
    },
    confirmDirImport() {
      if (!this.dirCurrent) return
      this.runDirImport(this.moduleSel[this.dirKey], this.dirCurrent)
    },
    quickDirImport(type) {
      if (!type) { this.$message.warning('请先选择导入类型'); return }
      this.runDirImport(type, this.dirPath)
    },
    runDirImport(type, dir) {
      if (!type) { this.$message.warning('请先选择导入类型'); return }
      if (!dir || !dir.trim()) { this.$message.warning('请先填写目录路径(或点「浏览…」选择)'); return }
      dir = dir.trim()
      this.dirImporting = true
      this.dirImportFiles = []
      api.get('/import/dirs', { params: { path: dir } })
        .then(res => {
          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: '' }))
          const run = (i) => {
            if (i >= files.length) {
              this.dirImporting = false
              const ok = this.dirImportFiles.filter(x => x.status === 'success').length
              const fail = this.dirImportFiles.filter(x => x.status === 'fail').length
              const skip = this.dirImportFiles.filter(x => x.status === 'skipped').length
              if (fail > 0) this.$message.warning('目录导入完成:成功 ' + ok + ',失败 ' + fail + ',跳过 ' + skip + '(失败原因见列表)')
              else this.$message.success('目录导入完成:成功 ' + ok + ',跳过 ' + skip)
              this.afterImport()
              return
            }
            const row = this.dirImportFiles[i]
            row.status = 'importing'
            const fd = new FormData()
            fd.append('type', type)
            fd.append('period', this.period || '')
            fd.append('skipImported', this.dirSkipImported ? 'true' : 'false')
            fd.append('path', dir.replace(/[\\/]+$/, '') + '\\' + files[i])
            api.post('/import/fromDirFile', fd, { timeout: 600000 })
              .then(res => {
                const r = (res && res.data) || {}
                row.period = r.period || ''
                if (r.skipped) { row.status = 'skipped'; row.msg = r.error || '已跳过' }
                else if (r.fail > 0 || r.error) { row.status = 'fail'; row.msg = r.error || ('失败 ' + r.fail + ' 条') }
                else { row.status = 'success'; row.msg = '解析 ' + r.success + ' 条' }
              })
              .catch(e => { row.status = 'fail'; row.msg = (e.response && e.response.data && e.response.data.message) || '导入失败' })
              .finally(() => run(i + 1))
          }
          run(0)
        })
        .catch(e => {
          this.dirImporting = false
          if (e && e._toast) return
          this.$message.error((e.response && e.response.data && e.response.data.message) || '目录不存在或无法访问:' + dir)
        })
    },
    progText(s) { return s === 'pending' ? '待导入' : s === 'importing' ? '导入中…' : s === 'success' ? '成功' : s === 'skipped' ? '已跳过' : '失败' },
    progTag(s) { return s === 'pending' ? 'info' : s === 'importing' ? 'warning' : s === 'success' ? 'success' : s === 'skipped' ? 'info' : 'danger' },
    progIcon(s) { return s === 'pending' ? 'el-icon-time' : s === 'importing' ? 'el-icon-loading' : s === 'success' ? 'el-icon-success' : s === 'skipped' ? 'el-icon-minus' : 'el-icon-error' },
    onFilePicked(key, file) {
      this.$set(this.files, this.moduleSel[key], file.raw)
      this.$set(this.fileNames, this.moduleSel[key], file.name)
@@ -591,33 +379,6 @@
        })
        .finally(() => { this.cityDirLoading = false })
    },
    addToQueue(key) {
      const type = this.moduleSel[key]
      if (!type) return
      const file = this.files[type]
      if (!file) { this.$message.warning('请先为该类型选择文件'); return }
      const g = this.groups.find(x => x.key === key)
      const t = g.types.find(x => x.value === type)
      this.queue.push({
        id: Date.now() + Math.random(),
        moduleKey: key,
        moduleName: g.name,
        type,
        typeLabel: t.label,
        file,
        fileName: this.fileNames[type] || file.name,
        status: 'pending',
        message: ''
      })
      this.moduleSel[key] = ''
      this.importType = ''
      this.files[type] = null
      this.$delete(this.fileNames, type)
    },
    removeQueueItem(row) {
      const i = this.queue.indexOf(row)
      if (i >= 0) this.queue.splice(i, 1)
    },
    importOne(row) {
      if (!this.period) { this.$message.warning('请先选择报表期'); return Promise.resolve() }
      row.status = 'uploading'
@@ -632,30 +393,6 @@
        .then(res => { row.status = 'success'; row.message = res.data || '导入成功' })
        .catch(e => { row.status = 'fail'; row.message = (e.response && e.response.data && e.response.data.message) || '导入失败' })
        .finally(() => {})
    },
    importQueue() {
      if (!this.period) { this.$message.warning('请先选择报表期'); return Promise.resolve(false) }
      const pending = this.queue.filter(x => x.status === 'pending')
      if (pending.length === 0) { this.$message.warning('队列中没有待导入项'); return Promise.resolve(false) }
      this.queueLoading = true
      return new Promise(resolve => {
        const run = (i) => {
          if (i >= pending.length) {
            this.queueLoading = false
            this.afterImport()
            resolve(true)
            return
          }
          this.importOne(pending[i]).then(() => {
            const t = pending[i].type
            if ((t === 'investment' || t === 'investmentLogistics') && pending[i].status === 'success') {
              this.checkInvest(this.period)
            }
            run(i + 1)
          })
        }
        run(0)
      })
    },
    afterImport() { this.loadData() },
    loadData() {
@@ -696,15 +433,6 @@
      if (o.successRows > 0) return { cls: 'ov-ok', text: '已导入 ' + o.successRows + ' 行' }
      return { cls: 'ov-empty', text: '无数据' }
    },
    loadBatches() {
      this.batchLoading = true
      const params = { limit: 50 }
      if (this.period) params.period = this.period
      api.get('/import/batches', { params })
        .then(res => { this.batches = (res.data && res.data.records) || [] })
        .catch(() => {})
        .finally(() => { this.batchLoading = false })
    },
    checkInvest(period) {
      const fd = new FormData()
      fd.append('period', period)
@@ -728,7 +456,19 @@
          }
        })
        .catch(() => {})
    }
    },
  parseFailDetails(row) {
    if (!row || !row.errorDetail) return []
    const lines = String(row.errorDetail).split(/\r?\n/)
    const out = []
    lines.forEach((line, i) => {
      if (!line || !line.trim()) return
      const m = line.match(/^第\s*(\d+)\s*行[((]([^))]*)[))]\s*[::]\s*([\s\S]*)$/)
      if (m) out.push({ no: i + 1, position: '第 ' + m[1] + ' 行(' + m[2] + ')', reason: m[3].trim() })
      else out.push({ no: i + 1, position: '', reason: line.trim() })
    })
    return out
  },
  },
  mounted() {
    this.loadData()
@@ -738,14 +478,30 @@
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
.import-card { margin-bottom: 0; }
.import-layout { display: flex; align-items: flex-start; gap: 15px; }
.import-main { flex: 1; min-width: 0; }
.import-side { position: relative; flex-shrink: 0; width: 34px; overflow: hidden; border: 1px solid #EBEEF5; border-radius: 4px; background: #FAFAFA; transition: width .2s ease; }
.import-side.side-open { width: 340px; }
.side-toggle { display: flex; flex-direction: column; align-items: center; cursor: pointer; color: #409EFF; padding: 10px 0; user-select: none; white-space: nowrap; }
.side-toggle i { font-size: 15px; }
.side-toggle-text { writing-mode: vertical-lr; letter-spacing: 4px; margin-top: 8px; font-size: 13px; }
.import-side.side-open .side-toggle { flex-direction: row; gap: 6px; padding: 8px 10px; border-bottom: 1px solid #EBEEF5; }
.import-side.side-open .side-toggle-text { writing-mode: horizontal-tb; letter-spacing: normal; margin-top: 0; }
.side-content { padding: 8px 12px 12px; max-height: calc(100vh - 220px); overflow-y: auto; overscroll-behavior: contain; }
.side-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; margin-bottom: 6px; }
.import-side .overview-grid { display: block; }
.import-side .ov-row { display: block; margin-bottom: 6px; }
.import-side .ov-group { width: auto; }
.import-side .ov-item { margin-bottom: 4px; }
.card-head { font-weight: 600; margin-bottom: 6px; }
.module-tabs >>> .el-tabs__header { margin-bottom: 12px; }
.module-sel { max-width: 480px; }
.module-sel { max-width: 1100px; }
.module-sel-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.tpl-box { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606266; font-size: 13px; }
.tpl-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
.group-label { font-size: 13px; color: #606266; font-weight: 600; margin-bottom: 6px; padding-left: 6px; border-left: 3px solid #409EFF; }
.module-file { margin-top: 8px; }
.file-name { font-size: 12px; color: #606266; background: #F5F7FA; border: 1px solid #EBEEF5; border-radius: 4px; padding: 4px 8px; margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-btns { display: flex; gap: 6px; }
.type-info { margin-top: 12px; color: #606266; font-size: 13px; background: #F5F7FA; border-radius: 4px; padding: 8px 10px; }
.tip { font-size: 12px; color: #909399; margin-top: 6px; line-height: 1.5; }
.slow-tip { font-size: 12px; color: #E6A23C; margin-left: 8px; }
.overview-grid { display: flex; flex-direction: column; gap: 8px; }
@@ -759,20 +515,7 @@
.ov-ok .ov-status { color: #67C23A; }
.ov-fail .ov-status { color: #E6A23C; }
.ov-empty .ov-status { color: #C0C4CC; }
.queue-msg { font-size: 12px; color: #606266; word-break: break-all; white-space: pre-line; }
.dir-import-bar { display: flex; align-items: center; gap: 8px; margin: 6px 0 4px; flex-wrap: wrap; }
.dir-progress { margin-top: 8px; border: 1px solid #EBEEF5; border-radius: 4px; padding: 8px; background: #FAFAFA; }
.dir-progress-row { display: flex; align-items: center; gap: 8px; padding: 4px 2px; font-size: 12px; color: #303133; flex-wrap: wrap; }
.dir-progress-row i { color: #409EFF; }
.dir-progress-row .prog-name { min-width: 0; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dir-progress-row .queue-msg { width: 100%; padding-left: 22px; }
.dir-toolbar { margin-bottom: 6px; }
.dir-path { font-size: 12px; color: #606266; background: #F5F7FA; border: 1px solid #EBEEF5; border-radius: 4px; padding: 6px 8px; margin-bottom: 8px; word-break: break-all; }
.dir-body { max-height: 320px; overflow-y: auto; border: 1px solid #EBEEF5; border-radius: 4px; }
.dir-row { display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer; font-size: 13px; color: #303133; }
.dir-row:hover { background: #ECF5FF; color: #409EFF; }
.dir-row i { color: #E6A23C; }
.dir-empty { padding: 14px; font-size: 12px; color: #909399; text-align: center; }
.dir-files { margin-top: 8px; }
.empty-tip { padding: 24px 0; text-align: center; color: #C0C4CC; font-size: 13px; }
.module-tabs-head { margin-bottom: 10px; }
.module-tab-pane { min-height: 120px; }
</style>
traffic-audit-web/src/views/DataView.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 数据查看"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/data">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
traffic-audit-web/src/views/ExplainReview.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 企业解释"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/explain">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
traffic-audit-web/src/views/Login.vue
@@ -79,10 +79,10 @@
          this.loggingIn = false
          const d = res.data
          if (d.code === 200) {
            localStorage.setItem('token', d.data.token)
            localStorage.setItem('username', d.data.username)
            localStorage.setItem('realName', d.data.realName || '')
            localStorage.setItem('modules', d.data.modules || '')
            sessionStorage.setItem('token', d.data.token)
            sessionStorage.setItem('username', d.data.username)
            sessionStorage.setItem('realName', d.data.realName || '')
            sessionStorage.setItem('modules', d.data.modules || '')
            this.$message.success('登录成功')
            this.$router.push('/dashboard')
          } else if (d.code === 401) {
traffic-audit-web/src/views/ReportExport.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 报表生成"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router :default-active="activeMenu">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
@@ -25,12 +25,12 @@
            </div>
          </div>
          <!-- 第二行:报表类型 Tab(货运 / 旅客 / 投资 / 能耗 / 城市客运) -->
          <el-tabs v-model="activeKey" class="module-tabs">
            <el-tab-pane v-for="m in modules" :key="m.key" :name="m.key">
              <span slot="label">{{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag></span>
            </el-tab-pane>
          </el-tabs>
          <!-- 第二行:报表类型切换(货运 / 旅客 / 投资 / 能耗 / 城市客运);el-radio-group 替代 el-tabs,规避 el-tabs 布局无限循环(与 /import 修复一致) -->
          <el-radio-group v-model="activeKey" class="module-tabs">
            <el-radio-button v-for="m in modules" :key="m.key" :label="m.key">
              {{ m.title }}<el-tag v-if="m.tag" size="mini" :type="m.tagType || 'info'" style="margin-left:6px">{{ m.tag }}</el-tag>
            </el-radio-button>
          </el-radio-group>
          <!-- 第三行:查询条件区(周期 + 月份 + 查询) -->
          <section class="query-panel">
@@ -154,7 +154,6 @@
  data() {
    return {
      loading: '',
      activeMenu: '/report',
      activeKey: '',
      updatedAt: '',
      // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy)
@@ -211,10 +210,10 @@
    }
  },
  computed: {
    username() { return localStorage.getItem('username') || '' },
    realName() { return localStorage.getItem('realName') || '' },
    username() { return sessionStorage.getItem('username') || '' },
    realName() { return sessionStorage.getItem('realName') || '' },
    myModules() {
      const s = localStorage.getItem('modules')
      const s = sessionStorage.getItem('modules')
      if (!s) return []
      return s.split(',').filter(Boolean)
    },
@@ -264,7 +263,7 @@
      return m ? m.title : key
    },
    logout() {
      localStorage.removeItem('token'); localStorage.removeItem('username'); localStorage.removeItem('realName'); localStorage.removeItem('modules')
      sessionStorage.removeItem('token'); sessionStorage.removeItem('username'); sessionStorage.removeItem('realName'); sessionStorage.removeItem('modules')
      this.$router.push('/login')
    },
    // ==================== 查询条件与状态刷新 ====================
traffic-audit-web/src/views/RuleManage.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 规则管理"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/rules">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
traffic-audit-web/src/views/UserManage.vue
@@ -3,7 +3,7 @@
    <app-header title="交通统计报表审核系统 - 用户管理"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router :default-active="activeMenu">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
@@ -43,6 +43,7 @@
              <el-table-column label="操作" width="140" align="center">
                <template slot-scope="{ row }">
                  <el-button type="text" size="mini" @click="openEdit(row)">编辑</el-button>
                  <el-button v-if="isAdmin" type="text" size="mini" @click="resetPwd(row)">重置密码</el-button>
                  <el-button type="text" size="mini" style="color:#f56c6c" @click="removeUser(row)">删除</el-button>
                </template>
              </el-table-column>
@@ -101,7 +102,6 @@
  name: 'UserManage',
  data() {
    return {
      activeMenu: '/users',
      keyword: '',
      list: [],
      loading: false,
@@ -113,6 +113,11 @@
        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
        password: [{ required: true, message: '请输入初始密码', trigger: 'blur' }]
      }
    }
  },
  computed: {
    isAdmin() {
      return sessionStorage.getItem('username') === 'admin'
    }
  },
  mounted() { this.loadList() },
@@ -168,6 +173,12 @@
          .finally(() => { this.saving = false })
      })
    },
    resetPwd(row) {
      this.$confirm('确定将用户「' + (row.realName || row.username) + '」的密码重置为 123456 吗?', '重置密码', { type: 'warning' })
        .then(() => api.post('/user/reset-password', { id: row.id }))
        .then(() => { this.$message.success('已重置为 123456'); this.loadList() })
        .catch(() => {})
    },
    removeUser(row) {
      this.$confirm('确定删除用户「' + (row.realName || row.username) + '」吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/user/delete', null, { params: { id: row.id } }))