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

@@ -1,24 +1,62 @@
<template>
<!-- CRT 滤镜叠加层 - 模拟老电视机效果 -->
<!-- CRT/VHS 滤镜叠加层 - 根据主题切换效果 -->
<div class="absolute inset-0 pointer-events-none select-none z-30" aria-hidden="true">
<!-- 扫描线层 - 水平条纹带滚动动画 -->
<div class="absolute inset-0 crt-scanlines"></div>
<!-- Game Boy / DOS: 扫描线层 -->
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-scanlines"></div>
<!-- RGB 子像素层 - 模拟 CRT 像素结构 -->
<div class="absolute inset-0 crt-rgb-pixels"></div>
<!-- Game Boy / DOS: RGB 子像素层 -->
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-rgb-pixels"></div>
<!-- 闪烁层 - 轻微亮度波动 -->
<div class="absolute inset-0 crt-flicker"></div>
<!-- Game Boy / DOS: 闪烁层 -->
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-flicker"></div>
<!-- 暗角层 - 边缘渐暗效果 -->
<!-- 共享: 暗角层 -->
<div class="absolute inset-0 crt-vignette"></div>
<!-- 屏幕曲率层 - 边缘微暗模拟曲面 -->
<div class="absolute inset-0 crt-curvature"></div>
<!-- Game Boy / DOS: 屏幕曲率层 -->
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-curvature"></div>
<!-- VHS: 跟踪线效果 -->
<div v-if="theme === 'vhs'" class="vhs-tracking"></div>
<!-- VHS: REC 指示器 -->
<div v-if="theme === 'vhs'" class="vhs-rec">REC</div>
<!-- VHS: 时间戳 -->
<div v-if="theme === 'vhs'" class="vhs-timestamp">{{ vhsTimestamp }}</div>
</div>
</template>
<script setup>
// CRT 滤镜叠加层组件
// 通过多层叠加实现复古显像管效果,不修改原始背景
// CRT/VHS 滤镜叠加层组件
// 根据当前主题切换不同的视觉效果
const { theme } = useWrappedTheme()
// VHS 时间戳(实时更新)
const vhsTimestamp = ref('')
const updateTimestamp = () => {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const year = now.getFullYear()
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
vhsTimestamp.value = `${month}/${day}/${year} ${hours}:${minutes}:${seconds}`
}
let timestampInterval = null
onMounted(() => {
updateTimestamp()
timestampInterval = setInterval(updateTimestamp, 1000)
})
onUnmounted(() => {
if (timestampInterval) {
clearInterval(timestampInterval)
}
})
</script>

View File

