feat(wrapped-ui): 新增年度总结页面与热力图卡片

- 新增 /wrapped PPT 风格滑动浏览(封面 + 卡片页)

- 新增 Card#1 组件与 24×7 周-小时热力图可视化

- 首页新增年度总结入口;useApi 增加 getWrappedAnnual;补充 wrapped 背景纹理
This commit is contained in:
2977094657
2026-01-30 16:26:52 +08:00
parent 519e9e9299
commit 79da96b2d3
12 changed files with 820 additions and 1 deletions

View File

@@ -110,6 +110,14 @@
@apply hover:transform hover:scale-[1.02] transition-all duration-300; @apply hover:transform hover:scale-[1.02] transition-all duration-300;
} }
/* Wrapped (年度总结) 背景纹理 */
.wrapped-noise {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iMTYwIj4KICA8ZmlsdGVyIGlkPSJuIj4KICAgIDxmZVR1cmJ1bGVuY2UgdHlwZT0iZnJhY3RhbE5vaXNlIiBiYXNlRnJlcXVlbmN5PSIwLjgiIG51bU9jdGF2ZXM9IjQiIHN0aXRjaFRpbGVzPSJzdGl0Y2giLz4KICAgIDxmZUNvbG9yTWF0cml4IHR5cGU9InNhdHVyYXRlIiB2YWx1ZXM9IjAiLz4KICA8L2ZpbHRlcj4KICA8cmVjdCB3aWR0aD0iMTYwIiBoZWlnaHQ9IjE2MCIgZmlsdGVyPSJ1cmwoI24pIiBvcGFjaXR5PSIwLjQ1Ii8+Cjwvc3ZnPg==");
background-repeat: repeat;
background-size: 320px 320px;
mix-blend-mode: multiply;
}
/* 输入框样式 */ /* 输入框样式 */
.input { .input {
@apply w-full px-4 py-3 bg-[#f7f8fa] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-[#07c160] focus:bg-white focus:border-[#07c160] transition-all duration-200; @apply w-full px-4 py-3 bg-[#f7f8fa] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-[#07c160] focus:bg-white focus:border-[#07c160] transition-all duration-200;

View File

@@ -0,0 +1,23 @@
<template>
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative" :variant="variant">
<template #badge>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-[#07C160]/10 text-[#07C160] border border-[#07C160]/20">
作息规律
</span>
</template>
<WeekdayHourHeatmap
:weekday-labels="card.data?.weekdayLabels"
:hour-labels="card.data?.hourLabels"
:matrix="card.data?.matrix"
:total-messages="card.data?.totalMessages || 0"
/>
</WrappedCardShell>
</template>
<script setup>
defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div v-if="variant === 'panel'" class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden">
<div class="px-6 py-5 border-b border-[#F3F3F3]">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000066]">
CARD {{ String(cardId).padStart(2, '0') }}
</div>
<h2 class="mt-2 text-xl font-bold text-[#000000e6]">{{ title }}</h2>
<p v-if="narrative" class="mt-2 text-sm text-[#7F7F7F]">
{{ narrative }}
</p>
</div>
<slot name="badge" />
</div>
</div>
<div class="px-6 py-6">
<slot />
</div>
</div>
<!-- Slide 模式单张卡片占据全页面背景由外层年度总结统一控制 -->
<section v-else class="relative h-full w-full overflow-hidden">
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000066]">
CARD {{ String(cardId).padStart(2, '0') }}
</div>
<h2 class="mt-2 text-2xl sm:text-3xl font-bold text-[#000000e6]">{{ title }}</h2>
<p v-if="narrative" class="mt-3 text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
{{ narrative }}
</p>
</div>
<slot name="badge" />
</div>
<div class="mt-6 sm:mt-8 flex-1 flex items-center">
<div class="w-full">
<slot />
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
cardId: { type: Number, required: true },
title: { type: String, required: true },
narrative: { type: String, default: '' },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="bg-white rounded-2xl border border-[#EDEDED] p-5 sm:p-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row gap-3 sm:items-end sm:justify-between">
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
<div v-if="showAccount">
<div class="text-xs font-medium text-[#00000099] mb-1">账号</div>
<select
class="w-full sm:w-56 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160]"
:disabled="accountsLoading || accounts.length === 0"
:value="modelAccount"
@change="$emit('update:account', $event.target.value || '')"
>
<option value="" :disabled="accounts.length > 0">默认自动选择</option>
<option v-for="a in accounts" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div>
<div class="text-xs font-medium text-[#00000099] mb-1">年份</div>
<select
class="w-full sm:w-40 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160]"
:value="String(modelYear)"
@change="$emit('update:year', Number($event.target.value))"
>
<option v-for="y in yearOptions" :key="y" :value="String(y)">{{ y }}</option>
</select>
</div>
<label class="inline-flex items-center gap-2 select-none">
<input
type="checkbox"
class="h-4 w-4 rounded border-[#EDEDED] text-[#07C160] focus:ring-[#07C160]"
:checked="modelRefresh"
@change="$emit('update:refresh', !!$event.target.checked)"
/>
<span class="text-sm text-[#7F7F7F]">强制刷新忽略缓存</span>
</label>
</div>
<div class="flex gap-2">
<button
class="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm font-medium hover:bg-[#06AD56] disabled:opacity-60 disabled:cursor-not-allowed transition"
:disabled="loading"
@click="$emit('reload')"
>
<span v-if="!loading">生成报告</span>
<span v-else>生成中...</span>
</button>
</div>
</div>
<div v-if="accountsLoading" class="text-xs text-[#7F7F7F]">
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
</div>
<div v-else-if="accounts.length === 0" class="text-xs text-[#B37800]">
{{ showAccount ? '未发现已解密账号请先解密数据库' : '未发现可用数据请先解密数据库' }}
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
accounts: { type: Array, default: () => [] },
accountsLoading: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
modelYear: { type: Number, required: true },
modelAccount: { type: String, default: '' },
modelRefresh: { type: Boolean, default: false },
showAccount: { type: Boolean, default: true }
})
defineEmits(['update:year', 'update:account', 'update:refresh', 'reload'])
const yearOptions = computed(() => {
const now = new Date().getFullYear()
const years = []
for (let i = 0; i < 8; i++) years.push(now - i)
// Ensure selected year is present
if (props.modelYear && !years.includes(props.modelYear)) years.unshift(props.modelYear)
return years
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<!-- Shared backdrop for all "Wrapped" slides (keeps cover + cards visually consistent). -->
<div class="absolute inset-0 pointer-events-none select-none z-0" aria-hidden="true">
<!-- Soft color blobs (brand + warm highlights) -->
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
<div class="absolute -top-24 -right-24 w-96 h-96 bg-[#F2AA00] opacity-[0.06] rounded-full blur-3xl"></div>
<div class="absolute -bottom-28 left-40 w-[28rem] h-[28rem] bg-[#10AEEF] opacity-[0.06] rounded-full blur-3xl"></div>
<!-- Subtle grid for "data / report" vibe -->
<div
class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.28]"
></div>
<!-- Grain/noise: adds texture without changing the base color -->
<div class="absolute inset-0 wrapped-noise opacity-[0.06]"></div>
<!-- Gentle vignette so typography stays readable on textured bg -->
<div class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/50 to-transparent"></div>
<div class="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-white/40 to-transparent"></div>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<template>
<div :class="rootClass">
<div v-if="variant !== 'slide'" class="absolute inset-0 pointer-events-none">
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
<div class="absolute -top-20 -right-20 w-96 h-96 bg-[#F2AA00] opacity-[0.07] rounded-full blur-3xl"></div>
<div class="absolute -bottom-24 left-40 w-96 h-96 bg-[#10AEEF] opacity-[0.07] rounded-full blur-3xl"></div>
<div class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.35]"></div>
</div>
<div :class="innerClass">
<template v-if="variant === 'slide'">
<div class="h-full flex flex-col justify-between">
<div class="flex items-start justify-between gap-4">
<div class="text-xs font-semibold tracking-[0.28em] text-[#00000080]">
WECHAT · WRAPPED
</div>
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000055]">
年度回望
</div>
</div>
<div class="mt-10 sm:mt-14">
<h1 class="text-4xl sm:text-6xl font-black tracking-tight text-[#000000e6] leading-[1.05]">
把这一年的聊天
<span class="block mt-3 text-[#07C160]">
轻轻翻一翻
</span>
</h1>
<div class="mt-7 sm:mt-9 max-w-2xl">
<p class="text-base sm:text-lg text-[#00000080] leading-relaxed">
有些问候写在对话框里有些陪伴藏在深夜里
我们不读取内容只把时间整理成几张卡片让你温柔地回望这一年
</p>
</div>
</div>
<div class="pb-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-[#00000066]">
<!-- Intentionally left blank (avoid "feature bullet list" tone on the cover). -->
</div>
</div>
</div>
</template>
<template v-else>
<div class="flex items-start justify-between gap-4">
<div class="text-xs font-semibold tracking-[0.28em] text-[#00000080]">
WECHAT · WRAPPED
</div>
<!-- 年份放到右上角分享视图不包含账号信息 -->
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-[#00000008] text-[#00000099] border border-[#00000010]"
>
{{ yearText }}
</span>
</div>
<div class="mt-5 sm:mt-7 flex flex-col gap-2">
<h1 class="text-3xl sm:text-4xl font-bold text-[#000000e6] leading-tight">
聊天年度总结
</h1>
<p class="text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
从时间里回看你的聊天节奏第一张卡年度赛博作息表24H × 7Days
</p>
</div>
<!-- Badges intentionally removed: keep the hero more human and less "feature list". -->
</template>
</div>
</div>
</template>
<script setup>
const props = defineProps({
year: { type: Number, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const yearText = computed(() => `${props.year}`)
const rootClass = computed(() => {
const base = 'relative overflow-hidden'
return props.variant === 'slide'
? `${base} h-full w-full`
: `${base} rounded-2xl border border-[#EDEDED] bg-white`
})
const innerClass = computed(() => (
props.variant === 'slide'
? 'relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'
: 'relative px-6 py-7 sm:px-8 sm:py-9'
))
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div class="w-full">
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-[#7F7F7F]">
<span class="text-[#07C160] font-semibold">{{ totalMessages }}</span> 条消息
</div>
<div class="text-xs text-[#00000066]">24H × 7Days</div>
</div>
<div class="mt-4 overflow-x-auto" data-wrapped-scroll-x>
<div class="min-w-[720px]">
<div class="grid gap-[3px] [grid-template-columns:24px_1fr] text-[11px] text-[#00000066] mb-2">
<div></div>
<div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]">
<span
v-for="(s, idx) in timeLabels"
:key="idx"
class="col-span-4 font-mono"
>
{{ s }}
</span>
</div>
</div>
<div class="grid gap-[3px] [grid-template-columns:24px_1fr] items-stretch">
<div class="grid gap-[3px] [grid-template-rows:repeat(7,minmax(0,1fr))] text-[11px] text-[#00000066]">
<div v-for="(w, wi) in weekdayLabels" :key="wi" class="flex items-center">
{{ w }}
</div>
</div>
<div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]">
<template v-for="(row, wi) in matrixSafe" :key="wi">
<div
v-for="(v, hi) in row"
:key="`${wi}-${hi}`"
class="aspect-square min-h-[10px] rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10 relative"
:style="{ backgroundColor: colorFor(v) }"
:title="tooltipFor(wi, hi, v)"
/>
</template>
</div>
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066]">
<div class="flex items-center gap-2">
<span></span>
<div class="flex items-center gap-[2px]">
<span v-for="i in 6" :key="i" class="w-4 h-2 rounded-[2px]" :style="{ backgroundColor: legendColor(i) }"></span>
</div>
<span></span>
</div>
<div v-if="maxValue > 0">最大 {{ maxValue }}</div>
</div>
</div>
</template>
<script setup>
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
const props = defineProps({
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
hourLabels: { type: Array, default: () => Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')) },
matrix: { type: Array, default: () => [] },
totalMessages: { type: Number, default: 0 }
})
const matrixSafe = computed(() => {
// Expect 7x24, but keep defensive to avoid UI crashes.
const m = Array.isArray(props.matrix) ? props.matrix : []
const out = []
for (let i = 0; i < 7; i++) {
const row = Array.isArray(m[i]) ? m[i] : []
const r = []
for (let h = 0; h < 24; h++) r.push(Number(row[h] || 0))
out.push(r)
}
return out
})
const maxValue = computed(() => maxInMatrix(matrixSafe.value))
const timeLabels = computed(() => {
// Show every 4 hours to reduce clutter, inspired by EchoTrace.
const labels = []
for (let i = 0; i < 24; i += 4) labels.push(props.hourLabels[i] ?? String(i).padStart(2, '0'))
return labels
})
const colorFor = (v) => heatColor(v, maxValue.value)
const tooltipFor = (weekdayIndex, hour, v) => {
const w = props.weekdayLabels?.[weekdayIndex] ?? `${weekdayIndex + 1}`
const hr = formatHourRange(hour)
const n = Number(v) || 0
return `${w} ${hr}${n}`
}
const legendColor = (i) => {
const t = i / 6
return heatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1)
}
</script>

View File

@@ -311,6 +311,16 @@ export const useApi = () => {
if (!exportId) throw new Error('Missing exportId') if (!exportId) throw new Error('Missing exportId')
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
} }
// WeChat Wrapped年度总结
const getWrappedAnnual = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.year != null) query.set('year', String(params.year))
if (params && params.account) query.set('account', String(params.account))
if (params && params.refresh != null) query.set('refresh', String(!!params.refresh))
const url = '/wrapped/annual' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
return { return {
detectWechat, detectWechat,
@@ -339,6 +349,7 @@ export const useApi = () => {
createChatExport, createChatExport,
getChatExport, getChatExport,
listChatExports, listChatExports,
cancelChatExport cancelChatExport,
getWrappedAnnual
} }
} }

