feat: 登录行为验证码(3D 立体图形按序点击)+ AppHeader 登出与上下布局 + 态势图构想记录(08-27 会话)
| | |
| | | > **用法**:新对话开始后,先读本文件 + 项目根目录 `AGENTS.md`,即可无缝接力。 |
| | | > **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。 |
| | | |
| | | ## 一、当前状态(2026-08-23) |
| | | - 前后端**均未运行**(8080/8090 连接被拒),需要先启动(见下)。 |
| | | - 最近代码改动截至 08-07;08-14 起代码已入库 Gitee 私有仓库(见第九节),main 分支工作区干净、已推送。 |
| | | ## 一、当前状态(2026-08-27) |
| | | - 前后端**运行中**(8090/8080 已监听,start-dev.ps1 启动),登录页已接入真实认证 + 行为验证码。 |
| | | - 08-27 行为验证码改动**未提交**(工作区有改动,见第十节);此前的代码已入库 Gitee 私有仓库(见第九节)。 |
| | | - 无已知阻塞性问题。 |
| | | |
| | | ## 二、快速启动 |
| | |
| | | - 每日反思自动化尚未创建(提示词已备好,见 `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/` 与数据库表结构。 |
| | |
| | | ## 十、会话记录 |
| | | - 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。 |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | @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); |
| | |
| | | } |
| | | return Result.error(401, "用户名或密码错误"); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private List<String> strList(Object o) { |
| | | if (o instanceof List) { |
| | | return (List<String>) o; |
| | | } |
| | | return Collections.emptyList(); |
| | | } |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| | |
| | | |
| | | Vue.use(Router) |
| | | |
| | | export default new Router({ |
| | | const router = new Router({ |
| | | routes: [ |
| | | { path: '/', redirect: '/login' }, |
| | | { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, |
| | |
| | | { 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 |
| | |
| | | <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"> |
| | |
| | | </el-container> |
| | | </template> |
| | | <script> |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import api from '@/api' |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'AuditResult', |
| | | data() { |
| | | return { |
| | |
| | | } |
| | | </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="/dashboard"> |
| | |
| | | </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> |
| | |
| | | <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"> |
| | |
| | | </el-container> |
| | | </template> |
| | | <script> |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import api from '@/api' |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'DataImport', |
| | | data() { |
| | | return { |
| | |
| | | } |
| | | </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> |
| | |
| | | <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"> |
| | |
| | | </el-container> |
| | | </template> |
| | | <script> |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import api from '@/api' |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'DataView', |
| | | data() { |
| | | return { |
| | |
| | | } |
| | | </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="/explain"> |
| | |
| | | </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, |
| | |
| | | } |
| | | </script> |
| | | <style scoped> |
| | | .el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; } |
| | | </style> |
| | |
| | | <template> |
| | | <template> |
| | | <div class="login-container"> |
| | | <el-card class="login-card"> |
| | | <h2>交通统计报表审核系统</h2> |
| | |
| | | <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> |
| | |
| | | <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"> |
| | |
| | | </el-container> |
| | | </template> |
| | | <script> |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import api from '@/api' |
| | | export default { |
| | | components: { AppHeader }, |
| | | name: 'ReportExport', |
| | | data() { |
| | | return { period: '', loading: '' } |
| | |
| | | } |
| | | </script> |
| | | <style scoped> |
| | | .el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; } |
| | | </style> |
| | |
| | | parallel: false, |
| | | devServer: { |
| | | port: 8080, |
| | | headers: { "Cache-Control": "no-store" }, |
| | | proxy: { |
| | | "/api": { |
| | | target: "http://localhost:8090", |