mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-21 07:10:50 +08:00
feat(wrapped): 增加月度好友墙卡片
- 新增月度好友墙卡片(chat/monthly_best_friends_wall):按月评选聊天搭子并输出评分维度 - 前端新增拍立得墙展示 12 个月获胜者与指标条,支持头像失败降级 - Wrapped deck 插入新卡片;emoji 卡片 id 顺延为 5,并同步更新测试 - Wrapped 页面默认展示上一年;切换年份时保持当前页并按需懒加载卡片 - WrappedCardShell(slide)支持 wide 布局;更新 wrapped cache version
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true">
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap justify-center gap-x-3 gap-y-4 px-3 py-2">
|
||||
<article
|
||||
v-for="item in months"
|
||||
:key="`month-${item.month}`"
|
||||
class="relative flex-shrink-0 monthly-polaroid"
|
||||
:class="item.winner ? '' : 'monthly-polaroid--empty'"
|
||||
:style="monthCardStyle(item.month)"
|
||||
>
|
||||
<!-- 有获胜者 -->
|
||||
<template v-if="item.winner">
|
||||
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
|
||||
<!-- 头像 -->
|
||||
<div class="polaroid-photo flex-shrink-0">
|
||||
<img
|
||||
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
|
||||
:src="winnerAvatar(item)"
|
||||
class="w-full h-full object-cover"
|
||||
alt="avatar"
|
||||
@error="avatarOk[item.winner.username] = false"
|
||||
/>
|
||||
<span v-else class="wrapped-number text-xl select-none" style="color:var(--accent)">
|
||||
{{ avatarFallback(item.winner.displayName) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 右列:姓名 / 月份 / 综合分 / 4 维度 -->
|
||||
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-1 min-w-0">
|
||||
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
|
||||
{{ item.winner.displayName }}
|
||||
</div>
|
||||
<!-- 月份徽章 -->
|
||||
<div class="month-badge wrapped-number text-[8px] font-bold flex-shrink-0" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)' }">
|
||||
{{ item.month }}月
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-0.5 wrapped-number text-[9px] font-semibold" :style="{ color: 'var(--accent)' }">
|
||||
综合分 {{ formatScore(item.winner.score100) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 4 维度 2×2 -->
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||
<div v-for="metric in metricRows(item)" :key="metric.key" class="min-w-0">
|
||||
<div class="flex items-center justify-between wrapped-label text-[8px] text-[#00000066]">
|
||||
<span class="truncate">{{ metric.label }}</span>
|
||||
<span v-if="metric.pct !== 100" class="wrapped-number flex-shrink-0 ml-0.5">{{ metric.pct }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 h-1 rounded-full bg-[#0000000D] overflow-hidden">
|
||||
<div class="h-full rounded-full" :style="{ width: `${metric.pct}%`, backgroundColor: 'var(--accent)', opacity: '0.75' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计行 -->
|
||||
<div class="polaroid-caption">
|
||||
<div class="wrapped-body text-[9px] text-[#00000055] leading-snug">
|
||||
共 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.totalMessages) }}</span> 条 ·
|
||||
互动 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.interaction) }}</span> 次 ·
|
||||
活跃 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.activeDays) }}</span> 天
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 无数据:空白拍立得 -->
|
||||
<template v-else>
|
||||
<div class="polaroid-photo-empty flex-shrink-0 mx-auto">
|
||||
<span class="text-lg select-none" style="color:var(--accent);opacity:0.25">〜</span>
|
||||
</div>
|
||||
<div class="polaroid-caption">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="wrapped-label text-[9px] text-[#00000044]">本月静悄悄</div>
|
||||
<div class="month-badge wrapped-number text-[8px]" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)', opacity: '0.5' }">
|
||||
{{ item.month }}月
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' }
|
||||
})
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
const formatScore = (n) => {
|
||||
const x = Number(n)
|
||||
if (!Number.isFinite(x)) return '0.0'
|
||||
return x.toFixed(1)
|
||||
}
|
||||
const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100)))
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const resolveMediaUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
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
|
||||
}
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
}
|
||||
|
||||
const avatarFallback = (name) => {
|
||||
const s = String(name || '').trim()
|
||||
return s ? s[0] : '?'
|
||||
}
|
||||
|
||||
const months = computed(() => {
|
||||
const raw = Array.isArray(props.card?.data?.months) ? props.card.data.months : []
|
||||
const byMonth = new Map()
|
||||
for (const x of raw) {
|
||||
const m = Number(x?.month)
|
||||
if (Number.isFinite(m) && m >= 1 && m <= 12) byMonth.set(m, x)
|
||||
}
|
||||
const out = []
|
||||
for (let m = 1; m <= 12; m += 1) {
|
||||
out.push(byMonth.get(m) || { month: m, winner: null, metrics: null, raw: null })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const avatarOk = reactive({})
|
||||
watch(
|
||||
months,
|
||||
() => { for (const key of Object.keys(avatarOk)) delete avatarOk[key] },
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const winnerAvatar = (item) => resolveMediaUrl(item?.winner?.avatarUrl)
|
||||
|
||||
const metricRows = (item) => {
|
||||
const m = item?.metrics || {}
|
||||
return [
|
||||
{ key: 'interaction', label: '互动', pct: clampPct(m.interactionScore) },
|
||||
{ key: 'speed', label: '速度', pct: clampPct(m.speedScore) },
|
||||
{ key: 'continuity', label: '连续', pct: clampPct(m.continuityScore) },
|
||||
{ key: 'coverage', label: '覆盖', pct: clampPct(m.coverageScore) }
|
||||
]
|
||||
}
|
||||
|
||||
// 12 个月各自独立 accent 色,驱动胶带、徽章、进度条
|
||||
const accents = [
|
||||
'#C96A4E', // 1月 砖红
|
||||
'#5B82C4', // 2月 矢车菊蓝
|
||||
'#4EA87A', // 3月 薄荷绿
|
||||
'#C4953A', // 4月 琥珀金
|
||||
'#8B65B5', // 5月 薰衣草紫
|
||||
'#3A9FB5', // 6月 孔雀蓝
|
||||
'#C45F7A', // 7月 玫瑰粉
|
||||
'#3E7FC4', // 8月 天蓝
|
||||
'#6AA86A', // 9月 苔绿
|
||||
'#C47A3A', // 10月 暖橙
|
||||
'#9B6BAF', // 11月 丁香紫
|
||||
'#4A8EB5', // 12月 冬湖蓝
|
||||
]
|
||||
|
||||
const monthCardStyle = (month) => {
|
||||
const idx = Math.max(0, Math.min(11, Number(month || 1) - 1))
|
||||
const rotations = [-9, 5, -4, 11, -2, 8, -7, 3, -10, 6, -3, 9]
|
||||
const yOffsets = [5, -4, 6, -5, 3, -7, 3, -4, 7, -3, 5, -4]
|
||||
const widths = [172, 165, 178, 168, 175, 163, 172, 167, 165, 178, 165, 170]
|
||||
return {
|
||||
transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`,
|
||||
width: `${widths[idx]}px`,
|
||||
'--delay': `${idx * 0.07}s`,
|
||||
'--accent': accents[idx],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 拍立得卡片基础 ── */
|
||||
.monthly-polaroid {
|
||||
background: #FFFDF7; /* 暖奶油底色 */
|
||||
padding: 4px 4px 0;
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
0 1px 1px rgba(0,0,0,0.06),
|
||||
0 4px 12px rgba(0,0,0,0.10),
|
||||
0 12px 28px rgba(0,0,0,0.08);
|
||||
animation: cardAppear 0.4s ease-out both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes cardAppear {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 空月卡片底色更浅 */
|
||||
.monthly-polaroid--empty {
|
||||
background: #F7F5F0;
|
||||
}
|
||||
|
||||
/* ── 彩色胶带条 ── */
|
||||
.monthly-polaroid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
width: 38px;
|
||||
height: 14px;
|
||||
transform: translateX(-50%) rotate(-1deg);
|
||||
border-radius: 2px;
|
||||
background: var(--accent, #c8a060);
|
||||
opacity: 0.55;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── 头像区域 ── */
|
||||
.polaroid-photo {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: #e0ddd8;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px; /* 照片圆角,更自然 */
|
||||
}
|
||||
|
||||
/* ── 空月占位图 ── */
|
||||
.polaroid-photo-empty {
|
||||
width: 70px;
|
||||
height: 44px;
|
||||
background: #E8E5DF;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 4px auto 0;
|
||||
}
|
||||
|
||||
/* ── 月份小徽章 ── */
|
||||
.month-badge {
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── 底部信息条 ── */
|
||||
.polaroid-caption {
|
||||
padding: 5px 5px 6px;
|
||||
border-top: 1px solid rgba(0,0,0,0.04); /* 细分隔线,区分照片与文字区 */
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,12 @@
|
||||
|
||||
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
|
||||
<section v-else class="relative h-full w-full overflow-hidden">
|
||||
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
|
||||
<div
|
||||
class="relative h-full flex flex-col"
|
||||
:class="wide
|
||||
? 'px-10 pt-20 pb-12 sm:px-14 sm:pt-24 sm:pb-14 lg:px-20 xl:px-20 2xl:px-40'
|
||||
: 'max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
|
||||
@@ -47,6 +52,9 @@ defineProps({
|
||||
cardId: { type: Number, required: true },
|
||||
title: { type: String, required: true },
|
||||
narrative: { type: String, default: '' },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
|
||||
// Slide 模式下是否取消 max-width 限制(让内容直接铺满页面宽度)。
|
||||
// 用于需要横向展示的可视化(如年度日历热力图)。
|
||||
wide: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -242,6 +242,10 @@ const PREVIEW_BY_KIND = {
|
||||
summary: '回复速度',
|
||||
question: '谁是你愿意秒回的那个人?'
|
||||
},
|
||||
'chat/monthly_best_friends_wall': {
|
||||
summary: '月度好友墙',
|
||||
question: '每个月谁是你最有默契的聊天搭子?'
|
||||
},
|
||||
'emoji/annual_universe': {
|
||||
summary: '梗图年鉴',
|
||||
question: '你这一年最常丢出的表情包是哪张?'
|
||||
|
||||
@@ -163,8 +163,14 @@
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card04MonthlyBestFriendsWall
|
||||
v-else-if="c && (c.kind === 'chat/monthly_best_friends_wall' || c.id === 4)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card04EmojiUniverse
|
||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
|
||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 5)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
@@ -199,7 +205,9 @@ const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const year = ref(Number(route.query?.year) || new Date().getFullYear())
|
||||
const queryYear = Number(route.query?.year)
|
||||
const defaultYear = new Date().getFullYear() - 1
|
||||
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
|
||||
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
||||
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
||||
|
||||
@@ -459,9 +467,10 @@ const retryCard = async (cardId) => {
|
||||
await ensureCardLoaded(cardId)
|
||||
}
|
||||
|
||||
const reload = async (forceRefresh = false) => {
|
||||
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
||||
const token = ++reportToken
|
||||
activeIndex.value = 0
|
||||
const keepIndex = preserveIndex ? activeIndex.value : 0
|
||||
if (!preserveIndex) activeIndex.value = 0
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
refreshCards.value = !!forceRefresh
|
||||
@@ -502,6 +511,15 @@ const reload = async (forceRefresh = false) => {
|
||||
}
|
||||
|
||||
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
|
||||
|
||||
if (preserveIndex) {
|
||||
activeIndex.value = clampIndex(keepIndex)
|
||||
const cardIdx = Number(activeIndex.value) - 1
|
||||
if (cardIdx >= 0) {
|
||||
const id = Number(report.value?.cards?.[cardIdx]?.id)
|
||||
if (Number.isFinite(id)) void ensureCardLoaded(id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (token !== reportToken) return
|
||||
report.value = null
|
||||
@@ -576,7 +594,7 @@ watch(year, async (newYear, oldYear) => {
|
||||
year.value = oldYear
|
||||
return
|
||||
}
|
||||
await reload()
|
||||
await reload(false, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user