@@ -50,3 +50,107 @@ defineProps({
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
</script>
<style>
/* ========== Game Boy 主题 ========== */
/* 卡片背景 */
.wrapped-theme-gameboy .bg-white {
background: #9bbc0f !important;
border-color: #306230 !important;
}
/* 标题 */
.wrapped-theme-gameboy .wrapped-title {
color: #0f380f !important;
font-family: var(--font-pixel-10), 'Courier New', monospace;
}
/* 描述文字 */
.wrapped-theme-gameboy .wrapped-body {
color: #306230 !important;
}
/* 数字高亮 */
.wrapped-theme-gameboy .wrapped-number {
color: #0f380f !important;
font-family: var(--font-pixel-10), 'Courier New', monospace;
}
/* 边框 */
.wrapped-theme-gameboy .border-\[\#EDEDED\],
.wrapped-theme-gameboy .border-\[\#F3F3F3\] {
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;
}
/* ========== VHS 主题 ========== */
/* 卡片背景 */
.wrapped-theme-vhs .bg-white {
background: #16213e !important;
border-color: #0f3460 !important;
}
/* 标题 */
.wrapped-theme-vhs .wrapped-title {
color: #eaeaea !important;
text-shadow:
-1px 0 rgba(0, 255, 247, 0.4),
1px 0 rgba(255, 0, 255, 0.4);
}
/* 描述文字 */
.wrapped-theme-vhs .wrapped-body {
color: #a0a0a0 !important;
}
/* 数字高亮 */
.wrapped-theme-vhs .wrapped-number {
color: #e94560 !important;
text-shadow:
-1px 0 rgba(0, 255, 247, 0.5),
1px 0 rgba(255, 0, 255, 0.5);
}
/* 边框 */
.wrapped-theme-vhs .border-\[\#EDEDED\],
.wrapped-theme-vhs .border-\[\#F3F3F3\] {
border-color: #0f3460 !important;
}
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="bg-white rounded-2xl border border-[#EDEDED] p-5 sm:p-6">
<div class="bg-white rounded-2xl border border-[#EDEDED] p-5 sm:p-6 controls-panel">
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row gap-3 sm:items-end sm:justify-between">
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
<div v-if="showAccount">
<div class="wrapped-label text-xs text-[#00000099] mb-1">Account</div>
<div class="wrapped-label text-xs text-[#00000099] mb-1 controls-label">Account</div>
<select
class="w-full sm:w-56 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm wrapped-body focus:outline-none focus:ring-2 focus:ring-[#07C160]"
class="w-full sm:w-56 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm wrapped-body focus:outline-none focus:ring-2 focus:ring-[#07C160] controls-select"
:disabled="accountsLoading || accounts.length === 0"
:value="modelAccount"
@change="$emit('update:account', $event.target.value || '')"
@@ -17,9 +17,9 @@
</div>
<div>
<div class="wrapped-label text-xs text-[#00000099] mb-1">Year</div>
<div class="wrapped-label text-xs text-[#00000099] mb-1 controls-label">Year</div>
<select
class="w-full sm:w-40 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm wrapped-body focus:outline-none focus:ring-2 focus:ring-[#07C160]"
class="w-full sm:w-40 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm wrapped-body focus:outline-none focus:ring-2 focus:ring-[#07C160] controls-select"
:value="String(modelYear)"
@change="$emit('update:year', Number($event.target.value))"
>
@@ -30,17 +30,18 @@
<label class="inline-flex items-center gap-2 select-none">
<input
type="checkbox"
class="h-4 w-4 rounded border-[#EDEDED] text-[#07C160] focus:ring-[#07C160]"
class="h-4 w-4 rounded border-[#EDEDED] text-[#07C160] focus:ring-[#07C160] controls-checkbox"
:checked="modelRefresh"
@change="$emit('update:refresh', !!$event.target.checked)"
/>
<span class="wrapped-body text-sm text-[#7F7F7F]">强制刷新忽略缓存</span>
<span class="wrapped-body text-sm text-[#7F7F7F] controls-hint">强制刷新忽略缓存</span>
</label>
</div>
<div class="flex gap-2">
<div class="flex gap-2 items-end">
<WrappedThemeSwitcher />
<button
class="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm wrapped-label hover:bg-[#06AD56] disabled:opacity-60 disabled:cursor-not-allowed transition"
class="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm wrapped-label hover:bg-[#06AD56] disabled:opacity-60 disabled:cursor-not-allowed transition controls-btn"
:disabled="loading"
@click="$emit('reload')"
>
@@ -50,10 +51,10 @@
</div>
</div>
<div v-if="accountsLoading" class="wrapped-body text-xs text-[#7F7F7F]">
<div v-if="accountsLoading" class="wrapped-body text-xs text-[#7F7F7F] controls-hint">
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
</div>
<div v-else-if="accounts.length === 0" class="wrapped-body text-xs text-[#B37800]">
<div v-else-if="accounts.length === 0" class="wrapped-body text-xs text-[#B37800] controls-warning">
{{ showAccount ? '未发现已解密账号请先解密数据库' : '未发现可用数据请先解密数据库' }}
</div>
</div>
@@ -82,3 +83,99 @@ const yearOptions = computed(() => {
return years
})
</script>
<style scoped>
/* 复古模式 - 控制面板样式 */
.wrapped-retro .controls-panel {
background-color: var(--wrapped-card-bg);
border-color: var(--wrapped-border);
}
.wrapped-retro .controls-label {
color: var(--wrapped-text-secondary);
}
.wrapped-retro .controls-select {
background-color: var(--wrapped-bg);
border-color: var(--wrapped-border);
color: var(--wrapped-text);
}
.wrapped-retro .controls-select:focus {
--tw-ring-color: var(--wrapped-accent);
}
.wrapped-retro .controls-checkbox {
border-color: var(--wrapped-border);
color: var(--wrapped-accent);
}
.wrapped-retro .controls-checkbox:focus {
--tw-ring-color: var(--wrapped-accent);
}
.wrapped-retro .controls-hint {
color: var(--wrapped-text-secondary);
}
.wrapped-retro .controls-warning {
color: var(--wrapped-warning);
}
.wrapped-retro .controls-btn {
background-color: var(--wrapped-accent);
color: var(--wrapped-bg);
}
.wrapped-retro .controls-btn:hover:not(:disabled) {
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;
}
/* VHS 特殊样式 */
.wrapped-theme-vhs .controls-panel {
border-radius: 4px;
background: linear-gradient(180deg, #16213e 0%, #1a1a2e 100%);
border-color: #0f3460;
}
.wrapped-theme-vhs .controls-select {
border-radius: 2px;
background: #1a1a2e;
border-color: #0f3460;
color: #eaeaea;
}
.wrapped-theme-vhs .controls-btn {
border-radius: 4px;
background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
color: #ffffff;
}
.wrapped-theme-vhs .controls-btn:hover:not(:disabled) {
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.5);
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<component :is="themeSwitcherComponent" />
</template>
<script setup>
const { theme } = useWrappedTheme()
// 根据当前主题动态选择对应的切换器组件
const themeSwitcherComponent = computed(() => {
const map = {
off: resolveComponent('WrappedThemeSwitcherModern'),
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
dos: resolveComponent('WrappedThemeSwitcherDos'),
vhs: resolveComponent('WrappedThemeSwitcherVhs')
}
return map[theme.value] || map.off
})
</script>

View File

@@ -0,0 +1,100 @@
<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: 'vhs', label: 'VHS' }
]
</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

@@ -0,0 +1,98 @@
<template>
<div class="gameboy-menu select-none">
<!-- 像素风格菜单框 -->
<div class="gameboy-menu-box">
<div class="gameboy-menu-title">SELECT THEME</div>
<div class="gameboy-menu-items">
<button
v-for="t in themes"
:key="t.value"
class="gameboy-menu-item"
:class="{ 'is-active': theme === t.value }"
@click="selectTheme(t.value)"
>
<span class="gameboy-cursor">{{ theme === t.value ? '▶' : ' ' }}</span>
<span class="gameboy-label">{{ t.label }}</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'MODERN' },
{ value: 'gameboy', label: 'GAME BOY' },
{ value: 'dos', label: 'DOS' },
{ value: 'vhs', label: 'VHS' }
]
const selectTheme = (value) => {
setTheme(value)
}
</script>
<style scoped>
.gameboy-menu {
font-family: 'Press Start 2P', 'Courier New', monospace;
font-size: 8px;
line-height: 1.5;
}
.gameboy-menu-box {
background: #0f380f;
border: 3px solid #306230;
padding: 8px;
box-shadow:
inset 2px 2px 0 #9bbc0f,
inset -2px -2px 0 #0f380f;
}
.gameboy-menu-title {
color: #9bbc0f;
text-align: center;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 2px dashed #306230;
}
.gameboy-menu-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.gameboy-menu-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
color: #9bbc0f;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.1s;
font-family: inherit;
font-size: inherit;
}
.gameboy-menu-item:hover {
background: #306230;
}
.gameboy-menu-item.is-active {
color: #8bac0f;
}
.gameboy-cursor {
width: 10px;
display: inline-block;
}
.gameboy-label {
letter-spacing: 1px;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex items-center gap-2">
<span class="text-xs text-[#00000099]">Theme</span>
<div class="inline-flex rounded-lg border border-[#EDEDED] overflow-hidden">
<button
v-for="t in themes"
:key="t.value"
class="px-3 py-1.5 text-xs wrapped-label transition-colors"
:class="[
theme === t.value
? 'bg-[#07C160] text-white'
: 'bg-white text-[#333] hover:bg-[#F5F5F5]'
]"
@click="setTheme(t.value)"
>
{{ t.label }}
</button>
</div>
</div>
</template>
<script setup>
const { theme, setTheme, VALID_THEMES } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'Game Boy' },
{ value: 'dos', label: 'DOS' },
{ value: 'vhs', label: 'VHS' }
]
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="vhs-panel select-none">
<!-- VHS 风格物理按钮组 -->
<div class="vhs-button-group">
<button
v-for="t in themes"
:key="t.value"
class="vhs-button"
:class="{ 'is-active': theme === t.value, 'is-pressed': pressedKey === t.value }"
@mousedown="pressButton(t.value)"
@mouseup="releaseButton"
@mouseleave="releaseButton"
@click="setTheme(t.value)"
>
<span class="vhs-button-face">{{ t.label }}</span>
<span class="vhs-led" :class="{ 'is-on': theme === t.value }"></span>
</button>
</div>
</div>
</template>
<script setup>
const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'MOD' },
{ value: 'gameboy', label: 'GB' },
{ value: 'dos', label: 'DOS' },
{ value: 'vhs', label: 'VHS' }
]
const pressedKey = ref(null)
const pressButton = (value) => {
pressedKey.value = value
}
const releaseButton = () => {
pressedKey.value = null
}
</script>
<style scoped>
.vhs-panel {
font-family: 'Arial', 'Helvetica', sans-serif;
}
.vhs-button-group {
display: flex;
gap: 6px;
padding: 8px 10px;
background: linear-gradient(180deg, #2a2a3e 0%, #1a1a2e 100%);
border-radius: 4px;
border: 1px solid #3a3a5e;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.1),
0 2px 4px rgba(0,0,0,0.3);
}
.vhs-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0;
background: none;
border: none;
cursor: pointer;
}
.vhs-button-face {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 24px;
font-size: 9px;
font-weight: bold;
letter-spacing: 0.5px;
color: #cccccc;
background: linear-gradient(180deg, #4a4a5e 0%, #2a2a3e 50%, #3a3a4e 100%);
border: 1px solid #5a5a7e;
border-radius: 3px;
box-shadow:
0 2px 0 #1a1a2e,
inset 0 1px 0 rgba(255,255,255,0.2);
transition: all 0.05s;
}
.vhs-button:hover .vhs-button-face {
background: linear-gradient(180deg, #5a5a6e 0%, #3a3a4e 50%, #4a4a5e 100%);
}
.vhs-button.is-pressed .vhs-button-face,
.vhs-button:active .vhs-button-face {
transform: translateY(2px);
box-shadow:
0 0 0 #1a1a2e,
inset 0 1px 2px rgba(0,0,0,0.3);
background: linear-gradient(180deg, #3a3a4e 0%, #2a2a3e 50%, #3a3a4e 100%);
}
.vhs-button.is-active .vhs-button-face {
background: linear-gradient(135deg, #e94560 0%, #c73e54 50%, #0f3460 100%);
color: #ffffff;
border-color: #e94560;
}
.vhs-led {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a1a1a;
border: 1px solid #3a3a3a;
transition: all 0.2s;
}
.vhs-led.is-on {
background: #ff3333;
border-color: #ff6666;
box-shadow:
0 0 4px #ff3333,
0 0 8px #ff3333,
0 0 12px rgba(255,51,51,0.5);
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="year-selector" :class="selectorClass">
<!-- Modern 风格下拉菜单 -->
<div v-if="theme === 'off'" class="year-modern">
<div class="relative inline-flex items-center">
<select
class="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="years.length <= 1"
:value="String(modelValue)"
@change="onSelectChange"
>
<option v-for="y in years" :key="y" :value="String(y)">{{ y }}</option>
</select>
<svg
v-if="years.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>
</div>
<!-- Game Boy 风格 -->
<div v-else-if="theme === 'gameboy'" class="year-gameboy">
<div class="gameboy-year-box">
<button
class="gameboy-arrow"
:disabled="!canGoPrev"
@click="prevYear"
aria-label="Previous year"
></button>
<span class="gameboy-year-value">{{ modelValue }}</span>
<button
class="gameboy-arrow"
:disabled="!canGoNext"
@click="nextYear"
aria-label="Next year"
></button>
</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>
<!-- VHS 风格 -->
<div v-else-if="theme === 'vhs'" class="year-vhs">
<button
class="vhs-transport-btn"
:disabled="!canGoPrev"
@click="prevYear"
aria-label="Previous year"
>
<span class="vhs-icon"></span>
</button>
<div class="vhs-led-display">
<span class="vhs-led-digit">{{ modelValue }}</span>
</div>
<button
class="vhs-transport-btn"
:disabled="!canGoNext"
@click="nextYear"
aria-label="Next year"
>
<span class="vhs-icon"></span>
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Number,
required: true
},
years: {
type: Array,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
const { theme } = useWrappedTheme()
const currentIndex = computed(() => props.years.indexOf(props.modelValue))
const canGoPrev = computed(() => currentIndex.value > 0)
const canGoNext = computed(() => currentIndex.value < props.years.length - 1)
const prevYear = () => {
if (canGoPrev.value) {
emit('update:modelValue', props.years[currentIndex.value - 1])
}
}
const nextYear = () => {
if (canGoNext.value) {
emit('update:modelValue', props.years[currentIndex.value + 1])
}
}
const onSelectChange = (e) => {
const val = Number(e.target.value)
if (Number.isFinite(val)) {
emit('update:modelValue', val)
}
}
const selectorClass = computed(() => {
return `year-selector-${theme.value}`
})
// 全局左右键切换年份(所有主题)
const handleKeydown = (e) => {
if (props.years.length <= 1) return
// 检查是否在可编辑元素中
const el = e.target
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) {
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
prevYear()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
nextYear()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
/* ========== Modern 风格 ========== */
.year-modern {
display: flex;
align-items: center;
}
/* ========== Game Boy 风格 ========== */
.year-gameboy {
font-family: 'Press Start 2P', 'Courier New', monospace;
}
.gameboy-year-box {
display: flex;
align-items: center;
gap: 4px;
background: #0f380f;
border: 3px solid #306230;
padding: 6px 8px;
box-shadow:
inset 2px 2px 0 #9bbc0f,
inset -2px -2px 0 #0f380f;
}
.gameboy-arrow {
background: #306230;
border: none;
color: #9bbc0f;
font-size: 8px;
padding: 4px 6px;
cursor: pointer;
transition: background 0.1s;
}
.gameboy-arrow:hover:not(:disabled) {
background: #8bac0f;
color: #0f380f;
}
.gameboy-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.gameboy-year-value {
color: #9bbc0f;
font-size: 10px;
min-width: 40px;
text-align: center;
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;
}
/* ========== VHS 风格 ========== */
.year-vhs {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: linear-gradient(180deg, #2a2a3e 0%, #1a1a2e 100%);
border-radius: 4px;
border: 1px solid #3a3a5e;
}
.vhs-transport-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 22px;
background: linear-gradient(180deg, #4a4a5e 0%, #2a2a3e 50%, #3a3a4e 100%);
border: 1px solid #5a5a7e;
border-radius: 3px;
color: #cccccc;
font-size: 8px;
cursor: pointer;
box-shadow:
0 2px 0 #1a1a2e,
inset 0 1px 0 rgba(255,255,255,0.2);
transition: all 0.05s;
}
.vhs-transport-btn:hover:not(:disabled) {
background: linear-gradient(180deg, #5a5a6e 0%, #3a3a4e 50%, #4a4a5e 100%);
}
.vhs-transport-btn:active:not(:disabled) {
transform: translateY(2px);
box-shadow:
0 0 0 #1a1a2e,
inset 0 1px 2px rgba(0,0,0,0.3);
}
.vhs-transport-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.vhs-icon {
letter-spacing: -2px;
}
.vhs-led-display {
background: #0a0a0a;
border: 1px solid #3a3a3a;
padding: 4px 10px;
border-radius: 2px;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
}
.vhs-led-digit {
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: bold;
color: #ff3333;
text-shadow:
0 0 4px #ff3333,
0 0 8px #ff3333;
letter-spacing: 2px;
}
</style>