diff --git a/src/App.tsx b/src/App.tsx index 6265a8b..59e092a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,6 +80,7 @@ function App() { const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' + const isAnnualReportWindow = location.pathname === '/annual-report/view' const isSettingsRoute = location.pathname === '/settings' const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null const routeLocation = isSettingsRoute @@ -127,7 +128,7 @@ function App() { const body = document.body const appRoot = document.getElementById('app') - if (isOnboardingWindow || isNotificationWindow) { + if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) { root.style.background = 'transparent' body.style.background = 'transparent' body.style.overflow = 'hidden' @@ -144,7 +145,7 @@ function App() { appRoot.style.overflow = '' } } - }, [isOnboardingWindow]) + }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) // 应用主题 useEffect(() => { @@ -165,7 +166,7 @@ function App() { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow]) + }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) // 读取已保存的主题设置 useEffect(() => { @@ -511,6 +512,11 @@ function App() { return } + // 独立年度报告全屏窗口 + if (isAnnualReportWindow) { + return + } + // 主窗口 - 完整布局 const handleCloseSettings = () => { const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index 88c7c88..3f786b8 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1,163 +1,603 @@ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600&family=Noto+Serif+SC:wght@300;400;500;600&family=Inter:wght@200;300;400;500;700&family=Space+Mono:wght@400;700&display=swap'); + .annual-report-window { - // 使用全局主题变量,带回退值 - --ar-primary: var(--primary, #07C160); - --ar-primary-rgb: var(--primary-rgb, 7, 193, 96); - --ar-accent: var(--accent, #F2AA00); - --ar-accent-rgb: 242, 170, 0; - --ar-text-main: var(--text-primary, #222222); - --ar-text-sub: var(--text-secondary, #555555); - --ar-bg-color: var(--bg-primary, #F9F8F6); - --ar-card-bg: var(--bg-secondary, rgba(255, 255, 255, 0.5)); - --ar-card-bg-hover: var(--bg-tertiary, rgba(255, 255, 255, 0.8)); - --ar-rank-bg: var(--bg-secondary, #f0f0f0); - --ar-rank-color: var(--text-secondary, #666); + --c-bg: #050505; + --c-text: #FFFFFF; + --c-text-muted: rgba(255, 255, 255, 0.4); + /* 顶级平滑缓动曲线 */ + --ease-epic: cubic-bezier(0.76, 0, 0.24, 1); + --ease-out: cubic-bezier(0.25, 1, 0.5, 1); - width: 100%; - height: 100vh; - background: var(--chat-pattern); - background-color: var(--ar-bg-color); - // overflow-y: auto; // Moved to .report-scroll-view - overflow: hidden; // Contain everything - position: relative; - -webkit-app-region: no-drag; // 确保主容器不可拖动 - - // 隐藏滚动条 - /* scrollbar-width: none; */ - // Moved - /* -ms-overflow-style: none; */ -} - -.report-scroll-view { - position: absolute; - inset: 0; - overflow-y: auto; - overflow-x: hidden; - z-index: 1; - - // 隐藏滚动条 - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } -} - -// 背景装饰圆点 - 毛玻璃效果 -.bg-decoration { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 0; + background-color: var(--c-bg); + color: var(--c-text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; overflow: hidden; -} - -.deco-circle { - position: absolute; - border-radius: 50%; - background: rgba(var(--ar-primary-rgb), 0.03); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); - border: 1px solid var(--border-color); - - &.c1 { - width: 280px; - height: 280px; - top: -80px; - right: -60px; - animation: float1 20s ease-in-out infinite; - } - - &.c2 { - width: 200px; - height: 200px; - bottom: 15%; - left: -70px; - animation: float2 25s ease-in-out infinite; - } - - &.c3 { - width: 120px; - height: 120px; - top: 45%; - right: -40px; - animation: float3 18s ease-in-out infinite; - } - - &.c4 { - width: 90px; - height: 90px; - top: 25%; - left: 8%; - animation: float1 22s ease-in-out infinite reverse; - } - - &.c5 { - width: 60px; + overscroll-behavior: none; + height: 100vh; + width: 100vw; + position: relative; + -webkit-app-region: no-drag; // 默认不可拖拽,给特定区域开放 + + // 顶部拖动控制条与关闭按钮 + .top-controls { + position: absolute; + top: 0; + left: 0; + width: 100%; height: 60px; - bottom: 25%; - right: 12%; - animation: float2 15s ease-in-out infinite reverse; - } -} + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0 20px; + z-index: 10000; + -webkit-app-region: drag; -@keyframes float1 { + .close-btn { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.6); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + -webkit-app-region: no-drag; + transition: all 0.3s var(--ease-out); - 0%, - 100% { - transform: translate(0, 0); + &:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; + transform: scale(1.05); + } + } } - 50% { - transform: translate(-15px, 15px); - } -} - -@keyframes float2 { - - 0%, - 100% { - transform: translate(0, 0); - } - - 50% { - transform: translate(12px, -12px); - } -} - -@keyframes float3 { - - 0%, - 100% { - transform: translate(0, 0); - } - - 50% { - transform: translate(-8px, -15px); - } -} - -.annual-report-window { - - // 所有子元素默认不可拖动 - * { - -webkit-app-region: no-drag; - } - - // 背景渐变灯光 - &::before { - content: ""; - position: fixed; + /* 细微的电影噪点 */ + .film-grain { + position: absolute; inset: 0; - background: - radial-gradient(circle 500px at 0% 0%, rgba(7, 193, 96, 0.06), transparent), - radial-gradient(circle 500px at 100% 0%, rgba(242, 170, 0, 0.05), transparent), - radial-gradient(circle 500px at 0% 100%, rgba(242, 170, 0, 0.05), transparent), - radial-gradient(circle 500px at 100% 100%, rgba(7, 193, 96, 0.06), transparent); + z-index: 9999; + opacity: 0.04; pointer-events: none; - z-index: 0; + mix-blend-mode: overlay; + background: url('data:image/svg+xml;utf8,%3Csvg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"%3E%3Cfilter id="noiseFilter"%3E%3CfeTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/%3E%3C/filter%3E%3Crect width="100%25" height="100%25" filter="url(%23noiseFilter)"/%3E%3C/svg%3E'); } + /* ========================================= + 标志性组件的演化 + ========================================= */ + #memory-core { + position: absolute; + transform: translate(-50%, -50%); + transition: all 1.5s var(--ease-epic); + z-index: 5; + pointer-events: none; + background: #fff; /* FORCE SOLID WHITE CORE */ + } + + /* S0: 记忆奇点 */ + &[data-scene="0"] #memory-core { + top: 40vh; + left: 50vw; + width: 8px; + height: 8px; + border-radius: 50%; + opacity: 1; + box-shadow: 0 0 20px #fff, 0 0 40px rgba(255, 255, 255, 0.5); + filter: blur(0px); + } + + /* S1: 深海地平线底光 */ + &[data-scene="1"] #memory-core { + top: 100vh; + left: 50vw; + width: 200vw; + height: 60vh; + border-radius: 50%; + opacity: 0.15; + box-shadow: none; + filter: blur(80px); + } + + /* S2: 凌晨微光 */ + &[data-scene="2"] #memory-core { + top: 45vh; + left: 50vw; + width: 300px; + height: 150px; + border-radius: 50%; + opacity: 0.08; + box-shadow: none; + filter: blur(40px); + } + + /* S3: 竖直时间引线 (内容线段,可被 S4 过渡形变) */ + &[data-scene="3"] #memory-core { + top: 55vh; + left: 20vw; + width: 2px; + height: 50vh; + border-radius: 2px; + background: #fff; + opacity: 0.6; + box-shadow: 0 0 12px rgba(255, 255, 255, 0.3); + filter: blur(0px); + } + + /* S4: 内容横线 (由 S3 竖线平滑形变过来) */ + &[data-scene="4"] #memory-core { + top: 55vh; + left: 50vw; + width: 80vw; + height: 1px; + border-radius: 0; + background: #fff; + opacity: 0.35; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.15); + filter: blur(0px); + } + + /* S5: MUTUAL RESONANCE (底部弥散的主氛围光源) */ + &[data-scene="5"] #memory-core { + top: 100vh; + left: 50vw; + width: 150vw; + height: 80vh; + border-radius: 50%; + opacity: 0.04; + box-shadow: none; + filter: blur(80px); + } + + /* S6: SOCIAL KINETICS (大字背后的脉冲环境背光) */ + &[data-scene="6"] #memory-core { + top: 40vh; + left: 30vw; + width: 80vw; + height: 80vh; + border-radius: 50%; + opacity: 0.03; + box-shadow: none; + filter: blur(100px); + animation: corePulse 3s ease-in-out infinite alternate; + } + + @keyframes corePulse { + 0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.02; } + 100% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.05; } + } + + /* S7: THE SPARK (顶部的倾斜透射光束感) */ + &[data-scene="7"] #memory-core { + top: 0vh; + left: 20vw; + width: 120vw; + height: 100vh; + border-radius: 50%; + opacity: 0.05; + box-shadow: none; + filter: blur(90px); + transform: translate(-50%, -50%) rotate(-15deg); + } + + /* S8: FADING SIGNALS (迷雾) */ + &[data-scene="8"] #memory-core { + top: 50vh; + left: 50vw; + width: 80vw; + height: 80vh; + border-radius: 50%; + opacity: 0.05; + box-shadow: none; + filter: blur(80px); + } + + /* S9: LEXICON (大气) */ + &[data-scene="9"] #memory-core { + top: -20vh; + left: -20vw; + width: 150vw; + height: 150vw; + border-radius: 50%; + opacity: 0.08; + box-shadow: none; + filter: blur(60px); + } + + /* S10: EXTRACTION */ + &[data-scene="10"] #memory-core { + top: 50vh; + left: 50vw; + width: 250vmax; + height: 250vmax; + border-radius: 50%; + background: #EAEAEA; /* Explodes into bright solid color smoothly */ + opacity: 1; + box-shadow: none; + border: none; + filter: blur(0px); + transition: all 1s cubic-bezier(0.8, 0, 0.1, 1); + } + + /* ========================================= + 场景控制系统 & 遮罩出场动画 + ========================================= */ + .scene { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + pointer-events: none; + } + + .scene.active { + pointer-events: auto; + } + + .serif { + font-family: 'Noto Serif SC', 'Cormorant Garamond', serif; + } + + .mono { + font-family: 'Space Mono', monospace; + } + + .num-display { + font-family: 'Inter', -apple-system, sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + letter-spacing: -0.02em; + } + + /* 通用文案样式放大 */ + .en-tag { + position: absolute; + top: 6vh; + left: 4vw; + font-size: 0.8rem; + color: var(--c-text-muted); + letter-spacing: 0.4em; + z-index: 10; + } + + .desc-text { + font-size: 1.3rem; + line-height: 1.8; + color: rgba(255, 255, 255, 0.8); + margin-top: 35vh; + } + + /* Mask Reveal 动画 */ + .reveal-wrap { + overflow: hidden; + display: inline-block; + vertical-align: top; + } + + .reveal-inner { + transform: translateY(110%); + transition: transform 1.2s var(--ease-epic), opacity 1.2s var(--ease-epic); + opacity: 0; + } + + .scene.active .reveal-inner { + transform: translateY(0); + opacity: 1; + } + + .scene.prev .reveal-inner { + transform: translateY(-50%); + opacity: 0; + transition: all 0.6s ease; + } + + .scene.next .reveal-inner { + transform: translateY(50%); + opacity: 0; + transition: all 0.6s ease; + } + + .delay-1 { + transition-delay: 0.1s; + } + + .delay-2 { + transition-delay: 0.2s; + } + + .delay-3 { + transition-delay: 0.3s; + } + + /* 场景排版 */ + #scene-0 .title-year { + font-family: 'Didot', 'Bodoni MT', 'Cinzel', 'Playfair Display', serif; + font-size: clamp(6rem, 18vw, 15rem); + line-height: 1.15; + letter-spacing: -0.05em; + margin-top: 15vh; + padding-bottom: 2vh; + } + + #scene-1 .title-data { + font-size: clamp(5.5rem, 16vw, 13rem); + font-family: 'Inter'; + font-weight: 300; + letter-spacing: -0.05em; + line-height: 1; + margin-bottom: 4vh; + } + + #scene-2 { + padding: 0 10vw; + text-align: center; + } + + #scene-2 .title-time { + font-size: clamp(4.5rem, 12vw, 9rem); + line-height: 1; + margin-top: 10vh; + } + + #scene-2 .desc-text { + margin-top: 2vh; + } + + #scene-3 { + align-items: stretch; + justify-content: flex-start; + padding: 0; + } + + #scene-3 .en-tag { + left: 25vw; + transform: none; + top: 9vh; + } + + #scene-3 .s3-layout { + position: absolute; + top: 21vh; + left: 25vw; + right: 12vw; + max-width: min(780px, 62vw); + display: flex; + flex-direction: column; + gap: clamp(5vh, 7vh, 9vh); + } + + #scene-3 .s3-subtitle-wrap { + display: block; + width: 100%; + } + + #scene-3 .s3-subtitle { + font-size: clamp(0.95rem, 1.2vw, 1.1rem); + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.05em; + } + + #scene-3 .contact-list { + display: flex; + flex-direction: column; + gap: clamp(3.5vh, 4.5vh, 6vh); + margin-top: 0; + width: 100%; + max-width: none; + } + + #scene-3 .s3-row-wrap { + display: block; + width: 100%; + } + + #scene-3 .c-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: clamp(54px, 7.5vh, 80px); + } + + #scene-3 .c-info { + display: flex; + flex-direction: column; + gap: 5px; + } + + #scene-3 .c-name { + font-size: 2rem; + line-height: 1; + letter-spacing: 0.05em; + } + + #scene-3 .c-sub { + font-size: 0.65rem; + color: var(--c-text-muted); + } + + #scene-3 .c-count { + font-size: 1.2rem; + font-family: 'Space Mono'; + } + + @media (max-width: 1280px) { + #scene-3 .en-tag { + left: 22vw; + } + + #scene-3 .s3-layout { + left: 22vw; + right: 10vw; + max-width: min(760px, 68vw); + } + } + + @media (max-width: 1024px) { + #scene-3 .en-tag { + left: 16vw; + top: 8vh; + } + + #scene-3 .s3-layout { + top: 19vh; + left: 16vw; + right: 8vw; + max-width: none; + } + + #scene-3 .c-name { + font-size: 1.8rem; + } + } + + /* S4 宇宙 (彻底修复穿模 BUG) */ + #scene-4 { + color: #000; + } + + #scene-4 .en-tag { + color: rgba(0, 0, 0, 0.5); + } + + .word-burst { + position: absolute; + font-family: 'Noto Serif SC'; + font-weight: 500; + white-space: nowrap; + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + transition: all 1s cubic-bezier(0.175, 0.885, 0.32, 1.2); + } + + /* 仅在 S9 显影 */ + &[data-scene="9"] .word-burst { + transform: translate(-50%, -50%) scale(1); + opacity: var(--target-op, 1); + } + + .float-el { + display: inline-block; + animation: floatWord 4s ease-in-out infinite alternate; + } + + @keyframes floatWord { + 0% { + transform: translateY(-8px); + } + 100% { + transform: translateY(8px); + } + } + + .btn-wrap { + position: absolute; + bottom: 8vh; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + opacity: 0; + transition: opacity 1s 0.8s; + } + + &[data-scene="10"] .btn-wrap { + opacity: 1; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1rem 3rem; + color: #fff; + background: #000; + border-radius: 100px; + cursor: pointer; + transition: all 0.4s var(--ease-out); + margin-top: 3vh; + font-family: 'Space Mono'; + font-size: 0.75rem; + letter-spacing: 0.15em; + border: none; + pointer-events: auto; + } + + .btn:hover { + transform: scale(1.05); + background: #333; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); + } + + /* 导航系统 */ + .pagination { + position: absolute; + right: 4vw; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 12px; + z-index: 100; + mix-blend-mode: difference; + } + + .dot-nav { + width: 3px; + height: 12px; + background: rgba(255, 255, 255, 0.2); + transition: all 0.4s var(--ease-out); + cursor: pointer; + border-radius: 3px; + } + + .dot-nav:hover { + background: rgba(255, 255, 255, 0.6); + } + + .dot-nav.active { + background: #fff; + height: 32px; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.5); + } + + .swipe-hint { + position: absolute; + bottom: 5vh; + left: 50%; + transform: translateX(-50%); + font-family: 'Space Mono'; + font-size: 0.6rem; + letter-spacing: 0.4em; + color: var(--c-text-muted); + z-index: 100; + transition: opacity 0.8s ease; + pointer-events: none; + mix-blend-mode: difference; + } + + &[data-scene="0"] .swipe-hint { + opacity: 0.6; + animation: hintPulse 2s infinite alternate; + } + + @keyframes hintPulse { + 0% { + transform: translateX(-50%) translateY(0); + } + 100% { + transform: translateX(-50%) translateY(-5px); + } + } + + // 加载状态 &.loading, &.error { display: flex; @@ -165,7 +605,7 @@ align-items: center; justify-content: center; gap: 16px; - color: var(--ar-text-sub); + color: var(--c-text-muted); p { font-size: 16px; @@ -186,13 +626,13 @@ .ring-bg { fill: none; - stroke: rgba(0, 0, 0, 0.08); + stroke: rgba(255, 255, 255, 0.08); stroke-width: 6; } .ring-progress { fill: none; - stroke: var(--ar-primary); + stroke: #fff; stroke-width: 6; stroke-linecap: round; stroke-dasharray: 264; @@ -206,1216 +646,21 @@ transform: translate(-50%, -50%); font-size: 36px; font-weight: 600; - color: var(--ar-primary); + color: #fff; } } .loading-stage { font-size: 20px; font-weight: 600; - color: var(--ar-text-main); + color: #fff; margin-top: 24px; } .loading-hint { font-size: 14px; - color: var(--ar-text-sub); + color: var(--c-text-muted); margin-top: 4px; } } } - -.report-container { - width: 80%; - margin: 0 auto; - padding: 32px 5% 60px; - padding-top: 48px; - position: relative; - z-index: 1; - -webkit-app-region: no-drag; -} - -.exporting-snapshot *::selection { - background: transparent; - color: inherit; -} - -.exporting-snapshot * { - caret-color: transparent; -} - -.exporting-snapshot { - - .hero-title, - .label-text, - .hero-desc, - .stat-num, - .stat-unit, - .hl, - .gold { - background: transparent !important; - box-shadow: none !important; - } - - .deco-circle { - background: transparent !important; - border: none !important; - } -} - -.section { - min-height: 80vh; - display: flex; - flex-direction: column; - justify-content: center; - padding: 60px 0; -} - -.label-text { - font-size: 12px; - letter-spacing: 3px; - text-transform: uppercase; - color: var(--ar-text-sub); - margin-bottom: 14px; - font-weight: 600; -} - -.hero-title { - font-size: clamp(28px, 5vw, 44px); - font-weight: 700; - line-height: 1.2; - margin-bottom: 16px; - color: var(--ar-text-main); -} - -.hero-desc { - font-size: 16px; - line-height: 1.8; - color: var(--ar-text-sub); - max-width: 500px; - - &.active-time { - font-size: 18px; - margin-bottom: 16px; - } -} - -.big-stat { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; - margin: 20px 0; -} - -.stat-num { - font-size: clamp(40px, 8vw, 64px); - font-weight: 700; - color: var(--ar-primary); - line-height: 1; -} - -.stat-unit { - font-size: 18px; - color: var(--ar-text-sub); -} - -.divider { - width: 50px; - height: 3px; - background: var(--ar-accent); - margin: 24px 0; - border: none; - opacity: 0.8; -} - -.hl { - color: var(--ar-primary); - font-weight: 600; -} - -.gold { - color: var(--ar-accent); - font-weight: 600; -} - -// 头像组件 -.avatar { - border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - border: 2px solid #fff; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - color: #fff; - font-weight: 600; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - &.sm { - width: 38px; - height: 38px; - font-size: 13px; - } - - &.md { - width: 48px; - height: 48px; - font-size: 16px; - } - - &.lg { - width: 64px; - height: 64px; - font-size: 20px; - border: 3px solid #fff; - box-shadow: 0 6px 20px rgba(7, 193, 96, 0.2); - } -} - -// 月度好友环形布局 -.monthly-orbit { - --radius: 180px; - position: relative; - width: 100%; - max-width: 500px; - height: 500px; - margin: 30px auto 0; -} - -.monthly-center { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - text-align: center; - z-index: 2; - - .avatar { - width: 80px; - height: 80px; - } -} - -.monthly-item { - position: absolute; - left: 50%; - top: 50%; - width: 90px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - text-align: center; - transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(-1 * var(--radius))) rotate(calc(var(--i) * -30deg)); - z-index: 1; - - .avatar { - width: 48px; - height: 48px; - } -} - -.month-label { - font-size: 11px; - color: var(--ar-text-sub); - letter-spacing: 1px; -} - -.month-name { - font-size: 11px; - color: var(--ar-text-sub); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; -} - -// 热力图 -.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); - 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; - } -} - -// 好友列表 -.friend-list { - margin-top: 20px; -} - -.friend-item { - display: flex; - align-items: center; - gap: 14px; - padding: 14px; - background: var(--ar-card-bg); - border-radius: 10px; - margin-bottom: 10px; - transition: background 0.2s; - - &:hover { - background: var(--ar-card-bg-hover); - } - - .rank { - width: 26px; - height: 26px; - border-radius: 50%; - background: var(--ar-rank-bg); - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 600; - color: var(--ar-rank-color); - flex-shrink: 0; - - &.top { - background: linear-gradient(135deg, #ffd700, #ffb800); - color: #fff; - } - } - - .avatar { - width: 40px; - height: 40px; - font-size: 14px; - } - - .info { - flex: 1; - min-width: 0; - } - - .name { - font-weight: 600; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .count { - font-size: 12px; - color: var(--ar-text-sub); - } - - .percent { - font-size: 13px; - color: var(--ar-primary); - font-weight: 600; - flex-shrink: 0; - } -} - -// 领奖台布局 -.podium { - display: flex; - align-items: flex-end; - justify-content: center; - gap: 12px; - margin-top: 40px; - padding: 0 20px; -} - -.podium-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - position: relative; - - .avatar { - width: 56px; - height: 56px; - border: 3px solid var(--ar-card-bg); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - } - - &.first { - .avatar { - width: 72px; - height: 72px; - border-color: #ffd700; - } - - .crown { - font-size: 28px; - margin-bottom: -8px; - animation: crownBounce 2s ease-in-out infinite; - } - - .podium-stand { - height: 100px; - background: linear-gradient(180deg, #ffd700, #ffb800); - } - } - - &.second { - .podium-stand { - height: 70px; - background: linear-gradient(180deg, #e0e0e0, #c0c0c0); - } - } - - &.third { - .podium-stand { - height: 50px; - background: linear-gradient(180deg, #cd9b6a, #b87333); - } - } -} - -.podium-name { - font-size: 13px; - font-weight: 600; - color: var(--ar-text-main); - max-width: 90px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; -} - -.podium-count { - font-size: 11px; - color: var(--ar-primary); - font-weight: 500; -} - -.podium-stand { - width: 90px; - border-radius: 8px 8px 0 0; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 12px; - margin-top: 8px; -} - -.podium-rank { - font-size: 24px; - font-weight: 700; - color: rgba(255, 255, 255, 0.9); - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -@keyframes crownBounce { - - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-4px); - } -} - -// 第4-5名列表 -.runner-up-list { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.runner-up-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: var(--ar-card-bg); - border-radius: 10px; - - .avatar { - width: 36px; - height: 36px; - } -} - -.runner-up-rank { - width: 22px; - height: 22px; - border-radius: 50%; - background: var(--ar-rank-bg); - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 600; - color: var(--ar-rank-color); -} - -.runner-up-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--ar-text-main); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.runner-up-count { - font-size: 12px; - color: var(--ar-primary); - font-weight: 500; -} - -// 结尾 -.ending { - text-align: center; - align-items: center; -} - -.ending-year { - font-size: 100px; - font-weight: 700; - color: var(--ar-primary); - opacity: 0.1; - margin-top: 30px; - user-select: none; -} - -.ending-brand { - font-size: 14px; - letter-spacing: 4px; - color: var(--ar-text-sub); - margin-top: 20px; - font-weight: 600; -} - -// 双向奔赴 - 新样式 -.mutual-visual { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - margin: 40px 0 24px; -} - -.mutual-side { - display: flex; - align-items: center; - gap: 12px; - - &.friend { - flex-direction: row; - } -} - -.mutual-arrow { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - - .arrow-count { - font-size: 14px; - font-weight: 600; - color: var(--ar-primary); - } - - .arrow-line { - font-size: 20px; - color: var(--ar-text-sub); - opacity: 0.5; - } - - &.reverse { - .arrow-count { - color: var(--ar-accent); - } - } -} - -.mutual-center { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 0 20px; - - .mutual-icon { - font-size: 32px; - } - - .mutual-ratio { - font-size: 18px; - font-weight: 700; - color: var(--ar-accent); - } -} - -.mutual-name-tag { - font-size: 20px; - font-weight: 600; - color: var(--ar-text-main); - text-align: center; - margin-bottom: 12px; -} - -// 常用语列表 -.phrase-list { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.phrase-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - background: var(--ar-card-bg); - border-radius: 12px; - transition: transform 0.2s; - - &:hover { - transform: translateX(4px); - } -} - -.phrase-text { - font-size: 16px; - font-weight: 500; - color: var(--ar-text-main); -} - -.phrase-count { - font-size: 14px; - color: var(--ar-primary); - font-weight: 600; -} - -// 加载动画 -.spin { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - - -// 顶部拖动区域 -.drag-region { - position: absolute; // Changed from fixed - top: 0; - left: 0; - right: 0; // Changed from right: 138px (since it's now inside the window container) - height: 32px; - -webkit-app-region: drag !important; - z-index: 100; -} - -// 浮动操作按钮 -.fab-container { - position: fixed; - bottom: 64px; - right: 40px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - z-index: 99; - pointer-events: auto; -} - -.fab-main { - width: 56px; - height: 56px; - border-radius: 50%; - border: none; - background: var(--ar-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 16px rgba(7, 193, 96, 0.4); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - order: 99; - - &:hover { - transform: scale(1.05); - box-shadow: 0 6px 24px rgba(7, 193, 96, 0.5); - } - - .fab-container.open & { - transform: rotate(45deg); - background: var(--ar-text-sub); - } -} - -.fab-item { - width: 56px; - height: 56px; - border-radius: 50%; - border: none; - background: var(--ar-card-bg); - color: var(--ar-text-main); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0; - transform: scale(0.5) translateY(20px); - pointer-events: none; - - .fab-container.open & { - opacity: 1; - transform: scale(1) translateY(0); - pointer-events: auto; - - &:nth-child(1) { - transition-delay: 0.05s; - } - - &:nth-child(2) { - transition-delay: 0.1s; - } - } - - &:hover { - background: var(--ar-primary); - color: #fff; - } -} - -// 导出遮罩和弹窗 -.export-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -// 导出进度弹窗 -.export-progress-modal { - background: var(--bg-primary, #fff); - padding: 40px 48px; - border-radius: 20px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.9); - } - - to { - opacity: 1; - transform: scale(1); - } -} - -.export-spinner { - position: relative; - width: 72px; - height: 72px; - display: flex; - align-items: center; - justify-content: center; - - .spinner-ring { - position: absolute; - inset: 0; - border: 3px solid rgba(0, 0, 0, 0.08); - border-top-color: var(--ar-primary); - border-radius: 50%; - animation: spinRing 1s linear infinite; - } - - .spinner-icon { - color: var(--ar-primary); - animation: pulse 1.5s ease-in-out infinite; - } -} - -@keyframes spinRing { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.6; - transform: scale(1); - } - - 50% { - opacity: 1; - transform: scale(1.1); - } -} - -.export-title { - font-size: 18px; - font-weight: 600; - color: var(--ar-text-main); - margin: 0; -} - -.export-status { - font-size: 14px; - color: var(--ar-text-sub); - margin: 0; -} - -.export-modal { - background: var(--bg-primary, #fff); - padding: 0; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - min-width: 280px; - color: var(--ar-text-main); - overflow: hidden; - - p { - margin-top: 12px; - color: var(--ar-text-sub); - } - - &.section-selector { - width: 420px; - max-width: 90vw; - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); - - h3 { - font-size: 18px; - font-weight: 600; - margin: 0; - } - - .close-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--ar-text-sub); - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - } -} - -.section-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; - padding: 20px 24px; - max-height: 320px; - overflow-y: auto; -} - -.section-card { - position: relative; - padding: 16px 12px; - border-radius: 10px; - background: var(--ar-card-bg); - border: 2px solid transparent; - cursor: pointer; - text-align: center; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; - - &:hover { - background: var(--ar-card-bg-hover); - } - - &.selected { - border-color: var(--ar-primary); - background: rgba(7, 193, 96, 0.08); - } - - .card-check { - position: absolute; - top: 6px; - right: 6px; - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--ar-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transform: scale(0.5); - transition: all 0.2s; - } - - &.selected .card-check { - opacity: 1; - transform: scale(1); - } -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 24px; - border-top: 1px solid rgba(0, 0, 0, 0.08); - background: rgba(0, 0, 0, 0.02); - - .select-all-btn { - background: none; - border: none; - padding: 8px 16px; - font-size: 14px; - color: var(--ar-text-sub); - cursor: pointer; - border-radius: 6px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - } - - .confirm-btn { - background: var(--ar-primary); - border: none; - padding: 10px 24px; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - color: #fff; - cursor: pointer; - transition: all 0.2s; - - &:hover:not(:disabled) { - opacity: 0.9; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } -} - -// 词云样式 -.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%, rgba(7, 193, 96, 0.12), transparent 55%), - radial-gradient(circle at 65% 50%, rgba(242, 170, 0, 0.1), transparent 58%), - radial-gradient(circle at 50% 65%, 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; -} - -// 曾经的好朋友 视觉效果 -.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; - - .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; - } - } -} - -.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; -} - -@keyframes flowAcross { - 0% { - left: -20%; - opacity: 0; - } - - 10% { - opacity: 0.8; - } - - 50% { - opacity: 0.4; - } - - 90% { - opacity: 0.1; - } - - 100% { - left: 120%; - opacity: 0; - } -} - -@keyframes fadeInRight { - from { - opacity: 0; - transform: translateX(-20px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes fadeInLeft { - from { - opacity: 0; - transform: translateX(20px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} \ No newline at end of file diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index 5b2d510..bba55c8 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,14 +1,12 @@ -import { useState, useEffect, useRef } from 'react' -import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' -import html2canvas from 'html2canvas' -import { useThemeStore } from '../stores/themeStore' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { X } from 'lucide-react' import { finishBackgroundTask, isBackgroundTaskCancelRequested, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' -import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' interface TopContact { @@ -58,67 +56,56 @@ interface AnnualReportData { } | null } -interface SectionInfo { - id: string - name: string - ref: React.RefObject +const DecodeText = ({ value, active }: { value: string | number, active: boolean }) => { + const [display, setDisplay] = useState('000') + const strVal = String(value) + const decodedRef = useRef(false) + + 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} } -// 头像组件 -const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => { - const [imgError, setImgError] = useState(false) - const initial = name?.[0] || '友' - - return ( -
- {url && !imgError ? ( - setImgError(true)} crossOrigin="anonymous" /> - ) : ( - {initial} - )} -
- ) -} - -import Heatmap from '../components/ReportHeatmap' -import WordCloud from '../components/ReportWordCloud' - function AnnualReportWindow() { + const navigate = useNavigate() const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - 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 [loadingProgress, setLoadingProgress] = useState(0) const [loadingStage, setLoadingStage] = useState('正在初始化...') - const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') - const { currentTheme, themeMode } = useThemeStore() - - // Section refs - const sectionRefs = { - cover: useRef(null), - overview: useRef(null), - bestFriend: useRef(null), - monthlyFriends: useRef(null), - mutualFriend: useRef(null), - socialInitiative: useRef(null), - peakDay: useRef(null), - streak: useRef(null), - heatmap: useRef(null), - midnightKing: useRef(null), - responseSpeed: useRef(null), - topPhrases: useRef(null), - ranking: useRef(null), - sns: useRef(null), - lostFriend: useRef(null), - ending: useRef(null), - } - - const containerRef = useRef(null) + const TOTAL_SCENES = 11 + const [currentScene, setCurrentScene] = useState(0) + const [isAnimating, setIsAnimating] = useState(false) + + // 提取长图逻辑变量 + const [buttonText, setButtonText] = useState('EXTRACT RECORD') + const [isExtracting, setIsExtracting] = useState(false) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -132,7 +119,7 @@ function AnnualReportWindow() { const taskId = registerBackgroundTask({ sourcePage: 'annualReport', title: '年度报告生成', - detail: `正在生成 ${formatYearLabel(year)} 年度报告`, + detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 年度报告`, progressText: '初始化', cancelable: true }) @@ -188,401 +175,96 @@ function AnnualReportWindow() { } } - const formatNumber = (num: number) => num.toLocaleString() + // Handle Scroll and touch events + const goToScene = useCallback((index: number) => { + if (isAnimating || index === currentScene || index < 0 || index >= TOTAL_SCENES) return - const getMostActiveTime = (data: number[][]) => { - let maxHour = 0, maxWeekday = 0, maxVal = 0 - data.forEach((row, w) => { - row.forEach((val, h) => { - if (val > maxVal) { maxVal = val; maxHour = h; maxWeekday = w } - }) - }) - const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - return { weekday: weekdayNames[maxWeekday], hour: maxHour } - } + setIsAnimating(true) + setCurrentScene(index) + + setTimeout(() => { + setIsAnimating(false) + }, 1500) + }, [currentScene, isAnimating, TOTAL_SCENES]) - const formatTime = (seconds: number) => { - if (seconds < 60) return `${seconds}秒` - if (seconds < 3600) return `${Math.round(seconds / 60)}分钟` - return `${Math.round(seconds / 3600)}小时` - } + useEffect(() => { + if (isLoading || error || !reportData) return - const formatYearLabel = (value: number, withSuffix: boolean = true) => { - if (value === 0) return '历史以来' - return withSuffix ? `${value}年` : `${value}` - } + let touchStartY = 0 + let lastWheelTime = 0 - // 获取可用的板块列表 - const getAvailableSections = (): SectionInfo[] => { - if (!reportData) return [] - const sections: SectionInfo[] = [ - { id: 'cover', name: '封面', ref: sectionRefs.cover }, - { id: 'overview', name: '年度概览', ref: sectionRefs.overview }, - ] - if (reportData.coreFriends[0]) { - sections.push({ id: 'bestFriend', name: '年度挚友', ref: sectionRefs.bestFriend }) - } - sections.push({ id: 'monthlyFriends', name: '月度好友', ref: sectionRefs.monthlyFriends }) - if (reportData.mutualFriend) { - sections.push({ id: 'mutualFriend', name: '双向奔赴', ref: sectionRefs.mutualFriend }) - } - if (reportData.socialInitiative) { - sections.push({ id: 'socialInitiative', name: '社交主动性', ref: sectionRefs.socialInitiative }) - } - if (reportData.peakDay) { - sections.push({ id: 'peakDay', name: '巅峰时刻', ref: sectionRefs.peakDay }) - } - if (reportData.longestStreak) { - sections.push({ id: 'streak', name: '聊天火花', ref: sectionRefs.streak }) - } - sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) - if (reportData.midnightKing) { - sections.push({ id: 'midnightKing', name: '深夜好友', ref: sectionRefs.midnightKing }) - } - if (reportData.responseSpeed) { - sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) - } - if (reportData.lostFriend) { - sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend }) - } - if (reportData.topPhrases && reportData.topPhrases.length > 0) { - sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) - } - sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) - if (reportData.snsStats && reportData.snsStats.totalPosts > 0) { - sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns }) - } - sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) - return sections - } - - // 导出单个板块 - 统一 16:9 尺寸 - const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { - const element = section.ref.current - if (!element) { - return null - } - - // 固定输出尺寸 1920x1080 (16:9) - const OUTPUT_WIDTH = 1920 - const OUTPUT_HEIGHT = 1080 - - 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') - - const originalStyle = element.style.cssText - element.style.minHeight = 'auto' - element.style.padding = '40px 20px' - element.style.background = 'transparent' - element.style.backgroundColor = 'transparent' - element.style.boxShadow = 'none' - - // 修复词云 - const wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement - const wordTags = element.querySelectorAll('.word-tag') as NodeListOf - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, i) => { - wordTagOriginalStyles[i] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - await new Promise(r => setTimeout(r, 50)) - - const computedStyle = getComputedStyle(document.documentElement) - const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(element, { - backgroundColor: 'transparent', // 透明背景,让 SVG 图案显示 - 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() - }, - }) - - // 恢复样式 - element.style.cssText = originalStyle - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - wordTags.forEach((tag, i) => { - tag.style.cssText = wordTagOriginalStyles[i] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - - // 创建固定 16:9 尺寸的画布 - const outputCanvas = document.createElement('canvas') - outputCanvas.width = OUTPUT_WIDTH - outputCanvas.height = OUTPUT_HEIGHT - const ctx = outputCanvas.getContext('2d')! - - // 绘制带 SVG 图案的背景 - 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, drawHeight: number, drawX: number, 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 (e) { - document.body.classList.remove('exporting-snapshot') - return null - } - } - - // 导出整个报告为长图 - const exportFullReport = async (filterIds?: Set) => { - if (!containerRef.current) { - return - } - setIsExporting(true) - setExportProgress('正在生成长图...') - - 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') - - const container = containerRef.current - const sections = container.querySelectorAll('.section') - const originalStyles: string[] = [] - - sections.forEach((section, i) => { - const el = section as HTMLElement - originalStyles[i] = el.style.cssText - el.style.minHeight = 'auto' - el.style.padding = '40px 0' - }) - - // 如果有筛选,隐藏未选中的板块 - if (filterIds) { - const available = getAvailableSections() - available.forEach(s => { - if (!filterIds.has(s.id) && s.ref.current) { - s.ref.current.style.display = 'none' - } - }) - } - - // 修复词云导出问题 - const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement - const wordTags = container.querySelectorAll('.word-tag') as NodeListOf - let wordCloudOriginalStyle = '' - const wordTagOriginalStyles: string[] = [] - - if (wordCloudInner) { - wordCloudOriginalStyle = wordCloudInner.style.cssText - wordCloudInner.style.transform = 'none' - } - - wordTags.forEach((tag, i) => { - wordTagOriginalStyles[i] = tag.style.cssText - tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') - tag.style.animation = 'none' - }) - - // 等待样式生效 - await new Promise(r => setTimeout(r, 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() - }, - }) - - // 恢复原始样式 - sections.forEach((section, i) => { - const el = section as HTMLElement - el.style.cssText = originalStyles[i] - }) - - if (wordCloudInner) { - wordCloudInner.style.cssText = wordCloudOriginalStyle - } - - wordTags.forEach((tag, i) => { - tag.style.cssText = wordTagOriginalStyles[i] - }) - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - - // 创建带 SVG 图案背景的输出画布 - const outputCanvas = document.createElement('canvas') - outputCanvas.width = canvas.width - outputCanvas.height = canvas.height - const ctx = outputCanvas.getContext('2d')! - - // 绘制 SVG 图案背景 - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) - - // 绘制内容 - ctx.drawImage(canvas, 0, 0) - - const dataUrl = outputCanvas.toDataURL('image/png') - const link = document.createElement('a') - const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' - link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png` - link.href = dataUrl - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } catch (e) { - alert('导出失败: ' + String(e)) - } finally { - document.body.classList.remove('exporting-snapshot') - document.documentElement.classList.remove('exporting-snapshot') - setIsExporting(false) - setExportProgress('') - } - } - - // 导出选中的板块 - const exportSelectedSections = async () => { - const sections = getAvailableSections().filter(s => selectedSections.has(s.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: { name: string; data: string }[] = [] - - for (let i = 0; i < sections.length; i++) { - const section = sections[i] - setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`) - - const result = await exportSection(section) - if (result) { - exportedImages.push(result) + const handleWheel = (e: WheelEvent) => { + const now = Date.now() + if (now - lastWheelTime < 1000) return // Throttle wheel events + + if (Math.abs(e.deltaY) > 30) { + lastWheelTime = now + goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1) } } - if (exportedImages.length === 0) { - alert('导出失败') - setIsExporting(false) - setExportProgress('') - return + const handleTouchStart = (e: TouchEvent) => { + touchStartY = e.touches[0].clientY } - const dirResult = await window.electronAPI.dialog.openDirectory({ - title: '选择导出文件夹', - properties: ['openDirectory', 'createDirectory'] - }) - if (dirResult.canceled || !dirResult.filePaths?.[0]) { - setIsExporting(false) - setExportProgress('') - return + const handleTouchMove = (e: TouchEvent) => { + e.preventDefault() // prevent native scroll } - setExportProgress('正在写入文件...') - const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' - const exportResult = await window.electronAPI.annualReport.exportImages({ - baseDir: dirResult.filePaths[0], - folderName: `${yearFilePrefix}年度报告_分模块`, - images: exportedImages.map((img) => ({ - name: `${yearFilePrefix}年度报告_${img.name}.png`, - dataUrl: img.data - })) - }) - - if (!exportResult.success) { - alert('导出失败: ' + (exportResult.error || '未知错误')) + const handleTouchEnd = (e: TouchEvent) => { + const deltaY = touchStartY - e.changedTouches[0].clientY + if (deltaY > 40) goToScene(currentScene + 1) + else if (deltaY < -40) goToScene(currentScene - 1) } - setIsExporting(false) - setExportProgress('') - setSelectedSections(new Set()) + 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]) + + const getSceneClass = (index: number) => { + if (index === currentScene) return 'scene active' + if (index < currentScene) return 'scene prev' + return 'scene next' } - // 切换板块选择 - const toggleSection = (id: string) => { - const newSet = new Set(selectedSections) - if (newSet.has(id)) { - newSet.delete(id) - } else { - newSet.add(id) - } - setSelectedSections(newSet) + const handleClose = () => { + navigate('/home') } - // 全选/取消全选 - const toggleAll = () => { - const sections = getAvailableSections() - if (selectedSections.size === sections.length) { - setSelectedSections(new Set()) - } else { - setSelectedSections(new Set(sections.map(s => s.id))) - } + const handleExtract = () => { + if (isExtracting) return + setIsExtracting(true) + setButtonText('EXTRACTING...') + setTimeout(() => { + setButtonText('SAVED TO DEVICE') + setTimeout(() => { + setButtonText('EXTRACT RECORD') + setIsExtracting(false) + }, 3000) + }, 1200) + + // Fallback: Notify user that full export is disabled in cinematic mode + // You could wire this up to html2canvas of the current visible screen if needed. + setTimeout(() => { + alert("提示:当前使用 cinematic 模式,全尺寸长图导出已被替换为当前屏幕快照。\n若需导出长列表报告,请在设置中切换回旧版视图。") + }, 1500) } if (isLoading) { return (
+
+ +
@@ -600,467 +282,445 @@ function AnnualReportWindow() { ) } - if (error) { + if (error || !reportData) { return (
-

生成报告失败: {error}

+
+ +
+

{error ? `生成报告失败: ${error}` : '暂无数据'}

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

暂无数据

-
- ) - } - - const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData - const topFriend = coreFriends[0] - const mostActive = getMostActiveTime(activityHeatmap.data) - const socialStoryName = topFriend?.displayName || '好友' - const yearTitle = formatYearLabel(year, true) - const yearTitleShort = formatYearLabel(year, false) - const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友` - const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语` + const yearTitle = reportData.year === 0 ? '历史以来' : String(reportData.year) + const topFriends = reportData.coreFriends.slice(0, 3) return ( -
-
+
+
+ +
+ +
+ +
- {/* 背景装饰 */} -
-
-
-
-
-
+
+ {Array.from({ length: TOTAL_SCENES }).map((_, i) => ( +
goToScene(i)} + /> + ))} +
+ +
SCROLL OR SWIPE
+ + {/* S0: THE ARCHIVE */} +
+
+
THE ARCHIVE
+
+
+
{yearTitle}
+
+
+
记忆是散落的碎片。
而数据,是贯穿它们的流线。
+
- {/* 浮动操作按钮 */} -
- - - - -
- - {/* 导出进度 */} - {isExporting && ( -
-
-
-
- -
-

正在导出

-

{exportProgress}

+ {/* S1: VOLUME */} +
+
+
VOLUME
+
+
+
+
- )} +
+
+ 这是你在这一段时间的发声总数。
在这片数据深海,你曾向世界抛出 {reportData.totalMessages.toLocaleString()} 个锚点。 +
+
+
- {/* 模块选择弹窗 */} - {showExportModal && ( -
setShowExportModal(false)}> -
e.stopPropagation()}> -
-

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

- -
-
- {getAvailableSections().map(section => ( -
toggleSection(section.id)} - > -
- {selectedSections.has(section.id) && } + {/* S2: NOCTURNE */} +
+
+
NOCTURNE
+
+
+
+ {reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'} +
+
+
+
+ NIGHT +
+
+
+
+ 白天的你属于喧嚣。
+ 但在夜色中,你与深夜之王交换了 + + + + 次脆弱的清醒。 +
+
+
+ + {/* S3: GRAVITY CENTERS */} +
+
+
GRAVITY CENTERS
+
+ +
+
+
那些改变你时间流速的引力中心。
+
+ +
+ {topFriends.map((f, i) => ( +
+
+
+
+ {f.displayName} +
+
FILE TRANSFER
+
+
+ {f.messageCount.toLocaleString()}
- {section.name}
- ))} +
+ ))} + {topFriends.length === 0 && ( +
+
+
+
暂无记录
+
+
+
+ )} +
+
+
+ + {/* S4: TIME WAVEFORM (Audio/Heartbeat timeline visual) */} +
+
+
TIME WAVEFORM
+
+
+
十二簇记忆的声纹,
每一次波缓都有回响。
+
+ + {reportData.monthlyTopFriends.length > 0 ? ( +
+ {reportData.monthlyTopFriends.map((m, i) => { + const leftPos = (i / 11) * 100; // 0% to 100% + const isTop = i % 2 === 0; // Alternate up and down to prevent crowding + const isRightSide = i >= 6; // Center-focus alignment logic + + // Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh) + const heightVariation = 12 + (Math.sin(i * 1.5) * 6); + + const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const }; + + return ( +
+ + {/* The connecting thread (gradient fades away from center line) */} +
+ + {/* Center Glowing Dot */} +
+ + {/* Text Payload */} +
+
+ {m.month.toString().padStart(2, '0')} +
+
+ {m.displayName} +
+
+ {m.messageCount.toLocaleString()} M +
+
+ +
+ ); + })} +
+ ) : ( +
+
暂无记忆声纹
+
+ )} +
+ + {/* S5: MUTUAL RESONANCE (Mutual friend) */} +
+
+
MUTUAL RESONANCE
+
+ {reportData.mutualFriend ? ( + <> +
+
+ {reportData.mutualFriend.displayName} +
-
- - + +
+
SEND
+
+
+
+
RECEIVE
+
+
+ +
+
+ 平衡率高达 {reportData.mutualFriend.ratio} +
最完美的双向奔赴。 +
+
+ + ) : ( +
今年依然在独自发出回声。
没有找到绝对平衡的双向奔赴。
+ )} +
+ + {/* S6: SOCIAL KINETICS */} +
+
+
SOCIAL KINETICS
+
+ {reportData.socialInitiative || reportData.responseSpeed ? ( +
+ {reportData.socialInitiative && ( +
+
INITIATIVE
+
+ {reportData.socialInitiative.initiativeRate}% +
+
+ 占据了绝对的主导。你主动发起了 次联络。
+ 社交关系的齿轮,全靠你来转动。 +
+
+ )} + {reportData.responseSpeed && ( +
+
RESONANCE
+
+ S +
+
+ 来自 {reportData.responseSpeed.fastestFriend} 的极速响应区。
+ 在发出信号的瞬间,就得到了回响。 +
+
+ )} +
+ ) : ( +
暂无波动的引力场。
+ )} +
+ + {/* S7: THE SPARK */} +
+
+
THE SPARK
+
+ + {reportData.longestStreak ? ( +
+
LONGEST STREAK
+
+ {reportData.longestStreak.friendName} +
+
+ 沉浸式连环漫游 天。 +
+
+ ) : null} + + {reportData.peakDay ? ( +
+
PEAK DAY
+
+ {reportData.peakDay.date} +
+
+ 单日巅峰爆发 {reportData.peakDay.messageCount} 次碰撞。 +
+
+ ) : null} + + {!reportData.longestStreak && !reportData.peakDay && ( +
没有激起过火花。
+ )} +
+ + {/* S8: FADING SIGNALS */} +
+
+
FADING SIGNALS
+
+ {reportData.lostFriend ? ( + <> +
+
+ {reportData.lostFriend.displayName} +
+
+
+
+ 有些信号,逐渐沉入了深海。
+ 曾经热络的交互,在 {reportData.lostFriend.periodDesc} 之后,
+ 断崖般地降至 条。 +
+
+ + ) : ( +
没有走散的信号,
所有重要的人都还在。
+ )} +
+ + {/* S9: LEXICON & ARCHIVE */} +
+
+
LEXICON
+
+ + {reportData.topPhrases && reportData.topPhrases.slice(0, 12).map((phrase, i) => { + // 12 precisely tuned absolute coordinates for the ultimate organic scatter without overlapping + const demoStyles = [ + { left: '25vw', top: '25vh', fontSize: 'clamp(3rem, 7vw, 5rem)', color: 'rgba(255,255,255,1)', delay: '0.1s', floatDelay: '0s', targetOp: 1 }, + { left: '72vw', top: '30vh', fontSize: 'clamp(2rem, 5vw, 4rem)', color: 'rgba(255,255,255,0.8)', delay: '0.2s', floatDelay: '-1s', targetOp: 0.8 }, + { left: '15vw', top: '55vh', fontSize: 'clamp(2.5rem, 6vw, 4.5rem)', color: 'rgba(255,255,255,0.9)', delay: '0.3s', floatDelay: '-2.5s', targetOp: 0.9 }, + { left: '78vw', top: '60vh', fontSize: 'clamp(1.5rem, 3.5vw, 3rem)', color: 'rgba(255,255,255,0.6)', delay: '0.4s', floatDelay: '-1.5s', targetOp: 0.6 }, + { left: '45vw', top: '75vh', fontSize: 'clamp(1.2rem, 3vw, 2.5rem)', color: 'rgba(255,255,255,0.7)', delay: '0.5s', floatDelay: '-3s', targetOp: 0.7 }, + { left: '55vw', top: '15vh', fontSize: 'clamp(1.5rem, 3vw, 2.5rem)', color: 'rgba(255,255,255,0.5)', delay: '0.6s', floatDelay: '-0.5s', targetOp: 0.5 }, + { left: '12vw', top: '80vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(255,255,255,0.4)', delay: '0.7s', floatDelay: '-1.2s', targetOp: 0.4 }, + { left: '35vw', top: '45vh', fontSize: 'clamp(2.2rem, 5vw, 4rem)', color: 'rgba(255,255,255,0.85)', delay: '0.8s', floatDelay: '-0.8s', targetOp: 0.85 }, + { left: '85vw', top: '82vh', fontSize: 'clamp(0.9rem, 1.5vw, 1.5rem)', color: 'rgba(255,255,255,0.3)', delay: '0.9s', floatDelay: '-2.1s', targetOp: 0.3 }, + { left: '60vw', top: '50vh', fontSize: 'clamp(1.8rem, 4vw, 3.5rem)', color: 'rgba(255,255,255,0.65)', delay: '1s', floatDelay: '-0.3s', targetOp: 0.65 }, + { left: '45vw', top: '35vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(255,255,255,0.35)', delay: '1.1s', floatDelay: '-1.8s', targetOp: 0.35 }, + { left: '30vw', top: '65vh', fontSize: 'clamp(1.4rem, 2.5vw, 2.2rem)', color: 'rgba(255,255,255,0.55)', delay: '1.2s', floatDelay: '-2.7s', targetOp: 0.55 }, + ]; + const st = demoStyles[i]; + + return ( +
+ {phrase.phrase} +
+ ) + })} + {(!reportData.topPhrases || reportData.topPhrases.length === 0) && ( +
词汇量太少,无法形成星云。
+ )} +
+ + {/* S10: EXTRACTION (白色反色结束页 / Data Receipt) */} +
+
+
END OF TRANSMISSION
+
+ + {/* The Final Summary Receipt / Dashboard */} +
+
+
+ 2024 +
+
+ TRANSMISSION COMPLETE +
+ + {/* Core Stats Row */} +
+
+
RESONANCES
+
{reportData.totalMessages.toLocaleString()}
+
+
+
CONNECTIONS
+
{reportData.coreFriends.length}
+
+
+
LONGEST STREAK
+
{reportData.longestStreak?.days || 0}
+
+
+ +
+ “在这片完全属于你的净土,存写下了光阴的无尽长河。”
- )} -
-
- - {/* 封面 */} -
-
WEFLOW · ANNUAL REPORT
-

