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

@@ -0,0 +1,194 @@
<template>
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
<template #narrative>
<div class="mt-2 wrapped-body text-sm text-[#7F7F7F] leading-relaxed">
<p>
<template v-if="totalMessages > 0">
这一年你在微信里发送了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
条消息平均每天
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatFloat(messagesPerDay, 1) }}</span>
</template>
<template v-else>
这一年你在微信里还没有发出聊天消息也许你把时间留给了更重要的人和事
</template>
<template v-if="activeDays > 0">
在与你相伴的
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(activeDays) }}</span>
天里
<template v-if="mostActiveHour !== null && mostActiveWeekdayName">
你最常在 {{ mostActiveWeekdayName }}
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveHour }}</span>
点出现
</template>
<template v-else>
你留下了不少对话的痕迹
</template>
</template>
<template v-if="topContact || topGroup">
<template v-if="topContact">
你发消息最多的人是
<span
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
:title="topContact.displayName"
>
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<img
v-if="topContactAvatarUrl && avatarOk.topContact"
:src="topContactAvatarUrl"
class="w-full h-full object-cover"
alt="avatar"
@error="avatarOk.topContact = false"
/>
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
{{ avatarFallback(topContact.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topContact.displayName }}</span>
</span>
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topContact.messages) }}</span>
</template>
<template v-if="topContact && topGroup"></template>
<template v-if="topGroup">
你最常发言的群是
<span
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
:title="topGroup.displayName"
>
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<img
v-if="topGroupAvatarUrl && avatarOk.topGroup"
:src="topGroupAvatarUrl"
class="w-full h-full object-cover"
alt="avatar"
@error="avatarOk.topGroup = false"
/>
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
{{ avatarFallback(topGroup.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topGroup.displayName }}</span>
</span>
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topGroup.messages) }}</span>
</template>
</template>
<template v-if="topKind && topKindPct > 0">
你更常用 {{ topKind.label }} 来表达<span class="wrapped-number text-[#07C160] font-semibold">{{ topKindPct }}</span>%
</template>
<template v-if="topPhrase && topPhrase.phrase && topPhrase.count > 0">
你说得最多的一句话是<span
class="privacy-blur inline-block max-w-[12rem] truncate align-bottom"
:title="topPhrase.phrase"
>{{ topPhrase.phrase }}</span><span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topPhrase.count) }}</span>
</template>
<span class="hidden sm:inline text-[#00000055]">愿你的每一句分享都有人回应</span>
</p>
</div>
</template>
<GlobalOverviewChart :data="card.data || {}" />
</WrappedCardShell>
</template>
<script setup>
import GlobalOverviewChart from '~/components/wrapped/visualizations/GlobalOverviewChart.vue'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const formatFloat = (n, digits = 1) => {
const v = Number(n)
if (!Number.isFinite(v)) return '0'
return v.toFixed(digits)
}
const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0))
const activeDays = computed(() => Number(props.card?.data?.activeDays || 0))
const messagesPerDay = computed(() => Number(props.card?.data?.messagesPerDay || 0))
const mostActiveHour = computed(() => {
const h = props.card?.data?.mostActiveHour
return Number.isFinite(Number(h)) ? Number(h) : null
})
const mostActiveWeekdayName = computed(() => {
const s = props.card?.data?.mostActiveWeekdayName
return typeof s === 'string' && s.trim() ? s.trim() : ''
})
const topContact = computed(() => {
const o = props.card?.data?.topContact
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const topGroup = computed(() => {
const o = props.card?.data?.topGroup
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
// Keep the same behavior as the chat page: media (including avatars) comes from backend :8000 in dev.
const mediaBase = process.client ? 'http://localhost:8000' : ''
const resolveMediaUrl = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) {
// qpic/qlogo are often hotlink-protected; proxy via backend (same as chat page).
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
}
// Most backend fields are like "/api/...", so just prefix.
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
}
const topContactAvatarUrl = computed(() => {
return resolveMediaUrl(topContact.value?.avatarUrl)
})
const topGroupAvatarUrl = computed(() => {
return resolveMediaUrl(topGroup.value?.avatarUrl)
})
const avatarOk = reactive({ topContact: true, topGroup: true })
const avatarFallback = (name) => {
const s = String(name || '').trim()
if (!s) return '?'
return s[0]
}
watch(topContactAvatarUrl, () => { avatarOk.topContact = true })
watch(topGroupAvatarUrl, () => { avatarOk.topGroup = true })
const topKind = computed(() => {
const o = props.card?.data?.topKind
return o && typeof o === 'object' && typeof o.label === 'string' ? o : null
})
const topKindPct = computed(() => {
const r = Number(topKind.value?.ratio || 0)
if (!Number.isFinite(r) || r <= 0) return 0
return Math.max(0, Math.min(100, Math.round(r * 100)))
})
const topPhrase = computed(() => {
const o = props.card?.data?.topPhrase
return o && typeof o === 'object' ? o : null
})
</script>

View File

@@ -1,11 +1,5 @@
<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"

View File

@@ -0,0 +1,42 @@
<template>
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
<template #narrative>
<div class="mt-2 wrapped-body text-sm text-[#7F7F7F] leading-relaxed">
<p>
<template v-if="sentChars > 0">
这一年你在微信里敲下了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(sentChars) }}</span>
个字
</template>
<template v-else>
这一年你还没有发出文字消息
</template>
<template v-if="receivedChars > 0">
你也收到了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(receivedChars) }}</span>
个字
</template>
</p>
</div>
</template>
<MessageCharsChart :data="card.data || {}" />
</WrappedCardShell>
</template>
<script setup>
import MessageCharsChart from '~/components/wrapped/visualizations/MessageCharsChart.vue'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const sentChars = computed(() => Number(props.card?.data?.sentChars || 0))
const receivedChars = computed(() => Number(props.card?.data?.receivedChars || 0))
</script>

