feat: 登录行为验证码(3D 立体图形按序点击)+ AppHeader 登出与上下布局 + 态势图构想记录(08-27 会话)
4个文件已添加
11个文件已修改
974 ■■■■■ 已修改文件
HANDOFF.md 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java 444 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AppHeader.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/Captcha3D.vue 244 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/router/index.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Dashboard.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Login.vue 66 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/vue.config.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -3,9 +3,9 @@
> **用法**:新对话开始后,先读本文件 + 项目根目录 `AGENTS.md`,即可无缝接力。
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
## 一、当前状态(2026-08-23)
- 前后端**均未运行**(8080/8090 连接被拒),需要先启动(见下)。
- 最近代码改动截至 08-07;08-14 起代码已入库 Gitee 私有仓库(见第九节),main 分支工作区干净、已推送。
## 一、当前状态(2026-08-27)
- 前后端**运行中**(8090/8080 已监听,start-dev.ps1 启动),登录页已接入真实认证 + 行为验证码。
- 08-27 行为验证码改动**未提交**(工作区有改动,见第十节);此前的代码已入库 Gitee 私有仓库(见第九节)。
- 无已知阻塞性问题。
## 二、快速启动
@@ -56,6 +56,8 @@
- 每日反思自动化尚未创建(提示词已备好,见 `docs/每日反思任务.md`;需要 automation 工具可用的会话)。
- 官方 Codex 手册本机访问 403,`docs/Codex功能清单.md` 待网络恢复后补官方核实版。
- 后续扩展:H203-1 公路旅客、W203 水路、P203 港口、H204 能源等(需求文档 v2/v3 计划),先把 H203-2 演示做好。
- 【新需求·仅记录未实现·2026-08-23】登录后首页新增「全省交通运输态势感知图」:把货运、客运、巡游出租、网约车、投资、能耗等方面的变化态势可视化展示。
- 【新需求·仅记录未实现·2026-08-23】扩展现有 AI 分析为「AI 助手」:以本地数据库数据为基础建立知识库,做 RAG,支持自然语言提问/聊天,灵活查看与挖掘数据。
## 八、每日交接约定
1. 新对话第一步:读 `AGENTS.md` + `HANDOFF.md`;涉及 AI 分析/审核时参考 `docs/` 与数据库表结构。
@@ -71,3 +73,7 @@
## 十、会话记录
- 2026-08-14:启动前后端验证正常(后均已停止);建立 git 仓库(新增 `.gitignore`、脱敏模板 `application.yml.example`,真实 `application.yml` 不入库),创建 Gitee 私有仓库 `jichengxiong/trafficAudit` 并推送 `main`(2 commits:初始提交 + HANDOFF 补充 git 说明)。
- 2026-08-23:交接前核实——git 工作区干净、远端已同步;前后端均未在运行。
- 2026-08-23:交接后启动前后端验证正常(8090/8080 已监听);记录两条新需求(态势感知图、AI 助手 RAG),仅记录未实现。
- 2026-08-27:登录功能改造完成——行为验证码(按提示顺序点击立体图形)。后端 security/captcha/CaptchaService.java 直接生成等轴测几何面(响应不含图形类型名,答案只在服务端),/api/auth/captcha 获取、/captcha/verify 校验(错 3 次换题)、/login 消费(一次性,未验证 4003/已过期 4002/顺序错 4001);前端 Captcha3D.vue SVG 渲染(背景随机色+6 种图案、8 种立体图形随机位置/大小/旋转/颜色),Login.vue 接入真实登录(axios+token+localStorage),router 加登录守卫。实测通过:正确顺序验证成功、错误顺序提示重试、未验证拦截、错密码 401、登录跳转工作台。坑:Vue2 组件名大小写敏感,组件注册 Captcha3D 时模板必须写 <captcha3-d>(<captcha3d> 会被解析成 Captcha3d 匹配不上)。
- 2026-08-27(续):登录页视觉与登出——验证码画布 340x190 缩为 340x148、图形 size 0.8~1.2 缩为 0.6~0.95、间距/抖动收紧、旋转 ±14°;登录卡片 420→400 宽、form 间距收紧(卡片高约 559px)。立方体/长方体改为斜二测教科书画法(前面正方形+顶/侧斜出,更立体)。新增公共 components/AppHeader.vue(标题+当前用户+退出登录,退出清 token 回登录页),替换 6 个业务页原有 el-header。坑:批量插入 components 时 ExplainReview 原有 components: { AiChatDialog } 被覆盖(重复键),需合并为 { AppHeader, AiChatDialog }。另:PowerShell 双引号字符串里的反引号会被吞掉,写 markdown 代码标记要避免。
- 2026-08-27(续2):六棱柱改为标准等轴测正六边形顶面+3 侧面(此前顶面过扁认不出);球改为径向渐变(亮→暗,前端渲染器新增 radial 渐变支持,gradient id 用 shape id 去重)。修布局回归:AppHeader 是自定义组件,el-container 检测不到 el-header 子组件导致外层容器变成横向(系统名称+登出只占左侧 435px)——6 个业务页外层容器显式加 direction="vertical" 修复(上:系统栏 左标题右用户+登出;下:左菜单右工作区)。坑:radialFaces 方法误放进 computed(Vue computed 不接受参数,s 为 undefined 报错),须放 methods;vue-loader 编译缓存/浏览器启发式缓存(index.html 无缓存头)导致改代码后页面仍加载旧 chunk——vue.config.js devServer.headers 加 Cache-Control: no-store,测试时用未访问过的 host:port。
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java
New file
@@ -0,0 +1,51 @@
package com.trafficaudit.security.captcha;
import com.trafficaudit.common.Result;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class CaptchaController {
    @Resource
    private CaptchaService captchaService;
    @GetMapping("/captcha")
    public Result<Map<String, Object>> captcha() {
        return Result.ok(captchaService.generate());
    }
    @PostMapping("/captcha/verify")
    public Result<Map<String, Object>> verify(@RequestBody Map<String, Object> params) {
        String captchaId = (String) params.get("captchaId");
        List<String> clickIds = strList(params.get("captchaClickIds"));
        CaptchaService.VerifyResult r = captchaService.verify(captchaId, clickIds);
        switch (r) {
            case OK:
                return Result.ok(Collections.singletonMap("ok", true));
            case WRONG:
                return Result.error(4001, "点击顺序错误,请重新点击");
            case RETRY_LIMIT:
                return Result.error(4002, "错误次数过多,已更换题目");
            default:
                return Result.error(4003, "验证码不存在或已过期,请刷新");
        }
    }
    @SuppressWarnings("unchecked")
    private List<String> strList(Object o) {
        if (o instanceof List) {
            return (List<String>) o;
        }
        return Collections.emptyList();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java
New file
@@ -0,0 +1,444 @@
package com.trafficaudit.security.captcha;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 行为验证码:按提示顺序点击立体图形。
 * 服务端直接生成每个图形的等轴测几何面(多边形/椭圆/路径),前端只负责渲染,
 * 响应中不包含任何图形类型名称,答案只保存在服务端。
 */
@Service
public class CaptchaService {
    public static final int CANVAS_W = 340;
    public static final int CANVAS_H = 148;
    private static final int SHAPE_COUNT = 6;
    private static final int ANSWER_COUNT = 4;
    private static final long TTL_MILLIS = 5 * 60 * 1000L;
    private static final int MAX_FAIL = 3;
    private static final char[] ID_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789".toCharArray();
    private static final Map<String, String> KIND_CN = new LinkedHashMap<>();
    static {
        KIND_CN.put("CUBE", "正方体");
        KIND_CN.put("BOX", "长方体");
        KIND_CN.put("CYLINDER", "圆柱");
        KIND_CN.put("CONE", "圆锥");
        KIND_CN.put("SPHERE", "球");
        KIND_CN.put("TRI_PRISM", "三棱柱");
        KIND_CN.put("PYRAMID", "金字塔");
        KIND_CN.put("HEX_PRISM", "六棱柱");
    }
    private static final String[] KINDS = KIND_CN.keySet().toArray(new String[0]);
    private static final String[] PATTERNS = {"dots", "grid", "lines", "rings", "cross", "tri"};
    private final Map<String, Challenge> store = new ConcurrentHashMap<>();
    private final Random random = new Random();
    public enum VerifyResult { OK, WRONG, NOT_FOUND, RETRY_LIMIT }
    public enum ConsumeResult { OK, WRONG, NOT_FOUND, NOT_VERIFIED }
    private static class Challenge {
        String id;
        List<String> answerKinds;
        Map<String, String> idToKind;
        boolean verified;
        long expireAt;
        int failCount;
    }
    /** 生成一组挑战,返回可直接下发前端的 JSON 结构。 */
    public Map<String, Object> generate() {
        boolean darkBg = random.nextBoolean();
        int bgHue = random.nextInt(360);
        int bgSat = 30 + random.nextInt(40);
        int bgLight = darkBg ? 14 + random.nextInt(16) : 74 + random.nextInt(16);
        String pattern = PATTERNS[random.nextInt(PATTERNS.length)];
        int patHue = (bgHue + 120 + random.nextInt(200)) % 360;
        int shapeSat = 55 + random.nextInt(30);
        int shapeLight = darkBg ? 60 + random.nextInt(18) : 28 + random.nextInt(16);
        int shapeHue = (bgHue + 30 + random.nextInt(300)) % 360;
        // 答案序列:随机取 ANSWER_COUNT 个不重复类型;其余位置用剩余类型填充
        List<String> pool = new ArrayList<>(Arrays.asList(KINDS));
        Collections.shuffle(pool, random);
        List<String> answerKinds = new ArrayList<>(pool.subList(0, ANSWER_COUNT));
        List<String> allKinds = new ArrayList<>();
        for (int i = 0; i < SHAPE_COUNT; i++) {
            if (i < ANSWER_COUNT) {
                allKinds.add(answerKinds.get(i));
            } else {
                allKinds.add(pool.get(ANSWER_COUNT + random.nextInt(pool.size() - ANSWER_COUNT)));
            }
        }
        Collections.shuffle(allKinds, random);
        int cols = 3, rows = 2;
        List<Map<String, Object>> shapes = new ArrayList<>();
        Map<String, String> idToKind = new HashMap<>();
        for (int i = 0; i < SHAPE_COUNT; i++) {
            int col = i % cols, row = i / cols;
            double x = CANVAS_W * (col + 0.5) / cols + (random.nextDouble() * 24 - 12);
            double y = CANVAS_H * (row + 0.5) / rows + (random.nextDouble() * 20 - 10);
            double size = 0.6 + random.nextDouble() * 0.35;
            double rotate = (random.nextInt(5) - 2) * 6 + (random.nextInt(5) - 2);
            String kind = allKinds.get(i);
            String id = randomId();
            idToKind.put(id, kind);
            shapes.add(buildShape(kind, id, x, y, size, rotate,
                    (shapeHue + random.nextInt(40) - 20 + 360) % 360, shapeSat, shapeLight));
        }
        Challenge ch = new Challenge();
        ch.id = randomId();
        ch.answerKinds = answerKinds;
        ch.idToKind = idToKind;
        ch.expireAt = System.currentTimeMillis() + TTL_MILLIS;
        store.put(ch.id, ch);
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("captchaId", ch.id);
        List<String> prompt = new ArrayList<>();
        for (String k : answerKinds) {
            prompt.add(KIND_CN.get(k));
        }
        data.put("prompt", prompt);
        Map<String, Object> bg = new LinkedHashMap<>();
        bg.put("hue", bgHue);
        bg.put("sat", bgSat);
        bg.put("light", bgLight);
        bg.put("pattern", pattern);
        bg.put("patternHue", patHue);
        bg.put("patternSat", 50);
        bg.put("patternLight", 60);
        bg.put("patternOpacity", 0.22);
        bg.put("patternSize", 9 + random.nextInt(8));
        data.put("background", bg);
        data.put("shapes", shapes);
        return data;
    }
    /** 校验点击顺序;通过后标记已认证,登录时消费。 */
    public VerifyResult verify(String captchaId, List<String> clickIds) {
        Challenge ch = getLive(captchaId);
        if (ch == null) {
            return VerifyResult.NOT_FOUND;
        }
        if (match(ch, clickIds)) {
            ch.verified = true;
            return VerifyResult.OK;
        }
        ch.failCount++;
        if (ch.failCount >= MAX_FAIL) {
            store.remove(ch.id);
            return VerifyResult.RETRY_LIMIT;
        }
        return VerifyResult.WRONG;
    }
    /** 登录时消费验证码:一次性,校验答案并删除挑战。 */
    public ConsumeResult consume(String captchaId, List<String> clickIds) {
        Challenge ch = getLive(captchaId);
        if (ch == null) {
            return ConsumeResult.NOT_FOUND;
        }
        store.remove(ch.id);
        if (!ch.verified) {
            return ConsumeResult.NOT_VERIFIED;
        }
        if (!match(ch, clickIds)) {
            return ConsumeResult.WRONG;
        }
        return ConsumeResult.OK;
    }
    private Challenge getLive(String captchaId) {
        if (captchaId == null || captchaId.isEmpty()) {
            return null;
        }
        Challenge ch = store.get(captchaId);
        if (ch == null) {
            return null;
        }
        if (ch.expireAt < System.currentTimeMillis()) {
            store.remove(ch.id);
            return null;
        }
        return ch;
    }
    private boolean match(Challenge ch, List<String> clickIds) {
        if (clickIds == null || clickIds.isEmpty() || clickIds.size() != ch.answerKinds.size()) {
            return false;
        }
        List<String> got = new ArrayList<>();
        for (String id : clickIds) {
            String kind = ch.idToKind.get(id);
            if (kind == null) {
                return false;
            }
            got.add(kind);
        }
        return ch.answerKinds.equals(got);
    }
    private String randomId() {
        StringBuilder sb = new StringBuilder(6);
        for (int i = 0; i < 6; i++) {
            sb.append(ID_CHARS[random.nextInt(ID_CHARS.length)]);
        }
        return sb.toString();
    }
    // ==================== 等轴测几何生成 ====================
    private Map<String, Object> buildShape(String kind, String id, double x, double y, double size,
                                           double rotate, int hue, int sat, int light) {
        ShapeArt art;
        switch (kind) {
            case "CUBE": art = cube(hue, sat, light); break;
            case "BOX": art = box(hue, sat, light); break;
            case "CYLINDER": art = cylinder(hue, sat, light); break;
            case "CONE": art = cone(hue, sat, light); break;
            case "SPHERE": art = sphere(hue, sat, light); break;
            case "TRI_PRISM": art = triPrism(hue, sat, light); break;
            case "PYRAMID": art = pyramid(hue, sat, light); break;
            case "HEX_PRISM": art = hexPrism(hue, sat, light); break;
            default: art = cube(hue, sat, light);
        }
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", id);
        m.put("x", x);
        m.put("y", y);
        m.put("size", size);
        m.put("rotate", rotate);
        m.put("hitW", art.bbox[0]);
        m.put("hitH", art.bbox[1]);
        m.put("faces", art.faces);
        return m;
    }
    private static class ShapeArt {
        List<Map<String, Object>> faces;
        double[] bbox; // {w, h} 未旋转未缩放时的包围盒
        ShapeArt(List<Map<String, Object>> faces, double w, double h) {
            this.faces = faces;
            this.bbox = new double[]{w, h};
        }
    }
    private static Map<String, Object> poly(String pts, String fill, String stroke) {
        Map<String, Object> f = new LinkedHashMap<>();
        f.put("type", "poly");
        f.put("pts", pts);
        f.put("fill", fill);
        f.put("stroke", stroke);
        f.put("sw", 1.2);
        return f;
    }
    private static Map<String, Object> ellipse(double cx, double cy, double rx, double ry,
                                               String fill, String stroke, double opacity) {
        Map<String, Object> f = new LinkedHashMap<>();
        f.put("type", "ellipse");
        f.put("cx", cx);
        f.put("cy", cy);
        f.put("rx", rx);
        f.put("ry", ry);
        f.put("fill", fill);
        f.put("stroke", stroke);
        f.put("sw", opacity > 0 ? 0 : 1.2);
        f.put("opacity", opacity);
        return f;
    }
    private static Map<String, Object> path(String d, String fill, String stroke) {
        Map<String, Object> f = new LinkedHashMap<>();
        f.put("type", "path");
        f.put("d", d);
        f.put("fill", fill);
        f.put("stroke", stroke);
        f.put("sw", 1.2);
        return f;
    }
    private static String hsl(int h, int s, int l) {
        return "hsl(" + h + "," + s + "%," + l + "%)";
    }
    private static Map<String, Object> stop(double offset, String color) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("o", offset);
        m.put("c", color);
        return m;
    }
    private static String shade(int h, int s, int l, int delta) {
        int v = Math.max(6, Math.min(94, l + delta));
        return hsl(h, s, v);
    }
    private static String fmt(double d) {
        return String.format(Locale.ROOT, "%.1f", d);
    }
    private static String pts(double... p) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < p.length; i += 2) {
            if (sb.length() > 0) {
                sb.append(' ');
            }
            sb.append(fmt(p[i])).append(',').append(fmt(p[i + 1]));
        }
        return sb.toString();
    }
    private static ShapeArt cube(int h, int s, int l) {
        double side = 26, d = 13, cy = d / 2;
        String fillTop = shade(h, s, l, 18);
        String fillFront = hsl(h, s, l);
        String fillRight = shade(h, s, l, -14);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2 + d, -cy - d, -side / 2 + d, -cy - d), fillTop, stroke));
        faces.add(poly(pts(side / 2, -cy, side / 2, -cy + side, side / 2 + d, -cy + side - d, side / 2 + d, -cy - d), fillRight, stroke));
        faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2, -cy + side, -side / 2, -cy + side), fillFront, stroke));
        return new ShapeArt(faces, side + d, side + d);
    }
    private static ShapeArt box(int h, int s, int l) {
        double w = 34, height = 22, d = 14, cy = d / 2;
        String fillTop = shade(h, s, l, 18);
        String fillFront = hsl(h, s, l);
        String fillRight = shade(h, s, l, -14);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2 + d, -cy - d, -w / 2 + d, -cy - d), fillTop, stroke));
        faces.add(poly(pts(w / 2, -cy, w / 2, -cy + height, w / 2 + d, -cy + height - d, w / 2 + d, -cy - d), fillRight, stroke));
        faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2, -cy + height, -w / 2, -cy + height), fillFront, stroke));
        return new ShapeArt(faces, w + d, height + d);
    }
    private static ShapeArt cylinder(int h, int s, int l) {
        double r = 18, height = 34;
        String fillSide = hsl(h, s, l);
        String fillTop = shade(h, s, l, 18);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        String side = "M" + fmt(-r) + "," + fmt(-height / 2)
                + " L" + fmt(-r) + "," + fmt(height / 2)
                + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(r) + "," + fmt(height / 2)
                + " L" + fmt(r) + "," + fmt(-height / 2)
                + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(-r) + "," + fmt(-height / 2) + " Z";
        faces.add(path(side, fillSide, stroke));
        faces.add(ellipse(0, -height / 2, r, r * 0.45, fillTop, stroke, 1));
        return new ShapeArt(faces, r * 2, height + r * 0.9);
    }
    private static ShapeArt cone(int h, int s, int l) {
        double r = 20, height = 38;
        double apexY = -height;
        double cy = (-height + r * 0.45) / 2;
        String fillBase = shade(h, s, l, -14);
        String fillSide = hsl(h, s, l);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(ellipse(0, -cy, r, r * 0.45, fillBase, stroke, 1));
        String side = "M" + fmt(0) + "," + fmt(apexY - cy)
                + " L" + fmt(-r) + "," + fmt(-cy)
                + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,1," + fmt(r) + "," + fmt(-cy) + " Z";
        faces.add(path(side, fillSide, stroke));
        return new ShapeArt(faces, r * 2, height + r * 0.45);
    }
    private static ShapeArt sphere(int h, int s, int l) {
        double r = 21;
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        Map<String, Object> grad = new LinkedHashMap<>();
        grad.put("id", "g");
        grad.put("cx", 0.35);
        grad.put("cy", 0.32);
        grad.put("r", 0.85);
        List<Map<String, Object>> stops = new ArrayList<>();
        stops.add(stop(0, shade(h, s, l, 26)));
        stops.add(stop(0.5, hsl(h, s, l)));
        stops.add(stop(1, shade(h, s, l, -20)));
        grad.put("stops", stops);
        Map<String, Object> f = new LinkedHashMap<>();
        f.put("type", "radial");
        f.put("cx", 0.0);
        f.put("cy", 0.0);
        f.put("r", r);
        f.put("grad", grad);
        f.put("stroke", stroke);
        f.put("sw", 1.2);
        faces.add(f);
        faces.add(ellipse(-r * 0.38, -r * 0.45, r * 0.3, r * 0.2, "#ffffff", "none", 0.65));
        faces.add(ellipse(r * 0.25, r * 0.45, r * 0.55, r * 0.2, shade(h, s, l, -32), "none", 0.45));
        return new ShapeArt(faces, r * 2, r * 2);
    }
    private static ShapeArt triPrism(int h, int s, int l) {
        double w = 34, height = 30, depth = 12;
        double cy = (depth * 0.5 + height) / 2;
        String fillTop = shade(h, s, l, 18);
        String fillRight = hsl(h, s, l);
        String fillLeft = shade(h, s, l, -14);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, -w / 2, -cy), fillTop, stroke));
        faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillRight, stroke));
        faces.add(poly(pts(0, -cy + depth * 0.5, -w / 2, -cy, -w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillLeft, stroke));
        return new ShapeArt(faces, w, depth * 0.5 + height);
    }
    private static ShapeArt pyramid(int h, int s, int l) {
        double half = 26, height = 34;
        double cy = (-height + half) / 2;
        String fillBase = shade(h, s, l, -20);
        String fillRight = hsl(h, s, l);
        String fillLeft = shade(h, s, l, -10);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(poly(pts(0, -cy, half, -cy + half * 0.5, 0, -cy + half, -half, -cy + half * 0.5), fillBase, stroke));
        faces.add(poly(pts(0, -cy - height, 0, -cy, -half, -cy + half * 0.5), fillLeft, stroke));
        faces.add(poly(pts(0, -cy - height, 0, -cy, half, -cy + half * 0.5), fillRight, stroke));
        return new ShapeArt(faces, half * 2, height + half);
    }
    private static ShapeArt hexPrism(int h, int s, int l) {
        double r = 22, height = 28, ry = r * 0.433;
        double cy = ry;
        String fillTop = shade(h, s, l, 18);
        String fillR = hsl(h, s, l);
        String fillF = shade(h, s, l, -12);
        String fillL = shade(h, s, l, -22);
        String stroke = shade(h, s, l, -30);
        List<Map<String, Object>> faces = new ArrayList<>();
        faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r, -cy,
                -r * 0.5, -cy - ry, r * 0.5, -cy - ry), fillTop, stroke));
        faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, r * 0.5, -cy + ry + height, r, -cy + height), fillR, stroke));
        faces.add(poly(pts(r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r * 0.5, -cy + ry + height, r * 0.5, -cy + ry + height), fillF, stroke));
        faces.add(poly(pts(-r * 0.5, -cy + ry, -r, -cy, -r, -cy + height, -r * 0.5, -cy + ry + height), fillL, stroke));
        return new ShapeArt(faces, r * 2, ry * 2 + height);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