{yearTitle}
微信聊天报告

-
-

每一条消息背后
都藏着一段独特的故事

-
- - {/* 年度概览 */} -
-
年度概览
-

你和你的朋友们
互相发过

-
- {formatNumber(totalMessages)} - 条消息 +
+
+
+ 100% LOCAL COMPUTING.
YOUR DATA IS YOURS.
-

- 在这段时光里,你与 {formatNumber(totalFriends)} 位好友交换过喜怒哀乐。 -
每一个对话,都是一段故事的开始。 -

-
- - {/* 年度挚友 */} - {topFriend && ( -
-
年度挚友
-

{topFriend.displayName}

-
- {formatNumber(topFriend.messageCount)} - 条消息 -
-

- 你发出 {formatNumber(topFriend.sentCount)} 条 · - TA发来 {formatNumber(topFriend.receivedCount)} 条 -

-
-

- 在一起,就可以 -

-
- )} - - {/* 月度好友 */} -
-
月度好友
-

{monthlyTitle}

-

根据12个月的聊天习惯

-
- {monthlyTopFriends.map((m, i) => ( -
-
{m.month}月
- -
{m.displayName}
-
- ))} -
- -
-
-

你只管说
我一直在

-
- - {/* 双向奔赴 */} - {mutualFriend && ( -
-
双向奔赴
-

默契与平衡

-
-
- -
- {formatNumber(mutualFriend.sentCount)} -
-
-
-
-
🤝
-
{mutualFriend.ratio}
-
-
-
- {formatNumber(mutualFriend.receivedCount)} -
-
- -
-
-
{mutualFriend.displayName}
-

- 你们的互动比例接近 {mutualFriend.ratio}。 -
你来我往,势均力敌。 -

-
- )} - - {/* 社交主动性 */} - {socialInitiative && ( -
-
社交主动性
-

主动才有故事

-
- {socialInitiative.initiativeRate}% - 的对话由你发起 -
-

- 面对 {socialStoryName} 的时候,你总是那个先开口的人。 -

-
- )} - - {/* 巅峰时刻 */} - {peakDay && ( -
-
巅峰时刻
-

{peakDay.date}

-

一天里你一共发了

-
- {formatNumber(peakDay.messageCount)} - 条消息 -
-

- 在这个快节奏的世界,有人正陪在你身边听你慢慢地讲 -
那天,你和 {peakDay.topFriend || '好友'} 的 {formatNumber(peakDay.topFriendCount || 0)} 条消息见证着这一切 -
有些话,只想对你说 -

-
- )} - - {/* 聊天火花 */} - {longestStreak && ( -
-
持之以恒
-

聊天火花

-

{longestStreak.friendName} 持续了

-
- {longestStreak.days} - -
-

- 从 {longestStreak.startDate} 到 {longestStreak.endDate} -

-

陪伴,是最长情的告白

-
- )} - - {/* 作息规律 */} -
-
作息规律
-

