feat(wrapped-ui): 年度总结页支持懒加载与复古模式,新增概览/字数卡片

- wrapped 页面改为:先拉 meta/年份列表,再按页请求单张卡片,首屏更快
- 新增 Card#0 全局概览页(含图表)
- 新增 Card#2 消息字数页(含键盘敲击统计与图表)
- 新增复古模式:像素字体资源 + CRT Overlay,支持一键开关
- 调整 shared 组件、types/useApi,更新前端依赖与 lock
This commit is contained in:
2977094657
2026-01-31 14:54:43 +08:00
parent 77a60bde70
commit 645dc1cff1
42 changed files with 2901 additions and 172 deletions

View File

@@ -1,18 +1,96 @@
<template>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<div
ref="deckEl"
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
:class="{ 'wrapped-retro': retro }"
:style="{ backgroundColor: currentBg }"
>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<WrappedDeckBackground />
<WrappedCRTOverlay v-if="retro" />
<!-- 年份固定在右上角类似 PPT 页眉避免把年份写进标题里 -->
<div class="absolute top-6 right-6 z-20 pointer-events-none select-none">
<!-- 上角刷新 + 复古模式开关 -->
<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="retro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
:aria-pressed="retro ? 'true' : 'false'"
aria-label="复古模式像素字体 + CRT 滤镜"
title="复古模式:像素字体 + CRT 滤镜"
@click="retro = !retro"
>
<img
src="/assets/images/wechat-audio-dark.png"
class="w-4 h-4 transition"
:style="{ filter: retro ? 'none' : 'grayscale(1)', opacity: retro ? '1' : '0.55' }"
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 class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div class="relative text-xs font-semibold tracking-[0.28em] text-[#00000066] text-right">
{{ year }}
<div class="relative flex justify-end">
<div class="relative inline-flex items-center">
<select
class="pointer-events-auto appearance-none bg-transparent pr-5 pl-0 py-0.5 rounded-md wrapped-label text-xs text-[#00000066] text-right focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 hover:bg-[#000000]/5 transition disabled:opacity-70 disabled:cursor-default"
:disabled="loading || accountsLoading || yearOptions.length <= 1"
:value="String(year)"
@change="setYear($event.target.value)"
>
<option v-for="y in yearOptions" :key="y" :value="String(y)">{{ y }}</option>
</select>
<svg
v-if="yearOptions.length > 1"
class="pointer-events-none absolute right-1 w-3 h-3 text-[#00000066]"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 10.94l3.71-3.71a.75.75 0 1 1 1.06 1.06l-4.24 4.24a.75.75 0 0 1-1.06 0L5.21 8.29a.75.75 0 0 1 .02-1.08z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class="relative mt-1 h-[1px] w-16 ml-auto bg-gradient-to-l from-[#07C160]/40 to-transparent"></div>
</div>
@@ -26,33 +104,6 @@
<section class="w-full" :style="slideStyle">
<div class="h-full w-full relative">
<WrappedHero :year="year" variant="slide" class="h-full w-full" />
<!-- 生成面板仅在尚未生成报告时显示分享视图隐藏账号相关内容 -->
<div v-if="bootstrapped && !report" class="absolute left-0 right-0 bottom-0 pb-8">
<div class="max-w-5xl mx-auto px-6 sm:px-8 space-y-3">
<div v-if="error" class="bg-white/90 backdrop-blur rounded-2xl border border-red-200 p-5">
<div class="text-red-700 font-semibold">生成失败</div>
<div class="mt-2 text-sm text-red-600 whitespace-pre-wrap">{{ error }}</div>
<div class="mt-4 text-xs text-[#7F7F7F]">
提示请确认已完成解密并且后端服务正在运行默认 http://127.0.0.1:8000
</div>
</div>
<WrappedControls
:accounts="accounts"
:accounts-loading="accountsLoading"
:loading="loading"
:model-year="year"
:model-account="account"
:model-refresh="refresh"
:show-account="false"
@update:year="(v) => { year.value = v }"
@update:account="(v) => { account.value = v }"
@update:refresh="(v) => { refresh.value = v }"
@reload="reload"
/>
</div>
</div>
</div>
</section>
@@ -63,8 +114,55 @@
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-if="c && (c.kind === 'time/weekday_hour_heatmap' || c.id === 1)"
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"
@@ -96,21 +194,36 @@ useHead({
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 : '')
const refresh = ref(false)
// Retro mode: pixel font + CRT overlay.
const retro = ref(true)
const accounts = ref([])
const accountsLoading = ref(true)
// Avoid flashing the "year card" controls before the initial auto-load finishes.
const bootstrapped = ref(false)
const loading = ref(false)
const error = ref('')
const report = ref(null)
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)
@@ -278,25 +391,161 @@ const loadAccounts = async () => {
}
}
const reload = async () => {
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.getWrappedAnnual({
const resp = await api.getWrappedAnnualMeta({
year: year.value,
account: account.value || null,
refresh: !!refresh.value
refresh: !!forceRefresh
})
report.value = resp || null
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)
})
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)
@@ -306,14 +555,10 @@ onMounted(async () => {
deckEl.value?.addEventListener('touchstart', onTouchStart, { passive: true })
deckEl.value?.addEventListener('touchend', onTouchEnd, { passive: true })
try {
await loadAccounts()
// Auto-generate once if we already have decrypted accounts, to match "one click" expectations.
if (accounts.value.length > 0) {
await reload()
}
} finally {
bootstrapped.value = true
await loadAccounts()
// Auto-generate once if we already have decrypted accounts, to match "one click" expectations.
if (accounts.value.length > 0) {
await reload()
}
})