2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
152 lines
4.5 KiB
Vue
152 lines
4.5 KiB
Vue
<template>
|
||
<div class="captcha">
|
||
<div class="relative" :style="{ height: H + 'px' }">
|
||
<canvas
|
||
ref="canvasRef"
|
||
class="block w-full rounded-lg ring-1 ring-gray-200"
|
||
:style="{ height: H + 'px' }"
|
||
/>
|
||
<!-- 滑块手柄 -->
|
||
<div
|
||
ref="handleRef"
|
||
class="absolute top-0 flex items-center justify-center rounded-lg bg-white shadow-md ring-1 ring-gray-200 cursor-grab select-none active:cursor-grabbing"
|
||
:class="passed ? '!bg-green-500 !text-white' : 'text-indigo-600'"
|
||
:style="{ left: sliderX + 'px', width: SIZE + 'px', height: H + 'px' }"
|
||
@pointerdown.prevent="onDown"
|
||
>
|
||
<svg v-if="!passed" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6l6 6-6 6" />
|
||
</svg>
|
||
<svg v-else class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</div>
|
||
<div
|
||
v-if="!passed"
|
||
class="pointer-events-none absolute inset-0 flex items-center justify-center text-xs text-gray-400"
|
||
>
|
||
拖动滑块完成拼图验证
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 flex items-center justify-end">
|
||
<button type="button" class="text-xs text-gray-400 hover:text-gray-600" @click="reload">看不清?换一个</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
const emit = defineEmits<{
|
||
passed: [payload: { token: string; x: number }]
|
||
failed: []
|
||
}>()
|
||
|
||
const H = 44
|
||
const SIZE = 42
|
||
const TOLERANCE = 10
|
||
|
||
const canvasRef = ref<HTMLCanvasElement>()
|
||
const handleRef = ref<HTMLElement>()
|
||
const W = ref(320)
|
||
const puzzleX = ref(0)
|
||
const token = ref('')
|
||
const sliderX = ref(0)
|
||
const dragging = ref(false)
|
||
const passed = ref(false)
|
||
const loading = ref(false)
|
||
const result = ref<{ token: string; x: number } | null>(null)
|
||
|
||
let moveStartX = 0
|
||
let startLeft = 0
|
||
|
||
function clamp(v: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, v))
|
||
}
|
||
|
||
async function reload() {
|
||
if (loading.value) return
|
||
loading.value = true
|
||
passed.value = false
|
||
sliderX.value = 0
|
||
result.value = null
|
||
await nextTick()
|
||
const width = canvasRef.value ? Math.round(canvasRef.value.clientWidth) || 320 : 320
|
||
try {
|
||
const res = await $fetch<{ token: string; puzzleX: number; width: number; size: number }>(
|
||
'/api/captcha/challenge',
|
||
{ method: 'POST', body: { width } }
|
||
)
|
||
token.value = res.token
|
||
puzzleX.value = res.puzzleX
|
||
W.value = res.width
|
||
draw()
|
||
} catch {
|
||
/* 失败可重试 */
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const c = canvasRef.value
|
||
if (!c) return
|
||
const ctx = c.getContext('2d')
|
||
if (!ctx) return
|
||
c.width = W.value
|
||
c.height = H
|
||
const g = ctx.createLinearGradient(0, 0, W.value, H)
|
||
g.addColorStop(0, '#eef2ff')
|
||
g.addColorStop(1, '#e0e7ff')
|
||
ctx.fillStyle = g
|
||
ctx.fillRect(0, 0, W.value, H)
|
||
for (let i = 0; i < 36; i++) {
|
||
ctx.fillStyle = `rgba(99,102,241,${Math.random() * 0.15})`
|
||
ctx.beginPath()
|
||
ctx.arc(Math.random() * W.value, Math.random() * H, Math.random() * 2, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
}
|
||
ctx.fillStyle = 'rgba(99,102,241,0.4)'
|
||
ctx.font = '13px sans-serif'
|
||
ctx.textAlign = 'center'
|
||
ctx.fillText('拖动滑块完成拼图验证', W.value / 2, H / 2 + 4)
|
||
// 挖出缺口(露出底层)
|
||
ctx.globalCompositeOperation = 'destination-out'
|
||
ctx.fillRect(puzzleX.value, 0, SIZE, H)
|
||
ctx.globalCompositeOperation = 'source-over'
|
||
}
|
||
|
||
function onDown(e: PointerEvent) {
|
||
if (passed.value || loading.value) return
|
||
dragging.value = true
|
||
moveStartX = e.clientX
|
||
startLeft = sliderX.value
|
||
}
|
||
function onMove(e: PointerEvent) {
|
||
if (!dragging.value) return
|
||
sliderX.value = clamp(startLeft + (e.clientX - moveStartX), 0, W.value - SIZE)
|
||
}
|
||
function onUp() {
|
||
if (!dragging.value) return
|
||
dragging.value = false
|
||
if (Math.abs(sliderX.value - puzzleX.value) <= TOLERANCE) {
|
||
passed.value = true
|
||
result.value = { token: token.value, x: Math.round(sliderX.value) }
|
||
emit('passed', result.value)
|
||
} else {
|
||
sliderX.value = 0
|
||
emit('failed')
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await reload()
|
||
window.addEventListener('pointermove', onMove)
|
||
window.addEventListener('pointerup', onUp)
|
||
})
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('pointermove', onMove)
|
||
window.removeEventListener('pointerup', onUp)
|
||
})
|
||
|
||
defineExpose({ result, passed, reload })
|
||
</script>
|