This commit is contained in:
xuncha
2026-02-01 02:26:00 +08:00
parent f40f885af3
commit ddbb0c3b26
5 changed files with 276 additions and 55 deletions

View File

@@ -25,8 +25,8 @@ function AnnualReportPage() {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear(result.data[0])
setSelectedPairYear(result.data[0])
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
}

View File

@@ -1,4 +1,13 @@
.annual-report-window.dual-report-window {
.hero-title {
font-size: clamp(22px, 4vw, 34px);
white-space: nowrap;
}
.dual-cover-title {
font-size: clamp(26px, 5vw, 44px);
white-space: normal;
}
.dual-names {
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
@@ -71,30 +80,144 @@
}
}
.first-chat-scene {
background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%);
border-radius: 20px;
padding: 28px 24px 24px;
color: #fff;
position: relative;
overflow: hidden;
margin-top: 16px;
}
.first-chat-scene::before {
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%),
radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%);
opacity: 0.6;
pointer-events: none;
}
.scene-title {
font-size: 24px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.scene-subtitle {
font-size: 18px;
font-weight: 500;
text-align: center;
margin-bottom: 20px;
opacity: 0.95;
}
.scene-messages {
display: flex;
flex-direction: column;
gap: 14px;
}
.scene-message {
display: flex;
align-items: flex-end;
gap: 12px;
&.sent {
flex-direction: row-reverse;
}
}
.scene-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
}
.scene-bubble {
background: rgba(255, 255, 255, 0.85);
color: #5a4d5e;
padding: 10px 14px;
border-radius: 14px;
max-width: 60%;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.scene-message.sent .scene-bubble {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
}
.scene-meta {
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
}
.scene-content {
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.scene-message.sent .scene-avatar {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
}
.dual-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin: 20px 0 24px;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 14px;
margin: 20px -28px 24px;
padding: 0 28px;
overflow: visible;
}
.dual-stat-card {
background: var(--ar-card-bg);
border-radius: 16px;
padding: 18px;
border-radius: 14px;
padding: 14px 12px;
text-align: center;
}
.stat-num {
font-size: clamp(20px, 2.8vw, 30px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stat-unit {
font-size: 12px;
}
.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(auto-fit, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 20px;
margin: 0 -12px;
}
.emoji-card {
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
border-radius: 16px;
padding: 16px;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;

View File

@@ -21,6 +21,7 @@ interface DualReportData {
isSentByMe: boolean
senderUsername?: string
} | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
@@ -257,11 +258,72 @@ function DualReportWindow() {
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 statItems = [
{ label: '总消息数', value: stats.totalMessages },
{ label: '总字数', value: stats.totalWords },
{ label: '图片', value: stats.imageCount },
{ label: '语音', value: stats.voiceCount },
{ label: '表情', value: stats.emojiCount },
]
const decodeEntities = (text: string) => (
text
.replace(/&/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
)
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
const extractXmlText = (content: string) => {
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return titleMatch[1]
const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return descMatch[1]
const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return summaryMatch[1]
const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i)
if (contentMatch?.[1]) return contentMatch[1]
return ''
}
const formatMessageContent = (content?: string) => {
const raw = String(content || '').trim()
if (!raw) return '(空)'
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
const extracted = extractXmlText(raw)
if (!extracted) return 'XML消息'
return decodeEntities(stripCdata(extracted).trim()) || 'XML消息'
}
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}`
}
return (
<div className="annual-report-window dual-report-window">
@@ -279,7 +341,7 @@ function DualReportWindow() {
<div className="report-container">
<section className="section">
<div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title">{yearTitle}<br /></h1>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
<div className="dual-names">
<span>{reportData.selfName}</span>
@@ -293,20 +355,33 @@ function DualReportWindow() {
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{firstChat.createTimeStr}</div>
<>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(firstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{daysSince} </div>
</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{daysSince} </div>
</div>
<div className="dual-info-card full">
<div className="info-label"></div>
<div className="info-value">{firstChat.content || '(空)'}</div>
</div>
</div>
{firstChatMessages.length > 0 ? (
<div className="dual-message-list">
{firstChatMessages.map((msg, idx) => (
<div
key={idx}
className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}
>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
) : null}
</>
) : (
<p className="hero-desc"></p>
)}
@@ -314,12 +389,14 @@ function DualReportWindow() {
{yearFirstChat ? (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
</h2>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{yearFirstChat.createTimeStr}</div>
<div className="info-label"></div>
<div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
@@ -329,8 +406,10 @@ function DualReportWindow() {
<div className="dual-message-list">
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="message-meta">{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {msg.createTimeStr}</div>
<div className="message-content">{msg.content || '(空)'}</div>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
@@ -347,26 +426,16 @@ function DualReportWindow() {
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
<div className="dual-stat-card">
<div className="stat-num">{stats.totalMessages.toLocaleString()}</div>
<div className="stat-unit"></div>
</div>
<div className="dual-stat-card">
<div className="stat-num">{stats.totalWords.toLocaleString()}</div>
<div className="stat-unit"></div>
</div>
<div className="dual-stat-card">
<div className="stat-num">{stats.imageCount.toLocaleString()}</div>
<div className="stat-unit"></div>
</div>
<div className="dual-stat-card">
<div className="stat-num">{stats.voiceCount.toLocaleString()}</div>
<div className="stat-unit"></div>
</div>
<div className="dual-stat-card">
<div className="stat-num">{stats.emojiCount.toLocaleString()}</div>
<div className="stat-unit"></div>
</div>
{statItems.map((item) => {
const valueText = item.value.toLocaleString()
const isLong = valueText.length > 7
return (
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}>
<div className="stat-num">{valueText}</div>
<div className="stat-unit">{item.label}</div>
</div>
)
})}
</div>
<div className="emoji-row">

View File

@@ -352,6 +352,12 @@ export interface ElectronAPI {
isSentByMe: boolean
senderUsername?: string
} | null
firstChatMessages?: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
yearFirstChat?: {
createTime: number
createTimeStr: string