@@ -1,11 +1,14 @@
package com.trafficaudit.security.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.security.captcha.CaptchaService;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@@ -15,10 +18,26 @@
    @Resource
    private JwtUtils jwtUtils;
    @Resource
    private CaptchaService captchaService;
    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody Map<String, String> params) {
        String username = params.get("username");
        String password = params.get("password");
    public Result<Map<String, Object>> login(@RequestBody Map<String, Object> params) {
        String username = (String) params.get("username");
        String password = (String) params.get("password");
        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 ("admin".equals(username) && "admin123".equals(password)) {
            String token = jwtUtils.generateToken(username);
@@ -29,4 +48,12 @@
        }
        return Result.error(401, "用户名或密码错误");
    }
    @SuppressWarnings("unchecked")
    private List<String> strList(Object o) {
        if (o instanceof List) {
            return (List<String>) o;
        }
        return Collections.emptyList();
    }
}
traffic-audit-web/src/components/AppHeader.vue
New file
@@ -0,0 +1,65 @@
<template>
  <el-header class="app-header">
    <span class="header-title">{{ title }}</span>
    <div class="header-right">
      <span class="header-user">
        <i class="el-icon-user"></i> {{ username }}
      </span>
      <el-button type="text" class="header-logout" @click="logout">
        <i class="el-icon-switch-button"></i> 退出登录
      </el-button>
    </div>
  </el-header>
