| | |
| | | verify_city*.txt |
| | | |
| | | deploy-test/ |
| | | |
| | | # ==== packaged static dir (copied from dist, not committed) ==== |
| | | web/ |
| | | traffic-audit-server/web/ |
| | |
| | | > **用法**:新对话开始后,先读本文件 + 项目根目录 `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。 |
| | |
| | | - 后续扩展: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/` 与数据库表结构。 |
| | |
| | | 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()); |
| | |
| | | } |
| | | } |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | @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"); |
| | |
| | | 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()) { |
| | |
| | | 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) { |
| | |
| | | # 复制为 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: |
| | |
| | | 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 |
| | |
| | | import axios from 'axios' |
| | | import { Message } from 'element-ui' |
| | | |
| | | const api = axios.create({ |
| | | baseURL: '/api', |
| | | timeout: 30000 |
| | | timeout: 15000 |
| | | }) |
| | | |
| | | api.interceptors.request.use(config => { |
| | |
| | | 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) |
| | | } |
| | |
| | | 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()) }) |
| | |
| | | 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()) }) |
| | |
| | | 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) { |
| | |
| | | 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 |
| | |
| | | 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 } }) |
| | |
| | | .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() { |
| | |
| | | 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 + '」为已审核,报表导出不再提示未审核') |
| | |
| | | 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() { |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | 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 = '' }, |
| | |
| | | |
| | | <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"> |
| | |
| | | <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-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"> |
| | |
| | | dirCurrent: '', |
| | | dirParent: '', |
| | | dirDirs: [], |
| | | dirDirsTruncated: false, |
| | | dirExcelCount: 0, |
| | | dirFilesTruncated: false, |
| | | dirFiles: [], |
| | | dirLoading: false, |
| | | dirImporting: false, |
| | |
| | | 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', |
| | |
| | | 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) }, |
| | |
| | | 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: '' })) |
| | |
| | | }) |
| | | .catch(e => { |
| | | this.dirImporting = false |
| | | if (e && e._toast) return |
| | | this.$message.error((e.response && e.response.data && e.response.data.message) || '目录不存在或无法访问:' + dir) |
| | | }) |
| | | }, |
| | |
| | | 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 }) |
| | |
| | | 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 |
| | |
| | | }) |
| | | 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] |
| | |
| | | } |
| | | }, |
| | | mounted() { |
| | | this.loadBatches() |
| | | this.loadOverview() |
| | | this.loadData() |
| | | } |
| | | } |
| | | </script> |
| | |
| | | this.page = 1 |
| | | this.loadData() |
| | | } |
| | | }).catch(() => { |
| | | }).catch(e => { |
| | | if (e && e._toast) return |
| | | this.$message.error('数据查询失败') |
| | | }).finally(() => { |
| | | this.loading = false |
| | |
| | | CITY_TAXI: 'city_taxi_monthly' |
| | | } |
| | | export default { |
| | | components: { AppHeader }, |
| | | components: { AppHeader, AiChatDialog }, |
| | | name: 'ExplainReview', |
| | | components: { AiChatDialog }, |
| | | data() { |
| | | return { |
| | | reportType: 'H2032', |
| | |
| | | this.page = 1 |
| | | this.loadData() |
| | | } |
| | | }).catch(() => { |
| | | }).catch(e => { |
| | | if (e && e._toast) return |
| | | this.$message.error('解释查询失败') |
| | | }).finally(() => { |
| | | this.loading = false |
| | |
| | | <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> |
| | |
| | | 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) { |
| | |
| | | login() { |
| | | this.$refs.form.validate(valid => { |
| | | if (!valid) return |
| | | if (!this.captchaVerified) { |
| | | if (this.captchaRequired && !this.captchaVerified) { |
| | | this.$message.warning('请先完成行为验证') |
| | | return |
| | | } |
| | |
| | | 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) { |
| | |
| | | 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('网络错误,登录失败') |
| | | }) |
| | | }) |
| | |
| | | </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; } |
| | |
| | | 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 => { |
| | |
| | | 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 => { |
| | |
| | | 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) |
| | |
| | | .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 |
| | |
| | | <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"> |
| | |
| | | <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> |
| | |
| | | </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: '环比百分比', |
| | |
| | | AVG_DIST_VS_H2032: '平均运距对比', LOADED_TON_RATIO: '载货吨位比值' |
| | | } |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'RuleManage', |
| | | data() { |
| | | return { reportType: 'H2032', rules: [], loading: false } |
| | |
| | | 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> |
| | |
| | | <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"> |
| | |
| | | <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> |
| | |
| | | </template> |
| | | <script> |
| | | import api from '@/api' |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | const MODULE_OPTIONS = [ |
| | | { key: 'freight', label: '货运报表' }, |
| | | { key: 'passenger', label: '公路旅客报表' }, |
| | |
| | | { key: 'cityPassenger', label: '城市客运报表' } |
| | | ] |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'UserManage', |
| | | data() { |
| | | return { |
| | |
| | | }, |
| | | 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) { |
| | |
| | | this.loadList() |
| | | }) |
| | | .catch(err => { |
| | | if (err && err._toast) return |
| | | const d = err && err.response && err.response.data |
| | | this.$message.error((d && d.message) || '保存失败') |
| | | }) |