修复双人年度报告相关

This commit is contained in:
xuncha
2026-02-08 22:41:50 +08:00
parent e28ef9b783
commit 2b5bb34392
9 changed files with 183 additions and 43 deletions

View File

@@ -7,6 +7,7 @@ interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
@@ -15,28 +16,29 @@ interface ContactRanking {
function DualReportPage() {
const navigate = useNavigate()
const [year, setYear] = useState<number>(0)
const [year] = useState<number>(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
return Number.isNaN(parsedYear) ? 0 : parsedYear
})
const [rankings, setRankings] = useState<ContactRanking[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
setYear(Number.isNaN(parsedYear) ? 0 : parsedYear)
}, [])
void loadRankings(year)
}, [year])
useEffect(() => {
loadRankings()
}, [])
const loadRankings = async () => {
const loadRankings = async (reportYear: number) => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.analytics.getContactRankings(200)
const isAllTime = reportYear <= 0
const beginTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
const result = await window.electronAPI.analytics.getContactRankings(200, beginTimestamp, endTimestamp)
if (result.success && result.data) {
setRankings(result.data)
} else {
@@ -55,7 +57,8 @@ function DualReportPage() {
if (!keyword.trim()) return rankings
const q = keyword.trim().toLowerCase()
return rankings.filter((item) => {
return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q)
const wechatId = (item.wechatId || '').toLowerCase()
return item.displayName.toLowerCase().includes(q) || wechatId.includes(q)
})
}, [rankings, keyword])
@@ -99,7 +102,7 @@ function DualReportPage() {
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索好友(昵称/备注/wxid"
placeholder="搜索好友(昵称/微信号"
/>
</div>
@@ -119,7 +122,7 @@ function DualReportPage() {
</div>
<div className="info">
<div className="name">{item.displayName}</div>
<div className="sub">{item.username}</div>
<div className="sub">{item.wechatId || '\u672A\u8bbe\u7f6e\u5fae\u4fe1\u53f7'}</div>
</div>
<div className="meta">
<div className="count">{item.messageCount.toLocaleString()} </div>

View File

@@ -296,6 +296,13 @@
font-size: 14px;
border: 2px solid var(--ar-card-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.count {
@@ -303,6 +310,13 @@
font-weight: 600;
color: var(--ar-text-sub);
}
.percent {
font-size: 12px;
color: var(--ar-text-main);
opacity: 0.85;
font-weight: 600;
}
}
.initiative-progress {
@@ -347,7 +361,7 @@
// --- New Response Speed Section (Grid + Icons) ---
.response-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
padding: 0 10px;
@@ -391,6 +405,11 @@
color: var(--ar-accent);
}
&.sample .icon-box {
background: rgba(16, 174, 255, 0.08);
color: #10AEFF;
}
.label {
font-size: 13px;
color: var(--ar-text-sub);
@@ -412,6 +431,14 @@
}
}
.response-note {
margin-top: 14px;
max-width: none;
text-align: center;
font-size: 14px;
color: var(--ar-text-sub);
}
// --- New Streak Section (Flame) ---
.streak-container {
@@ -473,4 +500,17 @@
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
}
.emoji-count {
font-size: 12px;
color: var(--ar-text-sub);
opacity: 0.85;
}
@media (max-width: 960px) {
.response-grid {
grid-template-columns: 1fr;
padding: 0;
}
}
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { Clock, Zap, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Clock, Zap, MessageCircle, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import './AnnualReportWindow.scss'
@@ -15,8 +15,10 @@ interface DualReportMessage {
interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: {
createTime: number
createTimeStr: string
@@ -43,6 +45,8 @@ interface DualReportData {
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
topPhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
@@ -108,6 +112,8 @@ function DualReportWindow() {
useEffect(() => {
const loadEmojis = async () => {
if (!reportData) return
setMyEmojiUrl(null)
setFriendEmojiUrl(null)
const stats = reportData.stats
if (stats.myTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
@@ -178,6 +184,9 @@ function DualReportWindow() {
: 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, icon: MessageSquare, color: '#07C160' },
{ label: '总字数', value: stats.totalWords, icon: Type, color: '#10AEFF' },
@@ -247,6 +256,30 @@ function DualReportWindow() {
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
return (
<div className="annual-report-window dual-report-window">
<div className="drag-region" />
@@ -344,6 +377,11 @@ function DualReportWindow() {
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{mostActive && (
<p className="hero-desc active-time dual-active-time">
{'\u5728'} <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> {'\u6700\u6d3b\u8dc3\uff08'}{mostActive.value}{'\u6761\uff09'}
</p>
)}
<ReportHeatmap data={reportData.heatmap} />
</section>
)}
@@ -358,22 +396,28 @@ function DualReportWindow() {
</div>
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder"></div>
<div className="count">{reportData.initiative.initiated}</div>
<div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '\u6211'}
</div>
<div className="count">{reportData.initiative.initiated}{'\u6b21'}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
</div>
<div className="initiative-progress">
<div
className="bar-segment left"
style={{ width: `${reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }}
style={{ width: `${initiatedPercent}%` }}
/>
<div
className="bar-segment right"
style={{ width: `${reportData.initiative.received / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }}
style={{ width: `${receivedPercent}%` }}
/>
</div>
<div className="initiative-side">
<div className="avatar-placeholder">{reportData.friendName.substring(0, 1)}</div>
<div className="count">{reportData.initiative.received}</div>
<div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
</div>
<div className="count">{reportData.initiative.received}{'\u6b21'}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div>
</div>
</div>
</div>
@@ -383,33 +427,43 @@ function DualReportWindow() {
{reportData.response && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<h2 className="hero-title">{'\u79d2\u56de\uff0c\u662f\u56e0\u4e3a\u5728\u4e4e'}</h2>
<div className="response-grid">
<div className="response-card">
<div className="icon-box">
<Clock size={24} />
</div>
<div className="label"></div>
<div className="value">{Math.round(reportData.response.avg / 60)}<span></span></div>
<div className="label">{'\u5e73\u5747\u56de\u590d'}</div>
<div className="value">{Math.round(reportData.response.avg / 60)}<span>{'\u5206'}</span></div>
</div>
<div className="response-card fastest">
<div className="icon-box">
<Zap size={24} />
</div>
<div className="label"></div>
<div className="value">{reportData.response.fastest}<span></span></div>
<div className="label">{'\u6700\u5feb\u56de\u590d'}</div>
<div className="value">{reportData.response.fastest}<span>{'\u79d2'}</span></div>
</div>
<div className="response-card sample">
<div className="icon-box">
<MessageCircle size={24} />
</div>
<div className="label">{'\u7edf\u8ba1\u6837\u672c'}</div>
<div className="value">{reportData.response.count}<span>{'\u6b21'}</span></div>
</div>
</div>
<p className="hero-desc response-note">
{`\u5171\u7edf\u8ba1 ${reportData.response.count} \u6b21\u6709\u6548\u56de\u590d\uff0c\u5e73\u5747\u7ea6 ${responseAvgMinutes} \u5206\u949f\uff0c\u6700\u5feb ${reportData.response.fastest} \u79d2\u3002`}
</p>
</section>
)}
{reportData.streak && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="label-text">{'\u804a\u5929\u706b\u82b1'}</div>
<h2 className="hero-title">{'\u6700\u957f\u8fde\u7eed\u804a\u5929'}</h2>
<div className="streak-container">
<div className="streak-flame">🔥</div>
<div className="streak-days">{reportData.streak.days}<span></span></div>
<div className="streak-flame">{'\uD83D\uDD25'}</div>
<div className="streak-days">{reportData.streak.days}<span>{'\u5929'}</span></div>
<div className="streak-range">
{reportData.streak.startDate} ~ {reportData.streak.endDate}
</div>
@@ -461,6 +515,7 @@ function DualReportWindow() {
) : (
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
)}
<div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}</div>
</div>
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
@@ -469,6 +524,7 @@ function DualReportWindow() {
) : (
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
)}
<div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}</div>
</div>
</div>
</section>