mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(wrapped): 新增梗图年鉴(Emoji Universe)卡片
- 后端新增 card_04_emoji_universe:统计表情包/emoji 使用与画像 - 前端新增 Card04EmojiUniverse + VueBits Stack/ImageTrail 交互展示 - 更新 Wrapped manifest/Hero 预览与用例覆盖
This commit is contained in:
793
frontend/components/wrapped/cards/Card04EmojiUniverse.vue
Normal file
793
frontend/components/wrapped/cards/Card04EmojiUniverse.vue
Normal file
@@ -0,0 +1,793 @@
|
||||
<template>
|
||||
<div ref="cardRoot" class="h-full w-full">
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
|
||||
<template #narrative>
|
||||
<div class="mt-1 wrapped-body text-sm sm:text-base text-[#6F6F6F] leading-relaxed">
|
||||
<p class="whitespace-normal">
|
||||
<template v-for="(seg, idx) in narrativeSegments" :key="`n-${idx}`">
|
||||
<img
|
||||
v-if="seg.type === 'emoji'"
|
||||
:src="seg.src"
|
||||
class="inline-block align-[-0.18em] rounded-[3px] wx-inline-emoji"
|
||||
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||
:alt="seg.alt || 'emoji'"
|
||||
/>
|
||||
<img
|
||||
v-else-if="seg.type === 'sticker'"
|
||||
:src="seg.src"
|
||||
class="inline-block align-[-0.16em] rounded-[4px] wx-inline-emoji"
|
||||
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||
:alt="seg.alt || 'sticker'"
|
||||
/>
|
||||
<span
|
||||
v-else-if="seg.type === 'num'"
|
||||
class="wrapped-number text-[#07C160] font-semibold"
|
||||
>
|
||||
{{ seg.content }}
|
||||
</span>
|
||||
<span v-else>{{ seg.content }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full -mt-1 sm:-mt-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2">
|
||||
<div class="lg:col-span-7 space-y-2 sm:space-y-2.5">
|
||||
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">高频表情卡堆(Vue Bits)</div>
|
||||
<div v-if="stackCardsData.length > 0" class="mt-2">
|
||||
<div class="relative h-[9.4rem] sm:h-[9.8rem] rounded-xl overflow-visible">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Stack
|
||||
:randomRotation="true"
|
||||
:sensitivity="180"
|
||||
:sendToBackOnClick="false"
|
||||
:cardDimensions="stackCardDimensions"
|
||||
:cardsData="stackCardsData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-col items-center justify-center text-center">
|
||||
<div class="wrapped-number text-base text-[#07C160] font-semibold leading-tight">
|
||||
{{ formatInt(Number(stackTopCount || 0)) }} 次
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-0.5 inline-flex items-center gap-1.5 rounded-md bg-[#00000008] px-1.5 py-1 max-w-full"
|
||||
:title="heroStickerOwnerName ? `常发送给 ${heroStickerOwnerName}` : '常发送给:未知'"
|
||||
>
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
|
||||
:src="heroStickerOwnerAvatarUrl"
|
||||
class="w-full h-full object-cover"
|
||||
alt="avatar"
|
||||
@error="avatarOk.topStickerOwner = false"
|
||||
/>
|
||||
<span v-else class="wrapped-number text-[10px] text-[#00000066]">
|
||||
{{ avatarFallback(heroStickerOwnerName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
|
||||
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 wrapped-label text-[10px] text-[#00000055]">拖动表情卡片翻一翻</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 wrapped-body text-xs text-[#00000055]">
|
||||
暂无可展示的高频表情图片。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">Emoji Top(小黄脸 + Unicode)</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#07C160]/10 text-[#07C160] border-[#07C160]/25">
|
||||
小黄脸
|
||||
</span>
|
||||
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#0EA5E9]/10 text-[#0EA5E9] border-[#0EA5E9]/25">
|
||||
Unicode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="emojiBubbleRows.length > 0" class="mt-2">
|
||||
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-2.5 place-items-end">
|
||||
<div
|
||||
v-for="row in emojiBubbleRows"
|
||||
:key="row.id"
|
||||
class="min-w-0 flex flex-col items-center gap-1"
|
||||
:title="`${row.label} · ${formatInt(row.count)}次`"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full shadow-sm"
|
||||
:class="row.kind === 'wechat' ? 'bg-[#07C160]/14' : 'bg-[#0EA5E9]/12'"
|
||||
:style="{ width: `${row.size}px`, height: `${row.size}px` }"
|
||||
>
|
||||
<img
|
||||
v-if="row.kind === 'wechat' && row.assetPath && emojiAssetOk[row.key] !== false"
|
||||
:src="resolveEmojiAsset(row.assetPath)"
|
||||
class="w-3/4 h-3/4 object-contain"
|
||||
alt="emoji"
|
||||
@error="emojiAssetOk[row.key] = false"
|
||||
/>
|
||||
<span v-else class="text-xl leading-none">
|
||||
{{ row.kind === 'unicode' ? row.label : '🙂' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="wrapped-number text-[10px] font-semibold leading-none"
|
||||
:class="row.kind === 'wechat' ? 'text-[#07C160]' : 'text-[#0EA5E9]'"
|
||||
>
|
||||
{{ formatInt(row.count) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 wrapped-body text-xs text-[#00000066]">
|
||||
今年没有统计到可识别的 Emoji(小黄脸/Unicode)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-5 h-full min-h-[20rem] sm:min-h-[21.5rem] rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3 relative overflow-hidden">
|
||||
<div class="relative z-[1] h-full flex flex-col">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">斗图热力时段</div>
|
||||
<div class="mt-1 wrapped-body text-sm text-[#000000e6]">
|
||||
<template v-if="peakHour !== null && peakWeekdayName">
|
||||
高峰在
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ peakWeekdayName }} {{ peakHour }}:00</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
今年没有明显的斗图高峰时段
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 h-16 sm:h-20 flex items-end gap-[2px]">
|
||||
<div
|
||||
v-for="item in hourBars"
|
||||
:key="`hour-${item.hour}`"
|
||||
class="flex-1 min-w-0 rounded-sm bg-[#07C160]/20"
|
||||
:style="{ height: `${item.heightPct}%` }"
|
||||
:title="`${item.hour}:00 · ${formatInt(item.count)}次`"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between wrapped-label text-[10px] text-[#00000055]">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-[#EDEDED] bg-white/60 p-2.5 flex-1 flex flex-col">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">表情新鲜度</div>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2.5">
|
||||
<div class="rounded-lg bg-[#07C160]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">年度新解锁</div>
|
||||
<div class="mt-1.5 wrapped-number text-lg text-[#07C160] font-semibold leading-tight">
|
||||
{{ formatInt(newStickerCountThisYear) }}
|
||||
</div>
|
||||
<div class="wrapped-label text-[11px] text-[#00000055]">
|
||||
占类型 {{ newStickerSharePct }}%
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="newStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||
<div
|
||||
v-for="item in newStickerDecorDisplayItems"
|
||||
:key="`new-chip-${item.id}`"
|
||||
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||
>
|
||||
<img
|
||||
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||
:src="item.src"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
@error="stickerDecorOk[item.id] = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-[#0EA5E9]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">回温表情</div>
|
||||
<div class="mt-1.5 wrapped-number text-lg text-[#0EA5E9] font-semibold leading-tight">
|
||||
{{ formatInt(revivedStickerCount) }}
|
||||
</div>
|
||||
<div class="wrapped-label text-[11px] text-[#00000055] leading-tight">
|
||||
间隔≥{{ formatInt(revivedMinGapDays) }}天,最长 {{ formatInt(revivedMaxGapDays) }} 天
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="revivedStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||
<div
|
||||
v-for="item in revivedStickerDecorDisplayItems"
|
||||
:key="`rev-chip-${item.id}`"
|
||||
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||
>
|
||||
<img
|
||||
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||
:src="item.src"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
@error="stickerDecorOk[item.id] = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 wrapped-body text-[11px] text-[#00000066]">
|
||||
今年共用过 <span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(uniqueStickerTypeCount) }}</span> 种表情,
|
||||
回温占比 <span class="wrapped-number text-[#0EA5E9] font-semibold">{{ revivedStickerSharePct }}</span>%。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="cursorFxEnabled && isCardVisible && cursorTrails.length > 0" class="emoji-cursor-layer" aria-hidden="true">
|
||||
<img
|
||||
v-for="item in cursorTrails"
|
||||
:key="item.id"
|
||||
class="emoji-cursor-item"
|
||||
:style="{
|
||||
left: `${item.x}px`,
|
||||
top: `${item.y}px`,
|
||||
width: `${item.size}px`,
|
||||
height: `${item.size}px`,
|
||||
'--drift-x': `${item.driftX}px`,
|
||||
'--drift-y': `${item.driftY}px`
|
||||
}"
|
||||
:src="item.src"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
|
||||
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
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
|
||||
}
|
||||
if (opts.backend || raw.startsWith('/api/')) {
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
}
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const resolveEmojiAsset = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
if (raw.startsWith('/wxemoji/')) return raw
|
||||
if (raw.startsWith('/')) return raw
|
||||
return `/wxemoji/${raw}`
|
||||
}
|
||||
|
||||
const resolveStickerUrl = (st) => {
|
||||
const remote = resolveMediaUrl(st?.emojiUrl, { backend: true })
|
||||
if (remote) return remote
|
||||
const asset = resolveEmojiAsset(st?.emojiAssetPath)
|
||||
return asset || ''
|
||||
}
|
||||
|
||||
const splitTextByNumbers = (text) => {
|
||||
const raw = String(text || '')
|
||||
if (!raw) return []
|
||||
const parts = raw.split(/(\d[\d,.]*)/g)
|
||||
const out = []
|
||||
for (const p of parts) {
|
||||
if (!p) continue
|
||||
if (/^\d[\d,.]*$/.test(p)) out.push({ type: 'num', content: p })
|
||||
else out.push({ type: 'text', content: p })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const appendParsedText = (segments, text) => {
|
||||
const parsed = parseTextWithEmoji(String(text || ''))
|
||||
for (const seg of parsed) {
|
||||
if (seg?.type === 'emoji' && seg.emojiSrc) {
|
||||
segments.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(seg.emojiSrc),
|
||||
alt: seg.content || 'emoji',
|
||||
sizeEm: 1.12
|
||||
})
|
||||
continue
|
||||
}
|
||||
const inner = splitTextByNumbers(seg?.content || '')
|
||||
for (const x of inner) segments.push(x)
|
||||
}
|
||||
}
|
||||
|
||||
const sentStickerCount = computed(() => Number(props.card?.data?.sentStickerCount || 0))
|
||||
const stickerActiveDays = computed(() => Number(props.card?.data?.stickerActiveDays || 0))
|
||||
const peakHour = computed(() => {
|
||||
const h = props.card?.data?.peakHour
|
||||
return Number.isFinite(Number(h)) ? Number(h) : null
|
||||
})
|
||||
const peakWeekdayName = computed(() => String(props.card?.data?.peakWeekdayName || '').trim())
|
||||
|
||||
const stickerPerActiveDayText = computed(() => {
|
||||
const v = Number(props.card?.data?.stickerPerActiveDay || 0)
|
||||
return Number.isFinite(v) ? v.toFixed(1) : '0.0'
|
||||
})
|
||||
const uniqueStickerTypeCount = computed(() => Number(props.card?.data?.uniqueStickerTypeCount || 0))
|
||||
const newStickerCountThisYear = computed(() => Number(props.card?.data?.newStickerCountThisYear || 0))
|
||||
const revivedStickerCount = computed(() => Number(props.card?.data?.revivedStickerCount || 0))
|
||||
const revivedMinGapDays = computed(() => Number(props.card?.data?.revivedMinGapDays || 60))
|
||||
const revivedMaxGapDays = computed(() => Number(props.card?.data?.revivedMaxGapDays || 0))
|
||||
const newStickerSharePct = computed(() => {
|
||||
const v = Number(props.card?.data?.newStickerShare)
|
||||
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((Number(newStickerCountThisYear.value || 0) / total) * 100)))
|
||||
})
|
||||
const revivedStickerSharePct = computed(() => {
|
||||
const v = Number(props.card?.data?.revivedStickerShare)
|
||||
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((Number(revivedStickerCount.value || 0) / total) * 100)))
|
||||
})
|
||||
const newStickerSamples = computed(() => {
|
||||
const arr = props.card?.data?.newStickerSamples
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const revivedStickerSamples = computed(() => {
|
||||
const arr = props.card?.data?.revivedStickerSamples
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const buildDecorStickerItems = (rows, prefix) => {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (let idx = 0; idx < rows.length; idx += 1) {
|
||||
const st = rows[idx] || {}
|
||||
const rawId = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || `${prefix}-${idx}`).trim()
|
||||
if (!rawId || seen.has(rawId)) continue
|
||||
seen.add(rawId)
|
||||
out.push({
|
||||
id: `${prefix}-${rawId}`,
|
||||
src: resolveStickerUrl(st),
|
||||
count: Math.max(0, Number(st?.count || 0)),
|
||||
gapDays: Math.max(0, Number(st?.gapDays || 0))
|
||||
})
|
||||
if (out.length >= 4) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const topStickers = computed(() => {
|
||||
const arr = props.card?.data?.topStickers
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const newStickerDecorItems = computed(() => buildDecorStickerItems(newStickerSamples.value, 'new'))
|
||||
const revivedStickerDecorItems = computed(() => buildDecorStickerItems(revivedStickerSamples.value, 'revived'))
|
||||
const topStickerDecorItems = computed(() => buildDecorStickerItems(topStickers.value, 'top'))
|
||||
const toRenderableDecor = (items) => items.filter((x) => String(x?.src || '').trim())
|
||||
const decorFallbackPool = computed(() => {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const item of [
|
||||
...toRenderableDecor(newStickerDecorItems.value),
|
||||
...toRenderableDecor(revivedStickerDecorItems.value),
|
||||
...toRenderableDecor(topStickerDecorItems.value)
|
||||
]) {
|
||||
const key = String(item?.src || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(item)
|
||||
if (out.length >= 6) break
|
||||
}
|
||||
return out
|
||||
})
|
||||
const newStickerDecorDisplayItems = computed(() => {
|
||||
const own = toRenderableDecor(newStickerDecorItems.value).slice(0, 3)
|
||||
if (own.length > 0) return own
|
||||
return decorFallbackPool.value.slice(0, 3)
|
||||
})
|
||||
const revivedStickerDecorDisplayItems = computed(() => {
|
||||
const own = toRenderableDecor(revivedStickerDecorItems.value).slice(0, 3)
|
||||
if (own.length > 0) return own
|
||||
const fallback = decorFallbackPool.value.slice(3, 6)
|
||||
return fallback.length > 0 ? fallback : decorFallbackPool.value.slice(0, 3)
|
||||
})
|
||||
|
||||
const heroSticker = computed(() => {
|
||||
const arr = topStickers.value
|
||||
return Array.isArray(arr) && arr.length > 0 ? arr[0] : null
|
||||
})
|
||||
|
||||
const heroStickerUrl = computed(() => resolveStickerUrl(heroSticker.value))
|
||||
const heroStickerOwnerName = computed(() => String(heroSticker.value?.sampleDisplayName || heroSticker.value?.sampleUsername || '').trim())
|
||||
const heroStickerOwnerAvatarUrl = computed(() => resolveMediaUrl(heroSticker.value?.sampleAvatarUrl, { backend: true }))
|
||||
|
||||
const stackCardDimensions = { width: 140, height: 140 }
|
||||
|
||||
const stackCardsData = computed(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const st of topStickers.value) {
|
||||
const key = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
const src = resolveStickerUrl(st)
|
||||
if (!src) continue
|
||||
seen.add(key)
|
||||
out.push({ id: key, img: src })
|
||||
if (out.length >= 4) break
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
return allWechatEmojiAssets.value.slice(0, 4).map((img, idx) => ({ id: `wx-${idx}`, img }))
|
||||
})
|
||||
|
||||
const stackTopCount = computed(() => Number(heroSticker.value?.count || 0))
|
||||
|
||||
const hourBars = computed(() => {
|
||||
const raw = Array.isArray(props.card?.data?.stickerHourCounts) ? props.card.data.stickerHourCounts : []
|
||||
const counts = Array.from({ length: 24 }, (_, i) => Math.max(0, Number(raw[i] || 0)))
|
||||
const maxV = Math.max(1, ...counts)
|
||||
return counts.map((count, hour) => ({
|
||||
hour,
|
||||
count,
|
||||
heightPct: Math.max(8, Math.round((count / maxV) * 100))
|
||||
}))
|
||||
})
|
||||
|
||||
const topTextEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topTextEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const topWechatEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topWechatEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const topUnicodeEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topUnicodeEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const smallWechatEmojiChips = computed(() => {
|
||||
if (topWechatEmojis.value.length > 0) {
|
||||
return topWechatEmojis.value.map((x) => ({
|
||||
key: String(x?.key || ''),
|
||||
count: Number(x?.count || 0),
|
||||
assetPath: String(x?.assetPath || '')
|
||||
}))
|
||||
}
|
||||
return topTextEmojis.value.map((x) => ({
|
||||
key: String(x?.key || ''),
|
||||
count: Number(x?.count || 0),
|
||||
assetPath: String(x?.assetPath || '')
|
||||
}))
|
||||
})
|
||||
|
||||
const emojiAssetOk = reactive({})
|
||||
watch(
|
||||
smallWechatEmojiChips,
|
||||
(arr) => {
|
||||
for (const k of Object.keys(emojiAssetOk)) delete emojiAssetOk[k]
|
||||
if (!Array.isArray(arr)) return
|
||||
for (const em of arr) {
|
||||
const key = String(em?.key || '').trim()
|
||||
if (key) emojiAssetOk[key] = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const emojiChartRows = computed(() => {
|
||||
const wechat = smallWechatEmojiChips.value
|
||||
.slice(0, 4)
|
||||
.map((x, idx) => ({
|
||||
id: `w-${String(x?.key || idx)}`,
|
||||
kind: 'wechat',
|
||||
key: String(x?.key || idx),
|
||||
label: String(x?.key || '').trim() || '微信表情',
|
||||
count: Math.max(0, Number(x?.count || 0)),
|
||||
assetPath: String(x?.assetPath || '').trim()
|
||||
}))
|
||||
|
||||
const unicode = topUnicodeEmojis.value
|
||||
.slice(0, 4)
|
||||
.map((x, idx) => ({
|
||||
id: `u-${String(x?.emoji || idx)}`,
|
||||
kind: 'unicode',
|
||||
key: String(x?.emoji || idx),
|
||||
label: String(x?.emoji || '').trim() || '😀',
|
||||
count: Math.max(0, Number(x?.count || 0)),
|
||||
assetPath: ''
|
||||
}))
|
||||
|
||||
const rows = [...wechat, ...unicode].filter((x) => x.count > 0 && x.label)
|
||||
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||
return rows
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
pct: Math.max(8, Math.round((x.count / maxV) * 100))
|
||||
}))
|
||||
})
|
||||
|
||||
const emojiBubbleRows = computed(() => {
|
||||
const rows = emojiChartRows.value
|
||||
if (!rows.length) return []
|
||||
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||
return rows.map((x) => {
|
||||
const ratio = Math.max(0, Math.min(1, x.count / maxV))
|
||||
const size = Math.round(32 + Math.sqrt(ratio) * 36)
|
||||
return { ...x, size: Math.max(28, Math.min(72, size)) }
|
||||
})
|
||||
})
|
||||
|
||||
const stickerDecorOk = reactive({})
|
||||
watch(
|
||||
() => [...newStickerDecorItems.value, ...revivedStickerDecorItems.value],
|
||||
(arr) => {
|
||||
for (const k of Object.keys(stickerDecorOk)) delete stickerDecorOk[k]
|
||||
if (!Array.isArray(arr)) return
|
||||
for (const x of arr) {
|
||||
const key = String(x?.id || '').trim()
|
||||
if (key) stickerDecorOk[key] = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const avatarOk = reactive({ topStickerOwner: true })
|
||||
watch(heroStickerOwnerAvatarUrl, () => { avatarOk.topStickerOwner = true })
|
||||
|
||||
const avatarFallback = (name) => {
|
||||
const s = String(name || '').trim()
|
||||
return s ? s[0] : '?'
|
||||
}
|
||||
|
||||
const narrativeSegments = computed(() => {
|
||||
const out = []
|
||||
|
||||
if (sentStickerCount.value > 0) {
|
||||
appendParsedText(
|
||||
out,
|
||||
`这一年,你用 ${formatInt(sentStickerCount.value)} 张表情包把聊天变得更有温度;在 ${formatInt(
|
||||
stickerActiveDays.value
|
||||
)} 个活跃日里,日均 ${stickerPerActiveDayText.value} 张。`
|
||||
)
|
||||
} else {
|
||||
appendParsedText(out, '这一年你几乎没发过表情包。')
|
||||
}
|
||||
|
||||
if (peakHour.value !== null && peakWeekdayName.value) {
|
||||
appendParsedText(out, `你最活跃的时刻是 ${peakWeekdayName.value} ${peakHour.value}:00。`)
|
||||
}
|
||||
|
||||
let hasTail = false
|
||||
if (heroSticker.value) {
|
||||
appendParsedText(out, '年度 C 位表情是 ')
|
||||
if (heroStickerUrl.value) {
|
||||
out.push({ type: 'sticker', src: heroStickerUrl.value, alt: '年度 C 位表情', sizeEm: 1.22 })
|
||||
} else {
|
||||
appendParsedText(out, '(图片缺失)')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(heroSticker.value?.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
}
|
||||
|
||||
const topWechat = topWechatEmojis.value[0]
|
||||
const topText = topTextEmojis.value[0]
|
||||
if (topWechat) {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}你最常用的小黄脸是 `)
|
||||
if (topWechat.assetPath) {
|
||||
out.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(topWechat.assetPath),
|
||||
alt: topWechat.key || 'emoji',
|
||||
sizeEm: 1.16
|
||||
})
|
||||
} else {
|
||||
appendParsedText(out, topWechat.key || '')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(topWechat.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
} else if (topText) {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}在文字聊天里,你最常打的小黄脸是 `)
|
||||
if (topText.assetPath) {
|
||||
out.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(topText.assetPath),
|
||||
alt: topText.key || 'emoji',
|
||||
sizeEm: 1.16
|
||||
})
|
||||
} else {
|
||||
appendParsedText(out, topText.key || '')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(topText.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
} else {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}今年没有命中可识别的小黄脸`)
|
||||
hasTail = true
|
||||
}
|
||||
|
||||
const topUnicode = topUnicodeEmojis.value[0]
|
||||
if (topUnicode) {
|
||||
appendParsedText(
|
||||
out,
|
||||
`${hasTail ? ',' : ''}普通 Emoji 最常用 ${topUnicode.emoji}(${formatInt(Number(topUnicode.count || 0))} 次)。`
|
||||
)
|
||||
} else if (hasTail) {
|
||||
appendParsedText(out, '。')
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const cardRoot = ref(null)
|
||||
const isCardVisible = ref(false)
|
||||
const cursorTrails = ref([])
|
||||
const cursorFxEnabled = ref(true)
|
||||
const allWechatEmojiAssets = computed(() => {
|
||||
const values = Object.values(WechatEmojiTable || {})
|
||||
const uniq = new Set()
|
||||
for (const x of values) {
|
||||
const p = resolveEmojiAsset(String(x || '').trim())
|
||||
if (p) uniq.add(p)
|
||||
}
|
||||
return Array.from(uniq)
|
||||
})
|
||||
|
||||
let trailSeq = 0
|
||||
let lastSpawnAt = 0
|
||||
let lastSpawnX = -9999
|
||||
let lastSpawnY = -9999
|
||||
const TRAIL_LIFETIME_MS = 780
|
||||
const MIN_SPAWN_INTERVAL_MS = 28
|
||||
const MIN_SPAWN_DISTANCE = 10
|
||||
const MAX_TRAIL_COUNT = 32
|
||||
|
||||
const checkCardVisible = () => {
|
||||
if (!process.client) return false
|
||||
const rect = cardRoot.value?.getBoundingClientRect?.()
|
||||
if (!rect) return false
|
||||
const vh = window.innerHeight || 0
|
||||
const vw = window.innerWidth || 0
|
||||
return rect.bottom > vh * 0.12 && rect.top < vh * 0.88 && rect.right > 0 && rect.left < vw
|
||||
}
|
||||
|
||||
const spawnCursorTrail = (x, y) => {
|
||||
const pool = allWechatEmojiAssets.value
|
||||
if (!pool.length) return
|
||||
const src = pool[Math.floor(Math.random() * pool.length)]
|
||||
const item = {
|
||||
id: ++trailSeq,
|
||||
x,
|
||||
y,
|
||||
src,
|
||||
size: 18 + Math.floor(Math.random() * 8),
|
||||
driftX: Math.round((Math.random() - 0.5) * 30),
|
||||
driftY: 20 + Math.floor(Math.random() * 20)
|
||||
}
|
||||
cursorTrails.value = [...cursorTrails.value.slice(-MAX_TRAIL_COUNT), item]
|
||||
setTimeout(() => {
|
||||
cursorTrails.value = cursorTrails.value.filter((t) => t.id !== item.id)
|
||||
}, TRAIL_LIFETIME_MS)
|
||||
}
|
||||
|
||||
const onWindowPointerMove = (e) => {
|
||||
if (!process.client || !cursorFxEnabled.value) return
|
||||
if (e?.pointerType === 'touch') return
|
||||
const visible = checkCardVisible()
|
||||
isCardVisible.value = visible
|
||||
if (!visible) return
|
||||
|
||||
const x = Number(e.clientX)
|
||||
const y = Number(e.clientY)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||
|
||||
const now = performance.now()
|
||||
const dx = x - lastSpawnX
|
||||
const dy = y - lastSpawnY
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if ((now - lastSpawnAt) < MIN_SPAWN_INTERVAL_MS && dist < MIN_SPAWN_DISTANCE) return
|
||||
|
||||
lastSpawnAt = now
|
||||
lastSpawnX = x
|
||||
lastSpawnY = y
|
||||
spawnCursorTrail(x, y)
|
||||
}
|
||||
|
||||
const onWindowPointerLeave = () => {
|
||||
lastSpawnX = -9999
|
||||
lastSpawnY = -9999
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
cursorFxEnabled.value = !mq.matches
|
||||
} catch {
|
||||
cursorFxEnabled.value = true
|
||||
}
|
||||
isCardVisible.value = checkCardVisible()
|
||||
window.addEventListener('pointermove', onWindowPointerMove, { passive: true })
|
||||
window.addEventListener('pointerleave', onWindowPointerLeave, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (process.client) {
|
||||
window.removeEventListener('pointermove', onWindowPointerMove)
|
||||
window.removeEventListener('pointerleave', onWindowPointerLeave)
|
||||
}
|
||||
cursorTrails.value = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-inline-emoji {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-cursor-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.emoji-cursor-item {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.94;
|
||||
animation: emoji-cursor-float 780ms ease-out forwards;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
@keyframes emoji-cursor-float {
|
||||
0% {
|
||||
opacity: 0.94;
|
||||
transform: translate(-50%, -50%) scale(0.88);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(-50% + var(--drift-x)), calc(-50% - var(--drift-y))) scale(1.16);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1225
frontend/components/wrapped/shared/VueBitsImageTrail.vue
Normal file
1225
frontend/components/wrapped/shared/VueBitsImageTrail.vue
Normal file
File diff suppressed because it is too large
Load Diff
294
frontend/components/wrapped/shared/VueBitsStack.vue
Normal file
294
frontend/components/wrapped/shared/VueBitsStack.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="vb-stack relative select-none" :style="stackStyle">
|
||||
<div
|
||||
v-for="(card, index) in cards"
|
||||
:key="String(card.id)"
|
||||
class="vb-stack-card absolute top-1/2 left-1/2 overflow-hidden rounded-2xl bg-white/80 shadow-sm"
|
||||
:style="{
|
||||
width: `${dims.width}px`,
|
||||
height: `${dims.height}px`,
|
||||
touchAction: index === cards.length - 1 ? 'none' : 'auto',
|
||||
pointerEvents: index === cards.length - 1 ? 'auto' : 'none'
|
||||
}"
|
||||
:ref="(el) => onCardRef(card.id, el)"
|
||||
@pointerdown="(e) => onPointerDown(e, card.id, index)"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
>
|
||||
<img :src="card.img" class="w-full h-full object-cover" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
type StackCard = {
|
||||
id: string | number
|
||||
img: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
randomRotation?: boolean
|
||||
sensitivity?: number
|
||||
sendToBackOnClick?: boolean
|
||||
cardDimensions?: { width?: number; height?: number }
|
||||
cardsData?: StackCard[]
|
||||
}>(),
|
||||
{
|
||||
randomRotation: false,
|
||||
sensitivity: 180,
|
||||
sendToBackOnClick: true,
|
||||
cardDimensions: () => ({ width: 200, height: 200 }),
|
||||
cardsData: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const dims = computed(() => {
|
||||
const w = Number(props.cardDimensions?.width)
|
||||
const h = Number(props.cardDimensions?.height)
|
||||
return {
|
||||
width: Number.isFinite(w) && w > 0 ? Math.floor(w) : 200,
|
||||
height: Number.isFinite(h) && h > 0 ? Math.floor(h) : 200
|
||||
}
|
||||
})
|
||||
|
||||
const stackStyle = computed(() => ({
|
||||
width: `${dims.value.width}px`,
|
||||
height: `${dims.value.height}px`
|
||||
}))
|
||||
|
||||
const cards = ref<StackCard[]>([])
|
||||
const elMap = new Map<string, HTMLDivElement>()
|
||||
const rotationMap = ref(new Map<string, number>())
|
||||
const mounted = ref(false)
|
||||
|
||||
const ROT_RANGE_DEG = 7
|
||||
const STACK_OFFSET_X = 2
|
||||
const STACK_OFFSET_Y = 4
|
||||
const STACK_SCALE_STEP = 0.03
|
||||
const MAX_TILT_DEG = 22
|
||||
|
||||
function normalizeCards(data: unknown): StackCard[] {
|
||||
const raw = Array.isArray(data) ? data : []
|
||||
const out: StackCard[] = []
|
||||
for (const item of raw) {
|
||||
const id = (item as any)?.id
|
||||
const img = String((item as any)?.img || '').trim()
|
||||
if (id === null || id === undefined) continue
|
||||
if (!img) continue
|
||||
out.push({ id, img })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureRotations(list: StackCard[]) {
|
||||
const next = new Map(rotationMap.value)
|
||||
if (!props.randomRotation) {
|
||||
for (const c of list) next.set(String(c.id), 0)
|
||||
rotationMap.value = next
|
||||
return
|
||||
}
|
||||
for (const c of list) {
|
||||
const key = String(c.id)
|
||||
if (next.has(key)) continue
|
||||
next.set(key, Math.round((Math.random() * 2 - 1) * ROT_RANGE_DEG))
|
||||
}
|
||||
rotationMap.value = next
|
||||
}
|
||||
|
||||
function onCardRef(id: StackCard['id'], el: Element | null) {
|
||||
const key = String(id)
|
||||
if (el && el instanceof HTMLDivElement) elMap.set(key, el)
|
||||
else elMap.delete(key)
|
||||
}
|
||||
|
||||
function applyLayout(animate: boolean) {
|
||||
if (!mounted.value) return
|
||||
const total = cards.value.length
|
||||
if (total === 0) return
|
||||
|
||||
cards.value.forEach((card, idx) => {
|
||||
const key = String(card.id)
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
const orderFromTop = total - 1 - idx
|
||||
const x = orderFromTop * STACK_OFFSET_X
|
||||
const y = orderFromTop * STACK_OFFSET_Y
|
||||
const scale = Math.max(0.88, 1 - orderFromTop * STACK_SCALE_STEP)
|
||||
const rotation = rotationMap.value.get(key) ?? 0
|
||||
|
||||
const tweenVars: gsap.TweenVars = {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
scale,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
zIndex: idx + 1,
|
||||
transformOrigin: 'center center',
|
||||
ease: 'power3.out',
|
||||
duration: animate ? 0.32 : 0
|
||||
}
|
||||
|
||||
gsap.killTweensOf(el)
|
||||
if (animate) gsap.to(el, tweenVars)
|
||||
else gsap.set(el, tweenVars)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.cardsData,
|
||||
(val) => {
|
||||
const nextCards = normalizeCards(val)
|
||||
cards.value = nextCards
|
||||
ensureRotations(nextCards)
|
||||
nextTick(() => applyLayout(false))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.randomRotation, props.cardDimensions?.width, props.cardDimensions?.height] as const,
|
||||
() => {
|
||||
ensureRotations(cards.value)
|
||||
nextTick(() => applyLayout(false))
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
applyLayout(false)
|
||||
})
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
||||
|
||||
let activePointerId: number | null = null
|
||||
let activeCardId: string | null = null
|
||||
let startClientX = 0
|
||||
let startClientY = 0
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startRotationZ = 0
|
||||
let startScale = 1
|
||||
let lastDx = 0
|
||||
let lastDy = 0
|
||||
|
||||
function sendToBack(id: string) {
|
||||
if (cards.value.length < 2) return
|
||||
const list = cards.value.slice()
|
||||
const idx = list.findIndex((c) => String(c.id) === id)
|
||||
if (idx < 0) return
|
||||
const [card] = list.splice(idx, 1)
|
||||
if (!card) return
|
||||
cards.value = [card, ...list]
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent, id: StackCard['id'], index: number) {
|
||||
if (activePointerId !== null) return
|
||||
if (index !== cards.value.length - 1) return
|
||||
|
||||
const key = String(id)
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
activePointerId = e.pointerId
|
||||
activeCardId = key
|
||||
startClientX = e.clientX
|
||||
startClientY = e.clientY
|
||||
startX = Number(gsap.getProperty(el, 'x')) || 0
|
||||
startY = Number(gsap.getProperty(el, 'y')) || 0
|
||||
startRotationZ = Number(gsap.getProperty(el, 'rotation')) || 0
|
||||
startScale = Number(gsap.getProperty(el, 'scale')) || 1
|
||||
lastDx = 0
|
||||
lastDy = 0
|
||||
|
||||
try {
|
||||
el.setPointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
|
||||
gsap.killTweensOf(el)
|
||||
gsap.set(el, { zIndex: 999 })
|
||||
gsap.to(el, { scale: startScale * 1.03, duration: 0.12, ease: 'power2.out' })
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
const key = activeCardId
|
||||
if (!key) return
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
const dx = e.clientX - startClientX
|
||||
const dy = e.clientY - startClientY
|
||||
lastDx = dx
|
||||
lastDy = dy
|
||||
|
||||
const w = Math.max(1, dims.value.width)
|
||||
const h = Math.max(1, dims.value.height)
|
||||
const nx = dx / (w * 0.55)
|
||||
const ny = dy / (h * 0.55)
|
||||
const tiltY = clamp(Math.tanh(nx) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||
const tiltX = clamp(-Math.tanh(ny) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||
|
||||
gsap.set(el, {
|
||||
x: startX + dx,
|
||||
y: startY + dy,
|
||||
rotation: startRotationZ + dx / 18,
|
||||
rotationX: tiltX,
|
||||
rotationY: tiltY
|
||||
})
|
||||
}
|
||||
|
||||
function finishPointer(id: number, shouldSendBack: boolean) {
|
||||
const key = activeCardId
|
||||
activePointerId = null
|
||||
activeCardId = null
|
||||
|
||||
const el = key ? elMap.get(key) : null
|
||||
if (el) {
|
||||
try {
|
||||
el.releasePointerCapture(id)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSendBack && key) sendToBack(key)
|
||||
applyLayout(true)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
const dist = Math.hypot(lastDx, lastDy)
|
||||
const clickLike = dist < 6
|
||||
const shouldSendBack = dist > Number(props.sensitivity || 0) || (props.sendToBackOnClick && clickLike)
|
||||
finishPointer(e.pointerId, shouldSendBack)
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
finishPointer(e.pointerId, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vb-stack {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.vb-stack-card {
|
||||
will-change: transform;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.vb-stack-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
@@ -329,6 +329,10 @@ const PREVIEW_BY_KIND = {
|
||||
'chat/reply_speed': {
|
||||
summary: '回复速度',
|
||||
question: '谁是你愿意秒回的那个人?'
|
||||
},
|
||||
'emoji/annual_universe': {
|
||||
summary: '梗图年鉴',
|
||||
question: '你这一年最常丢出的表情包是哪张?'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user