diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 3020b4c..f17946b 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -59,6 +59,8 @@ export interface AnnualReportData { initiatedChats: number receivedChats: number initiativeRate: number + topInitiatedFriend?: string + topInitiatedCount?: number } | null responseSpeed: { avgResponseTime: number @@ -1346,16 +1348,27 @@ class AnnualReportService { let socialInitiative: AnnualReportData['socialInitiative'] = null let totalInitiated = 0 let totalReceived = 0 - for (const stats of conversationStarts.values()) { + let topInitiatedSessionId = '' + let topInitiatedCount = 0 + for (const [sessionId, stats] of conversationStarts.entries()) { totalInitiated += stats.initiated totalReceived += stats.received + if (stats.initiated > topInitiatedCount) { + topInitiatedCount = stats.initiated + topInitiatedSessionId = sessionId + } } const totalConversations = totalInitiated + totalReceived if (totalConversations > 0) { + const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null socialInitiative = { initiatedChats: totalInitiated, receivedChats: totalReceived, - initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10 + initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10, + topInitiatedFriend: topInitiatedCount > 0 + ? (topInitiatedInfo?.displayName || topInitiatedSessionId) + : undefined, + topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined } } diff --git a/resources/fonts/annual-report/CormorantGaramond-Var.ttf b/resources/fonts/annual-report/CormorantGaramond-Var.ttf new file mode 100644 index 0000000..d992a83 Binary files /dev/null and b/resources/fonts/annual-report/CormorantGaramond-Var.ttf differ diff --git a/resources/fonts/annual-report/Inter-Var.ttf b/resources/fonts/annual-report/Inter-Var.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/resources/fonts/annual-report/Inter-Var.ttf differ diff --git a/resources/fonts/annual-report/NotoSerifSC-Var.ttf b/resources/fonts/annual-report/NotoSerifSC-Var.ttf new file mode 100644 index 0000000..eab063f Binary files /dev/null and b/resources/fonts/annual-report/NotoSerifSC-Var.ttf differ diff --git a/resources/fonts/annual-report/PlayfairDisplay-Var.ttf b/resources/fonts/annual-report/PlayfairDisplay-Var.ttf new file mode 100644 index 0000000..7a09eb7 Binary files /dev/null and b/resources/fonts/annual-report/PlayfairDisplay-Var.ttf differ diff --git a/resources/fonts/annual-report/SpaceMono-Bold.ttf b/resources/fonts/annual-report/SpaceMono-Bold.ttf new file mode 100644 index 0000000..2c4f268 Binary files /dev/null and b/resources/fonts/annual-report/SpaceMono-Bold.ttf differ diff --git a/resources/fonts/annual-report/SpaceMono-Regular.ttf b/resources/fonts/annual-report/SpaceMono-Regular.ttf new file mode 100644 index 0000000..1cfa365 Binary files /dev/null and b/resources/fonts/annual-report/SpaceMono-Regular.ttf differ diff --git a/src/pages/AnnualReportPage.scss b/src/pages/AnnualReportPage.scss index 3e7beab..396441c 100644 --- a/src/pages/AnnualReportPage.scss +++ b/src/pages/AnnualReportPage.scss @@ -1,4 +1,5 @@ .annual-report-page { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -8,6 +9,11 @@ padding: 40px 24px; } +.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) { + animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both; + pointer-events: none; +} + .header-icon { color: var(--primary); margin-bottom: 16px; @@ -199,6 +205,11 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); } + &.disabled { + pointer-events: none; + opacity: 0.72; + } + &.selected { border-color: var(--primary); background: var(--primary-light); @@ -251,6 +262,10 @@ cursor: not-allowed; } + &.is-pending { + pointer-events: none; + } + &.secondary { background: var(--card-bg); color: var(--text-primary); @@ -259,6 +274,40 @@ } } +.report-launch-overlay { + position: fixed; + inset: 0; + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-primary) 78%, transparent); + backdrop-filter: blur(8px); + animation: report-launch-overlay-in 420ms ease-out both; +} + +.launch-core { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; + color: var(--text-primary); + animation: report-launch-core-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.launch-title { + margin: 4px 0 0; + font-size: 18px; + font-weight: 650; +} + +.launch-subtitle { + margin: 0; + font-size: 13px; + color: var(--text-tertiary); +} + .spin { animation: spin 1s linear infinite; } @@ -271,3 +320,36 @@ @keyframes dot-ellipsis { to { width: 1.4em; } } + +@keyframes report-page-exit { + from { + opacity: 1; + filter: blur(0); + transform: scale(1); + } + to { + opacity: 0; + filter: blur(8px); + transform: scale(0.985); + } +} + +@keyframes report-launch-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes report-launch-core-in { + from { + opacity: 0; + transform: translateY(18px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 88f77d0..5d0be95 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' import { @@ -25,6 +25,8 @@ type YearsLoadPayload = { nativeTimedOut?: boolean } +const REPORT_LAUNCH_DELAY_MS = 420 + const formatLoadElapsed = (ms: number) => { const totalSeconds = Math.max(0, ms) / 1000 if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s` @@ -50,7 +52,10 @@ function AnnualReportPage() { const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false) const [nativeTimedOut, setNativeTimedOut] = useState(false) const [isGenerating, setIsGenerating] = useState(false) + const [isRouteTransitioning, setIsRouteTransitioning] = useState(false) + const [launchingYearLabel, setLaunchingYearLabel] = useState('') const [loadError, setLoadError] = useState(null) + const launchTimerRef = useRef(null) useEffect(() => { let disposed = false @@ -186,21 +191,37 @@ function AnnualReportPage() { } }, []) - const handleGenerateReport = async () => { - if (selectedYear === null) return - setIsGenerating(true) - try { - const yearParam = selectedYear === 'all' ? 0 : selectedYear - navigate(`/annual-report/view?year=${yearParam}`) - } catch (e) { - console.error('生成报告失败:', e) - } finally { - setIsGenerating(false) + useEffect(() => { + return () => { + if (launchTimerRef.current !== null) { + window.clearTimeout(launchTimerRef.current) + } } + }, []) + + const handleGenerateReport = () => { + if (selectedYear === null || isRouteTransitioning) return + const yearParam = selectedYear === 'all' ? 0 : selectedYear + const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}年` + setIsGenerating(true) + setIsRouteTransitioning(true) + setLaunchingYearLabel(yearLabel) + if (launchTimerRef.current !== null) { + window.clearTimeout(launchTimerRef.current) + } + launchTimerRef.current = window.setTimeout(() => { + try { + navigate(`/annual-report/view?year=${yearParam}`) + } catch (e) { + console.error('生成报告失败:', e) + setIsGenerating(false) + setIsRouteTransitioning(false) + } + }, REPORT_LAUNCH_DELAY_MS) } const handleGenerateDualReport = () => { - if (selectedPairYear === null) return + if (selectedPairYear === null || isRouteTransitioning) return const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear navigate(`/dual-report?year=${yearParam}`) } @@ -251,7 +272,7 @@ function AnnualReportPage() { ) return ( -
+

年度报告

选择年份,回顾你在微信里的点点滴滴

@@ -270,8 +291,11 @@ function AnnualReportPage() { {yearOptions.map(option => (
setSelectedYear(option)} + className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`} + onClick={() => { + if (isRouteTransitioning) return + setSelectedYear(option) + }} > {option === 'all' ? '全部' : option} {option === 'all' ? '时间' : '年'} @@ -281,14 +305,14 @@ function AnnualReportPage() {
+ + {isRouteTransitioning && ( +
+
+ +

正在进入{launchingYearLabel}年度报告

+

正在整理你的聊天记忆...

+
+
+ )}
) } diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index 3f786b8..126aad4 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1,4 +1,50 @@ -@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'); +@font-face { + font-family: 'InterLocal'; + src: url('../../resources/fonts/annual-report/Inter-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: 'PlayfairDisplayLocal'; + src: url('../../resources/fonts/annual-report/PlayfairDisplay-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 400 900; + font-display: swap; +} + +@font-face { + font-family: 'CormorantGaramondLocal'; + src: url('../../resources/fonts/annual-report/CormorantGaramond-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 300 700; + font-display: swap; +} + +@font-face { + font-family: 'NotoSerifSCLocal'; + src: url('../../resources/fonts/annual-report/NotoSerifSC-Var.ttf') format('truetype'); + font-style: normal; + font-weight: 200 900; + font-display: swap; +} + +@font-face { + font-family: 'SpaceMonoLocal'; + src: url('../../resources/fonts/annual-report/SpaceMono-Regular.ttf') format('truetype'); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'SpaceMonoLocal'; + src: url('../../resources/fonts/annual-report/SpaceMono-Bold.ttf') format('truetype'); + font-style: normal; + font-weight: 700; + font-display: swap; +} .annual-report-window { --c-bg: #050505; @@ -10,7 +56,7 @@ background-color: var(--c-bg); color: var(--c-text); - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-family: 'InterLocal', 'NotoSerifSCLocal', -apple-system, BlinkMacSystemFont, sans-serif; -webkit-font-smoothing: antialiased; overflow: hidden; overscroll-behavior: none; @@ -55,12 +101,54 @@ } } + &[data-scene="10"] .top-controls .close-btn { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.22); + color: rgba(0, 0, 0, 0.68); + } + + &[data-scene="10"] .top-controls .close-btn:hover { + background: rgba(0, 0, 0, 0.14); + color: #000; + } + + .p0-bg-layer { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0; + transition: opacity 1.1s var(--ease-out); + } + + &[data-scene="0"] .p0-bg-layer { + opacity: 1; + } + + &[data-scene="1"] .p0-bg-layer { + opacity: 0.16; + } + + .p0-particle-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.48; + } + + .p0-center-glow { + position: absolute; + inset: 0; + background: radial-gradient(circle at center, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.015) 38%, transparent 70%); + } + /* 细微的电影噪点 */ .film-grain { position: absolute; inset: 0; z-index: 9999; - opacity: 0.04; + opacity: 0.018; pointer-events: none; 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'); @@ -78,18 +166,25 @@ background: #fff; /* FORCE SOLID WHITE CORE */ } - /* S0: 记忆奇点 */ + /* S0: 年份下方引线(保留后续场景形变) */ &[data-scene="0"] #memory-core { - top: 40vh; + top: 84vh; left: 50vw; - width: 8px; - height: 8px; - border-radius: 50%; + width: clamp(120px, 16vw, 220px); + height: 1px; + border-radius: 999px; opacity: 1; - box-shadow: 0 0 20px #fff, 0 0 40px rgba(255, 255, 255, 0.5); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.35); filter: blur(0px); } + @media (max-width: 1024px) { + &[data-scene="0"] #memory-core { + top: 81vh; + width: clamp(96px, 22vw, 180px); + } + } + /* S1: 深海地平线底光 */ &[data-scene="1"] #memory-core { top: 100vh; @@ -116,14 +211,14 @@ /* S3: 竖直时间引线 (内容线段,可被 S4 过渡形变) */ &[data-scene="3"] #memory-core { - top: 55vh; - left: 20vw; - width: 2px; - height: 50vh; - border-radius: 2px; + top: var(--s3-line-top, 48vh); + left: var(--s3-line-left, calc(50vw - min(36vw, 440px) + 12px)); + width: 1px; + height: var(--s3-line-height, clamp(240px, 34vh, 320px)); + border-radius: 1px; background: #fff; - opacity: 0.6; - box-shadow: 0 0 12px rgba(255, 255, 255, 0.3); + opacity: 0.55; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.28); filter: blur(0px); } @@ -241,15 +336,15 @@ } .serif { - font-family: 'Noto Serif SC', 'Cormorant Garamond', serif; + font-family: 'NotoSerifSCLocal', 'CormorantGaramondLocal', serif; } .mono { - font-family: 'Space Mono', monospace; + font-family: 'SpaceMonoLocal', 'NotoSerifSCLocal', monospace; } .num-display { - font-family: 'Inter', -apple-system, sans-serif; + font-family: 'InterLocal', -apple-system, sans-serif; font-variant-numeric: tabular-nums; font-weight: 500; letter-spacing: -0.02em; @@ -260,9 +355,11 @@ position: absolute; top: 6vh; left: 4vw; - font-size: 0.8rem; - color: var(--c-text-muted); - letter-spacing: 0.4em; + font-size: clamp(0.9rem, 1.05vw, 1.05rem); + color: rgba(255, 255, 255, 0.66); + letter-spacing: 0.28em; + font-weight: 500; + text-rendering: optimizeLegibility; z-index: 10; } @@ -303,6 +400,13 @@ transition: all 0.6s ease; } + &.exporting-scenes .top-controls, + &.exporting-scenes .pagination, + &.exporting-scenes .swipe-hint { + opacity: 0 !important; + visibility: hidden !important; + } + .delay-1 { transition-delay: 0.1s; } @@ -316,18 +420,42 @@ } /* 场景排版 */ + #scene-0 { + text-align: center; + } + + #scene-0 .scene0-cn-tag { + letter-spacing: 0.22em; + font-weight: 500; + } + #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; + font-family: 'PlayfairDisplayLocal', 'CormorantGaramondLocal', serif; + font-size: clamp(6.8rem, 21vw, 18rem); + line-height: 1.02; + letter-spacing: -0.04em; + margin-top: 10vh; + text-shadow: 0 18px 45px rgba(0, 0, 0, 0.45); + } + + #scene-0 .title-year-wrap { + padding: clamp(6px, 0.8vh, 14px) 0; + } + + #scene-0 .p0-desc { + margin-top: clamp(9vh, 11vh, 13vh); + } + + #scene-0 .p0-desc-inner { + font-size: clamp(1rem, 1.35vw, 1.2rem); + line-height: 2; + color: rgba(255, 255, 255, 0.78); + letter-spacing: 0.08em; } #scene-1 .title-data { font-size: clamp(5.5rem, 16vw, 13rem); - font-family: 'Inter'; + font-family: 'InterLocal'; font-weight: 300; letter-spacing: -0.05em; line-height: 1; @@ -350,26 +478,27 @@ } #scene-3 { - align-items: stretch; + align-items: center; justify-content: flex-start; - padding: 0; + padding: 0 8vw; } #scene-3 .en-tag { - left: 25vw; - transform: none; - top: 9vh; + left: 4vw; + top: 6vh; } #scene-3 .s3-layout { position: absolute; - top: 21vh; - left: 25vw; - right: 12vw; - max-width: min(780px, 62vw); + top: 20vh; + left: 50%; + transform: translateX(-50%); + width: min(880px, 72vw); + max-width: 100%; display: flex; flex-direction: column; - gap: clamp(5vh, 7vh, 9vh); + gap: clamp(4vh, 5vh, 7vh); + padding-left: clamp(52px, 7vw, 108px); } #scene-3 .s3-subtitle-wrap { @@ -378,15 +507,16 @@ } #scene-3 .s3-subtitle { - font-size: clamp(0.95rem, 1.2vw, 1.1rem); + font-size: clamp(1rem, 1.25vw, 1.15rem); color: rgba(255, 255, 255, 0.55); letter-spacing: 0.05em; + line-height: 1.7; } #scene-3 .contact-list { display: flex; flex-direction: column; - gap: clamp(3.5vh, 4.5vh, 6vh); + gap: clamp(3.2vh, 4vh, 5.5vh); margin-top: 0; width: 100%; max-width: none; @@ -398,62 +528,284 @@ } #scene-3 .c-item { - display: flex; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: end; + column-gap: clamp(36px, 8vw, 140px); width: 100%; - min-height: clamp(54px, 7.5vh, 80px); + min-height: clamp(58px, 8vh, 88px); } #scene-3 .c-info { display: flex; flex-direction: column; - gap: 5px; + gap: 6px; + min-width: 0; } #scene-3 .c-name { - font-size: 2rem; + font-size: clamp(2rem, 4.3vw, 3.2rem); line-height: 1; - letter-spacing: 0.05em; + letter-spacing: 0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(56vw, 460px); } #scene-3 .c-sub { - font-size: 0.65rem; + font-size: 0.68rem; color: var(--c-text-muted); + letter-spacing: 0.08em; } #scene-3 .c-count { - font-size: 1.2rem; - font-family: 'Space Mono'; + font-size: clamp(1.4rem, 2.2vw, 2rem); + font-family: 'SpaceMonoLocal'; + line-height: 1; + text-align: right; + min-width: 7ch; } @media (max-width: 1280px) { - #scene-3 .en-tag { - left: 22vw; - } - #scene-3 .s3-layout { - left: 22vw; - right: 10vw; - max-width: min(760px, 68vw); + width: min(820px, 76vw); + padding-left: clamp(44px, 6vw, 92px); } } @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; + top: 18.5vh; + width: min(760px, 86vw); + padding-left: clamp(30px, 5vw, 60px); } #scene-3 .c-name { - font-size: 1.8rem; + font-size: clamp(1.65rem, 5.2vw, 2.4rem); + } + + #scene-3 .c-count { + font-size: clamp(1.2rem, 2.8vw, 1.75rem); + } + } + + @media (max-width: 760px) { + #scene-3 .s3-layout { + top: 17.5vh; + gap: clamp(3vh, 4.5vh, 5vh); + width: 90vw; + padding-left: 26px; + } + + #scene-3 .contact-list { + gap: clamp(2.8vh, 3.4vh, 4vh); + } + + #scene-3 .c-item { + column-gap: 24px; + min-height: 52px; + } + + #scene-3 .c-sub { + font-size: 0.62rem; + } + } + + #scene-8 { + align-items: flex-start; + justify-content: flex-start; + padding: 0 6vw; + } + + #scene-8 .s8-layout { + position: absolute; + top: 18vh; + left: 50%; + transform: translateX(-50%); + width: min(1240px, 86vw); + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr); + column-gap: clamp(34px, 4.8vw, 84px); + align-items: start; + } + + #scene-8 .s8-left { + display: flex; + flex-direction: column; + gap: clamp(2.5vh, 3.2vh, 4vh); + padding-top: clamp(8vh, 9vh, 11vh); + } + + #scene-8 .s8-name-wrap, + #scene-8 .s8-summary-wrap, + #scene-8 .s8-quote-wrap, + #scene-8 .s8-letter-wrap { + display: block; + width: 100%; + } + + #scene-8 .s8-name { + font-size: clamp(3.2rem, 7.4vw, 5.6rem); + color: rgba(255, 255, 255, 0.72); + letter-spacing: 0.08em; + line-height: 1.05; + } + + #scene-8 .s8-summary { + max-width: 34ch; + font-size: clamp(1.06rem, 1.35vw, 1.35rem); + color: rgba(255, 255, 255, 0.62); + line-height: 1.95; + letter-spacing: 0.02em; + } + + #scene-8 .s8-summary-count { + margin: 0 8px; + font-size: clamp(1.35rem, 2vw, 1.75rem); + color: #d8d8d8; + white-space: nowrap; + } + + #scene-8 .s8-quote { + max-width: 32ch; + font-size: clamp(0.98rem, 1.12vw, 1.1rem); + color: rgba(255, 255, 255, 0.5); + line-height: 1.9; + } + + #scene-8 .s8-letter-wrap { + margin-top: clamp(3vh, 4vh, 5.5vh); + } + + #scene-8 .s8-letter { + position: relative; + padding: clamp(24px, 3.2vh, 38px) clamp(20px, 2.6vw, 34px) clamp(24px, 3.2vh, 38px) clamp(30px, 3.2vw, 44px); + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02)); + font-size: clamp(0.95rem, 1.05vw, 1.08rem); + line-height: 2; + color: rgba(255, 255, 255, 0.72); + text-align: left; + text-shadow: 0 4px 16px rgba(0, 0, 0, 0.22); + } + + #scene-8 .s8-letter::before { + content: ''; + position: absolute; + top: 20px; + left: 14px; + width: 2px; + height: calc(100% - 40px); + border-radius: 2px; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.06)); + } + + #scene-8 .s8-empty-wrap { + display: block; + width: min(760px, 78vw); + margin-top: 24vh; + text-align: center; + } + + #scene-8 .s8-empty-text { + color: rgba(255, 255, 255, 0.74); + line-height: 2; + } + + @media (max-width: 1280px) { + #scene-8 .s8-layout { + width: min(1120px, 88vw); + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + column-gap: clamp(28px, 4vw, 56px); + } + + #scene-8 .s8-left { + padding-top: clamp(6vh, 8vh, 9vh); + } + } + + @media (max-width: 1024px) { + #scene-8 .s8-layout { + top: 16vh; + width: min(900px, 90vw); + grid-template-columns: 1fr; + row-gap: clamp(3vh, 3.5vh, 4.5vh); + } + + #scene-8 .s8-left { + padding-top: 0; + gap: clamp(1.6vh, 2.2vh, 2.8vh); + } + + #scene-8 .s8-name { + font-size: clamp(2.4rem, 8.4vw, 4.2rem); + letter-spacing: 0.06em; + } + + #scene-8 .s8-summary, + #scene-8 .s8-quote { + max-width: none; + } + + #scene-8 .s8-letter-wrap { + margin-top: 0; + } + + #scene-8 .s8-letter { + font-size: clamp(0.9rem, 1.9vw, 1rem); + line-height: 1.95; + } + } + + @media (max-width: 760px) { + #scene-8 .s8-layout { + top: 14.5vh; + width: 92vw; + row-gap: clamp(2.2vh, 3vh, 3.8vh); + } + + #scene-8 .s8-name { + font-size: clamp(2rem, 10vw, 3rem); + } + + #scene-8 .s8-summary { + font-size: clamp(0.92rem, 3.9vw, 1rem); + line-height: 1.85; + } + + #scene-8 .s8-summary-count { + margin: 0 6px; + font-size: clamp(1.1rem, 4.8vw, 1.35rem); + } + + #scene-8 .s8-quote { + font-size: clamp(0.86rem, 3.5vw, 0.95rem); + line-height: 1.8; + } + + #scene-8 .s8-letter { + border-radius: 14px; + padding: 16px 16px 16px 24px; + font-size: clamp(0.82rem, 3.4vw, 0.9rem); + line-height: 1.82; + } + + #scene-8 .s8-letter::before { + top: 16px; + left: 11px; + height: calc(100% - 32px); + } + + #scene-8 .s8-empty-wrap { + width: 88vw; + margin-top: 23vh; + } + + #scene-8 .s8-empty-text { + font-size: 1rem; + line-height: 1.9; } } @@ -468,7 +820,7 @@ .word-burst { position: absolute; - font-family: 'Noto Serif SC'; + font-family: 'NotoSerifSCLocal'; font-weight: 500; white-space: nowrap; transform: translate(-50%, -50%) scale(0.8); @@ -523,7 +875,7 @@ cursor: pointer; transition: all 0.4s var(--ease-out); margin-top: 3vh; - font-family: 'Space Mono'; + font-family: 'SpaceMonoLocal'; font-size: 0.75rem; letter-spacing: 0.15em; border: none; @@ -573,14 +925,16 @@ bottom: 5vh; left: 50%; transform: translateX(-50%); - font-family: 'Space Mono'; - font-size: 0.6rem; - letter-spacing: 0.4em; - color: var(--c-text-muted); + font-family: 'SpaceMonoLocal'; + font-size: clamp(0.74rem, 0.9vw, 0.9rem); + letter-spacing: 0.28em; + color: rgba(255, 255, 255, 0.56); + font-weight: 500; + text-rendering: geometricPrecision; z-index: 100; + opacity: 0; transition: opacity 0.8s ease; pointer-events: none; - mix-blend-mode: difference; } &[data-scene="0"] .swipe-hint { @@ -613,10 +967,13 @@ } &.loading { + animation: loadingPageEnter 0.46s var(--ease-out) both; + .loading-ring { position: relative; width: 160px; height: 160px; + animation: loadingRingEnter 0.52s var(--ease-epic) both; svg { width: 100%; @@ -655,12 +1012,49 @@ font-weight: 600; color: #fff; margin-top: 24px; + animation: loadingTextEnter 0.52s var(--ease-out) both; + animation-delay: 0.06s; } .loading-hint { font-size: 14px; color: var(--c-text-muted); margin-top: 4px; + animation: loadingTextEnter 0.52s var(--ease-out) both; + animation-delay: 0.12s; + } + } + + @keyframes loadingPageEnter { + from { + opacity: 0; + filter: blur(12px); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes loadingRingEnter { + from { + opacity: 0; + transform: translateY(12px) scale(0.94); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes loadingTextEnter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); } } } diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index bba55c8..2e5c614 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { X } from 'lucide-react' +import html2canvas from 'html2canvas' import { finishBackgroundTask, isBackgroundTaskCancelRequested, @@ -37,7 +38,13 @@ interface AnnualReportData { midnightKing: { displayName: string; count: number; percentage: number } | null selfAvatarUrl?: string mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null - socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null + socialInitiative?: { + initiatedChats: number + receivedChats: number + initiativeRate: number + topInitiatedFriend?: string + topInitiatedCount?: number + } | null responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null topPhrases?: { phrase: string; count: number }[] snsStats?: { @@ -56,11 +63,21 @@ interface AnnualReportData { } | null } -const DecodeText = ({ value, active }: { value: string | number, active: boolean }) => { - const [display, setDisplay] = useState('000') +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 @@ -93,6 +110,7 @@ const DecodeText = ({ value, active }: { value: string | number, active: boolean function AnnualReportWindow() { const navigate = useNavigate() + const containerRef = useRef(null) const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -102,6 +120,10 @@ function AnnualReportWindow() { const TOTAL_SCENES = 11 const [currentScene, setCurrentScene] = useState(0) const [isAnimating, setIsAnimating] = useState(false) + const p0CanvasRef = useRef(null) + const s3LayoutRef = useRef(null) + const s3ListRef = useRef(null) + const [s3LineVars, setS3LineVars] = useState({}) // 提取长图逻辑变量 const [buttonText, setButtonText] = useState('EXTRACT RECORD') @@ -230,6 +252,139 @@ function AnnualReportWindow() { } }, [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 + + let rafId = 0 + let particles: Array<{ + x: number + y: number + vx: number + vy: number + size: number + alpha: number + }> = [] + + const buildParticle = () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.3, + size: Math.random() * 1.5 + 0.5, + alpha: Math.random() * 0.5 + 0.1 + }) + + const initParticles = () => { + const count = Math.max(36, Math.floor((canvas.width * canvas.height) / 15000)) + particles = Array.from({ length: count }, () => buildParticle()) + } + + const resizeCanvas = () => { + canvas.width = window.innerWidth + canvas.height = window.innerHeight + initParticles() + } + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + + for (let i = 0; i < particles.length; i++) { + const p = particles[i] + p.x += p.vx + p.y += p.vy + + if (p.x < 0 || p.x > canvas.width) p.vx *= -1 + if (p.y < 0 || p.y > canvas.height) p.vy *= -1 + + ctx.beginPath() + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) + ctx.fillStyle = `rgba(255, 255, 255, ${p.alpha})` + ctx.fill() + + for (let j = i + 1; j < particles.length; j++) { + const q = particles[j] + const dx = p.x - q.x + const dy = p.y - q.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance < 150) { + const lineAlpha = (1 - distance / 150) * 0.15 + ctx.beginPath() + ctx.moveTo(p.x, p.y) + ctx.lineTo(q.x, q.y) + ctx.strokeStyle = `rgba(255, 255, 255, ${lineAlpha})` + ctx.lineWidth = 0.5 + ctx.stroke() + } + } + } + + rafId = requestAnimationFrame(animate) + } + + resizeCanvas() + window.addEventListener('resize', resizeCanvas) + animate() + + return () => { + window.removeEventListener('resize', resizeCanvas) + cancelAnimationFrame(rafId) + } + }, [isLoading, error, reportData, currentScene]) + + useEffect(() => { + if (isLoading || error || !reportData) return + + let rafId = 0 + + const updateS3Line = () => { + cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + const root = document.querySelector('.annual-report-window') as HTMLElement | null + const layout = s3LayoutRef.current + const list = s3ListRef.current + if (!root || !layout || !list) return + + const rootRect = root.getBoundingClientRect() + const layoutRect = layout.getBoundingClientRect() + const listRect = list.getBoundingClientRect() + if (listRect.height <= 0 || layoutRect.width <= 0) return + + const leftOffset = Math.max(8, Math.min(16, layoutRect.width * 0.018)) + const lineLeft = layoutRect.left - rootRect.left + leftOffset + const lineCenterTop = listRect.top - rootRect.top + listRect.height / 2 + + setS3LineVars({ + ['--s3-line-left' as '--s3-line-left']: `${lineLeft}px`, + ['--s3-line-top' as '--s3-line-top']: `${lineCenterTop}px`, + ['--s3-line-height' as '--s3-line-height']: `${listRect.height}px` + } as React.CSSProperties) + }) + } + + updateS3Line() + window.addEventListener('resize', updateS3Line) + + const resizeObserver = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => updateS3Line()) + : null + + if (resizeObserver) { + if (s3LayoutRef.current) resizeObserver.observe(s3LayoutRef.current) + if (s3ListRef.current) resizeObserver.observe(s3ListRef.current) + } + + return () => { + cancelAnimationFrame(rafId) + window.removeEventListener('resize', updateS3Line) + resizeObserver?.disconnect() + } + }, [isLoading, error, reportData, currentScene]) + const getSceneClass = (index: number) => { if (index === currentScene) return 'scene active' if (index < currentScene) return 'scene prev' @@ -240,23 +395,89 @@ function AnnualReportWindow() { navigate('/home') } - const handleExtract = () => { - if (isExtracting) return + const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + 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]) return + + const root = containerRef.current + const previousScene = currentScene + const sceneNames = [ + 'THE_ARCHIVE', + 'VOLUME', + 'NOCTURNE', + 'GRAVITY_CENTERS', + 'TIME_WAVEFORM', + 'MUTUAL_RESONANCE', + 'SOCIAL_KINETICS', + 'THE_SPARK', + 'FADING_SIGNALS', + 'LEXICON', + 'EXTRACTION' + ] + setIsExtracting(true) setButtonText('EXTRACTING...') - setTimeout(() => { + + try { + const images: Array<{ name: string; dataUrl: string }> = [] + root.classList.add('exporting-scenes') + + for (let i = 0; i < TOTAL_SCENES; i++) { + setCurrentScene(i) + setButtonText(`EXTRACTING ${i + 1}/${TOTAL_SCENES}`) + await wait(2000) + + const canvas = await html2canvas(root, { + backgroundColor: '#050505', + scale: 2, + useCORS: true, + allowTaint: true, + logging: false, + onclone: (clonedDoc) => { + clonedDoc.querySelector('.annual-report-window')?.classList.add('exporting-scenes') + } + }) + + images.push({ + name: `P${String(i).padStart(2, '0')}_${sceneNames[i] || `SCENE_${i}`}.png`, + dataUrl: canvas.toDataURL('image/png') + }) + } + + 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('SAVED TO DEVICE') + } catch (e) { + alert(`导出失败: ${String(e)}`) + setButtonText('EXTRACT RECORD') + } finally { + root.classList.remove('exporting-scenes') + setCurrentScene(previousScene) + await wait(80) + 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) + }, 2200) + } } if (isLoading) { @@ -294,13 +515,23 @@ function AnnualReportWindow() { } const yearTitle = reportData.year === 0 ? '历史以来' : String(reportData.year) + const finalYearLabel = reportData.year === 0 ? 'ALL YEARS' : String(reportData.year) const topFriends = reportData.coreFriends.slice(0, 3) + const endingPostCount = reportData.snsStats?.totalPosts ?? 0 + const endingReceivedChats = reportData.socialInitiative?.receivedChats ?? 0 + const endingTopPhrase = reportData.topPhrases?.[0]?.phrase || '' + const endingTopPhraseCount = reportData.topPhrases?.[0]?.count ?? 0 return ( -
+
+ +
+ +
+
@@ -316,25 +547,25 @@ function AnnualReportWindow() { ))}
-
SCROLL OR SWIPE
+
向下滑动以继续
{/* S0: THE ARCHIVE */}
-
THE ARCHIVE
+
一切的起点
-
+
{yearTitle}
-
-
记忆是散落的碎片。
而数据,是贯穿它们的流线。
+
+
那些被岁月悄悄掩埋的对话
原来都在这里,等待一个春天。
{/* S1: VOLUME */}
-
VOLUME
+
消息报告
@@ -343,7 +574,7 @@ function AnnualReportWindow() {
- 这是你在这一段时间的发声总数。
在这片数据深海,你曾向世界抛出 {reportData.totalMessages.toLocaleString()} 个锚点。 + 这一年,你说出了 {reportData.totalMessages.toLocaleString()} 句话。
无数个日夜的碎碎念,都是为了在茫茫人海中,刻下彼此来过的痕迹。
@@ -351,7 +582,7 @@ function AnnualReportWindow() { {/* S2: NOCTURNE */}
-
NOCTURNE
+
深夜
@@ -359,18 +590,17 @@ function AnnualReportWindow() {
-
- NIGHT +
+
+ 在深夜陪你聊天最多的人
- 白天的你属于喧嚣。
- 但在夜色中,你与深夜之王交换了 - + 梦境之外,你与{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}共同醒着度过了许多个夜晚
+ “曾有 - - 次脆弱的清醒。 +
条消息在那些无人知晓的夜里,代替星光照亮了彼此”
@@ -378,15 +608,15 @@ function AnnualReportWindow() { {/* S3: GRAVITY CENTERS */}
-
GRAVITY CENTERS
+
聊天排行
-
+
-
那些改变你时间流速的引力中心。
+
漫长的岁月里,是他们,让你的时间有了实实在在的重量。
-
+
{topFriends.map((f, i) => (
@@ -394,7 +624,7 @@ function AnnualReportWindow() {
{f.displayName}
-
FILE TRANSFER
+
TOP {i + 1}
{f.messageCount.toLocaleString()} @@ -418,10 +648,10 @@ function AnnualReportWindow() { {/* S4: TIME WAVEFORM (Audio/Heartbeat timeline visual) */}
-
TIME WAVEFORM
+
TIME WAVEFORM
-
十二簇记忆的声纹,
每一次波缓都有回响。
+
十二个月的更迭,就像走过了一万个冬天
时间在变,但好在总有人陪在身边。
{reportData.monthlyTopFriends.length > 0 ? ( @@ -487,7 +717,7 @@ function AnnualReportWindow() { {/* S5: MUTUAL RESONANCE (Mutual friend) */}
-
MUTUAL RESONANCE
+
回应的艺术
{reportData.mutualFriend ? ( <> @@ -498,89 +728,102 @@ function AnnualReportWindow() {
-
SEND
+
发出
-
RECEIVE
+
收到
- 平衡率高达 {reportData.mutualFriend.ratio} -
最完美的双向奔赴。 + 你们之间收发的消息高达 {reportData.mutualFriend.ratio} 的平衡率 +
+ “你抛出的每一句话,都落在了对方的心里。
所谓重逢,就是我走向你的时候,你也在走向我。”
) : ( -
今年依然在独自发出回声。
没有找到绝对平衡的双向奔赴。
+
今年似乎独自咽下了很多话。
请相信,分别和孤独总会迎来终结,你终会遇到那个懂你的TA。
)}
{/* S6: SOCIAL KINETICS */}
-
SOCIAL KINETICS
+
我的风格
{reportData.socialInitiative || reportData.responseSpeed ? (
{reportData.socialInitiative && (
-
INITIATIVE
+
我的主动性
- {reportData.socialInitiative.initiativeRate}% + {reportData.socialInitiative.initiativeRate}%
- 占据了绝对的主导。你主动发起了 次联络。
- 社交关系的齿轮,全靠你来转动。 +
+ 你的聊天开场大多由你发起。 +
+ {reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? ( +
+ 其中{reportData.socialInitiative.topInitiatedFriend}是你最常联系的人, + 有{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}次,是你先忍不住敲响了对方的门 +
+ ) : ( +
+ 你主动发起了{reportData.socialInitiative.initiatedChats.toLocaleString()}次联络。 +
+ )} + 想见一个人的心,总是走在时间的前面。
)} {reportData.responseSpeed && (
-
RESONANCE
+
回应速度
S
- 来自 {reportData.responseSpeed.fastestFriend} 的极速响应区。
- 在发出信号的瞬间,就得到了回响。 + {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} 次碰撞。 + “这一天,你们留下了 {reportData.peakDay.messageCount} 句话。
好像要把积攒了很久的想念,一天全都说完。”
) : null} @@ -593,32 +836,51 @@ function AnnualReportWindow() { {/* S8: FADING SIGNALS */}
-
FADING SIGNALS
+
曾经的好友
{reportData.lostFriend ? ( - <> -
-
- {reportData.lostFriend.displayName} +
+
+
+
+ {reportData.lostFriend.displayName} +
+
+
+
+ 后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的 + + + + 句话里。 +
+
+
+
+ “我一直相信我们能够再次相见,相信分别的日子总会迎来终结。” +
-
-
- 有些信号,逐渐沉入了深海。
- 曾经热络的交互,在 {reportData.lostFriend.periodDesc} 之后,
- 断崖般地降至 条。 +
+
+ 所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。
- +
) : ( -
没有走散的信号,
所有重要的人都还在。
+
+
+ 缘分温柔地眷顾着你。
+ 这一年,所有重要的人都在,没有一次无疾而终的告别。
+
+
)}
{/* S9: LEXICON & ARCHIVE */}
-
LEXICON
+
我的词云
{reportData.topPhrases && reportData.topPhrases.slice(0, 12).map((phrase, i) => { @@ -664,14 +926,14 @@ function AnnualReportWindow() { {/* S10: EXTRACTION (白色反色结束页 / Data Receipt) */}
-
END OF TRANSMISSION
+
旅程的终点
{/* The Final Summary Receipt / Dashboard */}
- 2024 + {finalYearLabel}
TRANSMISSION COMPLETE @@ -680,29 +942,40 @@ function AnnualReportWindow() { {/* Core Stats Row */}
-
RESONANCES
-
{reportData.totalMessages.toLocaleString()}
+
朋友圈发帖
+
{endingPostCount.toLocaleString()}
-
CONNECTIONS
-
{reportData.coreFriends.length}
+
被动开场
+
{endingReceivedChats.toLocaleString()}
-
LONGEST STREAK
-
{reportData.longestStreak?.days || 0}
+
你最爱说
+
“{endingTopPhrase}”
- “在这片完全属于你的净土,存写下了光阴的无尽长河。” + “故事的最后,我们把这一切悄悄还给岁月
只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
-
-
- 100% LOCAL COMPUTING.
YOUR DATA IS YOURS. +
+
+ 数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。
在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。
真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 30b6126..a45755e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -849,6 +849,8 @@ export interface ElectronAPI { initiatedChats: number receivedChats: number initiativeRate: number + topInitiatedFriend?: string + topInitiatedCount?: number } | null responseSpeed: { avgResponseTime: number