View File

@@ -0,0 +1,24 @@
<template>
<!-- CRT 滤镜叠加层 - 模拟老电视机效果 -->
<div class="absolute inset-0 pointer-events-none select-none z-30" aria-hidden="true">
<!-- 扫描线层 - 水平条纹带滚动动画 -->
<div class="absolute inset-0 crt-scanlines"></div>
<!-- RGB 子像素层 - 模拟 CRT 像素结构 -->
<div class="absolute inset-0 crt-rgb-pixels"></div>
<!-- 闪烁层 - 轻微亮度波动 -->
<div class="absolute inset-0 crt-flicker"></div>
<!-- 暗角层 - 边缘渐暗效果 -->
<div class="absolute inset-0 crt-vignette"></div>
<!-- 屏幕曲率层 - 边缘微暗模拟曲面 -->
<div class="absolute inset-0 crt-curvature"></div>
</div>
</template>
<script setup>
// CRT 滤镜叠加层组件
// 通过多层叠加实现复古显像管效果,不修改原始背景
</script>

View File

@@ -3,13 +3,12 @@
<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>
<h2 class="wrapped-title text-xl text-[#000000e6]">{{ title }}</h2>
<slot name="narrative">
<p v-if="narrative" class="mt-2 wrapped-body text-sm text-[#7F7F7F] whitespace-pre-wrap">
{{ narrative }}
</p>
</slot>
</div>
<slot name="badge" />
</div>
@@ -24,13 +23,12 @@
<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>
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
<slot name="narrative">
<p v-if="narrative" class="mt-3 wrapped-body text-sm sm:text-base text-[#7F7F7F] max-w-2xl whitespace-pre-wrap">
{{ narrative }}
</p>
</slot>
</div>
<slot name="badge" />
</div>

View File

@@ -4,9 +4,9 @@
<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>
<div class="wrapped-label text-xs text-[#00000099] mb-1">Account</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]"
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]"
:disabled="accountsLoading || accounts.length === 0"
:value="modelAccount"
@change="$emit('update:account', $event.target.value || '')"
@@ -17,9 +17,9 @@
</div>
<div>
<div class="text-xs font-medium text-[#00000099] mb-1">年份</div>
<div class="wrapped-label text-xs text-[#00000099] mb-1">Year</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]"
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]"
:value="String(modelYear)"
@change="$emit('update:year', Number($event.target.value))"
>
@@ -34,26 +34,26 @@
:checked="modelRefresh"
@change="$emit('update:refresh', !!$event.target.checked)"
/>
<span class="text-sm text-[#7F7F7F]">强制刷新忽略缓存</span>
<span class="wrapped-body 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"
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"
:disabled="loading"
@click="$emit('reload')"
>
<span v-if="!loading">生成报告</span>
<span v-else>生成中...</span>
<span v-if="!loading">Generate</span>
<span v-else>Loading...</span>
</button>
</div>
</div>
<div v-if="accountsLoading" class="text-xs text-[#7F7F7F]">
<div v-if="accountsLoading" class="wrapped-body text-xs text-[#7F7F7F]">
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
</div>
<div v-else-if="accounts.length === 0" class="text-xs text-[#B37800]">
<div v-else-if="accounts.length === 0" class="wrapped-body text-xs text-[#B37800]">
{{ showAccount ? '未发现已解密账号请先解密数据库' : '未发现可用数据请先解密数据库' }}
</div>
</div>

View File

@@ -11,8 +11,8 @@
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>
<!-- Grain/noise: enhanced with dynamic jitter for CRT feel -->
<div class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></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>

View File

