From 0f8a9602bdd2dc5d45b3462fa58a15418b42d04d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Tue, 21 Apr 2026 20:20:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=8C=E4=BA=BA=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E5=91=8A[Bug]:=20=E5=8F=8C=E4=BA=BA=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E5=91=8A=E5=9D=8F=E4=BA=86=20Fixes=20#816?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 12 +- src/pages/DualReportWindow.scss | 1144 ++++++------------------ src/pages/DualReportWindow.tsx | 1472 +++++++++++-------------------- 3 files changed, 775 insertions(+), 1853 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 59e092a..bb5d7f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,6 +81,7 @@ function App() { const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' const isAnnualReportWindow = location.pathname === '/annual-report/view' + const isDualReportWindow = location.pathname === '/dual-report/view' const isSettingsRoute = location.pathname === '/settings' const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null const routeLocation = isSettingsRoute @@ -128,7 +129,7 @@ function App() { const body = document.body const appRoot = document.getElementById('app') - if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) { + if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) { root.style.background = 'transparent' body.style.background = 'transparent' body.style.overflow = 'hidden' @@ -145,7 +146,7 @@ function App() { appRoot.style.overflow = '' } } - }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) + }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow]) // 应用主题 useEffect(() => { @@ -166,7 +167,7 @@ function App() { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) + }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow]) // 读取已保存的主题设置 useEffect(() => { @@ -517,6 +518,11 @@ function App() { return } + // 独立双人报告全屏窗口 + if (isDualReportWindow) { + return + } + // 主窗口 - 完整布局 const handleCloseSettings = () => { const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 7eb5ff9..db69701 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -1,353 +1,124 @@ +/* DualReportWindow.scss */ .annual-report-window.dual-report-window { - .hero-title { - font-size: clamp(22px, 4vw, 34px); - white-space: nowrap; - } - .dual-cover-title { - font-size: clamp(26px, 5vw, 44px); - white-space: normal; - } - - .dual-names { - font-size: clamp(24px, 4vw, 40px); - font-weight: 700; - display: flex; - align-items: center; - gap: 12px; - margin: 8px 0 16px; - color: var(--ar-text-main); - - .amp { - color: var(--ar-primary); - } - } - - .dual-info-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - margin-top: 16px; - } - - .dual-info-card { - padding: 16px; - - &.full { - grid-column: 1 / -1; - } - - .info-label { - font-size: 12px; - color: var(--ar-text-sub); - margin-bottom: 8px; - } - - .info-value { - font-size: 16px; - font-weight: 600; - color: var(--ar-text-main); - } - } - - .dual-message-list { - margin-top: 16px; + /* 全局公用样式补充 */ + .center-layout { display: flex; flex-direction: column; - gap: 12px; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; } - .dual-message { - padding: 14px; - - .message-meta { - font-size: 12px; - color: var(--ar-text-sub); - margin-bottom: 6px; - } - - .message-content { - font-size: 14px; - color: var(--ar-text-main); - } - } - - .first-chat-scene { - padding: 18px 16px 16px; - color: var(--ar-text-main); - position: relative; - overflow: hidden; - margin-top: 16px; - } - - .first-chat-scene::before { - display: none; - } - - .scene-title { - font-size: 24px; - font-weight: 700; - text-align: center; - margin-bottom: 8px; - color: var(--ar-text-main); - } - - .scene-subtitle { - font-size: 18px; + .title { + font-size: clamp(3rem, 10vw, 7rem); + font-family: 'PlayfairDisplayLocal', 'CormorantGaramondLocal', serif; + letter-spacing: 0.15em; font-weight: 500; + margin-bottom: 2vh; + color: var(--c-gold-strong); text-align: center; - margin-bottom: 20px; - opacity: 0.9; - color: var(--ar-text-sub); } - .scene-messages { - display: flex; - flex-direction: column; - gap: 16px; - } - - .scene-message { + /* 针对双人报告特有场景(S1-S8)的排版补充 */ + + /* ========================================= + S1: 首次聊天 (First Encounter) + ========================================= */ + #scene-1 .s1-layout { + width: min(880px, 80vw); display: flex; flex-direction: column; align-items: center; - margin-bottom: 32px; + gap: 3vh; + margin-top: 10vh; + } + + .s1-messages { width: 100%; - - &.system { - margin: 16px 0; - - .system-msg-content { - background: rgba(255, 255, 255, 0.05); - padding: 4px 12px; - border-radius: 12px; - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - text-align: center; - max-width: 80%; - } - } - - .scene-meta { - font-size: 10px; - opacity: 0.65; - margin-bottom: 12px; - color: var(--text-tertiary); - text-align: center; - width: 100%; - } - - .scene-body { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - max-width: 100%; - } - - &.sent .scene-body { - flex-direction: row-reverse; - justify-content: flex-start; - } - - &.received .scene-body { - flex-direction: row; - justify-content: flex-start; - } - } - - .scene-avatar { - width: 42px; - height: 42px; - border-radius: 50%; - background: var(--ar-card-bg); - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - color: var(--ar-text-sub); - border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); - overflow: hidden; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - } - - .scene-content-wrapper { + max-width: 600px; display: flex; flex-direction: column; - width: 100%; - max-width: min(78%, 720px); + gap: 1.5vh; } - - .scene-message.sent .scene-content-wrapper { - align-items: flex-end; - } - - .scene-bubble { - color: var(--ar-text-main); - padding: 10px 14px; - width: fit-content; - min-width: 40px; - max-width: 100%; - background: var(--ar-card-bg); - border-radius: 12px; - position: relative; - - &.no-bubble { - background: transparent; - padding: 0; - box-shadow: none; - } - } - - .scene-content { - line-height: 1.5; - font-size: clamp(14px, 1.8vw, 16px); - word-break: break-all; - white-space: pre-wrap; - overflow-wrap: break-word; - line-break: auto; - - .report-emoji-container { - display: inline-block; - vertical-align: middle; - margin: 2px 0; - - .report-emoji-img { - max-width: 120px; - max-height: 120px; - border-radius: 4px; - display: block; - } - } - } - - .scene-avatar.fallback { - font-size: 14px; - } - - .scene-avatar.with-image { - background: transparent; - color: transparent; - } - - .scene-message.sent .scene-avatar { - border-color: rgba(var(--ar-primary-rgb), 0.3); - } - - .dual-stat-grid { + + .s1-message-item { display: flex; - flex-wrap: nowrap; - gap: clamp(60px, 10vw, 120px); - margin: 48px 0 32px; - padding: 0; - justify-content: center; + flex-direction: column; align-items: flex-start; - - &.bottom { - margin-top: 0; - margin-bottom: 48px; - gap: clamp(40px, 6vw, 80px); - } - } - - .dual-stat-card { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - min-width: 140px; - max-width: 280px; - } - - .stat-num { - font-size: clamp(36px, 6vw, 64px); - font-weight: 800; - font-variant-numeric: tabular-nums; - color: var(--ar-primary); - line-height: 1; - white-space: nowrap; - - &.small { - font-size: clamp(24px, 4vw, 40px); - } - } - - .stat-unit { - font-size: 14px; - margin-top: 4px; - opacity: 0.8; - } - - .dual-stat-card.long .stat-num { - font-size: clamp(18px, 2.4vw, 26px); - letter-spacing: -0.02em; - } - - .emoji-row { - display: grid; - grid-template-columns: repeat(2, minmax(260px, 1fr)); - gap: 20px; - margin: 0 -12px; - } - - .emoji-card { - padding: 18px 16px; - display: flex; - flex-direction: column; - gap: 10px; - align-items: center; - justify-content: center; - - img { - width: 64px; - height: 64px; - object-fit: contain; - } - } - - .emoji-title { - font-size: 12px; - color: var(--ar-text-sub); - } - - .emoji-placeholder { - font-size: 12px; - color: var(--ar-text-sub); - word-break: break-all; - text-align: center; - } - - .word-cloud-empty { - color: var(--ar-text-sub); - font-size: 14px; - text-align: center; - padding: 24px 0; - } - - .initiative-container { - padding: 32px 0; width: 100%; - background: transparent; - border: none; + margin-bottom: 2vh; + + &.sent { + align-items: flex-end; + } + } + + .s1-meta { + font-size: 0.8rem; + color: var(--c-text-muted); + margin-bottom: 6px; + font-family: 'SpaceMonoLocal', monospace; + } + + .s1-bubble { + background: rgba(var(--c-gold-rgb), 0.12); + border: 1px solid rgba(var(--c-gold-rgb), 0.2); + padding: 12px 18px; + border-radius: 12px; + color: var(--c-text-bright); + font-size: clamp(0.95rem, 1.2vw, 1.1rem); + line-height: 1.6; + max-width: 80%; + word-break: break-all; + + &.sent { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); + } + } + + /* ========================================= + S2: 作息波纹 (Heatmap) + ========================================= */ + #scene-2 { + .heatmap-wrapper { + margin-top: 5vh; + width: min(1000px, 90vw); + + /* Make heatmap grid fit cinematic dark style */ + .heatmap { + background: transparent !important; + * { border-color: rgba(var(--c-gold-rgb), 0.1) !important; } + } + } + + .s2-active-text { + margin-top: 4vh; + font-size: clamp(1.2rem, 1.5vw, 1.4rem); + color: var(--c-text-soft); + .hl { color: var(--c-gold-strong); font-weight: 600; margin: 0 8px;} + } } + /* ========================================= + S3: 情感的天平 (Initiative) + ========================================= */ + #scene-3 .initiative-container { + width: min(700px, 80vw); + margin-top: 12vh; + } + .initiative-bar-wrapper { display: flex; align-items: center; - gap: 32px; + gap: 20px; width: 100%; padding: 24px 0; - margin-bottom: 24px; position: relative; } - + .initiative-side { display: flex; flex-direction: column; @@ -355,630 +126,245 @@ gap: 12px; min-width: 80px; z-index: 2; - + .avatar-placeholder { - width: 54px; - height: 54px; - border-radius: 18px; - background: var(--bg-tertiary); + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(var(--c-gold-rgb), 0.4); display: flex; align-items: center; justify-content: center; - font-weight: 700; - color: var(--ar-text-sub); - font-size: 16px; - border: 1.5px solid rgba(255, 255, 255, 0.15); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + font-size: 1.2rem; + color: var(--c-text-muted); overflow: hidden; - + img { width: 100%; height: 100%; object-fit: cover; } } - + .count { - font-size: 11px; - font-weight: 500; - opacity: 0.4; - color: var(--ar-text-sub); + font-size: 0.8rem; + color: var(--c-text-muted); } - + .percent { - font-size: 14px; - color: var(--ar-text-main); - font-weight: 800; - opacity: 0.9; + font-size: 1.5rem; + color: var(--c-gold-strong); + font-family: 'SpaceMonoLocal', monospace; } } - + .initiative-progress { flex: 1; - height: 1px; // 线条样式 + height: 1px; position: relative; display: flex; align-items: center; - + .line-bg { position: absolute; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, - transparent 0%, - rgba(255, 255, 255, 0.1) 20%, - rgba(255, 255, 255, 0.1) 80%, - transparent 100%); + left: 0; right: 0; height: 1px; + background: rgba(255, 255, 255, 0.1); } - + .initiative-indicator { position: absolute; - width: 8px; - height: 8px; - background: #fff; + width: 12px; height: 12px; + background: var(--c-gold-strong); border-radius: 50%; transform: translateX(-50%); - transition: left 1.5s cubic-bezier(0.16, 1, 0.3, 1); - box-shadow: - 0 0 10px #fff, - 0 0 20px rgba(255, 255, 255, 0.5), - 0 0 30px var(--ar-primary); - z-index: 3; - - &::before { - content: ''; - position: absolute; - top: -4px; - left: -4px; - right: -4px; - bottom: -4px; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 50%; - animation: pulse 2s infinite; - } + transition: left 1.5s var(--ease-epic); + box-shadow: 0 0 20px var(--c-gold-strong); } } - .initiative-desc { - text-align: center; - font-size: 14px; - color: var(--ar-text-sub); - letter-spacing: 1px; - opacity: 0.6; - background: transparent; - padding: 0; - margin: 0 auto; - font-style: italic; - } - - @keyframes pulse { - 0% { - transform: scale(1); - opacity: 0.8; - } - - 100% { - transform: scale(2); - opacity: 0; - } - } - - - .response-pulse-container { - width: 100%; - padding: 80px 0; - display: flex; - justify-content: center; - } - - .pulse-visual { - position: relative; - width: 420px; - height: 240px; - display: flex; - align-items: center; - justify-content: center; - } - - .pulse-hub { - position: relative; - z-index: 5; + /* ========================================= + S4: 回应的速度 (Echoes) + ========================================= */ + #scene-4 .response-wrapper { display: flex; flex-direction: column; align-items: center; - justify-content: center; - width: 160px; - height: 160px; - background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12) 0%, transparent 75%); + gap: 6vh; + margin-top: 15vh; + } + + .response-circle { + width: 200px; + height: 200px; border-radius: 50%; - box-shadow: 0 0 40px rgba(255, 255, 255, 0.1); - + border: 1px solid rgba(var(--c-gold-rgb), 0.3); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + box-shadow: 0 0 50px rgba(var(--c-gold-rgb), 0.1); + .label { - font-size: 13px; - color: var(--ar-text-sub); - opacity: 0.6; - margin-bottom: 6px; - letter-spacing: 2px; + font-size: 1rem; + color: var(--c-text-muted); + letter-spacing: 0.2em; + margin-bottom: 8px; } - + .value { - font-size: 54px; - font-weight: 950; - color: #fff; - line-height: 1; - text-shadow: 0 0 30px rgba(255, 255, 255, 0.5); - - span { - font-size: 18px; - font-weight: 500; - margin-left: 4px; - opacity: 0.7; - } + font-size: 3.5rem; + color: var(--c-gold-strong); + font-family: 'SpaceMonoLocal', monospace; + text-shadow: 0 0 20px rgba(var(--c-gold-rgb), 0.4); + + span { font-size: 1.2rem; margin-left: 4px; color: var(--c-text-soft); } } } - .pulse-node { - position: absolute; + .response-stats { + display: flex; + gap: 10vw; + + .stat-item { + text-align: center; + .label { color: var(--c-text-muted); font-size: 0.9rem; margin-bottom: 8px; letter-spacing: 0.1em; } + .value { color: var(--c-text-bright); font-size: 1.8rem; font-family: 'SpaceMonoLocal', monospace; } + } + } + + /* ========================================= + S5: 连绵不绝的火花 (Streak) + ========================================= */ + #scene-5 .streak-wrapper { display: flex; flex-direction: column; align-items: center; - z-index: 4; - animation: floatNode 4s ease-in-out infinite; - - &.left { - left: 0; - transform: translateX(-15%); - } - - &.right { - right: 0; - transform: translateX(15%); - animation-delay: -2s; - } - - .label { - font-size: 12px; - color: var(--ar-text-sub); - opacity: 0.5; - margin-bottom: 4px; - } - - .value { - font-size: 24px; - font-weight: 800; - color: var(--ar-text-main); - opacity: 0.95; - - span { - font-size: 13px; - margin-left: 2px; - opacity: 0.6; - } - } + margin-top: 12vh; } - - .pulse-ripple { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - border: 1.5px solid rgba(255, 255, 255, 0.08); - border-radius: 50%; - animation: ripplePulse 8s linear infinite; - pointer-events: none; - - &.one { - animation-delay: 0s; - } - - &.two { - animation-delay: 2.5s; - } - - &.three { - animation-delay: 5s; - } + + .streak-days { + font-size: clamp(6rem, 15vw, 10rem); + color: var(--c-gold-strong); + font-family: 'SpaceMonoLocal', monospace; + text-shadow: 0 0 40px rgba(var(--c-gold-rgb), 0.4); + line-height: 1; + margin-bottom: 10px; } - - @keyframes ripplePulse { - 0% { - width: 140px; - height: 140px; - opacity: 0.5; - } - - 100% { - width: 700px; - height: 700px; - opacity: 0; - } + + .streak-label { + font-size: 1.5rem; + color: var(--c-text-soft); + letter-spacing: 0.5em; + margin-bottom: 4vh; } - - @keyframes floatNode { - - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-16px); - } - } - - .response-note { - text-align: center; - font-size: 14px; - color: var(--ar-text-sub); - opacity: 0.5; - margin-top: 32px; - font-style: italic; - max-width: none; - line-height: 1.6; - } - - .streak-spark-visual.premium { - width: 100%; - height: 400px; - position: relative; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - margin: 20px 0; - overflow: visible; - - .spark-ambient-glow { - position: absolute; - top: 40%; - left: 50%; - transform: translate(-50%, -50%); - width: 600px; - height: 480px; - background: radial-gradient(circle at center, rgba(242, 170, 0, 0.04) 0%, transparent 70%); - filter: blur(60px); - z-index: 1; - pointer-events: none; - } - } - - .spark-core-wrapper { - position: relative; - width: 220px; - height: 280px; + + .streak-dates { display: flex; align-items: center; - justify-content: center; - z-index: 5; - animation: flameSway 6s ease-in-out infinite; - transform-origin: bottom center; - } - - .spark-flame-outer { - position: absolute; - width: 100%; - height: 100%; - background: radial-gradient(ellipse at 50% 85%, rgba(242, 170, 0, 0.15) 0%, transparent 75%); - border-radius: 50% 50% 20% 20% / 80% 80% 30% 30%; - filter: blur(25px); - animation: flickerOuter 4s infinite alternate; - } - - .spark-flame-inner { - position: absolute; - bottom: 20%; - width: 140px; - height: 180px; - background: radial-gradient(ellipse at 50% 90%, rgba(255, 215, 0, 0.2) 0%, transparent 80%); - border-radius: 50% 50% 30% 30% / 85% 85% 25% 25%; - filter: blur(12px); - animation: flickerInner 3s infinite alternate-reverse; - } - - .spark-core { - position: relative; - z-index: 10; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding-bottom: 20px; - - .spark-days { - font-size: 84px; - font-weight: 800; - color: rgba(255, 255, 255, 0.9); - line-height: 1; - letter-spacing: -1px; - text-shadow: - 0 0 15px rgba(255, 255, 255, 0.4), - 0 8px 30px rgba(0, 0, 0, 0.3); - } - - .spark-label { - font-size: 14px; - font-weight: 800; - color: rgba(255, 255, 255, 0.4); - letter-spacing: 6px; - margin-top: 12px; - text-indent: 6px; - } - } - - .streak-bridge.premium { - width: 100%; - max-width: 500px; - display: flex; - align-items: center; - gap: 0; - margin-top: -20px; - z-index: 20; - - .bridge-date { - display: flex; - flex-direction: column; - align-items: center; - position: relative; + gap: 20px; + color: var(--c-text-muted); + font-family: 'SpaceMonoLocal', monospace; + font-size: 0.9rem; + + .line { width: 100px; - - span { - font-size: 13px; - color: var(--ar-text-sub); - opacity: 0.6; - font-weight: 500; - letter-spacing: 0.2px; - position: absolute; - top: 24px; - white-space: nowrap; - } - - .date-orb { - width: 6px; - height: 6px; - background: #fff; - border-radius: 50%; - box-shadow: 0 0 12px var(--ar-accent); - border: 1px solid rgba(252, 170, 0, 0.5); - } - } - - .bridge-line { - flex: 1; - height: 40px; - position: relative; - display: flex; - align-items: center; - - .line-string { - width: 100%; - height: 1.5px; - background: linear-gradient(90deg, - rgba(242, 170, 0, 0) 0%, - rgba(242, 170, 0, 0.6) 20%, - rgba(242, 170, 0, 0.6) 80%, - rgba(242, 170, 0, 0) 100%); - mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%); - } - - .line-glow { - position: absolute; - width: 100%; - height: 8px; - background: radial-gradient(ellipse at center, rgba(242, 170, 0, 0.2) 0%, transparent 80%); - filter: blur(4px); - animation: sparkFlicker 2s infinite alternate; - } + height: 1px; + background: rgba(var(--c-gold-rgb), 0.4); } } - .spark-ember { - position: absolute; - background: #FFD700; - border-radius: 50%; - filter: blur(0.5px); - box-shadow: 0 0 6px #F2AA00; - opacity: 0; - z-index: 4; - - &.one { - width: 3px; - height: 3px; - left: 46%; - animation: emberRise 5s infinite 0s; - } - - &.two { - width: 2px; - height: 2px; - left: 53%; - animation: emberRise 4s infinite 1.2s; - } - - &.three { - width: 4px; - height: 4px; + /* ========================================= + S6: 专属词典 (Lexicon) + ========================================= */ + #scene-6 .word-cloud-wrapper-outer { + margin-top: 15vh; + width: 80vw; + height: 50vh; + position: relative; + + .word-cloud-tabs { + position: absolute; + top: -6vh; left: 50%; - animation: emberRise 6s infinite 2.5s; - } - - &.four { - width: 2.5px; - height: 2.5px; - left: 48%; - animation: emberRise 5.5s infinite 3.8s; + transform: translateX(-50%); + display: flex; + gap: 16px; + z-index: 20; + + .tab-item { + background: transparent; + border: none; + color: var(--c-text-muted); + font-size: 0.9rem; + cursor: pointer; + padding: 4px 12px; + transition: all 0.3s; + + &.active, &:hover { + color: var(--c-gold-strong); + } + } } } - @keyframes flameSway { - - 0%, - 100% { - transform: rotate(-1deg) skewX(-1deg); - } - - 50% { - transform: rotate(1.5deg) skewX(1deg); + /* ========================================= + S7: 绝对数据 (Stats) + ========================================= */ + #scene-7 .stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4vh 8vw; + margin-top: 15vh; + + .stat-card { + text-align: center; + background: transparent; + box-shadow: none; + border: none; + backdrop-filter: none; + padding: 0; + .val { + font-size: 2.5rem; + color: var(--c-gold-strong); + font-family: 'SpaceMonoLocal', monospace; + margin-bottom: 8px; + } + .lbl { + font-size: 0.9rem; + color: var(--c-text-muted); + letter-spacing: 0.1em; + } } } - @keyframes flickerOuter { - - 0%, - 100% { - opacity: 0.15; - filter: blur(25px); - } - - 50% { - opacity: 0.25; - filter: blur(30px); + /* ========================================= + S8: 漫长的归途 / 提取 (Extraction) + ========================================= */ + #scene-8 { + .extract-title { + font-size: clamp(3.5rem, 8vw, 5rem); + margin-top: 0; + color: var(--c-text-bright); } } - @keyframes flickerInner { - - 0%, - 100% { - transform: scale(1); - opacity: 0.2; - } - - 50% { - transform: scale(1.08); - opacity: 0.3; - } - } - - @keyframes emberRise { - 0% { - transform: translateY(100px) scale(1); - opacity: 0; - } - - 20% { - opacity: 0.8; - } - - 80% { - opacity: 0.3; - } - - 100% { - transform: translateY(-260px) scale(0.4); - opacity: 0; - } - } - - @keyframes sparkFlicker { - - 0%, - 100% { - transform: scale(1); - opacity: 0.9; - filter: brightness(1); - } - - 50% { - transform: scale(1.03); - opacity: 1; - filter: brightness(1.2); - } - } - - @media (max-width: 960px) { - .pulse-visual { - transform: scale(0.85); - } - - .scene-avatar { - width: 36px; - height: 36px; - font-size: 13px; - } - - .scene-content-wrapper { - max-width: min(86%, 500px); - } - - .scene-bubble { - max-width: 100%; - min-width: 56px; - } - } - - // Word Cloud Tabs - .word-cloud-section { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - } - - .word-cloud-tabs { - display: flex; - gap: 8px; - background: rgba(255, 255, 255, 0.08); - padding: 4px; - border-radius: 12px; - margin: 0 auto 32px; - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .tab-item { - padding: 8px 16px; - border-radius: 8px; - border: none; + .extract-btn { + margin-top: 10vh; background: transparent; - color: var(--ar-text-sub); - font-size: 14px; - font-weight: 500; + border: 1px solid rgba(var(--c-gold-rgb), 0.4); + color: var(--c-gold-strong); + padding: 16px 42px; + font-size: 1rem; + letter-spacing: 0.25em; + border-radius: 30px; cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; - + transition: all 0.4s var(--ease-epic); + &:hover { - color: var(--ar-text-main); - background: rgba(255, 255, 255, 0.05); - } - - &.active { - background: var(--ar-card-bg); - color: var(--ar-primary); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - font-weight: 600; - } - } - - .word-cloud-container { - width: 100%; - - &.fade-in { - animation: fadeIn 0.4s ease-out; - } - } - - .empty-state { - text-align: center; - padding: 40px 0; - color: var(--ar-text-sub); - opacity: 0.6; - font-size: 14px; - background: rgba(255, 255, 255, 0.03); - border-radius: 16px; - border: 1px dashed rgba(255, 255, 255, 0.1); - margin-top: 20px; - } - - @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); + background: rgba(var(--c-gold-rgb), 0.1); + box-shadow: 0 0 20px rgba(var(--c-gold-rgb), 0.2); } } } diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index e9f9627..81d1d6a 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,10 +1,15 @@ -import { useEffect, useRef, useState } from 'react' -import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react' -import html2canvas from 'html2canvas' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { X, Check } from 'lucide-react' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' import { useThemeStore } from '../stores/themeStore' -import { drawPatternBackground } from '../utils/reportExport' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -70,44 +75,63 @@ interface DualReportData { streak?: { days: number; startDate: string; endDate: string } } -interface SectionInfo { - id: string - name: string - ref: React.RefObject +const DecodeText = ({ value, active }: { value: string | number; active: boolean }) => { + const strVal = String(value) + const [display, setDisplay] = useState(strVal) + const decodedRef = useRef(false) + + useEffect(() => { setDisplay(strVal) }, [strVal]) + + useEffect(() => { + if (!active) { + decodedRef.current = false + return + } + if (decodedRef.current) return + decodedRef.current = true + + const chars = '018X-/#*' + let iter = 0 + const inv = setInterval(() => { + setDisplay(strVal.split('').map((c, i) => { + if (c === ',' || c === ' ' || c === ':') return c + if (i < iter) return strVal[i] + return chars[Math.floor(Math.random() * chars.length)] + }).join('')) + + if (iter >= strVal.length) { + clearInterval(inv) + setDisplay(strVal) + } + iter += 1 / 3 + }, 35) + return () => clearInterval(inv) + }, [active, strVal]) + + return <>{display.length > 0 ? display : value} } function DualReportWindow() { + const navigate = useNavigate() const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const [loadingStage, setLoadingStage] = useState('准备中') const [loadingProgress, setLoadingProgress] = useState(0) + const [loadingStage, setLoadingStage] = useState('正在初始化...') + + const TOTAL_SCENES = 9 + const [currentScene, setCurrentScene] = useState(0) + const [isAnimating, setIsAnimating] = useState(false) + const p0CanvasRef = useRef(null) + const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') + + const containerRef = useRef(null) + + const [buttonText, setButtonText] = useState('EXTRACT RECORD') + const [isExtracting, setIsExtracting] = useState(false) + const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) - const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') - const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState('') - const [showExportModal, setShowExportModal] = useState(false) - const [selectedSections, setSelectedSections] = useState>(new Set()) - const [fabOpen, setFabOpen] = useState(false) - const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') - - const { themeMode } = useThemeStore() - - const sectionRefs = { - cover: useRef(null), - firstChat: useRef(null), - yearFirstChat: useRef(null), - heatmap: useRef(null), - initiative: useRef(null), - response: useRef(null), - streak: useRef(null), - wordCloud: useRef(null), - stats: useRef(null), - ending: useRef(null) - } - - const containerRef = useRef(null) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -124,6 +148,13 @@ function DualReportWindow() { }, []) const generateReport = async (friendUsername: string, year: number) => { + const taskId = registerBackgroundTask({ + sourcePage: 'dualReport', + title: '双人报告生成', + detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 双人年度报告`, + progressText: '初始化', + cancelable: true + }) setIsLoading(true) setError(null) setLoadingProgress(0) @@ -131,407 +162,231 @@ function DualReportWindow() { const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => { setLoadingProgress(payload.progress) setLoadingStage(payload.status) + updateBackgroundTask(taskId, { + detail: payload.status || '正在生成年度报告', + progressText: `${Math.max(0, Math.round(payload.progress || 0))}%` + }) }) try { const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year }) removeProgressListener?.() + + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载' }) + setIsLoading(false) + return + } setLoadingProgress(100) setLoadingStage('完成') if (result.success && result.data) { - const normalizedResponse = result.data.response - ? { - ...result.data.response, - slowest: result.data.response.slowest ?? result.data.response.avg - } - : undefined - setReportData({ - ...result.data, - response: normalizedResponse - }) - setIsLoading(false) + finishBackgroundTask(taskId, 'completed', { detail: '双人报告生成完成' }) + setTimeout(() => { + setReportData(result.data!) + setIsLoading(false) + }, 500) + + if (result.data.stats?.myTopEmojiUrl) { + setMyEmojiUrl(result.data.stats.myTopEmojiUrl) + } + if (result.data.stats?.friendTopEmojiUrl) { + setFriendEmojiUrl(result.data.stats.friendTopEmojiUrl) + } } else { + finishBackgroundTask(taskId, 'failed', { detail: result.error || '生成失败' }) setError(result.error || '生成报告失败') setIsLoading(false) } } catch (e) { removeProgressListener?.() + finishBackgroundTask(taskId, 'failed', { detail: String(e) }) setError(String(e)) setIsLoading(false) } } + const goToScene = useCallback((index: number) => { + if (isAnimating || index === currentScene || index < 0 || index >= TOTAL_SCENES) return + setIsAnimating(true) + setCurrentScene(index) + setTimeout(() => { setIsAnimating(false) }, 1500) + }, [currentScene, isAnimating, TOTAL_SCENES]) + useEffect(() => { - const loadEmojis = async () => { - if (!reportData) return - setMyEmojiUrl(null) - setFriendEmojiUrl(null) - const stats = reportData.stats - if (stats.myTopEmojiUrl) { - const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) - if (res.success && res.localPath) { - setMyEmojiUrl(res.localPath) - } - } - if (stats.friendTopEmojiUrl) { - const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5) - if (res.success && res.localPath) { - setFriendEmojiUrl(res.localPath) - } + if (isLoading || error || !reportData) return + + let touchStartY = 0 + let lastWheelTime = 0 + + const handleWheel = (e: WheelEvent) => { + const now = Date.now() + if (now - lastWheelTime < 1000) return + if (Math.abs(e.deltaY) > 30) { + lastWheelTime = now + goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1) } } - void loadEmojis() - }, [reportData]) + + const handleTouchStart = (e: TouchEvent) => { + touchStartY = e.touches[0].clientY + } + const handleTouchMove = (e: TouchEvent) => { + e.preventDefault() + } + const handleTouchEnd = (e: TouchEvent) => { + const deltaY = touchStartY - e.changedTouches[0].clientY + if (deltaY > 40) goToScene(currentScene + 1) + else if (deltaY < -40) goToScene(currentScene - 1) + } + + window.addEventListener('wheel', handleWheel, { passive: false }) + window.addEventListener('touchstart', handleTouchStart, { passive: false }) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + window.addEventListener('touchend', handleTouchEnd) + + return () => { + window.removeEventListener('wheel', handleWheel) + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + } + }, [currentScene, isLoading, error, reportData, goToScene]) + + useEffect(() => { + if (isLoading || error || !reportData || currentScene !== 0) return + + const canvas = p0CanvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + + canvas.width = canvas.offsetWidth * window.devicePixelRatio + canvas.height = canvas.offsetHeight * window.devicePixelRatio + ctx.scale(window.devicePixelRatio, window.devicePixelRatio) + + let particles = Array.from({ length: 60 }).map(() => ({ + x: Math.random() * canvas.offsetWidth, + y: Math.random() * canvas.offsetHeight, + r: Math.random() * 1.5 + 0.5, + dx: (Math.random() - 0.5) * 0.2, + dy: (Math.random() - 0.5) * 0.2, + alpha: Math.random() * 0.5 + 0.1 + })) + + let rafId: number + const animate = () => { + ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight) + particles.forEach(p => { + p.x += p.dx + p.y += p.dy + if (p.x < 0 || p.x > canvas.offsetWidth) p.dx *= -1 + if (p.y < 0 || p.y > canvas.offsetHeight) p.dy *= -1 + ctx.beginPath() + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2) + ctx.fillStyle = `rgba(242, 170, 0, ${p.alpha})` + ctx.fill() + }) + rafId = requestAnimationFrame(animate) + } + animate() + + return () => cancelAnimationFrame(rafId) + }, [isLoading, error, reportData, currentScene]) + + const getSceneClass = (index: number) => { + if (index === currentScene) return 'scene active' + if (index < currentScene) return 'scene prev' + return 'scene next' + } + + const handleClose = () => { navigate('/home') } const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) - - const sanitizeFileNameSegment = (value: string) => { - const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim() - return sanitized || '好友' + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const waitForNextPaint = () => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) }) + }) + + const captureSceneDataUrl = async (): Promise => { + const captureFn = window.electronAPI.annualReport.captureCurrentWindow + if (typeof captureFn !== 'function') throw new Error('当前版本未启用原生截图接口') + const captureResult = await captureFn() + if (!captureResult.success || !captureResult.dataUrl) throw new Error(captureResult.error || '原生截图失败') + return captureResult.dataUrl } - const getAvailableSections = (): SectionInfo[] => { - if (!reportData) return [] - - const sections: SectionInfo[] = [ - { id: 'cover', name: '封面', ref: sectionRefs.cover }, - { id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat } - ] - - if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) { - sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat }) - } - if (reportData.heatmap) { - sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) - } - if (reportData.initiative) { - sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative }) - } - if (reportData.response) { - sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response }) - } - if (reportData.streak) { - sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak }) - } - - sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud }) - sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats }) - sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) - - return sections - } - - const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { - const element = section.ref.current - if (!element) { - return null - } - - const OUTPUT_WIDTH = 1920 - const OUTPUT_HEIGHT = 1080 - let wordCloudInner: HTMLElement | null = null - let wordTags: NodeListOf | null = null - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - const originalStyle = element.style.cssText - - try { - const selection = window.getSelection() - if (selection && selection.rangeCount > 0) selection.removeAllRanges() - const activeEl = document.activeElement as HTMLElement | null - activeEl?.blur?.() - document.body.classList.add('exporting-snapshot') - document.documentElement.classList.add('exporting-snapshot') - - element.style.minHeight = 'auto' - element.style.padding = '40px 20px' - element.style.background = 'transparent' - element.style.backgroundColor = 'transparent' - element.style.boxShadow = 'none' - - wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null - wordTags = element.querySelectorAll('.word-tag') as NodeListOf - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, index) => { - wordTagOriginalStyles[index] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - await new Promise((resolve) => setTimeout(resolve, 50)) - - const computedStyle = getComputedStyle(document.documentElement) - const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(element, { - backgroundColor: 'transparent', - scale: 2, - useCORS: true, - allowTaint: true, - logging: false, - onclone: (clonedDoc) => { - clonedDoc.body.classList.add('exporting-snapshot') - clonedDoc.documentElement.classList.add('exporting-snapshot') - clonedDoc.getSelection?.()?.removeAllRanges() - } - }) - - const outputCanvas = document.createElement('canvas') - outputCanvas.width = OUTPUT_WIDTH - outputCanvas.height = OUTPUT_HEIGHT - const ctx = outputCanvas.getContext('2d') - if (!ctx) { - return null - } - - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) - - const PADDING = 80 - const contentWidth = OUTPUT_WIDTH - PADDING * 2 - const contentHeight = OUTPUT_HEIGHT - PADDING * 2 - const srcRatio = canvas.width / canvas.height - const dstRatio = contentWidth / contentHeight - - let drawWidth: number - let drawHeight: number - let drawX: number - let drawY: number - - if (srcRatio > dstRatio) { - drawWidth = contentWidth - drawHeight = contentWidth / srcRatio - drawX = PADDING - drawY = PADDING + (contentHeight - drawHeight) / 2 - } else { - drawHeight = contentHeight - drawWidth = contentHeight * srcRatio - drawX = PADDING + (contentWidth - drawWidth) / 2 - drawY = PADDING - } - - ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight) - return { name: section.name, data: outputCanvas.toDataURL('image/png') } - } catch { - return null - } finally { - element.style.cssText = originalStyle - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - wordTags?.forEach((tag, index) => { - tag.style.cssText = wordTagOriginalStyles[index] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - } - } - - const exportFullReport = async (filterIds?: Set) => { - if (!containerRef.current || !reportData) { - return - } - - setIsExporting(true) - setExportProgress('正在生成长图...') - - let wordCloudInner: HTMLElement | null = null - let wordTags: NodeListOf | null = null - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - const container = containerRef.current - const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[] - const originalStyles = sections.map((section) => section.style.cssText) - - try { - const selection = window.getSelection() - if (selection && selection.rangeCount > 0) selection.removeAllRanges() - const activeEl = document.activeElement as HTMLElement | null - activeEl?.blur?.() - document.body.classList.add('exporting-snapshot') - document.documentElement.classList.add('exporting-snapshot') - - sections.forEach((section) => { - section.style.minHeight = 'auto' - section.style.padding = '40px 0' - }) - - if (filterIds) { - getAvailableSections().forEach((section) => { - if (!filterIds.has(section.id) && section.ref.current) { - section.ref.current.style.display = 'none' - } - }) - } - - wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null - wordTags = container.querySelectorAll('.word-tag') as NodeListOf - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, index) => { - wordTagOriginalStyles[index] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - await new Promise((resolve) => setTimeout(resolve, 100)) - - const computedStyle = getComputedStyle(document.documentElement) - const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(container, { - backgroundColor: 'transparent', - scale: 2, - useCORS: true, - allowTaint: true, - logging: false, - onclone: (clonedDoc) => { - clonedDoc.body.classList.add('exporting-snapshot') - clonedDoc.documentElement.classList.add('exporting-snapshot') - clonedDoc.getSelection?.()?.removeAllRanges() - } - }) - - const outputCanvas = document.createElement('canvas') - outputCanvas.width = canvas.width - outputCanvas.height = canvas.height - const ctx = outputCanvas.getContext('2d') - if (!ctx) { - throw new Error('无法创建导出画布') - } - - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) - ctx.drawImage(canvas, 0, 0) - - const yearFilePrefix = formatFileYearLabel(reportData.year) - const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) - const link = document.createElement('a') - link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png` - link.href = outputCanvas.toDataURL('image/png') - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } catch (e) { - alert('导出失败: ' + String(e)) - } finally { - sections.forEach((section, index) => { - section.style.cssText = originalStyles[index] - }) - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - wordTags?.forEach((tag, index) => { - tag.style.cssText = wordTagOriginalStyles[index] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - setIsExporting(false) - setExportProgress('') - } - } - - const exportSelectedSections = async () => { - if (!reportData) return - - const sections = getAvailableSections().filter((section) => selectedSections.has(section.id)) - if (sections.length === 0) { - alert('请至少选择一个板块') - return - } - - if (exportMode === 'long') { - setShowExportModal(false) - await exportFullReport(selectedSections) - setSelectedSections(new Set()) - return - } - - setIsExporting(true) - setShowExportModal(false) - - const exportedImages: Array<{ name: string; data: string }> = [] - - for (let index = 0; index < sections.length; index++) { - const section = sections[index] - setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`) - - const result = await exportSection(section) - if (result) { - exportedImages.push(result) - } - } - - if (exportedImages.length === 0) { - alert('导出失败') - setIsExporting(false) - setExportProgress('') - return - } + const handleExtract = async () => { + if (isExtracting || !reportData || !containerRef.current) return const dirResult = await window.electronAPI.dialog.openDirectory({ title: '选择导出文件夹', properties: ['openDirectory', 'createDirectory'] }) - if (dirResult.canceled || !dirResult.filePaths?.[0]) { - setIsExporting(false) - setExportProgress('') - return + if (dirResult.canceled || !dirResult.filePaths?.[0]) return + + const root = containerRef.current + const sceneNames = [ + 'THE_BINDING', + 'FIRST_ENCOUNTER', + 'SYNCHRONIZATION', + 'MUTUAL_INITIATIVE', + 'ECHOES', + 'THE_SPARK', + 'LEXICON', + 'VOLUME', + 'EXTRACTION' + ] + + setIsExtracting(true) + setButtonText('EXTRACTING...') + + try { + const images: Array<{ name: string; dataUrl: string }> = [] + root.classList.add('exporting-scenes') + await waitForNextPaint() + await wait(120) + await captureSceneDataUrl() + + for (let i = 0; i < TOTAL_SCENES; i++) { + setCurrentScene(i) + setButtonText(`EXTRACTING ${i + 1}/${TOTAL_SCENES}`) + await waitForNextPaint() + await wait(1700) + + images.push({ + name: `P${String(i).padStart(2, '0')}_${sceneNames[i] || 'SCENE'}.png`, + dataUrl: await captureSceneDataUrl() + }) + } + + const yearFilePrefix = formatFileYearLabel(reportData.year) + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${yearFilePrefix}双人报告_分页面`, + images + }) + + if (!exportResult.success) throw new Error(exportResult.error || '导出失败') + + setButtonText('ARCHIVED') + setTimeout(() => setButtonText('EXTRACT RECORD'), 2000) + } catch (err: any) { + alert(err.message || '导出过程中出现错误') + setButtonText('EXTRACT FAILED') + setTimeout(() => setButtonText('EXTRACT RECORD'), 2000) + } finally { + setIsExtracting(false) + root.classList.remove('exporting-scenes') + setCurrentScene(8) } - - setExportProgress('正在写入文件...') - const yearFilePrefix = formatFileYearLabel(reportData.year) - const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) - const exportResult = await window.electronAPI.annualReport.exportImages({ - baseDir: dirResult.filePaths[0], - folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`, - images: exportedImages.map((image) => ({ - name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`, - dataUrl: image.data - })) - }) - - if (!exportResult.success) { - alert('导出失败: ' + (exportResult.error || '未知错误')) - } - - setIsExporting(false) - setExportProgress('') - setSelectedSections(new Set()) - } - - const toggleSection = (id: string) => { - const next = new Set(selectedSections) - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } - setSelectedSections(next) - } - - const toggleAll = () => { - const sections = getAvailableSections() - if (selectedSections.size === sections.length) { - setSelectedSections(new Set()) - return - } - setSelectedSections(new Set(sections.map((section) => section.id))) } if (isLoading) { return ( -
+
+
+ +
@@ -541,629 +396,304 @@ function DualReportWindow() { style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }} /> - {loadingProgress}% + {Math.round(loadingProgress)}%

