feat(ui): 翻牌老虎机翻页钟动效 + footer 后品牌幕布

首页统计翻牌:悬停某组时整组以随机节奏连续乱翻,最后逐个落回正确值;front 装旧值 / back 装新值,还原「上半先变新值、牌翻过来下半再变新值」的翻页钟时序,数字与动画之间用 nextTick 对齐,避免翻到一半数字跳变。

入场翻牌时长由原站内联的 26~35ms 统一到 0.45s,并摘掉模板里会被 Vue 写回的 --flipduration 绑定,改由 JS 独占控制。

footer 之后新增品牌幕布:贴视口下半屏的深色底 + 左侧灰色巨字 GVSDS,滚到底继续滑时整块露出。
This commit is contained in:
TermiNexus committed 2026-09-29 13:33:31 +08:00
1 parent 7516af5f4a
commit 8a682e8ed2
2 files changed
+175 -17

No files matched your search

+57 -1
View File
@@ -1,5 +1,7 @@
<template>
<div class="min-h-screen flex flex-col font-sans">
<!-- 内容层:relative + z-1 抬到幕布之上,bg-white 保证任何空隙都不透出幕布
(幕布在最底层,只有末尾的透明占位区才把它露出来) -->
<div class="relative z-[1] min-h-screen flex flex-col font-sans bg-white">
<!-- ===== 顶部导航(对齐原站 _base) ===== -->
<header
id="header"
@@ -184,6 +186,16 @@
<!-- Cookie 横幅 -->
<CookieBanner />
</div>
<!-- ===== 品牌幕布:滚到最底后继续滑,露出半屏深色底 + 灰色巨字 GVSDS =====
幕布固定贴住视口下半屏(z-0),平时被内容层(z-1 + 白底)完整盖住;
末尾留半屏透明占位,滚到底时占位区把幕布整块露出来("抽盖子"式的翻页观感)。
用 z-0 / z-1 这对正数分层,而不是负 z-index:body 的不透明背景会吃掉负 z-index,
幕布就永远看不见了。两者放在内容层之外,否则会被内容层的层叠上下文与白底吃掉。 -->
<div class="brand-curtain" aria-hidden="true">
<span class="brand-curtain-word">GVSDS</span>
</div>
<div class="brand-curtain-spacer" aria-hidden="true"></div>
</template>
<script setup lang="ts">
@@ -538,4 +550,48 @@ watch(
#header[data-nav-theme='light'].menu-open::before {
opacity: 1;
}
/* ===== 品牌幕布(footer 之后那半屏) ===== */
.brand-curtain {
position: fixed;
left: 0;
right: 0;
bottom: 0;
/* 高度必须与下面的占位一致:占位多高,滚到底时露出的幕布就有多高。
只占视口下半屏,与 footer 的深灰接成一片,不是整屏吞掉画面 */
height: 50vh;
height: 50svh;
/* z-0 站住"内容之下、根背景之上"这一层:内容层是 relative + z-1,白底会把幕布盖住;
末端透明占位不带背景,就把幕布让出来 */
z-index: 0;
display: flex;
align-items: center;
/* 巨字靠左:字号已经接近顶满宽度,左对齐让品牌字从左侧起笔 */
justify-content: flex-start;
padding-left: 2vw;
overflow: hidden;
background: #0a0f16;
pointer-events: none;
}
.brand-curtain-word {
font-family: var(--font-sans);
/* 字号同时受两个方向的约束:横向 5 个字母约 3.6em 不能超出视口,纵向要撑满这半屏幕布。
取两者较小值 → 宽屏时高度顶满、窄屏时宽度顶满(vh 那行是 svh 不支持时的回退) */
font-size: min(26vw, 40vh);
font-size: min(26vw, 40svh);
font-weight: 900;
/* 不用 line-height < 1:字形会超出内容盒(配 background-clip 时会被裁掉一截) */
line-height: 1;
letter-spacing: -0.015em;
white-space: nowrap;
/* 就用纯灰:background-clip: text 在部分环境下会把文字整段吃成透明,得不偿失 */
color: #4b5563;
}
/* 末尾占位:与幕布等高(半屏,svh 避开移动端地址栏收缩导致的跳动),透明 → 露出幕布 */
.brand-curtain-spacer {
height: 50vh;
height: 50svh;
}
</style>
+118 -16
View File
@@ -49,18 +49,18 @@
</a>
</div>
</div>
<!-- 底部统计翻牌 -->
<!-- 底部统计翻牌(鼠标悬停某一组:老虎机式乱翻,最后逐个落回正常值) -->
<div class="hero-stats 2xl:h-[198px] h-[100px] sm:h-[120px] pr-[16px] sm:pr-[30px] 2xl:pr-[174px] xl:pr-[100px] flex justify-between 2xl:justify-start md:justify-start md:gap-[80px] lg:gap-[160px] 2xl:gap-[267px] items-start hidden md:flex">
<div>
<div class="font-['DIN_Condensed'] font-bold leading-none tracking-normal uppercase mb-[8px] md:mb-[4px] 2xl:mb-[12px] text-[24px] sm:text-[23px] md:text-[24px] text-[#FFF] opacity-80 [text-shadow:0_2px_8px_rgba(0,0,0,0.6)]">Total Code Size (characters)</div>
<div class="flex gap-[3px] sm:gap-1 scale-[0.65] sm:scale-[0.8] 2xl:scale-[1] origin-left">
<div v-for="d in codeDigits" :key="'c' + d.idx" class="M-FlipCard down flip-win bg-bg-primary" :style="{ '--flipduration': d.dur + 's' }"><div class="digital front" :class="'number' + d.val"></div><div class="digital back" :class="'number' + d.val"></div></div>
<div class="flex gap-[3px] sm:gap-1 scale-[0.65] sm:scale-[0.8] 2xl:scale-[1] origin-left" data-flip-group="code" @mouseenter="spinGroup('code')">
<div v-for="(d, i) in codeDigits" :key="'c' + d.idx" class="M-FlipCard down flip-win bg-bg-primary"><div class="digital front" :class="'number' + codeFront[i]"></div><div class="digital back" :class="'number' + codeBack[i]"></div></div>
</div>
</div>
<div>
<div class="font-['DIN_Condensed'] font-bold leading-none tracking-normal uppercase mb-[8px] md:mb-[4px] 2xl:mb-[12px] text-[24px] sm:text-[23px] md:text-[24px] text-[#FFF] opacity-80 [text-shadow:0_2px_8px_rgba(0,0,0,0.6)]">Maximum Insurance Amount (CNY)</div>
<div class="flex gap-[3px] sm:gap-1 scale-[0.65] sm:scale-[0.8] 2xl:scale-[1] origin-left">
<div v-for="d in insuranceDigits" :key="'i' + d.idx" class="M-FlipCard down flip-win bg-bg-primary" :style="{ '--flipduration': d.dur + 's' }"><div class="digital front" :class="'number' + d.val"></div><div class="digital back" :class="'number' + d.val"></div></div>
<div class="flex gap-[3px] sm:gap-1 scale-[0.65] sm:scale-[0.8] 2xl:scale-[1] origin-left" data-flip-group="insurance" @mouseenter="spinGroup('insurance')">
<div v-for="(d, i) in insuranceDigits" :key="'i' + d.idx" class="M-FlipCard down flip-win bg-bg-primary"><div class="digital front" :class="'number' + insuranceFront[i]"></div><div class="digital back" :class="'number' + insuranceBack[i]"></div></div>
</div>
</div>
</div>
@@ -379,7 +379,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -389,17 +389,19 @@ function go(path: string): void {
}
// ==== 翻牌数字(对齐 index.html 硬编码) ====
// 逐位拆开渲染;每位原本自带一个 26~35ms 的翻牌时长(dur),太快看不出翻动,
// 已改为统一由 JS 控制 --flipduration(见 FLIP_ANIM_DURATION),故这里不再带 dur
const codeDigits = [
{ idx: 0, val: 1, dur: 0.026242368233221722 }, { idx: 1, val: 2, dur: 0.029718667678514322 },
{ idx: 2, val: 4, dur: 0.025401492287061424 }, { idx: 3, val: 9, dur: 0.03088231871236644 },
{ idx: 4, val: 0, dur: 0.031675451149336446 }, { idx: 5, val: 7, dur: 0.03497610376846389 },
{ idx: 6, val: 8, dur: 0.030530656690159304 }, { idx: 7, val: 1, dur: 0.02840509640985139 },
{ idx: 0, val: 1 }, { idx: 1, val: 2 },
{ idx: 2, val: 4 }, { idx: 3, val: 9 },
{ idx: 4, val: 0 }, { idx: 5, val: 7 },
{ idx: 6, val: 8 }, { idx: 7, val: 1 },
]
const insuranceDigits = [
{ idx: 0, val: 0, dur: 0.02627530204186018 }, { idx: 1, val: 0, dur: 0.03200964712168997 },
{ idx: 2, val: 0, dur: 0.03464767830776408 }, { idx: 3, val: 0, dur: 0.030824110890535515 },
{ idx: 4, val: 3, dur: 0.02593196506159544 }, { idx: 5, val: 8, dur: 0.03374560321012195 },
{ idx: 6, val: 9, dur: 0.03213503227905559 }, { idx: 7, val: 9, dur: 0.03463823789640608 },
{ idx: 0, val: 0 }, { idx: 1, val: 0 },
{ idx: 2, val: 0 }, { idx: 3, val: 0 },
{ idx: 4, val: 3 }, { idx: 5, val: 8 },
{ idx: 6, val: 9 }, { idx: 7, val: 9 },
]
// ==== 理念 ====
@@ -575,12 +577,110 @@ function startCarousel(): void {
animate()
}
// ==== 翻牌鼠标动效:悬停时整组老虎机式乱翻,最后逐个落回正常值 ====
type FlipGroup = 'code' | 'insurance'
/** 老虎机节奏
* 单次翻转 0.45s:原站内联的 26~35ms 一闪而过、初版 0.2s 也偏快,0.45s 才看得清翻动过程;
* 翻完再停 100~250ms 才翻下一下 —— 跳动间隔 = 动画时长 + 停顿,
* 间隔必须不小于动画时长,否则每次翻转都会被下一次重播打断,永远只看到半程。 */
const FLIP_ANIM_MS = 450
const FLIP_ANIM_DURATION = '0.45s'
const FLIP_GAP_MIN_MS = 100
const FLIP_GAP_MAX_MS = 250
/** 每位起跳/停止时刻都带随机 → 乱序滚,最后前后错开地落定 */
const FLIP_START_MAX_MS = 500
const FLIP_TOTAL_MIN_MS = 2500
const FLIP_TOTAL_MAX_MS = 3500
/**
* 每位两张页面,各自的数字必须分开管:
* front = 当前值(静止时露在外面的那张)
* back = 即将翻出来的新值
* 翻牌组件正是"front 的上半页先翻落 → 露出 back 的上半页(上半先变新值),
* 随后 back 的下半页翻下来盖住 front 的下半页(下半后变新值)",
* 也就是经典翻页钟的机制 —— 前提是 front 放旧值、back 放新值。
* 原站两层用同一个数字,所以只像"抖一下"。
*/
const codeFront = ref(codeDigits.map((d) => d.val))
const codeBack = ref(codeDigits.map((d) => d.val))
const insuranceFront = ref(insuranceDigits.map((d) => d.val))
const insuranceBack = ref(insuranceDigits.map((d) => d.val))
/** 每组的定时器:重入同一组时先清掉上一轮,避免两轮叠在一起 */
const flipTimers: Record<FlipGroup, number[]> = { code: [], insurance: [] }
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
function randomBetween(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function clearFlipTimers(group: FlipGroup): void {
flipTimers[group].forEach((id) => window.clearTimeout(id))
flipTimers[group] = []
}
/**
* 把某一位翻到新值。四步的顺序是硬约束,每一步之间都要等 DOM 落地:
* ① front 更新为"当前值"(= 上一轮的 back 值),先让新类进 DOM
* ② 摘掉 .go 回到静止画面 —— 上一步已把 front 对齐,所以这一帧画面完全不变
* ③ back 装入新值(此时 back 的两个半页都停在 180° 不可见位,改了也不会跳变)
* ④ 加 .go 起翻
* 若省略 ①②的先后、或先起翻再改数字,就会出现"翻到一半数字跳变"。
*/
async function flipCard(
card: HTMLElement,
front: Ref<number[]>,
back: Ref<number[]>,
index: number,
nextVal: number
): Promise<void> {
front.value[index] = back.value[index]
await nextTick()
card.classList.remove('go')
void card.offsetWidth // 强制回流:让"静止显示 front"这一帧立刻成立
back.value[index] = nextVal
await nextTick()
card.classList.add('go')
}
/**
* 悬停某一组 → 该组每位数字以随机节奏连续乱翻,最后逐个落回正确值(老虎机)。
* 鼠标离开不打断:必须让它跑完落定,否则会停在随机数字上。
*/
function spinGroup(group: FlipGroup): void {
if (prefersReducedMotion.matches) return
const cards = Array.from(
document.querySelectorAll<HTMLElement>(`[data-flip-group="${group}"] .M-FlipCard`)
)
if (cards.length === 0) return
clearFlipTimers(group)
const front: Ref<number[]> = group === 'code' ? codeFront : insuranceFront
const back: Ref<number[]> = group === 'code' ? codeBack : insuranceBack
const answers: number[] =
group === 'code' ? codeDigits.map((d) => d.val) : insuranceDigits.map((d) => d.val)
const timers = flipTimers[group]
cards.forEach((card, i) => {
const stopAt = performance.now() + randomBetween(FLIP_TOTAL_MIN_MS, FLIP_TOTAL_MAX_MS)
const roll = async (): Promise<void> => {
const settled = performance.now() >= stopAt
await flipCard(card, front, back, i, settled ? answers[i] : Math.floor(Math.random() * 10))
if (settled) return // 落定后停手,数字停在正确值上
timers.push(window.setTimeout(roll, FLIP_ANIM_MS + randomBetween(FLIP_GAP_MIN_MS, FLIP_GAP_MAX_MS)))
}
timers.push(window.setTimeout(roll, randomBetween(0, FLIP_START_MAX_MS)))
})
}
onMounted(() => {
initReveal()
startCarousel()
// 触发翻牌动画:为 M-FlipCard 添加 .go 类(对齐原站 flip-win 翻牌效果)
const cards = document.querySelectorAll('.M-FlipCard.down')
// 入场翻牌:逐位错开依次翻开(对齐原站 flip-win 效果)。
// 时长同样提到 0.45s:原站内联的 26~35ms 一闪而过,根本看不出是"翻"出来的
const cards = document.querySelectorAll<HTMLElement>('.M-FlipCard.down')
cards.forEach((card, index) => {
card.style.setProperty('--flipduration', FLIP_ANIM_DURATION)
setTimeout(() => {
card.classList.add('go')
}, index * 60)
@@ -590,5 +690,7 @@ onMounted(() => {
onBeforeUnmount(() => {
cancelAnimationFrame(rafId)
revealObserver?.disconnect()
clearFlipTimers('code')
clearFlipTimers('insurance')
})
</script>