+
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
-
+
{{ showAccount ? '未发现已解密账号(请先解密数据库)。' : '未发现可用数据(请先解密数据库)。' }}
@@ -82,3 +83,99 @@ const yearOptions = computed(() => {
return years
})
+
+
diff --git a/frontend/components/wrapped/shared/WrappedThemeSwitcher.vue b/frontend/components/wrapped/shared/WrappedThemeSwitcher.vue
new file mode 100644
index 0000000..bf31e6d
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedThemeSwitcher.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/frontend/components/wrapped/shared/WrappedThemeSwitcherDos.vue b/frontend/components/wrapped/shared/WrappedThemeSwitcherDos.vue
new file mode 100644
index 0000000..9166ce4
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedThemeSwitcherDos.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
diff --git a/frontend/components/wrapped/shared/WrappedThemeSwitcherGameboy.vue b/frontend/components/wrapped/shared/WrappedThemeSwitcherGameboy.vue
new file mode 100644
index 0000000..c33798b
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedThemeSwitcherGameboy.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
diff --git a/frontend/components/wrapped/shared/WrappedThemeSwitcherModern.vue b/frontend/components/wrapped/shared/WrappedThemeSwitcherModern.vue
new file mode 100644
index 0000000..52f401b
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedThemeSwitcherModern.vue
@@ -0,0 +1,31 @@
+
+
+
Theme
+
+
+
+
+
+
+
diff --git a/frontend/components/wrapped/shared/WrappedThemeSwitcherVhs.vue b/frontend/components/wrapped/shared/WrappedThemeSwitcherVhs.vue
new file mode 100644
index 0000000..ae1cf62
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedThemeSwitcherVhs.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/wrapped/shared/WrappedYearSelector.vue b/frontend/components/wrapped/shared/WrappedYearSelector.vue
new file mode 100644
index 0000000..1ddec9b
--- /dev/null
+++ b/frontend/components/wrapped/shared/WrappedYearSelector.vue
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+
+
+
+ {{ modelValue }}
+
+
+
+
+
+
+ C:\WRAPPED>
+ YEAR:
+
+ {{ modelValue }}
+
+
+
+
+
+
+
+ {{ modelValue }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/composables/useWrappedTheme.js b/frontend/composables/useWrappedTheme.js
new file mode 100644
index 0000000..3b949b8
--- /dev/null
+++ b/frontend/composables/useWrappedTheme.js
@@ -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
+ }
+}
diff --git a/frontend/pages/wrapped/index.vue b/frontend/pages/wrapped/index.vue
index ef8b6ae..c4ea32d 100644
--- a/frontend/pages/wrapped/index.vue
+++ b/frontend/pages/wrapped/index.vue
@@ -2,12 +2,12 @@
-
+
@@ -40,16 +40,16 @@
-
+
@@ -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()
+})