{loadingStage}

-

进行中

+

DUAL RECORD INIT

) } - if (error) { + if (error || !reportData) { return ( -
-

生成报告失败: {error}

+
+
+ +
+

Report Initialization Failed

+

{error}

) } - if (!reportData) { - return ( -
-

暂无数据

-
- ) - } - - const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年` - const firstChat = reportData.firstChat - const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0) - ? reportData.firstChatMessages.slice(0, 3) - : firstChat - ? [{ - content: firstChat.content, - isSentByMe: firstChat.isSentByMe, - createTime: firstChat.createTime, - createTimeStr: firstChat.createTimeStr - }] - : [] - const daysSince = firstChat - ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) - : null - const yearFirstChat = reportData.yearFirstChat - const stats = reportData.stats - const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0) - const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0 - const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0 - const statItems = [ - { label: '总消息数', value: stats.totalMessages, color: '#07C160' }, - { label: '总字数', value: stats.totalWords, color: '#10AEFF' }, - { label: '图片', value: stats.imageCount, color: '#FFC300' }, - { label: '语音', value: stats.voiceCount, color: '#FA5151' }, - { label: '表情', value: stats.emojiCount, color: '#FA9D3B' }, - ] - - const decodeEntities = (text: string) => ( - text - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - ) - - const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => { - let actualCount = 0 - const result: DualReportMessage[] = [] - for (const msg of messages) { - const isSystem = msg.localType === 10000 || msg.localType === 10002 - if (!isSystem) { - if (actualCount >= maxActual) break - actualCount++ - } - result.push(msg) + const formatFirstChat = (content: string) => { + if (!content) return '' + if (content.includes('')) { + const match = content.match(/([^<]+)<\/title>/) + return match && match[1] ? `[${match[1]}]` : '[富文本消息]' } - return result + return content.trim() } - const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') - const compactMessageText = (text: string) => ( - text - .replace(/\r\n/g, '\n') - .replace(/\s*\n+\s*/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim() - ) - - const extractXmlText = (content: string) => { - const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i) - if (titleMatch?.[1]) return titleMatch[1] - const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i) - if (descMatch?.[1]) return descMatch[1] - const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i) - if (summaryMatch?.[1]) return summaryMatch[1] - const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i) - if (contentMatch?.[1]) return contentMatch[1] - return '' - } - - const formatMessageContent = (content?: string, localType?: number) => { - const isSystemMsg = localType === 10000 || localType === 10002 - if (!isSystemMsg) { - if (localType === 3) return '[图片]' - if (localType === 34) return '[语音]' - if (localType === 43) return '[视频]' - if (localType === 47) return '[表情]' - if (localType === 42) return '[名片]' - if (localType === 48) return '[位置]' - if (localType === 49) return '[链接/文件]' - } - - const raw = compactMessageText(String(content || '').trim()) - if (!raw) return '(空)' - - // 1. 尝试提取 XML 关键字段 - const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i) - if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim())) - - const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i) - if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim())) - - const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i) - if (summaryMatch?.[1]) return compactMessageText(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 - - if (!looksLikeXml) return raw - - // 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本 - const stripped = raw.replace(/<[^>]+>/g, '').trim() - if (stripped && stripped.length > 0 && stripped.length < 50) { - return compactMessageText(decodeEntities(stripped)) - } - - return '[多媒体消息]' - } - - const ReportMessageItem = ({ msg }: { msg: DualReportMessage }) => { - if (msg.localType === 47 && (msg.emojiMd5 || msg.emojiCdnUrl)) { - const emojiUrl = msg.emojiCdnUrl || (msg.emojiMd5 ? `https://emoji.qpic.cn/wx_emoji/${msg.emojiMd5}/0` : '') - if (emojiUrl) { - return ( - <div className="report-emoji-container"> - <img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); - }} /> - <span style={{ display: 'none' }}>[表情]</span> - </div> - ) - } - } - return <span>{formatMessageContent(msg.content, msg.localType)}</span> - } - const formatFullDate = (timestamp: number) => { - const d = new Date(timestamp) - const year = d.getFullYear() - const month = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - const hour = String(d.getHours()).padStart(2, '0') - const minute = String(d.getMinutes()).padStart(2, '0') - return `${year}/${month}/${day} ${hour}:${minute}` - } - - const getMostActiveTime = (data: number[][]) => { - let maxHour = 0 - let maxWeekday = 0 - let maxVal = -1 - data.forEach((row, weekday) => { - row.forEach((value, hour) => { - if (value > maxVal) { - maxVal = value - maxHour = hour - maxWeekday = weekday - } - }) - }) - const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - return { - weekday: weekdayNames[maxWeekday] || '周一', - hour: maxHour, - value: Math.max(0, maxVal) - } - } - - const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null - const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0 - const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl) - const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1)) - const renderSceneAvatar = (isSentByMe: boolean) => { - const avatarUrl = getSceneAvatarUrl(isSentByMe) - if (avatarUrl) { - return ( - <div className="scene-avatar with-image"> - <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" /> - </div> - ) - } - return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div> - } - - const renderMessageList = (messages: DualReportMessage[]) => { - const displayMsgs = filterDisplayMessages(messages) - let lastTime = 0 - const TIME_THRESHOLD = 5 * 60 * 1000 // 5 分钟 - - return displayMsgs.map((msg, idx) => { - const isSystem = msg.localType === 10000 || msg.localType === 10002 - const showTime = idx === 0 || (msg.createTime - lastTime > TIME_THRESHOLD) - lastTime = msg.createTime - - if (isSystem) { - return ( - <div key={idx} className="scene-message system"> - {showTime && ( - <div className="scene-meta"> - {formatFullDate(msg.createTime).split(' ')[1]} - </div> - )} - <div className="system-msg-content"> - <ReportMessageItem msg={msg} /> - </div> - </div> - ) - } - return ( - <div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}> - {showTime && ( - <div className="scene-meta"> - {formatFullDate(msg.createTime).split(' ')[1]} - </div> - )} - <div className="scene-body"> - {renderSceneAvatar(msg.isSentByMe)} - <div className="scene-content-wrapper"> - <div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}> - <div className="scene-content"><ReportMessageItem msg={msg} /></div> - </div> - </div> - </div> - </div> - ) - }) - } + // 计算第一句话数据 + const displayFirstChat = reportData.yearFirstChat || reportData.firstChat + const firstChatArray = (reportData.yearFirstChatMessages || reportData.firstChatMessages || (displayFirstChat ? [displayFirstChat] : [])).slice(0, 3) + + // 聊天火花 + const showSpark = reportData.streak && reportData.streak.days > 0 + // 回复速度 + const avgResponseMins = reportData.response ? reportData.response.avg / 60 : 0 + const fastestResponseSecs = reportData.response ? reportData.response.fastest : 0 + // 主动性 + const initRate = reportData.initiative ? (reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100).toFixed(1) : 50 + + // 当前词云数据 + const currentWordList = activeWordCloudTab === 'shared' ? reportData.topPhrases + : activeWordCloudTab === 'my' ? reportData.myExclusivePhrases + : reportData.friendExclusivePhrases return ( - <div className="annual-report-window dual-report-window"> - <div className="drag-region" /> - - <div className="bg-decoration"> - <div className="deco-circle c1" /> - <div className="deco-circle c2" /> - <div className="deco-circle c3" /> - <div className="deco-circle c4" /> - <div className="deco-circle c5" /> + <div className={`annual-report-window dual-report-window dark-theme`} ref={containerRef}> + <div className="top-controls"> + <button className="close-btn" onClick={handleClose} title="关闭 (Esc)"><X size={16} /></button> </div> - <div className={`fab-container ${fabOpen ? 'open' : ''}`}> - <button - className="fab-item" - onClick={() => { - setFabOpen(false) - setExportMode('separate') - setShowExportModal(true) - }} - title="分模块导出" - > - <Image size={18} /> - </button> - <button - className="fab-item" - onClick={() => { - setFabOpen(false) - setExportMode('long') - setShowExportModal(true) - }} - title="自定义导出长图" - > - <SlidersHorizontal size={18} /> - </button> - <button - className="fab-item" - onClick={() => { - setFabOpen(false) - void exportFullReport() - }} - title="导出长图" - > - <Download size={18} /> - </button> - <button className="fab-main" onClick={() => setFabOpen(!fabOpen)}> - {fabOpen ? <X size={22} /> : <Download size={22} />} - </button> + {/* ============== 背景系统 ============== */} + <div className="cinematic-bg-system"> + <div className="film-grain" /> + <div className="p0-bg-layer" style={{ opacity: currentScene === 0 ? 1 : 0.4 }}> + <canvas ref={p0CanvasRef} className="p0-canvas" /> + <div className="p0-overlay-grad" /> + </div> </div> - {isExporting && ( - <div className="export-overlay"> - <div className="export-progress-modal"> - <div className="export-spinner"> - <div className="spinner-ring"></div> - <Download size={24} className="spinner-icon" /> + <div className="scene-container"> + + {/* S0: THE BINDING */} + <div className={getSceneClass(0)} id="scene-0"> + <div className="center-layout s0-layout"> + <div className="reveal-wrap"> + <div className="reveal-inner en-tag delay-1">WEFLOW · DUAL RECORD</div> + </div> + <div className="reveal-wrap"> + <h1 className="reveal-inner hero-title delay-2"> + <DecodeText value={reportData.year === 0 ? '所有时间' : `${reportData.year}年`} active={currentScene === 0} /> + </h1> + </div> + <div className="reveal-wrap"> + <div className="reveal-inner hero-desc dual-names delay-3" style={{ fontSize: '1.2rem', marginTop: '20px' }}> + <DecodeText value={reportData.selfName || '你'} active={currentScene === 0} /> + <span className="amp">&</span> + <DecodeText value={reportData.friendName || reportData.friendUsername} active={currentScene === 0} /> + </div> </div> - <p className="export-title">正在导出</p> - <p className="export-status">{exportProgress}</p> </div> </div> - )} - {showExportModal && ( - <div className="export-overlay" onClick={() => setShowExportModal(false)}> - <div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}> - <div className="modal-header"> - <h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3> - <button className="close-btn" onClick={() => setShowExportModal(false)}> - <X size={20} /> - </button> - </div> - <div className="section-grid"> - {getAvailableSections().map((section) => ( - <div - key={section.id} - className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`} - onClick={() => toggleSection(section.id)} - > - <div className="card-check"> - {selectedSections.has(section.id) && <Check size={14} />} - </div> - <span>{section.name}</span> + {/* S1: FIRST ENCOUNTER */} + <div className={getSceneClass(1)} id="scene-1"> + <div className="s1-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">FIRST ENCOUNTER</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">故事的开始</h2></div> + <div className="s1-messages reveal-inner delay-3"> + {firstChatArray.map((chat: any, idx: number) => ( + <div key={idx} className={`s1-message-item ${chat.sender === 'self' ? 'sent' : ''}`}> + <span className="s1-meta">{chat.createTimeStr || formatMonthDayTime(chat.timestamp)}</span> + <div className="scene-bubble s1-bubble">{formatFirstChat(chat.content)}</div> </div> ))} </div> - <div className="modal-footer"> - <button className="select-all-btn" onClick={toggleAll}> - {selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'} - </button> - <button - className="confirm-btn" - onClick={() => void exportSelectedSections()} - disabled={selectedSections.size === 0} - > - {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''} - </button> - </div> </div> </div> - )} - <div className="report-scroll-view"> - <div className="report-container" ref={containerRef}> - <section className="section" ref={sectionRefs.cover}> - <div className="label-text">WEFLOW · DUAL REPORT</div> - <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> - <hr className="divider" /> - <div className="dual-names"> - <span>我</span> - <span className="amp">&</span> - <span>{reportData.friendName}</span> + {/* S2: SYNCHRONIZATION */} + <div className={getSceneClass(2)} id="scene-2"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">SYNCHRONIZATION</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">作息波纹</h2></div> + <div className="reveal-wrap"> + <div className="reveal-inner desc delay-3 s2-active-text"> + {reportData.heatmap ? (() => { + let maxVal = 0, maxDay = 0, maxHour = 0; + reportData.heatmap.forEach((dayRow: number[], dayIdx: number) => { + dayRow.forEach((val: number, hourIdx: number) => { + if (val > maxVal) { maxVal = val; maxDay = dayIdx; maxHour = hourIdx; } + }); + }); + const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return <>在<span className="hl">{dayNames[maxDay]}</span>的<span className="hl">{String(maxHour).padStart(2, '0')}:00</span>,我们最为活跃</> + })() : '我们的时空,在这里高频交叠'} + </div> </div> - <p className="hero-desc">每一次对话都值得被珍藏</p> - </section> - - <section className="section" ref={sectionRefs.firstChat}> - <div className="label-text">首次聊天</div> - <h2 className="hero-title">故事的开始</h2> - {firstChat ? ( - <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="scene-messages"> - {renderMessageList(firstChatMessages)} - </div> - ) : ( - <div className="hero-desc" style={{ textAlign: 'center' }}>暂无消息详情</div> - )} - <div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}> - 距离今天已经 {daysSince} 天 - </div> + {reportData.heatmap && ( + <div className="reveal-wrap"> + <div className="heatmap-wrapper reveal-inner delay-3"> + <ReportHeatmap data={reportData.heatmap} /> </div> - ) : ( - <p className="hero-desc">暂无首条消息</p> + </div> )} - </section> + </div> + </div> - {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( - <section className="section" ref={sectionRefs.yearFirstChat}> - <div className="label-text">第一段对话</div> - <h2 className="hero-title"> - {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} - </h2> - <div className="first-chat-scene"> - <div className="scene-title">久别重逢</div> - <div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div> - <div className="scene-messages"> - {renderMessageList(yearFirstChat.firstThreeMessages)} - </div> - </div> - </section> - ) : null} - - {reportData.heatmap && ( - <section className="section" ref={sectionRefs.heatmap}> - <div className="label-text">聊天习惯</div> - <h2 className="hero-title">作息规律</h2> - {mostActive && ( - <p className="hero-desc active-time dual-active-time"> - 在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃({mostActive.value}条) - </p> - )} - <ReportHeatmap data={reportData.heatmap} /> - </section> - )} - - {reportData.initiative && ( - <section className="section" ref={sectionRefs.initiative}> - <div className="label-text">主动性</div> - <h2 className="hero-title">情感的天平</h2> - <div className="initiative-container"> + {/* S3: MUTUAL INITIATIVE */} + <div className={getSceneClass(3)} id="scene-3"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">MUTUAL INITIATIVE</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">情感的天平</h2></div> + + {reportData.initiative && ( + <div className="reveal-wrap"> + <div className="reveal-inner initiative-container delay-3"> <div className="initiative-bar-wrapper"> <div className="initiative-side"> <div className="avatar-placeholder"> - {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'} + {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} /> : reportData.selfName.substring(0, 1) || 'Me'} </div> - <div className="count">{reportData.initiative.initiated}次</div> - <div className="percent">{initiatedPercent.toFixed(1)}%</div> + <div className="count">{reportData.initiative.initiated}</div> + <div className="percent">{initRate}%</div> </div> + <div className="initiative-progress"> <div className="line-bg" /> - <div - className="initiative-indicator" - style={{ left: `${initiatedPercent}%` }} - /> + <div className="initiative-indicator" style={{ left: `${initRate}%` }} /> </div> - <div className="initiative-side"> + + <div className="initiative-side right"> <div className="avatar-placeholder"> - {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)} - </div> - <div className="count">{reportData.initiative.received}次</div> - <div className="percent">{receivedPercent.toFixed(1)}%</div> - </div> - </div> - <div className="initiative-desc"> - {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} - </div> - </div> - </section> - )} - - {reportData.response && ( - <section className="section" ref={sectionRefs.response}> - <div className="label-text">回应速度</div> - <h2 className="hero-title">你说,我在</h2> - <div className="response-pulse-container"> - <div className="pulse-visual"> - <div className="pulse-ripple one" /> - <div className="pulse-ripple two" /> - <div className="pulse-ripple three" /> - - <div className="pulse-node left"> - <div className="label">最快回复</div> - <div className="value">{reportData.response.fastest}<span>秒</span></div> - </div> - - <div className="pulse-hub"> - <div className="label">平均回复</div> - <div className="value">{Math.round(reportData.response.avg / 60)}<span>分</span></div> - </div> - - <div className="pulse-node right"> - <div className="label">最慢回复</div> - <div className="value"> - {reportData.response.slowest > 3600 - ? (reportData.response.slowest / 3600).toFixed(1) - : Math.round(reportData.response.slowest / 60)} - <span>{reportData.response.slowest > 3600 ? '时' : '分'}</span> + {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} /> : reportData.friendName.substring(0, 1)} </div> + <div className="count">{reportData.initiative.received}</div> + <div className="percent">{(100 - parseFloat(initRate as any)).toFixed(1)}%</div> </div> </div> </div> - <p className="hero-desc response-note"> - {`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`} - </p> - </section> - )} - - {reportData.streak && ( - <section className="section" ref={sectionRefs.streak}> - <div className="label-text">聊天火花</div> - <h2 className="hero-title">最长连续聊天</h2> - <div className="streak-spark-visual premium"> - <div className="spark-ambient-glow" /> - - <div className="spark-ember one" /> - <div className="spark-ember two" /> - <div className="spark-ember three" /> - <div className="spark-ember four" /> - - <div className="spark-core-wrapper"> - <div className="spark-flame-outer" /> - <div className="spark-flame-inner" /> - <div className="spark-core"> - <div className="spark-days">{reportData.streak.days}</div> - <div className="spark-label">DAYS</div> - </div> - </div> - - <div className="streak-bridge premium"> - <div className="bridge-date start"> - <div className="date-orb" /> - <span>{reportData.streak.startDate}</span> - </div> - <div className="bridge-line"> - <div className="line-glow" /> - <div className="line-string" /> - </div> - <div className="bridge-date end"> - <span>{reportData.streak.endDate}</span> - <div className="date-orb" /> - </div> - </div> - </div> - </section> - )} - - <section className="section word-cloud-section" ref={sectionRefs.wordCloud}> - <div className="label-text">常用语</div> - <h2 className="hero-title">{yearTitle}常用语</h2> - - <div className="word-cloud-tabs"> - <button - className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`} - onClick={() => setActiveWordCloudTab('shared')} - > - 共用词汇 - </button> - <button - className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`} - onClick={() => setActiveWordCloudTab('my')} - > - 我的专属 - </button> - <button - className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`} - onClick={() => setActiveWordCloudTab('friend')} - > - TA的专属 - </button> </div> - - <div className={`word-cloud-container fade-in ${activeWordCloudTab}`}> - {activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />} - {activeWordCloudTab === 'my' && ( - reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? ( - <ReportWordCloud words={reportData.myExclusivePhrases} /> - ) : ( - <div className="empty-state">暂无专属词汇</div> - ) - )} - {activeWordCloudTab === 'friend' && ( - reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? ( - <ReportWordCloud words={reportData.friendExclusivePhrases} /> - ) : ( - <div className="empty-state">暂无专属词汇</div> - ) - )} - </div> - </section> - - <section className="section" ref={sectionRefs.stats}> - <div className="label-text">年度统计</div> - <h2 className="hero-title">{yearTitle}数据概览</h2> - <div className="dual-stat-grid"> - {statItems.slice(0, 2).map((item) => ( - <div key={item.label} className="dual-stat-card"> - <div className="stat-num">{item.value.toLocaleString()}</div> - <div className="stat-unit">{item.label}</div> - </div> - ))} - </div> - <div className="dual-stat-grid bottom"> - {statItems.slice(2).map((item) => ( - <div key={item.label} className="dual-stat-card"> - <div className="stat-num small">{item.value.toLocaleString()}</div> - <div className="stat-unit">{item.label}</div> - </div> - ))} - </div> - - <div className="emoji-row"> - <div className="emoji-card"> - <div className="emoji-title">我常用的表情</div> - {myEmojiUrl ? ( - <img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => { - (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); - (e.target as HTMLImageElement).style.display = 'none'; - }} /> - ) : null} - <div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}> - {stats.myTopEmojiMd5 || '暂无'} - </div> - <div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}</div> - </div> - <div className="emoji-card"> - <div className="emoji-title">{reportData.friendName}常用的表情</div> - {friendEmojiUrl ? ( - <img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => { - (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); - (e.target as HTMLImageElement).style.display = 'none'; - }} /> - ) : null} - <div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}> - {stats.friendTopEmojiMd5 || '暂无'} - </div> - <div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}</div> - </div> - </div> - </section> - - <section className="section" ref={sectionRefs.ending}> - <div className="label-text">尾声</div> - <h2 className="hero-title">谢谢你一直在</h2> - <p className="hero-desc">愿我们继续把故事写下去</p> - </section> + )} + </div> </div> + + {/* S4: ECHOES */} + <div className={getSceneClass(4)} id="scene-4"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">ECHOES</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">回应的速度</h2></div> + + <div className="reveal-wrap"> + <div className="reveal-inner response-wrapper delay-3"> + <div className="response-circle"> + <span className="label">AVG RESPONSE</span> + <span className="value"><DecodeText value={avgResponseMins.toFixed(1)} active={currentScene === 4}/><span>m</span></span> + </div> + + <div className="response-stats"> + <div className="stat-item"> + <div className="label">FASTEST</div> + <div className="value"><DecodeText value={fastestResponseSecs} active={currentScene === 4}/>s</div> + </div> + </div> + </div> + </div> + </div> + </div> + + {/* S5: THE SPARK */} + <div className={getSceneClass(5)} id="scene-5"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">THE SPARK</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">连绵不绝的火花</h2></div> + + {showSpark && reportData.streak ? ( + <div className="reveal-wrap"> + <div className="reveal-inner streak-wrapper delay-3"> + <span className="streak-days"><DecodeText value={reportData.streak.days} active={currentScene === 5}/></span> + <span className="streak-label">DAYS STREAK</span> + <div className="streak-dates"> + {reportData.streak.startDate} + <div className="line" /> + {reportData.streak.endDate} + </div> + </div> + </div> + ) : ( + <div className="reveal-wrap"><p className="reveal-inner desc delay-3" style={{marginTop:"3vh"}}>火种尚未点亮...</p></div> + )} + </div> + </div> + + {/* S6: LEXICON */} + <div className={getSceneClass(6)} id="scene-6"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">LEXICON</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">专属词典</h2></div> + + <div className="reveal-wrap"> + <div className="reveal-inner word-cloud-wrapper-outer delay-3"> + <div className="word-cloud-tabs"> + <button className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('shared')}>共同</button> + <button className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('my')}>我方</button> + <button className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('friend')}>对方</button> + </div> + {currentWordList && currentWordList.length > 0 ? ( + <ReportWordCloud words={currentWordList} /> + ) : ( + <div style={{textAlign: 'center', marginTop: '10vh', color: 'var(--c-text-muted)'}}>没有足够的词汇数据</div> + )} + </div> + </div> + </div> + </div> + + {/* S7: VOLUME */} + <div className={getSceneClass(7)} id="scene-7"> + <div className="center-layout"> + <div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">VOLUME</div></div> + <div className="reveal-wrap"><h2 className="reveal-inner title delay-2">数据归档</h2></div> + + <div className="reveal-wrap"> + <div className="reveal-inner stats-grid delay-3" style={{ background: 'transparent' }}> + <div className="stat-card"> + <div className="val"><DecodeText value={reportData.stats.totalMessages} active={currentScene === 7}/></div> + <div className="lbl">MESSAGES</div> + </div> + <div className="stat-card"> + <div className="val"><DecodeText value={reportData.stats.totalWords} active={currentScene === 7}/></div> + <div className="lbl">WORDS</div> + </div> + <div className="stat-card"> + <div className="val"><DecodeText value={reportData.stats.imageCount} active={currentScene === 7}/></div> + <div className="lbl">IMAGES</div> + </div> + <div className="stat-card"> + <div className="val"><DecodeText value={reportData.stats.emojiCount} active={currentScene === 7}/></div> + <div className="lbl">EMOJIS</div> + </div> + </div> + </div> + </div> + </div> + + {/* S8: EXTRACTION */} + <div className={getSceneClass(8)} id="scene-8"> + <div className="center-layout"> + <div className="reveal-wrap"> + <h1 className="reveal-inner extract-title delay-1">ARCHIVED</h1> + </div> + <div className="reveal-wrap"> + <p className="reveal-inner desc delay-2">WeFlow</p> + </div> + <div className="reveal-wrap"> + <button + className={`reveal-inner extract-btn delay-3 ${isExtracting ? 'processing' : ''}`} + onClick={handleExtract} + disabled={isExtracting} + > + {buttonText} + </button> + </div> + </div> + </div> + + </div> + + {/* 底部导航点 */} + <div className="scene-nav"> + {Array.from({ length: TOTAL_SCENES }).map((_, i) => ( + <div + key={i} + className={`nav-dot ${i === currentScene ? 'active' : ''}`} + onClick={() => goToScene(i)} + /> + ))} </div> </div> )