双人年度报告后端实现

This commit is contained in:
xuncha
2026-02-01 01:13:17 +08:00
parent 53f0e299e0
commit 5413d7e2c8
14 changed files with 1572 additions and 19 deletions

View File

@@ -0,0 +1,45 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { dualReportService } from './services/dualReportService'
interface WorkerConfig {
year: number
friendUsername: string
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await dualReportService.generateReportWithConfig({
year: config.year,
friendUsername: config.friendUsername,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'dualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
})

View File

@@ -1029,6 +1029,73 @@ function registerIpcHandlers() {
})
})
ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const logEnabled = cfg.get('logEnabled')
const friendUsername = payload?.friendUsername
const year = payload?.year ?? 0
if (!friendUsername) {
return { success: false, error: '缺少好友用户名' }
}
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'dualReportWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'dualReport:progress') {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('dualReport:progress', msg.data)
}
}
return
}
if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
cleanup()
void worker.terminate()
resolve(msg.data ?? msg.result)
return
}
if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
cleanup()
void worker.terminate()
resolve({ success: false, error: msg.error || '双人报告生成失败' })
}
})
worker.on('error', (err) => {
cleanup()
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code) => {
if (code !== 0) {
cleanup()
resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
}
})
})
})
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
try {
const { baseDir, folderName, images } = payload

View File

@@ -202,6 +202,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeAllListeners('annualReport:progress')
}
},
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) =>
ipcRenderer.invoke('dualReport:generateReport', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('dualReport:progress')
}
},
// 导出
export: {

View File

@@ -397,8 +397,10 @@ class AnnualReportService {
this.reportProgress('加载会话列表...', 15, onProgress)
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
const isAllTime = year <= 0
const reportYear = isAllTime ? 0 : year
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
let totalMessages = 0
const contactStats = new Map<string, { sent: number; received: number }>()
@@ -902,7 +904,7 @@ class AnnualReportService {
.map(([phrase, count]) => ({ phrase, count }))
const reportData: AnnualReportData = {
year,
year: reportYear,
totalMessages,
totalFriends: contactStats.size,
coreFriends,

View File

@@ -0,0 +1,445 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}
export interface DualReportFirstChat {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
}
export interface DualReportYearlyStats {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
}
export interface DualReportWordCloud {
words: Array<{ phrase: string; count: number }>
totalWords: number
totalMessages: number
}
export interface DualReportData {
year: number
myName: string
friendUsername: string
friendName: string
firstChat: DualReportFirstChat | null
thisYearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
} | null
yearlyStats: DualReportYearlyStats
wordCloud: DualReportWordCloud
}
class DualReportService {
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateTime(milliseconds: number): string {
const dt = new Date(milliseconds)
const month = String(dt.getMonth() + 1).padStart(2, '0')
const day = String(dt.getDate()).padStart(2, '0')
const hour = String(dt.getHours()).padStart(2, '0')
const minute = String(dt.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${minute}`
}
private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
if (attrMatch) {
let url = attrMatch[1].replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url
}
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
return tagMatch?.[1]
}
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
}
private async getDisplayName(username: string, fallback: string): Promise<string> {
const result = await wcdbService.getDisplayNames([username])
if (result.success && result.map) {
return result.map[username] || fallback
}
return fallback
}
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send
if (isSendRaw !== undefined && isSendRaw !== null) {
return parseInt(isSendRaw, 10) === 1
}
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return sender === rawLower || sender === cleanedLower
}
private async getFirstMessages(
sessionId: string,
limit: number,
beginTimestamp: number,
endTimestamp: number
): Promise<any[]> {
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) return []
try {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) return []
return batch.rows.slice(0, limit)
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
async generateReportWithConfig(params: {
year: number
friendUsername: string
dbPath: string
decryptKey: string
wxid: string
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
try {
const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const reportYear = year <= 0 ? 0 : year
const isAllTime = reportYear === 0
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
this.reportProgress('加载联系人信息...', 10, onProgress)
const friendName = await this.getDisplayName(friendUsername, friendUsername)
let myName = await this.getDisplayName(rawWxid, rawWxid)
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
myName = await this.getDisplayName(cleanedWxid, rawWxid)
}
this.reportProgress('获取首条聊天记录...', 15, onProgress)
const firstRows = await this.getFirstMessages(friendUsername, 1, 0, 0)
let firstChat: DualReportFirstChat | null = null
if (firstRows.length > 0) {
const row = firstRows[0]
const createTime = parseInt(row.create_time || '0', 10) * 1000
const content = this.decodeMessageContent(row.message_content, row.compress_content)
firstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(content || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender
}
}
let thisYearFirstChat: DualReportData['thisYearFirstChat'] = null
if (!isAllTime) {
this.reportProgress('获取今年首次聊天...', 20, onProgress)
const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime)
if (firstYearRows.length > 0) {
const firstRow = firstYearRows[0]
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
return {
content: String(msgContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime)
}
})
thisYearFirstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''),
isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid),
friendName,
firstThreeMessages
}
}
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const yearlyStats: DualReportYearlyStats = {
totalMessages: 0,
totalWords: 0,
imageCount: 0,
voiceCount: 0,
emojiCount: 0
}
const wordCountMap = new Map<string, number>()
const myEmojiCounts = new Map<string, number>()
const friendEmojiCounts = new Map<string, number>()
const myEmojiUrlMap = new Map<string, string>()
const friendEmojiUrlMap = new Map<string, string>()
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
const totalForProgress = messageCountResult.success && messageCountResult.count
? messageCountResult.count
: 0
let processed = 0
let lastProgressAt = 0
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
yearlyStats.totalMessages += 1
if (localType === 3) yearlyStats.imageCount += 1
if (localType === 34) yearlyStats.voiceCount += 1
if (localType === 47) {
yearlyStats.emojiCount += 1
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const md5 = this.extractEmojiMd5(content)
const url = this.extractEmojiUrl(content)
if (md5) {
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
if (url) {
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
if (!urlMap.has(md5)) urlMap.set(md5, url)
}
}
}
if (localType === 1 || localType === 244813135921) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content || '').trim()
if (text.length > 0) {
yearlyStats.totalWords += text.replace(/\s+/g, '').length
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length >= 2 &&
normalized.length <= 50 &&
!normalized.includes('http') &&
!normalized.includes('<') &&
!normalized.startsWith('[') &&
!normalized.startsWith('<?xml')) {
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 1)
}
}
}
if (totalForProgress > 0) {
processed++
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
if (totalForProgress > 0) {
const ratio = Math.min(1, processed / totalForProgress)
const progress = 30 + Math.floor(ratio * 50)
this.reportProgress('统计聊天数据...', progress, onProgress)
}
lastProgressAt = now
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
const pickTop = (map: Map<string, number>): string | undefined => {
let topKey: string | undefined
let topCount = -1
for (const [key, count] of map.entries()) {
if (count > topCount) {
topCount = count
topKey = key
}
}
return topKey
}
const myTopEmojiMd5 = pickTop(myEmojiCounts)
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
yearlyStats.myTopEmojiMd5 = myTopEmojiMd5
yearlyStats.friendTopEmojiMd5 = friendTopEmojiMd5
yearlyStats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
yearlyStats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
this.reportProgress('生成常用语词云...', 85, onProgress)
const wordCloudWords = Array.from(wordCountMap.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([phrase, count]) => ({ phrase, count }))
const wordCloud: DualReportWordCloud = {
words: wordCloudWords,
totalWords: yearlyStats.totalWords,
totalMessages: yearlyStats.totalMessages
}
const reportData: DualReportData = {
year: reportYear,
myName,
friendUsername,
friendName,
firstChat,
thisYearFirstChat,
yearlyStats,
wordCloud
}
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const dualReportService = new DualReportService()

View File

@@ -10,6 +10,8 @@ import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
import DualReportWindow from './pages/DualReportWindow'
import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import SettingsPage from './pages/SettingsPage'
@@ -398,6 +400,8 @@ function App() {
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />

View File

@@ -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>

View File

@@ -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>

View 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); }
}

View 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

View 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); }
}

View 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} &amp; {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

View File

@@ -337,6 +337,55 @@ export interface ElectronAPI {
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) => Promise<{
success: boolean
data?: {
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: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
} | 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
}
}
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
export: {
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean

View File

@@ -57,6 +57,24 @@ export default defineConfig({
}
}
},
{
entry: 'electron/dualReportWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'koffi',
'fsevents'
],
output: {
entryFileNames: 'dualReportWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{
entry: 'electron/imageSearchWorker.ts',
vite: {