<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', { timeout: 10000 })
|
.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()
|
}, { timeout: 10000 })
|
.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>
|