</template>
<script>
export default {
  name: 'AppHeader',
  props: {
    title: { type: String, required: true }
  },
  computed: {
    username() {
      return localStorage.getItem('username') || 'admin'
    }
  },
  methods: {
    logout() {
      this.$confirm('确定退出登录吗?', '提示', {
        confirmButtonText: '退出',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        localStorage.removeItem('token')
        localStorage.removeItem('username')
        this.$message.success('已退出登录')
        this.$router.push('/login')
      }).catch(() => {})
    }
  }
}
</script>
<style scoped>
.app-header {
  background: #409EFF;
  color: #fff;
  line-height: 60px;
  font-size: 18px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  flex-shrink: 0;
}
.header-right {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 14px;
  line-height: normal;
}
.header-user { opacity: 0.92; }
.header-logout { color: #fff; font-size: 14px; padding: 0; }
.header-logout:hover { color: #ffd04b; }
</style>
traffic-audit-web/src/components/Captcha3D.vue
New file
@@ -0,0 +1,244 @@
<template>
  <div class="captcha-box">
    <div class="captcha-prompt">
      <span class="prompt-label">请依次点击:</span>
      <span v-for="(t, i) in promptItems" :key="i" class="prompt-item" :style="{ background: promptColors[i % promptColors.length] }">{{ t }}</span>
      <el-button type="text" icon="el-icon-refresh" class="refresh-btn" :loading="loading" @click="load">换一组</el-button>
    </div>
    <svg ref="svg" class="captcha-svg" :viewBox="'0 0 ' + CANVAS_W + ' ' + CANVAS_H" @click="onSvgClick">
      <defs>
        <pattern v-if="challenge" :id="patId" :width="challenge.background.patternSize" :height="challenge.background.patternSize" patternUnits="userSpaceOnUse">
          <g v-if="challenge.background.pattern === 'dots'">
            <circle :cx="challenge.background.patternSize / 2" :cy="challenge.background.patternSize / 2" r="1.2" :fill="patColor" />
          </g>
          <g v-else-if="challenge.background.pattern === 'grid'">
            <path :d="'M0 ' + challenge.background.patternSize / 2 + ' H' + challenge.background.patternSize + ' M' + challenge.background.patternSize / 2 + ' 0 V' + challenge.background.patternSize" :stroke="patColor" stroke-width="0.7" />
          </g>
          <g v-else-if="challenge.background.pattern === 'lines'">
            <line x1="0" :y1="challenge.background.patternSize" :x2="challenge.background.patternSize" y2="0" :stroke="patColor" stroke-width="1" />
          </g>
          <g v-else-if="challenge.background.pattern === 'rings'">
            <circle :cx="challenge.background.patternSize / 2" :cy="challenge.background.patternSize / 2" r="1.8" fill="none" :stroke="patColor" stroke-width="1" />
          </g>
          <g v-else-if="challenge.background.pattern === 'cross'">
            <path :d="'M0 0 L' + challenge.background.patternSize + ' ' + challenge.background.patternSize + ' M0 ' + challenge.background.patternSize + ' L' + challenge.background.patternSize + ' 0'" :stroke="patColor" stroke-width="0.7" />
          </g>
          <g v-else-if="challenge.background.pattern === 'tri'">
            <polygon :points="'0 0 ' + challenge.background.patternSize + ' 0 ' + challenge.background.patternSize / 2 + ' ' + challenge.background.patternSize" :fill="patColor" />
          </g>
        </pattern>
        <template v-for="s in radialShapes">
          <radialGradient v-for="f in radialFaces(s)" :key="'grad-' + s.id + '-' + f.grad.id"
            :id="s.id + '-' + f.grad.id" :cx="f.grad.cx" :cy="f.grad.cy" :r="f.grad.r">
            <stop v-for="(st, si) in f.grad.stops" :key="si" :offset="st.o" :stop-color="st.c" />
          </radialGradient>
        </template>
      </defs>
      <template v-if="challenge">
        <rect :width="CANVAS_W" :height="CANVAS_H" :fill="bgColor" />
        <rect :width="CANVAS_W" :height="CANVAS_H" :fill="'url(#' + patId + ')'" />
        <g v-for="s in challenge.shapes" :key="s.id" class="shape"
           :transform="'translate(' + s.x + ',' + s.y + ') rotate(' + s.rotate + ') scale(' + s.size + ')'">
          <template v-for="(f, fi) in s.faces">
            <polygon v-if="f.type === 'poly'" :key="'p' + fi" :points="f.pts" :fill="f.fill" :stroke="f.stroke" :stroke-width="f.sw" stroke-linejoin="round" />
            <ellipse v-else-if="f.type === 'ellipse'" :key="'e' + fi" :cx="f.cx" :cy="f.cy" :rx="f.rx" :ry="f.ry" :fill="f.fill" :stroke="f.stroke" :stroke-width="f.sw" :opacity="f.opacity" />
            <path v-else-if="f.type === 'path'" :key="'d' + fi" :d="f.d" :fill="f.fill" :stroke="f.stroke" :stroke-width="f.sw" stroke-linejoin="round" />
            <circle v-else-if="f.type === 'radial'" :key="'c' + fi" :cx="f.cx" :cy="f.cy" :r="f.r" :fill="'url(#' + s.id + '-' + f.grad.id + ')'" :stroke="f.stroke" :stroke-width="f.sw" />
          </template>
        </g>
        <g v-for="(sid, idx) in selectedIds" :key="'sel' + sid">
          <circle :cx="shapeById[sid].x" :cy="shapeById[sid].y" r="10" fill="#409EFF" stroke="#fff" stroke-width="2" />
          <text :x="shapeById[sid].x" :y="shapeById[sid].y + 4" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">{{ idx + 1 }}</text>
        </g>
      </template>
    </svg>
    <div class="captcha-status" :class="{ ok: verified, err: !!errorMsg }">
      <span v-if="loading"><i class="el-icon-loading"></i> 加载中...</span>
      <span v-else-if="verified"><i class="el-icon-success"></i> 验证通过</span>
      <span v-else-if="errorMsg"><i class="el-icon-warning"></i> {{ errorMsg }}</span>
      <span v-else class="muted">请按提示顺序点击对应图形</span>
    </div>
  </div>
</template>
<script>
import axios from 'axios'
export default {
  name: 'Captcha3D',
  data() {
    return {
      CANVAS_W: 340,
      CANVAS_H: 148,
      challenge: null,
      loading: false,
      selectedIds: [],
      verified: false,
      errorMsg: '',
      promptColors: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9C27B0']
    }
  },
  computed: {
    promptItems() {
      return this.challenge ? this.challenge.prompt : []
    },
    patId() {
      return this.challenge ? 'bgpat-' + this.challenge.captchaId : 'bgpat'
    },
    bgColor() {
      if (!this.challenge) return '#f0f2f5'
      const b = this.challenge.background
      return 'hsl(' + b.hue + ',' + b.sat + '%,' + b.light + '%)'
    },
    patColor() {
      if (!this.challenge) return 'rgba(0,0,0,0.2)'
      const b = this.challenge.background
      return 'hsla(' + b.patternHue + ',' + b.patternSat + '%,' + b.patternLight + '%,' + b.patternOpacity + ')'
    },
    shapeById() {
      const m = {}
      if (this.challenge) {
        this.challenge.shapes.forEach(s => { m[s.id] = s })
      }
      return m
    },
    radialShapes() {
      return this.challenge ? this.challenge.shapes : []
    }
  },
  mounted() {
    this.load()
  },
  methods: {
    radialFaces(s) {
      return s.faces.filter(f => f.type === 'radial')
    },
    load() {
      this.loading = true
      this.verified = false
      this.selectedIds = []
      this.errorMsg = ''
      this.$emit('reset')
      axios.get('/api/auth/captcha')
        .then(res => {
          this.loading = false
          if (res.data.code === 200) {
            this.challenge = res.data.data
          } else {
            this.errorMsg = '验证码加载失败,请刷新重试'
          }
        })
        .catch(() => {
          this.loading = false
          this.errorMsg = '验证码加载失败,请检查网络'
        })
    },
    onSvgClick(ev) {
      if (!this.challenge || this.verified || this.loading) return
      const rect = this.$refs.svg.getBoundingClientRect()
      const x = (ev.clientX - rect.left) * (this.CANVAS_W / rect.width)
      const y = (ev.clientY - rect.top) * (this.CANVAS_H / rect.height)
      const hit = this.hitShape(x, y)
      if (!hit) return
      const idx = this.selectedIds.indexOf(hit.id)
      if (idx >= 0) {
        this.selectedIds.splice(idx, 1)
      } else {
        this.selectedIds.push(hit.id)
      }
      if (this.selectedIds.length === this.promptItems.length && this.promptItems.length > 0) {
        this.doVerify()
      }
    },
    hitShape(x, y) {
      let best = null
      let bestDist = Infinity
      this.challenge.shapes.forEach(s => {
        const rad = -s.rotate * Math.PI / 180
        const dx = x - s.x
        const dy = y - s.y
        const rx = dx * Math.cos(rad) - dy * Math.sin(rad)
        const ry = dx * Math.sin(rad) + dy * Math.cos(rad)
        const hw = s.hitW * s.size / 2
        const hh = s.hitH * s.size / 2
        if (Math.abs(rx) <= hw && Math.abs(ry) <= hh) {
          const d = dx * dx + dy * dy
          if (d < bestDist) {
            bestDist = d
            best = s
          }
        }
      })
      return best
    },
    doVerify() {
      this.loading = true
      axios.post('/api/auth/captcha/verify', {
        captchaId: this.challenge.captchaId,
        captchaClickIds: this.selectedIds.slice()
      })
        .then(res => {
          this.loading = false
          const d = res.data
          if (d.code === 200) {
            this.verified = true
            this.errorMsg = ''
            this.$emit('verified', {
              captchaId: this.challenge.captchaId,
              clickIds: this.selectedIds.slice()
            })
          } else if (d.code === 4001) {
            this.selectedIds = []
            this.errorMsg = d.message || '顺序不对,请重新点击'
          } else {
            this.errorMsg = d.message || '验证失败,已更换题目'
            this.load()
          }
        })
        .catch(() => {
          this.loading = false
          this.errorMsg = '网络错误,请重试'
        })
    }
  }
}
</script>
<style scoped>
.captcha-box {
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  padding: 6px;
  background: #fff;
}
.captcha-prompt {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 5px;
  font-size: 12px;
}
.prompt-label { color: #606266; }
.prompt-item {
  color: #fff;
  border-radius: 3px;
  padding: 2px 8px;
  font-weight: bold;
}
.refresh-btn { margin-left: auto; }
.captcha-svg {
  display: block;
  width: 100%;
  height: auto;
  cursor: pointer;
  user-select: none;
  border-radius: 3px;
}
.shape { transition: filter 0.15s; }
.shape:hover { filter: brightness(1.15); }
.captcha-status { margin-top: 4px; font-size: 12px; min-height: 14px; }
.captcha-status.ok { color: #67C23A; }
.captcha-status.err { color: #F56C6C; }
.captcha-status .muted { color: #909399; }
</style>
traffic-audit-web/src/router/index.js
@@ -3,7 +3,7 @@
Vue.use(Router)
export default new Router({
const router = new Router({
  routes: [
    { path: '/', redirect: '/login' },
    { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
@@ -17,3 +17,14 @@
    { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') }
  ]
})
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  if (to.path === '/login') {
    next(token ? '/dashboard' : undefined)
  } else {
    next(token ? undefined : '/login')
  }
})
export default router
traffic-audit-web/src/views/AuditResult.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 审核结果</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 审核结果"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/audit">
@@ -71,8 +71,10 @@
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'AuditResult',
  data() {
    return {
@@ -138,5 +140,4 @@
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
traffic-audit-web/src/views/Dashboard.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 工作台</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 工作台"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/dashboard">
@@ -37,9 +37,10 @@
  </el-container>
</template>
<script>
export default { name: 'Dashboard' }
import AppHeader from '@/components/AppHeader.vue'
export default {
  components: { AppHeader }, name: 'Dashboard' }
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
.stat-num { font-size: 36px; color: #409EFF; }
</style>
traffic-audit-web/src/views/DataImport.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 数据导入</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 数据导入"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/import">
@@ -53,8 +53,10 @@
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'DataImport',
  data() {
    return {
@@ -111,6 +113,5 @@
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
.tip { font-size: 12px; color: #909399; margin-top: 6px; }
</style>
traffic-audit-web/src/views/DataView.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 数据查看</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 数据查看"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/data">
@@ -58,8 +58,10 @@
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'DataView',
  data() {
    return {
@@ -111,5 +113,4 @@
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
traffic-audit-web/src/views/ExplainReview.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 企业解释</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 企业解释"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/explain">
@@ -61,11 +61,12 @@
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
import AiChatDialog from '@/components/AiChatDialog'
export default {
  name: 'ExplainReview',
  components: { AiChatDialog },
  components: { AppHeader, AiChatDialog },
  data() {
    return {
      period: '', keyword: '', page: 1, size: 20, total: 0, records: [], loading: false,
@@ -114,5 +115,4 @@
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
traffic-audit-web/src/views/Login.vue
@@ -1,4 +1,4 @@
<template>
<template>
  <div class="login-container">
    <el-card class="login-card">
      <h2>交通统计报表审核系统</h2>
@@ -10,37 +10,89 @@
          <el-input v-model="form.password" type="password" placeholder="密码" @keyup.enter.native="login"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="login" style="width:100%">登 录</el-button>
          <captcha3-d ref="captcha" @verified="onCaptchaVerified" @reset="onCaptchaReset"></captcha3-d>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :loading="loggingIn" @click="login" style="width:100%">登 录</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>
<script>
import axios from 'axios'
import Captcha3D from '@/components/Captcha3D.vue'
export default {
  name: 'Login',
  components: { Captcha3D },
  data() {
    return {
      form: { username: '', password: '' },
      rules: {
        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
        password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
      }
      },
      captchaVerified: false,
      captchaId: '',
      clickIds: [],
      loggingIn: false
    }
  },
  methods: {
    onCaptchaVerified(payload) {
      this.captchaVerified = true
      this.captchaId = payload.captchaId
      this.clickIds = payload.clickIds
    },
    onCaptchaReset() {
      this.captchaVerified = false
      this.captchaId = ''
      this.clickIds = []
    },
    login() {
      this.$refs.form.validate(valid => {
        if (valid) {
          this.$router.push('/dashboard')
        if (!valid) return
        if (!this.captchaVerified) {
          this.$message.warning('请先完成行为验证')
          return
        }
        this.loggingIn = true
        axios.post('/api/auth/login', {
          username: this.form.username,
          password: this.form.password,
          captchaId: this.captchaId,
          captchaClickIds: this.clickIds
        }).then(res => {
          this.loggingIn = false
          const d = res.data
          if (d.code === 200) {
            localStorage.setItem('token', d.data.token)
            localStorage.setItem('username', d.data.username)
            this.$message.success('登录成功')
            this.$router.push('/dashboard')
          } else if (d.code === 401) {
            this.$message.error(d.message || '用户名或密码错误')
            this.$refs.captcha.load()
          } else if (d.code === 4001 || d.code === 4002 || d.code === 4003) {
            this.$message.error(d.message || '验证未通过,请重新验证')
            this.$refs.captcha.load()
          } else {
            this.$message.error(d.message || '登录失败,请重试')
            this.$refs.captcha.load()
          }
        }).catch(() => {
          this.loggingIn = false
          this.$message.error('网络错误,登录失败')
        })
      })
    }
  }
}
</script>
<style scoped>
.login-container { display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5; }
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px 0; box-sizing: border-box; background: #f0f2f5; }
.login-card { width: 400px; }
.login-card h2 { text-align: center; margin-bottom: 30px; }
.login-card h2 { text-align: center; margin-bottom: 18px; }
.login-card .el-form-item { margin-bottom: 16px; }
</style>
traffic-audit-web/src/views/ReportExport.vue
@@ -1,6 +1,6 @@
<template>
  <el-container>
    <el-header>交通统计报表审核系统 - 报表生成</el-header>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - 报表生成"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu router default-active="/report">
@@ -40,8 +40,10 @@
  </el-container>
</template>
<script>
import AppHeader from '@/components/AppHeader.vue'
import api from '@/api'
export default {
  components: { AppHeader },
  name: 'ReportExport',
  data() {
    return { period: '', loading: '' }
@@ -78,5 +80,4 @@
}
</script>
<style scoped>
.el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; }
</style>
traffic-audit-web/vue.config.js
@@ -2,6 +2,7 @@
  parallel: false,
  devServer: {
    port: 8080,
    headers: { "Cache-Control": "no-store" },
    proxy: {
      "/api": {
        target: "http://localhost:8090",