Files
WeChatDataAnalysis/frontend/pages/wrapped/index.vue
2977094657 7ce6abecca improvement(wrapped-ui): 移除 VHS 主题并优化 DOS/CRT 视觉效果
- 主题系统收敛为 Modern/Game Boy/DOS(快捷键改为 F1-F3)
- 删除 VHS 切换器与相关样式(卡片/控件/年份选择/图表等)
- DOS 主题统一使用像素字体,减弱发光强度并细化扫描线/闪烁参数
- DOS 闪烁光标改由 WrappedCRTOverlay 渲染,避免全局样式副作用
- 移除热力图 vhs 配色分支
2026-02-01 19:27:51 +08:00

546 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
ref="deckEl"
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
:class="themeClass"
:style="{ backgroundColor: currentBg }"
>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<WrappedDeckBackground />
<WrappedCRTOverlay v-if="isRetro" />
<!-- 左上角刷新 + 复古模式开关 -->
<div class="absolute top-6 left-6 z-20 select-none">
<div class="flex items-center gap-3">
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent text-[#07C160] hover:bg-[#07C160]/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 disabled:opacity-60 disabled:cursor-not-allowed transition"
:disabled="loading || accountsLoading || accounts.length === 0"
aria-label="强制刷新忽略缓存"
title="强制刷新(忽略缓存)"
@click="reload(true)"
>
<!-- Refresh icon (spins while loading) -->
<svg
class="w-4 h-4"
:class="loading ? 'animate-spin' : ''"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-3-6.7" />
<path d="M21 3v7h-7" />
</svg>
</button>
<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="isRetro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
:aria-pressed="isRetro ? 'true' : 'false'"
:aria-label="`复古模式当前${theme === 'off' ? 'Modern' : theme.toUpperCase()}`"
:title="`复古模式:${theme === 'off' ? 'Modern' : theme.toUpperCase()}(点击切换)`"
@click="cycleTheme"
>
<img
src="/assets/images/wechat-audio-dark.png"
class="w-4 h-4 transition"
:style="{ filter: isRetro ? 'none' : 'grayscale(1)', opacity: isRetro ? '1' : '0.55' }"
alt=""
aria-hidden="true"
draggable="false"
/>
</button>
</div>
<div v-if="error" class="mt-2 pointer-events-auto bg-white/90 backdrop-blur rounded-xl border border-red-200 px-3 py-2">
<div class="wrapped-label text-xs text-red-700">生成失败</div>
<div class="mt-1 wrapped-body text-xs text-red-600 whitespace-pre-wrap">{{ error }}</div>
</div>
</div>
<!-- 右上角年份选择器主题化 -->
<div class="absolute top-6 right-6 z-20 pointer-events-auto select-none">
<div class="relative">
<div v-if="!isRetro" class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div class="relative flex justify-end">
<WrappedYearSelector
v-if="yearOptions.length > 1"
v-model="year"
:years="yearOptions"
/>
<div v-else class="wrapped-label text-xs text-[#00000066]">{{ year }}</div>
</div>
<div v-if="!isRetro" class="relative mt-1 h-[1px] w-16 ml-auto bg-gradient-to-l from-[#07C160]/40 to-transparent"></div>
</div>
</div>
<div
class="relative z-10 h-full w-full will-change-transform transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
:style="trackStyle"
>
<!-- Cover -->
<section class="w-full" :style="slideStyle">
<div class="h-full w-full relative">
<WrappedHero :year="year" variant="slide" class="h-full w-full" />
</div>
</section>
<!-- Cards -->
<section
v-for="(c, idx) in report?.cards || []"
:key="`${c?.id ?? idx}`"
class="w-full"
:style="slideStyle"
>
<WrappedCardShell
v-if="!c || c.status !== 'ok'"
:card-id="Number(c?.id || (idx + 1))"
:title="c?.title || '正在生成…'"
:narrative="c?.status === 'error' ? '生成失败' : (c?.status === 'loading' ? '正在生成本页数据…' : '进入该页后将开始生成')"
variant="slide"
class="h-full w-full"
>
<div v-if="c?.status === 'error'" class="text-sm text-[#7F7F7F]">
<div class="wrapped-body text-sm text-red-600 whitespace-pre-wrap">{{ c?.error || '未知错误' }}</div>
<button
type="button"
class="mt-4 inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm wrapped-label hover:bg-[#06AD56] transition"
@click="retryCard(Number(c?.id))"
>
重试
</button>
</div>
<div v-else class="flex items-center gap-3 text-sm text-[#7F7F7F]">
<svg class="w-4 h-4 animate-spin text-[#07C160]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z"
/>
</svg>
<div class="wrapped-body text-sm text-[#7F7F7F]">
<span v-if="c?.status === 'idle'">翻到此页后开始生成</span>
<span v-else>正在生成本页数据</span>
</div>
</div>
</WrappedCardShell>
<Card00GlobalOverview
v-else-if="c && (c.kind === 'global/overview' || c.id === 0)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<Card01CyberSchedule
v-else-if="c && (c.kind === 'time/weekday_hour_heatmap' || c.id === 1)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<Card02MessageChars
v-else-if="c && (c.kind === 'text/message_chars' || c.id === 2)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<WrappedCardShell
v-else
:card-id="Number(c?.id || (idx + 1))"
:title="c?.title || '暂不支持的卡片'"
:narrative="`kind=${c?.kind} / id=${c?.id}`"
variant="slide"
class="h-full w-full"
>
<div class="text-sm text-[#7F7F7F]">
该卡片暂未实现后续会逐步补齐
</div>
</WrappedCardShell>
</section>
</div>
</div>
</template>
<script setup>
import { useApi } from '~/composables/useApi'
useHead({
title: '年度总结 · WeChat Wrapped',
bodyAttrs: { style: 'overflow: hidden; overscroll-behavior: none;' }
})
const api = useApi()
const route = useRoute()
const router = useRouter()
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 : '')
// 主题管理modern / gameboy / dos
const { theme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
const accounts = ref([])
const accountsLoading = ref(true)
const loading = ref(false)
const error = ref('')
const report = ref(null)
// If user clicks "强制刷新", pass refresh=true for subsequent per-card requests in this session.
const refreshCards = ref(false)
let reportToken = 0
const availableYears = ref([])
const yearOptions = computed(() => {
const ys = Array.isArray(availableYears.value) ? availableYears.value : []
const out = ys
.map((x) => Number(x))
.filter((x) => Number.isFinite(x))
.sort((a, b) => b - a)
// Fallback to current year if backend couldn't provide a list yet.
return out.length > 0 ? out : [year.value]
})
const deckEl = ref(null)
const viewportHeight = ref(0)
const activeIndex = ref(0)
const navLocked = ref(false)
const wheelAcc = ref(0)
let navUnlockTimer = null
// 各主题的背景颜色
const THEME_BG = {
off: '#F3FFF8', // Modern: 浅绿
gameboy: '#9bbc0f', // Game Boy: 亮绿
dos: '#0a0a0a' // DOS: 黑色
}
const slides = computed(() => {
const cards = Array.isArray(report.value?.cards) ? report.value.cards : []
const out = [{ key: 'cover' }]
for (const c of cards) out.push({ key: `card-${c?.id ?? out.length}` })
return out
})
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
const slideStyle = computed(() => (
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
))
const trackStyle = computed(() => {
const dy = viewportHeight.value > 0 ? -activeIndex.value * viewportHeight.value : 0
return { transform: `translate3d(0, ${dy}px, 0)` }
})
const clampIndex = (i) => {
const max = Math.max(0, slides.value.length - 1)
return Math.min(Math.max(0, i), max)
}
const goTo = (i) => {
activeIndex.value = clampIndex(i)
}
const next = () => goTo(activeIndex.value + 1)
const prev = () => goTo(activeIndex.value - 1)
const lockNav = () => {
navLocked.value = true
if (navUnlockTimer) clearTimeout(navUnlockTimer)
navUnlockTimer = setTimeout(() => { navLocked.value = false }, 650)
}
const isEditable = (t) => {
const el = t
if (!el || !(el instanceof Element)) return false
const tag = el.tagName
return el.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
const findScrollableYAncestor = (t) => {
let el = t instanceof Element ? t : null
while (el && el !== deckEl.value) {
const style = window.getComputedStyle(el)
const oy = style.overflowY
const scrollable = (oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 1
if (scrollable) return el
el = el.parentElement
}
return null
}
const onWheel = (e) => {
if (!slides.value || slides.value.length <= 1) return
if (isEditable(e.target)) return
// 若在可水平滚动区域且用户在做水平滚动手势,则不拦截
const scrollX = e.target instanceof Element ? e.target.closest('[data-wrapped-scroll-x]') : null
if (scrollX && scrollX.scrollWidth > scrollX.clientWidth + 1) {
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
}
const scrollY = findScrollableYAncestor(e.target)
if (scrollY) {
const canUp = scrollY.scrollTop > 0
const canDown = scrollY.scrollTop + scrollY.clientHeight < scrollY.scrollHeight - 1
if ((e.deltaY < 0 && canUp) || (e.deltaY > 0 && canDown)) return
}
// 进入 deck 逻辑:阻止默认滚动,转为“翻页”
e.preventDefault()
if (navLocked.value) return
wheelAcc.value += e.deltaY
const threshold = 80
if (Math.abs(wheelAcc.value) < threshold) return
if (wheelAcc.value > 0) next()
else prev()
wheelAcc.value = 0
lockNav()
}
const onKeydown = (e) => {
if (!slides.value || slides.value.length <= 1) return
if (isEditable(e.target)) return
if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
next()
lockNav()
return
}
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
prev()
lockNav()
return
}
if (e.key === 'Home') {
e.preventDefault()
goTo(0)
lockNav()
return
}
if (e.key === 'End') {
e.preventDefault()
goTo(slides.value.length - 1)
lockNav()
}
}
let touchStartY = 0
const onTouchStart = (e) => {
if (!slides.value || slides.value.length <= 1) return
touchStartY = e.touches?.[0]?.clientY ?? 0
}
const onTouchEnd = (e) => {
if (!slides.value || slides.value.length <= 1) return
const endY = e.changedTouches?.[0]?.clientY ?? 0
const dy = endY - touchStartY
if (Math.abs(dy) < 50) return
if (dy < 0) next()
else prev()
lockNav()
}
const updateViewport = () => {
const h = deckEl.value?.clientHeight || window.innerHeight || 0
if (!h) return
// Avoid endless reflows from 1px rounding errors (especially in Electron).
if (Math.abs(viewportHeight.value - h) > 1) viewportHeight.value = h
}
const loadAccounts = async () => {
accountsLoading.value = true
try {
const resp = await api.listChatAccounts()
accounts.value = Array.isArray(resp?.accounts) ? resp.accounts : []
} catch (e) {
accounts.value = []
} finally {
accountsLoading.value = false
}
}
const ensureCardLoaded = async (cardId) => {
const id = Number(cardId)
if (!Number.isFinite(id)) return
const token = reportToken
const cards = report.value?.cards
if (!Array.isArray(cards)) return
const idx = cards.findIndex((x) => Number(x?.id) === id)
if (idx < 0) return
const cur = cards[idx]
if (cur?.status === 'ok' || cur?.status === 'loading') return
// Mark as loading immediately so the UI can show a spinner on this slide.
cards[idx] = {
...(cur || {}),
id,
title: cur?.title || `Card ${id}`,
scope: cur?.scope || 'global',
category: cur?.category || 'A',
kind: cur?.kind || '',
status: 'loading',
error: ''
}
try {
const resp = await api.getWrappedAnnualCard(id, {
year: year.value,
account: account.value || null,
refresh: !!refreshCards.value
})
// Ignore stale responses after year/account reload.
if (token !== reportToken) return
if (resp && Number(resp?.id) === id) {
cards[idx] = resp
} else {
// Best-effort fallback (shouldn't happen unless backend shape changes).
cards[idx] = resp || cards[idx]
}
} catch (e) {
if (token !== reportToken) return
const msg = e?.message || String(e)
cards[idx] = {
...(cur || {}),
id,
title: cur?.title || `Card ${id}`,
scope: cur?.scope || 'global',
category: cur?.category || 'A',
kind: cur?.kind || '',
status: 'error',
narrative: '',
data: null,
error: msg
}
}
}
const retryCard = async (cardId) => {
await ensureCardLoaded(cardId)
}
const reload = async (forceRefresh = false) => {
const token = ++reportToken
activeIndex.value = 0
error.value = ''
loading.value = true
refreshCards.value = !!forceRefresh
try {
const resp = await api.getWrappedAnnualMeta({
year: year.value,
account: account.value || null,
refresh: !!forceRefresh
})
if (token !== reportToken) return
const manifest = Array.isArray(resp?.cards) ? resp.cards : []
report.value = {
...(resp || {}),
cards: manifest.map((m, i) => ({
id: Number(m?.id ?? i),
title: String(m?.title || `Card ${m?.id ?? i}`),
scope: m?.scope || 'global',
category: m?.category || 'A',
kind: String(m?.kind || ''),
status: 'idle',
narrative: '',
data: null,
error: ''
}))
}
// Backend may snap the year to the latest available year (only years with data are selectable).
const respYear = Number(resp?.year)
if (Number.isFinite(respYear)) {
year.value = respYear
try {
await router.replace({ query: { ...route.query, year: String(respYear) } })
} catch {
// ignore
}
}
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
} catch (e) {
if (token !== reportToken) return
report.value = null
error.value = e?.message || String(e)
} finally {
if (token !== reportToken) return
loading.value = false
}
}
// Lazy-load the active slide's card data.
watch(activeIndex, (i) => {
const cardIdx = Number(i) - 1
if (!Number.isFinite(cardIdx) || cardIdx < 0) return
const c = report.value?.cards?.[cardIdx]
const id = Number(c?.id)
if (!Number.isFinite(id)) return
void ensureCardLoaded(id)
})
onMounted(async () => {
updateViewport()
window.addEventListener('resize', updateViewport)
// passive:false 才能 preventDefault避免外层容器产生滚动/回弹
deckEl.value?.addEventListener('wheel', onWheel, { passive: false })
window.addEventListener('keydown', onKeydown)
deckEl.value?.addEventListener('touchstart', onTouchStart, { passive: true })
deckEl.value?.addEventListener('touchend', onTouchEnd, { passive: true })
await loadAccounts()
// Auto-generate once if we already have decrypted accounts, to match "one click" expectations.
if (accounts.value.length > 0) {
await reload()
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
deckEl.value?.removeEventListener('wheel', onWheel)
window.removeEventListener('keydown', onKeydown)
deckEl.value?.removeEventListener('touchstart', onTouchStart)
deckEl.value?.removeEventListener('touchend', onTouchEnd)
if (navUnlockTimer) clearTimeout(navUnlockTimer)
})
watch(
() => slides.value.length,
() => {
// Slide 数量变化(重新生成/新增卡片)时,确保 index 合法
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>