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