feat(ui): 翻牌老虎机翻页钟动效 + footer 后品牌幕布
首页统计翻牌:悬停某组时整组以随机节奏连续乱翻,最后逐个落回正确值;front 装旧值 / back 装新值,还原「上半先变新值、牌翻过来下半再变新值」的翻页钟时序,数字与动画之间用 nextTick 对齐,避免翻到一半数字跳变。 入场翻牌时长由原站内联的 26~35ms 统一到 0.45s,并摘掉模板里会被 Vue 写回的 --flipduration 绑定,改由 JS 独占控制。 footer 之后新增品牌幕布:贴视口下半屏的深色底 + 左侧灰色巨字 GVSDS,滚到底继续滑时整块露出。
This commit is contained in:
1 parent
7516af5f4a
commit
8a682e8ed2
2 files changed
+175
-17
No files matched your search
@@ -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
@@ -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>
|
||||
Reference in new issue
Block a user