From 715e9e12d8eaa4439110e224afaae7b3ddbd3e6d Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期四, 27 八月 2026 14:52:57 +0800
Subject: [PATCH] feat: 登录行为验证码(3D 立体图形按序点击)+ AppHeader 登出与上下布局 + 态势图构想记录(08-27 会话)
---
traffic-audit-web/src/views/Dashboard.vue | 9
traffic-audit-web/src/views/ReportExport.vue | 7
traffic-audit-web/src/router/index.js | 13
traffic-audit-web/src/views/DataImport.vue | 7
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java | 33 +
traffic-audit-web/vue.config.js | 1
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java | 51 +++
traffic-audit-web/src/components/AppHeader.vue | 65 +++
traffic-audit-web/src/views/ExplainReview.vue | 8
HANDOFF.md | 12
traffic-audit-web/src/views/Login.vue | 66 +++
traffic-audit-web/src/components/Captcha3D.vue | 244 ++++++++++++++
traffic-audit-web/src/views/DataView.vue | 7
traffic-audit-web/src/views/AuditResult.vue | 7
traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java | 444 ++++++++++++++++++++++++++
15 files changed, 940 insertions(+), 34 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index ac6761b..f3a5b19 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -3,9 +3,9 @@
> **鐢ㄦ硶**锛氭柊瀵硅瘽寮�濮嬪悗锛屽厛璇绘湰鏂囦欢 + 椤圭洰鏍圭洰褰� `AGENTS.md`锛屽嵆鍙棤缂濇帴鍔涖��
> **姣忓ぉ缁撴潫鏃�**锛氭妸褰撳ぉ杩涘睍銆佽俯杩囩殑鍧戙�佹柊闇�姹傘�佹湭瀹屾垚浜嬮」鏇存柊鍒版湰鏂囦欢锛岀劧鍚庡彲浠ユ斁蹇冨紑鏂板璇濄��
-## 涓�銆佸綋鍓嶇姸鎬侊紙2026-08-23锛�
-- 鍓嶅悗绔�**鍧囨湭杩愯**锛�8080/8090 杩炴帴琚嫆锛夛紝闇�瑕佸厛鍚姩锛堣涓嬶級銆�
-- 鏈�杩戜唬鐮佹敼鍔ㄦ埅鑷� 08-07锛�08-14 璧蜂唬鐮佸凡鍏ュ簱 Gitee 绉佹湁浠撳簱锛堣绗節鑺傦級锛宮ain 鍒嗘敮宸ヤ綔鍖哄共鍑�銆佸凡鎺ㄩ�併��
+## 涓�銆佸綋鍓嶇姸鎬侊紙2026-08-27锛�
+- 鍓嶅悗绔�**杩愯涓�**锛�8090/8080 宸茬洃鍚紝start-dev.ps1 鍚姩锛夛紝鐧诲綍椤靛凡鎺ュ叆鐪熷疄璁よ瘉 + 琛屼负楠岃瘉鐮併��
+- 08-27 琛屼负楠岃瘉鐮佹敼鍔�**鏈彁浜�**锛堝伐浣滃尯鏈夋敼鍔紝瑙佺鍗佽妭锛夛紱姝ゅ墠鐨勪唬鐮佸凡鍏ュ簱 Gitee 绉佹湁浠撳簱锛堣绗節鑺傦級銆�
- 鏃犲凡鐭ラ樆濉炴�ч棶棰樸��
## 浜屻�佸揩閫熷惎鍔�
@@ -56,6 +56,8 @@
- 姣忔棩鍙嶆�濊嚜鍔ㄥ寲灏氭湭鍒涘缓锛堟彁绀鸿瘝宸插濂斤紝瑙� `docs/姣忔棩鍙嶆�濅换鍔�.md`锛涢渶瑕� automation 宸ュ叿鍙敤鐨勪細璇濓級銆�
- 瀹樻柟 Codex 鎵嬪唽鏈満璁块棶 403锛宍docs/Codex鍔熻兘娓呭崟.md` 寰呯綉缁滄仮澶嶅悗琛ュ畼鏂规牳瀹炵増銆�
- 鍚庣画鎵╁睍锛欻203-1 鍏矾鏃呭銆乄203 姘磋矾銆丳203 娓彛銆丠204 鑳芥簮绛夛紙闇�姹傛枃妗� v2/v3 璁″垝锛夛紝鍏堟妸 H203-2 婕旂ず鍋氬ソ銆�
+- 銆愭柊闇�姹偮蜂粎璁板綍鏈疄鐜奥�2026-08-23銆戠櫥褰曞悗棣栭〉鏂板銆屽叏鐪佷氦閫氳繍杈撴�佸娍鎰熺煡鍥俱�嶏細鎶婅揣杩愩�佸杩愩�佸贰娓稿嚭绉熴�佺綉绾﹁溅銆佹姇璧勩�佽兘鑰楃瓑鏂归潰鐨勫彉鍖栨�佸娍鍙鍖栧睍绀恒��
+- 銆愭柊闇�姹偮蜂粎璁板綍鏈疄鐜奥�2026-08-23銆戞墿灞曠幇鏈� AI 鍒嗘瀽涓恒�孉I 鍔╂墜銆嶏細浠ユ湰鍦版暟鎹簱鏁版嵁涓哄熀纭�寤虹珛鐭ヨ瘑搴擄紝鍋� RAG锛屾敮鎸佽嚜鐒惰瑷�鎻愰棶/鑱婂ぉ锛岀伒娲绘煡鐪嬩笌鎸栨帢鏁版嵁銆�
## 鍏�佹瘡鏃ヤ氦鎺ョ害瀹�
1. 鏂板璇濈涓�姝ワ細璇� `AGENTS.md` + `HANDOFF.md`锛涙秹鍙� AI 鍒嗘瀽/瀹℃牳鏃跺弬鑰� `docs/` 涓庢暟鎹簱琛ㄧ粨鏋勩��
@@ -71,3 +73,7 @@
## 鍗併�佷細璇濊褰�
- 2026-08-14锛氬惎鍔ㄥ墠鍚庣楠岃瘉姝e父锛堝悗鍧囧凡鍋滄锛夛紱寤虹珛 git 浠撳簱锛堟柊澧� `.gitignore`銆佽劚鏁忔ā鏉� `application.yml.example`锛岀湡瀹� `application.yml` 涓嶅叆搴擄級锛屽垱寤� Gitee 绉佹湁浠撳簱 `jichengxiong/trafficAudit` 骞舵帹閫� `main`锛�2 commits锛氬垵濮嬫彁浜� + HANDOFF 琛ュ厖 git 璇存槑锛夈��
- 2026-08-23锛氫氦鎺ュ墠鏍稿疄鈥斺�攇it 宸ヤ綔鍖哄共鍑�銆佽繙绔凡鍚屾锛涘墠鍚庣鍧囨湭鍦ㄨ繍琛屻��
+- 2026-08-23锛氫氦鎺ュ悗鍚姩鍓嶅悗绔獙璇佹甯革紙8090/8080 宸茬洃鍚級锛涜褰曚袱鏉℃柊闇�姹傦紙鎬佸娍鎰熺煡鍥俱�丄I 鍔╂墜 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 鎺ュ叆鐪熷疄鐧诲綍锛坅xios+token+localStorage锛夛紝router 鍔犵櫥褰曞畧鍗�傚疄娴嬮�氳繃锛氭纭『搴忛獙璇佹垚鍔熴�侀敊璇『搴忔彁绀洪噸璇曘�佹湭楠岃瘉鎷︽埅銆侀敊瀵嗙爜 401銆佺櫥褰曡烦杞伐浣滃彴銆傚潙锛歏ue2 缁勪欢鍚嶅ぇ灏忓啓鏁忔劅锛岀粍浠舵敞鍐� Captcha3D 鏃舵ā鏉垮繀椤诲啓 <captcha3-d>锛�<captcha3d> 浼氳瑙f瀽鎴� Captcha3d 鍖归厤涓嶄笂锛夈��
+- 2026-08-27锛堢画锛夛細鐧诲綍椤佃瑙変笌鐧诲嚭鈥斺�旈獙璇佺爜鐢诲竷 340x190 缂╀负 340x148銆佸浘褰� size 0.8~1.2 缂╀负 0.6~0.95銆侀棿璺�/鎶栧姩鏀剁揣銆佹棆杞� 卤14掳锛涚櫥褰曞崱鐗� 420鈫�400 瀹姐�乫orm 闂磋窛鏀剁揣锛堝崱鐗囬珮绾� 559px锛夈�傜珛鏂逛綋/闀挎柟浣撴敼涓烘枩浜屾祴鏁欑涔︾敾娉曪紙鍓嶉潰姝f柟褰�+椤�/渚ф枩鍑猴紝鏇寸珛浣擄級銆傛柊澧炲叕鍏� components/AppHeader.vue锛堟爣棰�+褰撳墠鐢ㄦ埛+閫�鍑虹櫥褰曪紝閫�鍑烘竻 token 鍥炵櫥褰曢〉锛夛紝鏇挎崲 6 涓笟鍔¢〉鍘熸湁 el-header銆傚潙锛氭壒閲忔彃鍏� components 鏃� ExplainReview 鍘熸湁 components: { AiChatDialog } 琚鐩栵紙閲嶅閿級锛岄渶鍚堝苟涓� { AppHeader, AiChatDialog }銆傚彟锛歅owerShell 鍙屽紩鍙峰瓧绗︿覆閲岀殑鍙嶅紩鍙蜂細琚悶鎺夛紝鍐� markdown 浠g爜鏍囪瑕侀伩鍏嶃��
+- 2026-08-27锛堢画2锛夛細鍏1鏌辨敼涓烘爣鍑嗙瓑杞存祴姝e叚杈瑰舰椤堕潰+3 渚ч潰锛堟鍓嶉《闈㈣繃鎵佽涓嶅嚭锛夛紱鐞冩敼涓哄緞鍚戞笎鍙橈紙浜啋鏆楋紝鍓嶇娓叉煋鍣ㄦ柊澧� radial 娓愬彉鏀寔锛実radient id 鐢� shape id 鍘婚噸锛夈�備慨甯冨眬鍥炲綊锛欰ppHeader 鏄嚜瀹氫箟缁勪欢锛宔l-container 妫�娴嬩笉鍒� el-header 瀛愮粍浠跺鑷村灞傚鍣ㄥ彉鎴愭í鍚戯紙绯荤粺鍚嶇О+鐧诲嚭鍙崰宸︿晶 435px锛夆�斺��6 涓笟鍔¢〉澶栧眰瀹瑰櫒鏄惧紡鍔� direction="vertical" 淇锛堜笂锛氱郴缁熸爮 宸︽爣棰樺彸鐢ㄦ埛+鐧诲嚭锛涗笅锛氬乏鑿滃崟鍙冲伐浣滃尯锛夈�傚潙锛歳adialFaces 鏂规硶璇斁杩� computed锛圴ue computed 涓嶆帴鍙楀弬鏁帮紝s 涓� undefined 鎶ラ敊锛夛紝椤绘斁 methods锛泇ue-loader 缂栬瘧缂撳瓨/娴忚鍣ㄥ惎鍙戝紡缂撳瓨锛坕ndex.html 鏃犵紦瀛樺ご锛夊鑷存敼浠g爜鍚庨〉闈粛鍔犺浇鏃� chunk鈥斺�攙ue.config.js devServer.headers 鍔� Cache-Control: no-store锛屾祴璇曟椂鐢ㄦ湭璁块棶杩囩殑 host:port銆�
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java b/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java
new file mode 100644
index 0000000..d034b87
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaController.java
@@ -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();
+ }
+}
\ No newline at end of file
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java b/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java
new file mode 100644
index 0000000..ebb972c
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/security/captcha/CaptchaService.java
@@ -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;
+
+/**
+ * 琛屼负楠岃瘉鐮侊細鎸夋彁绀洪『搴忕偣鍑荤珛浣撳浘褰€��
+ * 鏈嶅姟绔洿鎺ョ敓鎴愭瘡涓浘褰㈢殑绛夎酱娴嬪嚑浣曢潰锛堝杈瑰舰/妞渾/璺緞锛夛紝鍓嶇鍙礋璐f覆鏌擄紝
+ * 鍝嶅簲涓笉鍖呭惈浠讳綍鍥惧舰绫诲瀷鍚嶇О锛岀瓟妗堝彧淇濆瓨鍦ㄦ湇鍔$銆�
+ */
+@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", "姝f柟浣�");
+ KIND_CN.put("BOX", "闀挎柟浣�");
+ KIND_CN.put("CYLINDER", "鍦嗘煴");
+ KIND_CN.put("CONE", "鍦嗛敟");
+ KIND_CN.put("SPHERE", "鐞�");
+ KIND_CN.put("TRI_PRISM", "涓夋1鏌�");
+ KIND_CN.put("PYRAMID", "閲戝瓧濉�");
+ KIND_CN.put("HEX_PRISM", "鍏1鏌�");
+ }
+
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java b/traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
index 5424c4e..a49a3b9 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
+++ b/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();
+ }
}
diff --git a/traffic-audit-web/src/components/AppHeader.vue b/traffic-audit-web/src/components/AppHeader.vue
new file mode 100644
index 0000000..09eb2bb
--- /dev/null
+++ b/traffic-audit-web/src/components/AppHeader.vue
@@ -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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/components/Captcha3D.vue b/traffic-audit-web/src/components/Captcha3D.vue
new file mode 100644
index 0000000..0264cc7
--- /dev/null
+++ b/traffic-audit-web/src/components/Captcha3D.vue
@@ -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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/router/index.js b/traffic-audit-web/src/router/index.js
index 2540f38..0971ecf 100644
--- a/traffic-audit-web/src/router/index.js
+++ b/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
diff --git a/traffic-audit-web/src/views/AuditResult.vue b/traffic-audit-web/src/views/AuditResult.vue
index 4fc27a0..06f90d2 100644
--- a/traffic-audit-web/src/views/AuditResult.vue
+++ b/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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/views/Dashboard.vue b/traffic-audit-web/src/views/Dashboard.vue
index 1054c6f..a6134e7 100644
--- a/traffic-audit-web/src/views/Dashboard.vue
+++ b/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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/views/DataImport.vue b/traffic-audit-web/src/views/DataImport.vue
index 5b40688..2ba149a 100644
--- a/traffic-audit-web/src/views/DataImport.vue
+++ b/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>
diff --git a/traffic-audit-web/src/views/DataView.vue b/traffic-audit-web/src/views/DataView.vue
index 1ece0a6..23882f3 100644
--- a/traffic-audit-web/src/views/DataView.vue
+++ b/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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/views/ExplainReview.vue b/traffic-audit-web/src/views/ExplainReview.vue
index 767be34..766c949 100644
--- a/traffic-audit-web/src/views/ExplainReview.vue
+++ b/traffic-audit-web/src/views/ExplainReview.vue
@@ -1,6 +1,6 @@
<template>
- <el-container>
- <el-header>浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 浼佷笟瑙i噴</el-header>
+ <el-container direction="vertical">
+ <app-header title="浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 浼佷笟瑙i噴"></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>
\ No newline at end of file
diff --git a/traffic-audit-web/src/views/Login.vue b/traffic-audit-web/src/views/Login.vue
index 4993005..42528ba 100644
--- a/traffic-audit-web/src/views/Login.vue
+++ b/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>
diff --git a/traffic-audit-web/src/views/ReportExport.vue b/traffic-audit-web/src/views/ReportExport.vue
index ef1f647..3fddd46 100644
--- a/traffic-audit-web/src/views/ReportExport.vue
+++ b/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>
diff --git a/traffic-audit-web/vue.config.js b/traffic-audit-web/vue.config.js
index decc876..66931b7 100644
--- a/traffic-audit-web/vue.config.js
+++ b/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",
--
Gitblit v1.9.1