Files
WeChatDataAnalysis/frontend/components/wrapped/visualizations/ChatReplayAnimation.vue
2977094657 dbe4d54f10 improvement(wrapped-ui): 完善 Win98 主题的可视化适配并优化聊天回放信息区
- ChatReplayAnimation:时间戳移到顶栏,简化气泡尾巴;补齐 Game Boy/DOS/Win98 顶栏文案颜色与边框风格
- 热力图:新增 win98 配色分支,并为 heatmap/legend 单元加硬边框
- Overview/MessageChars 等可视化补充 Win98 主题样式(键盘/进度条/雷达图等)
2026-02-02 00:07:45 +08:00

435 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="wrapped-chat-replay">
<!-- Top bar -->
<div class="wrapped-chat-replay__top">
<div class="wrapped-chat-replay__top-left">
<div :class="['wrapped-chat-replay__avatar', { 'privacy-blur': privacyMode }]">
<img
v-if="resolvedAvatarUrl && avatarOk"
:src="resolvedAvatarUrl"
alt="avatar"
@error="onAvatarError"
/>
<span v-else class="wrapped-chat-replay__avatar-fallback wrapped-number">
{{ avatarFallback }}
</span>
</div>
<div class="min-w-0">
<div class="wrapped-label text-[10px] text-[#00000066]">{{ label }}</div>
<div class="wrapped-body text-sm text-[#000000e6] truncate" :title="displayName">
{{ displayNameShown }}
</div>
</div>
</div>
<div class="wrapped-chat-replay__top-right">
<div class="wrapped-label text-[10px] text-[#00000066]">聊天回放</div>
<div v-if="showTimestamp" class="wrapped-label text-[10px] text-[#00000055]">
{{ date }} {{ time }}
</div>
</div>
</div>
<!-- Chat area -->
<div class="wrapped-chat-replay__chat">
<div class="wrapped-chat-replay__row">
<div v-if="showTyping" class="wrapped-chat-replay__typing" aria-label="typing">
<span class="wrapped-chat-replay__dot" />
<span class="wrapped-chat-replay__dot" />
<span class="wrapped-chat-replay__dot" />
</div>
<transition name="wrapped-chat-replay-slide">
<div v-if="showBubble" class="wrapped-chat-replay__bubble">
<div :class="['wrapped-chat-replay__bubble-text', { 'privacy-blur': privacyMode }]" :title="content">
{{ typedText }}
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
time: { type: String, default: '' }, // "05:23"
date: { type: String, default: '' }, // "2024-01-15"
displayName: { type: String, default: '' }, // 发送对象名称
maskedName: { type: String, default: '' }, // 脱敏名称
avatarUrl: { type: String, default: '' }, // 头像URL
content: { type: String, default: '' }, // 消息内容
label: { type: String, default: '' }, // "最早的一条" / "最晚的一条"
delay: { type: Number, default: 0 }, // 动画开始延迟ms
privacyMode: { type: Boolean, default: false } // 是否启用隐私模糊
})
const showTyping = ref(false)
const showBubble = ref(false)
const typedText = ref('')
const showTimestamp = ref(false)
const avatarOk = ref(true)
const onAvatarError = () => { avatarOk.value = false }
const displayNameShown = computed(() => String(props.displayName || props.maskedName || '').trim())
// Keep the same behavior as the chat page: media (including avatars) comes from backend :8000 in dev.
const mediaBase = process.client ? 'http://localhost:8000' : ''
const resolveMediaUrl = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) {
// qpic/qlogo are often hotlink-protected; proxy via backend (same as chat page).
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
}
// Most backend fields are like "/api/...", so just prefix.
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
}
const resolvedAvatarUrl = computed(() => resolveMediaUrl(props.avatarUrl))
const avatarFallback = computed(() => {
const s = displayNameShown.value
return s ? s[0] : '?'
})
let timers = []
let typingTimer = null
const cleanup = () => {
for (const t of timers) clearTimeout(t)
timers = []
if (typingTimer) clearTimeout(typingTimer)
typingTimer = null
}
const reset = () => {
showTyping.value = false
showBubble.value = false
typedText.value = ''
showTimestamp.value = false
avatarOk.value = true
}
const startTyping = () => {
const chars = Array.from(String(props.content || ''))
if (chars.length === 0) {
showTimestamp.value = true
return
}
let i = 0
const step = () => {
i += 1
typedText.value = chars.slice(0, i).join('')
if (i >= chars.length) {
showTimestamp.value = true
typingTimer = null
return
}
typingTimer = setTimeout(step, 50)
}
step()
}
const start = () => {
cleanup()
reset()
const base = Math.max(0, Number(props.delay) || 0)
timers.push(setTimeout(() => { showTyping.value = true }, base + 500))
timers.push(setTimeout(() => {
showTyping.value = false
showBubble.value = true
}, base + 1500))
timers.push(setTimeout(() => { startTyping() }, base + 1700))
}
onMounted(start)
onBeforeUnmount(cleanup)
watch(
() => [props.time, props.date, props.displayName, props.maskedName, props.avatarUrl, props.content, props.label, props.delay],
() => start()
)
</script>
<style>
.wrapped-chat-replay {
/* 微信风格 CSS 变量 */
--wr-chat-frame-bg: #EDEDED;
--wr-chat-top-bg: #EDEDED;
--wr-chat-chat-bg: #EDEDED;
--wr-chat-border: transparent;
--wr-chat-bubble-bg: #95EC69;
--wr-chat-bubble-tail: #95EC69;
--wr-chat-bubble-text: #000000;
--wr-chat-typing-bg: #FFFFFF;
--wr-chat-typing-dot: rgba(0, 0, 0, 0.55);
background: var(--wr-chat-frame-bg);
border: none;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.wrapped-chat-replay__top {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--wr-chat-top-bg);
border-bottom: none;
}
.wrapped-chat-replay__top-right {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
text-align: right;
}
.wrapped-chat-replay__top-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.wrapped-chat-replay__avatar {
width: 34px;
height: 34px;
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.08);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.wrapped-chat-replay__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wrapped-chat-replay__avatar-fallback {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
.wrapped-chat-replay__chat {
padding: 10px 12px;
background: var(--wr-chat-chat-bg);
min-height: 90px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.wrapped-chat-replay__row {
display: flex;
justify-content: flex-end;
align-items: flex-end;
gap: 10px;
min-height: 36px;
}
.wrapped-chat-replay__typing,
.wrapped-chat-replay__bubble {
max-width: 82%;
position: relative;
border-radius: 4px;
}
.wrapped-chat-replay__typing {
background: var(--wr-chat-typing-bg);
padding: 10px 12px;
}
.wrapped-chat-replay__bubble {
background: var(--wr-chat-bubble-bg);
padding: 10px 12px;
}
.wrapped-chat-replay__bubble-text {
color: var(--wr-chat-bubble-text) !important;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.wrapped-chat-replay__dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--wr-chat-typing-dot);
display: inline-block;
margin-right: 4px;
animation: wrapped-chat-replay-dot 1s infinite ease-in-out;
}
.wrapped-chat-replay__dot:nth-child(2) { animation-delay: 0.15s; }
.wrapped-chat-replay__dot:nth-child(3) { animation-delay: 0.3s; }
.wrapped-chat-replay__dot:last-child { margin-right: 0; }
@keyframes wrapped-chat-replay-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.55; }
40% { transform: translateY(-3px); opacity: 1; }
}
.wrapped-chat-replay-slide-enter-active {
transition: opacity 220ms ease, transform 220ms ease;
}
.wrapped-chat-replay-slide-enter-from {
opacity: 0;
transform: translateX(18px);
}
.wrapped-chat-replay-slide-enter-to {
opacity: 1;
transform: translateX(0);
}
/* ========== Game Boy 主题 ========== */
.wrapped-theme-gameboy .wrapped-chat-replay {
--wr-chat-frame-bg: #9bbc0f;
--wr-chat-top-bg: #8bac0f;
--wr-chat-chat-bg: #9bbc0f;
--wr-chat-border: #306230;
--wr-chat-bubble-bg: #8bac0f;
--wr-chat-bubble-tail: #8bac0f;
--wr-chat-bubble-text: #0f380f;
--wr-chat-typing-bg: #c5d870;
--wr-chat-typing-dot: #0f380f;
border-width: 4px;
border-radius: 0;
box-shadow:
inset -2px -2px 0 0 #306230,
inset 2px 2px 0 0 #c5d870;
}
.wrapped-theme-gameboy .wrapped-chat-replay__top {
border-bottom-width: 3px;
}
.wrapped-theme-gameboy .wrapped-chat-replay__typing,
.wrapped-theme-gameboy .wrapped-chat-replay__bubble {
border-radius: 0;
border: 3px solid #306230;
box-shadow:
inset -2px -2px 0 0 #306230,
inset 2px 2px 0 0 #9bbc0f;
}
.wrapped-theme-gameboy .wrapped-chat-replay__top .wrapped-label,
.wrapped-theme-gameboy .wrapped-chat-replay__top .wrapped-body {
color: #0f380f !important;
}
.wrapped-theme-gameboy .wrapped-chat-replay__bubble-text {
font-family: var(--font-pixel-10), 'Courier New', monospace;
}
/* ========== DOS 主题 ========== */
.wrapped-theme-dos .wrapped-chat-replay {
--wr-chat-frame-bg: #0a0a0a;
--wr-chat-top-bg: #0a0a0a;
--wr-chat-chat-bg: #0a0a0a;
--wr-chat-border: #33ff33;
--wr-chat-bubble-bg: #0a0a0a;
--wr-chat-bubble-tail: #0a0a0a;
--wr-chat-bubble-text: #33ff33;
--wr-chat-typing-bg: #0a0a0a;
--wr-chat-typing-dot: #33ff33;
box-shadow: 0 0 10px rgba(51, 255, 51, 0.2);
}
.wrapped-theme-dos .wrapped-chat-replay__typing,
.wrapped-theme-dos .wrapped-chat-replay__bubble {
border: 1px solid #33ff33;
box-shadow: 0 0 6px rgba(51, 255, 51, 0.18);
}
.wrapped-theme-dos .wrapped-chat-replay__top .wrapped-label,
.wrapped-theme-dos .wrapped-chat-replay__top .wrapped-body {
color: #33ff33 !important;
text-shadow: 0 0 3px rgba(51, 255, 51, 0.6);
}
.wrapped-theme-dos .wrapped-chat-replay__bubble-text {
font-family: 'Courier New', monospace;
text-shadow: 0 0 3px rgba(51, 255, 51, 0.6);
}
/* ========== Win98 主题 ========== */
.wrapped-theme-win98 .wrapped-chat-replay {
--wr-chat-frame-bg: #c0c0c0;
--wr-chat-top-bg: linear-gradient(90deg, #000080, #1084d0);
--wr-chat-chat-bg: #ffffff;
--wr-chat-border: #808080;
--wr-chat-bubble-bg: #ffffff;
--wr-chat-bubble-tail: #ffffff;
--wr-chat-bubble-text: #000000;
--wr-chat-typing-bg: #ffffff;
--wr-chat-typing-dot: #000000;
border-radius: 0;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
.wrapped-theme-win98 .wrapped-chat-replay__top {
border-bottom: 1px solid #808080;
}
.wrapped-theme-win98 .wrapped-chat-replay__top .wrapped-label,
.wrapped-theme-win98 .wrapped-chat-replay__top .wrapped-body {
/* Title-bar text should be white on the Win98 blue gradient. */
color: #ffffff !important;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.35);
}
.wrapped-theme-win98 .wrapped-chat-replay__avatar-fallback {
color: rgba(255, 255, 255, 0.9);
}
.wrapped-theme-win98 .wrapped-chat-replay__typing,
.wrapped-theme-win98 .wrapped-chat-replay__bubble {
border-radius: 0;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
</style>