From 664cae654867b528ea339f187ebe290cb04d6fcf Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期四, 27 八月 2026 18:41:21 +0800
Subject: [PATCH] docs: 关机前交接——运行状态、待验证项、外置配置要点

---
 traffic-audit-web/src/components/Captcha3D.vue |  244 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 244 insertions(+), 0 deletions(-)

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

--
Gitblit v1.9.1