2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
266 lines
7.8 KiB
Vue
266 lines
7.8 KiB
Vue
<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>
|