mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
a79ff81be0
- Move frontend/utils/ to frontend/lib/ to avoid Nuxt unimport scanner incorrectly extracting function parameter names (value, fallback) as module-level exports, which injected phantom imports that broke all client-side JavaScript execution - Update all import paths across 13 files from ~/utils/ to ~/lib/ - Add timeout (5s) and negative cache (60s TTL) to WCDB realtime ensure_connected() to prevent open_account() from hanging indefinitely when the WeChat database is locked - Reorder selectContact() to fire loadMessages() before navigateTo() so the message fetch starts before route navigation triggers Suspense - Add watch: false to SSR useAsyncData calls to prevent unnecessary re-fetching on client-side route changes
485 lines
15 KiB
Vue
485 lines
15 KiB
Vue
<template>
|
||
<div ref="rootEl" class="kw-cloud relative w-full h-full select-none">
|
||
<!-- Words -->
|
||
<div
|
||
class="absolute inset-0"
|
||
:class="shouldAnimate ? 'kw-animate' : ''"
|
||
@pointerdown="onBgPointerDown"
|
||
>
|
||
<div class="kw-cloud-core" :style="{ '--cloud-scale': String(cloudScale) }">
|
||
<div class="kw-cloud-halo" aria-hidden="true" />
|
||
<button
|
||
v-for="(w, idx) in placedWords"
|
||
:key="w.word"
|
||
type="button"
|
||
class="kw-word"
|
||
:class="selectedWord === w.word ? 'kw-word--selected' : ''"
|
||
:style="wordStyle(w, idx)"
|
||
:title="`${w.word} · ${formatInt(w.count)} 次`"
|
||
@pointerdown.stop="selectWord(w.word, $event)"
|
||
>
|
||
<span class="wrapped-privacy-keyword">{{ w.word }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div v-if="placedWords.length === 0" class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<div class="rounded-2xl border border-[#EDEDED] bg-white/70 backdrop-blur px-5 py-4 text-center">
|
||
<div class="wrapped-title text-base text-[#000000e6]">暂无常用语</div>
|
||
<div class="mt-1 wrapped-body text-sm text-[#7F7F7F]">这一年你还没有足够的重复短句来生成常用语词云。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Examples panel -->
|
||
<Teleport to="body">
|
||
<transition name="kw-panel">
|
||
<div
|
||
v-if="selectedInfo"
|
||
class="kw-panel fixed z-[100] w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
|
||
:class="{ 'wrapped-privacy': privacyMode }"
|
||
:style="panelStyle"
|
||
data-no-accel
|
||
@pointerdown.stop
|
||
>
|
||
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
|
||
<div class="min-w-0">
|
||
<div class="wrapped-title text-base text-[#000000e6] truncate">
|
||
<span class="wrapped-privacy-keyword">{{ selectedInfo.word }}</span>
|
||
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} 次</span>
|
||
</div>
|
||
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
|
||
点击其它词,看看它出现的瞬间
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center justify-center w-8 h-8 rounded-full text-[#00000066] hover:bg-[#00000008] transition"
|
||
aria-label="关闭"
|
||
@click="clearSelection"
|
||
>
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="M18 6L6 18" />
|
||
<path d="M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="px-4 py-3 max-h-[220px] overflow-auto">
|
||
<div v-if="selectedInfo.messages.length === 0" class="wrapped-body text-sm text-[#7F7F7F]">
|
||
没找到可展示的例句。
|
||
</div>
|
||
|
||
<div v-else class="space-y-2">
|
||
<div
|
||
v-for="(m, i) in selectedInfo.messages"
|
||
:key="`${selectedInfo.word}-${i}-${m.raw}`"
|
||
class="flex justify-end"
|
||
>
|
||
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
|
||
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words wrapped-privacy-message">
|
||
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
|
||
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
|
||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
|
||
</span>
|
||
</span>
|
||
<span v-else>{{ m.raw }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||
import { usePrivacyStore } from '~/stores/privacy'
|
||
|
||
const props = defineProps({
|
||
keywords: { type: Array, default: () => [] }, // [{word,count,weight}]
|
||
examples: { type: Array, default: () => [] }, // [{word,count,messages:[]}]
|
||
animate: { type: Boolean, default: true },
|
||
reducedMotion: { type: Boolean, default: false }
|
||
})
|
||
|
||
const privacyStore = usePrivacyStore()
|
||
const { privacyMode } = storeToRefs(privacyStore)
|
||
|
||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||
|
||
const rootEl = ref(null)
|
||
const width = ref(0)
|
||
const height = ref(0)
|
||
|
||
let resizeObserver = null
|
||
const updateSize = () => {
|
||
if (!import.meta.client) return
|
||
const rect = rootEl.value?.getBoundingClientRect?.()
|
||
if (!rect) return
|
||
width.value = Math.max(0, Math.round(rect.width || 0))
|
||
height.value = Math.max(0, Math.round(rect.height || 0))
|
||
}
|
||
|
||
const shouldAnimate = computed(() => !!props.animate && !props.reducedMotion)
|
||
|
||
const examplesMap = computed(() => {
|
||
const out = new Map()
|
||
const list = Array.isArray(props.examples) ? props.examples : []
|
||
for (const x of list) {
|
||
const w = String(x?.word || '').trim()
|
||
if (!w) continue
|
||
const cnt = Number(x?.count || 0)
|
||
const msgs = Array.isArray(x?.messages) ? x.messages.map((m) => String(m || '')).filter((m) => m.trim()) : []
|
||
out.set(w, {
|
||
word: w,
|
||
count: Number.isFinite(cnt) ? cnt : 0,
|
||
messages: msgs.slice(0, 3).map((m) => ({ raw: m, segments: parseTextWithEmoji(m) }))
|
||
})
|
||
}
|
||
return out
|
||
})
|
||
|
||
const selectedWord = ref('')
|
||
const mousePos = ref({ x: 0, y: 0 })
|
||
const selectedInfo = computed(() => {
|
||
const w = String(selectedWord.value || '').trim()
|
||
if (!w) return null
|
||
const ex = examplesMap.value.get(w)
|
||
if (ex) return ex
|
||
|
||
// Fallback: if examples missing, still show count from keywords.
|
||
const kw = (Array.isArray(props.keywords) ? props.keywords : []).find((k) => String(k?.word || '').trim() === w)
|
||
const cnt = kw ? Number(kw.count || 0) : 0
|
||
return { word: w, count: Number.isFinite(cnt) ? cnt : 0, messages: [] }
|
||
})
|
||
|
||
const clearSelection = () => { selectedWord.value = '' }
|
||
const selectWord = (w, e) => {
|
||
selectedWord.value = String(w || '').trim()
|
||
if (e && Number.isFinite(e.clientX) && Number.isFinite(e.clientY)) {
|
||
mousePos.value = { x: e.clientX, y: e.clientY }
|
||
}
|
||
}
|
||
|
||
const panelStyle = computed(() => {
|
||
if (!import.meta.client) return {}
|
||
|
||
const vw = window.innerWidth || 0
|
||
const vh = window.innerHeight || 0
|
||
const maxW = 420
|
||
const maxH = 280
|
||
const margin = 12
|
||
|
||
let left = mousePos.value.x + 20
|
||
let top = mousePos.value.y - (maxH / 2)
|
||
|
||
if ((left + maxW) > (vw - margin)) {
|
||
left = mousePos.value.x - maxW - 20
|
||
}
|
||
if (left < margin) left = margin
|
||
|
||
if (top < margin) top = margin
|
||
if ((top + maxH) > (vh - margin)) top = Math.max(margin, vh - maxH - margin)
|
||
|
||
return {
|
||
left: `${Math.round(left)}px`,
|
||
top: `${Math.round(top)}px`
|
||
}
|
||
})
|
||
|
||
const onBgPointerDown = (e) => {
|
||
if (!e) return
|
||
// Clicking blank area closes the panel.
|
||
clearSelection()
|
||
}
|
||
|
||
// Spiral layout (canvas measureText).
|
||
let measureCanvas = null
|
||
let measureCtx = null
|
||
const ensureMeasureCtx = () => {
|
||
if (!import.meta.client) return null
|
||
if (measureCtx) return measureCtx
|
||
measureCanvas = document.createElement('canvas')
|
||
measureCtx = measureCanvas.getContext('2d')
|
||
return measureCtx
|
||
}
|
||
|
||
const placedWords = ref([])
|
||
|
||
const wordStyle = (w, idx) => ({
|
||
left: `${Number(w?.xPct ?? 50)}%`,
|
||
top: `${Number(w?.yPct ?? 50)}%`,
|
||
fontSize: `${Math.max(10, Math.round(Number(w?.fontSize || 14)))}px`,
|
||
fontWeight: Number(w?.fontWeight || 600),
|
||
'--kw-color': String(w?.color || '#111827'),
|
||
'--d': `${Math.max(0, Number(idx || 0) * 40)}ms`
|
||
})
|
||
|
||
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
|
||
const cloudScale = computed(() => {
|
||
// 根据容器宽高自适应放大
|
||
const maxScaleByW = width.value ? clamp(width.value / 520, 0.8, 1.3) : 1
|
||
const maxScaleByH = height.value ? clamp(height.value / 520, 0.8, 1.3) : 1
|
||
return Math.min(maxScaleByW, maxScaleByH)
|
||
})
|
||
const seededRandom = (seed) => {
|
||
const x = Math.sin(seed) * 10000
|
||
return x - Math.floor(x)
|
||
}
|
||
|
||
const layoutWords = async () => {
|
||
if (!import.meta.client) return
|
||
if (!width.value || !height.value) return
|
||
|
||
const ctx = ensureMeasureCtx()
|
||
if (!ctx) return
|
||
|
||
// 允许根据实际容器比例一定程度上拉伸碰撞边界的空间,但不改变布局基础数值逻辑
|
||
const baseW = 560
|
||
const baseH = 560
|
||
|
||
const srcRaw = (Array.isArray(props.keywords) ? props.keywords : [])
|
||
.map((x) => ({
|
||
word: String(x?.word || '').trim(),
|
||
count: Number(x?.count || 0),
|
||
weight: Number(x?.weight || 0)
|
||
}))
|
||
.filter((x) => x.word && Number.isFinite(x.count) && x.count > 0)
|
||
|
||
srcRaw.sort((a, b) => {
|
||
if (b.weight !== a.weight) return b.weight - a.weight
|
||
if (b.count !== a.count) return b.count - a.count
|
||
return a.word.localeCompare(b.word)
|
||
})
|
||
|
||
const topWords = srcRaw.slice(0, 32)
|
||
if (topWords.length === 0) {
|
||
placedWords.value = []
|
||
await nextTick()
|
||
return
|
||
}
|
||
|
||
const maxCount = Math.max(1, Number(topWords[0]?.count || 1))
|
||
const placedItems = []
|
||
const placed = []
|
||
|
||
const canPlace = (x, y, wPct, hPct) => {
|
||
const halfW = wPct / 2
|
||
const halfH = hPct / 2
|
||
const dx = x - 50
|
||
const dy = y - 50
|
||
const dist = Math.sqrt((dx * dx) + (dy * dy))
|
||
const maxR = 49 - Math.max(halfW, halfH)
|
||
if (dist > maxR) return false
|
||
|
||
const pad = 4.0
|
||
for (const p of placedItems) {
|
||
if (
|
||
(x - halfW - pad) < (p.x + (p.w / 2)) &&
|
||
(x + halfW + pad) > (p.x - (p.w / 2)) &&
|
||
(y - halfH - pad) < (p.y + (p.h / 2)) &&
|
||
(y + halfH + pad) > (p.y - (p.h / 2))
|
||
) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
for (let i = 0; i < topWords.length; i += 1) {
|
||
const item = topWords[i]
|
||
const ratio = clamp(item.count / maxCount, 0, 1)
|
||
const fontSize = Math.round(12 + (Math.pow(ratio, 0.65) * 20))
|
||
const fontWeight = i === 0 ? 800 : 600
|
||
|
||
ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif`
|
||
const textW = Math.ceil(ctx.measureText(item.word).width || 0)
|
||
const textH = Math.ceil(fontSize * 1.1)
|
||
const widthPct = (textW / baseW) * 100
|
||
const heightPct = (textH / baseH) * 100
|
||
|
||
let x = 50
|
||
let y = 50
|
||
let placedOk = false
|
||
const tries = i === 0 ? 1 : 420
|
||
|
||
for (let t = 0; t < tries; t += 1) {
|
||
if (i === 0) {
|
||
x = 50
|
||
y = 50
|
||
} else {
|
||
const spiralIndex = i + (t * 0.28)
|
||
const radius = (Math.sqrt(spiralIndex) * 7.6) + ((seededRandom((i * 1000) + t) * 1.2) - 0.6)
|
||
const angle = (spiralIndex * 2.399963) + (seededRandom((i * 2000) + t) * 0.35)
|
||
|
||
// Use an oval shape logic so it spreads wider horizontally over time
|
||
x = 50 + (radius * Math.cos(angle) * 1.3)
|
||
y = 50 + (radius * Math.sin(angle) * 0.7)
|
||
}
|
||
|
||
if (canPlace(x, y, widthPct, heightPct)) {
|
||
placedOk = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if (!placedOk) continue
|
||
|
||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||
const alpha = Math.min(1, Math.max(0.35, 0.35 + (ratio * 0.65)))
|
||
const color = i === 0 ? '#2f3338' : `rgba(45, 49, 54, ${alpha})`
|
||
placed.push({
|
||
word: item.word,
|
||
count: Math.round(Number(item.count) || 0),
|
||
weight: item.weight,
|
||
fontSize,
|
||
fontWeight,
|
||
color,
|
||
xPct: Number(x.toFixed(2)),
|
||
yPct: Number(y.toFixed(2))
|
||
})
|
||
}
|
||
|
||
placedWords.value = placed
|
||
await nextTick()
|
||
}
|
||
|
||
watch(
|
||
() => [width.value, height.value, props.keywords],
|
||
() => {
|
||
if (!import.meta.client) return
|
||
layoutWords()
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
watch(
|
||
() => props.examples,
|
||
() => {
|
||
// Keep selection stable if possible; clear if word no longer exists.
|
||
if (!selectedWord.value) return
|
||
if (!examplesMap.value.get(selectedWord.value) && !(Array.isArray(props.keywords) && props.keywords.find((k) => k?.word === selectedWord.value))) {
|
||
selectedWord.value = ''
|
||
}
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
onMounted(() => {
|
||
privacyStore.init()
|
||
if (!import.meta.client) return
|
||
updateSize()
|
||
if (typeof ResizeObserver !== 'undefined' && rootEl.value) {
|
||
resizeObserver = new ResizeObserver(() => updateSize())
|
||
resizeObserver.observe(rootEl.value)
|
||
} else {
|
||
window.addEventListener('resize', updateSize)
|
||
}
|
||
layoutWords()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
resizeObserver?.disconnect?.()
|
||
resizeObserver = null
|
||
if (import.meta.client) window.removeEventListener('resize', updateSize)
|
||
measureCanvas = null
|
||
measureCtx = null
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.kw-cloud-core {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 100%;
|
||
max-width: 720px;
|
||
height: 480px;
|
||
transform: translate(-50%, -50%) scale(var(--cloud-scale, 1));
|
||
transform-origin: center;
|
||
}
|
||
|
||
.kw-cloud-halo {
|
||
position: absolute;
|
||
inset: -6% -15%;
|
||
background:
|
||
radial-gradient(ellipse at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%),
|
||
radial-gradient(ellipse at 65% 50%, rgba(242, 170, 0, 0.10), transparent 58%),
|
||
radial-gradient(ellipse at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%);
|
||
filter: blur(24px);
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.kw-word {
|
||
position: absolute;
|
||
z-index: 1;
|
||
transform: translate(-50%, -50%);
|
||
line-height: 1.2;
|
||
letter-spacing: 0.02em;
|
||
padding: 4px 8px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
color: var(--kw-color);
|
||
text-shadow: none;
|
||
transition: transform 160ms ease, filter 160ms ease, color 160ms ease;
|
||
opacity: 1;
|
||
}
|
||
|
||
.kw-word:hover {
|
||
transform: translate(-50%, -50%) scale(1.08);
|
||
color: #2b2f35;
|
||
}
|
||
|
||
.kw-word--selected {
|
||
color: #202429;
|
||
transform: translate(-50%, -50%) scale(1.12);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.kw-animate .kw-word {
|
||
opacity: 0;
|
||
animation: kw-pop 450ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||
animation-delay: var(--d, 0ms);
|
||
}
|
||
|
||
@keyframes kw-pop {
|
||
0% {
|
||
opacity: 0;
|
||
transform: translate(-50%, -50%) scale(0.92);
|
||
filter: blur(2px);
|
||
}
|
||
100% {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
filter: blur(0);
|
||
}
|
||
}
|
||
|
||
.kw-panel-enter-active,
|
||
.kw-panel-leave-active {
|
||
transition: opacity 200ms ease, transform 200ms ease;
|
||
}
|
||
.kw-panel-enter-from,
|
||
.kw-panel-leave-to {
|
||
opacity: 0;
|
||
transform: translate(0, 10px);
|
||
}
|
||
</style>
|