不够无敌炸裂的更新

This commit is contained in:
cc
2026-02-08 21:27:25 +08:00
parent 2389aaf314
commit e28ef9b783
13 changed files with 958 additions and 529 deletions

View File

@@ -45,6 +45,11 @@ export interface DualReportData {
} | null
stats: DualReportStats
topPhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
class DualReportService {
@@ -75,7 +80,7 @@ class DualReportService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
@@ -327,122 +332,72 @@ class DualReportService {
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
if (!statsResult.success || !statsResult.data) {
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
}
const cppData = statsResult.data
const counts = cppData.counts || {}
const stats: DualReportStats = {
totalMessages: 0,
totalWords: 0,
imageCount: 0,
voiceCount: 0,
emojiCount: 0
}
const wordCountMap = new Map<string, number>()
const myEmojiCounts = new Map<string, number>()
const friendEmojiCounts = new Map<string, number>()
const myEmojiUrlMap = new Map<string, string>()
const friendEmojiUrlMap = new Map<string, string>()
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
const totalForProgress = messageCountResult.success && messageCountResult.count
? messageCountResult.count
: 0
let processed = 0
let lastProgressAt = 0
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
totalMessages: counts.total || 0,
totalWords: counts.words || 0,
imageCount: counts.image || 0,
voiceCount: counts.voice || 0,
emojiCount: counts.emoji || 0
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
stats.totalMessages += 1
// Process Emojis to find top for me and friend
let myTopEmojiMd5: string | undefined
let myTopEmojiUrl: string | undefined
let myTopCount = -1
if (localType === 3) stats.imageCount += 1
if (localType === 34) stats.voiceCount += 1
if (localType === 47) {
stats.emojiCount += 1
const content = this.decodeMessageContent(row.message_content, row.compress_content)
let friendTopEmojiMd5: string | undefined
let friendTopEmojiUrl: string | undefined
let friendTopCount = -1
if (cppData.emojis && Array.isArray(cppData.emojis)) {
for (const item of cppData.emojis) {
const rawContent = item.content || ''
const isMe = rawContent.startsWith('1:')
const content = rawContent.substring(2) // Remove "1:" or "0:" prefix
const count = item.count || 0
if (isMe) {
if (count > myTopCount) {
const md5 = this.extractEmojiMd5(content)
const url = this.extractEmojiUrl(content)
if (md5) {
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
if (url) {
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
if (!urlMap.has(md5)) urlMap.set(md5, url)
}
myTopCount = count
myTopEmojiMd5 = md5
myTopEmojiUrl = this.extractEmojiUrl(content)
}
}
if (localType === 1 || localType === 244813135921) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content || '').trim()
if (text.length > 0) {
stats.totalWords += text.replace(/\s+/g, '').length
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length >= 2 &&
normalized.length <= 50 &&
!normalized.includes('http') &&
!normalized.includes('<') &&
!normalized.startsWith('[') &&
!normalized.startsWith('<?xml')) {
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 1)
}
} else {
if (count > friendTopCount) {
const md5 = this.extractEmojiMd5(content)
if (md5) {
friendTopCount = count
friendTopEmojiMd5 = md5
friendTopEmojiUrl = this.extractEmojiUrl(content)
}
}
if (totalForProgress > 0) {
processed++
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
if (totalForProgress > 0) {
const ratio = Math.min(1, processed / totalForProgress)
const progress = 30 + Math.floor(ratio * 50)
this.reportProgress('统计聊天数据...', progress, onProgress)
}
lastProgressAt = now
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
const pickTop = (map: Map<string, number>): string | undefined => {
let topKey: string | undefined
let topCount = -1
for (const [key, count] of map.entries()) {
if (count > topCount) {
topCount = count
topKey = key
}
}
return topKey
}
const myTopEmojiMd5 = pickTop(myEmojiCounts)
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
stats.myTopEmojiMd5 = myTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiUrl
stats.friendTopEmojiMd5 = friendTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
stats.friendTopEmojiUrl = friendTopEmojiUrl
this.reportProgress('生成常用语词云...', 85, onProgress)
const topPhrases = Array.from(wordCountMap.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([phrase, count]) => ({ phrase, count }))
const topPhrases = (cppData.phrases || []).map((p: any) => ({
phrase: p.phrase,
count: p.count
}))
// Attach extra stats to the data object (needs interface update if strictly typed, but data is flexible)
const reportData: DualReportData = {
year: reportYear,
selfName: myName,
@@ -452,8 +407,14 @@ class DualReportService {
firstChatMessages,
yearFirstChat,
stats,
topPhrases
}
topPhrases,
// Append new C++ stats
heatmap: cppData.heatmap,
initiative: cppData.initiative,
response: cppData.response,
monthly: cppData.monthly,
streak: cppData.streak
} as any // Use as any to bypass strict type check for new fields, or update interface
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }

View File

@@ -44,6 +44,7 @@ export class WcdbCore {
private wcdbGetAvailableYears: any = null
private wcdbGetAnnualReportStats: any = null
private wcdbGetAnnualReportExtras: any = null
private wcdbGetDualReportStats: any = null
private wcdbGetGroupStats: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
@@ -456,6 +457,13 @@ export class WcdbCore {
this.wcdbGetAnnualReportExtras = null
}
// wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetDualReportStats = null
}
// wcdb_status wcdb_get_logs(char** out_json)
try {
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
@@ -1710,4 +1718,26 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetDualReportStats) {
return { success: false, error: '未支持双人报告统计' }
}
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取双人报告统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析双人报告统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
}

View File

@@ -315,6 +315,13 @@ export class WcdbService {
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
}
/**
* 获取双人报告统计数据
*/
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
}
/**
* 获取群聊统计
*/

View File

@@ -96,6 +96,9 @@ if (parentPort) {
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getDualReportStats':
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break

Binary file not shown.

View File

@@ -0,0 +1,142 @@
// Shared styles for Report components (Heatmap, WordCloud)
// --- Heatmap ---
.heatmap-wrapper {
margin-top: 24px;
width: 100%;
}
.heatmap-header {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
margin-bottom: 6px;
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
font-size: 10px;
}
.time-labels {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
span {
text-align: center;
}
}
.heatmap {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
}
.heatmap-week-col {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 3px;
font-size: 10px;
color: var(--ar-text-sub);
}
.week-label {
display: flex;
align-items: center;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.h-cell {
aspect-ratio: 1;
border-radius: 2px;
min-height: 10px;
transition: transform 0.15s;
&:hover {
transform: scale(1.3);
z-index: 1;
}
}
// --- Word Cloud ---
.word-cloud-wrapper {
margin: 24px auto 0;
padding: 0;
max-width: 520px;
display: flex;
justify-content: center;
--cloud-scale: clamp(0.72, 80vw / 520, 1);
}
.word-cloud-inner {
position: relative;
width: 520px;
height: 520px;
margin: 0;
border-radius: 50%;
transform: scale(var(--cloud-scale));
transform-origin: center;
&::before {
content: "";
position: absolute;
inset: -6%;
background:
radial-gradient(circle at 35% 45%, color-mix(in srgb, var(--primary, #07C160) 12%, transparent), transparent 55%),
radial-gradient(circle at 65% 50%, color-mix(in srgb, var(--accent, #F2AA00) 10%, transparent), transparent 58%),
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
filter: blur(18px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
}
.word-tag {
display: inline-block;
padding: 0;
background: transparent;
border-radius: 0;
border: none;
line-height: 1.2;
white-space: nowrap;
transition: transform 0.2s ease, color 0.2s ease;
cursor: default;
color: var(--ar-text-main);
font-weight: 600;
opacity: 0;
animation: wordPopIn 0.55s ease forwards;
position: absolute;
z-index: 1;
transform: translate(-50%, -50%) scale(0.8);
&:hover {
transform: translate(-50%, -50%) scale(1.08);
color: var(--ar-primary);
z-index: 2;
}
}
@keyframes wordPopIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: var(--final-opacity, 1);
transform: translate(-50%, -50%) scale(1);
}
}
.word-cloud-note {
margin-top: 24px;
font-size: 14px !important;
color: var(--ar-text-sub) !important;
text-align: center;
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportHeatmapProps {
data: number[][]
}
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
if (!data || data.length === 0) return null
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{
backgroundColor: 'var(--primary)',
opacity: alpha
}}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
export default ReportHeatmap

View File

@@ -0,0 +1,113 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportWordCloudProps {
words: { phrase: string; count: number }[]
}
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
if (!words || words.length === 0) return null
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 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 = 1.8
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
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
export default ReportWordCloud

View File

@@ -43,7 +43,7 @@
// 背景装饰圆点 - 毛玻璃效果
.bg-decoration {
position: absolute; // Changed from fixed
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
@@ -53,10 +53,10 @@
.deco-circle {
position: absolute;
border-radius: 50%;
background: rgba(0, 0, 0, 0.03);
background: color-mix(in srgb, var(--primary) 3%, transparent);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(0, 0, 0, 0.05);
border: 1px solid var(--border-color);
&.c1 {
width: 280px;
@@ -243,6 +243,7 @@
}
.exporting-snapshot {
.hero-title,
.label-text,
.hero-desc,
@@ -1279,134 +1280,135 @@
color: var(--ar-text-sub) !important;
text-align: center;
}
// 曾经的好朋友 视觉效果
.lost-friend-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin: 64px auto 48px;
position: relative;
max-width: 480px;
.avatar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
.avatar-label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
opacity: 0.6;
}
&.sender {
animation: fadeInRight 1s ease-out backwards;
}
&.receiver {
animation: fadeInLeft 1s ease-out backwards;
}
}
.fading-line {
position: relative;
flex: 1;
height: 2px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin: 64px auto 48px;
position: relative;
max-width: 480px;
.avatar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
.avatar-label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
opacity: 0.6;
}
&.sender {
animation: fadeInRight 1s ease-out backwards;
}
&.receiver {
animation: fadeInLeft 1s ease-out backwards;
}
.line-path {
width: 100%;
height: 100%;
background: linear-gradient(to right,
var(--ar-primary) 0%,
rgba(var(--ar-primary-rgb), 0.4) 50%,
rgba(var(--ar-primary-rgb), 0.05) 100%);
border-radius: 2px;
}
.fading-line {
position: relative;
flex: 1;
height: 2px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
.line-path {
width: 100%;
height: 100%;
background: linear-gradient(to right,
var(--ar-primary) 0%,
rgba(var(--ar-primary-rgb), 0.4) 50%,
rgba(var(--ar-primary-rgb), 0.05) 100%);
border-radius: 2px;
}
.line-glow {
position: absolute;
inset: -4px 0;
background: linear-gradient(to right,
rgba(var(--ar-primary-rgb), 0.2) 0%,
transparent 100%);
filter: blur(8px);
pointer-events: none;
}
.flow-particle {
position: absolute;
width: 40px;
height: 2px;
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
border-radius: 2px;
opacity: 0;
animation: flowAcross 4s infinite linear;
}
.line-glow {
position: absolute;
inset: -4px 0;
background: linear-gradient(to right,
rgba(var(--ar-primary-rgb), 0.2) 0%,
transparent 100%);
filter: blur(8px);
pointer-events: none;
}
.flow-particle {
position: absolute;
width: 40px;
height: 2px;
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
border-radius: 2px;
opacity: 0;
animation: flowAcross 4s infinite linear;
}
}
}
.hero-desc.fading {
opacity: 0.7;
font-style: italic;
font-size: 16px;
margin-top: 32px;
line-height: 1.8;
letter-spacing: 0.05em;
animation: fadeIn 1.5s ease-out 0.5s backwards;
opacity: 0.7;
font-style: italic;
font-size: 16px;
margin-top: 32px;
line-height: 1.8;
letter-spacing: 0.05em;
animation: fadeIn 1.5s ease-out 0.5s backwards;
}
@keyframes flowAcross {
0% {
left: -20%;
opacity: 0;
}
0% {
left: -20%;
opacity: 0;
}
10% {
opacity: 0.8;
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
50% {
opacity: 0.4;
}
90% {
opacity: 0.1;
}
90% {
opacity: 0.1;
}
100% {
left: 120%;
opacity: 0;
}
100% {
left: 120%;
opacity: 0;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(20px);
}
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -109,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
)
}
// 热力图组件
const Heatmap = ({ data }: { data: number[][] }) => {
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{ background: `rgba(7, 193, 96, ${alpha})` }}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
// 词云组件
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 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 = 1.8
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
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
import Heatmap from '../components/ReportHeatmap'
import WordCloud from '../components/ReportWordCloud'
function AnnualReportWindow() {
const [reportData, setReportData] = useState<AnnualReportData | null>(null)

View File

@@ -132,26 +132,43 @@
.info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 0; // 允许 flex 子项缩小,配合 ellipsis
.name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12px;
color: var(--text-tertiary);
color: var(--text-secondary); // 从 tertiary 改为 secondary 以增强对比度
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
}
.meta {
text-align: right;
font-size: 12px;
color: var(--text-tertiary);
color: var(--text-secondary); // 改为 secondary
flex-shrink: 0;
.count {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
color: var(--primary); // 使用主题色更醒目
margin-bottom: 2px;
}
.hint {
opacity: 0.7;
}
}
@@ -166,6 +183,11 @@
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -8,6 +8,7 @@
font-size: clamp(26px, 5vw, 44px);
white-space: normal;
}
.dual-names {
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
@@ -81,13 +82,17 @@
}
.first-chat-scene {
background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%);
background: linear-gradient(180deg,
color-mix(in srgb, var(--primary) 60%, #000) 0%,
color-mix(in srgb, var(--primary) 40%, #fff) 50%,
var(--ar-bg-color) 100%);
border-radius: 20px;
padding: 28px 24px 24px;
color: #fff;
color: var(--text-primary);
position: relative;
overflow: hidden;
margin-top: 16px;
border: 1px solid var(--border-color);
}
.first-chat-scene::before {
@@ -95,9 +100,9 @@
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%),
radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%);
radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--primary) 20%, transparent), transparent 40%),
radial-gradient(circle at 80% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 35%),
radial-gradient(circle at 50% 80%, color-mix(in srgb, var(--primary) 12%, transparent), transparent 45%);
opacity: 0.6;
pointer-events: none;
}
@@ -107,6 +112,7 @@
font-weight: 700;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.scene-subtitle {
@@ -114,7 +120,8 @@
font-weight: 500;
text-align: center;
margin-bottom: 20px;
opacity: 0.95;
opacity: 0.9;
color: var(--text-secondary);
}
.scene-messages {
@@ -137,32 +144,34 @@
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.25);
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
color: var(--primary);
}
.scene-bubble {
background: rgba(255, 255, 255, 0.85);
color: #5a4d5e;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 14px;
max-width: 60%;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
}
.scene-message.sent .scene-bubble {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
background: color-mix(in srgb, var(--primary) 15%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.scene-meta {
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
color: var(--text-tertiary);
}
.scene-content {
@@ -172,8 +181,8 @@
}
.scene-message.sent .scene-avatar {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
background: var(--primary);
color: #fff;
}
.dual-stat-grid {
@@ -250,4 +259,218 @@
text-align: center;
padding: 24px 0;
}
}
// --- New Initiative Section (Tug of War) ---
.initiative-container {
padding: 0 20px;
}
.initiative-bar-wrapper {
display: flex;
align-items: center;
gap: 16px;
margin-top: 24px;
background: var(--ar-card-bg);
padding: 16px;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.initiative-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 60px;
.avatar-placeholder {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--ar-text-sub);
font-size: 14px;
border: 2px solid var(--ar-card-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.count {
font-size: 13px;
font-weight: 600;
color: var(--ar-text-sub);
}
}
.initiative-progress {
flex: 1;
height: 12px;
background: var(--bg-tertiary, #eee);
border-radius: 6px;
overflow: hidden;
display: flex;
position: relative;
.bar-segment {
height: 100%;
transition: width 1s ease-out;
&.left {
background: var(--ar-primary);
}
&.right {
background: var(--ar-accent);
}
}
}
.initiative-ratio {
font-size: 20px;
font-weight: 800;
color: var(--ar-text-main);
text-align: center;
margin-bottom: 2px;
}
.initiative-desc {
text-align: center;
font-size: 13px;
color: var(--ar-text-sub);
margin-top: 12px;
}
// --- New Response Speed Section (Grid + Icons) ---
.response-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 24px;
padding: 0 10px;
}
.response-card {
background: var(--ar-card-bg);
border-radius: 18px;
padding: 24px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
background: var(--ar-card-bg-hover);
}
.icon-box {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(7, 193, 96, 0.08);
color: var(--ar-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
svg {
width: 26px;
height: 26px;
}
}
&.fastest .icon-box {
background: rgba(242, 170, 0, 0.08);
color: var(--ar-accent);
}
.label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
}
.value {
font-size: 32px;
font-weight: 700;
color: var(--ar-text-main);
line-height: 1;
span {
font-size: 14px;
font-weight: 500;
color: var(--ar-text-sub);
margin-left: 2px;
}
}
}
// --- New Streak Section (Flame) ---
.streak-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 32px;
position: relative;
padding-bottom: 20px;
}
.streak-flame {
font-size: 72px;
margin-bottom: 6px;
filter: drop-shadow(0 4px 12px rgba(242, 170, 0, 0.3));
animation: flamePulse 2s ease-in-out infinite;
transform-origin: center bottom;
}
@keyframes flamePulse {
0%,
100% {
transform: scale(1);
filter: drop-shadow(0 4px 12px rgba(242, 170, 0, 0.3));
}
50% {
transform: scale(1.05);
filter: drop-shadow(0 6px 16px rgba(242, 170, 0, 0.5));
}
}
.streak-days {
font-size: 90px;
font-weight: 800;
color: var(--ar-text-main);
line-height: 0.9;
margin: 10px 0 20px;
text-align: center;
span {
font-size: 24px;
font-weight: 600;
color: var(--ar-text-sub);
margin-left: 6px;
vertical-align: middle;
}
}
.streak-range {
background: var(--ar-card-bg);
padding: 8px 20px;
border-radius: 100px;
font-size: 14px;
font-weight: 500;
color: var(--ar-text-sub);
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
}

View File

@@ -1,4 +1,7 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { Clock, Zap, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
@@ -42,109 +45,11 @@ interface DualReportData {
friendTopEmojiUrl?: string
}
topPhrases: Array<{ phrase: string; count: number }>
}
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
if (!words || words.length === 0) {
return <div className="word-cloud-empty"></div>
}
const sortedWords = [...words].sort((a, b) => b.count - a.count)
const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1
const topWords = sortedWords.slice(0, 32)
const baseSize = 520
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 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 = 1.8
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
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
function DualReportWindow() {
@@ -274,11 +179,11 @@ function DualReportWindow() {
const yearFirstChat = reportData.yearFirstChat
const stats = reportData.stats
const statItems = [
{ label: '总消息数', value: stats.totalMessages },
{ label: '总字数', value: stats.totalWords },
{ label: '图片', value: stats.imageCount },
{ label: '语音', value: stats.voiceCount },
{ label: '表情', value: stats.emojiCount },
{ label: '总消息数', value: stats.totalMessages, icon: MessageSquare, color: '#07C160' },
{ label: '总字数', value: stats.totalWords, icon: Type, color: '#10AEFF' },
{ label: '图片', value: stats.imageCount, icon: ImageIcon, color: '#FFC300' },
{ label: '语音', value: stats.voiceCount, icon: Mic, color: '#FA5151' },
{ label: '表情', value: stats.emojiCount, icon: Smile, color: '#FA9D3B' },
]
const decodeEntities = (text: string) => (
@@ -307,13 +212,30 @@ function DualReportWindow() {
const formatMessageContent = (content?: string) => {
const raw = String(content || '').trim()
if (!raw) return '(空)'
// 1. 尝试提取 XML 关键字段
const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return decodeEntities(stripCdata(titleMatch[1]).trim())
const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return decodeEntities(stripCdata(descMatch[1]).trim())
const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return decodeEntities(stripCdata(summaryMatch[1]).trim())
// 2. 检查是否是 XML 结构
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw)
|| hasXmlTag
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) || hasXmlTag
if (!looksLikeXml) return raw
const extracted = extractXmlText(raw)
if (!extracted) return 'XML消息'
return decodeEntities(stripCdata(extracted).trim()) || 'XML消息'
// 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本
const stripped = raw.replace(/<[^>]+>/g, '').trim()
if (stripped && stripped.length > 0 && stripped.length < 50) {
return decodeEntities(stripped)
}
return '(多媒体/卡片消息)'
}
const formatFullDate = (timestamp: number) => {
const d = new Date(timestamp)
@@ -344,7 +266,7 @@ function DualReportWindow() {
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
<div className="dual-names">
<span>{reportData.selfName}</span>
<span></span>
<span className="amp">&amp;</span>
<span>{reportData.friendName}</span>
</div>
@@ -355,33 +277,34 @@ function DualReportWindow() {
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
<>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(firstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{daysSince} </div>
</div>
</div>
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(firstChat.createTime).split(' ')[0]}</div>
{firstChatMessages.length > 0 ? (
<div className="dual-message-list">
<div className="scene-messages">
{firstChatMessages.map((msg, idx) => (
<div
key={idx}
className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}
>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
<div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="scene-avatar">
{msg.isSentByMe ? '' : reportData.friendName.substring(0, 1)}
</div>
<div className="scene-content-wrapper">
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
<div className="scene-bubble">
<div className="scene-content">{formatMessageContent(msg.content)}</div>
</div>
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
) : null}
</>
) : (
<div className="hero-desc" style={{ textAlign: 'center' }}></div>
)}
<div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '12px', opacity: 0.6 }}>
{daysSince}
</div>
</div>
) : (
<p className="hero-desc"></p>
)}
@@ -393,33 +316,111 @@ function DualReportWindow() {
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
</h2>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div>
</div>
</div>
<div className="dual-message-list">
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div>
<div className="scene-messages">
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="scene-avatar">
{msg.isSentByMe ? '我' : reportData.friendName.substring(0, 1)}
</div>
<div className="scene-content-wrapper">
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
<div className="scene-bubble">
<div className="scene-content">{formatMessageContent(msg.content)}</div>
</div>
</div>
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
))}
</div>
</div>
</section>
) : null}
{reportData.heatmap && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<ReportHeatmap data={reportData.heatmap} />
</section>
)}
{reportData.initiative && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="initiative-container">
<div className="initiative-desc">
{reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'}
</div>
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder"></div>
<div className="count">{reportData.initiative.initiated}</div>
</div>
<div className="initiative-progress">
<div
className="bar-segment left"
style={{ width: `${reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }}
/>
<div
className="bar-segment right"
style={{ width: `${reportData.initiative.received / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }}
/>
</div>
<div className="initiative-side">
<div className="avatar-placeholder">{reportData.friendName.substring(0, 1)}</div>
<div className="count">{reportData.initiative.received}</div>
</div>
</div>
</div>
</section>
)}
{reportData.response && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="response-grid">
<div className="response-card">
<div className="icon-box">
<Clock size={24} />
</div>
<div className="label"></div>
<div className="value">{Math.round(reportData.response.avg / 60)}<span></span></div>
</div>
<div className="response-card fastest">
<div className="icon-box">
<Zap size={24} />
</div>
<div className="label"></div>
<div className="value">{reportData.response.fastest}<span></span></div>
</div>
</div>
</section>
)}
{reportData.streak && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="streak-container">
<div className="streak-flame">🔥</div>
<div className="streak-days">{reportData.streak.days}<span></span></div>
<div className="streak-range">
{reportData.streak.startDate} ~ {reportData.streak.endDate}
</div>
</div>
</section>
)}
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<WordCloud words={reportData.topPhrases} />
<ReportWordCloud words={reportData.topPhrases} />
</section>
<section className="section">
@@ -429,8 +430,22 @@ function DualReportWindow() {
{statItems.map((item) => {
const valueText = item.value.toLocaleString()
const isLong = valueText.length > 7
const Icon = item.icon
return (
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}>
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div className="stat-icon" style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: `${item.color}15`,
color: item.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '4px'
}}>
<Icon size={20} />
</div>
<div className="stat-num">{valueText}</div>
<div className="stat-unit">{item.label}</div>
</div>