Files
hjc-web/app/components/SliderCaptcha.vue
T
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

266 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="slider-captcha">
<div
ref="trackRef"
class="relative w-full select-none overflow-hidden rounded-lg"
:class="shake ? 'shake' : ''"
:style="{
height: trackHeight + 'px',
background: '#eef2f6',
backgroundImage:
'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(0,0,0,0.03) 10px, rgba(0,0,0,0.03) 20px)',
touchAction: 'pan-y'
}"
>
<!-- 目标缺口拼图应放置的位置 -->
<div
class="target-gap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md"
:style="{
left: (puzzleX - size / 2) + 'px',
width: size + 'px',
height: size + 'px'
}"
>
<svg
v-if="!verified && !dragging"
class="h-5 w-5 text-white drop-shadow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
<!-- 已拖动填充 -->
<div
class="absolute top-0 left-0 h-full transition-[width] duration-75"
:style="{ width: knobX + 'px', background: accent, opacity: 0.16 }"
/>
<!-- 拼图块可拖动 -->
<div
class="absolute top-1/2 z-20 flex -translate-y-1/2 items-center justify-center rounded-md text-white shadow-md"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
:style="{
left: knobX + 'px',
width: size + 'px',
height: size + 'px',
background: verified ? '#16a34a' : accent,
transition: dragging ? 'none' : 'left 0.2s ease',
boxShadow: verified ? '0 4px 12px rgba(22,163,74,0.35)' : '0 4px 12px rgba(0,0,0,0.18)',
touchAction: 'none'
}"
@pointerdown.prevent="startDrag"
>
<!-- 拖动图标 -->
<svg v-if="!verified" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7L4 12l4 5M16 7l4 5-4 5" />
</svg>
<!-- 通过图标 -->
<svg v-else class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<!-- 操作提示 -->
<div
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center text-xs"
:class="dragging ? 'text-transparent' : 'text-gray-500'"
>
{{ loading ? '加载中...' : '拖动左侧滑块到缺口位置' }}
</div>
<!-- 验证通过遮罩 -->
<div
v-if="verified"
class="absolute inset-0 z-30 flex items-center justify-center text-xs font-medium text-white"
:style="{ background: 'rgba(22,163,74,0.9)' }"
>
验证通过
</div>
</div>
<p v-if="error" class="mt-1 text-xs text-red-500">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
/**
* 滑块拼图验证码(客户端)。
* 1) 挂载时向后端申请 challenge(含缺口位置 puzzleX
* 2) 用户拖动拼图块到缺口附近松手
* 3) 校验通过则 emit('verified', ticket),由父组件随留言一并提交
*/
withDefaults(defineProps<{ accent?: string }>(), {
accent: '#1a6dff'
})
const emit = defineEmits<{ (e: 'verified', ticket: string): void }>()
const size = 44
const trackHeight = size // 上下各留 1px,滑块几乎贴满轨道
const tolerance = 8
const trackRef = ref<HTMLElement | null>(null)
const challenge = ref<{ token: string; puzzleX: number; width: number; size: number } | null>(null)
const knobX = ref(0)
const dragging = ref(false)
const loading = ref(true)
const verified = ref(false)
const error = ref('')
const shake = ref(false)
let pointerStartX = 0
let knobStartX = 0
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const puzzleX = computed(() => challenge.value?.puzzleX ?? size)
function loadChallenge() {
const width = Math.min(Math.max(trackRef.value?.clientWidth || 320, 240), 480)
loading.value = true
$fetch<{ token: string; puzzleX: number; width: number; size: number }>('/api/captcha/challenge', {
method: 'POST',
body: { width }
})
.then((data) => {
challenge.value = data
knobX.value = 0
verified.value = false
error.value = ''
})
.catch(() => {
error.value = '验证码加载失败,请刷新页面'
})
.finally(() => {
loading.value = false
})
}
function startDrag(e: PointerEvent) {
if (verified.value || loading.value || !challenge.value) return
dragging.value = true
pointerStartX = e.clientX
knobStartX = knobX.value
error.value = ''
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
function onMove(e: PointerEvent) {
if (!dragging.value || !challenge.value) return
const max = challenge.value.width - size
const next = knobStartX + (e.clientX - pointerStartX)
knobX.value = Math.min(Math.max(next, 0), max)
}
function onUp() {
if (!dragging.value) return
dragging.value = false
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
if (!challenge.value) return
const userX = knobX.value + size / 2
const ok = Math.abs(userX - challenge.value.puzzleX) <= tolerance
if (!ok) {
error.value = '未对准缺口,请重试'
doShake()
knobX.value = 0
loadChallenge()
return
}
$fetch<{ pass: boolean; ticket?: string }>('/api/captcha/verify', {
method: 'POST',
body: { token: challenge.value.token, x: userX }
})
.then((res) => {
if (res.pass && res.ticket) {
verified.value = true
error.value = ''
emit('verified', res.ticket)
} else {
failAndReset('验证失败,请重试')
}
})
.catch(() => {
failAndReset('验证失败,请重试')
})
}
function failAndReset(msg: string) {
error.value = msg
doShake()
knobX.value = 0
loadChallenge()
}
function doShake() {
shake.value = true
setTimeout(() => {
shake.value = false
}, 350)
}
/** 供父组件在提交失败后重置(票据一次性,需重新验证) */
function reset() {
verified.value = false
knobX.value = 0
error.value = ''
loadChallenge()
}
defineExpose({ reset })
function onResize() {
if (verified.value) return
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(loadChallenge, 200)
}
onMounted(() => {
loadChallenge()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
</script>
<style scoped>
@keyframes slider-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
.shake {
animation: slider-shake 0.35s ease-in-out;
}
@keyframes target-pulse {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(255,255,255,0.95), 0 4px 10px rgba(0,0,0,0.14); }
50% { box-shadow: inset 0 0 0 2px rgba(255,255,255,1), 0 4px 14px rgba(0,0,0,0.22); }
}
.target-gap {
background: rgba(0, 0, 0, 0.22);
border: 2px dashed rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.95), 0 4px 10px rgba(0, 0, 0, 0.14);
animation: target-pulse 1.6s ease-in-out infinite;
}
</style>