1 parent
33fde44cc3
commit
0f8a9602bd
3 files changed
+750
-1828
No files matched your search
+9
-3
@@ -81,6 +81,7 @@ function App() {
|
|||||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||||
|
const isDualReportWindow = location.pathname === '/dual-report/view'
|
||||||
const isSettingsRoute = location.pathname === '/settings'
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||||
const routeLocation = isSettingsRoute
|
const routeLocation = isSettingsRoute
|
||||||
@@ -128,7 +129,7 @@ function App() {
|
|||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -145,7 +146,7 @@ function App() {
|
|||||||
appRoot.style.overflow = ''
|
appRoot.style.overflow = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,7 +167,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
mq.addEventListener('change', handler)
|
mq.addEventListener('change', handler)
|
||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -517,6 +518,11 @@ function App() {
|
|||||||
return <AnnualReportWindow />
|
return <AnnualReportWindow />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立双人报告全屏窗口
|
||||||
|
if (isDualReportWindow) {
|
||||||
|
return <DualReportWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
const handleCloseSettings = () => {
|
const handleCloseSettings = () => {
|
||||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
|||||||
+244
-858
@@ -1,350 +1,121 @@
|
|||||||
|
/* DualReportWindow.scss */
|
||||||
.annual-report-window.dual-report-window {
|
.annual-report-window.dual-report-window {
|
||||||
.hero-title {
|
|
||||||
font-size: clamp(22px, 4vw, 34px);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-cover-title {
|
/* 全局公用样式补充 */
|
||||||
font-size: clamp(26px, 5vw, 44px);
|
.center-layout {
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-names {
|
|
||||||
font-size: clamp(24px, 4vw, 40px);
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 8px 0 16px;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
|
|
||||||
.amp {
|
|
||||||
color: var(--ar-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-info-card {
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
&.full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-message-list {
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-message {
|
.title {
|
||||||
padding: 14px;
|
font-size: clamp(3rem, 10vw, 7rem);
|
||||||
|
font-family: 'PlayfairDisplayLocal', 'CormorantGaramondLocal', serif;
|
||||||
.message-meta {
|
letter-spacing: 0.15em;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-chat-scene {
|
|
||||||
padding: 18px 16px 16px;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-chat-scene::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-title {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-subtitle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-bottom: 2vh;
|
||||||
|
color: var(--c-gold-strong);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 20px;
|
|
||||||
opacity: 0.9;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-messages {
|
/* 针对双人报告特有场景(S1-S8)的排版补充 */
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-message {
|
/* =========================================
|
||||||
|
S1: 首次聊天 (First Encounter)
|
||||||
|
========================================= */
|
||||||
|
#scene-1 .s1-layout {
|
||||||
|
width: min(880px, 80vw);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 32px;
|
gap: 3vh;
|
||||||
|
margin-top: 10vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s1-messages {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
&.system {
|
|
||||||
margin: 16px 0;
|
|
||||||
|
|
||||||
.system-msg-content {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
text-align: center;
|
|
||||||
max-width: 80%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-meta {
|
|
||||||
font-size: 10px;
|
|
||||||
opacity: 0.65;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-body {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sent .scene-body {
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.received .scene-body {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-avatar {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--ar-card-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-content-wrapper {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
gap: 1.5vh;
|
||||||
max-width: min(78%, 720px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-message.sent .scene-content-wrapper {
|
.s1-message-item {
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-bubble {
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
padding: 10px 14px;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 40px;
|
|
||||||
max-width: 100%;
|
|
||||||
background: var(--ar-card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&.no-bubble {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-content {
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: clamp(14px, 1.8vw, 16px);
|
|
||||||
word-break: break-all;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
line-break: auto;
|
|
||||||
|
|
||||||
.report-emoji-container {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 2px 0;
|
|
||||||
|
|
||||||
.report-emoji-img {
|
|
||||||
max-width: 120px;
|
|
||||||
max-height: 120px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-avatar.fallback {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-avatar.with-image {
|
|
||||||
background: transparent;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-message.sent .scene-avatar {
|
|
||||||
border-color: rgba(var(--ar-primary-rgb), 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-stat-grid {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-direction: column;
|
||||||
gap: clamp(60px, 10vw, 120px);
|
|
||||||
margin: 48px 0 32px;
|
|
||||||
padding: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
&.bottom {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
gap: clamp(40px, 6vw, 80px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-stat-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 140px;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-num {
|
|
||||||
font-size: clamp(36px, 6vw, 64px);
|
|
||||||
font-weight: 800;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--ar-primary);
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.small {
|
|
||||||
font-size: clamp(24px, 4vw, 40px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-unit {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 4px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dual-stat-card.long .stat-num {
|
|
||||||
font-size: clamp(18px, 2.4vw, 26px);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin: 0 -12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-card {
|
|
||||||
padding: 18px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-placeholder {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
word-break: break-all;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-cloud-empty {
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.initiative-container {
|
|
||||||
padding: 32px 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
margin-bottom: 2vh;
|
||||||
border: none;
|
|
||||||
|
&.sent {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.s1-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s1-bubble {
|
||||||
|
background: rgba(var(--c-gold-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--c-gold-rgb), 0.2);
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--c-text-bright);
|
||||||
|
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 80%;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.sent {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
S2: 作息波纹 (Heatmap)
|
||||||
|
========================================= */
|
||||||
|
#scene-2 {
|
||||||
|
.heatmap-wrapper {
|
||||||
|
margin-top: 5vh;
|
||||||
|
width: min(1000px, 90vw);
|
||||||
|
|
||||||
|
/* Make heatmap grid fit cinematic dark style */
|
||||||
|
.heatmap {
|
||||||
|
background: transparent !important;
|
||||||
|
* { border-color: rgba(var(--c-gold-rgb), 0.1) !important; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.s2-active-text {
|
||||||
|
margin-top: 4vh;
|
||||||
|
font-size: clamp(1.2rem, 1.5vw, 1.4rem);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
.hl { color: var(--c-gold-strong); font-weight: 600; margin: 0 8px;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
S3: 情感的天平 (Initiative)
|
||||||
|
========================================= */
|
||||||
|
#scene-3 .initiative-container {
|
||||||
|
width: min(700px, 80vw);
|
||||||
|
margin-top: 12vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-bar-wrapper {
|
.initiative-bar-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32px;
|
gap: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
margin-bottom: 24px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,18 +128,16 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 54px;
|
width: 64px;
|
||||||
height: 54px;
|
height: 64px;
|
||||||
border-radius: 18px;
|
border-radius: 50%;
|
||||||
background: var(--bg-tertiary);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(var(--c-gold-rgb), 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
font-size: 1.2rem;
|
||||||
color: var(--ar-text-sub);
|
color: var(--c-text-muted);
|
||||||
font-size: 16px;
|
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.15);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@@ -379,606 +148,223 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-size: 11px;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
color: var(--c-text-muted);
|
||||||
opacity: 0.4;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
font-size: 14px;
|
font-size: 1.5rem;
|
||||||
color: var(--ar-text-main);
|
color: var(--c-gold-strong);
|
||||||
font-weight: 800;
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-progress {
|
.initiative-progress {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px; // 线条样式
|
height: 1px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.line-bg {
|
.line-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0; right: 0; height: 1px;
|
||||||
right: 0;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 255, 255, 0.1) 20%,
|
|
||||||
rgba(255, 255, 255, 0.1) 80%,
|
|
||||||
transparent 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-indicator {
|
.initiative-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 8px;
|
width: 12px; height: 12px;
|
||||||
height: 8px;
|
background: var(--c-gold-strong);
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
transition: left 1.5s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: left 1.5s var(--ease-epic);
|
||||||
box-shadow:
|
box-shadow: 0 0 20px var(--c-gold-strong);
|
||||||
0 0 10px #fff,
|
|
||||||
0 0 20px rgba(255, 255, 255, 0.5),
|
|
||||||
0 0 30px var(--ar-primary);
|
|
||||||
z-index: 3;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
left: -4px;
|
|
||||||
right: -4px;
|
|
||||||
bottom: -4px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-desc {
|
/* =========================================
|
||||||
text-align: center;
|
S4: 回应的速度 (Echoes)
|
||||||
font-size: 14px;
|
========================================= */
|
||||||
color: var(--ar-text-sub);
|
#scene-4 .response-wrapper {
|
||||||
letter-spacing: 1px;
|
|
||||||
opacity: 0.6;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(2);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.response-pulse-container {
|
|
||||||
width: 100%;
|
|
||||||
padding: 80px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-visual {
|
|
||||||
position: relative;
|
|
||||||
width: 420px;
|
|
||||||
height: 240px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-hub {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 6vh;
|
||||||
width: 160px;
|
margin-top: 15vh;
|
||||||
height: 160px;
|
}
|
||||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12) 0%, transparent 75%);
|
|
||||||
|
.response-circle {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 40px rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(var(--c-gold-rgb), 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 50px rgba(var(--c-gold-rgb), 0.1);
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 13px;
|
font-size: 1rem;
|
||||||
color: var(--ar-text-sub);
|
color: var(--c-text-muted);
|
||||||
opacity: 0.6;
|
letter-spacing: 0.2em;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: 54px;
|
font-size: 3.5rem;
|
||||||
font-weight: 950;
|
color: var(--c-gold-strong);
|
||||||
color: #fff;
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
line-height: 1;
|
text-shadow: 0 0 20px rgba(var(--c-gold-rgb), 0.4);
|
||||||
text-shadow: 0 0 30px rgba(255, 255, 255, 0.5);
|
|
||||||
|
|
||||||
span {
|
span { font-size: 1.2rem; margin-left: 4px; color: var(--c-text-soft); }
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-node {
|
.response-stats {
|
||||||
position: absolute;
|
display: flex;
|
||||||
|
gap: 10vw;
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
.label { color: var(--c-text-muted); font-size: 0.9rem; margin-bottom: 8px; letter-spacing: 0.1em; }
|
||||||
|
.value { color: var(--c-text-bright); font-size: 1.8rem; font-family: 'SpaceMonoLocal', monospace; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
S5: 连绵不绝的火花 (Streak)
|
||||||
|
========================================= */
|
||||||
|
#scene-5 .streak-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 4;
|
margin-top: 12vh;
|
||||||
animation: floatNode 4s ease-in-out infinite;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(-15%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
right: 0;
|
|
||||||
transform: translateX(15%);
|
|
||||||
animation-delay: -2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--ar-text-main);
|
|
||||||
opacity: 0.95;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-left: 2px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-ripple {
|
.streak-days {
|
||||||
position: absolute;
|
font-size: clamp(6rem, 15vw, 10rem);
|
||||||
top: 50%;
|
color: var(--c-gold-strong);
|
||||||
left: 50%;
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
transform: translate(-50%, -50%);
|
text-shadow: 0 0 40px rgba(var(--c-gold-rgb), 0.4);
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.08);
|
line-height: 1;
|
||||||
border-radius: 50%;
|
margin-bottom: 10px;
|
||||||
animation: ripplePulse 8s linear infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&.one {
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.two {
|
|
||||||
animation-delay: 2.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.three {
|
|
||||||
animation-delay: 5s;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ripplePulse {
|
.streak-label {
|
||||||
0% {
|
font-size: 1.5rem;
|
||||||
width: 140px;
|
color: var(--c-text-soft);
|
||||||
height: 140px;
|
letter-spacing: 0.5em;
|
||||||
opacity: 0.5;
|
margin-bottom: 4vh;
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
width: 700px;
|
|
||||||
height: 700px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes floatNode {
|
.streak-dates {
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translateY(-16px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-note {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-top: 32px;
|
|
||||||
font-style: italic;
|
|
||||||
max-width: none;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streak-spark-visual.premium {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
.spark-ambient-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: 40%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 600px;
|
|
||||||
height: 480px;
|
|
||||||
background: radial-gradient(circle at center, rgba(242, 170, 0, 0.04) 0%, transparent 70%);
|
|
||||||
filter: blur(60px);
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-core-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 220px;
|
|
||||||
height: 280px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 20px;
|
||||||
z-index: 5;
|
color: var(--c-text-muted);
|
||||||
animation: flameSway 6s ease-in-out infinite;
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
transform-origin: bottom center;
|
font-size: 0.9rem;
|
||||||
}
|
|
||||||
|
|
||||||
.spark-flame-outer {
|
.line {
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: radial-gradient(ellipse at 50% 85%, rgba(242, 170, 0, 0.15) 0%, transparent 75%);
|
|
||||||
border-radius: 50% 50% 20% 20% / 80% 80% 30% 30%;
|
|
||||||
filter: blur(25px);
|
|
||||||
animation: flickerOuter 4s infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-flame-inner {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20%;
|
|
||||||
width: 140px;
|
|
||||||
height: 180px;
|
|
||||||
background: radial-gradient(ellipse at 50% 90%, rgba(255, 215, 0, 0.2) 0%, transparent 80%);
|
|
||||||
border-radius: 50% 50% 30% 30% / 85% 85% 25% 25%;
|
|
||||||
filter: blur(12px);
|
|
||||||
animation: flickerInner 3s infinite alternate-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-core {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
|
|
||||||
.spark-days {
|
|
||||||
font-size: 84px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
text-shadow:
|
|
||||||
0 0 15px rgba(255, 255, 255, 0.4),
|
|
||||||
0 8px 30px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
letter-spacing: 6px;
|
|
||||||
margin-top: 12px;
|
|
||||||
text-indent: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.streak-bridge.premium {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
margin-top: -20px;
|
|
||||||
z-index: 20;
|
|
||||||
|
|
||||||
.bridge-date {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
width: 100px;
|
width: 100px;
|
||||||
|
height: 1px;
|
||||||
span {
|
background: rgba(var(--c-gold-rgb), 0.4);
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 24px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-orb {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 12px var(--ar-accent);
|
|
||||||
border: 1px solid rgba(252, 170, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bridge-line {
|
|
||||||
flex: 1;
|
|
||||||
height: 40px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.line-string {
|
|
||||||
width: 100%;
|
|
||||||
height: 1.5px;
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
rgba(242, 170, 0, 0) 0%,
|
|
||||||
rgba(242, 170, 0, 0.6) 20%,
|
|
||||||
rgba(242, 170, 0, 0.6) 80%,
|
|
||||||
rgba(242, 170, 0, 0) 100%);
|
|
||||||
mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-glow {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background: radial-gradient(ellipse at center, rgba(242, 170, 0, 0.2) 0%, transparent 80%);
|
|
||||||
filter: blur(4px);
|
|
||||||
animation: sparkFlicker 2s infinite alternate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spark-ember {
|
/* =========================================
|
||||||
position: absolute;
|
S6: 专属词典 (Lexicon)
|
||||||
background: #FFD700;
|
========================================= */
|
||||||
border-radius: 50%;
|
#scene-6 .word-cloud-wrapper-outer {
|
||||||
filter: blur(0.5px);
|
margin-top: 15vh;
|
||||||
box-shadow: 0 0 6px #F2AA00;
|
width: 80vw;
|
||||||
opacity: 0;
|
height: 50vh;
|
||||||
z-index: 4;
|
position: relative;
|
||||||
|
|
||||||
&.one {
|
.word-cloud-tabs {
|
||||||
width: 3px;
|
position: absolute;
|
||||||
height: 3px;
|
top: -6vh;
|
||||||
left: 46%;
|
|
||||||
animation: emberRise 5s infinite 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.two {
|
|
||||||
width: 2px;
|
|
||||||
height: 2px;
|
|
||||||
left: 53%;
|
|
||||||
animation: emberRise 4s infinite 1.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.three {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
animation: emberRise 6s infinite 2.5s;
|
transform: translateX(-50%);
|
||||||
}
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
&.four {
|
.tab-item {
|
||||||
width: 2.5px;
|
background: transparent;
|
||||||
height: 2.5px;
|
border: none;
|
||||||
left: 48%;
|
color: var(--c-text-muted);
|
||||||
animation: emberRise 5.5s infinite 3.8s;
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 12px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.active, &:hover {
|
||||||
|
color: var(--c-gold-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flameSway {
|
/* =========================================
|
||||||
|
S7: 绝对数据 (Stats)
|
||||||
|
========================================= */
|
||||||
|
#scene-7 .stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 4vh 8vw;
|
||||||
|
margin-top: 15vh;
|
||||||
|
|
||||||
0%,
|
.stat-card {
|
||||||
100% {
|
text-align: center;
|
||||||
transform: rotate(-1deg) skewX(-1deg);
|
background: transparent;
|
||||||
}
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
50% {
|
backdrop-filter: none;
|
||||||
transform: rotate(1.5deg) skewX(1deg);
|
padding: 0;
|
||||||
|
.val {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--c-gold-strong);
|
||||||
|
font-family: 'SpaceMonoLocal', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flickerOuter {
|
/* =========================================
|
||||||
|
S8: 漫长的归途 / 提取 (Extraction)
|
||||||
0%,
|
========================================= */
|
||||||
100% {
|
#scene-8 {
|
||||||
opacity: 0.15;
|
.extract-title {
|
||||||
filter: blur(25px);
|
font-size: clamp(3.5rem, 8vw, 5rem);
|
||||||
}
|
margin-top: 0;
|
||||||
|
color: var(--c-text-bright);
|
||||||
50% {
|
|
||||||
opacity: 0.25;
|
|
||||||
filter: blur(30px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flickerInner {
|
.extract-btn {
|
||||||
|
margin-top: 10vh;
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(1.08);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes emberRise {
|
|
||||||
0% {
|
|
||||||
transform: translateY(100px) scale(1);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
20% {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
80% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateY(-260px) scale(0.4);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sparkFlicker {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.9;
|
|
||||||
filter: brightness(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(1.03);
|
|
||||||
opacity: 1;
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.pulse-visual {
|
|
||||||
transform: scale(0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-avatar {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-content-wrapper {
|
|
||||||
max-width: min(86%, 500px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-bubble {
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Word Cloud Tabs
|
|
||||||
.word-cloud-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-cloud-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin: 0 auto 32px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ar-text-sub);
|
border: 1px solid rgba(var(--c-gold-rgb), 0.4);
|
||||||
font-size: 14px;
|
color: var(--c-gold-strong);
|
||||||
font-weight: 500;
|
padding: 16px 42px;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
border-radius: 30px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.4s var(--ease-epic);
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--ar-text-main);
|
background: rgba(var(--c-gold-rgb), 0.1);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
box-shadow: 0 0 20px rgba(var(--c-gold-rgb), 0.2);
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--ar-card-bg);
|
|
||||||
color: var(--ar-primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-cloud-container {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.fade-in {
|
|
||||||
animation: fadeIn 0.4s ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
color: var(--ar-text-sub);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 14px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+497
-967
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import html2canvas from 'html2canvas'
|
import { X, Check } from 'lucide-react'
|
||||||
import ReportHeatmap from '../components/ReportHeatmap'
|
import ReportHeatmap from '../components/ReportHeatmap'
|
||||||
import ReportWordCloud from '../components/ReportWordCloud'
|
import ReportWordCloud from '../components/ReportWordCloud'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import { drawPatternBackground } from '../utils/reportExport'
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
import './DualReportWindow.scss'
|
import './DualReportWindow.scss'
|
||||||
|
|
||||||
@@ -70,44 +75,63 @@ interface DualReportData {
|
|||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionInfo {
|
const DecodeText = ({ value, active }: { value: string | number; active: boolean }) => {
|
||||||
id: string
|
const strVal = String(value)
|
||||||
name: string
|
const [display, setDisplay] = useState(strVal)
|
||||||
ref: React.RefObject<HTMLElement | null>
|
const decodedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => { setDisplay(strVal) }, [strVal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
decodedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (decodedRef.current) return
|
||||||
|
decodedRef.current = true
|
||||||
|
|
||||||
|
const chars = '018X-/#*'
|
||||||
|
let iter = 0
|
||||||
|
const inv = setInterval(() => {
|
||||||
|
setDisplay(strVal.split('').map((c, i) => {
|
||||||
|
if (c === ',' || c === ' ' || c === ':') return c
|
||||||
|
if (i < iter) return strVal[i]
|
||||||
|
return chars[Math.floor(Math.random() * chars.length)]
|
||||||
|
}).join(''))
|
||||||
|
|
||||||
|
if (iter >= strVal.length) {
|
||||||
|
clearInterval(inv)
|
||||||
|
setDisplay(strVal)
|
||||||
|
}
|
||||||
|
iter += 1 / 3
|
||||||
|
}, 35)
|
||||||
|
return () => clearInterval(inv)
|
||||||
|
}, [active, strVal])
|
||||||
|
|
||||||
|
return <>{display.length > 0 ? display : value}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DualReportWindow() {
|
function DualReportWindow() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loadingStage, setLoadingStage] = useState('准备中')
|
|
||||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
|
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||||
|
|
||||||
|
const TOTAL_SCENES = 9
|
||||||
|
const [currentScene, setCurrentScene] = useState(0)
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
const p0CanvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const [buttonText, setButtonText] = useState('EXTRACT RECORD')
|
||||||
|
const [isExtracting, setIsExtracting] = useState(false)
|
||||||
|
|
||||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||||
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
|
||||||
const [exportProgress, setExportProgress] = useState('')
|
|
||||||
const [showExportModal, setShowExportModal] = useState(false)
|
|
||||||
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
|
|
||||||
const [fabOpen, setFabOpen] = useState(false)
|
|
||||||
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
|
||||||
|
|
||||||
const { themeMode } = useThemeStore()
|
|
||||||
|
|
||||||
const sectionRefs = {
|
|
||||||
cover: useRef<HTMLElement>(null),
|
|
||||||
firstChat: useRef<HTMLElement>(null),
|
|
||||||
yearFirstChat: useRef<HTMLElement>(null),
|
|
||||||
heatmap: useRef<HTMLElement>(null),
|
|
||||||
initiative: useRef<HTMLElement>(null),
|
|
||||||
response: useRef<HTMLElement>(null),
|
|
||||||
streak: useRef<HTMLElement>(null),
|
|
||||||
wordCloud: useRef<HTMLElement>(null),
|
|
||||||
stats: useRef<HTMLElement>(null),
|
|
||||||
ending: useRef<HTMLElement>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
@@ -124,6 +148,13 @@ function DualReportWindow() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const generateReport = async (friendUsername: string, year: number) => {
|
const generateReport = async (friendUsername: string, year: number) => {
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'dualReport',
|
||||||
|
title: '双人报告生成',
|
||||||
|
detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 双人年度报告`,
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoadingProgress(0)
|
setLoadingProgress(0)
|
||||||
@@ -131,407 +162,231 @@ function DualReportWindow() {
|
|||||||
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||||
setLoadingProgress(payload.progress)
|
setLoadingProgress(payload.progress)
|
||||||
setLoadingStage(payload.status)
|
setLoadingStage(payload.status)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: payload.status || '正在生成年度报告',
|
||||||
|
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
|
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载' })
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLoadingProgress(100)
|
setLoadingProgress(100)
|
||||||
setLoadingStage('完成')
|
setLoadingStage('完成')
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const normalizedResponse = result.data.response
|
finishBackgroundTask(taskId, 'completed', { detail: '双人报告生成完成' })
|
||||||
? {
|
setTimeout(() => {
|
||||||
...result.data.response,
|
setReportData(result.data!)
|
||||||
slowest: result.data.response.slowest ?? result.data.response.avg
|
setIsLoading(false)
|
||||||
}
|
}, 500)
|
||||||
: undefined
|
|
||||||
setReportData({
|
if (result.data.stats?.myTopEmojiUrl) {
|
||||||
...result.data,
|
setMyEmojiUrl(result.data.stats.myTopEmojiUrl)
|
||||||
response: normalizedResponse
|
}
|
||||||
})
|
if (result.data.stats?.friendTopEmojiUrl) {
|
||||||
setIsLoading(false)
|
setFriendEmojiUrl(result.data.stats.friendTopEmojiUrl)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: result.error || '生成失败' })
|
||||||
setError(result.error || '生成报告失败')
|
setError(result.error || '生成报告失败')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: String(e) })
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToScene = useCallback((index: number) => {
|
||||||
|
if (isAnimating || index === currentScene || index < 0 || index >= TOTAL_SCENES) return
|
||||||
|
setIsAnimating(true)
|
||||||
|
setCurrentScene(index)
|
||||||
|
setTimeout(() => { setIsAnimating(false) }, 1500)
|
||||||
|
}, [currentScene, isAnimating, TOTAL_SCENES])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadEmojis = async () => {
|
if (isLoading || error || !reportData) return
|
||||||
if (!reportData) return
|
|
||||||
setMyEmojiUrl(null)
|
let touchStartY = 0
|
||||||
setFriendEmojiUrl(null)
|
let lastWheelTime = 0
|
||||||
const stats = reportData.stats
|
|
||||||
if (stats.myTopEmojiUrl) {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
|
const now = Date.now()
|
||||||
if (res.success && res.localPath) {
|
if (now - lastWheelTime < 1000) return
|
||||||
setMyEmojiUrl(res.localPath)
|
if (Math.abs(e.deltaY) > 30) {
|
||||||
}
|
lastWheelTime = now
|
||||||
}
|
goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1)
|
||||||
if (stats.friendTopEmojiUrl) {
|
|
||||||
const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5)
|
|
||||||
if (res.success && res.localPath) {
|
|
||||||
setFriendEmojiUrl(res.localPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void loadEmojis()
|
|
||||||
}, [reportData])
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
touchStartY = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
const handleTouchEnd = (e: TouchEvent) => {
|
||||||
|
const deltaY = touchStartY - e.changedTouches[0].clientY
|
||||||
|
if (deltaY > 40) goToScene(currentScene + 1)
|
||||||
|
else if (deltaY < -40) goToScene(currentScene - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
window.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||||||
|
window.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||||
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('wheel', handleWheel)
|
||||||
|
window.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
}
|
||||||
|
}, [currentScene, isLoading, error, reportData, goToScene])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || error || !reportData || currentScene !== 0) return
|
||||||
|
|
||||||
|
const canvas = p0CanvasRef.current
|
||||||
|
const ctx = canvas?.getContext('2d')
|
||||||
|
if (!canvas || !ctx) return
|
||||||
|
|
||||||
|
canvas.width = canvas.offsetWidth * window.devicePixelRatio
|
||||||
|
canvas.height = canvas.offsetHeight * window.devicePixelRatio
|
||||||
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||||
|
|
||||||
|
let particles = Array.from({ length: 60 }).map(() => ({
|
||||||
|
x: Math.random() * canvas.offsetWidth,
|
||||||
|
y: Math.random() * canvas.offsetHeight,
|
||||||
|
r: Math.random() * 1.5 + 0.5,
|
||||||
|
dx: (Math.random() - 0.5) * 0.2,
|
||||||
|
dy: (Math.random() - 0.5) * 0.2,
|
||||||
|
alpha: Math.random() * 0.5 + 0.1
|
||||||
|
}))
|
||||||
|
|
||||||
|
let rafId: number
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
|
||||||
|
particles.forEach(p => {
|
||||||
|
p.x += p.dx
|
||||||
|
p.y += p.dy
|
||||||
|
if (p.x < 0 || p.x > canvas.offsetWidth) p.dx *= -1
|
||||||
|
if (p.y < 0 || p.y > canvas.offsetHeight) p.dy *= -1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(242, 170, 0, ${p.alpha})`
|
||||||
|
ctx.fill()
|
||||||
|
})
|
||||||
|
rafId = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
animate()
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(rafId)
|
||||||
|
}, [isLoading, error, reportData, currentScene])
|
||||||
|
|
||||||
|
const getSceneClass = (index: number) => {
|
||||||
|
if (index === currentScene) return 'scene active'
|
||||||
|
if (index < currentScene) return 'scene prev'
|
||||||
|
return 'scene next'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { navigate('/home') }
|
||||||
|
|
||||||
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
const waitForNextPaint = () => new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) })
|
||||||
|
})
|
||||||
|
|
||||||
const sanitizeFileNameSegment = (value: string) => {
|
const captureSceneDataUrl = async (): Promise<string> => {
|
||||||
const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim()
|
const captureFn = window.electronAPI.annualReport.captureCurrentWindow
|
||||||
return sanitized || '好友'
|
if (typeof captureFn !== 'function') throw new Error('当前版本未启用原生截图接口')
|
||||||
|
const captureResult = await captureFn()
|
||||||
|
if (!captureResult.success || !captureResult.dataUrl) throw new Error(captureResult.error || '原生截图失败')
|
||||||
|
return captureResult.dataUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvailableSections = (): SectionInfo[] => {
|
const handleExtract = async () => {
|
||||||
if (!reportData) return []
|
if (isExtracting || !reportData || !containerRef.current) return
|
||||||
|
|
||||||
const sections: SectionInfo[] = [
|
|
||||||
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
|
|
||||||
{ id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat }
|
|
||||||
]
|
|
||||||
|
|
||||||
if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) {
|
|
||||||
sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat })
|
|
||||||
}
|
|
||||||
if (reportData.heatmap) {
|
|
||||||
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
|
|
||||||
}
|
|
||||||
if (reportData.initiative) {
|
|
||||||
sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative })
|
|
||||||
}
|
|
||||||
if (reportData.response) {
|
|
||||||
sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response })
|
|
||||||
}
|
|
||||||
if (reportData.streak) {
|
|
||||||
sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak })
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud })
|
|
||||||
sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats })
|
|
||||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
|
||||||
|
|
||||||
return sections
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
|
|
||||||
const element = section.ref.current
|
|
||||||
if (!element) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const OUTPUT_WIDTH = 1920
|
|
||||||
const OUTPUT_HEIGHT = 1080
|
|
||||||
let wordCloudInner: HTMLElement | null = null
|
|
||||||
let wordTags: NodeListOf<HTMLElement> | null = null
|
|
||||||
let wordCloudOriginalStyle = ''
|
|
||||||
const wordTagOriginalStyles: string[] = []
|
|
||||||
const originalStyle = element.style.cssText
|
|
||||||
|
|
||||||
try {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
|
||||||
const activeEl = document.activeElement as HTMLElement | null
|
|
||||||
activeEl?.blur?.()
|
|
||||||
document.body.classList.add('exporting-snapshot')
|
|
||||||
document.documentElement.classList.add('exporting-snapshot')
|
|
||||||
|
|
||||||
element.style.minHeight = 'auto'
|
|
||||||
element.style.padding = '40px 20px'
|
|
||||||
element.style.background = 'transparent'
|
|
||||||
element.style.backgroundColor = 'transparent'
|
|
||||||
element.style.boxShadow = 'none'
|
|
||||||
|
|
||||||
wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null
|
|
||||||
wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
|
||||||
|
|
||||||
if (wordCloudInner) {
|
|
||||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
|
||||||
wordCloudInner.style.transform = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
wordTags.forEach((tag, index) => {
|
|
||||||
wordTagOriginalStyles[index] = tag.style.cssText
|
|
||||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
|
||||||
tag.style.animation = 'none'
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
|
||||||
|
|
||||||
const canvas = await html2canvas(element, {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
allowTaint: true,
|
|
||||||
logging: false,
|
|
||||||
onclone: (clonedDoc) => {
|
|
||||||
clonedDoc.body.classList.add('exporting-snapshot')
|
|
||||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
|
||||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const outputCanvas = document.createElement('canvas')
|
|
||||||
outputCanvas.width = OUTPUT_WIDTH
|
|
||||||
outputCanvas.height = OUTPUT_HEIGHT
|
|
||||||
const ctx = outputCanvas.getContext('2d')
|
|
||||||
if (!ctx) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = themeMode === 'dark'
|
|
||||||
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
|
|
||||||
|
|
||||||
const PADDING = 80
|
|
||||||
const contentWidth = OUTPUT_WIDTH - PADDING * 2
|
|
||||||
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
|
|
||||||
const srcRatio = canvas.width / canvas.height
|
|
||||||
const dstRatio = contentWidth / contentHeight
|
|
||||||
|
|
||||||
let drawWidth: number
|
|
||||||
let drawHeight: number
|
|
||||||
let drawX: number
|
|
||||||
let drawY: number
|
|
||||||
|
|
||||||
if (srcRatio > dstRatio) {
|
|
||||||
drawWidth = contentWidth
|
|
||||||
drawHeight = contentWidth / srcRatio
|
|
||||||
drawX = PADDING
|
|
||||||
drawY = PADDING + (contentHeight - drawHeight) / 2
|
|
||||||
} else {
|
|
||||||
drawHeight = contentHeight
|
|
||||||
drawWidth = contentHeight * srcRatio
|
|
||||||
drawX = PADDING + (contentWidth - drawWidth) / 2
|
|
||||||
drawY = PADDING
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
|
|
||||||
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
element.style.cssText = originalStyle
|
|
||||||
if (wordCloudInner) {
|
|
||||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
|
||||||
}
|
|
||||||
wordTags?.forEach((tag, index) => {
|
|
||||||
tag.style.cssText = wordTagOriginalStyles[index]
|
|
||||||
})
|
|
||||||
document.body.classList.remove('exporting-snapshot')
|
|
||||||
document.documentElement.classList.remove('exporting-snapshot')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportFullReport = async (filterIds?: Set<string>) => {
|
|
||||||
if (!containerRef.current || !reportData) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsExporting(true)
|
|
||||||
setExportProgress('正在生成长图...')
|
|
||||||
|
|
||||||
let wordCloudInner: HTMLElement | null = null
|
|
||||||
let wordTags: NodeListOf<HTMLElement> | null = null
|
|
||||||
let wordCloudOriginalStyle = ''
|
|
||||||
const wordTagOriginalStyles: string[] = []
|
|
||||||
const container = containerRef.current
|
|
||||||
const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[]
|
|
||||||
const originalStyles = sections.map((section) => section.style.cssText)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
|
||||||
const activeEl = document.activeElement as HTMLElement | null
|
|
||||||
activeEl?.blur?.()
|
|
||||||
document.body.classList.add('exporting-snapshot')
|
|
||||||
document.documentElement.classList.add('exporting-snapshot')
|
|
||||||
|
|
||||||
sections.forEach((section) => {
|
|
||||||
section.style.minHeight = 'auto'
|
|
||||||
section.style.padding = '40px 0'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filterIds) {
|
|
||||||
getAvailableSections().forEach((section) => {
|
|
||||||
if (!filterIds.has(section.id) && section.ref.current) {
|
|
||||||
section.ref.current.style.display = 'none'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null
|
|
||||||
wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
|
||||||
|
|
||||||
if (wordCloudInner) {
|
|
||||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
|
||||||
wordCloudInner.style.transform = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
wordTags.forEach((tag, index) => {
|
|
||||||
wordTagOriginalStyles[index] = tag.style.cssText
|
|
||||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
|
||||||
tag.style.animation = 'none'
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
|
||||||
|
|
||||||
const canvas = await html2canvas(container, {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
allowTaint: true,
|
|
||||||
logging: false,
|
|
||||||
onclone: (clonedDoc) => {
|
|
||||||
clonedDoc.body.classList.add('exporting-snapshot')
|
|
||||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
|
||||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const outputCanvas = document.createElement('canvas')
|
|
||||||
outputCanvas.width = canvas.width
|
|
||||||
outputCanvas.height = canvas.height
|
|
||||||
const ctx = outputCanvas.getContext('2d')
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('无法创建导出画布')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = themeMode === 'dark'
|
|
||||||
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
|
|
||||||
ctx.drawImage(canvas, 0, 0)
|
|
||||||
|
|
||||||
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
|
||||||
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png`
|
|
||||||
link.href = outputCanvas.toDataURL('image/png')
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
} catch (e) {
|
|
||||||
alert('导出失败: ' + String(e))
|
|
||||||
} finally {
|
|
||||||
sections.forEach((section, index) => {
|
|
||||||
section.style.cssText = originalStyles[index]
|
|
||||||
})
|
|
||||||
if (wordCloudInner) {
|
|
||||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
|
||||||
}
|
|
||||||
wordTags?.forEach((tag, index) => {
|
|
||||||
tag.style.cssText = wordTagOriginalStyles[index]
|
|
||||||
})
|
|
||||||
document.body.classList.remove('exporting-snapshot')
|
|
||||||
document.documentElement.classList.remove('exporting-snapshot')
|
|
||||||
setIsExporting(false)
|
|
||||||
setExportProgress('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportSelectedSections = async () => {
|
|
||||||
if (!reportData) return
|
|
||||||
|
|
||||||
const sections = getAvailableSections().filter((section) => selectedSections.has(section.id))
|
|
||||||
if (sections.length === 0) {
|
|
||||||
alert('请至少选择一个板块')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exportMode === 'long') {
|
|
||||||
setShowExportModal(false)
|
|
||||||
await exportFullReport(selectedSections)
|
|
||||||
setSelectedSections(new Set())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsExporting(true)
|
|
||||||
setShowExportModal(false)
|
|
||||||
|
|
||||||
const exportedImages: Array<{ name: string; data: string }> = []
|
|
||||||
|
|
||||||
for (let index = 0; index < sections.length; index++) {
|
|
||||||
const section = sections[index]
|
|
||||||
setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`)
|
|
||||||
|
|
||||||
const result = await exportSection(section)
|
|
||||||
if (result) {
|
|
||||||
exportedImages.push(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exportedImages.length === 0) {
|
|
||||||
alert('导出失败')
|
|
||||||
setIsExporting(false)
|
|
||||||
setExportProgress('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirResult = await window.electronAPI.dialog.openDirectory({
|
const dirResult = await window.electronAPI.dialog.openDirectory({
|
||||||
title: '选择导出文件夹',
|
title: '选择导出文件夹',
|
||||||
properties: ['openDirectory', 'createDirectory']
|
properties: ['openDirectory', 'createDirectory']
|
||||||
})
|
})
|
||||||
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
|
if (dirResult.canceled || !dirResult.filePaths?.[0]) return
|
||||||
setIsExporting(false)
|
|
||||||
setExportProgress('')
|
const root = containerRef.current
|
||||||
return
|
const sceneNames = [
|
||||||
|
'THE_BINDING',
|
||||||
|
'FIRST_ENCOUNTER',
|
||||||
|
'SYNCHRONIZATION',
|
||||||
|
'MUTUAL_INITIATIVE',
|
||||||
|
'ECHOES',
|
||||||
|
'THE_SPARK',
|
||||||
|
'LEXICON',
|
||||||
|
'VOLUME',
|
||||||
|
'EXTRACTION'
|
||||||
|
]
|
||||||
|
|
||||||
|
setIsExtracting(true)
|
||||||
|
setButtonText('EXTRACTING...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const images: Array<{ name: string; dataUrl: string }> = []
|
||||||
|
root.classList.add('exporting-scenes')
|
||||||
|
await waitForNextPaint()
|
||||||
|
await wait(120)
|
||||||
|
await captureSceneDataUrl()
|
||||||
|
|
||||||
|
for (let i = 0; i < TOTAL_SCENES; i++) {
|
||||||
|
setCurrentScene(i)
|
||||||
|
setButtonText(`EXTRACTING ${i + 1}/${TOTAL_SCENES}`)
|
||||||
|
await waitForNextPaint()
|
||||||
|
await wait(1700)
|
||||||
|
|
||||||
|
images.push({
|
||||||
|
name: `P${String(i).padStart(2, '0')}_${sceneNames[i] || 'SCENE'}.png`,
|
||||||
|
dataUrl: await captureSceneDataUrl()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||||
|
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||||
|
baseDir: dirResult.filePaths[0],
|
||||||
|
folderName: `${yearFilePrefix}双人报告_分页面`,
|
||||||
|
images
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exportResult.success) throw new Error(exportResult.error || '导出失败')
|
||||||
|
|
||||||
|
setButtonText('ARCHIVED')
|
||||||
|
setTimeout(() => setButtonText('EXTRACT RECORD'), 2000)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || '导出过程中出现错误')
|
||||||
|
setButtonText('EXTRACT FAILED')
|
||||||
|
setTimeout(() => setButtonText('EXTRACT RECORD'), 2000)
|
||||||
|
} finally {
|
||||||
|
setIsExtracting(false)
|
||||||
|
root.classList.remove('exporting-scenes')
|
||||||
|
setCurrentScene(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
setExportProgress('正在写入文件...')
|
|
||||||
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
|
||||||
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
|
||||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
|
||||||
baseDir: dirResult.filePaths[0],
|
|
||||||
folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`,
|
|
||||||
images: exportedImages.map((image) => ({
|
|
||||||
name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`,
|
|
||||||
dataUrl: image.data
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!exportResult.success) {
|
|
||||||
alert('导出失败: ' + (exportResult.error || '未知错误'))
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsExporting(false)
|
|
||||||
setExportProgress('')
|
|
||||||
setSelectedSections(new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSection = (id: string) => {
|
|
||||||
const next = new Set(selectedSections)
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id)
|
|
||||||
} else {
|
|
||||||
next.add(id)
|
|
||||||
}
|
|
||||||
setSelectedSections(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
const sections = getAvailableSections()
|
|
||||||
if (selectedSections.size === sections.length) {
|
|
||||||
setSelectedSections(new Set())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSelectedSections(new Set(sections.map((section) => section.id)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-window loading">
|
<div className="annual-report-window dual-report-window loading dark-theme">
|
||||||
|
<div className="top-controls">
|
||||||
|
<button className="window-close-btn close-btn" onClick={handleClose}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
<div className="loading-ring">
|
<div className="loading-ring">
|
||||||
<svg viewBox="0 0 100 100">
|
<svg viewBox="0 0 100 100">
|
||||||
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
||||||
@@ -541,629 +396,304 @@ function DualReportWindow() {
|
|||||||
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
|
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="ring-text">{loadingProgress}%</span>
|
<span className="ring-text">{Math.round(loadingProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="loading-stage">{loadingStage}</p>
|
<p className="loading-stage">{loadingStage}</p>
|
||||||
<p className="loading-hint">进行中</p>
|
<p className="loading-hint">DUAL RECORD INIT</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error || !reportData) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-window error">
|
<div className="annual-report-window dual-report-window error dark-theme">
|
||||||
<p>生成报告失败: {error}</p>
|
<div className="top-controls">
|
||||||
|
<button className="window-close-btn close-btn" onClick={handleClose}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<h2>Report Initialization Failed</h2>
|
||||||
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reportData) {
|
const formatFirstChat = (content: string) => {
|
||||||
return (
|
if (!content) return ''
|
||||||
<div className="annual-report-window error">
|
if (content.includes('<?xml') || content.includes('<msg>')) {
|
||||||
<p>暂无数据</p>
|
const match = content.match(/<title>([^<]+)<\/title>/)
|
||||||
</div>
|
return match && match[1] ? `[${match[1]}]` : '[富文本消息]'
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年`
|
|
||||||
const firstChat = reportData.firstChat
|
|
||||||
const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0)
|
|
||||||
? reportData.firstChatMessages.slice(0, 3)
|
|
||||||
: firstChat
|
|
||||||
? [{
|
|
||||||
content: firstChat.content,
|
|
||||||
isSentByMe: firstChat.isSentByMe,
|
|
||||||
createTime: firstChat.createTime,
|
|
||||||
createTimeStr: firstChat.createTimeStr
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
const daysSince = firstChat
|
|
||||||
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
|
|
||||||
: null
|
|
||||||
const yearFirstChat = reportData.yearFirstChat
|
|
||||||
const stats = reportData.stats
|
|
||||||
const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0)
|
|
||||||
const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0
|
|
||||||
const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0
|
|
||||||
const statItems = [
|
|
||||||
{ label: '总消息数', value: stats.totalMessages, color: '#07C160' },
|
|
||||||
{ label: '总字数', value: stats.totalWords, color: '#10AEFF' },
|
|
||||||
{ label: '图片', value: stats.imageCount, color: '#FFC300' },
|
|
||||||
{ label: '语音', value: stats.voiceCount, color: '#FA5151' },
|
|
||||||
{ label: '表情', value: stats.emojiCount, color: '#FA9D3B' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const decodeEntities = (text: string) => (
|
|
||||||
text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
)
|
|
||||||
|
|
||||||
const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => {
|
|
||||||
let actualCount = 0
|
|
||||||
const result: DualReportMessage[] = []
|
|
||||||
for (const msg of messages) {
|
|
||||||
const isSystem = msg.localType === 10000 || msg.localType === 10002
|
|
||||||
if (!isSystem) {
|
|
||||||
if (actualCount >= maxActual) break
|
|
||||||
actualCount++
|
|
||||||
}
|
|
||||||
result.push(msg)
|
|
||||||
}
|
}
|
||||||
return result
|
return content.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
// 计算第一句话数据
|
||||||
const compactMessageText = (text: string) => (
|
const displayFirstChat = reportData.yearFirstChat || reportData.firstChat
|
||||||
text
|
const firstChatArray = (reportData.yearFirstChatMessages || reportData.firstChatMessages || (displayFirstChat ? [displayFirstChat] : [])).slice(0, 3)
|
||||||
.replace(/\r\n/g, '\n')
|
|
||||||
.replace(/\s*\n+\s*/g, ' ')
|
|
||||||
.replace(/\s{2,}/g, ' ')
|
|
||||||
.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
const extractXmlText = (content: string) => {
|
// 聊天火花
|
||||||
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
|
const showSpark = reportData.streak && reportData.streak.days > 0
|
||||||
if (titleMatch?.[1]) return titleMatch[1]
|
// 回复速度
|
||||||
const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i)
|
const avgResponseMins = reportData.response ? reportData.response.avg / 60 : 0
|
||||||
if (descMatch?.[1]) return descMatch[1]
|
const fastestResponseSecs = reportData.response ? reportData.response.fastest : 0
|
||||||
const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i)
|
// 主动性
|
||||||
if (summaryMatch?.[1]) return summaryMatch[1]
|
const initRate = reportData.initiative ? (reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100).toFixed(1) : 50
|
||||||
const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i)
|
|
||||||
if (contentMatch?.[1]) return contentMatch[1]
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMessageContent = (content?: string, localType?: number) => {
|
// 当前词云数据
|
||||||
const isSystemMsg = localType === 10000 || localType === 10002
|
const currentWordList = activeWordCloudTab === 'shared' ? reportData.topPhrases
|
||||||
if (!isSystemMsg) {
|
: activeWordCloudTab === 'my' ? reportData.myExclusivePhrases
|
||||||
if (localType === 3) return '[图片]'
|
: reportData.friendExclusivePhrases
|
||||||
if (localType === 34) return '[语音]'
|
|
||||||
if (localType === 43) return '[视频]'
|
|
||||||
if (localType === 47) return '[表情]'
|
|
||||||
if (localType === 42) return '[名片]'
|
|
||||||
if (localType === 48) return '[位置]'
|
|
||||||
if (localType === 49) return '[链接/文件]'
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = compactMessageText(String(content || '').trim())
|
|
||||||
if (!raw) return '(空)'
|
|
||||||
|
|
||||||
// 1. 尝试提取 XML 关键字段
|
|
||||||
const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i)
|
|
||||||
if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim()))
|
|
||||||
|
|
||||||
const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i)
|
|
||||||
if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim()))
|
|
||||||
|
|
||||||
const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i)
|
|
||||||
if (summaryMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(summaryMatch[1]).trim()))
|
|
||||||
|
|
||||||
// 2. 检查是否是 XML 结构
|
|
||||||
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
|
|
||||||
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) || hasXmlTag
|
|
||||||
|
|
||||||
if (!looksLikeXml) return raw
|
|
||||||
|
|
||||||
// 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本
|
|
||||||
const stripped = raw.replace(/<[^>]+>/g, '').trim()
|
|
||||||
if (stripped && stripped.length > 0 && stripped.length < 50) {
|
|
||||||
return compactMessageText(decodeEntities(stripped))
|
|
||||||
}
|
|
||||||
|
|
||||||
return '[多媒体消息]'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReportMessageItem = ({ msg }: { msg: DualReportMessage }) => {
|
|
||||||
if (msg.localType === 47 && (msg.emojiMd5 || msg.emojiCdnUrl)) {
|
|
||||||
const emojiUrl = msg.emojiCdnUrl || (msg.emojiMd5 ? `https://emoji.qpic.cn/wx_emoji/${msg.emojiMd5}/0` : '')
|
|
||||||
if (emojiUrl) {
|
|
||||||
return (
|
|
||||||
<div className="report-emoji-container">
|
|
||||||
<img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
|
||||||
}} />
|
|
||||||
<span style={{ display: 'none' }}>[表情]</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return <span>{formatMessageContent(msg.content, msg.localType)}</span>
|
|
||||||
}
|
|
||||||
const formatFullDate = (timestamp: number) => {
|
|
||||||
const d = new Date(timestamp)
|
|
||||||
const year = d.getFullYear()
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
|
||||||
const hour = String(d.getHours()).padStart(2, '0')
|
|
||||||
const minute = String(d.getMinutes()).padStart(2, '0')
|
|
||||||
return `${year}/${month}/${day} ${hour}:${minute}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMostActiveTime = (data: number[][]) => {
|
|
||||||
let maxHour = 0
|
|
||||||
let maxWeekday = 0
|
|
||||||
let maxVal = -1
|
|
||||||
data.forEach((row, weekday) => {
|
|
||||||
row.forEach((value, hour) => {
|
|
||||||
if (value > maxVal) {
|
|
||||||
maxVal = value
|
|
||||||
maxHour = hour
|
|
||||||
maxWeekday = weekday
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
|
||||||
return {
|
|
||||||
weekday: weekdayNames[maxWeekday] || '周一',
|
|
||||||
hour: maxHour,
|
|
||||||
value: Math.max(0, maxVal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null
|
|
||||||
const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0
|
|
||||||
const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl)
|
|
||||||
const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1))
|
|
||||||
const renderSceneAvatar = (isSentByMe: boolean) => {
|
|
||||||
const avatarUrl = getSceneAvatarUrl(isSentByMe)
|
|
||||||
if (avatarUrl) {
|
|
||||||
return (
|
|
||||||
<div className="scene-avatar with-image">
|
|
||||||
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderMessageList = (messages: DualReportMessage[]) => {
|
|
||||||
const displayMsgs = filterDisplayMessages(messages)
|
|
||||||
let lastTime = 0
|
|
||||||
const TIME_THRESHOLD = 5 * 60 * 1000 // 5 分钟
|
|
||||||
|
|
||||||
return displayMsgs.map((msg, idx) => {
|
|
||||||
const isSystem = msg.localType === 10000 || msg.localType === 10002
|
|
||||||
const showTime = idx === 0 || (msg.createTime - lastTime > TIME_THRESHOLD)
|
|
||||||
lastTime = msg.createTime
|
|
||||||
|
|
||||||
if (isSystem) {
|
|
||||||
return (
|
|
||||||
<div key={idx} className="scene-message system">
|
|
||||||
{showTime && (
|
|
||||||
<div className="scene-meta">
|
|
||||||
{formatFullDate(msg.createTime).split(' ')[1]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="system-msg-content">
|
|
||||||
<ReportMessageItem msg={msg} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
|
|
||||||
{showTime && (
|
|
||||||
<div className="scene-meta">
|
|
||||||
{formatFullDate(msg.createTime).split(' ')[1]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="scene-body">
|
|
||||||
{renderSceneAvatar(msg.isSentByMe)}
|
|
||||||
<div className="scene-content-wrapper">
|
|
||||||
<div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}>
|
|
||||||
<div className="scene-content"><ReportMessageItem msg={msg} /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-window dual-report-window">
|
<div className={`annual-report-window dual-report-window dark-theme`} ref={containerRef}>
|
||||||
<div className="drag-region" />
|
<div className="top-controls">
|
||||||
|
<button className="close-btn" onClick={handleClose} title="关闭 (Esc)"><X size={16} /></button>
|
||||||
<div className="bg-decoration">
|
|
||||||
<div className="deco-circle c1" />
|
|
||||||
<div className="deco-circle c2" />
|
|
||||||
<div className="deco-circle c3" />
|
|
||||||
<div className="deco-circle c4" />
|
|
||||||
<div className="deco-circle c5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
{/* ============== 背景系统 ============== */}
|
||||||
<button
|
<div className="cinematic-bg-system">
|
||||||
className="fab-item"
|
<div className="film-grain" />
|
||||||
onClick={() => {
|
<div className="p0-bg-layer" style={{ opacity: currentScene === 0 ? 1 : 0.4 }}>
|
||||||
setFabOpen(false)
|
<canvas ref={p0CanvasRef} className="p0-canvas" />
|
||||||
setExportMode('separate')
|
<div className="p0-overlay-grad" />
|
||||||
setShowExportModal(true)
|
</div>
|
||||||
}}
|
|
||||||
title="分模块导出"
|
|
||||||
>
|
|
||||||
<Image size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="fab-item"
|
|
||||||
onClick={() => {
|
|
||||||
setFabOpen(false)
|
|
||||||
setExportMode('long')
|
|
||||||
setShowExportModal(true)
|
|
||||||
}}
|
|
||||||
title="自定义导出长图"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="fab-item"
|
|
||||||
onClick={() => {
|
|
||||||
setFabOpen(false)
|
|
||||||
void exportFullReport()
|
|
||||||
}}
|
|
||||||
title="导出长图"
|
|
||||||
>
|
|
||||||
<Download size={18} />
|
|
||||||
</button>
|
|
||||||
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
|
|
||||||
{fabOpen ? <X size={22} /> : <Download size={22} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExporting && (
|
<div className="scene-container">
|
||||||
<div className="export-overlay">
|
|
||||||
<div className="export-progress-modal">
|
{/* S0: THE BINDING */}
|
||||||
<div className="export-spinner">
|
<div className={getSceneClass(0)} id="scene-0">
|
||||||
<div className="spinner-ring"></div>
|
<div className="center-layout s0-layout">
|
||||||
<Download size={24} className="spinner-icon" />
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner en-tag delay-1">WEFLOW · DUAL RECORD</div>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<h1 className="reveal-inner hero-title delay-2">
|
||||||
|
<DecodeText value={reportData.year === 0 ? '所有时间' : `${reportData.year}年`} active={currentScene === 0} />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner hero-desc dual-names delay-3" style={{ fontSize: '1.2rem', marginTop: '20px' }}>
|
||||||
|
<DecodeText value={reportData.selfName || '你'} active={currentScene === 0} />
|
||||||
|
<span className="amp">&</span>
|
||||||
|
<DecodeText value={reportData.friendName || reportData.friendUsername} active={currentScene === 0} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="export-title">正在导出</p>
|
|
||||||
<p className="export-status">{exportProgress}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{showExportModal && (
|
{/* S1: FIRST ENCOUNTER */}
|
||||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
<div className={getSceneClass(1)} id="scene-1">
|
||||||
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
|
<div className="s1-layout">
|
||||||
<div className="modal-header">
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">FIRST ENCOUNTER</div></div>
|
||||||
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">故事的开始</h2></div>
|
||||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
<div className="s1-messages reveal-inner delay-3">
|
||||||
<X size={20} />
|
{firstChatArray.map((chat: any, idx: number) => (
|
||||||
</button>
|
<div key={idx} className={`s1-message-item ${chat.sender === 'self' ? 'sent' : ''}`}>
|
||||||
</div>
|
<span className="s1-meta">{chat.createTimeStr || formatMonthDayTime(chat.timestamp)}</span>
|
||||||
<div className="section-grid">
|
<div className="scene-bubble s1-bubble">{formatFirstChat(chat.content)}</div>
|
||||||
{getAvailableSections().map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
|
|
||||||
onClick={() => toggleSection(section.id)}
|
|
||||||
>
|
|
||||||
<div className="card-check">
|
|
||||||
{selectedSections.has(section.id) && <Check size={14} />}
|
|
||||||
</div>
|
|
||||||
<span>{section.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="select-all-btn" onClick={toggleAll}>
|
|
||||||
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="confirm-btn"
|
|
||||||
onClick={() => void exportSelectedSections()}
|
|
||||||
disabled={selectedSections.size === 0}
|
|
||||||
>
|
|
||||||
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="report-scroll-view">
|
{/* S2: SYNCHRONIZATION */}
|
||||||
<div className="report-container" ref={containerRef}>
|
<div className={getSceneClass(2)} id="scene-2">
|
||||||
<section className="section" ref={sectionRefs.cover}>
|
<div className="center-layout">
|
||||||
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">SYNCHRONIZATION</div></div>
|
||||||
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">作息波纹</h2></div>
|
||||||
<hr className="divider" />
|
<div className="reveal-wrap">
|
||||||
<div className="dual-names">
|
<div className="reveal-inner desc delay-3 s2-active-text">
|
||||||
<span>我</span>
|
{reportData.heatmap ? (() => {
|
||||||
<span className="amp">&</span>
|
let maxVal = 0, maxDay = 0, maxHour = 0;
|
||||||
<span>{reportData.friendName}</span>
|
reportData.heatmap.forEach((dayRow: number[], dayIdx: number) => {
|
||||||
|
dayRow.forEach((val: number, hourIdx: number) => {
|
||||||
|
if (val > maxVal) { maxVal = val; maxDay = dayIdx; maxHour = hourIdx; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
return <>在<span className="hl">{dayNames[maxDay]}</span>的<span className="hl">{String(maxHour).padStart(2, '0')}:00</span>,我们最为活跃</>
|
||||||
|
})() : '我们的时空,在这里高频交叠'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
{reportData.heatmap && (
|
||||||
</section>
|
<div className="reveal-wrap">
|
||||||
|
<div className="heatmap-wrapper reveal-inner delay-3">
|
||||||
<section className="section" ref={sectionRefs.firstChat}>
|
<ReportHeatmap data={reportData.heatmap} />
|
||||||
<div className="label-text">首次聊天</div>
|
|
||||||
<h2 className="hero-title">故事的开始</h2>
|
|
||||||
{firstChat ? (
|
|
||||||
<div className="first-chat-scene">
|
|
||||||
<div className="scene-title">第一次遇见</div>
|
|
||||||
<div className="scene-subtitle">{formatFullDate(firstChat.createTime).split(' ')[0]}</div>
|
|
||||||
{firstChatMessages.length > 0 ? (
|
|
||||||
<div className="scene-messages">
|
|
||||||
{renderMessageList(firstChatMessages)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="hero-desc" style={{ textAlign: 'center' }}>暂无消息详情</div>
|
|
||||||
)}
|
|
||||||
<div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}>
|
|
||||||
距离今天已经 {daysSince} 天
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="hero-desc">暂无首条消息</p>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
|
{/* S3: MUTUAL INITIATIVE */}
|
||||||
<section className="section" ref={sectionRefs.yearFirstChat}>
|
<div className={getSceneClass(3)} id="scene-3">
|
||||||
<div className="label-text">第一段对话</div>
|
<div className="center-layout">
|
||||||
<h2 className="hero-title">
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">MUTUAL INITIATIVE</div></div>
|
||||||
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">情感的天平</h2></div>
|
||||||
</h2>
|
|
||||||
<div className="first-chat-scene">
|
|
||||||
<div className="scene-title">久别重逢</div>
|
|
||||||
<div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div>
|
|
||||||
<div className="scene-messages">
|
|
||||||
{renderMessageList(yearFirstChat.firstThreeMessages)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{reportData.heatmap && (
|
{reportData.initiative && (
|
||||||
<section className="section" ref={sectionRefs.heatmap}>
|
<div className="reveal-wrap">
|
||||||
<div className="label-text">聊天习惯</div>
|
<div className="reveal-inner initiative-container delay-3">
|
||||||
<h2 className="hero-title">作息规律</h2>
|
|
||||||
{mostActive && (
|
|
||||||
<p className="hero-desc active-time dual-active-time">
|
|
||||||
在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃({mostActive.value}条)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ReportHeatmap data={reportData.heatmap} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reportData.initiative && (
|
|
||||||
<section className="section" ref={sectionRefs.initiative}>
|
|
||||||
<div className="label-text">主动性</div>
|
|
||||||
<h2 className="hero-title">情感的天平</h2>
|
|
||||||
<div className="initiative-container">
|
|
||||||
<div className="initiative-bar-wrapper">
|
<div className="initiative-bar-wrapper">
|
||||||
<div className="initiative-side">
|
<div className="initiative-side">
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
|
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} /> : reportData.selfName.substring(0, 1) || 'Me'}
|
||||||
</div>
|
</div>
|
||||||
<div className="count">{reportData.initiative.initiated}次</div>
|
<div className="count">{reportData.initiative.initiated}</div>
|
||||||
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
|
<div className="percent">{initRate}%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="initiative-progress">
|
<div className="initiative-progress">
|
||||||
<div className="line-bg" />
|
<div className="line-bg" />
|
||||||
<div
|
<div className="initiative-indicator" style={{ left: `${initRate}%` }} />
|
||||||
className="initiative-indicator"
|
|
||||||
style={{ left: `${initiatedPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="initiative-side">
|
|
||||||
|
<div className="initiative-side right">
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
|
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} /> : reportData.friendName.substring(0, 1)}
|
||||||
</div>
|
|
||||||
<div className="count">{reportData.initiative.received}次</div>
|
|
||||||
<div className="percent">{receivedPercent.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="initiative-desc">
|
|
||||||
{reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reportData.response && (
|
|
||||||
<section className="section" ref={sectionRefs.response}>
|
|
||||||
<div className="label-text">回应速度</div>
|
|
||||||
<h2 className="hero-title">你说,我在</h2>
|
|
||||||
<div className="response-pulse-container">
|
|
||||||
<div className="pulse-visual">
|
|
||||||
<div className="pulse-ripple one" />
|
|
||||||
<div className="pulse-ripple two" />
|
|
||||||
<div className="pulse-ripple three" />
|
|
||||||
|
|
||||||
<div className="pulse-node left">
|
|
||||||
<div className="label">最快回复</div>
|
|
||||||
<div className="value">{reportData.response.fastest}<span>秒</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pulse-hub">
|
|
||||||
<div className="label">平均回复</div>
|
|
||||||
<div className="value">{Math.round(reportData.response.avg / 60)}<span>分</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pulse-node right">
|
|
||||||
<div className="label">最慢回复</div>
|
|
||||||
<div className="value">
|
|
||||||
{reportData.response.slowest > 3600
|
|
||||||
? (reportData.response.slowest / 3600).toFixed(1)
|
|
||||||
: Math.round(reportData.response.slowest / 60)}
|
|
||||||
<span>{reportData.response.slowest > 3600 ? '时' : '分'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="count">{reportData.initiative.received}</div>
|
||||||
|
<div className="percent">{(100 - parseFloat(initRate as any)).toFixed(1)}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc response-note">
|
|
||||||
{`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reportData.streak && (
|
|
||||||
<section className="section" ref={sectionRefs.streak}>
|
|
||||||
<div className="label-text">聊天火花</div>
|
|
||||||
<h2 className="hero-title">最长连续聊天</h2>
|
|
||||||
<div className="streak-spark-visual premium">
|
|
||||||
<div className="spark-ambient-glow" />
|
|
||||||
|
|
||||||
<div className="spark-ember one" />
|
|
||||||
<div className="spark-ember two" />
|
|
||||||
<div className="spark-ember three" />
|
|
||||||
<div className="spark-ember four" />
|
|
||||||
|
|
||||||
<div className="spark-core-wrapper">
|
|
||||||
<div className="spark-flame-outer" />
|
|
||||||
<div className="spark-flame-inner" />
|
|
||||||
<div className="spark-core">
|
|
||||||
<div className="spark-days">{reportData.streak.days}</div>
|
|
||||||
<div className="spark-label">DAYS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="streak-bridge premium">
|
|
||||||
<div className="bridge-date start">
|
|
||||||
<div className="date-orb" />
|
|
||||||
<span>{reportData.streak.startDate}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bridge-line">
|
|
||||||
<div className="line-glow" />
|
|
||||||
<div className="line-string" />
|
|
||||||
</div>
|
|
||||||
<div className="bridge-date end">
|
|
||||||
<span>{reportData.streak.endDate}</span>
|
|
||||||
<div className="date-orb" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
|
|
||||||
<div className="label-text">常用语</div>
|
|
||||||
<h2 className="hero-title">{yearTitle}常用语</h2>
|
|
||||||
|
|
||||||
<div className="word-cloud-tabs">
|
|
||||||
<button
|
|
||||||
className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveWordCloudTab('shared')}
|
|
||||||
>
|
|
||||||
共用词汇
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveWordCloudTab('my')}
|
|
||||||
>
|
|
||||||
我的专属
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveWordCloudTab('friend')}
|
|
||||||
>
|
|
||||||
TA的专属
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className={`word-cloud-container fade-in ${activeWordCloudTab}`}>
|
</div>
|
||||||
{activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />}
|
|
||||||
{activeWordCloudTab === 'my' && (
|
|
||||||
reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? (
|
|
||||||
<ReportWordCloud words={reportData.myExclusivePhrases} />
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">暂无专属词汇</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{activeWordCloudTab === 'friend' && (
|
|
||||||
reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? (
|
|
||||||
<ReportWordCloud words={reportData.friendExclusivePhrases} />
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">暂无专属词汇</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="section" ref={sectionRefs.stats}>
|
|
||||||
<div className="label-text">年度统计</div>
|
|
||||||
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
|
||||||
<div className="dual-stat-grid">
|
|
||||||
{statItems.slice(0, 2).map((item) => (
|
|
||||||
<div key={item.label} className="dual-stat-card">
|
|
||||||
<div className="stat-num">{item.value.toLocaleString()}</div>
|
|
||||||
<div className="stat-unit">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="dual-stat-grid bottom">
|
|
||||||
{statItems.slice(2).map((item) => (
|
|
||||||
<div key={item.label} className="dual-stat-card">
|
|
||||||
<div className="stat-num small">{item.value.toLocaleString()}</div>
|
|
||||||
<div className="stat-unit">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="emoji-row">
|
|
||||||
<div className="emoji-card">
|
|
||||||
<div className="emoji-title">我常用的表情</div>
|
|
||||||
{myEmojiUrl ? (
|
|
||||||
<img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}} />
|
|
||||||
) : null}
|
|
||||||
<div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}>
|
|
||||||
{stats.myTopEmojiMd5 || '暂无'}
|
|
||||||
</div>
|
|
||||||
<div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="emoji-card">
|
|
||||||
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
|
||||||
{friendEmojiUrl ? (
|
|
||||||
<img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}} />
|
|
||||||
) : null}
|
|
||||||
<div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}>
|
|
||||||
{stats.friendTopEmojiMd5 || '暂无'}
|
|
||||||
</div>
|
|
||||||
<div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="section" ref={sectionRefs.ending}>
|
|
||||||
<div className="label-text">尾声</div>
|
|
||||||
<h2 className="hero-title">谢谢你一直在</h2>
|
|
||||||
<p className="hero-desc">愿我们继续把故事写下去</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* S4: ECHOES */}
|
||||||
|
<div className={getSceneClass(4)} id="scene-4">
|
||||||
|
<div className="center-layout">
|
||||||
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">ECHOES</div></div>
|
||||||
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">回应的速度</h2></div>
|
||||||
|
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner response-wrapper delay-3">
|
||||||
|
<div className="response-circle">
|
||||||
|
<span className="label">AVG RESPONSE</span>
|
||||||
|
<span className="value"><DecodeText value={avgResponseMins.toFixed(1)} active={currentScene === 4}/><span>m</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="response-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="label">FASTEST</div>
|
||||||
|
<div className="value"><DecodeText value={fastestResponseSecs} active={currentScene === 4}/>s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S5: THE SPARK */}
|
||||||
|
<div className={getSceneClass(5)} id="scene-5">
|
||||||
|
<div className="center-layout">
|
||||||
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">THE SPARK</div></div>
|
||||||
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">连绵不绝的火花</h2></div>
|
||||||
|
|
||||||
|
{showSpark && reportData.streak ? (
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner streak-wrapper delay-3">
|
||||||
|
<span className="streak-days"><DecodeText value={reportData.streak.days} active={currentScene === 5}/></span>
|
||||||
|
<span className="streak-label">DAYS STREAK</span>
|
||||||
|
<div className="streak-dates">
|
||||||
|
{reportData.streak.startDate}
|
||||||
|
<div className="line" />
|
||||||
|
{reportData.streak.endDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="reveal-wrap"><p className="reveal-inner desc delay-3" style={{marginTop:"3vh"}}>火种尚未点亮...</p></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S6: LEXICON */}
|
||||||
|
<div className={getSceneClass(6)} id="scene-6">
|
||||||
|
<div className="center-layout">
|
||||||
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">LEXICON</div></div>
|
||||||
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">专属词典</h2></div>
|
||||||
|
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner word-cloud-wrapper-outer delay-3">
|
||||||
|
<div className="word-cloud-tabs">
|
||||||
|
<button className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('shared')}>共同</button>
|
||||||
|
<button className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('my')}>我方</button>
|
||||||
|
<button className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('friend')}>对方</button>
|
||||||
|
</div>
|
||||||
|
{currentWordList && currentWordList.length > 0 ? (
|
||||||
|
<ReportWordCloud words={currentWordList} />
|
||||||
|
) : (
|
||||||
|
<div style={{textAlign: 'center', marginTop: '10vh', color: 'var(--c-text-muted)'}}>没有足够的词汇数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S7: VOLUME */}
|
||||||
|
<div className={getSceneClass(7)} id="scene-7">
|
||||||
|
<div className="center-layout">
|
||||||
|
<div className="reveal-wrap"><div className="reveal-inner en-tag delay-1">VOLUME</div></div>
|
||||||
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">数据归档</h2></div>
|
||||||
|
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<div className="reveal-inner stats-grid delay-3" style={{ background: 'transparent' }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="val"><DecodeText value={reportData.stats.totalMessages} active={currentScene === 7}/></div>
|
||||||
|
<div className="lbl">MESSAGES</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="val"><DecodeText value={reportData.stats.totalWords} active={currentScene === 7}/></div>
|
||||||
|
<div className="lbl">WORDS</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="val"><DecodeText value={reportData.stats.imageCount} active={currentScene === 7}/></div>
|
||||||
|
<div className="lbl">IMAGES</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="val"><DecodeText value={reportData.stats.emojiCount} active={currentScene === 7}/></div>
|
||||||
|
<div className="lbl">EMOJIS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S8: EXTRACTION */}
|
||||||
|
<div className={getSceneClass(8)} id="scene-8">
|
||||||
|
<div className="center-layout">
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<h1 className="reveal-inner extract-title delay-1">ARCHIVED</h1>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<p className="reveal-inner desc delay-2">WeFlow</p>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-wrap">
|
||||||
|
<button
|
||||||
|
className={`reveal-inner extract-btn delay-3 ${isExtracting ? 'processing' : ''}`}
|
||||||
|
onClick={handleExtract}
|
||||||
|
disabled={isExtracting}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部导航点 */}
|
||||||
|
<div className="scene-nav">
|
||||||
|
{Array.from({ length: TOTAL_SCENES }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`nav-dot ${i === currentScene ? 'active' : ''}`}
|
||||||
|
onClick={() => goToScene(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user