mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
3072297769
- 增加 Card07BentoSummary 前端渲染与加载/失败重试交互(注入 wrappedRetryCard) - 后端新增 card_07_bento_summary:基于已实现卡片聚合生成 snapshot,保证渲染稳定 - Wrapped manifest/implemented_upto 升级到 7,并支持按卡片 id 单独构建 - 新增 manifest 末尾卡片校验测试
2748 lines
80 KiB
Vue
2748 lines
80 KiB
Vue
<template>
|
||
<WrappedCardShell
|
||
:card-id="Number(card?.id || 7)"
|
||
:title="String(card?.title || '便当总览:一屏看完这一年')"
|
||
:narrative="String(card?.narrative || '')"
|
||
:variant="variant"
|
||
:wide="true"
|
||
:hide-chrome="true"
|
||
class="h-full w-full"
|
||
>
|
||
<div
|
||
ref="stageRoot"
|
||
class="bento-stage"
|
||
:class="[variant === 'slide' ? 'is-slide' : 'is-panel', !isOk ? 'is-loading' : '']"
|
||
:aria-busy="!isOk"
|
||
:aria-label="!isOk ? '便当总览正在生成' : null"
|
||
>
|
||
<div v-if="isOk" class="bento-container">
|
||
<!-- 1. 发送消息条数 -->
|
||
<div class="bento-card card-messages group">
|
||
<div class="aura bg-blue-400/20 w-[120%] h-[120%] -top-[60%] -left-[20%]"></div>
|
||
<div class="content justify-between relative">
|
||
<div class="card-title z-10">
|
||
<span class="text-blue-500/80">✈</span>
|
||
发送消息条数
|
||
</div>
|
||
|
||
<div
|
||
class="absolute inset-x-0 bottom-0 h-1/2 flex items-end opacity-20 group-hover:opacity-40 transition-opacity z-0 pointer-events-none"
|
||
>
|
||
<svg viewBox="0 0 100 40" preserveAspectRatio="none" class="w-full h-full text-blue-500">
|
||
<path
|
||
d="M0 40 L0 30 C20 30, 30 15, 50 20 C70 25, 80 5, 100 10 L100 40 Z"
|
||
fill="currentColor"
|
||
></path>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="mt-auto flex flex-col z-10 shrink-0">
|
||
<div class="flex items-baseline gap-1.5 mb-1">
|
||
<span class="text-6xl font-black tracking-tighter">{{ formatInt(totalMessages) }}</span>
|
||
<span class="text-lg text-gray-500 font-medium">条</span>
|
||
</div>
|
||
<div
|
||
class="text-[0.65rem] sm:text-xs text-blue-600 font-medium bg-blue-500/10 self-start px-2 py-0.5 rounded-full border border-blue-500/20"
|
||
>
|
||
平均每天发送 <span>{{ formatInt(messagesPerDayRounded) }}</span> 条
|
||
</div>
|
||
</div>
|
||
<div class="bento-watermark text-blue-500">✈</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 发送消息字数 -->
|
||
<div class="bento-card card-words group">
|
||
<div class="aura bg-emerald-400/20 w-[150%] h-[200%] -bottom-[100%] right-[0%]"></div>
|
||
<div class="content justify-between overflow-hidden">
|
||
<div class="card-title">
|
||
<span class="text-emerald-500/80">✍</span>
|
||
发送消息字数
|
||
</div>
|
||
<div class="mt-auto flex flex-col items-start min-h-0">
|
||
<div class="flex items-baseline gap-1.5 min-h-0">
|
||
<span class="text-6xl font-black tracking-tighter">{{ sentCharsWan }}</span>
|
||
<span class="text-lg text-gray-500 font-medium">万字</span>
|
||
</div>
|
||
<div
|
||
class="text-[0.7rem] sm:text-[0.8rem] md:text-xs text-emerald-700 font-medium bg-emerald-500/10 inline-flex items-center px-2 py-1 sm:px-2.5 sm:py-1.5 rounded-full mt-1 border border-emerald-500/20 max-w-full overflow-hidden shrink-0"
|
||
>
|
||
<span class="mr-1.5 sm:mr-2 shrink-0">📖</span>
|
||
<span class="truncate">相当于写了一本《了不起的盖茨比》</span>
|
||
</div>
|
||
</div>
|
||
<div class="bento-watermark text-emerald-500">✍</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. 新加好友数量 -->
|
||
<div class="bento-card card-friends group">
|
||
<div class="aura bg-yellow-400/25 w-[150%] h-[150%] top-[20%] left-[20%]"></div>
|
||
<div class="content justify-between h-full relative">
|
||
<div class="card-title z-10">
|
||
<span class="text-yellow-500/80">➕</span>
|
||
新加好友
|
||
</div>
|
||
|
||
<div
|
||
class="absolute inset-0 flex items-center justify-center opacity-10 group-hover:opacity-30 transition-opacity z-0 pointer-events-none"
|
||
>
|
||
<div class="grid grid-cols-3 gap-2 sm:gap-3 p-4">
|
||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500"></div>
|
||
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
|
||
<div class="w-1 h-1 rounded-full bg-yellow-500"></div>
|
||
<div class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500"></div>
|
||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||
<div class="w-1 h-1 rounded-full bg-yellow-500"></div>
|
||
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-auto flex flex-col z-10 shrink-0 mb-1">
|
||
<div class="flex items-baseline gap-1">
|
||
<span class="text-4xl font-black tracking-tighter text-yellow-500">{{ formatInt(addedFriends) }}</span>
|
||
<span class="text-sm text-gray-500 ml-1">位</span>
|
||
</div>
|
||
<div class="text-[0.6rem] sm:text-[0.65rem] text-yellow-600 font-medium mt-0.5">扩列了全新的生活圈</div>
|
||
</div>
|
||
<div class="bento-watermark text-yellow-500">➕</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4. 最常活跃时间 -->
|
||
<div class="bento-card card-time group">
|
||
<div class="aura bg-purple-400/20 w-full h-full -top-[30%] -right-[30%]"></div>
|
||
<div class="content flex flex-col justify-between h-full">
|
||
<div class="card-title">
|
||
<span class="text-purple-500/80">🕒</span>
|
||
最常活跃时间
|
||
</div>
|
||
<div class="mt-auto mb-1 z-10 shrink-0">
|
||
<div class="text-3xl font-black tracking-tighter">{{ mostActiveHourLabel }}</div>
|
||
<div class="text-[0.6rem] sm:text-[0.65rem] text-purple-600 mt-0.5 opacity-90 truncate">
|
||
{{ mostActiveHourDesc }}
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex items-end gap-1.5 h-6 sm:h-8 opacity-60 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-auto"
|
||
>
|
||
<div class="w-full bg-black/10 rounded-t-sm h-[20%]"></div>
|
||
<div class="w-full bg-black/10 rounded-t-sm h-[40%]"></div>
|
||
<div class="w-full bg-black/10 rounded-t-sm h-[60%]"></div>
|
||
<div
|
||
class="w-full bg-purple-500 rounded-t-sm h-[100%] relative shadow-[0_0_8px_rgba(168,85,247,0.4)]"
|
||
>
|
||
<div
|
||
class="absolute -top-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-white rounded-full shadow-sm"
|
||
></div>
|
||
</div>
|
||
<div class="w-full bg-black/10 rounded-t-sm h-[60%]"></div>
|
||
<div class="w-full bg-black/10 rounded-t-sm h-[30%]"></div>
|
||
</div>
|
||
<div class="bento-watermark text-purple-500">🕒</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 5. 年度聊天搭子 -->
|
||
<div class="bento-card card-partner group">
|
||
<div class="aura bg-pink-400/20 w-[150%] h-[150%] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
|
||
<!-- Floating Hearts Background -->
|
||
<div class="absolute inset-0 overflow-hidden pointer-events-none z-0" aria-hidden="true">
|
||
<i class="fa-solid fa-heart floating-heart text-pink-300" style="left: 10%; animation-delay: 0s; font-size: 1.2rem;"></i>
|
||
<i class="fa-solid fa-heart floating-heart text-rose-300" style="left: 30%; animation-delay: 1.5s; font-size: 1.5rem;"></i>
|
||
<i class="fa-solid fa-heart floating-heart text-pink-200" style="left: 70%; animation-delay: 2.5s; font-size: 1rem;"></i>
|
||
<i class="fa-solid fa-heart floating-heart text-red-300" style="left: 85%; animation-delay: 0.5s; font-size: 1.3rem;"></i>
|
||
</div>
|
||
|
||
<div class="content flex flex-col h-full overflow-hidden relative z-10">
|
||
<div class="card-title shrink-0 relative z-20">
|
||
<i class="fa-solid fa-heart text-pink-500 animate-pulse"></i>
|
||
年度聊天搭子
|
||
</div>
|
||
|
||
<div class="partner-split-layout mt-1 flex-1 flex flex-row items-stretch gap-3">
|
||
<div class="partner-profile-zone flex flex-col items-center justify-center w-[35%] shrink-0">
|
||
<div class="relative w-[4.5rem] h-[4.5rem] sm:w-[5.5rem] sm:h-[5.5rem] mb-2 shrink-0">
|
||
<div
|
||
class="absolute inset-[-8px] bg-gradient-to-tr from-pink-400 to-rose-300 rounded-full animate-pulse opacity-40 blur-lg"
|
||
></div>
|
||
|
||
<template v-if="bestBuddyAvatarUrl && !broken.bestBuddy">
|
||
<img
|
||
:src="bestBuddyAvatarUrl"
|
||
class="w-full h-full rounded-full border-[3px] border-white object-cover relative z-10 shadow-lg bg-gray-50 filter drop-shadow-[0_4px_12px_rgba(244,114,182,0.3)] transition-transform duration-500 hover:scale-105"
|
||
:alt="bestBuddyName"
|
||
@error="markBroken('bestBuddy')"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="w-full h-full rounded-full border-[3px] border-white relative z-10 shadow-lg bg-gray-50 flex items-center justify-center font-black text-xl text-pink-600"
|
||
>
|
||
{{ avatarFallback(bestBuddyName) }}
|
||
</div>
|
||
</template>
|
||
|
||
<div
|
||
class="absolute -bottom-1 left-1/2 -translate-x-1/2 bg-gradient-to-r from-yellow-400 to-amber-500 text-white text-[10px] sm:text-[11px] px-2.5 py-0.5 rounded-full z-20 shadow-md border-[1.5px] border-white whitespace-nowrap font-black tracking-tight"
|
||
>
|
||
<i class="fa-solid fa-crown text-[9px] mr-0.5"></i> MVP
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-lg sm:text-xl font-black tracking-tight text-gray-800 text-center truncate w-full px-1">
|
||
{{ bestBuddyName }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="partner-metrics-zone flex-1 flex flex-col justify-center gap-2">
|
||
<!-- Strip 1: Volume -->
|
||
<div
|
||
class="partner-metric-strip bg-white/40 p-2 rounded-xl flex items-center gap-2.5 shadow-sm border border-white/50"
|
||
>
|
||
<div
|
||
class="partner-metric-icon bg-gradient-to-br from-blue-400 to-blue-500 text-white w-8 h-8 rounded-full flex items-center justify-center shrink-0 shadow-sm"
|
||
>
|
||
<i class="fa-solid fa-message text-xs"></i>
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-wider">总互动</span>
|
||
<div class="flex items-baseline gap-1 leading-none">
|
||
<span class="text-xl font-black text-gray-800 tracking-tight">{{ formatInt(bestBuddyTotal) }}</span>
|
||
<span class="text-[10px] font-bold text-gray-400">次</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Strip 2: Streak -->
|
||
<div
|
||
class="partner-metric-strip bg-white/40 p-2 rounded-xl flex items-center gap-2.5 shadow-sm border border-white/50"
|
||
>
|
||
<div
|
||
class="partner-metric-icon bg-gradient-to-br from-orange-400 to-amber-500 text-white w-8 h-8 rounded-full flex items-center justify-center shrink-0 shadow-sm"
|
||
>
|
||
<i class="fa-solid fa-fire text-xs"></i>
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-wider">最长连聊</span>
|
||
<div class="flex items-baseline gap-1 leading-none">
|
||
<span class="text-xl font-black text-gray-800 tracking-tight">{{ bestBuddyStreakDaysLabel }}</span>
|
||
<span class="text-[10px] font-bold text-gray-400">天</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Strip 3: Resonance -->
|
||
<div
|
||
class="partner-metric-strip bg-white/40 p-2 rounded-xl flex items-center gap-2.5 shadow-sm border border-white/50"
|
||
>
|
||
<div
|
||
class="partner-metric-icon bg-gradient-to-br from-emerald-400 to-teal-500 text-white w-8 h-8 rounded-full flex items-center justify-center shrink-0 shadow-sm"
|
||
>
|
||
<i class="fa-solid fa-clock text-xs"></i>
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-wider">同频时刻</span>
|
||
<div class="flex items-baseline gap-1 leading-none">
|
||
<span class="text-xl font-black text-emerald-600 tracking-tight">{{ bestBuddyPeakLabel }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 5.1 最爱聊的群聊 -->
|
||
<div class="bento-card card-group group">
|
||
<div class="aura bg-sky-400/20 w-[120%] h-[120%] -bottom-[20%] -right-[20%]"></div>
|
||
<div class="content flex flex-col h-full overflow-hidden">
|
||
<div class="card-title shrink-0">
|
||
<span class="text-sky-500/80">👥</span>
|
||
最爱群聊
|
||
</div>
|
||
<div class="flex-grow flex items-center gap-3 mt-1 px-1 sm:px-2 min-h-0">
|
||
<div class="shrink-0">
|
||
<template v-if="topGroupAvatarUrl && !broken.topGroup">
|
||
<img
|
||
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-gray-50 object-cover"
|
||
:src="topGroupAvatarUrl"
|
||
:alt="topGroupName"
|
||
@error="markBroken('topGroup')"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-sky-100 flex items-center justify-center font-black text-sky-700"
|
||
>
|
||
{{ avatarFallback(topGroupName) }}
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="flex flex-col min-w-0 flex-1">
|
||
<div class="text-base sm:text-lg font-black tracking-tight text-gray-800 w-full truncate">{{ topGroupName }}</div>
|
||
<div
|
||
class="mt-1 text-[10px] sm:text-xs font-semibold text-sky-600 bg-sky-500/10 px-3 py-1 rounded-full border border-sky-500/10 inline-flex items-center gap-1.5 max-w-full truncate self-start"
|
||
>
|
||
<span class="shrink-0">🔥</span>
|
||
<span class="truncate"
|
||
>全年发了 <span class="font-bold">{{ formatInt(topGroupMessages) }}</span> 条</span
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="shrink-0">
|
||
<div
|
||
class="group-share-ring w-14 h-14 sm:w-16 sm:h-16 rounded-full p-[3px] shadow-[0_12px_26px_rgba(14,165,233,0.14)]"
|
||
:style="{ '--p': String(topGroupSharePct) }"
|
||
>
|
||
<div
|
||
class="w-full h-full rounded-full bg-white/70 border border-white/80 backdrop-blur-xl flex flex-col items-center justify-center text-center leading-none"
|
||
>
|
||
<div class="text-sm sm:text-base font-black text-sky-700 tracking-tight">{{ topGroupSharePct }}%</div>
|
||
<div class="text-[8px] sm:text-[9px] font-semibold text-sky-600/80 mt-0.5">占全年</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[9px] sm:text-[10px] font-semibold text-sky-700/70 text-center whitespace-nowrap">
|
||
日均 {{ topGroupDailyLabel }} 条
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bento-watermark text-sky-500">👥</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 5.2 回复速度 -->
|
||
<div class="bento-card card-speed group">
|
||
<div class="aura bg-orange-400/20 w-[150%] h-[150%] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
|
||
<div class="content flex flex-col h-full overflow-hidden">
|
||
<div class="card-title shrink-0">
|
||
<span class="text-orange-500/80">⏱</span>
|
||
回复速度
|
||
</div>
|
||
|
||
<div class="reply-bento">
|
||
<div class="reply-subcard large">
|
||
<div class="subcard-title relative z-10">中位数 P50</div>
|
||
<div class="subcard-value text-transparent bg-clip-text bg-gradient-to-r from-orange-500 to-amber-500 relative z-10">
|
||
{{ replyP50Label }}
|
||
</div>
|
||
<div class="reply-subcard-icon text-orange-400 drop-shadow-sm transition-all group-hover:scale-110">⚡</div>
|
||
</div>
|
||
|
||
<div class="reply-subcard">
|
||
<div class="reply-subcard-icon text-emerald-500">🚀</div>
|
||
<div class="subcard-title text-emerald-700/60 flex items-center gap-1 relative z-10">
|
||
<span class="bg-emerald-100 rounded-full w-3 h-3 flex items-center justify-center shrink-0">🚀</span>
|
||
秒回
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-[2px] min-w-0 w-full overflow-hidden relative z-10">
|
||
<span class="subcard-value text-emerald-600 truncate max-w-[50%]">{{ fastestReplyLabel }}</span>
|
||
<div class="ml-auto shrink-0" :title="fastestContactName">
|
||
<template v-if="fastestAvatarUrl && !broken.fastestAvatar">
|
||
<img
|
||
class="w-7 h-7 rounded-full bg-white border border-emerald-500/20 shadow-sm object-cover"
|
||
:src="fastestAvatarUrl"
|
||
:alt="fastestContactName"
|
||
@error="markBroken('fastestAvatar')"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="w-7 h-7 rounded-full bg-emerald-50 border border-emerald-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-emerald-700"
|
||
>
|
||
{{ avatarFallback(fastestContactName) }}
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="reply-subcard">
|
||
<div class="reply-subcard-icon text-rose-500">🐌</div>
|
||
<div class="subcard-title text-rose-700/60 flex items-center gap-1 relative z-10">
|
||
<span class="bg-rose-100 rounded-full w-3 h-3 flex items-center justify-center shrink-0">🐌</span>
|
||
意念
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-[2px] min-w-0 w-full overflow-hidden relative z-10">
|
||
<span class="subcard-value text-rose-500 truncate max-w-[50%]">{{ slowestReplyLabel }}</span>
|
||
<div class="ml-auto shrink-0" :title="slowestContactName">
|
||
<template v-if="slowestAvatarUrl && !broken.slowestAvatar">
|
||
<img
|
||
class="w-7 h-7 rounded-full bg-white border border-rose-500/20 shadow-sm object-cover"
|
||
:src="slowestAvatarUrl"
|
||
:alt="slowestContactName"
|
||
@error="markBroken('slowestAvatar')"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="w-7 h-7 rounded-full bg-rose-50 border border-rose-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-rose-700"
|
||
>
|
||
{{ avatarFallback(slowestContactName) }}
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 6. 年度口头禅 -->
|
||
<div class="bento-card card-catchphrase group">
|
||
<div class="aura bg-indigo-400/20 w-40 h-40 -top-10 -left-10"></div>
|
||
<div class="content flex flex-col h-full">
|
||
<div class="card-title">
|
||
<span class="text-indigo-500/80">❝</span>
|
||
年度口头禅
|
||
</div>
|
||
<div class="flex-grow flex items-center justify-center">
|
||
<div
|
||
class="text-3xl font-black text-transparent text-gradient bg-gradient-to-br from-blue-500 to-indigo-600 transform group-hover:scale-110 transition-transform"
|
||
>
|
||
"{{ topPhraseWord }}"
|
||
</div>
|
||
</div>
|
||
<div class="text-[10px] sm:text-xs text-indigo-600 mt-auto text-center bg-indigo-500/10 rounded-full py-0.5 sm:py-1">
|
||
说了 <span>{{ formatInt(topPhraseCount) }}</span> 次
|
||
</div>
|
||
<div class="bento-watermark text-indigo-400">❝</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 8. 最爱的表情包 -->
|
||
<div class="bento-card card-sticker group">
|
||
<div class="aura bg-orange-400/15 w-full h-[80%] bottom-0 right-0"></div>
|
||
<div class="content flex flex-col items-center h-full overflow-hidden">
|
||
<div class="card-title shrink-0 self-start w-full">
|
||
<span class="text-orange-500/80">🖼</span>
|
||
最爱表情包
|
||
</div>
|
||
<div class="flex-grow flex flex-col items-center justify-center min-h-0 relative">
|
||
<div
|
||
class="absolute w-32 h-32 rounded-full bg-gradient-to-br from-orange-300/20 via-amber-300/12 to-yellow-300/8 blur-2xl pointer-events-none"
|
||
></div>
|
||
<div class="relative w-full h-full flex items-center justify-center p-2">
|
||
<template v-if="topStickerUrl && !broken.topSticker">
|
||
<img
|
||
v-if="topStickerMode === 'img'"
|
||
:src="topStickerUrl"
|
||
class="relative z-10 w-full h-full object-contain rounded-2xl filter drop-shadow-[0_8px_24px_rgba(249,115,22,0.18)] group-hover:scale-[1.04] group-hover:-rotate-1 transition-transform duration-500 ease-out"
|
||
alt="Top sticker"
|
||
@error="onTopStickerImgError"
|
||
/>
|
||
<video
|
||
v-else
|
||
:src="topStickerUrl"
|
||
class="relative z-10 w-full h-full object-contain rounded-2xl filter drop-shadow-[0_8px_24px_rgba(249,115,22,0.18)] group-hover:scale-[1.04] group-hover:-rotate-1 transition-transform duration-500 ease-out"
|
||
autoplay
|
||
loop
|
||
muted
|
||
playsinline
|
||
preload="auto"
|
||
@error="onTopStickerVideoError"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div class="relative z-10 text-6xl opacity-60 filter drop-shadow-[0_8px_24px_rgba(249,115,22,0.12)]">🧩</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="shrink-0 flex flex-col items-center gap-1.5 pb-1">
|
||
<div class="flex items-baseline gap-1">
|
||
<span
|
||
class="text-3xl sm:text-4xl font-black tracking-tight text-transparent text-gradient bg-gradient-to-br from-orange-500 to-amber-500"
|
||
>
|
||
{{ formatInt(sentStickerCount) }}
|
||
</span>
|
||
<span class="text-xs sm:text-sm text-gray-400 font-semibold">次发送</span>
|
||
</div>
|
||
<div
|
||
class="text-[9px] sm:text-[10px] font-semibold text-orange-700/80 bg-orange-500/8 border border-orange-500/12 px-2.5 py-0.5 rounded-full whitespace-nowrap"
|
||
>
|
||
占全年消息的 {{ stickerShareText }}
|
||
</div>
|
||
</div>
|
||
<div class="bento-watermark text-orange-400">🖼</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 9. 最爱的emoji -->
|
||
<div class="bento-card card-emoji group">
|
||
<div class="aura bg-yellow-400/20 w-[150%] h-[150%] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
|
||
<div class="content w-full h-full flex flex-col items-center overflow-hidden">
|
||
<div class="card-title self-start w-full shrink-0">
|
||
<span class="text-yellow-500/80">☺</span>
|
||
最爱emoji
|
||
</div>
|
||
<div class="flex-grow flex items-center justify-center w-full relative min-h-0">
|
||
<div
|
||
class="absolute w-20 h-20 rounded-full bg-gradient-to-br from-yellow-300/25 via-amber-300/15 to-orange-300/10 blur-xl pointer-events-none"
|
||
></div>
|
||
|
||
<span
|
||
class="scattered-emoji text-3xl"
|
||
style="--tx: -4rem; --ty: -2.5rem; --s: 0.8; --r: -15deg; animation-delay: 0.1s; --o: 0.6;"
|
||
>😂</span
|
||
>
|
||
<span
|
||
class="scattered-emoji text-4xl"
|
||
style="--tx: 4.5rem; --ty: -1.5rem; --s: 0.6; --r: 25deg; animation-delay: 0.2s; --o: 0.5;"
|
||
>🤣</span
|
||
>
|
||
<span
|
||
class="scattered-emoji text-2xl"
|
||
style="--tx: -3.5rem; --ty: 3rem; --s: 0.7; --r: 10deg; animation-delay: 0.15s; --o: 0.5;"
|
||
>❤️</span
|
||
>
|
||
<span
|
||
class="scattered-emoji text-3xl"
|
||
style="--tx: 3rem; --ty: 3.5rem; --s: 0.9; --r: -20deg; animation-delay: 0.05s; --o: 0.7;"
|
||
>😭</span
|
||
>
|
||
<span
|
||
class="scattered-emoji text-4xl"
|
||
style="--tx: -0.5rem; --ty: -4rem; --s: 0.5; --r: 45deg; animation-delay: 0.25s; --o: 0.3;"
|
||
>🙏</span
|
||
>
|
||
<span
|
||
class="scattered-emoji text-2xl"
|
||
style="--tx: 1rem; --ty: 4rem; --s: 0.65; --r: -35deg; animation-delay: 0.3s; --o: 0.45;"
|
||
>👍</span
|
||
>
|
||
|
||
<span
|
||
class="text-5xl sm:text-6xl filter drop-shadow-[0_4px_12px_rgba(250,204,21,0.5)] group-hover:scale-[1.25] group-hover:-rotate-12 transition-all duration-300 cursor-pointer z-10 relative"
|
||
>
|
||
{{ topUnicodeEmoji }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="text-[9px] sm:text-[10px] font-semibold text-yellow-700/80 bg-yellow-500/10 border border-yellow-500/15 px-2.5 py-0.5 rounded-full whitespace-nowrap shrink-0"
|
||
>
|
||
使用了 <span class="font-black">{{ formatInt(topUnicodeEmojiCount) }}</span> 次
|
||
</div>
|
||
<div class="bento-watermark text-yellow-500">☺</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 7. 月度最佳好友 -->
|
||
<div class="bento-card card-monthly group">
|
||
<div class="aura bg-teal-400/20 w-[150%] h-[150%] -bottom-[20%] -right-[20%]"></div>
|
||
<div class="content flex flex-col h-full overflow-hidden">
|
||
<div class="card-title shrink-0">
|
||
<span class="text-teal-600/80">📅</span>
|
||
月度最佳好友
|
||
</div>
|
||
|
||
<div v-if="showMonthlyHero" class="monthly-hero">
|
||
<div class="monthly-avatar-lg">
|
||
<template v-if="monthlyMvpAvatarUrl && !broken.monthlyMvp">
|
||
<img :src="monthlyMvpAvatarUrl" alt="MVP" @error="markBroken('monthlyMvp')" />
|
||
</template>
|
||
<template v-else>
|
||
<div class="monthly-avatar-fallback-lg">{{ avatarFallback(monthlyMvpName) }}</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="monthly-hero-info">
|
||
<span
|
||
class="text-2xl sm:text-3xl font-black tracking-tight text-transparent bg-clip-text bg-gradient-to-br from-teal-600 to-emerald-500 leading-none truncate"
|
||
>
|
||
{{ monthlyMvpName }}
|
||
</span>
|
||
<span
|
||
class="text-[9px] sm:text-[10px] font-semibold text-teal-700/70 bg-teal-500/10 border border-teal-500/15 px-2 py-0.5 rounded-full whitespace-nowrap w-fit"
|
||
>
|
||
上榜 <span>{{ monthlyMvpMonths }}</span>/12 个月
|
||
</span>
|
||
</div>
|
||
|
||
<div class="monthly-metrics">
|
||
<div v-for="m in monthlyMvpMetrics" :key="m.key" class="monthly-metric-item">
|
||
<div class="monthly-metric-header">
|
||
<span>{{ m.label }}</span>
|
||
<span class="metric-val">{{ m.pct }}</span>
|
||
</div>
|
||
<div class="monthly-metric-bar">
|
||
<div class="monthly-metric-fill" :style="{ width: `${m.pct}%` }"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monthly-grid shrink-0 mt-auto" :class="showMonthlyHero ? '' : 'expanded'">
|
||
<div
|
||
v-for="item in monthlyCells"
|
||
:key="item.month"
|
||
class="monthly-cell"
|
||
:class="item._isMvp ? 'is-mvp' : ''"
|
||
:style="item._cellStyle"
|
||
:title="`${item.month}月 · ${item._nameLabel} · ${formatInt(item.messages)} 条`"
|
||
>
|
||
<template v-if="item._avatarUrl && !broken['m-' + item.month]">
|
||
<img
|
||
class="monthly-avatar-sm"
|
||
:src="item._avatarUrl"
|
||
:alt="item._nameLabel"
|
||
@error="markBroken('m-' + item.month)"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div class="monthly-avatar-fallback-sm" :style="{ background: item._avatarFallbackBg }">
|
||
{{ avatarFallback(item._nameLabel) }}
|
||
</div>
|
||
</template>
|
||
<div class="monthly-cell-month">{{ item.month }}月</div>
|
||
<div v-if="!showMonthlyHero" class="monthly-cell-name">{{ item._nameLabel }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="bento-watermark text-teal-600">📅</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 10. 年度热力图 -->
|
||
<div class="bento-card card-heatmap group">
|
||
<div class="aura bg-emerald-400/15 w-[50%] h-[150%] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
|
||
<div class="content flex flex-col h-full min-h-0">
|
||
<div class="flex items-center justify-between gap-2 z-10">
|
||
<div class="card-title mb-0">
|
||
<span class="text-emerald-500/80">🗓</span>
|
||
活跃时段热力图
|
||
</div>
|
||
<div class="flex items-center gap-2 shrink-0">
|
||
<div class="heatmap-year-tag">{{ heatmapYearLabel }}</div>
|
||
<div class="heatmap-max-label">{{ heatmapMaxLabel }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="heatmap-shell relative z-10">
|
||
<div class="heatmap-main">
|
||
<div class="heatmap-weekday-col">
|
||
<span v-for="(w, i) in weekdayLabels" :key="i">{{ w }}</span>
|
||
</div>
|
||
<div class="heatmap-grid" aria-label="24x7 聊天活跃热力图">
|
||
<button
|
||
v-for="cell in heatmapCells"
|
||
:key="cell.key"
|
||
type="button"
|
||
class="heatmap-cell"
|
||
:style="{ backgroundColor: cell.bg, opacity: cell.opacity }"
|
||
:title="cell.title"
|
||
:aria-label="cell.title"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="heatmap-hour-row mt-1">
|
||
<span class="heatmap-hour-spacer"></span>
|
||
<span v-for="h in hourLabelRow" :key="h.key" class="heatmap-hour-label">{{ h.label }}</span>
|
||
</div>
|
||
|
||
<div class="heatmap-legend mt-3 justify-end w-full">
|
||
<div class="heatmap-legend-scale flex items-center gap-1">
|
||
<span class="text-[0.6rem] text-emerald-800/40 font-semibold mr-1">少</span>
|
||
<div
|
||
v-for="(c, i) in heatmapLegendColors"
|
||
:key="i"
|
||
class="heatmap-legend-dot"
|
||
:style="{ backgroundColor: c, opacity: 1 }"
|
||
></div>
|
||
<span class="text-[0.6rem] text-emerald-800/80 font-bold ml-1">多</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bento-watermark text-emerald-500">🗓</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template v-if="!isOk">
|
||
<!-- Loading animation (Card07 only): move bento-summary.html loader in full -->
|
||
<div class="packing-scene" aria-hidden="true">
|
||
<div class="bento-loader-box">
|
||
<!-- Slot 1: Yearly Summary (Big 2x2) -->
|
||
<div class="bento-slot slot-1">
|
||
<div class="memory-piece piece-1">
|
||
<i
|
||
class="fa-solid fa-envelope-open-text"
|
||
style="font-size: 3.5rem; opacity: 0.9; margin-bottom: 0.5rem;"
|
||
></i>
|
||
<div class="piece-bar long" style="height: 8px; opacity: 0.2; width: 60%; margin-bottom: 6px;"></div>
|
||
<div class="piece-bar long" style="height: 8px; opacity: 0.2; width: 40%;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slot 2: Best Buddy (Small) -->
|
||
<div class="bento-slot slot-2">
|
||
<div class="memory-piece piece-2">
|
||
<i class="fa-solid fa-heart" style="font-size: 2.5rem; opacity: 0.9;"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slot 3: Time (Small) -->
|
||
<div class="bento-slot slot-3">
|
||
<div class="memory-piece piece-3">
|
||
<i class="fa-solid fa-moon" style="font-size: 2.2rem; opacity: 0.9;"></i>
|
||
<div class="piece-bar short" style="height: 6px; width: 20px; margin-top: 6px; opacity: 0.3;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slot 4: Group (Wide) -->
|
||
<div class="bento-slot slot-4">
|
||
<div class="memory-piece piece-4" style="flex-direction: row; gap: 1.5rem;">
|
||
<i class="fa-solid fa-users" style="font-size: 2.2rem; opacity: 0.9;"></i>
|
||
<div style="display: flex; gap: 4px;">
|
||
<div
|
||
style="width: 10px; height: 10px; border-radius: 50%; background: currentColor; opacity: 0.3;"
|
||
></div>
|
||
<div
|
||
style="width: 10px; height: 10px; border-radius: 50%; background: currentColor; opacity: 0.3;"
|
||
></div>
|
||
<div
|
||
style="width: 10px; height: 10px; border-radius: 50%; background: currentColor; opacity: 0.3;"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slot 5: Emoji (Small) -->
|
||
<div class="bento-slot slot-5">
|
||
<div class="memory-piece piece-5">
|
||
<i class="fa-solid fa-face-laugh-squint" style="font-size: 2.5rem; opacity: 0.9;"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bento-loading-title"><span class="bento-loading-emoji">🍱</span>便当装盒中…</div>
|
||
<div class="loader-text" aria-live="polite" :class="{ fade: funLoaderTextFading }">{{ funLoaderText }}</div>
|
||
<div class="bento-loading-desc">
|
||
<template v-if="cardStatus === 'idle'">翻到此页后开始聚合生成,首次加载可能稍久。</template>
|
||
<template v-else-if="cardStatus === 'loading'">正在聚合各页数据,这一页会比其它页久一点点。</template>
|
||
<template v-else-if="cardStatus === 'error'">生成失败,可以重试一次。</template>
|
||
<template v-else>正在准备数据…</template>
|
||
</div>
|
||
|
||
<div v-if="cardStatus === 'error'" class="bento-loading-error">{{ cardErrorText }}</div>
|
||
|
||
<div class="bento-loading-actions">
|
||
<button v-if="canRetry" type="button" class="bento-loading-btn" @click="onRetry">重试</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</WrappedCardShell>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, inject, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
|
||
const props = defineProps({
|
||
card: { type: Object, required: true },
|
||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||
})
|
||
|
||
// Hide deck-level chrome (top-left nav + year selector) when this bento card is visible.
|
||
const deckChromeHidden = inject('deckChromeHidden', ref(false))
|
||
const retryFromDeck = inject('wrappedRetryCard', null)
|
||
const stageRoot = ref(null)
|
||
const isVisible = ref(false)
|
||
let io = null
|
||
|
||
const cardStatus = computed(() => String(props.card?.status || '').trim().toLowerCase())
|
||
const isOk = computed(() => cardStatus.value === 'ok')
|
||
const cardErrorText = computed(() => {
|
||
const s = String(props.card?.error || '').trim()
|
||
return s || '未知错误'
|
||
})
|
||
const canRetry = computed(() => typeof retryFromDeck === 'function' && (cardStatus.value === 'error' || cardStatus.value === 'idle'))
|
||
const onRetry = async () => {
|
||
if (typeof retryFromDeck !== 'function') return
|
||
try {
|
||
await retryFromDeck(Number(props.card?.id || 7))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const updateVisibility = (v) => {
|
||
isVisible.value = !!v
|
||
if (props.variant === 'slide') {
|
||
deckChromeHidden.value = !!v
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.variant,
|
||
() => {
|
||
updateVisibility(isVisible.value)
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
if (!import.meta.client) return
|
||
|
||
if (typeof IntersectionObserver !== 'undefined' && stageRoot.value) {
|
||
io = new IntersectionObserver(
|
||
(entries) => {
|
||
const ent = entries && entries[0]
|
||
updateVisibility(!!ent?.isIntersecting && (ent.intersectionRatio || 0) >= 0.35)
|
||
},
|
||
{ threshold: [0, 0.35, 0.6, 1] }
|
||
)
|
||
io.observe(stageRoot.value)
|
||
} else {
|
||
updateVisibility(true)
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
io?.disconnect?.()
|
||
io = null
|
||
// Ensure deck chrome restores when leaving this card.
|
||
if (props.variant === 'slide') deckChromeHidden.value = false
|
||
})
|
||
|
||
const FUN_LOADER_TEXTS = [
|
||
'正在回收年度碎片...',
|
||
'为您精心装盘组合...',
|
||
'封存三百六十五个日夜...',
|
||
'年度便当即将完美出炉...'
|
||
]
|
||
const funLoaderText = ref(FUN_LOADER_TEXTS[0])
|
||
const funLoaderTextFading = ref(false)
|
||
let funLoaderTextIndex = 0
|
||
let funLoaderInterval = null
|
||
let funLoaderFadeTimer = null
|
||
|
||
const stopFunLoaderCycle = () => {
|
||
if (funLoaderInterval) clearInterval(funLoaderInterval)
|
||
funLoaderInterval = null
|
||
if (funLoaderFadeTimer) clearTimeout(funLoaderFadeTimer)
|
||
funLoaderFadeTimer = null
|
||
funLoaderTextFading.value = false
|
||
funLoaderTextIndex = 0
|
||
}
|
||
|
||
const startFunLoaderCycle = () => {
|
||
stopFunLoaderCycle()
|
||
funLoaderTextIndex = 0
|
||
funLoaderText.value = FUN_LOADER_TEXTS[0]
|
||
funLoaderInterval = setInterval(() => {
|
||
funLoaderTextIndex = (funLoaderTextIndex + 1) % FUN_LOADER_TEXTS.length
|
||
funLoaderTextFading.value = true
|
||
if (funLoaderFadeTimer) clearTimeout(funLoaderFadeTimer)
|
||
funLoaderFadeTimer = setTimeout(() => {
|
||
funLoaderText.value = FUN_LOADER_TEXTS[funLoaderTextIndex]
|
||
funLoaderTextFading.value = false
|
||
}, 300)
|
||
}, 1800)
|
||
}
|
||
|
||
const updateFunLoader = () => {
|
||
if (!import.meta.client) return
|
||
if (!isVisible.value) {
|
||
stopFunLoaderCycle()
|
||
funLoaderText.value = FUN_LOADER_TEXTS[0]
|
||
return
|
||
}
|
||
|
||
if (cardStatus.value === 'ok') {
|
||
stopFunLoaderCycle()
|
||
funLoaderText.value = '打包完成!'
|
||
return
|
||
}
|
||
|
||
if (cardStatus.value === 'error') {
|
||
stopFunLoaderCycle()
|
||
funLoaderText.value = '生成失败,可以重试一次。'
|
||
return
|
||
}
|
||
|
||
if (!funLoaderInterval) startFunLoaderCycle()
|
||
}
|
||
|
||
watch([cardStatus, isVisible], updateFunLoader, { immediate: true })
|
||
onBeforeUnmount(() => {
|
||
stopFunLoaderCycle()
|
||
})
|
||
|
||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||
|
||
const snapshot = computed(() => {
|
||
const s = props.card?.data?.snapshot
|
||
return s && typeof s === 'object' ? s : {}
|
||
})
|
||
|
||
const year = computed(() => Number(snapshot.value?.year || new Date().getFullYear() - 1))
|
||
|
||
const totalMessages = computed(() => Number(snapshot.value?.totalMessages || 0))
|
||
const messagesPerDay = computed(() => Number(snapshot.value?.messagesPerDay || 0))
|
||
const messagesPerDayRounded = computed(() => Math.round(messagesPerDay.value || 0))
|
||
|
||
const sentChars = computed(() => Number(snapshot.value?.sentChars || 0))
|
||
const sentCharsWan = computed(() => Number((sentChars.value / 10000).toFixed(1)).toFixed(1))
|
||
|
||
const addedFriends = computed(() => Number(snapshot.value?.addedFriends || 0))
|
||
|
||
const pad2 = (n) => String(Math.max(0, Number(n) || 0)).padStart(2, '0')
|
||
const formatHour = (h) => `${pad2(h)}:00`
|
||
const daysInYear = (y) => {
|
||
const yy = Number(y)
|
||
const isLeap = yy % 4 === 0 && (yy % 100 !== 0 || yy % 400 === 0)
|
||
return isLeap ? 366 : 365
|
||
}
|
||
|
||
const mostActiveHour = computed(() => {
|
||
const n = Number(snapshot.value?.mostActiveHour)
|
||
return Number.isFinite(n) && n >= 0 && n <= 23 ? n : null
|
||
})
|
||
|
||
const mostActiveHourLabel = computed(() => (mostActiveHour.value === null ? '--:--' : formatHour(mostActiveHour.value)))
|
||
const mostActiveHourDesc = computed(() => {
|
||
const h = Number(mostActiveHour.value)
|
||
if (!Number.isFinite(h)) return '聊天节奏稳定在线'
|
||
if (h >= 5 && h <= 8) return '早起高效开聊模式'
|
||
if (h >= 9 && h <= 12) return '白天沟通主力时段'
|
||
if (h >= 13 && h <= 18) return '午后消息最活跃'
|
||
if (h >= 19 && h <= 23) return '夜晚交流持续在线'
|
||
return '深夜灵感爆棚的夜猫子'
|
||
})
|
||
|
||
const formatDurationZh = (seconds) => {
|
||
if (seconds === null || seconds === undefined || seconds === '') return '--'
|
||
const s = Math.round(Number(seconds))
|
||
if (!Number.isFinite(s) || s < 0) return '--'
|
||
if (s < 60) return `${s}秒`
|
||
if (s < 3600) {
|
||
const m = Math.floor(s / 60)
|
||
const sec = s % 60
|
||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`
|
||
}
|
||
if (s < 86400) {
|
||
const h = Math.floor(s / 3600)
|
||
const m = Math.floor((s % 3600) / 60)
|
||
return m > 0 ? `${h}小时${m}分` : `${h}小时`
|
||
}
|
||
const d = Math.floor(s / 86400)
|
||
const h = Math.floor((s % 86400) / 3600)
|
||
return h > 0 ? `${d}天${h}小时` : `${d}天`
|
||
}
|
||
|
||
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||
const raw = String(value || '').trim()
|
||
if (!raw) return ''
|
||
if (/^(data:|blob:)/i.test(raw)) return raw
|
||
if (/^https?:\/\//i.test(raw)) {
|
||
try {
|
||
const host = new URL(raw).hostname.toLowerCase()
|
||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||
// Keep same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
|
||
return `/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||
}
|
||
} catch {}
|
||
return raw
|
||
}
|
||
// Keep `/api/...` as same-origin (avoid hardcoding backend host like `localhost:8000`).
|
||
return raw.startsWith('/') ? raw : `/${raw}`
|
||
}
|
||
|
||
const broken = reactive({})
|
||
const markBroken = (key) => {
|
||
broken[String(key || '')] = true
|
||
}
|
||
|
||
const avatarFallback = (name) => {
|
||
const s = String(name || '').trim()
|
||
return s ? s[0] : '?'
|
||
}
|
||
|
||
const bestBuddy = computed(() => {
|
||
const o = snapshot.value?.bestBuddy
|
||
return o && typeof o === 'object' ? o : null
|
||
})
|
||
const bestBuddyName = computed(() => String(bestBuddy.value?.displayName || '').trim() || '--')
|
||
const bestBuddyAvatarUrl = computed(() => resolveMediaUrl(bestBuddy.value?.avatarUrl, { backend: true }))
|
||
const bestBuddyTotal = computed(() => Number(bestBuddy.value?.totalMessages || 0))
|
||
const bestBuddyStreakDays = computed(() => Number(bestBuddy.value?.longestStreakDays || 0))
|
||
const bestBuddyStreakDaysLabel = computed(() => (bestBuddyStreakDays.value > 0 ? formatInt(bestBuddyStreakDays.value) : '--'))
|
||
const bestBuddyPeakLabel = computed(() => {
|
||
const s = String(bestBuddy.value?.peakHourLabel || '').trim()
|
||
if (s) return s
|
||
const ph = Number(bestBuddy.value?.peakHour)
|
||
if (Number.isFinite(ph) && ph >= 0 && ph <= 23) return formatHour(ph)
|
||
return '--:--'
|
||
})
|
||
|
||
const topGroup = computed(() => {
|
||
const o = snapshot.value?.topGroup
|
||
return o && typeof o === 'object' ? o : null
|
||
})
|
||
const topGroupName = computed(() => {
|
||
if (!topGroup.value) return '暂无群聊'
|
||
return String(topGroup.value?.displayName || '').trim() || '--'
|
||
})
|
||
const topGroupMessages = computed(() => Number(topGroup.value?.messages || 0))
|
||
const topGroupAvatarUrl = computed(() => resolveMediaUrl(topGroup.value?.avatarUrl, { backend: true }))
|
||
const topGroupSharePct = computed(() => {
|
||
const total = totalMessages.value
|
||
if (!(total > 0)) return 0
|
||
const pct = Math.round((topGroupMessages.value / total) * 100)
|
||
return Math.max(0, Math.min(100, pct))
|
||
})
|
||
const topGroupDailyLabel = computed(() => {
|
||
const msg = topGroupMessages.value
|
||
if (!(msg > 0)) return '--'
|
||
return formatInt(Math.round(msg / Math.max(1, daysInYear(year.value))))
|
||
})
|
||
|
||
const replyStats = computed(() => {
|
||
const o = snapshot.value?.replyStats
|
||
return o && typeof o === 'object' ? o : null
|
||
})
|
||
const replyP50Label = computed(() => formatDurationZh(replyStats.value?.p50Seconds))
|
||
|
||
const fastest = computed(() => {
|
||
const o = snapshot.value?.fastest
|
||
return o && typeof o === 'object' ? o : null
|
||
})
|
||
const slowest = computed(() => {
|
||
const o = snapshot.value?.slowest
|
||
return o && typeof o === 'object' ? o : null
|
||
})
|
||
const fastestContactName = computed(() => String(fastest.value?.displayName || '').trim() || '暂无')
|
||
const slowestContactName = computed(() => String(slowest.value?.displayName || '').trim() || '暂无')
|
||
const fastestAvatarUrl = computed(() => resolveMediaUrl(fastest.value?.avatarUrl, { backend: true }))
|
||
const slowestAvatarUrl = computed(() => resolveMediaUrl(slowest.value?.avatarUrl, { backend: true }))
|
||
const fastestReplyLabel = computed(() => formatDurationZh(fastest.value?.seconds))
|
||
const slowestReplyLabel = computed(() => formatDurationZh(slowest.value?.seconds))
|
||
|
||
const topPhrase = computed(() => {
|
||
const o = snapshot.value?.topPhrase
|
||
if (!o || typeof o !== 'object') return null
|
||
const phrase = String(o.phrase || o.word || '').trim()
|
||
const count = Number(o.count || 0)
|
||
return phrase ? { phrase, count: Number.isFinite(count) && count > 0 ? count : 0 } : null
|
||
})
|
||
const topPhraseWord = computed(() => topPhrase.value?.phrase || '--')
|
||
const topPhraseCount = computed(() => Number(topPhrase.value?.count || 0))
|
||
|
||
const sentStickerCount = computed(() => Number(snapshot.value?.sentStickerCount || 0))
|
||
// NOTE: topSticker.imageUrl may be either "/api/..." (backend) or "/wxemoji/..." (Nuxt public).
|
||
// resolveMediaUrl keeps them as same-origin paths (Nuxt devProxy / backend-mounted UI will handle `/api`).
|
||
const topStickerUrl = computed(() => resolveMediaUrl(snapshot.value?.topSticker?.imageUrl))
|
||
const stickerShareText = computed(() => {
|
||
const total = Number(totalMessages.value || 0)
|
||
const stickers = Number(sentStickerCount.value || 0)
|
||
if (!(total > 0) || stickers <= 0) return '0%'
|
||
const ratio = Math.max(0, Math.min(1, stickers / total))
|
||
const pct = ratio * 100
|
||
return pct >= 10 ? `${Math.round(pct)}%` : `${pct.toFixed(1)}%`
|
||
})
|
||
|
||
const topStickerMode = ref('img') // 'img' | 'video'
|
||
const onTopStickerImgError = () => {
|
||
// Some WeChat stickers are returned as video/mp4; fall back to <video> first.
|
||
if (topStickerMode.value !== 'video') {
|
||
topStickerMode.value = 'video'
|
||
return
|
||
}
|
||
markBroken('topSticker')
|
||
}
|
||
const onTopStickerVideoError = () => {
|
||
markBroken('topSticker')
|
||
}
|
||
watch(
|
||
topStickerUrl,
|
||
() => {
|
||
topStickerMode.value = 'img'
|
||
broken.topSticker = false
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const topUnicodeEmoji = computed(() => String(snapshot.value?.topUnicodeEmoji || '😀'))
|
||
const topUnicodeEmojiCount = computed(() => Number(snapshot.value?.topUnicodeEmojiCount || 0))
|
||
|
||
const rawMonthly = computed(() => (Array.isArray(snapshot.value?.monthlyBestBuddies) ? snapshot.value.monthlyBestBuddies : []))
|
||
const monthlyNormalized = computed(() => {
|
||
const byMonth = new Map()
|
||
for (const it of rawMonthly.value) {
|
||
if (!it || typeof it !== 'object') continue
|
||
const m = Number(it.month)
|
||
if (!Number.isFinite(m) || m < 1 || m > 12) continue
|
||
if (!byMonth.has(m)) byMonth.set(m, it)
|
||
}
|
||
return Array.from({ length: 12 }, (_, i) => {
|
||
const m = i + 1
|
||
return byMonth.get(m) || { month: m, displayName: '--', maskedName: '--', avatarUrl: '', messages: 0, metrics: null }
|
||
})
|
||
})
|
||
|
||
const monthlyKey = (item) => {
|
||
const name = String(item?.displayName || '').trim()
|
||
return name && name !== '--' ? name : '--'
|
||
}
|
||
|
||
const monthlyCounts = computed(() => {
|
||
const counts = new Map()
|
||
for (const it of monthlyNormalized.value) {
|
||
const k = monthlyKey(it)
|
||
if (!k || k === '--') continue
|
||
counts.set(k, (counts.get(k) || 0) + 1)
|
||
}
|
||
return counts
|
||
})
|
||
|
||
const monthlyMvpName = computed(() => {
|
||
let bestName = '--'
|
||
let bestCnt = 0
|
||
for (const [k, v] of monthlyCounts.value.entries()) {
|
||
if (v > bestCnt) {
|
||
bestName = k
|
||
bestCnt = v
|
||
}
|
||
}
|
||
return bestName
|
||
})
|
||
const monthlyMvpMonths = computed(() => Number(monthlyCounts.value.get(monthlyMvpName.value) || 0))
|
||
const showMonthlyHero = computed(() => monthlyMvpMonths.value >= 3)
|
||
|
||
const monthlyMvpAvatarUrl = computed(() => {
|
||
if (monthlyMvpName.value === '--') return ''
|
||
const hit = monthlyNormalized.value.find((it) => monthlyKey(it) === monthlyMvpName.value && String(it?.avatarUrl || '').trim())
|
||
return resolveMediaUrl(hit?.avatarUrl, { backend: true })
|
||
})
|
||
|
||
const monthlyMvpMetrics = computed(() => {
|
||
if (!showMonthlyHero.value) return []
|
||
const key = monthlyMvpName.value
|
||
if (!key || key === '--') return []
|
||
const items = monthlyNormalized.value.filter(
|
||
(it) => monthlyKey(it) === key && it && typeof it.metrics === 'object' && it.metrics
|
||
)
|
||
if (items.length <= 0) return []
|
||
|
||
const keys = [
|
||
{ key: 'interactionScore', label: '互动' },
|
||
{ key: 'speedScore', label: '速度' },
|
||
{ key: 'continuityScore', label: '连续' },
|
||
{ key: 'coverageScore', label: '覆盖' }
|
||
]
|
||
return keys.map(({ key: k, label }) => {
|
||
const avg = items.reduce((s, it) => s + Number(it.metrics?.[k] || 0), 0) / items.length
|
||
const pct = Math.max(0, Math.min(100, Math.round(avg * 100)))
|
||
return { key: k, label, pct }
|
||
})
|
||
})
|
||
|
||
const buddyColors = [
|
||
'hsl(174, 72%, 42%)',
|
||
'hsl(25, 92%, 52%)',
|
||
'hsl(262, 62%, 55%)',
|
||
'hsl(340, 70%, 52%)',
|
||
'hsl(210, 72%, 50%)',
|
||
'hsl(45, 88%, 48%)'
|
||
]
|
||
|
||
const monthlyColorMap = computed(() => {
|
||
const m = new Map()
|
||
let idx = 0
|
||
for (const it of monthlyNormalized.value) {
|
||
const k = monthlyKey(it)
|
||
if (!k || k === '--') continue
|
||
if (!m.has(k)) {
|
||
m.set(k, buddyColors[idx % buddyColors.length])
|
||
idx += 1
|
||
}
|
||
}
|
||
return m
|
||
})
|
||
|
||
const hsla = (hsl, a) => String(hsl || '').replace('hsl(', 'hsla(').replace(')', `, ${a})`)
|
||
|
||
const monthlyCells = computed(() => {
|
||
const mvp = monthlyMvpName.value
|
||
return monthlyNormalized.value.map((raw) => {
|
||
const month = Number(raw.month || 0)
|
||
const name = monthlyKey(raw)
|
||
const color = monthlyColorMap.value.get(name)
|
||
const bg = color ? hsla(color, 0.08) : 'rgba(0,0,0,0.03)'
|
||
const avatarBg = color || 'linear-gradient(135deg, #2dd4bf, #34d399)'
|
||
const avatarUrl = resolveMediaUrl(raw.avatarUrl, { backend: true })
|
||
return {
|
||
month,
|
||
messages: Number(raw.messages || 0),
|
||
_nameLabel: name,
|
||
_isMvp: name !== '--' && name === mvp,
|
||
_cellStyle: { background: bg, border: '1px solid rgba(255,255,255,0.55)' },
|
||
_avatarFallbackBg: avatarBg,
|
||
_avatarUrl: avatarUrl
|
||
}
|
||
})
|
||
})
|
||
|
||
const weekdayLabels = computed(() => {
|
||
const xs = snapshot.value?.weekdayLabels
|
||
return Array.isArray(xs) && xs.length === 7 ? xs.map((x) => String(x)) : ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||
})
|
||
|
||
const heatmapMatrix = computed(() => {
|
||
const m = snapshot.value?.weekdayHourMatrix
|
||
if (!Array.isArray(m) || m.length !== 7) return Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
|
||
return m.map((row) => {
|
||
if (!Array.isArray(row) || row.length !== 24) return Array.from({ length: 24 }, () => 0)
|
||
return row.map((v) => Number(v || 0))
|
||
})
|
||
})
|
||
|
||
const heatmapMax = computed(() => {
|
||
let max = 0
|
||
for (const row of heatmapMatrix.value) {
|
||
for (const v of row) max = Math.max(max, Number(v || 0))
|
||
}
|
||
return max
|
||
})
|
||
|
||
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)'
|
||
const t = Math.max(0, Math.min(1, Math.sqrt(v / m)))
|
||
const hue = 145 - 50 * t
|
||
const sat = 70
|
||
const light = 92 - 42 * t
|
||
return `hsl(${hue.toFixed(1)} ${sat}% ${light.toFixed(1)}%)`
|
||
}
|
||
|
||
const heatmapCells = computed(() => {
|
||
const max = Math.max(1, heatmapMax.value)
|
||
const out = []
|
||
for (let w = 0; w < 7; w += 1) {
|
||
for (let h = 0; h < 24; h += 1) {
|
||
const count = Number(heatmapMatrix.value[w]?.[h] || 0)
|
||
const hourEnd = (h + 1) % 24
|
||
const slot = `${weekdayLabels.value[w]} ${pad2(h)}:00-${pad2(hourEnd)}:00`
|
||
const title = count > 0 ? `${slot},发送 ${formatInt(count)} 条消息` : `${slot},该时段几乎没有消息`
|
||
out.push({
|
||
key: `${w}-${h}`,
|
||
title,
|
||
bg: count > 0 ? heatColor(count, max) : 'rgba(7, 193, 96, 0.1)',
|
||
opacity: count > 0 ? 0.95 : 0.72
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
|
||
const hourLabelRow = computed(() => Array.from({ length: 24 }, (_, h) => ({ key: h, label: h % 3 === 0 ? pad2(h) : '' })))
|
||
|
||
const heatmapLegendColors = computed(() => {
|
||
const max = Math.max(1, heatmapMax.value)
|
||
const dots = 5
|
||
return Array.from({ length: dots }, (_, i) => {
|
||
const step = (i + 1) / dots
|
||
const val = Math.max(1, step * max)
|
||
return heatColor(val, max)
|
||
})
|
||
})
|
||
|
||
const heatmapYearLabel = computed(() => `${year.value}年聚合`)
|
||
const heatmapMaxLabel = computed(() => (heatmapMax.value > 0 ? `最大 ${formatInt(heatmapMax.value)} 条` : '暂无数据'))
|
||
</script>
|
||
|
||
<style scoped>
|
||
.bento-stage {
|
||
width: 100%;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
color: #1d1d1f;
|
||
isolation: isolate;
|
||
}
|
||
|
||
.bento-stage.is-loading {
|
||
justify-content: center;
|
||
padding: 0 1.25rem 2rem;
|
||
gap: 0.55rem;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Packing Memory Bento Loader Styles (ported from bento-summary.html) */
|
||
.packing-scene {
|
||
position: relative;
|
||
width: 300px;
|
||
height: 300px;
|
||
perspective: 800px;
|
||
margin-bottom: 2.5rem;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.bento-loader-box {
|
||
position: relative;
|
||
width: 320px;
|
||
height: 320px;
|
||
background: #ffffff;
|
||
border-radius: 32px;
|
||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08);
|
||
padding: 12px;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
grid-template-rows: 1fr 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.bento-slot {
|
||
background: #f3f4f6;
|
||
border-radius: 20px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
border: 2px dashed #e5e7eb;
|
||
}
|
||
|
||
.slot-1 {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 1 / span 2;
|
||
}
|
||
|
||
.slot-2 {
|
||
grid-column: 3 / span 1;
|
||
grid-row: 1 / span 1;
|
||
}
|
||
|
||
.slot-3 {
|
||
grid-column: 3 / span 1;
|
||
grid-row: 2 / span 1;
|
||
}
|
||
|
||
.slot-4 {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 3 / span 1;
|
||
}
|
||
|
||
.slot-5 {
|
||
grid-column: 3 / span 1;
|
||
grid-row: 3 / span 1;
|
||
}
|
||
|
||
.memory-piece {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||
text-align: center;
|
||
animation: smoothLoop 3.2s infinite;
|
||
}
|
||
|
||
@keyframes smoothLoop {
|
||
0% {
|
||
transform: scale(0.6) translateY(20px);
|
||
opacity: 0;
|
||
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
|
||
15% {
|
||
transform: scale(1.05) translateY(-5px);
|
||
opacity: 1;
|
||
animation-timing-function: ease-out;
|
||
}
|
||
|
||
20% {
|
||
transform: scale(1) translateY(0);
|
||
opacity: 1;
|
||
animation-timing-function: linear;
|
||
}
|
||
|
||
65% {
|
||
transform: scale(1) translateY(0);
|
||
opacity: 1;
|
||
animation-timing-function: ease-in;
|
||
}
|
||
|
||
80% {
|
||
transform: scale(0.8) translateY(15px);
|
||
opacity: 0;
|
||
animation-timing-function: linear;
|
||
}
|
||
|
||
100% {
|
||
transform: scale(0.6) translateY(20px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.piece-1 {
|
||
background: #3b82f6;
|
||
color: #ffffff;
|
||
padding: 1.5rem;
|
||
align-items: flex-start;
|
||
animation-delay: 0s;
|
||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
.piece-2 {
|
||
background: #ec4899;
|
||
color: #ffffff;
|
||
animation-delay: 0.2s;
|
||
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.4);
|
||
}
|
||
|
||
.piece-3 {
|
||
background: #10b981;
|
||
color: #ffffff;
|
||
animation-delay: 0.4s;
|
||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
|
||
}
|
||
|
||
.piece-4 {
|
||
background: #8b5cf6;
|
||
color: #ffffff;
|
||
flex-direction: row;
|
||
gap: 1rem;
|
||
animation-delay: 0.6s;
|
||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
||
}
|
||
|
||
.piece-5 {
|
||
background: #f97316;
|
||
color: #ffffff;
|
||
animation-delay: 0.8s;
|
||
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.4);
|
||
}
|
||
|
||
.piece-row {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.piece-bar {
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
background: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.piece-bar.short {
|
||
width: 40%;
|
||
}
|
||
|
||
.piece-bar.long {
|
||
width: 70%;
|
||
}
|
||
|
||
/* Ensure texts/buttons render above the loader box */
|
||
.bento-loading-title,
|
||
.loader-text,
|
||
.bento-loading-desc,
|
||
.bento-loading-error,
|
||
.bento-loading-actions {
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.packing-scene {
|
||
transform: scale(0.9);
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
}
|
||
|
||
.bento-stage.is-slide {
|
||
height: 100dvh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bento-stage.is-panel {
|
||
height: auto;
|
||
min-height: 0;
|
||
}
|
||
|
||
.bento-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: 1040px;
|
||
max-height: 900px;
|
||
padding: 1.25rem;
|
||
display: grid;
|
||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||
grid-template-rows: 0.8fr 0.8fr 1.1fr 0.6fr 1fr;
|
||
gap: 0.9rem;
|
||
box-sizing: border-box;
|
||
grid-auto-flow: row dense;
|
||
}
|
||
|
||
.bento-loading-title {
|
||
font-weight: 900;
|
||
letter-spacing: -0.02em;
|
||
font-size: 1.2rem;
|
||
color: rgba(6, 95, 70, 0.92);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.bento-loading-emoji {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.bento-loading-desc {
|
||
margin-top: 0.45rem;
|
||
font-size: 0.9rem;
|
||
color: rgba(15, 23, 42, 0.62);
|
||
line-height: 1.5;
|
||
max-width: min(560px, 100%);
|
||
}
|
||
|
||
.loader-text {
|
||
margin-top: 0.25rem;
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
color: rgba(15, 23, 42, 0.55);
|
||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||
}
|
||
|
||
.loader-text.fade {
|
||
opacity: 0;
|
||
transform: translateY(8px);
|
||
}
|
||
|
||
.bento-loading-error {
|
||
margin-top: 0.75rem;
|
||
font-size: 0.85rem;
|
||
color: rgba(220, 38, 38, 0.92);
|
||
background: rgba(254, 226, 226, 0.65);
|
||
border: 1px solid rgba(248, 113, 113, 0.22);
|
||
padding: 0.65rem 0.85rem;
|
||
border-radius: 1rem;
|
||
text-align: left;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.bento-loading-actions {
|
||
margin-top: 0.85rem;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.bento-loading-btn {
|
||
appearance: none;
|
||
border: 0;
|
||
cursor: pointer;
|
||
padding: 0.6rem 1rem;
|
||
border-radius: 999px;
|
||
background: #07c160;
|
||
color: white;
|
||
font-weight: 800;
|
||
font-size: 0.92rem;
|
||
box-shadow: 0 10px 26px rgba(7, 193, 96, 0.25);
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||
}
|
||
|
||
.bento-loading-btn:hover {
|
||
transform: translateY(-1px);
|
||
background: #06ad56;
|
||
box-shadow: 0 16px 34px rgba(7, 193, 96, 0.28);
|
||
}
|
||
|
||
.bento-card {
|
||
background: rgba(255, 255, 255, 0.58);
|
||
backdrop-filter: saturate(180%) blur(28px);
|
||
-webkit-backdrop-filter: saturate(180%) blur(28px);
|
||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||
border-radius: 2rem;
|
||
position: relative;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
opacity: 1;
|
||
transition:
|
||
transform 0.4s cubic-bezier(0.25, 1, 0.25, 1),
|
||
background 0.4s ease,
|
||
border-color 0.4s ease,
|
||
box-shadow 0.4s ease,
|
||
opacity 0.4s ease;
|
||
}
|
||
|
||
.bento-card:hover {
|
||
transform: translate3d(0, -2px, 0) scale(1.015);
|
||
background: rgba(255, 255, 255, 0.82);
|
||
border-color: rgba(255, 255, 255, 1);
|
||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.09), inset 0 1px 0 rgba(255, 255, 255, 1);
|
||
z-index: 10;
|
||
}
|
||
|
||
.aura {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(50px);
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
opacity: 0.85;
|
||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||
}
|
||
|
||
.bento-card:hover .aura {
|
||
opacity: 1;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.bento-watermark {
|
||
position: absolute;
|
||
right: -0.75rem;
|
||
bottom: -1rem;
|
||
font-size: 6rem;
|
||
opacity: 0.05;
|
||
transform: rotate(-15deg);
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
transition: all 0.5s cubic-bezier(0.25, 1, 0.25, 1);
|
||
}
|
||
|
||
.bento-card:hover .bento-watermark {
|
||
transform: rotate(-5deg) scale(1.15);
|
||
opacity: 0.08;
|
||
}
|
||
|
||
.content {
|
||
position: relative;
|
||
z-index: 1;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
color: #86868b;
|
||
margin-bottom: 0.4rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
letter-spacing: -0.01em;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-messages {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 1 / span 1;
|
||
padding: 1.25rem;
|
||
}
|
||
|
||
.card-words {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 2 / span 1;
|
||
padding: 1.25rem;
|
||
}
|
||
|
||
.card-friends {
|
||
grid-column: 8 / span 1;
|
||
grid-row: 2 / span 1;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.card-time {
|
||
grid-column: 6 / span 3;
|
||
grid-row: 1 / span 1;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.card-partner {
|
||
grid-column: 3 / span 3;
|
||
grid-row: 1 / span 2;
|
||
padding: 1.35rem;
|
||
}
|
||
|
||
.card-group {
|
||
grid-column: 3 / span 3;
|
||
grid-row: 5 / span 1;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.card-sticker {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 3 / span 2;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.card-emoji {
|
||
grid-column: 1 / span 2;
|
||
grid-row: 5 / span 1;
|
||
padding: 1.05rem;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
@keyframes stickerFlyOut {
|
||
0% {
|
||
transform: translate(-50%, -50%) scale(0) rotate(0deg);
|
||
opacity: 0;
|
||
}
|
||
|
||
15% {
|
||
opacity: 1;
|
||
}
|
||
|
||
100% {
|
||
transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(var(--s)) rotate(var(--r));
|
||
opacity: var(--o, 0.5);
|
||
}
|
||
}
|
||
|
||
.scattered-emoji {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
font-size: 2.2rem;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
z-index: 5;
|
||
filter: drop-shadow(0 4px 12px rgba(250, 204, 21, 0.4));
|
||
animation: stickerFlyOut 1.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||
}
|
||
|
||
.group-share-ring {
|
||
--p: 0;
|
||
background: conic-gradient(rgba(14, 165, 233, 0.92) calc(var(--p) * 1%), rgba(14, 165, 233, 0.16) 0);
|
||
}
|
||
|
||
/* Floating Hearts Animation (Partner card) */
|
||
@keyframes floatUp {
|
||
0% {
|
||
transform: translateY(0) scale(0.8);
|
||
opacity: 0;
|
||
}
|
||
|
||
10% {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
100% {
|
||
transform: translateY(-120px) scale(1.1);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.floating-heart {
|
||
display: inline-block;
|
||
line-height: 1;
|
||
position: absolute;
|
||
animation: floatUp 5s infinite ease-in;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.card-partner .content {
|
||
z-index: 10;
|
||
}
|
||
|
||
.partner-split-layout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
height: 100%;
|
||
width: 100%;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.partner-split-layout {
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
.partner-profile-zone {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0.5rem;
|
||
flex: 1;
|
||
min-width: 40%;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||
border-radius: 1rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.partner-metrics-zone {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
flex: 1.5;
|
||
justify-content: center;
|
||
}
|
||
|
||
.partner-metric-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||
border-radius: 0.85rem;
|
||
padding: 0.45rem 0.6rem;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03), inset 0 1px 0 rgba(255, 255, 255, 1);
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
width: 100%;
|
||
}
|
||
|
||
.partner-metric-strip:hover {
|
||
transform: scale(1.02);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 1);
|
||
background: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.partner-metric-icon {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 0.6rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||
margin-right: 0.6rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.card-speed {
|
||
grid-column: 6 / span 3;
|
||
grid-row: 3 / span 1;
|
||
padding: 0.75rem 0.95rem;
|
||
}
|
||
|
||
.card-speed .card-title {
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
|
||
.card-catchphrase {
|
||
grid-column: 6 / span 2;
|
||
grid-row: 2 / span 1;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.text-gradient {
|
||
background-clip: text;
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
|
||
.reply-bento {
|
||
display: grid;
|
||
grid-template-columns: 1.15fr 1fr;
|
||
grid-template-rows: 1fr 1fr;
|
||
gap: 0.35rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.reply-subcard {
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(245, 245, 245, 0.5));
|
||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||
border-radius: 0.65rem;
|
||
padding: 0.25rem 0.4rem;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1), 0 2px 8px rgba(0, 0, 0, 0.03);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.reply-subcard:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1), 0 4px 12px rgba(0, 0, 0, 0.05);
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(250, 250, 250, 0.8));
|
||
}
|
||
|
||
.reply-subcard.large {
|
||
grid-column: 1 / span 1;
|
||
grid-row: 1 / span 2;
|
||
padding: 0.4rem 0.6rem;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.reply-subcard .subcard-title {
|
||
font-size: 0.55rem;
|
||
font-weight: 600;
|
||
color: rgba(17, 24, 39, 0.5);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
margin-bottom: 0.1rem;
|
||
z-index: 10;
|
||
}
|
||
|
||
.reply-subcard.large .subcard-title {
|
||
font-size: 0.65rem;
|
||
margin-bottom: 0.1rem;
|
||
}
|
||
|
||
.reply-subcard .subcard-value {
|
||
font-size: 1.1rem;
|
||
font-weight: 800;
|
||
color: #1f2937;
|
||
letter-spacing: -0.02em;
|
||
line-height: 1.25;
|
||
z-index: 10;
|
||
}
|
||
|
||
.reply-subcard.large .subcard-value {
|
||
font-size: 1.7rem;
|
||
line-height: 1.25;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
.reply-subcard-icon {
|
||
position: absolute;
|
||
right: -0.3rem;
|
||
bottom: -0.3rem;
|
||
font-size: 2.2rem;
|
||
opacity: 0.08;
|
||
transform: rotate(-15deg);
|
||
z-index: 1;
|
||
}
|
||
|
||
.reply-subcard.large .reply-subcard-icon {
|
||
right: -0.8rem;
|
||
bottom: -0.8rem;
|
||
font-size: 4rem;
|
||
}
|
||
|
||
.person-name {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: #4b5563;
|
||
max-width: 3.5rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-monthly {
|
||
grid-column: 6 / span 3;
|
||
grid-row: 4 / span 2;
|
||
padding: 1.05rem;
|
||
}
|
||
|
||
.card-heatmap {
|
||
grid-column: 3 / span 3;
|
||
grid-row: 3 / span 2;
|
||
padding: 0.95rem 1.05rem;
|
||
}
|
||
|
||
/* Monthly — Avatar Hero + 6×2 grid */
|
||
.monthly-hero {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.65rem;
|
||
min-height: 0;
|
||
flex: 1;
|
||
padding: 0.15rem 0;
|
||
}
|
||
|
||
.monthly-avatar-lg {
|
||
position: relative;
|
||
width: 3.25rem;
|
||
height: 3.25rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.monthly-avatar-lg img,
|
||
.monthly-avatar-lg .monthly-avatar-fallback-lg {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
border: 2.5px solid white;
|
||
object-fit: cover;
|
||
position: relative;
|
||
z-index: 1;
|
||
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.2);
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.monthly-avatar-fallback-lg {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.15rem;
|
||
font-weight: 800;
|
||
color: white;
|
||
background: linear-gradient(135deg, #2dd4bf, #34d399) !important;
|
||
}
|
||
|
||
.monthly-hero-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.monthly-metrics {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.2rem 0.45rem;
|
||
margin-left: auto;
|
||
flex: 1;
|
||
max-width: 60%;
|
||
flex-shrink: 0;
|
||
align-self: center;
|
||
}
|
||
|
||
.monthly-metric-item {
|
||
min-width: 0;
|
||
}
|
||
|
||
.monthly-metric-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 0.55rem;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.4);
|
||
line-height: 1;
|
||
}
|
||
|
||
.monthly-metric-header .metric-val {
|
||
font-weight: 700;
|
||
font-size: 0.5rem;
|
||
color: rgba(13, 148, 136, 0.7);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.monthly-metric-bar {
|
||
margin-top: 0.15rem;
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: rgba(0, 0, 0, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.monthly-metric-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, #2dd4bf, #34d399);
|
||
opacity: 0.75;
|
||
transition: width 0.6s cubic-bezier(0.25, 1, 0.25, 1);
|
||
}
|
||
|
||
.monthly-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.monthly-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0.25rem 0.1rem;
|
||
border-radius: 0.65rem;
|
||
min-width: 0;
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
cursor: default;
|
||
position: relative;
|
||
gap: 0.15rem;
|
||
}
|
||
|
||
.monthly-cell:hover {
|
||
transform: scale(1.08);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
z-index: 2;
|
||
}
|
||
|
||
.monthly-avatar-sm {
|
||
width: 1.6rem;
|
||
height: 1.6rem;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 1.5px solid rgba(255, 255, 255, 0.9);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
flex-shrink: 0;
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.monthly-avatar-fallback-sm {
|
||
width: 1.6rem;
|
||
height: 1.6rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.55rem;
|
||
font-weight: 700;
|
||
color: white;
|
||
flex-shrink: 0;
|
||
border: 1.5px solid rgba(255, 255, 255, 0.9);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.monthly-cell-month {
|
||
font-size: 0.55rem;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
color: rgba(0, 0, 0, 0.38);
|
||
}
|
||
|
||
.monthly-cell.is-mvp .monthly-avatar-sm,
|
||
.monthly-cell.is-mvp .monthly-avatar-fallback-sm {
|
||
box-shadow: 0 0 0 2px rgba(45, 212, 191, 0.6), 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.monthly-cell.is-mvp .monthly-cell-month {
|
||
color: rgba(13, 148, 136, 0.85);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.monthly-grid.expanded {
|
||
flex: 1;
|
||
gap: 0.35rem;
|
||
align-content: center;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-cell {
|
||
padding: 0.3rem 0.15rem;
|
||
gap: 0.12rem;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-avatar-sm,
|
||
.monthly-grid.expanded .monthly-avatar-fallback-sm {
|
||
width: 1.85rem;
|
||
height: 1.85rem;
|
||
font-size: 0.6rem;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-cell-name {
|
||
font-size: 0.5rem;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-cell.is-mvp .monthly-cell-name {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
/* Heatmap — 24×7 */
|
||
.heatmap-year-tag {
|
||
font-size: 0.65rem;
|
||
line-height: 1;
|
||
font-weight: 600;
|
||
color: rgba(6, 95, 70, 0.78);
|
||
background: rgba(16, 185, 129, 0.14);
|
||
border: 1px solid rgba(16, 185, 129, 0.22);
|
||
padding: 0.3rem 0.5rem;
|
||
border-radius: 999px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.heatmap-max-label {
|
||
font-size: 0.65rem;
|
||
line-height: 1;
|
||
font-weight: 600;
|
||
color: rgba(6, 95, 70, 0.6);
|
||
}
|
||
|
||
.card-heatmap .heatmap-shell {
|
||
margin-top: 0.42rem;
|
||
min-height: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-row {
|
||
display: grid;
|
||
grid-template-columns: 2.05rem repeat(24, minmax(0, 1fr));
|
||
gap: 1px;
|
||
min-height: 0.9rem;
|
||
align-items: end;
|
||
user-select: none;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-spacer {
|
||
display: block;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-label {
|
||
font-size: 0.6rem;
|
||
color: rgba(6, 95, 70, 0.58);
|
||
font-weight: 600;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-heatmap .heatmap-main {
|
||
display: grid;
|
||
grid-template-columns: 2.05rem minmax(0, 1fr);
|
||
gap: 0.25rem;
|
||
min-height: 0;
|
||
flex: 1;
|
||
align-content: center;
|
||
}
|
||
|
||
.card-heatmap .heatmap-weekday-col {
|
||
display: grid;
|
||
grid-template-rows: repeat(7, auto);
|
||
gap: 4px;
|
||
justify-items: center;
|
||
align-items: center;
|
||
user-select: none;
|
||
}
|
||
|
||
.card-heatmap .heatmap-weekday-col span {
|
||
font-size: 0.5rem;
|
||
color: rgba(17, 24, 39, 0.44);
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.card-heatmap .heatmap-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||
grid-template-rows: repeat(7, auto);
|
||
gap: 4px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.card-heatmap .heatmap-cell {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
aspect-ratio: 1;
|
||
border: 1px solid rgba(7, 193, 96, 0.1);
|
||
border-radius: 2px;
|
||
background: rgba(0, 0, 0, 0.05);
|
||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
|
||
transform-origin: center;
|
||
cursor: pointer;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.card-heatmap .heatmap-cell:hover {
|
||
transform: scale(1.14);
|
||
box-shadow: 0 6px 14px rgba(7, 193, 96, 0.28);
|
||
z-index: 2;
|
||
opacity: 1;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.65rem;
|
||
user-select: none;
|
||
color: rgba(17, 24, 39, 0.55);
|
||
font-size: 0.74rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend-scale {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend-dot {
|
||
width: 0.9rem;
|
||
height: 0.35rem;
|
||
border-radius: 999px;
|
||
background: rgba(7, 193, 96, 0.18);
|
||
border: 1px solid rgba(7, 193, 96, 0.14);
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.bento-container {
|
||
max-width: 100%;
|
||
padding: 1rem;
|
||
gap: 0.8rem;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
grid-template-rows: none;
|
||
grid-auto-rows: minmax(140px, auto);
|
||
}
|
||
|
||
.card-partner,
|
||
.card-messages,
|
||
.card-words,
|
||
.card-time,
|
||
.card-catchphrase,
|
||
.card-group {
|
||
grid-column: auto / span 3;
|
||
grid-row: auto / span 1;
|
||
}
|
||
|
||
.card-speed {
|
||
grid-column: auto / span 3;
|
||
grid-row: auto / span 1;
|
||
order: 12;
|
||
}
|
||
|
||
.card-heatmap {
|
||
grid-column: auto / span 3;
|
||
grid-row: auto / span 2;
|
||
order: 10;
|
||
}
|
||
|
||
.card-monthly {
|
||
grid-column: auto / span 3;
|
||
grid-row: auto / span 2;
|
||
}
|
||
|
||
.card-sticker {
|
||
grid-column: auto / span 2;
|
||
grid-row: auto / span 2;
|
||
}
|
||
|
||
.card-emoji,
|
||
.card-friends {
|
||
grid-column: auto / span 2;
|
||
grid-row: auto / span 1;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.bento-stage.is-slide {
|
||
overflow-y: auto;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.bento-container {
|
||
height: auto;
|
||
max-height: none;
|
||
min-height: 100vh;
|
||
padding: 0.85rem;
|
||
gap: 0.65rem;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
grid-template-rows: none;
|
||
grid-auto-rows: minmax(170px, auto);
|
||
}
|
||
|
||
.card-messages,
|
||
.card-words,
|
||
.card-partner,
|
||
.card-group,
|
||
.card-speed,
|
||
.card-monthly,
|
||
.card-heatmap {
|
||
grid-column: auto / span 2;
|
||
grid-row: auto / span 1;
|
||
}
|
||
|
||
.card-friends,
|
||
.card-time,
|
||
.card-catchphrase,
|
||
.card-sticker,
|
||
.card-emoji {
|
||
grid-column: auto / span 1;
|
||
grid-row: auto / span 1;
|
||
}
|
||
|
||
.card-heatmap .heatmap-shell {
|
||
min-height: 0;
|
||
}
|
||
|
||
.partner-split-layout {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.partner-metric-strip {
|
||
padding: 0.35rem 0.5rem;
|
||
}
|
||
|
||
.partner-metric-icon {
|
||
width: 1.6rem;
|
||
height: 1.6rem;
|
||
}
|
||
|
||
.reply-subcard {
|
||
padding: 0.2rem 0.35rem;
|
||
border-radius: 0.5rem;
|
||
}
|
||
|
||
.reply-subcard.large {
|
||
padding: 0.2rem 0.45rem;
|
||
}
|
||
|
||
.reply-subcard .subcard-value {
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.reply-subcard.large .subcard-value {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.reply-subcard .subcard-title {
|
||
font-size: 0.45rem;
|
||
}
|
||
|
||
.reply-bento {
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.monthly-grid {
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
.monthly-cell {
|
||
padding: 0.15rem 0.05rem;
|
||
border-radius: 0.5rem;
|
||
}
|
||
|
||
.monthly-cell-month {
|
||
font-size: 0.45rem;
|
||
}
|
||
|
||
.monthly-avatar-sm,
|
||
.monthly-avatar-fallback-sm {
|
||
width: 1.35rem;
|
||
height: 1.35rem;
|
||
font-size: 0.45rem;
|
||
}
|
||
|
||
.monthly-avatar-lg {
|
||
width: 2.5rem;
|
||
height: 2.5rem;
|
||
}
|
||
|
||
.monthly-avatar-fallback-lg {
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-avatar-sm,
|
||
.monthly-grid.expanded .monthly-avatar-fallback-sm {
|
||
width: 1.5rem;
|
||
height: 1.5rem;
|
||
font-size: 0.5rem;
|
||
}
|
||
|
||
.monthly-grid.expanded .monthly-cell-name {
|
||
font-size: 0.42rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.bento-container {
|
||
padding: 0.65rem;
|
||
gap: 0.55rem;
|
||
grid-auto-rows: minmax(150px, auto);
|
||
}
|
||
|
||
.card-heatmap .heatmap-grid {
|
||
gap: 2px;
|
||
}
|
||
}
|
||
|
||
/* Additional safe-guards for smaller resolutions */
|
||
@media (max-height: 950px) {
|
||
.bento-container {
|
||
gap: 0.75rem;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.bento-card {
|
||
border-radius: 1.25rem;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 0.8rem;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
|
||
.card-messages,
|
||
.card-words {
|
||
padding: 0.75rem 1rem;
|
||
}
|
||
|
||
.card-friends,
|
||
.card-time,
|
||
.card-catchphrase,
|
||
.card-sticker,
|
||
.card-emoji,
|
||
.card-monthly,
|
||
.card-heatmap {
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
.card-heatmap {
|
||
padding: 0.88rem;
|
||
}
|
||
|
||
.card-partner {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.card-group,
|
||
.card-speed {
|
||
padding: 0.85rem;
|
||
}
|
||
|
||
.text-6xl {
|
||
font-size: 2.5rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.text-5xl {
|
||
font-size: 2rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.text-4xl {
|
||
font-size: 1.75rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.text-3xl {
|
||
font-size: 1.5rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.text-2xl {
|
||
font-size: 1.25rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.text-lg {
|
||
font-size: 0.875rem !important;
|
||
}
|
||
|
||
.text-base {
|
||
font-size: 0.8rem !important;
|
||
}
|
||
|
||
.text-sm {
|
||
font-size: 0.75rem !important;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.65rem !important;
|
||
}
|
||
|
||
.w-32,
|
||
.lg\:w-32,
|
||
.w-28,
|
||
.sm\:w-28,
|
||
.w-24 {
|
||
width: 4.5rem !important;
|
||
}
|
||
|
||
.h-32,
|
||
.lg\:h-32,
|
||
.h-28,
|
||
.sm\:h-28,
|
||
.h-24 {
|
||
height: 4.5rem !important;
|
||
}
|
||
|
||
.w-10,
|
||
.sm\:w-10,
|
||
.w-8 {
|
||
width: 1.75rem !important;
|
||
}
|
||
|
||
.h-10,
|
||
.sm\:h-10,
|
||
.h-8 {
|
||
height: 1.75rem !important;
|
||
}
|
||
|
||
.card-heatmap .text-\[8px\],
|
||
.card-heatmap .sm\:text-\[9px\] {
|
||
font-size: 7px !important;
|
||
}
|
||
|
||
.heatmap-year-tag {
|
||
font-size: 0.58rem;
|
||
padding: 0.22rem 0.4rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-shell {
|
||
border-radius: 0.95rem;
|
||
padding: 0.45rem 0.5rem;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-row {
|
||
grid-template-columns: 1.45rem repeat(24, minmax(0, 1fr));
|
||
gap: 1.5px;
|
||
}
|
||
|
||
.card-heatmap .heatmap-main {
|
||
grid-template-columns: 1.45rem minmax(0, 1fr);
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-label,
|
||
.card-heatmap .heatmap-weekday-col span {
|
||
font-size: 0.5rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend {
|
||
font-size: 0.56rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend-dot {
|
||
width: 0.7rem;
|
||
height: 0.34rem;
|
||
}
|
||
}
|
||
|
||
@media (max-height: 750px) {
|
||
.bento-container {
|
||
gap: 0.5rem;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.bento-card {
|
||
border-radius: 1rem;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.card-messages,
|
||
.card-words {
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
|
||
.card-friends,
|
||
.card-time,
|
||
.card-catchphrase,
|
||
.card-sticker,
|
||
.card-emoji,
|
||
.card-monthly,
|
||
.card-heatmap {
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.card-heatmap {
|
||
padding: 0.65rem;
|
||
}
|
||
|
||
.card-partner,
|
||
.card-group,
|
||
.card-speed {
|
||
padding: 0.65rem;
|
||
}
|
||
|
||
.text-6xl {
|
||
font-size: 2rem !important;
|
||
}
|
||
|
||
.text-5xl {
|
||
font-size: 1.75rem !important;
|
||
}
|
||
|
||
.text-4xl {
|
||
font-size: 1.5rem !important;
|
||
}
|
||
|
||
.text-3xl {
|
||
font-size: 1.25rem !important;
|
||
}
|
||
|
||
.text-lg {
|
||
font-size: 0.75rem !important;
|
||
}
|
||
|
||
.text-sm {
|
||
font-size: 0.65rem !important;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.55rem !important;
|
||
}
|
||
|
||
.w-32,
|
||
.lg\:w-32,
|
||
.w-28,
|
||
.sm\:w-28,
|
||
.w-24 {
|
||
width: 3.5rem !important;
|
||
}
|
||
|
||
.h-32,
|
||
.lg\:h-32,
|
||
.h-28,
|
||
.sm\:h-28,
|
||
.h-24 {
|
||
height: 3.5rem !important;
|
||
}
|
||
|
||
.w-10,
|
||
.sm\:w-10,
|
||
.w-8 {
|
||
width: 1.25rem !important;
|
||
}
|
||
|
||
.h-10,
|
||
.sm\:h-10,
|
||
.h-8 {
|
||
height: 1.25rem !important;
|
||
}
|
||
|
||
.card-heatmap .heatmap-shell {
|
||
border-radius: 0.8rem;
|
||
padding: 0.3rem 0.36rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-row {
|
||
grid-template-columns: 1.2rem repeat(24, minmax(0, 1fr));
|
||
gap: 1px;
|
||
}
|
||
|
||
.card-heatmap .heatmap-main {
|
||
grid-template-columns: 1.2rem minmax(0, 1fr);
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-hour-label,
|
||
.card-heatmap .heatmap-weekday-col span,
|
||
.card-heatmap .heatmap-legend {
|
||
font-size: 0.45rem;
|
||
}
|
||
|
||
.card-heatmap .heatmap-legend-dot {
|
||
width: 0.52rem;
|
||
height: 0.24rem;
|
||
}
|
||
}
|
||
</style>
|