mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-20 06:40:49 +08:00
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:
@@ -1147,3 +1147,362 @@
|
|||||||
.wrapped-animate-in {
|
.wrapped-animate-in {
|
||||||
animation: wrapped-fade-in 0.6s ease-out forwards;
|
animation: wrapped-fade-in 0.6s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Wrapped 三主题系统 - Game Boy / DOS / VHS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* 复古模式共享基础样式 */
|
||||||
|
.wrapped-retro {
|
||||||
|
/* 共享 CSS 变量(各主题覆盖) */
|
||||||
|
--wrapped-bg: #9bbc0f;
|
||||||
|
--wrapped-card-bg: #8bac0f;
|
||||||
|
--wrapped-text: #0f380f;
|
||||||
|
--wrapped-text-secondary: #306230;
|
||||||
|
--wrapped-accent: #0f380f;
|
||||||
|
--wrapped-border: #306230;
|
||||||
|
--wrapped-warning: #0f380f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Theme 1: Game Boy - 复古绿色系
|
||||||
|
============================================ */
|
||||||
|
.wrapped-theme-gameboy {
|
||||||
|
/* Game Boy 4色调色板 */
|
||||||
|
--wrapped-bg: #9bbc0f; /* 最亮绿 */
|
||||||
|
--wrapped-card-bg: #8bac0f; /* 次亮绿 */
|
||||||
|
--wrapped-text: #0f380f; /* 最深绿 */
|
||||||
|
--wrapped-text-secondary: #306230; /* 中深绿 */
|
||||||
|
--wrapped-accent: #0f380f;
|
||||||
|
--wrapped-border: #306230;
|
||||||
|
--wrapped-warning: #306230;
|
||||||
|
|
||||||
|
/* 像素化渲染 */
|
||||||
|
image-rendering: pixelated;
|
||||||
|
-webkit-font-smoothing: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Boy 像素化边框 */
|
||||||
|
.wrapped-theme-gameboy .wrapped-card-shell,
|
||||||
|
.wrapped-theme-gameboy [class*="rounded"] {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset -4px -4px 0 0 #306230,
|
||||||
|
inset 4px 4px 0 0 #9bbc0f,
|
||||||
|
0 0 0 4px #0f380f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Boy 步进动画 */
|
||||||
|
.wrapped-theme-gameboy * {
|
||||||
|
animation-timing-function: steps(8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Boy 按钮样式 */
|
||||||
|
.wrapped-theme-gameboy button {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset -2px -2px 0 0 #306230,
|
||||||
|
inset 2px 2px 0 0 #9bbc0f;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy button:active {
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 0 #306230,
|
||||||
|
inset -2px -2px 0 0 #9bbc0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Theme 2: DOS Terminal - 黑底绿字/琥珀字
|
||||||
|
============================================ */
|
||||||
|
.wrapped-theme-dos {
|
||||||
|
--wrapped-bg: #000000;
|
||||||
|
--wrapped-card-bg: #0a0a0a;
|
||||||
|
--wrapped-text: #33ff33; /* 磷光绿 */
|
||||||
|
--wrapped-text-secondary: #22aa22;
|
||||||
|
--wrapped-accent: #33ff33;
|
||||||
|
--wrapped-border: #33ff33;
|
||||||
|
--wrapped-warning: #ffaa00; /* 琥珀警告色 */
|
||||||
|
|
||||||
|
background-color: #000000 !important;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS 文字发光效果 */
|
||||||
|
.wrapped-theme-dos .wrapped-title,
|
||||||
|
.wrapped-theme-dos .wrapped-body,
|
||||||
|
.wrapped-theme-dos .wrapped-label,
|
||||||
|
.wrapped-theme-dos .wrapped-number {
|
||||||
|
color: #33ff33 !important;
|
||||||
|
text-shadow:
|
||||||
|
0 0 5px #33ff33,
|
||||||
|
0 0 10px #33ff33,
|
||||||
|
0 0 20px #33ff33,
|
||||||
|
0 0 40px #22aa22;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace !important;
|
||||||
|
-webkit-font-smoothing: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS 闪烁光标 */
|
||||||
|
.wrapped-theme-dos::after {
|
||||||
|
content: '█';
|
||||||
|
color: #33ff33;
|
||||||
|
animation: dos-cursor-blink 530ms steps(1) infinite;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-shadow: 0 0 10px #33ff33;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dos-cursor-blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS ASCII 边框 */
|
||||||
|
.wrapped-theme-dos .wrapped-card-shell,
|
||||||
|
.wrapped-theme-dos [class*="border"] {
|
||||||
|
border: 2px solid #33ff33 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 5px #33ff33,
|
||||||
|
inset 0 0 5px rgba(51, 255, 51, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS 磷光残影效果 */
|
||||||
|
.wrapped-theme-dos * {
|
||||||
|
transition: text-shadow 0.1s ease-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS 扫描线(更明显) */
|
||||||
|
.wrapped-theme-dos .crt-scanlines {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.4) 2px,
|
||||||
|
rgba(0, 0, 0, 0.4) 4px
|
||||||
|
) !important;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOS 按钮样式 */
|
||||||
|
.wrapped-theme-dos button {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: 1px solid #33ff33 !important;
|
||||||
|
color: #33ff33 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
text-shadow: 0 0 5px #33ff33;
|
||||||
|
box-shadow: 0 0 5px rgba(51, 255, 51, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos button:hover {
|
||||||
|
background-color: #33ff33 !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Theme 3: VHS Tape - 色彩溢出与信号干扰
|
||||||
|
============================================ */
|
||||||
|
.wrapped-theme-vhs {
|
||||||
|
--wrapped-bg: #1a1a2e;
|
||||||
|
--wrapped-card-bg: #16213e;
|
||||||
|
--wrapped-text: #eaeaea;
|
||||||
|
--wrapped-text-secondary: #a0a0a0;
|
||||||
|
--wrapped-accent: #e94560;
|
||||||
|
--wrapped-border: #0f3460;
|
||||||
|
--wrapped-warning: #f39c12;
|
||||||
|
|
||||||
|
background-color: #1a1a2e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 色彩溢出(Chromatic Aberration) */
|
||||||
|
.wrapped-theme-vhs .wrapped-title,
|
||||||
|
.wrapped-theme-vhs .wrapped-number {
|
||||||
|
position: relative;
|
||||||
|
color: #eaeaea !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-title::before,
|
||||||
|
.wrapped-theme-vhs .wrapped-number::before {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
top: 0;
|
||||||
|
color: #00fff7;
|
||||||
|
opacity: 0.7;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-title::after,
|
||||||
|
.wrapped-theme-vhs .wrapped-number::after {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 0;
|
||||||
|
color: #ff00ff;
|
||||||
|
opacity: 0.7;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 水平条纹滚动 */
|
||||||
|
.wrapped-theme-vhs::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 4px,
|
||||||
|
rgba(255, 255, 255, 0.03) 4px,
|
||||||
|
rgba(255, 255, 255, 0.03) 8px
|
||||||
|
);
|
||||||
|
animation: vhs-scanlines 8s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhs-scanlines {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
100% { transform: translateY(100px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 信号干扰/故障动画 */
|
||||||
|
.wrapped-theme-vhs .wrapped-card-shell {
|
||||||
|
animation: vhs-glitch 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhs-glitch {
|
||||||
|
0%, 95%, 100% {
|
||||||
|
transform: translate(0);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
96% {
|
||||||
|
transform: translate(-2px, 1px);
|
||||||
|
filter: hue-rotate(90deg);
|
||||||
|
}
|
||||||
|
97% {
|
||||||
|
transform: translate(2px, -1px);
|
||||||
|
filter: hue-rotate(-90deg) saturate(1.5);
|
||||||
|
}
|
||||||
|
98% {
|
||||||
|
transform: translate(-1px, 2px);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
99% {
|
||||||
|
transform: translate(1px, -2px);
|
||||||
|
filter: contrast(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 降低对比度和饱和度 */
|
||||||
|
.wrapped-theme-vhs img,
|
||||||
|
.wrapped-theme-vhs svg {
|
||||||
|
filter: saturate(0.8) contrast(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 时间戳样式 */
|
||||||
|
.wrapped-theme-vhs .vhs-timestamp {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-family: 'VCR OSD Mono', 'Courier New', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-shadow: 2px 2px 0 #ff0000, -2px -2px 0 #00ffff;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 跟踪线效果 */
|
||||||
|
.wrapped-theme-vhs .vhs-tracking {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.8) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: vhs-tracking 2s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhs-tracking {
|
||||||
|
0%, 100% {
|
||||||
|
top: -10px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
top: calc(100vh + 10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 边框样式 */
|
||||||
|
.wrapped-theme-vhs [class*="border"] {
|
||||||
|
border-color: #0f3460 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs [class*="rounded"] {
|
||||||
|
border-radius: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS 按钮样式 */
|
||||||
|
.wrapped-theme-vhs button {
|
||||||
|
background: linear-gradient(135deg, #e94560 0%, #0f3460 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
box-shadow: 0 2px 10px rgba(233, 69, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VHS REC 指示器 */
|
||||||
|
.wrapped-theme-vhs .vhs-rec {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: 'VCR OSD Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #ff0000;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .vhs-rec::before {
|
||||||
|
content: '●';
|
||||||
|
animation: vhs-rec-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhs-rec-blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- CRT 滤镜叠加层 - 模拟老电视机效果 -->
|
<!-- CRT/VHS 滤镜叠加层 - 根据主题切换效果 -->
|
||||||
<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 / DOS: 扫描线层 -->
|
||||||
<div class="absolute inset-0 crt-scanlines"></div>
|
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-scanlines"></div>
|
||||||
|
|
||||||
<!-- RGB 子像素层 - 模拟 CRT 像素结构 -->
|
<!-- Game Boy / DOS: RGB 子像素层 -->
|
||||||
<div class="absolute inset-0 crt-rgb-pixels"></div>
|
<div v-if="theme !== 'vhs'" class="absolute inset-0 crt-rgb-pixels"></div>
|
||||||
|
|
||||||
<!-- 闪烁层 - 轻微亮度波动 -->
|
<!-- Game Boy / DOS: 闪烁层 -->
|
||||||
<div class="absolute inset-0 crt-flicker"></div>
|
<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-vignette"></div>
|
||||||
|
|
||||||
<!-- 屏幕曲率层 - 边缘微暗模拟曲面 -->
|
<!-- Game Boy / DOS: 屏幕曲率层 -->
|
||||||
<div class="absolute inset-0 crt-curvature"></div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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>
|
</script>
|
||||||
|
|||||||
@@ -50,3 +50,107 @@ defineProps({
|
|||||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<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 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 sm:justify-between">
|
||||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||||
<div v-if="showAccount">
|
<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
|
<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"
|
:disabled="accountsLoading || accounts.length === 0"
|
||||||
:value="modelAccount"
|
:value="modelAccount"
|
||||||
@change="$emit('update:account', $event.target.value || '')"
|
@change="$emit('update:account', $event.target.value || '')"
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<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)"
|
:value="String(modelYear)"
|
||||||
@change="$emit('update:year', Number($event.target.value))"
|
@change="$emit('update:year', Number($event.target.value))"
|
||||||
>
|
>
|
||||||
@@ -30,17 +30,18 @@
|
|||||||
<label class="inline-flex items-center gap-2 select-none">
|
<label class="inline-flex items-center gap-2 select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
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"
|
:checked="modelRefresh"
|
||||||
@change="$emit('update:refresh', !!$event.target.checked)"
|
@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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 items-end">
|
||||||
|
<WrappedThemeSwitcher />
|
||||||
<button
|
<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"
|
:disabled="loading"
|
||||||
@click="$emit('reload')"
|
@click="$emit('reload')"
|
||||||
>
|
>
|
||||||
@@ -50,10 +51,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ? '正在加载账号列表...' : '正在检查数据...' }}
|
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
|
||||||
</div>
|
</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 ? '未发现已解密账号(请先解密数据库)。' : '未发现可用数据(请先解密数据库)。' }}
|
{{ showAccount ? '未发现已解密账号(请先解密数据库)。' : '未发现可用数据(请先解密数据库)。' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,3 +83,99 @@ const yearOptions = computed(() => {
|
|||||||
return years
|
return years
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
18
frontend/components/wrapped/shared/WrappedThemeSwitcher.vue
Normal file
18
frontend/components/wrapped/shared/WrappedThemeSwitcher.vue
Normal 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>
|
||||||
100
frontend/components/wrapped/shared/WrappedThemeSwitcherDos.vue
Normal file
100
frontend/components/wrapped/shared/WrappedThemeSwitcherDos.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
126
frontend/components/wrapped/shared/WrappedThemeSwitcherVhs.vue
Normal file
126
frontend/components/wrapped/shared/WrappedThemeSwitcherVhs.vue
Normal 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>
|
||||||
334
frontend/components/wrapped/shared/WrappedYearSelector.vue
Normal file
334
frontend/components/wrapped/shared/WrappedYearSelector.vue
Normal 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></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>
|
||||||
115
frontend/composables/useWrappedTheme.js
Normal file
115
frontend/composables/useWrappedTheme.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 年度总结页面主题管理 composable
|
||||||
|
* 支持四种主题:modern(现代)、gameboy(Game Boy)、dos(DOS终端)、vhs(VHS录像带)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'wrapped-theme'
|
||||||
|
const VALID_THEMES = ['off', 'gameboy', 'dos', 'vhs']
|
||||||
|
|
||||||
|
// 全局响应式状态(跨组件共享)
|
||||||
|
const theme = ref('off')
|
||||||
|
let initialized = false
|
||||||
|
let keyboardInitialized = false
|
||||||
|
|
||||||
|
export function useWrappedTheme() {
|
||||||
|
// 初始化:从 localStorage 读取(仅执行一次)
|
||||||
|
const initTheme = () => {
|
||||||
|
if (initialized) return
|
||||||
|
if (import.meta.client) {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved && VALID_THEMES.includes(saved)) {
|
||||||
|
theme.value = saved
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即初始化(客户端)
|
||||||
|
if (import.meta.client) {
|
||||||
|
initTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置主题
|
||||||
|
const setTheme = (newTheme) => {
|
||||||
|
if (!VALID_THEMES.includes(newTheme)) {
|
||||||
|
console.warn(`Invalid theme: ${newTheme}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
theme.value = newTheme
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到下一个主题(循环)
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const currentIndex = VALID_THEMES.indexOf(theme.value)
|
||||||
|
const nextIndex = (currentIndex + 1) % VALID_THEMES.length
|
||||||
|
setTheme(VALID_THEMES[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性:是否为复古模式(非 off)
|
||||||
|
const isRetro = computed(() => theme.value !== 'off')
|
||||||
|
|
||||||
|
// 计算属性:当前主题的 CSS 类名
|
||||||
|
const themeClass = computed(() => {
|
||||||
|
if (theme.value === 'off') return ''
|
||||||
|
return `wrapped-retro wrapped-theme-${theme.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:主题显示名称
|
||||||
|
const themeName = computed(() => {
|
||||||
|
const names = {
|
||||||
|
off: 'Modern',
|
||||||
|
gameboy: 'Game Boy',
|
||||||
|
dos: 'DOS Terminal',
|
||||||
|
vhs: 'VHS Tape'
|
||||||
|
}
|
||||||
|
return names[theme.value] || 'Modern'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局 F1-F4 快捷键切换主题(仅初始化一次)
|
||||||
|
const initKeyboardShortcuts = () => {
|
||||||
|
if (keyboardInitialized || !import.meta.client) return
|
||||||
|
keyboardInitialized = true
|
||||||
|
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
// 检查是否在可编辑元素中
|
||||||
|
const el = e.target
|
||||||
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'F1') {
|
||||||
|
e.preventDefault()
|
||||||
|
setTheme('off')
|
||||||
|
} else if (e.key === 'F2') {
|
||||||
|
e.preventDefault()
|
||||||
|
setTheme('gameboy')
|
||||||
|
} else if (e.key === 'F3') {
|
||||||
|
e.preventDefault()
|
||||||
|
setTheme('dos')
|
||||||
|
} else if (e.key === 'F4') {
|
||||||
|
e.preventDefault()
|
||||||
|
setTheme('vhs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动初始化键盘快捷键
|
||||||
|
if (import.meta.client) {
|
||||||
|
initKeyboardShortcuts()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: readonly(theme),
|
||||||
|
setTheme,
|
||||||
|
cycleTheme,
|
||||||
|
isRetro,
|
||||||
|
themeClass,
|
||||||
|
themeName,
|
||||||
|
VALID_THEMES
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
<div
|
<div
|
||||||
ref="deckEl"
|
ref="deckEl"
|
||||||
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
|
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
|
||||||
:class="{ 'wrapped-retro': retro }"
|
:class="themeClass"
|
||||||
:style="{ backgroundColor: currentBg }"
|
:style="{ backgroundColor: currentBg }"
|
||||||
>
|
>
|
||||||
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
||||||
<WrappedDeckBackground />
|
<WrappedDeckBackground />
|
||||||
<WrappedCRTOverlay v-if="retro" />
|
<WrappedCRTOverlay v-if="isRetro" />
|
||||||
|
|
||||||
<!-- 左上角:刷新 + 复古模式开关 -->
|
<!-- 左上角:刷新 + 复古模式开关 -->
|
||||||
<div class="absolute top-6 left-6 z-20 select-none">
|
<div class="absolute top-6 left-6 z-20 select-none">
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
<button
|
<button
|
||||||
type="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="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'"
|
:class="isRetro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
|
||||||
:aria-pressed="retro ? 'true' : 'false'"
|
:aria-pressed="isRetro ? 'true' : 'false'"
|
||||||
aria-label="复古模式(像素字体 + CRT 滤镜)"
|
:aria-label="`复古模式(当前:${theme === 'off' ? 'Modern' : theme.toUpperCase()})`"
|
||||||
title="复古模式:像素字体 + CRT 滤镜"
|
:title="`复古模式:${theme === 'off' ? 'Modern' : theme.toUpperCase()}(点击切换)`"
|
||||||
@click="retro = !retro"
|
@click="cycleTheme"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/images/wechat-audio-dark.png"
|
src="/assets/images/wechat-audio-dark.png"
|
||||||
class="w-4 h-4 transition"
|
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=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@@ -63,36 +63,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右上角:年份(仅可切换有数据的年份) -->
|
<!-- 右上角:年份选择器(主题化) -->
|
||||||
<div class="absolute top-6 right-6 z-20 pointer-events-auto select-none">
|
<div class="absolute top-6 right-6 z-20 pointer-events-auto select-none">
|
||||||
<div class="relative">
|
<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 flex justify-end">
|
||||||
<div class="relative inline-flex items-center">
|
<WrappedYearSelector
|
||||||
<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"
|
v-if="yearOptions.length > 1"
|
||||||
class="pointer-events-none absolute right-1 w-3 h-3 text-[#00000066]"
|
v-model="year"
|
||||||
viewBox="0 0 20 20"
|
:years="yearOptions"
|
||||||
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 v-else class="wrapped-label text-xs text-[#00000066]">{{ year }}年</div>
|
||||||
</div>
|
</div>
|
||||||
</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 class="relative mt-1 h-[1px] w-16 ml-auto bg-gradient-to-l from-[#07C160]/40 to-transparent"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,8 +183,8 @@ 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 : '')
|
||||||
|
|
||||||
// Retro mode: pixel font + CRT overlay.
|
// 主题管理:modern / gameboy / dos / vhs
|
||||||
const retro = ref(true)
|
const { theme, setTheme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
|
||||||
|
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const accountsLoading = ref(true)
|
const accountsLoading = ref(true)
|
||||||
@@ -232,17 +215,22 @@ const navLocked = ref(false)
|
|||||||
const wheelAcc = ref(0)
|
const wheelAcc = ref(0)
|
||||||
let navUnlockTimer = null
|
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 slides = computed(() => {
|
||||||
const cards = Array.isArray(report.value?.cards) ? report.value.cards : []
|
const cards = Array.isArray(report.value?.cards) ? report.value.cards : []
|
||||||
const coverBg = WRAPPED_BG
|
const out = [{ key: 'cover' }]
|
||||||
const out = [{ key: 'cover', bg: coverBg }]
|
for (const c of cards) out.push({ key: `card-${c?.id ?? out.length}` })
|
||||||
for (const c of cards) out.push({ key: `card-${c?.id ?? out.length}`, bg: cardBg(c) })
|
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentBg = computed(() => slides.value?.[activeIndex.value]?.bg || '#ffffff')
|
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
|
||||||
|
|
||||||
const slideStyle = computed(() => (
|
const slideStyle = computed(() => (
|
||||||
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
|
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
|
||||||
@@ -253,12 +241,6 @@ const trackStyle = computed(() => {
|
|||||||
return { transform: `translate3d(0, ${dy}px, 0)` }
|
return { transform: `translate3d(0, ${dy}px, 0)` }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cardBg = (card) => {
|
|
||||||
// 当前统一使用同一套背景色(后续扩展更多卡片时再按 id/kind 细分)。
|
|
||||||
void card
|
|
||||||
return WRAPPED_BG
|
|
||||||
}
|
|
||||||
|
|
||||||
const clampIndex = (i) => {
|
const clampIndex = (i) => {
|
||||||
const max = Math.max(0, slides.value.length - 1)
|
const max = Math.max(0, slides.value.length - 1)
|
||||||
return Math.min(Math.max(0, i), max)
|
return Math.min(Math.max(0, i), max)
|
||||||
@@ -518,34 +500,6 @@ watch(activeIndex, (i) => {
|
|||||||
void ensureCardLoaded(id)
|
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 () => {
|
onMounted(async () => {
|
||||||
updateViewport()
|
updateViewport()
|
||||||
window.addEventListener('resize', updateViewport)
|
window.addEventListener('resize', updateViewport)
|
||||||
@@ -578,4 +532,15 @@ watch(
|
|||||||
activeIndex.value = clampIndex(activeIndex.value)
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user