View File

@@ -49,6 +49,14 @@
</svg> </svg>
<span>聊天预览</span> <span>聊天预览</span>
</NuxtLink> </NuxtLink>
<NuxtLink to="/wrapped"
class="group inline-flex items-center px-12 py-4 bg-white text-[#B37800] border border-[#F2AA00] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 3v18m9-9H3"/>
</svg>
<span>年度总结</span>
</NuxtLink>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,336 @@
<template>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<div
ref="deckEl"
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
:style="{ backgroundColor: currentBg }"
>
<WrappedDeckBackground />
<!-- 年份固定在右上角类似 PPT 页眉避免把年份写进标题里 -->
<div class="absolute top-6 right-6 z-20 pointer-events-none 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>
<div 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 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>
<!-- Cards -->
<section
v-for="(c, idx) in report?.cards || []"
:key="`${c?.id ?? idx}`"
class="w-full"
:style="slideStyle"
>
<Card01CyberSchedule
v-if="c && (c.kind === 'time/weekday_hour_heatmap' || c.id === 1)"
: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 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)
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 deckEl = ref(null)
const viewportHeight = ref(0)
const activeIndex = ref(0)
const navLocked = ref(false)
const wheelAcc = ref(0)
let navUnlockTimer = null
const WRAPPED_BG = '#F3FFF8'
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) })
return out
})
const currentBg = computed(() => slides.value?.[activeIndex.value]?.bg || '#ffffff')
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 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)
}
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 reload = async () => {
activeIndex.value = 0
error.value = ''
loading.value = true
try {
const resp = await api.getWrappedAnnual({
year: year.value,
account: account.value || null,
refresh: !!refresh.value
})
report.value = resp || null
} catch (e) {
report.value = null
error.value = e?.message || String(e)
} finally {
loading.value = false
}
}
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 })
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
}
})
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)
}
)
</script>

