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
+151
View File
@@ -0,0 +1,151 @@
<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>