feat(app): 添加多模板关于我们页面及相关路由和404页面

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