improvement(wrapped): 优化月度好友墙木板布局与拖拽交互

- 重构月度好友墙卡片容器与木板视觉层

- 优化卡片尺寸、旋转分布、阴影与入场动画

- 支持拍立得拖拽、悬停放大与层级提升
This commit is contained in:
2977094657
2026-02-22 11:57:02 +08:00
Unverified
parent 9254d46802
commit d9e9bcc16a
@@ -1,98 +1,162 @@
<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>
<!-- 由于父级 section 存在高度受限 (height: 695px)取消激进的负间距和平移改用温和的 padding 调整内部空间 -->
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true" class="h-full monthly-best-friends-shell-lift">
<!-- mt 设为 0 或微负整体靠容器 flex 分布 -->
<div class="w-full h-full p-2 sm:p-4 -mt-5 sm:-mt-6 flex items-stretch">
<!-- 拟真木板容器 -->
<div class="wood-board-container relative w-full h-full rounded-2xl shadow-[0_10px_25px_rgba(0,0,0,0.15)] flex flex-col">
<!-- 背景图层必须带上 rounded-2xl 否则纯 CSS background 会溢出直角 -->
<div class="wood-board-bg absolute inset-0 rounded-2xl"></div>
<!-- 边缘受光与暗角阴影且通过 overflow-hidden rounded-2xl 确保不破坏外壳 -->
<div class="wood-board-frame absolute inset-0 pointer-events-none z-20 rounded-2xl overflow-hidden"></div>
<!-- 无数据空白拍立得 -->
<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 class="flex flex-wrap justify-center content-start gap-x-6 sm:gap-x-8 gap-y-12 sm:gap-y-14 px-4 sm:px-8 py-8 w-full h-full relative z-10 overflow-y-auto overflow-x-hidden custom-scrollbar rounded-2xl">
<article
v-for="(item, index) in months"
:key="`month-${item.month}`"
class="relative flex-shrink-0 monthly-polaroid origin-center select-none cursor-grab"
:class="[
item.winner ? '' : 'monthly-polaroid--empty',
{ 'is-dragging': positions[index].dragging }
]"
:style="monthCardStyle(item.month, index)"
@pointerdown="(e) => onPointerDown(e, index)"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<!-- 有获胜者 -->
<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>
</template>
</article>
<!-- 统计行 -->
<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>
</div>
</WrappedCardShell>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { computed, reactive, watch, ref } from 'vue'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' }
})
// === 拖拽交互状态 ===
let maxZ = 10;
const draggingIdx = ref(-1);
const startCoords = { x: 0, y: 0 };
const initialOffsets = { dx: 0, dy: 0 };
const positions = reactive(Array(12).fill(0).map(() => ({ dx: 0, dy: 0, z: 1, dragging: false })))
const onPointerDown = (e, index) => {
if (e.button !== 0 && e.type.startsWith('mouse')) return;
e.preventDefault();
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
draggingIdx.value = index;
maxZ += 1;
positions[index].z = maxZ;
positions[index].dragging = true;
startCoords.x = e.clientX;
startCoords.y = e.clientY;
initialOffsets.dx = positions[index].dx;
initialOffsets.dy = positions[index].dy;
}
const onPointerMove = (e) => {
if (draggingIdx.value === -1) return;
const idx = draggingIdx.value;
const dx = e.clientX - startCoords.x;
const dy = e.clientY - startCoords.y;
positions[idx].dx = initialOffsets.dx + dx;
positions[idx].dy = initialOffsets.dy + dy;
}
const onPointerUp = (e) => {
if (draggingIdx.value === -1) return;
const idx = draggingIdx.value;
positions[idx].dragging = false;
const el = e.currentTarget;
if (el.hasPointerCapture(e.pointerId)) {
el.releasePointerCapture(e.pointerId);
}
draggingIdx.value = -1;
}
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const formatScore = (n) => {
@@ -172,37 +236,128 @@ const accents = [
'#4A8EB5', // 12月 冬湖蓝
]
const monthCardStyle = (month) => {
const monthCardStyle = (month, vIndex) => {
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]
// Increase angle spread
const rotations = [-11, 8, -6, 14, -5, 12, -9, 4, -13, 7, -4, 11]
// Significantly adjust vertical scatter to fill whitespace across two rows
const yOffsets = [20, -10, 30, -20, 15, -25, 25, -15, 35, -5, 10, -30]
// Base width scaled up
const widths = [198, 192, 204, 194, 201, 190, 198, 194, 192, 204, 192, 196]
const p = positions[vIndex] || { dx: 0, dy: 0, z: 1 }
const ty = yOffsets[idx]
return {
transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`,
width: `${widths[idx]}px`,
'--delay': `${idx * 0.07}s`,
'--rotate': `${rotations[idx]}deg`,
'--drag-x': `${p.dx}px`,
'--drag-y': `${p.dy + ty}px`,
'--width': `${widths[idx]}px`,
'--delay': `${idx * 0.08}s`,
'--accent': accents[idx],
'z-index': p.z > 1 ? p.z : undefined,
}
}
</script>
<style scoped>
.monthly-best-friends-shell-lift :deep(.relative.h-full.flex.flex-col) {
padding-top: 2rem !important;
}
/* ── 拟真浅色木板背景(纯CSS) ── */
.wood-board-bg {
/* 基础底色:温暖且带有微小绿意的米白/浅卡其,和应用背景呼应 */
background-color: #E8EDE4;
background-image:
/* 木板接缝的立体浅灰阴影 */
repeating-linear-gradient(to right, transparent, transparent 19.5%, rgba(0,0,0,0.04) 19.8%, rgba(0,0,0,0.08) 20%, transparent 20.2%),
/* 细微的纵向浅色木纹 */
repeating-linear-gradient(to right, transparent, transparent 1px, rgba(0,0,0,0.01) 1px, rgba(0,0,0,0.01) 2px),
/* 宽条纹基础色差模拟浅色木材打磨 */
linear-gradient(90deg, #E2e8DD 0%, #ebf0e7 15%, #E2e8DD 30%, #E8EDE4 50%, #dce4d6 75%, #E2e8DD 100%);
background-size: 100% 100%;
}
.wood-board-frame {
/* 去掉黑重的边框,改为温和的内收高光与微弱阴影边缘 */
box-shadow:
inset 0 0 40px rgba(0,0,0,0.06),
inset 0 0 100px rgba(0,0,0,0.03),
inset 0 1px 2px rgba(255,255,255,0.8),
inset 0 -1px 3px rgba(0,0,0,0.05);
border: 4px solid rgba(255, 255, 255, 0.4);
border-bottom-color: rgba(220, 230, 215, 0.6);
border-top-color: rgba(255, 255, 255, 0.9);
border-radius: 1rem;
}
/* 隐藏式滚动条,不破坏木板质感 */
.wood-board-container .custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.wood-board-container .custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.wood-board-container .custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.wood-board-container .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
/* ── 拍立得卡片基础 ── */
.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;
0 2px 6px rgba(0,0,0,0.08),
0 8px 16px rgba(0,0,0,0.06),
0 16px 32px rgba(0,0,0,0.04);
width: var(--width, 170px);
transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)) rotate(var(--rotate, 0deg));
transform-origin: center center;
position: relative;
z-index: 1;
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.4s ease, z-index 0s 0.4s;
animation: cardAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
animation-delay: var(--delay, 0s);
touch-action: none;
}
.monthly-polaroid:hover:not(.is-dragging) {
transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)) scale(1.15) rotate(0deg) !important;
z-index: 9999 !important;
box-shadow:
0 20px 40px rgba(0,0,0,0.12),
0 8px 16px rgba(0,0,0,0.08);
cursor: grab;
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.4s ease, z-index 0s 0s;
}
.monthly-polaroid.is-dragging {
transition: transform 0s ease, box-shadow 0s ease !important;
cursor: grabbing !important;
z-index: 10000 !important;
/* 拖拽时适度缩放,保留旋转角度 */
transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)) scale(1.05) rotate(var(--rotate, 0deg)) !important;
box-shadow:
0 24px 48px rgba(0,0,0,0.15),
0 12px 20px rgba(0,0,0,0.1);
}
@keyframes cardAppear {
from { opacity: 0; }
to { opacity: 1; }
0% {
opacity: 0;
transform: translate(var(--drag-x, 0px), calc(var(--drag-y, 0px) + 40px)) rotate(0deg) scale(0.8);
}
100% {
opacity: 1;
transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)) rotate(var(--rotate, 0deg)) scale(1);
}
}
/* 空月卡片底色更浅 */
@@ -221,9 +376,20 @@ const monthCardStyle = (month) => {
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);
/* 调高胶带透明度并在浅色背景上自然融合 */
opacity: 0.85;
box-shadow:
0 1px 2px rgba(0,0,0,0.08),
inset 0 1px 1px rgba(255,255,255,0.6);
z-index: 1;
transition: transform 0.3s ease;
}
.monthly-polaroid:hover::before {
transform: translateX(-50%) rotate(-4deg) scale(1.05);
box-shadow:
0 2px 4px rgba(0,0,0,0.12),
inset 0 1px 1px rgba(255,255,255,0.6);
}
/* ── 头像区域 ── */