feat(wrapped-ui): 引入多主题系统与切换器(Modern/Game Boy/DOS/VHS)

- 新增 useWrappedTheme:主题状态全局共享、localStorage 持久化,支持 F1-F4 快捷键与循环切换

- 新增主题切换器组件(Modern/Game Boy/DOS/VHS)与主题化年份选择器

- 年度总结页接入 themeClass/currentBg;CRT 叠加层支持 VHS 效果(REC/时间戳/跟踪线)

- 补充主题全局样式与卡片/控制面板主题适配
This commit is contained in:
2977094657
2026-01-31 19:59:41 +08:00
parent 645dc1cff1
commit b6295071b8
12 changed files with 1483 additions and 98 deletions

View File

@@ -2,12 +2,12 @@
<div
ref="deckEl"
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
:class="{ 'wrapped-retro': retro }"
:class="themeClass"
:style="{ backgroundColor: currentBg }"
>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<WrappedDeckBackground />
<WrappedCRTOverlay v-if="retro" />
<WrappedCRTOverlay v-if="isRetro" />
<!-- 左上角刷新 + 复古模式开关 -->
<div class="absolute top-6 left-6 z-20 select-none">
@@ -40,16 +40,16 @@
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent transition disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30"
:class="retro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
:aria-pressed="retro ? 'true' : 'false'"
aria-label="复古模式像素字体 + CRT 滤镜"
title="复古模式:像素字体 + CRT 滤镜"
@click="retro = !retro"
:class="isRetro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
:aria-pressed="isRetro ? 'true' : 'false'"
:aria-label="`复古模式当前${theme === 'off' ? 'Modern' : theme.toUpperCase()}`"
:title="`复古模式:${theme === 'off' ? 'Modern' : theme.toUpperCase()}(点击切换)`"
@click="cycleTheme"
>
<img
src="/assets/images/wechat-audio-dark.png"
class="w-4 h-4 transition"
:style="{ filter: retro ? 'none' : 'grayscale(1)', opacity: retro ? '1' : '0.55' }"
:style="{ filter: isRetro ? 'none' : 'grayscale(1)', opacity: isRetro ? '1' : '0.55' }"
alt=""
aria-hidden="true"
draggable="false"
@@ -63,36 +63,19 @@
</div>
</div>
<!-- 右上角年份仅可切换有数据的年份 -->
<!-- 右上角年份选择器主题化 -->
<div class="absolute top-6 right-6 z-20 pointer-events-auto select-none">
<div class="relative">
<div class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div v-if="!isRetro" class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div class="relative flex justify-end">
<div class="relative inline-flex items-center">
<select
class="pointer-events-auto appearance-none bg-transparent pr-5 pl-0 py-0.5 rounded-md wrapped-label text-xs text-[#00000066] text-right focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 hover:bg-[#000000]/5 transition disabled:opacity-70 disabled:cursor-default"
:disabled="loading || accountsLoading || yearOptions.length <= 1"
:value="String(year)"
@change="setYear($event.target.value)"
>
<option v-for="y in yearOptions" :key="y" :value="String(y)">{{ y }}</option>
</select>
<svg
v-if="yearOptions.length > 1"
class="pointer-events-none absolute right-1 w-3 h-3 text-[#00000066]"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 10.94l3.71-3.71a.75.75 0 1 1 1.06 1.06l-4.24 4.24a.75.75 0 0 1-1.06 0L5.21 8.29a.75.75 0 0 1 .02-1.08z"
clip-rule="evenodd"
/>
</svg>
</div>
<WrappedYearSelector
v-if="yearOptions.length > 1"
v-model="year"
:years="yearOptions"
/>
<div v-else class="wrapped-label text-xs text-[#00000066]">{{ year }}</div>
</div>
<div class="relative mt-1 h-[1px] w-16 ml-auto bg-gradient-to-l from-[#07C160]/40 to-transparent"></div>
<div v-if="!isRetro" class="relative mt-1 h-[1px] w-16 ml-auto bg-gradient-to-l from-[#07C160]/40 to-transparent"></div>
</div>
</div>
@@ -200,8 +183,8 @@ const year = ref(Number(route.query?.year) || new Date().getFullYear())
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
// Retro mode: pixel font + CRT overlay.
const retro = ref(true)
// 主题管理modern / gameboy / dos / vhs
const { theme, setTheme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
const accounts = ref([])
const accountsLoading = ref(true)
@@ -232,17 +215,22 @@ const navLocked = ref(false)
const wheelAcc = ref(0)
let navUnlockTimer = null
const WRAPPED_BG = '#F3FFF8'
// 各主题的背景颜色
const THEME_BG = {
off: '#F3FFF8', // Modern: 浅绿
gameboy: '#9bbc0f', // Game Boy: 亮绿
dos: '#0a0a0a', // DOS: 黑色
vhs: '#0a0a14' // VHS: 深蓝黑
}
const slides = computed(() => {
const cards = Array.isArray(report.value?.cards) ? report.value.cards : []
const coverBg = WRAPPED_BG
const out = [{ key: 'cover', bg: coverBg }]
for (const c of cards) out.push({ key: `card-${c?.id ?? out.length}`, bg: cardBg(c) })
const out = [{ key: 'cover' }]
for (const c of cards) out.push({ key: `card-${c?.id ?? out.length}` })
return out
})
const currentBg = computed(() => slides.value?.[activeIndex.value]?.bg || '#ffffff')
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
const slideStyle = computed(() => (
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
@@ -253,12 +241,6 @@ const trackStyle = computed(() => {
return { transform: `translate3d(0, ${dy}px, 0)` }
})
const cardBg = (card) => {
// 当前统一使用同一套背景色(后续扩展更多卡片时再按 id/kind 细分)。
void card
return WRAPPED_BG
}
const clampIndex = (i) => {
const max = Math.max(0, slides.value.length - 1)
return Math.min(Math.max(0, i), max)
@@ -518,34 +500,6 @@ watch(activeIndex, (i) => {
void ensureCardLoaded(id)
})
const setYear = async (y) => {
const ny = Number(y)
if (!Number.isFinite(ny)) return
if (ny === year.value) return
// Only allow switching to years that the backend reported as having data.
if (Array.isArray(availableYears.value) && availableYears.value.length > 0 && !availableYears.value.includes(ny)) return
year.value = ny
await reload()
}
onMounted(() => {
try {
const saved = localStorage.getItem('wrapped_retro')
if (saved === '0') retro.value = false
if (saved === '1') retro.value = true
} catch {
// ignore
}
})
watch(retro, (v) => {
try {
localStorage.setItem('wrapped_retro', v ? '1' : '0')
} catch {
// ignore
}
})
onMounted(async () => {
updateViewport()
window.addEventListener('resize', updateViewport)
@@ -578,4 +532,15 @@ watch(
activeIndex.value = clampIndex(activeIndex.value)
}
)
// 监听年份变化(由 WrappedYearSelector v-model 触发)
watch(year, async (newYear, oldYear) => {
if (newYear === oldYear) return
// 仅允许切换到后端报告有数据的年份
if (Array.isArray(availableYears.value) && availableYears.value.length > 0 && !availableYears.value.includes(newYear)) {
year.value = oldYear
return
}
await reload()
})
</script>