View File

@@ -0,0 +1,47 @@
// Utilities for Wrapped heatmap rendering.
export const clamp01 = (v) => {
const n = Number(v)
if (!Number.isFinite(n)) return 0
if (n < 0) return 0
if (n > 1) return 1
return n
}
export const maxInMatrix = (matrix) => {
if (!Array.isArray(matrix)) return 0
let m = 0
for (const row of matrix) {
if (!Array.isArray(row)) continue
for (const v of row) {
const n = Number(v)
if (Number.isFinite(n) && n > m) m = n
}
}
return m
}
// Color inspired by WeChat green, with a slight "gold" shift on high intensity
// (EchoTrace-style accent) while keeping the overall WeChat vibe.
export const heatColor = (value, max) => {
const v = Number(value) || 0
const m = Number(max) || 0
if (!(v > 0) || !(m > 0)) return 'rgba(0,0,0,0.05)'
// Use sqrt scaling to make low values still visible.
const t = clamp01(Math.sqrt(v / m))
// Hue from green (~145) -> yellow-green (~95)
const hue = 145 - 50 * t
const sat = 70
const light = 92 - 42 * t
return `hsl(${hue.toFixed(1)} ${sat}% ${light.toFixed(1)}%)`
}
export const formatHourRange = (hour) => {
const h = Number(hour)
if (!Number.isFinite(h)) return ''
const hh = String(h).padStart(2, '0')
return `${hh}:00-${hh}:59`
}

View File

@@ -0,0 +1,27 @@
// JSDoc types for the Wrapped API (kept in JS to match the current codebase).
/**
* @typedef {Object} WrappedCardBase
* @property {number} id
* @property {string} title
* @property {'global'} scope
* @property {'A'|'B'|'C'|'D'|'E'} category
* @property {'ok'|'error'} status
* @property {string} kind
* @property {string} narrative
* @property {Record<string, any>} data
*/
/**
* @typedef {Object} WrappedAnnualResponse
* @property {string} account
* @property {number} year
* @property {'global'} scope
* @property {string|null} username
* @property {number} generated_at
* @property {boolean} cached
* @property {WrappedCardBase[]} cards
*/
export {}