Skip to content

Commit ffa6491

Browse files
committed
feat(wrapped): 增加月度好友墙卡片
- 新增月度好友墙卡片(chat/monthly_best_friends_wall):按月评选聊天搭子并输出评分维度 - 前端新增拍立得墙展示 12 个月获胜者与指标条,支持头像失败降级 - Wrapped deck 插入新卡片;emoji 卡片 id 顺延为 5,并同步更新测试 - Wrapped 页面默认展示上一年;切换年份时保持当前页并按需懒加载卡片 - WrappedCardShell(slide)支持 wide 布局;更新 wrapped cache version
1 parent a79eb35 commit ffa6491

9 files changed

Lines changed: 1045 additions & 13 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
<template>
2+
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true">
3+
<div class="w-full">
4+
<div class="flex flex-wrap justify-center gap-x-3 gap-y-4 px-3 py-2">
5+
<article
6+
v-for="item in months"
7+
:key="`month-${item.month}`"
8+
class="relative flex-shrink-0 monthly-polaroid"
9+
:class="item.winner ? '' : 'monthly-polaroid--empty'"
10+
:style="monthCardStyle(item.month)"
11+
>
12+
<!-- 有获胜者 -->
13+
<template v-if="item.winner">
14+
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
15+
<!-- 头像 -->
16+
<div class="polaroid-photo flex-shrink-0">
17+
<img
18+
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
19+
:src="winnerAvatar(item)"
20+
class="w-full h-full object-cover"
21+
alt="avatar"
22+
@error="avatarOk[item.winner.username] = false"
23+
/>
24+
<span v-else class="wrapped-number text-xl select-none" style="color:var(--accent)">
25+
{{ avatarFallback(item.winner.displayName) }}
26+
</span>
27+
</div>
28+
<!-- 右列:姓名 / 月份 / 综合分 / 4 维度 -->
29+
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
30+
<div>
31+
<div class="flex items-center justify-between gap-1 min-w-0">
32+
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
33+
{{ item.winner.displayName }}
34+
</div>
35+
<!-- 月份徽章 -->
36+
<div class="month-badge wrapped-number text-[8px] font-bold flex-shrink-0" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)' }">
37+
{{ item.month }}月
38+
</div>
39+
</div>
40+
<div class="mt-0.5 wrapped-number text-[9px] font-semibold" :style="{ color: 'var(--accent)' }">
41+
综合分 {{ formatScore(item.winner.score100) }}
42+
</div>
43+
</div>
44+
<!-- 4 维度 2×2 -->
45+
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
46+
<div v-for="metric in metricRows(item)" :key="metric.key" class="min-w-0">
47+
<div class="flex items-center justify-between wrapped-label text-[8px] text-[#00000066]">
48+
<span class="truncate">{{ metric.label }}</span>
49+
<span v-if="metric.pct !== 100" class="wrapped-number flex-shrink-0 ml-0.5">{{ metric.pct }}</span>
50+
</div>
51+
<div class="mt-0.5 h-1 rounded-full bg-[#0000000D] overflow-hidden">
52+
<div class="h-full rounded-full" :style="{ width: `${metric.pct}%`, backgroundColor: 'var(--accent)', opacity: '0.75' }" />
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
<!-- 统计行 -->
59+
<div class="polaroid-caption">
60+
<div class="wrapped-body text-[9px] text-[#00000055] leading-snug">
61+
共 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.totalMessages) }}</span> 条 ·
62+
互动 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.interaction) }}</span> 次 ·
63+
活跃 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.activeDays) }}</span> 天
64+
</div>
65+
</div>
66+
</template>
67+
68+
<!-- 无数据:空白拍立得 -->
69+
<template v-else>
70+
<div class="polaroid-photo-empty flex-shrink-0 mx-auto">
71+
<span class="text-lg select-none" style="color:var(--accent);opacity:0.25">〜</span>
72+
</div>
73+
<div class="polaroid-caption">
74+
<div class="flex items-center justify-between gap-1">
75+
<div class="wrapped-label text-[9px] text-[#00000044]">本月静悄悄</div>
76+
<div class="month-badge wrapped-number text-[8px]" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)', opacity: '0.5' }">
77+
{{ item.month }}月
78+
</div>
79+
</div>
80+
</div>
81+
</template>
82+
</article>
83+
</div>
84+
</div>
85+
</WrappedCardShell>
86+
</template>
87+
88+
<script setup>
89+
import { computed, reactive, watch } from 'vue'
90+
91+
const props = defineProps({
92+
card: { type: Object, required: true },
93+
variant: { type: String, default: 'panel' }
94+
})
95+
96+
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
97+
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
98+
const formatScore = (n) => {
99+
const x = Number(n)
100+
if (!Number.isFinite(x)) return '0.0'
101+
return x.toFixed(1)
102+
}
103+
const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100)))
104+
105+
const mediaBase = process.client ? 'http://localhost:8000' : ''
106+
const resolveMediaUrl = (value) => {
107+
const raw = String(value || '').trim()
108+
if (!raw) return ''
109+
if (/^https?:\/\//i.test(raw)) {
110+
try {
111+
const host = new URL(raw).hostname.toLowerCase()
112+
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
113+
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
114+
}
115+
} catch {}
116+
return raw
117+
}
118+
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
119+
}
120+
121+
const avatarFallback = (name) => {
122+
const s = String(name || '').trim()
123+
return s ? s[0] : '?'
124+
}
125+
126+
const months = computed(() => {
127+
const raw = Array.isArray(props.card?.data?.months) ? props.card.data.months : []
128+
const byMonth = new Map()
129+
for (const x of raw) {
130+
const m = Number(x?.month)
131+
if (Number.isFinite(m) && m >= 1 && m <= 12) byMonth.set(m, x)
132+
}
133+
const out = []
134+
for (let m = 1; m <= 12; m += 1) {
135+
out.push(byMonth.get(m) || { month: m, winner: null, metrics: null, raw: null })
136+
}
137+
return out
138+
})
139+
140+
const avatarOk = reactive({})
141+
watch(
142+
months,
143+
() => { for (const key of Object.keys(avatarOk)) delete avatarOk[key] },
144+
{ deep: true, immediate: true }
145+
)
146+
147+
const winnerAvatar = (item) => resolveMediaUrl(item?.winner?.avatarUrl)
148+
149+
const metricRows = (item) => {
150+
const m = item?.metrics || {}
151+
return [
152+
{ key: 'interaction', label: '互动', pct: clampPct(m.interactionScore) },
153+
{ key: 'speed', label: '速度', pct: clampPct(m.speedScore) },
154+
{ key: 'continuity', label: '连续', pct: clampPct(m.continuityScore) },
155+
{ key: 'coverage', label: '覆盖', pct: clampPct(m.coverageScore) }
156+
]
157+
}
158+
159+
// 12 个月各自独立 accent 色,驱动胶带、徽章、进度条
160+
const accents = [
161+
'#C96A4E', // 1月 砖红
162+
'#5B82C4', // 2月 矢车菊蓝
163+
'#4EA87A', // 3月 薄荷绿
164+
'#C4953A', // 4月 琥珀金
165+
'#8B65B5', // 5月 薰衣草紫
166+
'#3A9FB5', // 6月 孔雀蓝
167+
'#C45F7A', // 7月 玫瑰粉
168+
'#3E7FC4', // 8月 天蓝
169+
'#6AA86A', // 9月 苔绿
170+
'#C47A3A', // 10月 暖橙
171+
'#9B6BAF', // 11月 丁香紫
172+
'#4A8EB5', // 12月 冬湖蓝
173+
]
174+
175+
const monthCardStyle = (month) => {
176+
const idx = Math.max(0, Math.min(11, Number(month || 1) - 1))
177+
const rotations = [-9, 5, -4, 11, -2, 8, -7, 3, -10, 6, -3, 9]
178+
const yOffsets = [5, -4, 6, -5, 3, -7, 3, -4, 7, -3, 5, -4]
179+
const widths = [172, 165, 178, 168, 175, 163, 172, 167, 165, 178, 165, 170]
180+
return {
181+
transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`,
182+
width: `${widths[idx]}px`,
183+
'--delay': `${idx * 0.07}s`,
184+
'--accent': accents[idx],
185+
}
186+
}
187+
</script>
188+
189+
<style scoped>
190+
/* ── 拍立得卡片基础 ── */
191+
.monthly-polaroid {
192+
background: #FFFDF7; /* 暖奶油底色 */
193+
padding: 4px 4px 0;
194+
border-radius: 3px;
195+
box-shadow:
196+
0 1px 1px rgba(0,0,0,0.06),
197+
0 4px 12px rgba(0,0,0,0.10),
198+
0 12px 28px rgba(0,0,0,0.08);
199+
animation: cardAppear 0.4s ease-out both;
200+
animation-delay: var(--delay, 0s);
201+
}
202+
203+
@keyframes cardAppear {
204+
from { opacity: 0; }
205+
to { opacity: 1; }
206+
}
207+
208+
/* 空月卡片底色更浅 */
209+
.monthly-polaroid--empty {
210+
background: #F7F5F0;
211+
}
212+
213+
/* ── 彩色胶带条 ── */
214+
.monthly-polaroid::before {
215+
content: '';
216+
position: absolute;
217+
top: -7px;
218+
left: 50%;
219+
width: 38px;
220+
height: 14px;
221+
transform: translateX(-50%) rotate(-1deg);
222+
border-radius: 2px;
223+
background: var(--accent, #c8a060);
224+
opacity: 0.55;
225+
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
226+
z-index: 1;
227+
}
228+
229+
/* ── 头像区域 ── */
230+
.polaroid-photo {
231+
width: 70px;
232+
height: 70px;
233+
background: #e0ddd8;
234+
overflow: hidden;
235+
display: flex;
236+
align-items: center;
237+
justify-content: center;
238+
border-radius: 4px; /* 照片圆角,更自然 */
239+
}
240+
241+
/* ── 空月占位图 ── */
242+
.polaroid-photo-empty {
243+
width: 70px;
244+
height: 44px;
245+
background: #E8E5DF;
246+
border-radius: 4px;
247+
display: flex;
248+
align-items: center;
249+
justify-content: center;
250+
margin: 4px auto 0;
251+
}
252+
253+
/* ── 月份小徽章 ── */
254+
.month-badge {
255+
border: 1px solid;
256+
border-radius: 3px;
257+
padding: 0 3px;
258+
line-height: 1.6;
259+
}
260+
261+
/* ── 底部信息条 ── */
262+
.polaroid-caption {
263+
padding: 5px 5px 6px;
264+
border-top: 1px solid rgba(0,0,0,0.04); /* 细分隔线,区分照片与文字区 */
265+
margin-top: 4px;
266+
}
267+
</style>

frontend/components/wrapped/shared/WrappedCardShell.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020

2121
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
2222
<section v-else class="relative h-full w-full overflow-hidden">
23-
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
23+
<div
24+
class="relative h-full flex flex-col"
25+
:class="wide
26+
? 'px-10 pt-20 pb-12 sm:px-14 sm:pt-24 sm:pb-14 lg:px-20 xl:px-20 2xl:px-40'
27+
: 'max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'"
28+
>
2429
<div class="flex items-start justify-between gap-4">
2530
<div>
2631
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
@@ -47,6 +52,9 @@ defineProps({
4752
cardId: { type: Number, required: true },
4853
title: { type: String, required: true },
4954
narrative: { type: String, default: '' },
50-
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
55+
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
56+
// Slide 模式下是否取消 max-width 限制(让内容直接铺满页面宽度)。
57+
// 用于需要横向展示的可视化(如年度日历热力图)。
58+
wide: { type: Boolean, default: false }
5159
})
5260
</script>

frontend/components/wrapped/shared/WrappedHero.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ const PREVIEW_BY_KIND = {
242242
summary: '回复速度',
243243
question: '谁是你愿意秒回的那个人?'
244244
},
245+
'chat/monthly_best_friends_wall': {
246+
summary: '月度好友墙',
247+
question: '每个月谁是你最有默契的聊天搭子?'
248+
},
245249
'emoji/annual_universe': {
246250
summary: '梗图年鉴',
247251
question: '你这一年最常丢出的表情包是哪张?'

frontend/pages/wrapped/index.vue

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,14 @@
163163
variant="slide"
164164
class="h-full w-full"
165165
/>
166+
<Card04MonthlyBestFriendsWall
167+
v-else-if="c && (c.kind === 'chat/monthly_best_friends_wall' || c.id === 4)"
168+
:card="c"
169+
variant="slide"
170+
class="h-full w-full"
171+
/>
166172
<Card04EmojiUniverse
167-
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
173+
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 5)"
168174
:card="c"
169175
variant="slide"
170176
class="h-full w-full"
@@ -199,7 +205,9 @@ const api = useApi()
199205
const route = useRoute()
200206
const router = useRouter()
201207
202-
const year = ref(Number(route.query?.year) || new Date().getFullYear())
208+
const queryYear = Number(route.query?.year)
209+
const defaultYear = new Date().getFullYear() - 1
210+
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
203211
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
204212
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
205213
@@ -459,9 +467,10 @@ const retryCard = async (cardId) => {
459467
await ensureCardLoaded(cardId)
460468
}
461469
462-
const reload = async (forceRefresh = false) => {
470+
const reload = async (forceRefresh = false, preserveIndex = false) => {
463471
const token = ++reportToken
464-
activeIndex.value = 0
472+
const keepIndex = preserveIndex ? activeIndex.value : 0
473+
if (!preserveIndex) activeIndex.value = 0
465474
error.value = ''
466475
loading.value = true
467476
refreshCards.value = !!forceRefresh
@@ -502,6 +511,15 @@ const reload = async (forceRefresh = false) => {
502511
}
503512
504513
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
514+
515+
if (preserveIndex) {
516+
activeIndex.value = clampIndex(keepIndex)
517+
const cardIdx = Number(activeIndex.value) - 1
518+
if (cardIdx >= 0) {
519+
const id = Number(report.value?.cards?.[cardIdx]?.id)
520+
if (Number.isFinite(id)) void ensureCardLoaded(id)
521+
}
522+
}
505523
} catch (e) {
506524
if (token !== reportToken) return
507525
report.value = null
@@ -576,7 +594,7 @@ watch(year, async (newYear, oldYear) => {
576594
year.value = oldYear
577595
return
578596
}
579-
await reload()
597+
await reload(false, true)
580598
})
581599
</script>
582600

src/wechat_decrypt_tool/wrapped/cards/card_04_emoji_universe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,7 @@ def build_card_04_emoji_universe(*, account_dir: Path, year: int) -> dict[str, A
12541254
narrative = "".join(parts)
12551255

12561256
return {
1257-
"id": 4,
1257+
"id": 5,
12581258
"title": "这一年,你的表情包里藏了多少心情?",
12591259
"scope": "global",
12601260
"category": "B",

0 commit comments

Comments
 (0)