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