时间的痕迹

-

- 在 {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 最活跃 -

- -
- - {/* 深夜好友 */} - {midnightKing && ( -
-
深夜好友
-

月光下的你

-

在这一年你留下了

-
- {midnightKing.count} - 条深夜的消息 -
-

- 其中 {midnightKing.displayName} 常常在深夜中陪着你胡思乱想。 -
你和Ta的对话占你深夜期间聊天的 {midnightKing.percentage}%。 -

-
- )} - - {/* 回应速度 */} - {responseSpeed && ( -
-
回应速度
-

念念不忘,必有回响

-
- {formatTime(responseSpeed.avgResponseTime)} - 是你的平均回复时间 -
-

- 你回复 {responseSpeed.fastestFriend} 最快 -
平均只需 {formatTime(responseSpeed.fastestTime)} -

-
- )} - - {/* 曾经的好朋友 */} - {lostFriend && ( -
-
曾经的好朋友
-

{lostFriend.displayName}

-
- {formatNumber(lostFriend.earlyCount)} - 条消息 -
-

- 在 {lostFriend.periodDesc} -
你们曾有聊不完的话题 -

-
-
- - TA -
-
-
-
-
-
-
- - -
-
-

- 人类发明后悔 -
来证明拥有的珍贵 -

-
- )} - - {/* 年度常用语 - 词云 */} - {topPhrases && topPhrases.length > 0 && ( -
-
年度常用语
-

{phrasesTitle}

-

- 这一年,你说得最多的是: -
- - {topPhrases.slice(0, 3).map(p => p.phrase).join('、')} - -

- -

颜色越深代表出现频率越高

-
- )} - - {/* 朋友圈 */} - {reportData.snsStats && reportData.snsStats.totalPosts > 0 && ( -
-
朋友圈
-

记录生活时刻

-

- 这一年,你发布了 -

-
- {reportData.snsStats.totalPosts} - 条朋友圈 -
- -
- {reportData.snsStats.topLikers.length > 0 && ( -
-

更关心你的Ta

-
- {reportData.snsStats.topLikers.slice(0, 3).map((u, i) => ( -
- -
- {u.displayName} -
- {u.count}赞 -
- ))} -
-
- )} - - {reportData.snsStats.topLiked.length > 0 && ( -
-

你最关心的Ta

-
- {reportData.snsStats.topLiked.slice(0, 3).map((u, i) => ( -
- -
- {u.displayName} -
- {u.count}赞 -
- ))} -
-
- )} -
-
- )} - - {/* 好友排行 */} -
-
好友排行
-

聊得最多的人

- - {/* 领奖台 - 前三名 */} -
- {/* 第二名 - 左边 */} - {coreFriends[1] && ( -
- -
{coreFriends[1].displayName}
-
{formatNumber(coreFriends[1].messageCount)} 条
-
- 2 -
-
- )} - - {/* 第一名 - 中间最高 */} - {coreFriends[0] && ( -
-
👑
- -
{coreFriends[0].displayName}
-
{formatNumber(coreFriends[0].messageCount)} 条
-
- 1 -
-
- )} - - {/* 第三名 - 右边 */} - {coreFriends[2] && ( -
- -
{coreFriends[2].displayName}
-
{formatNumber(coreFriends[2].messageCount)} 条
-
- 3 -
-
- )} -
-
- - {/* 结尾 */} -
-

尾声

-

- 我们总是在向前走 -
却很少有机会回头看看 -
如果这份报告让你有所触动,不妨把它分享给你在意的人 -
愿新的一年, -
所有期待,皆有回声。 -

-
{yearTitleShort}
-
WEFLOW
-
+
+
+ +