mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
双人年度报告后端实现
This commit is contained in:
@@ -39,10 +39,11 @@ function AnnualReportPage() {
|
||||
}
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedYear || selectedYear === 'all') return
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
@@ -50,6 +51,12 @@ function AnnualReportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
@@ -111,7 +118,7 @@ function AnnualReportPage() {
|
||||
<button
|
||||
className="generate-btn"
|
||||
onClick={handleGenerateReport}
|
||||
disabled={!selectedYear || selectedYear === 'all' || isGenerating}
|
||||
disabled={!selectedYear || isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
@@ -125,9 +132,6 @@ function AnnualReportPage() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{selectedYear === 'all' ? (
|
||||
<p className="section-hint">全部时间报告功能准备中</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="report-section">
|
||||
@@ -155,11 +159,15 @@ function AnnualReportPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="generate-btn secondary" disabled>
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
</button>
|
||||
<p className="section-hint">双人年度报告入口已留出,功能在开发中</p>
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,7 +282,8 @@ function AnnualReportWindow() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const yearParam = params.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
|
||||
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
|
||||
generateReport(year)
|
||||
}, [])
|
||||
|
||||
@@ -337,6 +338,11 @@ function AnnualReportWindow() {
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||
if (value === 0) return '全部时间'
|
||||
return withSuffix ? `${value}年` : `${value}`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
@@ -595,7 +601,8 @@ function AnnualReportWindow() {
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
@@ -658,11 +665,12 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${reportData?.year}年度报告_分模块`,
|
||||
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||
images: exportedImages.map((img) => ({
|
||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
||||
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||
dataUrl: img.data
|
||||
}))
|
||||
})
|
||||
@@ -737,6 +745,10 @@ function AnnualReportWindow() {
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
const socialStoryName = topFriend?.displayName || '好友'
|
||||
const yearTitle = formatYearLabel(year, true)
|
||||
const yearTitleShort = formatYearLabel(year, false)
|
||||
const monthlyTitle = year === 0 ? '全部时间月度好友' : `${year}年月度好友`
|
||||
const phrasesTitle = year === 0 ? '你在全部时间的常用语' : `你在${year}年的年度常用语`
|
||||
|
||||
return (
|
||||
<div className="annual-report-window">
|
||||
@@ -827,7 +839,7 @@ function AnnualReportWindow() {
|
||||
{/* 封面 */}
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||
<h1 className="hero-title">{year}年<br />微信聊天报告</h1>
|
||||
<h1 className="hero-title">{yearTitle}<br />微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||
</section>
|
||||
@@ -869,7 +881,7 @@ function AnnualReportWindow() {
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{year}年月度好友</h2>
|
||||
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
@@ -1016,7 +1028,7 @@ function AnnualReportWindow() {
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
||||
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
<br />
|
||||
@@ -1085,7 +1097,7 @@ function AnnualReportWindow() {
|
||||
<br />愿新的一年,
|
||||
<br />所有期待,皆有回声。
|
||||
</p>
|
||||
<div className="ending-year">{year}</div>
|
||||
<div className="ending-year">{yearTitleShort}</div>
|
||||
<div className="ending-brand">WEFLOW</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
171
src/pages/DualReportPage.scss
Normal file
171
src/pages/DualReportPage.scss
Normal file
@@ -0,0 +1,171 @@
|
||||
.dual-report-page {
|
||||
padding: 32px 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dual-report-page.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&.top {
|
||||
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.count {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
138
src/pages/DualReportPage.tsx
Normal file
138
src/pages/DualReportPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Search, Users } from 'lucide-react'
|
||||
import './DualReportPage.scss'
|
||||
|
||||
interface ContactRanking {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
sentCount: number
|
||||
receivedCount: number
|
||||
lastMessageTime?: number | null
|
||||
}
|
||||
|
||||
function DualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [year, setYear] = useState<number>(0)
|
||||
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)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRankings()
|
||||
}, [])
|
||||
|
||||
const loadRankings = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getContactRankings(200)
|
||||
if (result.success && result.data) {
|
||||
setRankings(result.data)
|
||||
} else {
|
||||
setLoadError(result.error || '加载好友列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setLoadError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const yearLabel = year === 0 ? '全部时间' : `${year}年`
|
||||
|
||||
const filteredRankings = useMemo(() => {
|
||||
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)
|
||||
})
|
||||
}, [rankings, keyword])
|
||||
|
||||
const handleSelect = (username: string) => {
|
||||
const yearParam = year === 0 ? 0 : year
|
||||
navigate(`/dual-report/view?username=${encodeURIComponent(username)}&year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="dual-report-page loading">
|
||||
<Loader2 size={32} className="spin" />
|
||||
<p>正在加载聊天排行...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="dual-report-page loading">
|
||||
<p>加载失败:{loadError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dual-report-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>双人年度报告</h1>
|
||||
<p>选择一位好友,生成你们的专属聊天报告</p>
|
||||
</div>
|
||||
<div className="year-badge">
|
||||
<Users size={14} />
|
||||
<span>{yearLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索好友(昵称/备注/wxid)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ranking-list">
|
||||
{filteredRankings.map((item, index) => (
|
||||
<button
|
||||
key={item.username}
|
||||
className="ranking-item"
|
||||
onClick={() => handleSelect(item.username)}
|
||||
>
|
||||
<span className={`rank-badge ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="avatar">
|
||||
{item.avatarUrl
|
||||
? <img src={item.avatarUrl} alt={item.displayName} />
|
||||
: <span>{item.displayName.slice(0, 1) || '?'}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="info">
|
||||
<div className="name">{item.displayName}</div>
|
||||
<div className="sub">{item.username}</div>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<div className="count">{item.messageCount.toLocaleString()} 条</div>
|
||||
<div className="hint">总消息</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredRankings.length === 0 ? (
|
||||
<div className="empty">没有匹配的好友</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DualReportPage
|
||||
220
src/pages/DualReportWindow.scss
Normal file
220
src/pages/DualReportWindow.scss
Normal file
@@ -0,0 +1,220 @@
|
||||
.dual-report-window {
|
||||
color: var(--text-primary);
|
||||
padding: 32px 24px 60px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.dual-report-window.loading,
|
||||
.dual-report-window.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dual-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin: 16px auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.dual-section.cover {
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 10%, transparent) 0%, var(--card-bg) 100%);
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
|
||||
&.received {
|
||||
background: color-mix(in srgb, var(--border-color) 35%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.emoji-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
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(--text-tertiary);
|
||||
}
|
||||
|
||||
.emoji-placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.word-cloud-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 80%;
|
||||
background: color-mix(in srgb, var(--primary) 4%, transparent);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.word-cloud-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.word-tag {
|
||||
position: absolute;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
animation: fadeUp 0.8s ease forwards;
|
||||
}
|
||||
|
||||
.word-cloud-empty {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) translateY(10px); }
|
||||
to { opacity: var(--final-opacity, 1); transform: translate(-50%, -50%) translateY(0); }
|
||||
}
|
||||
366
src/pages/DualReportWindow.tsx
Normal file
366
src/pages/DualReportWindow.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import './DualReportWindow.scss'
|
||||
|
||||
interface DualReportMessage {
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
}
|
||||
|
||||
interface DualReportData {
|
||||
year: number
|
||||
myName: string
|
||||
friendUsername: string
|
||||
friendName: string
|
||||
firstChat: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
senderUsername?: string
|
||||
} | null
|
||||
thisYearFirstChat?: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
friendName: string
|
||||
firstThreeMessages: DualReportMessage[]
|
||||
} | null
|
||||
yearlyStats: {
|
||||
totalMessages: number
|
||||
totalWords: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
emojiCount: number
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
}
|
||||
wordCloud: {
|
||||
words: Array<{ phrase: string; count: number }>
|
||||
totalWords: number
|
||||
totalMessages: number
|
||||
}
|
||||
}
|
||||
|
||||
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
|
||||
if (!words || words.length === 0) {
|
||||
return <div className="word-cloud-empty">暂无高频语句</div>
|
||||
}
|
||||
const maxCount = words.length > 0 ? words[0].count : 1
|
||||
const topWords = words.slice(0, 32)
|
||||
const baseSize = 520
|
||||
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
const placedItems: { x: number; y: number; w: number; h: number }[] = []
|
||||
|
||||
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const dx = x - 50
|
||||
const dy = y - 50
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const maxR = 49 - Math.max(halfW, halfH)
|
||||
if (dist > maxR) return false
|
||||
|
||||
const pad = 1.8
|
||||
for (const p of placedItems) {
|
||||
if ((x - halfW - pad) < (p.x + p.w / 2) &&
|
||||
(x + halfW + pad) > (p.x - p.w / 2) &&
|
||||
(y - halfH - pad) < (p.y + p.h / 2) &&
|
||||
(y + halfH + pad) > (p.y - p.h / 2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const wordItems = topWords.map((item, i) => {
|
||||
const ratio = item.count / maxCount
|
||||
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
|
||||
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
|
||||
const delay = (i * 0.04).toFixed(2)
|
||||
|
||||
const charCount = Math.max(1, item.phrase.length)
|
||||
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
|
||||
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
|
||||
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
|
||||
const widthPx = fontSize * (charCount * widthFactor)
|
||||
const heightPx = fontSize * 1.1
|
||||
const widthPct = (widthPx / baseSize) * 100
|
||||
const heightPct = (heightPx / baseSize) * 100
|
||||
|
||||
let x = 50, y = 50
|
||||
let placedOk = false
|
||||
const tries = i === 0 ? 1 : 420
|
||||
|
||||
for (let t = 0; t < tries; t++) {
|
||||
if (i === 0) {
|
||||
x = 50
|
||||
y = 50
|
||||
} else {
|
||||
const idx = i + t * 0.28
|
||||
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
|
||||
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
|
||||
x = 50 + radius * Math.cos(angle)
|
||||
y = 50 + radius * Math.sin(angle)
|
||||
}
|
||||
if (canPlace(x, y, widthPct, heightPct)) {
|
||||
placedOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedOk) return null
|
||||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="word-tag"
|
||||
style={{
|
||||
'--final-opacity': opacity,
|
||||
left: `${x.toFixed(2)}%`,
|
||||
top: `${y.toFixed(2)}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
animationDelay: `${delay}s`,
|
||||
} as CSSProperties}
|
||||
title={`${item.phrase} (出现 ${item.count} 次)`}
|
||||
>
|
||||
{item.phrase}
|
||||
</span>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="word-cloud-wrapper">
|
||||
<div className="word-cloud-inner">
|
||||
{wordItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DualReportWindow() {
|
||||
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingStage, setLoadingStage] = useState('准备中')
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const username = params.get('username')
|
||||
const yearParam = params.get('year')
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
|
||||
const year = Number.isNaN(parsedYear) ? 0 : parsedYear
|
||||
if (!username) {
|
||||
setError('缺少好友信息')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
generateReport(username, year)
|
||||
}, [])
|
||||
|
||||
const generateReport = async (friendUsername: string, year: number) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setLoadingProgress(0)
|
||||
|
||||
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||
setLoadingProgress(payload.progress)
|
||||
setLoadingStage(payload.status)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
|
||||
removeProgressListener?.()
|
||||
setLoadingProgress(100)
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
setReportData(result.data)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError(result.error || '生成报告失败')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
removeProgressListener?.()
|
||||
setError(String(e))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmojis = async () => {
|
||||
if (!reportData) return
|
||||
const stats = reportData.yearlyStats
|
||||
if (stats.myTopEmojiUrl) {
|
||||
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
|
||||
if (res.success && res.localPath) {
|
||||
setMyEmojiUrl(res.localPath)
|
||||
}
|
||||
}
|
||||
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])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="dual-report-window loading">
|
||||
<Loader2 size={36} className="spin" />
|
||||
<div className="progress">{loadingProgress}%</div>
|
||||
<div className="stage">{loadingStage}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="dual-report-window error">
|
||||
<p>生成报告失败:{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reportData) {
|
||||
return (
|
||||
<div className="dual-report-window error">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年`
|
||||
const firstChat = reportData.firstChat
|
||||
const daysSince = firstChat
|
||||
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
|
||||
: null
|
||||
const thisYearFirstChat = reportData.thisYearFirstChat
|
||||
const stats = reportData.yearlyStats
|
||||
|
||||
return (
|
||||
<div className="dual-report-window">
|
||||
<section className="dual-section cover">
|
||||
<div className="label">DUAL REPORT</div>
|
||||
<h1>{reportData.myName} & {reportData.friendName}</h1>
|
||||
<p>让我们一起回顾这段独一无二的对话</p>
|
||||
</section>
|
||||
|
||||
<section className="dual-section">
|
||||
<div className="section-title">首次聊天</div>
|
||||
{firstChat ? (
|
||||
<div className="info-card">
|
||||
<div className="info-row">
|
||||
<span className="info-label">第一次聊天时间</span>
|
||||
<span className="info-value">{firstChat.createTimeStr}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">距今天数</span>
|
||||
<span className="info-value">{daysSince} 天</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">首条消息</span>
|
||||
<span className="info-value">{firstChat.content || '(空)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="info-empty">暂无首条消息</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{thisYearFirstChat ? (
|
||||
<section className="dual-section">
|
||||
<div className="section-title">今年首次聊天</div>
|
||||
<div className="info-card">
|
||||
<div className="info-row">
|
||||
<span className="info-label">首次时间</span>
|
||||
<span className="info-value">{thisYearFirstChat.createTimeStr}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">发起者</span>
|
||||
<span className="info-value">{thisYearFirstChat.isSentByMe ? reportData.myName : reportData.friendName}</span>
|
||||
</div>
|
||||
<div className="message-list">
|
||||
{thisYearFirstChat.firstThreeMessages.map((msg, idx) => (
|
||||
<div key={idx} className={`message-item ${msg.isSentByMe ? 'sent' : 'received'}`}>
|
||||
<div className="message-meta">{msg.isSentByMe ? reportData.myName : reportData.friendName} · {msg.createTimeStr}</div>
|
||||
<div className="message-content">{msg.content || '(空)'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="dual-section">
|
||||
<div className="section-title">{yearTitle}常用语</div>
|
||||
<WordCloud words={reportData.wordCloud.words} />
|
||||
</section>
|
||||
|
||||
<section className="dual-section">
|
||||
<div className="section-title">{yearTitle}统计</div>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.totalMessages.toLocaleString()}</div>
|
||||
<div className="stat-label">总消息数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.totalWords.toLocaleString()}</div>
|
||||
<div className="stat-label">总字数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.imageCount.toLocaleString()}</div>
|
||||
<div className="stat-label">图片</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.voiceCount.toLocaleString()}</div>
|
||||
<div className="stat-label">语音</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.emojiCount.toLocaleString()}</div>
|
||||
<div className="stat-label">表情</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="emoji-row">
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">我常用的表情</div>
|
||||
{myEmojiUrl ? (
|
||||
<img src={myEmojiUrl} alt="my-emoji" />
|
||||
) : (
|
||||
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
||||
{friendEmojiUrl ? (
|
||||
<img src={friendEmojiUrl} alt="friend-emoji" />
|
||||
) : (
|
||||
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DualReportWindow
|
||||
Reference in New Issue
Block a user