mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(wrapped-ui): 年度总结页支持懒加载与复古模式,新增概览/字数卡片
- wrapped 页面改为:先拉 meta/年份列表,再按页请求单张卡片,首屏更快 - 新增 Card#0 全局概览页(含图表) - 新增 Card#2 消息字数页(含键盘敲击统计与图表) - 新增复古模式:像素字体资源 + CRT Overlay,支持一键开关 - 调整 shared 组件、types/useApi,更新前端依赖与 lock
This commit is contained in:
194
frontend/components/wrapped/cards/Card00GlobalOverview.vue
Normal file
194
frontend/components/wrapped/cards/Card00GlobalOverview.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
42
frontend/components/wrapped/cards/Card02MessageChars.vue
Normal file
42
frontend/components/wrapped/cards/Card02MessageChars.vue
Normal 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>
|
||||
|
||||
24
frontend/components/wrapped/shared/WrappedCRTOverlay.vue
Normal file
24
frontend/components/wrapped/shared/WrappedCRTOverlay.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
416
frontend/components/wrapped/visualizations/MessageCharsChart.vue
Normal file
416
frontend/components/wrapped/visualizations/MessageCharsChart.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user