improvement(wrapped-ui): 下线 DOS 主题并优化 Wrapped 多主题体验

- 移除 DOS 主题入口、切换器组件与相关样式逻辑,统一主题为 Modern / GameBoy / Win98。

- 新增 WrappedGameboyDither 组件,并在背景与 CRT 叠加层中引入 GameBoy 噪点效果。

- 优化 wrapped 页面视口高度与背景同步逻辑(含 ResizeObserver 与 100dvh 适配),提升桌面容器显示稳定性。

- 调整封面标题与预览位移、回复速度卡片滚动行为等细节,提升主题下视觉与交互一致性。
This commit is contained in:
2977094657
2026-02-07 20:59:03 +08:00
parent 017ec6d089
commit e9c81caa12
15 changed files with 192 additions and 296 deletions

View File

@@ -186,7 +186,7 @@
暂无可展示的排行榜数据
</div>
<div v-else class="race-scroll mt-4 max-h-[26rem] overflow-auto pr-1">
<div v-else class="race-scroll mt-4 max-h-[26rem] overflow-y-auto overflow-x-hidden pr-1">
<TransitionGroup
name="race"
tag="div"
@@ -258,8 +258,7 @@ const props = defineProps({
const { theme } = useWrappedTheme()
const isGameboy = computed(() => theme.value === 'gameboy')
const isDos = computed(() => theme.value === 'dos')
const isRetro = computed(() => isGameboy.value || isDos.value)
const isRetro = computed(() => isGameboy.value)
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
@@ -756,6 +755,16 @@ onBeforeUnmount(() => {
transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1) !important;
}
.race-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.race-scroll::-webkit-scrollbar {
width: 0;
height: 0;
}
.race-move {
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
}

View File

@@ -1,6 +1,17 @@
<template>
<!-- CRT 滤镜叠加层 - 复古主题使用 -->
<div class="absolute inset-0 pointer-events-none select-none z-30" aria-hidden="true">
<!-- Game Boy: noise 作为最前景层统一覆盖整个画面 -->
<WrappedGameboyDither
v-if="theme === 'gameboy'"
class="opacity-[0.3]"
style="filter: contrast(1.16)"
:pattern-refresh-interval="1"
:pattern-alpha="56"
mix-blend-mode="overlay"
:pattern-size="256"
/>
<!-- 扫描线 / RGB 子像素 / 闪烁 / 暗角 / 曲率 -->
<div class="absolute inset-0 crt-scanlines"></div>
<div class="absolute inset-0 crt-rgb-pixels"></div>
@@ -8,31 +19,9 @@
<div class="absolute inset-0 crt-vignette"></div>
<div class="absolute inset-0 crt-curvature"></div>
<!-- DOS: 语义化光标 -->
<div v-if="theme === 'dos'" class="dos-cursor"></div>
</div>
</template>
<script setup>
const { theme } = useWrappedTheme()
</script>
<style scoped>
/* DOS 语义化光标 */
.dos-cursor {
position: fixed;
bottom: 20px;
right: 20px;
color: #33ff33;
font-size: 1.5rem;
font-family: var(--font-pixel-10), 'Courier New', monospace;
text-shadow: 0 0 8px rgba(51, 255, 51, 0.6);
animation: dos-cursor-blink 530ms steps(1) infinite;
z-index: 100;
}
@keyframes dos-cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>

View File

@@ -87,8 +87,7 @@ defineProps({
const { theme } = useWrappedTheme()
const isWin98 = computed(() => theme.value === 'win98')
const isGameboy = computed(() => theme.value === 'gameboy')
const isDos = computed(() => theme.value === 'dos')
const isCompactSlide = computed(() => isGameboy.value || isDos.value)
const isCompactSlide = computed(() => isGameboy.value)
const slideTitleClass = computed(() => (
isCompactSlide.value ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'
@@ -142,39 +141,4 @@ const slideContainerClass = computed(() => (
border-color: #306230 !important;
}
/* ========== DOS 主题 ========== */
/* 卡片背景 */
.wrapped-theme-dos .bg-white {
background: #0a0a0a !important;
border-color: #33ff33 !important;
box-shadow: 0 0 10px rgba(51, 255, 51, 0.3);
}
/* 标题 */
.wrapped-theme-dos .wrapped-title {
color: #33ff33 !important;
text-shadow: 0 0 5px #33ff33;
font-family: 'Courier New', monospace;
}
/* 描述文字 */
.wrapped-theme-dos .wrapped-body {
color: #22aa22 !important;
text-shadow: 0 0 3px #22aa22;
font-family: 'Courier New', monospace;
}
/* 数字高亮 */
.wrapped-theme-dos .wrapped-number {
color: #33ff33 !important;
text-shadow: 0 0 8px #33ff33;
font-family: 'Courier New', monospace;
}
/* 边框 */
.wrapped-theme-dos .border-\[\#EDEDED\],
.wrapped-theme-dos .border-\[\#F3F3F3\] {
border-color: #33ff33 !important;
}
</style>

View File

@@ -131,30 +131,6 @@ const yearOptions = computed(() => {
filter: brightness(1.1);
}
/* DOS 特殊样式 */
.wrapped-theme-dos .controls-panel {
border-radius: 0;
border: 2px solid #33ff33;
box-shadow: 0 0 10px rgba(51, 255, 51, 0.3);
}
.wrapped-theme-dos .controls-select {
border-radius: 0;
border: 1px solid #33ff33;
text-shadow: 0 0 5px #33ff33;
}
.wrapped-theme-dos .controls-btn {
border-radius: 0;
background-color: #33ff33;
color: #000000;
text-shadow: none;
}
.wrapped-theme-dos .controls-btn:hover:not(:disabled) {
background-color: #44ff44;
}
/* Win98 特殊样式 */
.wrapped-theme-win98 .controls-panel {
border-radius: 0;

View File

@@ -1,5 +1,5 @@
<template>
<!-- Shared backdrop for all "Wrapped" slides (keeps cover + cards visually consistent). -->
<!-- Shared backdrop for modern/gameboy "Wrapped" slides (keeps cover + cards visually consistent). -->
<div v-if="theme !== 'win98'" class="absolute inset-0 pointer-events-none select-none z-0" aria-hidden="true">
<!-- Soft color blobs (brand + warm highlights) -->
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
@@ -11,8 +11,17 @@
class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.28]"
></div>
<!-- Grain/noise: enhanced with dynamic jitter for CRT feel -->
<div class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></div>
<!-- Grain/noise: gameboy 使用动态 canvas 噪点其它主题沿用现有纹理 -->
<WrappedGameboyDither
v-if="theme === 'gameboy'"
class="opacity-[0.3]"
style="filter: contrast(1.16)"
:pattern-refresh-interval="1"
:pattern-alpha="56"
mix-blend-mode="overlay"
:pattern-size="256"
/>
<div v-else class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></div>
<!-- Gentle vignette so typography stays readable on textured bg -->
<div class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/50 to-transparent"></div>

View File

@@ -0,0 +1,98 @@
<template>
<canvas
ref="grainRef"
class="pointer-events-none absolute inset-0 h-full w-full"
:style="canvasStyle"
aria-hidden="true"
></canvas>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
patternRefreshInterval: {
type: Number,
default: 2
},
patternAlpha: {
type: Number,
default: 20
},
mixBlendMode: {
type: String,
default: 'multiply'
},
patternSize: {
type: Number,
default: 512
}
})
const grainRef = ref(null)
const canvasStyle = computed(() => `image-rendering: pixelated; mix-blend-mode: ${props.mixBlendMode};`)
let animationId = 0
let frame = 0
let noiseData
let noise32
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
const resize = () => {
const canvas = grainRef.value
if (!canvas) return
const size = Math.max(64, Math.round(props.patternSize))
canvas.width = size
canvas.height = size
}
const initImageData = (ctx) => {
const canvas = grainRef.value
if (!canvas) return
noiseData = ctx.createImageData(canvas.width, canvas.height)
noise32 = new Uint32Array(noiseData.data.buffer)
}
const drawGrain = () => {
if (!noise32) return
const alpha = clamp(Math.round(props.patternAlpha), 0, 255) << 24
for (let i = 0; i < noise32.length; i++) {
const value = (Math.random() * 255) | 0
noise32[i] = alpha | (value << 16) | (value << 8) | value
}
}
const loop = (ctx) => {
const refreshEvery = Math.max(1, Math.round(props.patternRefreshInterval))
if (frame % refreshEvery === 0) {
drawGrain()
ctx.putImageData(noiseData, 0, 0)
}
frame++
animationId = window.requestAnimationFrame(() => loop(ctx))
}
onMounted(() => {
const canvas = grainRef.value
if (!canvas) return
const ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return
resize()
initImageData(ctx)
drawGrain()
ctx.putImageData(noiseData, 0, 0)
loop(ctx)
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
window.cancelAnimationFrame(animationId)
})
</script>

View File

@@ -35,7 +35,7 @@
</div>
<div class="mt-10 sm:mt-14">
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
<h1 class="wrapped-title text-3xl sm:text-5xl text-[#000000e6] leading-[1.05]">
{{ randomTitle.main }}
<span class="block mt-3 win98-hero-highlight">
{{ randomTitle.highlight }}
@@ -70,7 +70,7 @@
</div>
<div class="mt-10 sm:mt-14">
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
<h1 class="wrapped-title text-3xl sm:text-5xl text-[#000000e6] leading-[1.05]">
{{ randomTitle.main }}
<span class="block mt-3 text-[#07C160]">
{{ randomTitle.highlight }}
@@ -404,8 +404,8 @@ const modernPreviewItems = computed(() => {
const previewStageClass = computed(() => (
isGameboy.value
? 'w-[500px] h-[360px] translate-x-12 -translate-y-8'
: 'w-[620px] h-[420px] translate-x-20 -translate-y-10'
? 'w-[500px] h-[360px] translate-x-24 -translate-y-8'
: 'w-[620px] h-[420px] translate-x-32 -translate-y-10'
))
const previewViewportClass = computed(() => (

View File

@@ -10,7 +10,6 @@ const themeSwitcherComponent = computed(() => {
const map = {
off: resolveComponent('WrappedThemeSwitcherModern'),
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
dos: resolveComponent('WrappedThemeSwitcherDos'),
win98: resolveComponent('WrappedThemeSwitcherWin98')
}
return map[theme.value] || map.off

View File

@@ -1,100 +0,0 @@
<template>
<div class="dos-menu select-none">
<!-- DOS 风格功能键菜单栏 -->
<div class="dos-menu-bar">
<button
v-for="(t, idx) in themes"
:key="t.value"
class="dos-menu-item"
:class="{ 'is-active': theme === t.value }"
@click="setTheme(t.value)"
>
<span class="dos-fkey">F{{ idx + 1 }}</span>
<span class="dos-label">{{ t.label }}</span>
</button>
</div>
<!-- 状态提示 -->
<div class="dos-status">
Press F1-F4 to switch theme
</div>
</div>
</template>
<script setup>
const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'GameBoy' },
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>
<style scoped>
.dos-menu {
font-family: 'Courier New', 'Consolas', monospace;
font-size: 11px;
}
.dos-menu-bar {
display: flex;
background: #000000;
border: 1px solid #33ff33;
overflow: hidden;
}
.dos-menu-item {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: #000000;
color: #33ff33;
border: none;
border-right: 1px solid #1a5c1a;
cursor: pointer;
text-shadow: 0 0 5px #33ff33;
transition: all 0.1s;
font-family: inherit;
font-size: inherit;
}
.dos-menu-item:last-child {
border-right: none;
}
.dos-menu-item:hover {
background: #0a1a0a;
}
.dos-menu-item.is-active {
background: #33ff33;
color: #000000;
text-shadow: none;
}
.dos-fkey {
font-weight: bold;
padding: 1px 3px;
background: #1a5c1a;
color: #33ff33;
font-size: 9px;
}
.dos-menu-item.is-active .dos-fkey {
background: #000000;
color: #33ff33;
}
.dos-label {
letter-spacing: 0.5px;
}
.dos-status {
margin-top: 4px;
color: #1a5c1a;
font-size: 9px;
text-shadow: 0 0 3px #1a5c1a;
}
</style>

View File

@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'MODERN' },
{ value: 'gameboy', label: 'GAME BOY' },
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'WIN98' }
]

View File

@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'Game Boy' },
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>

View File

@@ -23,7 +23,6 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'Game Boy' },
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>

View File

@@ -19,25 +19,6 @@
</div>
</div>
<!-- DOS 风格 -->
<div v-else-if="theme === 'dos'" class="year-dos">
<span class="dos-prompt">C:\WRAPPED&gt;</span>
<span class="dos-label">YEAR:</span>
<button
class="dos-arrow"
:disabled="!canGoPrev"
@click="prevYear"
aria-label="Previous year"
>[-]</button>
<span class="dos-value">{{ modelValue }}</span>
<button
class="dos-arrow"
:disabled="!canGoNext"
@click="nextYear"
aria-label="Next year"
>[+]</button>
</div>
<!-- Win98 风格 -->
<div v-else-if="theme === 'win98'" class="year-win98">
<div class="win98-year-box">
@@ -209,57 +190,6 @@ onBeforeUnmount(() => {
letter-spacing: 2px;
}
/* ========== DOS 风格 ========== */
.year-dos {
font-family: 'Courier New', 'Consolas', monospace;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
color: #33ff33;
text-shadow: 0 0 5px #33ff33;
}
.dos-prompt {
color: #1a5c1a;
}
.dos-label {
color: #33ff33;
}
.dos-arrow {
background: transparent;
border: none;
color: #33ff33;
font-family: inherit;
font-size: inherit;
cursor: pointer;
padding: 0 2px;
text-shadow: 0 0 5px #33ff33;
transition: color 0.1s;
}
.dos-arrow:hover:not(:disabled) {
color: #66ff66;
text-shadow: 0 0 8px #66ff66;
}
.dos-arrow:disabled {
color: #1a5c1a;
cursor: not-allowed;
text-shadow: none;
}
.dos-value {
background: #0a1a0a;
padding: 2px 6px;
border: 1px solid #1a5c1a;
letter-spacing: 1px;
min-width: 50px;
text-align: center;
}
/* ========== Win98 风格 ========== */
.year-win98 {
font-family: "MS Sans Serif", Tahoma, "Microsoft Sans Serif", "Segoe UI", sans-serif;