@@ -11,26 +11,25 @@
<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 class="wrapped-label text-xs text-[#00000080]">
WECHAT WRAPPED
</div>
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000055]">
<div class="wrapped-body text-xs 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]">
把这一年的聊天
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
{{ randomTitle.main }}
<span class="block mt-3 text-[#07C160]">
轻轻翻一翻
{{ randomTitle.highlight }}
</span>
</h1>
<div class="mt-7 sm:mt-9 max-w-2xl">
<p class="text-base sm:text-lg text-[#00000080] leading-relaxed">
有些问候写在对话框里有些陪伴藏在深夜里
我们不读取内容只把时间整理成几张卡片让你温柔地回望这一年
<p class="wrapped-body text-base sm:text-lg text-[#00000080]">
{{ randomSubtitle }}
</p>
</div>
</div>
@@ -45,23 +44,23 @@
<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 class="wrapped-label text-xs 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]"
class="wrapped-label 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 class="wrapped-title text-3xl sm:text-4xl text-[#000000e6] leading-tight">
聊天年度总结
</h1>
<p class="text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
从时间里回看你的聊天节奏第一张卡年度赛博作息表24H × 7Days
<p class="wrapped-body text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
从时间里回看你的聊天节奏第一张卡年度赛博作息表24H x 7Days
</p>
</div>
@@ -72,6 +71,120 @@
</template>
<script setup>
// 50 个主标题(主句 + 高亮句)
const TITLES = [
{ main: '把这一年的聊天', highlight: '轻轻翻一翻' },
{ main: '这一年', highlight: '谁陪你说了最多的话' },
{ main: '那些深夜的消息', highlight: '都去哪儿了' },
{ main: '一年的对话', highlight: '值得被温柔记住' },
{ main: '你的聊天记录里', highlight: '藏着这一年' },
{ main: '有些人', highlight: '一直在消息列表里陪着你' },
{ main: '翻开这一年的', highlight: '对话框' },
{ main: '这一年的问候', highlight: '都在这里了' },
{ main: '一年又一年', highlight: '聊天框里的人还在吗' },
{ main: '回头看看', highlight: '这一年你和谁聊得最多' },
{ main: '你今年说得最多的', highlight: '那个人是谁' },
{ main: '这一年', highlight: '你在深夜回复过谁' },
{ main: '你的消息', highlight: '都发给了谁' },
{ main: '谁在等你的消息', highlight: '你又在等谁的' },
{ main: '你有多久', highlight: '没和 TA 聊天了' },
{ main: '那个秒回你的人', highlight: '还在吗' },
{ main: '你置顶的人', highlight: '这一年变过吗' },
{ main: '最后一条消息', highlight: '是你发的还是 TA 发的' },
{ main: '你的「在吗」', highlight: '都发给了谁' },
{ main: '有没有一个人', highlight: '你想聊却没聊' },
{ main: '对话框里的', highlight: '四季' },
{ main: '字里行间', highlight: '这一年' },
{ main: '消息如潮水', highlight: '来了又退' },
{ main: '屏幕那头', highlight: '有人亮着灯' },
{ main: '打字的手指', highlight: '记得这一年' },
{ main: '时间会走', highlight: '对话会留下来' },
{ main: '每一条消息', highlight: '都是一次想起' },
{ main: '文字落下的地方', highlight: '有人在等' },
{ main: '那些发出去的字', highlight: '都有回响吗' },
{ main: '对话框亮起的', highlight: '瞬间' },
{ main: '这一年的', highlight: '「在吗」和「晚安」' },
{ main: '聊着聊着', highlight: '一年就过去了' },
{ main: '发出去的消息', highlight: '收到的回复' },
{ main: '那些秒回你的人', highlight: '和你秒回的人' },
{ main: '置顶的人', highlight: '还是那几个吗' },
{ main: '深夜的消息', highlight: '清晨的问候' },
{ main: '群聊里的热闹', highlight: '私聊里的安静' },
{ main: '表情包发了多少', highlight: '真心话说了几句' },
{ main: '已读不回的', highlight: '和秒回的' },
{ main: '消息免打扰的', highlight: '和置顶的' },
{ main: '总有人', highlight: '在消息那头' },
{ main: '每条消息背后', highlight: '都有一个想你的人' },
{ main: '感谢这一年', highlight: '愿意听你说话的人' },
{ main: '有人找你聊天', highlight: '是件幸运的事' },
{ main: '被回复的感觉', highlight: '叫做被在乎' },
{ main: '有些陪伴', highlight: '藏在对话框里' },
{ main: '谢谢那些', highlight: '愿意等你回复的人' },
{ main: '聊天这件小事', highlight: '其实是大事' },
{ main: '能说话的人', highlight: '都是重要的人' },
{ main: '这一年', highlight: '谢谢你们陪我聊天' },
]
// 50 个副标题
const SUBTITLES = [
'有些问候写在对话框里,有些陪伴藏在深夜里。',
'有些陪伴不说出口,但聊天记录都记得。',
'凌晨三点的消息、周末的闲聊、节日的祝福——都在这里。',
'一年的对话,浓缩成几张卡片,轻轻回看。',
'有些人聊着聊着就淡了,有些人聊着聊着就近了。',
'消息可以删除,但陪伴的时间删不掉。',
'那些打出来又删掉的字,也算说过了。',
'每一次「在吗」,都是一次想念。',
'深夜的对话,往往最真。',
'感谢每一个愿意听你说话的人。',
'一年的时间,几张卡片,一些数字,一点回忆。',
'深夜、清晨、周末、假期——你的聊天节奏,藏着生活的样子。',
'数字不会说谎,时间不会忘记。',
'365 天的对话,整理成几个瞬间。',
'时间知道你和谁聊得最多。',
'从时间维度,回看你的聊天节奏。',
'把一年的对话,整理成可以回望的样子。',
'时间会告诉你,谁一直都在。',
'这一年的时间,都花在了谁身上。',
'日历翻过去了,对话还留着。',
'不读内容,只看时间。让数字告诉你,谁一直都在。',
'我们只整理时间,不窥探秘密。这是属于你的一年。',
'不翻聊天记录,只看时间留下的痕迹。',
'这不是监控,是回望。这不是窥探,是整理。',
'我们只看时间,不看内容。你的秘密,依然是秘密。',
'不读取内容,只呈现时间的痕迹。',
'你的对话内容我们不碰,只帮你数数时间。',
'隐私是你的,回忆也是你的。',
'内容属于你,我们只借用时间。',
'安全地回望,温柔地整理。',
'从时间里回看你的聊天节奏。',
'一些数字,一点回忆。',
'简单整理,安静回看。',
'不多说,你自己看。',
'数字背后,是你的生活。',
'几张卡片,一年时光。',
'安静地看看这一年。',
'让数据说话。',
'你的一年,你的节奏。',
'回望,不打扰。',
'早安、晚安、在吗、好的——这些小词,撑起了一整年。',
'工作日的忙碌,周末的闲聊,都在这里了。',
'有些群天天响,有些人很少聊,但都是生活的一部分。',
'秒回的、已读不回的、消息免打扰的——都是你的选择。',
'置顶的那几个人,大概就是最重要的人吧。',
'表情包、语音、文字——你更喜欢哪种聊天方式?',
'深夜还在聊的,大概都是真朋友。',
'节日的群发祝福,和单独发的那条,不一样。',
'有些对话很长,有些只有一个表情包,但都算聊过。',
'聊天记录里,藏着你这一年的喜怒哀乐。',
]
// 随机选择 - 使用 useState 确保 SSR/CSR 一致,避免 hydration mismatch
const titleIndex = useState('wrapped-title-index', () => Math.floor(Math.random() * TITLES.length))
const subtitleIndex = useState('wrapped-subtitle-index', () => Math.floor(Math.random() * SUBTITLES.length))
const randomTitle = computed(() => TITLES[titleIndex.value])
const randomSubtitle = computed(() => SUBTITLES[subtitleIndex.value])
const props = defineProps({
year: { type: Number, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'

View File

@@ -0,0 +1,236 @@
<template>
<div class="w-full">
<div class="rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6">
<div class="flex items-center justify-between gap-4">
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
</div>
<div class="mt-4 grid gap-6 sm:grid-cols-[280px_1fr] items-center">
<div class="w-full max-w-[320px] mx-auto">
<svg viewBox="0 0 220 220" class="w-full h-auto select-none">
<!-- Grid -->
<g>
<polygon
v-for="i in rings"
:key="i"
:points="gridPolygonPoints(i / rings)"
fill="none"
stroke="rgba(0,0,0,0.08)"
stroke-width="1"
/>
<line
v-for="(p, idx) in axisPoints"
:key="idx"
:x1="cx"
:y1="cy"
:x2="p.x"
:y2="p.y"
stroke="rgba(0,0,0,0.10)"
stroke-width="1"
/>
</g>
<!-- Data polygon -->
<polygon
:points="dataPolygonPoints"
fill="rgba(7,193,96,0.18)"
stroke="rgba(7,193,96,0.85)"
stroke-width="2"
/>
<!-- Data nodes + tooltips -->
<g>
<circle
v-for="(p, idx) in dataPoints"
:key="idx"
:cx="p.x"
:cy="p.y"
r="4"
fill="#07C160"
stroke="white"
stroke-width="1.5"
>
<title>{{ p.title }}</title>
</circle>
</g>
<!-- Labels -->
<g>
<text
v-for="(l, idx) in labels"
:key="idx"
:x="l.x"
:y="l.y"
:text-anchor="l.anchor"
dominant-baseline="middle"
font-size="11"
fill="rgba(0,0,0,0.70)"
>
{{ l.label }}
</text>
</g>
</svg>
</div>
<div class="grid gap-3">
<div
v-for="m in metrics"
:key="m.key"
class="flex items-center justify-between gap-4"
>
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
<div class="flex items-center gap-3 min-w-[160px]">
<div class="h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden">
<div class="h-full rounded-full bg-[#07C160]" :style="{ width: Math.round(m.norm * 100) + '%' }" />
</div>
<div
:class="[
'wrapped-number text-sm w-[74px] text-right',
m.display === '—' ? 'text-[#00000055]' : 'text-[#07C160] font-semibold'
]"
>
{{ m.display }}
</div>
</div>
</div>
<!-- Note removed per UI requirement (keep layout compact). -->
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const formatFloat = (n, digits = 1) => {
const v = Number(n)
if (!Number.isFinite(v)) return '0'
return v.toFixed(digits)
}
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
const logNorm = (v, maxLog) => {
const n = Number(v) || 0
const ml = Number(maxLog) || 1
if (n <= 0) return 0
return clamp01(Math.log10(1 + n) / ml)
}
const totalMessages = computed(() => Number(props.data?.totalMessages || 0))
const activeDays = computed(() => Number(props.data?.activeDays || 0))
const messagesPerDay = computed(() => Number(props.data?.messagesPerDay || 0))
const topContactMessages = computed(() => Number(props.data?.topContact?.messages || 0))
const topGroupMessages = computed(() => Number(props.data?.topGroup?.messages || 0))
const topKindPct = computed(() => {
const ratio = Number(props.data?.topKind?.ratio || 0)
if (!Number.isFinite(ratio) || ratio <= 0) return 0
return Math.max(0, Math.min(100, Math.round(ratio * 100)))
})
const metrics = computed(() => [
{
key: 'totalMessages',
name: '发送消息',
label: '发送',
display: `${formatInt(totalMessages.value)}`,
norm: logNorm(totalMessages.value, 6)
},
{
key: 'activeDays',
name: '发消息天数',
label: '天数',
display: `${formatInt(activeDays.value)}/365`,
norm: clamp01(activeDays.value / 365)
},
{
key: 'messagesPerDay',
name: '日均发送',
label: '日均',
display: `${formatFloat(messagesPerDay.value, 1)}`,
norm: logNorm(messagesPerDay.value, 3)
},
{
key: 'topContactMessages',
name: '发得最多的人',
label: '常发',
display: topContactMessages.value > 0 ? `${formatInt(topContactMessages.value)}` : '—',
norm: logNorm(topContactMessages.value, 5)
},
{
key: 'topGroupMessages',
name: '发言最多的群',
label: '发言',
display: topGroupMessages.value > 0 ? `${formatInt(topGroupMessages.value)}` : '—',
norm: logNorm(topGroupMessages.value, 5)
},
{
key: 'topKindPct',
name: '最常用表达',
label: '表达',
display: topKindPct.value > 0 ? `${topKindPct.value}%` : '—',
norm: clamp01(topKindPct.value / 100)
}
])
const rings = 5
const cx = 110
const cy = 110
const radius = 74
const axisPoints = computed(() => {
const n = metrics.value.length
return metrics.value.map((_, idx) => {
const a = (Math.PI * 2 * idx) / n - Math.PI / 2
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), a }
})
})
const gridPolygonPoints = (t) => {
const pts = axisPoints.value.map((p) => `${cx + (p.x - cx) * t},${cy + (p.y - cy) * t}`)
return pts.join(' ')
}
const dataPoints = computed(() => {
const pts = []
const n = metrics.value.length
for (let i = 0; i < n; i++) {
const m = metrics.value[i]
const a = (Math.PI * 2 * i) / n - Math.PI / 2
const r = radius * clamp01(m.norm)
const x = cx + r * Math.cos(a)
const y = cy + r * Math.sin(a)
pts.push({ x, y, title: `${m.name}${m.display}` })
}
return pts
})
const dataPolygonPoints = computed(() => dataPoints.value.map((p) => `${p.x},${p.y}`).join(' '))
const labels = computed(() => {
const out = []
const n = metrics.value.length
for (let i = 0; i < n; i++) {
const m = metrics.value[i]
const a = (Math.PI * 2 * i) / n - Math.PI / 2
const r = radius + 18
const x = cx + r * Math.cos(a)
const y = cy + r * Math.sin(a)
const cos = Math.cos(a)
let anchor = 'middle'
if (cos > 0.35) anchor = 'start'
else if (cos < -0.35) anchor = 'end'
out.push({ x, y, label: m.label, anchor })
}
return out
})
</script>

View File

@@ -0,0 +1,416 @@
<template>
<div class="w-full">
<!-- 聊天气泡区域 -->
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
<div class="flex flex-col gap-3">
<!-- Received (left) -->
<div class="flex items-end gap-2">
<div class="avatar-box bg-white">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#07C160" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
</svg>
</div>
<div class="bubble-left">
<div class="wrapped-label text-xs text-[#00000066]">你收到的字</div>
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
{{ formatInt(receivedChars) }}
</div>
<div class="mt-1 wrapped-body text-xs text-[#7F7F7F]">
<template v-if="receivedA4Text">{{ receivedA4Text }}</template>
<template v-else-if="receivedChars > 0">这么多字都是别人认真对你的回应</template>
<template v-else>今年还没有收到文字消息</template>
</div>
<div v-if="receivedA4 && receivedA4.a4 && receivedA4.a4.sheets > 0" class="mt-1 text-[10px] text-[#00000055] wrapped-label">
{{ formatInt(receivedA4.a4.sheets) }} A4 · 堆叠高度约 {{ receivedA4.a4.heightText }}
</div>
</div>
</div>
<!-- Sent (right) -->
<div class="flex items-end gap-2 justify-end">
<div class="bubble-right">
<div class="wrapped-label text-xs text-[#00000080] text-right">你发送的字</div>
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
{{ formatInt(sentChars) }}
</div>
<div class="mt-1 wrapped-body text-xs text-[#00000099] text-right">
<template v-if="sentBookText">{{ sentBookText }}</template>
<template v-else-if="sentChars > 0">这么多字是你打出的每一次认真</template>
<template v-else>今年还没有文字消息</template>
</div>
</div>
<div class="avatar-box bg-[#95EC69]">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#1f2d1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</div>
</div>
</div>
</div>
<!-- 键盘磨损可视化 -->
<div class="keyboard-outer">
<div class="keyboard-inner">
<!-- 顶部信息 -->
<div class="keyboard-header">
<div class="keyboard-dots">
<span class="dot dot-red"></span>
<span class="dot dot-yellow"></span>
<span class="dot dot-green"></span>
</div>
<div class="keyboard-stats">{{ formatInt(totalKeyHits) }} KEYSTROKES</div>
</div>
<!-- 键盘主体 -->
<div class="keyboard-body">
<div v-for="(row, ri) in keyboardRows" :key="ri" class="kb-row">
<div
v-for="key in row"
:key="key.code + key.label"
class="kb-key"
:class="[`kb-w-${key.w || 1}`, { 'kb-space': key.isSpace }]"
:style="getKeyStyle(key.code)"
>
<div class="kb-key-top" :style="getKeyTopStyle(key.code)">
<span v-if="key.sub" class="kb-sub">{{ key.sub }}</span>
<span v-if="key.label" class="kb-label" :class="{ 'kb-label-sm': key.isFunc }" :style="getLabelStyle(key.code)">{{ key.label }}</span>
<div v-if="key.isSpace" class="kb-space-bar"></div>
</div>
</div>
</div>
</div>
<!-- 底部品牌 -->
<div class="keyboard-brand">WeChat Mechanical KB</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const sentChars = computed(() => Number(props.data?.sentChars || 0))
const receivedChars = computed(() => Number(props.data?.receivedChars || 0))
const sentBookText = computed(() => props.data?.sentBook?.text || '')
const receivedA4 = computed(() => props.data?.receivedA4 || null)
const receivedA4Text = computed(() => receivedA4.value?.text || '')
// 从后端获取键盘统计数据
const keyboardData = computed(() => props.data?.keyboard || null)
// 总敲击次数(优先使用后端数据)
const totalKeyHits = computed(() => {
// 注意totalKeyHits 可能为 0比如今年没发出文字消息不能用 truthy 判断。
const backend = Number(keyboardData.value?.totalKeyHits)
if (Number.isFinite(backend)) return backend
// 回退:粗略估算(仅基于"你发送的字",假设拼音输入 + 一定比例空格)
const letterHits = Math.round(sentChars.value * 2.8)
return letterHits + Math.round(letterHits * 0.15)
})
// 获取各键的敲击次数(优先使用后端精确数据)
const keyHitsMap = computed(() => {
const backendHits = keyboardData.value?.keyHits
const backendSpace = Number(keyboardData.value?.spaceHits || 0)
if (backendHits && typeof backendHits === 'object') {
// 后端把空格次数单独放在 spaceHits这里合并进 keyHitsMap 以便空格键也能显示磨损。
return backendSpace > 0 ? { ...backendHits, space: backendSpace } : backendHits
}
// 回退:使用默认频率估算(仅基于"你发送的字"
const defaultFreq = {
a: 0.121, i: 0.118, n: 0.098, e: 0.089, u: 0.082, g: 0.072, h: 0.065,
o: 0.052, z: 0.048, s: 0.042, x: 0.038, y: 0.036, d: 0.032, l: 0.028,
j: 0.026, b: 0.022, c: 0.020, w: 0.018, m: 0.016, f: 0.014, t: 0.012,
r: 0.010, p: 0.009, k: 0.007, q: 0.005, v: 0.001,
}
const letterHits = Math.round(sentChars.value * 2.8)
const spaceHits = Math.round(letterHits * 0.15)
const result = {}
for (const [k, freq] of Object.entries(defaultFreq)) {
result[k] = Math.round(freq * letterHits)
}
if (spaceHits > 0) result.space = spaceHits
return result
})
// 计算磨损程度0-1基于实际敲击次数
const getWear = (code) => {
const k = code.toLowerCase()
const hits = Number(keyHitsMap.value[k] || 0)
if (!Number.isFinite(hits) || hits <= 0) return 0
const values = Object.values(keyHitsMap.value).map((v) => Number(v) || 0)
const maxHits = Math.max(...values, 1)
// 小数量级键(如数字/标点)容易"看起来没变化",用对数缩放增强可视化差异。
const ratio = Math.log1p(hits) / Math.log1p(maxHits)
return Math.min(1, Math.pow(ratio, 1.6))
}
// 键盘布局
const keyboardRows = [
[
{ code: '`', label: '`', sub: '~' }, { code: '1', label: '1', sub: '!' },
{ code: '2', label: '2', sub: '@' }, { code: '3', label: '3', sub: '#' },
{ code: '4', label: '4', sub: '$' }, { code: '5', label: '5', sub: '%' },
{ code: '6', label: '6', sub: '^' }, { code: '7', label: '7', sub: '&' },
{ code: '8', label: '8', sub: '*' }, { code: '9', label: '9', sub: '(' },
{ code: '0', label: '0', sub: ')' }, { code: '-', label: '-', sub: '_' },
{ code: '=', label: '=', sub: '+' }, { code: 'backspace', label: '⌫', w: 2, isFunc: true },
],
[
{ code: 'tab', label: 'Tab', w: 1.5, isFunc: true },
{ code: 'q', label: 'Q' }, { code: 'w', label: 'W' }, { code: 'e', label: 'E' },
{ code: 'r', label: 'R' }, { code: 't', label: 'T' }, { code: 'y', label: 'Y' },
{ code: 'u', label: 'U' }, { code: 'i', label: 'I' }, { code: 'o', label: 'O' },
{ code: 'p', label: 'P' }, { code: '[', label: '[', sub: '{' },
{ code: ']', label: ']', sub: '}' }, { code: '\\', label: '\\', sub: '|', w: 1.5 },
],
[
{ code: 'caps', label: 'Caps', w: 1.75, isFunc: true },
{ code: 'a', label: 'A' }, { code: 's', label: 'S' }, { code: 'd', label: 'D' },
{ code: 'f', label: 'F' }, { code: 'g', label: 'G' }, { code: 'h', label: 'H' },
{ code: 'j', label: 'J' }, { code: 'k', label: 'K' }, { code: 'l', label: 'L' },
{ code: ';', label: ';', sub: ':' }, { code: "'", label: "'", sub: '"' },
{ code: 'enter', label: 'Enter', w: 2.25, isFunc: true },
],
[
{ code: 'shift', label: 'Shift', w: 2.25, isFunc: true },
{ code: 'z', label: 'Z' }, { code: 'x', label: 'X' }, { code: 'c', label: 'C' },
{ code: 'v', label: 'V' }, { code: 'b', label: 'B' }, { code: 'n', label: 'N' },
{ code: 'm', label: 'M' }, { code: ',', label: ',', sub: '<' },
{ code: '.', label: '.', sub: '>' }, { code: '/', label: '/', sub: '?' },
{ code: 'shift', label: 'Shift', w: 2.75, isFunc: true },
],
[
{ code: 'ctrl', label: 'Ctrl', w: 1.25, isFunc: true },
{ code: 'alt', label: 'Alt', w: 1.25, isFunc: true },
{ code: 'space', label: '', w: 6.25, isSpace: true },
{ code: 'alt', label: 'Alt', w: 1.25, isFunc: true },
{ code: 'ctrl', label: 'Ctrl', w: 1.25, isFunc: true },
],
]
// 键帽样式
const getKeyStyle = (code) => {
const w = getWear(code)
// Light theme: clean keys are bright; wear makes keys warmer and slightly darker.
const baseL = 94 - w * 18
const sat = 8 + w * 20
return {
'--key-bg': `hsl(40, ${sat}%, ${baseL}%)`,
'--key-bg-dark': `hsl(40, ${sat}%, ${baseL - 6}%)`,
'--key-border': `hsl(40, ${Math.max(0, sat - 2)}%, ${baseL - 18}%)`,
}
}
const getKeyTopStyle = (code) => {
const w = getWear(code)
const highlight = 0.55 - w * 0.35
const depth = 0.12 + w * 0.06
return {
background: `linear-gradient(180deg, var(--key-bg) 0%, var(--key-bg-dark) 100%)`,
// 用连续函数替代"阈值切换",避免出现"没到阈值就看不出变化"的感觉。
boxShadow: `inset 0 1px 0 rgba(255,255,255,${highlight}), inset 0 -1px 2px rgba(0,0,0,${depth})`,
}
}
const getLabelStyle = (code) => {
const w = getWear(code)
return {
opacity: 1 - w * 0.85,
filter: `blur(${w * 1.8}px)`,
}
}
</script>
<style scoped>
/* 头像 */
.avatar-box {
@apply w-8 h-8 rounded-lg border border-[#00000010] flex items-center justify-center flex-shrink-0;
}
/* 气泡 - 左侧 */
.bubble-left {
@apply relative max-w-[85%] bg-white shadow-sm rounded-xl px-3 py-2;
}
.bubble-left::before {
content: '';
position: absolute;
left: -6px;
bottom: 8px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid #fff;
filter: drop-shadow(-1px 0 0 rgba(0,0,0,0.05));
}
/* 气泡 - 右侧 */
.bubble-right {
@apply relative max-w-[85%] bg-[#95EC69] shadow-sm rounded-xl px-3 py-2;
}
.bubble-right::after {
content: '';
position: absolute;
right: -6px;
bottom: 8px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid #95EC69;
filter: drop-shadow(1px 0 0 rgba(0,0,0,0.05));
}
/* 键盘外框 */
.keyboard-outer {
@apply mt-3 rounded-2xl p-1;
background: linear-gradient(145deg, #ffffff, #e8e8e8);
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.keyboard-inner {
@apply rounded-xl p-3;
background: linear-gradient(180deg, #fbfbfb, #f0f0f0);
border: 1px solid rgba(0,0,0,0.06);
}
.keyboard-header {
@apply flex items-center justify-between mb-2 px-1;
}
.keyboard-dots {
@apply flex items-center gap-1.5;
}
.dot {
@apply w-2 h-2 rounded-full;
}
.dot-red { background: #ff5f57; }
.dot-yellow { background: #febc2e; }
.dot-green { background: #28c840; }
.keyboard-stats {
@apply text-[10px] text-[#00000066] tracking-wider;
font-family: ui-monospace, monospace;
}
.keyboard-body {
@apply rounded-lg p-2;
background: #f4f4f5;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.12);
}
.kb-row {
@apply flex justify-center gap-[3px] mb-[3px];
}
.kb-row:last-child {
@apply mb-0;
}
/* 键帽 */
.kb-key {
--unit: 22px;
height: 26px;
width: var(--unit);
position: relative;
}
@media (min-width: 640px) {
.kb-key {
--unit: 28px;
height: 32px;
}
}
/* 宽度变体 */
.kb-w-1 { width: var(--unit); }
.kb-w-1\.25 { width: calc(var(--unit) * 1.25 + 3px * 0.25); }
.kb-w-1\.5 { width: calc(var(--unit) * 1.5 + 3px * 0.5); }
.kb-w-1\.75 { width: calc(var(--unit) * 1.75 + 3px * 0.75); }
.kb-w-2 { width: calc(var(--unit) * 2 + 3px); }
.kb-w-2\.25 { width: calc(var(--unit) * 2.25 + 3px * 1.25); }
.kb-w-2\.75 { width: calc(var(--unit) * 2.75 + 3px * 1.75); }
.kb-w-6\.25 { width: calc(var(--unit) * 6.25 + 3px * 5.25); }
.kb-key::before {
content: '';
position: absolute;
inset: 0;
top: 2px;
background: #d4d4d8;
border-radius: 4px;
}
.kb-key-top {
position: absolute;
inset: 0;
bottom: 2px;
border-radius: 4px;
border: 1px solid var(--key-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.kb-sub {
font-size: 7px;
line-height: 1;
color: #666;
margin-bottom: 1px;
}
@media (min-width: 640px) {
.kb-sub {
font-size: 8px;
}
}
.kb-label {
font-size: 10px;
font-weight: 500;
color: #262626;
line-height: 1;
text-shadow: 0 1px 0 rgba(255,255,255,0.6);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
}
@media (min-width: 640px) {
.kb-label {
font-size: 11px;
}
}
.kb-label-sm {
font-size: 7px !important;
font-weight: 400;
}
@media (min-width: 640px) {
.kb-label-sm {
font-size: 8px !important;
}
}
.kb-space-bar {
width: 50%;
height: 3px;
background: rgba(0,0,0,0.12);
border-radius: 2px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.18);
}
.keyboard-brand {
@apply mt-2 text-center text-[8px] text-[#00000025] tracking-[0.15em] uppercase;
}
</style>

View File

@@ -1,10 +1,10 @@
<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 class="wrapped-body text-sm text-[#7F7F7F]">
<span class="wrapped-number text-[#07C160] font-semibold">{{ totalMessages }}</span> 条消息
</div>
<div class="text-xs text-[#00000066]">24H × 7Days</div>
<div class="wrapped-label text-xs text-[#00000066]">24H x 7Days</div>
</div>
<div class="mt-4 overflow-x-auto" data-wrapped-scroll-x>
@@ -15,7 +15,7 @@
<span
v-for="(s, idx) in timeLabels"
:key="idx"
class="col-span-4 font-mono"
class="col-span-4 wrapped-number"
>
{{ s }}
</span>
@@ -24,7 +24,7 @@
<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">
<div v-for="(w, wi) in weekdayLabels" :key="wi" class="flex items-center wrapped-body">
{{ w }}
</div>
</div>
@@ -35,7 +35,7 @@
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) }"
:style="{ backgroundColor: colorFor(v), transformOrigin: originFor(wi, hi) }"
:title="tooltipFor(wi, hi, v)"
/>
</template>
@@ -46,13 +46,13 @@
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066]">
<div class="flex items-center gap-2">
<span></span>
<span class="wrapped-body"></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>
<span class="wrapped-body"></span>
</div>
<div v-if="maxValue > 0">最大 {{ maxValue }}</div>
<div v-if="maxValue > 0" class="wrapped-number">最大 {{ maxValue }}</div>
</div>
</div>
</template>
@@ -102,4 +102,12 @@ const legendColor = (i) => {
const t = i / 6
return heatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1)
}
const originFor = (weekdayIndex, hour) => {
// Avoid hover scaling pushing scrollWidth/scrollHeight and showing scrollbars:
// keep the "outer" edges anchored on the first/last row/col.
const x = hour === 0 ? 'left' : (hour === 23 ? 'right' : 'center')
const y = weekdayIndex === 0 ? 'top' : (weekdayIndex === 6 ? 'bottom' : 'center')
return `${x} ${y}`
}
</script>