mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
improvement(wrapped-ui): 下线 DOS 主题并优化 Wrapped 多主题体验
- 移除 DOS 主题入口、切换器组件与相关样式逻辑,统一主题为 Modern / GameBoy / Win98。 - 新增 WrappedGameboyDither 组件,并在背景与 CRT 叠加层中引入 GameBoy 噪点效果。 - 优化 wrapped 页面视口高度与背景同步逻辑(含 ResizeObserver 与 100dvh 适配),提升桌面容器显示稳定性。 - 调整封面标题与预览位移、回复速度卡片滚动行为等细节,提升主题下视觉与交互一致性。
This commit is contained in:
@@ -186,7 +186,7 @@
|
|||||||
暂无可展示的排行榜数据。
|
暂无可展示的排行榜数据。
|
||||||
</div>
|
</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
|
<TransitionGroup
|
||||||
name="race"
|
name="race"
|
||||||
tag="div"
|
tag="div"
|
||||||
@@ -258,8 +258,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { theme } = useWrappedTheme()
|
const { theme } = useWrappedTheme()
|
||||||
const isGameboy = computed(() => theme.value === 'gameboy')
|
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||||
const isDos = computed(() => theme.value === 'dos')
|
const isRetro = computed(() => isGameboy.value)
|
||||||
const isRetro = computed(() => isGameboy.value || isDos.value)
|
|
||||||
|
|
||||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 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;
|
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 {
|
.race-move {
|
||||||
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- CRT 滤镜叠加层 - 复古主题使用 -->
|
<!-- CRT 滤镜叠加层 - 复古主题使用 -->
|
||||||
<div class="absolute inset-0 pointer-events-none select-none z-30" aria-hidden="true">
|
<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 子像素 / 闪烁 / 暗角 / 曲率 -->
|
<!-- 扫描线 / RGB 子像素 / 闪烁 / 暗角 / 曲率 -->
|
||||||
<div class="absolute inset-0 crt-scanlines"></div>
|
<div class="absolute inset-0 crt-scanlines"></div>
|
||||||
<div class="absolute inset-0 crt-rgb-pixels"></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-vignette"></div>
|
||||||
<div class="absolute inset-0 crt-curvature"></div>
|
<div class="absolute inset-0 crt-curvature"></div>
|
||||||
|
|
||||||
<!-- DOS: 语义化光标 -->
|
|
||||||
<div v-if="theme === 'dos'" class="dos-cursor">█</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { theme } = useWrappedTheme()
|
const { theme } = useWrappedTheme()
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ defineProps({
|
|||||||
const { theme } = useWrappedTheme()
|
const { theme } = useWrappedTheme()
|
||||||
const isWin98 = computed(() => theme.value === 'win98')
|
const isWin98 = computed(() => theme.value === 'win98')
|
||||||
const isGameboy = computed(() => theme.value === 'gameboy')
|
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||||
const isDos = computed(() => theme.value === 'dos')
|
const isCompactSlide = computed(() => isGameboy.value)
|
||||||
const isCompactSlide = computed(() => isGameboy.value || isDos.value)
|
|
||||||
|
|
||||||
const slideTitleClass = computed(() => (
|
const slideTitleClass = computed(() => (
|
||||||
isCompactSlide.value ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'
|
isCompactSlide.value ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'
|
||||||
@@ -142,39 +141,4 @@ const slideContainerClass = computed(() => (
|
|||||||
border-color: #306230 !important;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -131,30 +131,6 @@ const yearOptions = computed(() => {
|
|||||||
filter: brightness(1.1);
|
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 特殊样式 */
|
/* Win98 特殊样式 */
|
||||||
.wrapped-theme-win98 .controls-panel {
|
.wrapped-theme-win98 .controls-panel {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<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) -->
|
<!-- 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>
|
<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]"
|
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>
|
></div>
|
||||||
|
|
||||||
<!-- Grain/noise: enhanced with dynamic jitter for CRT feel -->
|
<!-- Grain/noise: gameboy 使用动态 canvas 噪点,其它主题沿用现有纹理 -->
|
||||||
<div class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></div>
|
<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 -->
|
<!-- 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>
|
<div class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/50 to-transparent"></div>
|
||||||
|
|||||||
98
frontend/components/wrapped/shared/WrappedGameboyDither.vue
Normal file
98
frontend/components/wrapped/shared/WrappedGameboyDither.vue
Normal 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>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10 sm:mt-14">
|
<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 }}
|
{{ randomTitle.main }}
|
||||||
<span class="block mt-3 win98-hero-highlight">
|
<span class="block mt-3 win98-hero-highlight">
|
||||||
{{ randomTitle.highlight }}
|
{{ randomTitle.highlight }}
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10 sm:mt-14">
|
<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 }}
|
{{ randomTitle.main }}
|
||||||
<span class="block mt-3 text-[#07C160]">
|
<span class="block mt-3 text-[#07C160]">
|
||||||
{{ randomTitle.highlight }}
|
{{ randomTitle.highlight }}
|
||||||
@@ -404,8 +404,8 @@ const modernPreviewItems = computed(() => {
|
|||||||
|
|
||||||
const previewStageClass = computed(() => (
|
const previewStageClass = computed(() => (
|
||||||
isGameboy.value
|
isGameboy.value
|
||||||
? 'w-[500px] h-[360px] translate-x-12 -translate-y-8'
|
? 'w-[500px] h-[360px] translate-x-24 -translate-y-8'
|
||||||
: 'w-[620px] h-[420px] translate-x-20 -translate-y-10'
|
: 'w-[620px] h-[420px] translate-x-32 -translate-y-10'
|
||||||
))
|
))
|
||||||
|
|
||||||
const previewViewportClass = computed(() => (
|
const previewViewportClass = computed(() => (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const themeSwitcherComponent = computed(() => {
|
|||||||
const map = {
|
const map = {
|
||||||
off: resolveComponent('WrappedThemeSwitcherModern'),
|
off: resolveComponent('WrappedThemeSwitcherModern'),
|
||||||
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
|
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
|
||||||
dos: resolveComponent('WrappedThemeSwitcherDos'),
|
|
||||||
win98: resolveComponent('WrappedThemeSwitcherWin98')
|
win98: resolveComponent('WrappedThemeSwitcherWin98')
|
||||||
}
|
}
|
||||||
return map[theme.value] || map.off
|
return map[theme.value] || map.off
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
|
|||||||
const themes = [
|
const themes = [
|
||||||
{ value: 'off', label: 'MODERN' },
|
{ value: 'off', label: 'MODERN' },
|
||||||
{ value: 'gameboy', label: 'GAME BOY' },
|
{ value: 'gameboy', label: 'GAME BOY' },
|
||||||
{ value: 'dos', label: 'DOS' },
|
|
||||||
{ value: 'win98', label: 'WIN98' }
|
{ value: 'win98', label: 'WIN98' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
|
|||||||
const themes = [
|
const themes = [
|
||||||
{ value: 'off', label: 'Modern' },
|
{ value: 'off', label: 'Modern' },
|
||||||
{ value: 'gameboy', label: 'Game Boy' },
|
{ value: 'gameboy', label: 'Game Boy' },
|
||||||
{ value: 'dos', label: 'DOS' },
|
|
||||||
{ value: 'win98', label: 'Win98' }
|
{ value: 'win98', label: 'Win98' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const { theme, setTheme } = useWrappedTheme()
|
|||||||
const themes = [
|
const themes = [
|
||||||
{ value: 'off', label: 'Modern' },
|
{ value: 'off', label: 'Modern' },
|
||||||
{ value: 'gameboy', label: 'Game Boy' },
|
{ value: 'gameboy', label: 'Game Boy' },
|
||||||
{ value: 'dos', label: 'DOS' },
|
|
||||||
{ value: 'win98', label: 'Win98' }
|
{ value: 'win98', label: 'Win98' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,25 +19,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DOS 风格 -->
|
|
||||||
<div v-else-if="theme === 'dos'" class="year-dos">
|
|
||||||
<span class="dos-prompt">C:\WRAPPED></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 风格 -->
|
<!-- Win98 风格 -->
|
||||||
<div v-else-if="theme === 'win98'" class="year-win98">
|
<div v-else-if="theme === 'win98'" class="year-win98">
|
||||||
<div class="win98-year-box">
|
<div class="win98-year-box">
|
||||||
@@ -209,57 +190,6 @@ onBeforeUnmount(() => {
|
|||||||
letter-spacing: 2px;
|
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 风格 ========== */
|
/* ========== Win98 风格 ========== */
|
||||||
.year-win98 {
|
.year-win98 {
|
||||||
font-family: "MS Sans Serif", Tahoma, "Microsoft Sans Serif", "Segoe UI", sans-serif;
|
font-family: "MS Sans Serif", Tahoma, "Microsoft Sans Serif", "Segoe UI", sans-serif;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 年度总结页面主题管理 composable
|
* 年度总结页面主题管理 composable
|
||||||
* 支持三种主题:modern(现代)、gameboy(Game Boy)、dos(DOS终端)
|
* 支持三种主题:modern(现代)、gameboy(Game Boy)、win98(Windows 98)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'wrapped-theme'
|
const STORAGE_KEY = 'wrapped-theme'
|
||||||
const VALID_THEMES = ['off', 'gameboy', 'dos', 'win98']
|
const VALID_THEMES = ['off', 'gameboy', 'win98']
|
||||||
const RETRO_THEMES = new Set(['gameboy', 'dos'])
|
const RETRO_THEMES = new Set(['gameboy'])
|
||||||
|
|
||||||
// 全局响应式状态(跨组件共享)
|
// 全局响应式状态(跨组件共享)
|
||||||
const theme = ref('off')
|
const theme = ref('off')
|
||||||
@@ -15,19 +15,12 @@ let keyboardInitialized = false
|
|||||||
export function useWrappedTheme() {
|
export function useWrappedTheme() {
|
||||||
// 初始化:从 localStorage 读取(仅执行一次)
|
// 初始化:从 localStorage 读取(仅执行一次)
|
||||||
const initTheme = () => {
|
const initTheme = () => {
|
||||||
if (initialized) return
|
if (initialized || !import.meta.client) return
|
||||||
if (import.meta.client) {
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
if (saved && VALID_THEMES.includes(saved)) {
|
||||||
if (saved && VALID_THEMES.includes(saved)) {
|
theme.value = saved
|
||||||
theme.value = saved
|
|
||||||
}
|
|
||||||
initialized = true
|
|
||||||
}
|
}
|
||||||
}
|
initialized = true
|
||||||
|
|
||||||
// 立即初始化(客户端)
|
|
||||||
if (import.meta.client) {
|
|
||||||
initTheme()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置主题
|
// 设置主题
|
||||||
@@ -66,7 +59,6 @@ export function useWrappedTheme() {
|
|||||||
const names = {
|
const names = {
|
||||||
off: 'Modern',
|
off: 'Modern',
|
||||||
gameboy: 'Game Boy',
|
gameboy: 'Game Boy',
|
||||||
dos: 'DOS Terminal',
|
|
||||||
win98: 'Windows 98'
|
win98: 'Windows 98'
|
||||||
}
|
}
|
||||||
return names[theme.value] || 'Modern'
|
return names[theme.value] || 'Modern'
|
||||||
@@ -91,9 +83,6 @@ export function useWrappedTheme() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTheme('gameboy')
|
setTheme('gameboy')
|
||||||
} else if (e.key === 'F3') {
|
} else if (e.key === 'F3') {
|
||||||
e.preventDefault()
|
|
||||||
setTheme('dos')
|
|
||||||
} else if (e.key === 'F4') {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTheme('win98')
|
setTheme('win98')
|
||||||
}
|
}
|
||||||
@@ -102,10 +91,11 @@ export function useWrappedTheme() {
|
|||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动初始化键盘快捷键
|
// 客户端挂载后再初始化:避免 SSR 与首帧 hydration 不一致
|
||||||
if (import.meta.client) {
|
onMounted(() => {
|
||||||
|
initTheme()
|
||||||
initKeyboardShortcuts()
|
initKeyboardShortcuts()
|
||||||
}
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: readonly(theme),
|
theme: readonly(theme),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="deckEl"
|
ref="deckEl"
|
||||||
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
|
class="wrapped-deck-root relative h-screen w-full overflow-hidden transition-colors duration-500"
|
||||||
:class="themeClass"
|
:class="themeClass"
|
||||||
:style="{ backgroundColor: currentBg }"
|
:style="{ backgroundColor: currentBg }"
|
||||||
>
|
>
|
||||||
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
||||||
<WrappedDeckBackground />
|
<WrappedDeckBackground />
|
||||||
<!-- CRT 叠加层仅用于“像素屏/终端”类主题,Win98 等桌面 GUI 主题不应开启 -->
|
<!-- CRT 叠加层仅用于“像素屏”类主题,Win98 等桌面 GUI 主题不应开启 -->
|
||||||
<WrappedCRTOverlay v-if="theme === 'gameboy' || theme === 'dos'" />
|
<WrappedCRTOverlay v-if="theme === 'gameboy'" />
|
||||||
|
|
||||||
<!-- 左上角:刷新 + 复古模式开关 -->
|
<!-- 左上角:刷新 + 复古模式开关 -->
|
||||||
<div class="absolute top-6 left-6 z-20 select-none">
|
<div class="absolute top-6 left-6 z-20 select-none">
|
||||||
@@ -81,7 +81,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative z-10 h-full w-full will-change-transform transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
class="relative h-full w-full will-change-transform transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
:class="deckTrackClass"
|
||||||
:style="trackStyle"
|
:style="trackStyle"
|
||||||
>
|
>
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
@@ -201,7 +202,7 @@ const year = ref(Number(route.query?.year) || new Date().getFullYear())
|
|||||||
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
||||||
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
||||||
|
|
||||||
// 主题管理:modern / gameboy / dos
|
// 主题管理:modern / gameboy / win98
|
||||||
const { theme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
|
const { theme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
|
||||||
|
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
@@ -232,12 +233,12 @@ const activeIndex = ref(0)
|
|||||||
const navLocked = ref(false)
|
const navLocked = ref(false)
|
||||||
const wheelAcc = ref(0)
|
const wheelAcc = ref(0)
|
||||||
let navUnlockTimer = null
|
let navUnlockTimer = null
|
||||||
|
let deckResizeObserver = null
|
||||||
|
|
||||||
// 各主题的背景颜色
|
// 各主题的背景颜色
|
||||||
const THEME_BG = {
|
const THEME_BG = {
|
||||||
off: '#F3FFF8', // Modern: 浅绿
|
off: '#F3FFF8', // Modern: 浅绿
|
||||||
gameboy: '#9bbc0f', // Game Boy: 亮绿
|
gameboy: '#9bbc0f', // Game Boy: 亮绿
|
||||||
dos: '#0a0a0a', // DOS: 黑色
|
|
||||||
win98: '#008080' // Win98: 经典桌面青色
|
win98: '#008080' // Win98: 经典桌面青色
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +258,14 @@ const taskbarTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
|
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
|
||||||
|
const deckTrackClass = computed(() => 'z-10')
|
||||||
|
|
||||||
|
const applyViewportBg = () => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const bg = currentBg.value
|
||||||
|
document.documentElement.style.backgroundColor = bg
|
||||||
|
document.body.style.backgroundColor = bg
|
||||||
|
}
|
||||||
|
|
||||||
const slideStyle = computed(() => (
|
const slideStyle = computed(() => (
|
||||||
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
|
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
|
||||||
@@ -381,7 +390,7 @@ const onTouchEnd = (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateViewport = () => {
|
const updateViewport = () => {
|
||||||
const h = deckEl.value?.clientHeight || window.innerHeight || 0
|
const h = Math.round(deckEl.value?.getBoundingClientRect?.().height || deckEl.value?.clientHeight || window.innerHeight || 0)
|
||||||
if (!h) return
|
if (!h) return
|
||||||
// Reserve space for the Win98 taskbar at the bottom.
|
// Reserve space for the Win98 taskbar at the bottom.
|
||||||
const offset = theme.value === 'win98' ? 40 : 0
|
const offset = theme.value === 'win98' ? 40 : 0
|
||||||
@@ -530,7 +539,14 @@ watch(activeIndex, (i) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
applyViewportBg()
|
||||||
updateViewport()
|
updateViewport()
|
||||||
|
if (import.meta.client && typeof ResizeObserver !== 'undefined' && deckEl.value) {
|
||||||
|
deckResizeObserver = new ResizeObserver(() => {
|
||||||
|
updateViewport()
|
||||||
|
})
|
||||||
|
deckResizeObserver.observe(deckEl.value)
|
||||||
|
}
|
||||||
window.addEventListener('resize', updateViewport)
|
window.addEventListener('resize', updateViewport)
|
||||||
// passive:false 才能 preventDefault,避免外层容器产生滚动/回弹
|
// passive:false 才能 preventDefault,避免外层容器产生滚动/回弹
|
||||||
deckEl.value?.addEventListener('wheel', onWheel, { passive: false })
|
deckEl.value?.addEventListener('wheel', onWheel, { passive: false })
|
||||||
@@ -547,10 +563,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Theme switch may change reserved UI space (e.g., Win98 taskbar)
|
// Theme switch may change reserved UI space (e.g., Win98 taskbar)
|
||||||
watch(theme, () => {
|
watch(theme, () => {
|
||||||
|
applyViewportBg()
|
||||||
updateViewport()
|
updateViewport()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.documentElement.style.backgroundColor = ''
|
||||||
|
document.body.style.backgroundColor = ''
|
||||||
|
}
|
||||||
|
deckResizeObserver?.disconnect()
|
||||||
|
deckResizeObserver = null
|
||||||
window.removeEventListener('resize', updateViewport)
|
window.removeEventListener('resize', updateViewport)
|
||||||
deckEl.value?.removeEventListener('wheel', onWheel)
|
deckEl.value?.removeEventListener('wheel', onWheel)
|
||||||
window.removeEventListener('keydown', onKeydown)
|
window.removeEventListener('keydown', onKeydown)
|
||||||
@@ -578,3 +601,15 @@ watch(year, async (newYear, oldYear) => {
|
|||||||
await reload()
|
await reload()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapped-deck-root {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-desktop .wechat-desktop-content > .wrapped-deck-root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user