mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,3 +57,4 @@ Thumbs.db
|
|||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
*info
|
*info
|
||||||
|
*.md
|
||||||
|
|||||||
45
electron/dualReportWorker.ts
Normal file
45
electron/dualReportWorker.ts
Normal 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) })
|
||||||
|
})
|
||||||
@@ -845,6 +845,18 @@ function registerIpcHandlers() {
|
|||||||
return analyticsService.getTimeDistribution()
|
return analyticsService.getTimeDistribution()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
||||||
|
return analyticsService.getExcludedUsernames()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
|
||||||
|
return analyticsService.setExcludedUsernames(usernames)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('analytics:getExcludeCandidates', async () => {
|
||||||
|
return analyticsService.getExcludeCandidates()
|
||||||
|
})
|
||||||
|
|
||||||
// 缓存管理
|
// 缓存管理
|
||||||
ipcMain.handle('cache:clearAnalytics', async () => {
|
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||||
return analyticsService.clearCache()
|
return analyticsService.clearCache()
|
||||||
@@ -1017,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 }> }) => {
|
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
|
||||||
try {
|
try {
|
||||||
const { baseDir, folderName, images } = payload
|
const { baseDir, folderName, images } = payload
|
||||||
|
|||||||
@@ -162,9 +162,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
||||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||||
|
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||||
|
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||||
|
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||||
@@ -199,6 +202,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
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: {
|
export: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile, rm } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -46,6 +47,58 @@ class AnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeUsername(username: string): string {
|
||||||
|
return username.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeExcludedUsernames(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const normalized = value
|
||||||
|
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
return Array.from(new Set(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExcludedUsernamesList(): string[] {
|
||||||
|
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExcludedUsernamesSet(): Set<string> {
|
||||||
|
return new Set(this.getExcludedUsernamesList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeSqlValue(value: string): string {
|
||||||
|
return value.replace(/'/g, "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
|
const chunkSize = 200
|
||||||
|
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||||
|
const chunk = usernames.slice(i, i + chunkSize)
|
||||||
|
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||||
|
if (!inList) continue
|
||||||
|
const sql = `
|
||||||
|
SELECT username, alias
|
||||||
|
FROM contact
|
||||||
|
WHERE username IN (${inList})
|
||||||
|
`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const username = row.username || ''
|
||||||
|
const alias = row.alias || ''
|
||||||
|
if (username && alias) {
|
||||||
|
map[username] = alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(name: string): string {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -97,13 +150,15 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getPrivateSessions(
|
private async getPrivateSessions(
|
||||||
cleanedWxid: string
|
cleanedWxid: string,
|
||||||
|
excludedUsernames?: Set<string>
|
||||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
if (!sessionResult.success || !sessionResult.sessions) {
|
if (!sessionResult.success || !sessionResult.sessions) {
|
||||||
return { usernames: [], numericIds: [] }
|
return { usernames: [], numericIds: [] }
|
||||||
}
|
}
|
||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||||
|
|
||||||
const sample = rows[0]
|
const sample = rows[0]
|
||||||
void sample
|
void sample
|
||||||
@@ -124,7 +179,11 @@ class AnalyticsService {
|
|||||||
return { username, idValue }
|
return { username, idValue }
|
||||||
})
|
})
|
||||||
const usernames = sessions.map((s) => s.username)
|
const usernames = sessions.map((s) => s.username)
|
||||||
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
|
const privateSessions = sessions.filter((s) => {
|
||||||
|
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
|
||||||
|
if (excluded.size === 0) return true
|
||||||
|
return !excluded.has(this.normalizeUsername(s.username))
|
||||||
|
})
|
||||||
const privateUsernames = privateSessions.map((s) => s.username)
|
const privateUsernames = privateSessions.map((s) => s.username)
|
||||||
const numericIds = privateSessions
|
const numericIds = privateSessions
|
||||||
.map((s) => s.idValue)
|
.map((s) => s.idValue)
|
||||||
@@ -177,8 +236,12 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||||
const sample = sessionIds.slice(0, 5).join(',')
|
if (sessionIds.length === 0) {
|
||||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
return `${beginTimestamp}-${endTimestamp}-0-empty`
|
||||||
|
}
|
||||||
|
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
|
||||||
|
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
|
||||||
|
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||||
@@ -369,6 +432,65 @@ class AnalyticsService {
|
|||||||
void results
|
void results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
return { success: true, data: this.getExcludedUsernamesList() }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const normalized = this.normalizeExcludedUsernames(usernames)
|
||||||
|
this.configService.set('analyticsExcludedUsernames', normalized)
|
||||||
|
await this.clearCache()
|
||||||
|
return { success: true, data: normalized }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const excluded = this.getExcludedUsernamesSet()
|
||||||
|
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
|
||||||
|
|
||||||
|
const usernames = new Set<string>(sessionInfo.usernames)
|
||||||
|
for (const name of excluded) usernames.add(name)
|
||||||
|
|
||||||
|
if (usernames.size === 0) {
|
||||||
|
return { success: true, data: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameList = Array.from(usernames)
|
||||||
|
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(usernameList),
|
||||||
|
wcdbService.getAvatarUrls(usernameList),
|
||||||
|
this.getAliasMap(usernameList)
|
||||||
|
])
|
||||||
|
|
||||||
|
const entries = usernameList.map((username) => {
|
||||||
|
const displayName = displayNames.success && displayNames.map
|
||||||
|
? (displayNames.map[username] || username)
|
||||||
|
: username
|
||||||
|
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||||
|
? avatarUrls.map[username]
|
||||||
|
: undefined
|
||||||
|
const alias = aliasMap[username]
|
||||||
|
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||||
|
return { username, displayName, avatarUrl, wechatId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: entries }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ export interface AnnualReportData {
|
|||||||
phrase: string
|
phrase: string
|
||||||
count: number
|
count: number
|
||||||
}[]
|
}[]
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
class AnnualReportService {
|
class AnnualReportService {
|
||||||
@@ -397,8 +411,15 @@ class AnnualReportService {
|
|||||||
|
|
||||||
this.reportProgress('加载会话列表...', 15, onProgress)
|
this.reportProgress('加载会话列表...', 15, onProgress)
|
||||||
|
|
||||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
const isAllTime = year <= 0
|
||||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
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)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
|
||||||
|
const actualStartTime = startTime
|
||||||
|
const actualEndTime = endTime
|
||||||
|
|
||||||
let totalMessages = 0
|
let totalMessages = 0
|
||||||
const contactStats = new Map<string, { sent: number; received: number }>()
|
const contactStats = new Map<string, { sent: number; received: number }>()
|
||||||
@@ -420,7 +441,7 @@ class AnnualReportService {
|
|||||||
const CONVERSATION_GAP = 3600
|
const CONVERSATION_GAP = 3600
|
||||||
|
|
||||||
this.reportProgress('统计会话消息...', 20, onProgress)
|
this.reportProgress('统计会话消息...', 20, onProgress)
|
||||||
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
|
const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
||||||
}
|
}
|
||||||
@@ -474,7 +495,7 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
||||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
|
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||||
if (extras.success && extras.data) {
|
if (extras.success && extras.data) {
|
||||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||||
const extrasData = extras.data as any
|
const extrasData = extras.data as any
|
||||||
@@ -554,7 +575,7 @@ class AnnualReportService {
|
|||||||
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
||||||
for (let i = 0; i < sessionIds.length; i++) {
|
for (let i = 0; i < sessionIds.length; i++) {
|
||||||
const sessionId = sessionIds[i]
|
const sessionId = sessionIds[i]
|
||||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
|
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
|
||||||
if (!cursor.success || !cursor.cursor) continue
|
if (!cursor.success || !cursor.cursor) continue
|
||||||
|
|
||||||
let lastDayIndex: number | null = null
|
let lastDayIndex: number | null = null
|
||||||
@@ -689,7 +710,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
if (!streakComputedInLoop) {
|
if (!streakComputedInLoop) {
|
||||||
this.reportProgress('计算连续聊天...', 45, onProgress)
|
this.reportProgress('计算连续聊天...', 45, onProgress)
|
||||||
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
|
const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
|
||||||
if (streakResult.days > longestStreakDays) {
|
if (streakResult.days > longestStreakDays) {
|
||||||
longestStreakDays = streakResult.days
|
longestStreakDays = streakResult.days
|
||||||
longestStreakSessionId = streakResult.sessionId
|
longestStreakSessionId = streakResult.sessionId
|
||||||
@@ -698,6 +719,42 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取朋友圈统计
|
||||||
|
this.reportProgress('分析朋友圈数据...', 75, onProgress)
|
||||||
|
let snsStatsResult: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||||
|
|
||||||
|
if (snsStats.success && snsStats.data) {
|
||||||
|
const d = snsStats.data
|
||||||
|
const usersToFetch = new Set<string>()
|
||||||
|
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
|
||||||
|
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
|
||||||
|
|
||||||
|
const snsUserIds = Array.from(usersToFetch)
|
||||||
|
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(snsUserIds),
|
||||||
|
wcdbService.getAvatarUrls(snsUserIds)
|
||||||
|
])
|
||||||
|
|
||||||
|
const getSnsUserInfo = (username: string) => ({
|
||||||
|
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
|
||||||
|
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
snsStatsResult = {
|
||||||
|
totalPosts: d.totalPosts || 0,
|
||||||
|
typeCounts: d.typeCounts,
|
||||||
|
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
|
||||||
|
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||||
|
|
||||||
const contactIds = Array.from(contactStats.keys())
|
const contactIds = Array.from(contactStats.keys())
|
||||||
@@ -901,8 +958,130 @@ class AnnualReportService {
|
|||||||
.slice(0, 32)
|
.slice(0, 32)
|
||||||
.map(([phrase, count]) => ({ phrase, count }))
|
.map(([phrase, count]) => ({ phrase, count }))
|
||||||
|
|
||||||
|
// 曾经的好朋友 (Once Best Friend / Lost Friend)
|
||||||
|
let lostFriend: AnnualReportData['lostFriend'] = null
|
||||||
|
let maxEarlyCount = 80 // 最低门槛
|
||||||
|
let bestEarlyCount = 0
|
||||||
|
let bestLateCount = 0
|
||||||
|
let bestSid = ''
|
||||||
|
let bestPeriodDesc = ''
|
||||||
|
|
||||||
|
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
|
||||||
|
|
||||||
|
const currentYearNum = now.getFullYear()
|
||||||
|
|
||||||
|
if (isAllTime) {
|
||||||
|
const days = Object.keys(d.daily).sort()
|
||||||
|
if (days.length >= 2) {
|
||||||
|
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
|
||||||
|
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
|
||||||
|
const midPoint = Math.floor((firstDay + lastDay) / 2)
|
||||||
|
|
||||||
|
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
|
||||||
|
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
|
||||||
|
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
|
||||||
|
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
|
||||||
|
|
||||||
|
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||||
|
const earlyData = earlyRes.data.sessions || {}
|
||||||
|
const lateData = (lateRes.data?.sessions) || {}
|
||||||
|
for (const sid of sessionIds) {
|
||||||
|
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||||
|
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||||
|
const early = (e.sent || 0) + (e.received || 0)
|
||||||
|
const late = (l.sent || 0) + (l.received || 0)
|
||||||
|
if (early > 100 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = '这段时间以来'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (year === currentYearNum) {
|
||||||
|
// 当前年份:独立获取过去12个月的滚动数据
|
||||||
|
this.reportProgress('分析近期好友趋势...', 86, onProgress)
|
||||||
|
// 往前数12个月的起点、中点、终点
|
||||||
|
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
|
||||||
|
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
|
||||||
|
const rollingEnd = Math.floor(now.getTime() / 1000)
|
||||||
|
|
||||||
|
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
|
||||||
|
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
|
||||||
|
|
||||||
|
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||||
|
const earlyData = earlyRes.data.sessions || {}
|
||||||
|
const lateData = lateRes.data?.sessions || {}
|
||||||
|
for (const sid of sessionIds) {
|
||||||
|
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||||
|
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||||
|
const early = (e.sent || 0) + (e.received || 0)
|
||||||
|
const late = (l.sent || 0) + (l.received || 0)
|
||||||
|
if (early > 80 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = '去年的这个时候'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 指定完整年份 (1-6 vs 7-12)
|
||||||
|
for (const [sid, stat] of Object.entries(d.sessions)) {
|
||||||
|
const s = stat as any
|
||||||
|
const mWeights = s.monthly || {}
|
||||||
|
let early = 0
|
||||||
|
let late = 0
|
||||||
|
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
|
||||||
|
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
|
||||||
|
|
||||||
|
if (early > 80 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = `${year}年上半年`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestSid) {
|
||||||
|
let info = contactInfoMap.get(bestSid)
|
||||||
|
// 如果 contactInfoMap 中没有该联系人,则单独获取
|
||||||
|
if (!info) {
|
||||||
|
const [displayNameRes, avatarUrlRes] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames([bestSid]),
|
||||||
|
wcdbService.getAvatarUrls([bestSid])
|
||||||
|
])
|
||||||
|
info = {
|
||||||
|
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
|
||||||
|
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lostFriend = {
|
||||||
|
username: bestSid,
|
||||||
|
displayName: info?.displayName || bestSid,
|
||||||
|
avatarUrl: info?.avatarUrl,
|
||||||
|
earlyCount: bestEarlyCount,
|
||||||
|
lateCount: bestLateCount,
|
||||||
|
periodDesc: bestPeriodDesc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reportData: AnnualReportData = {
|
const reportData: AnnualReportData = {
|
||||||
year,
|
year: reportYear,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
totalFriends: contactStats.size,
|
totalFriends: contactStats.size,
|
||||||
coreFriends,
|
coreFriends,
|
||||||
@@ -915,7 +1094,9 @@ class AnnualReportService {
|
|||||||
mutualFriend,
|
mutualFriend,
|
||||||
socialInitiative,
|
socialInitiative,
|
||||||
responseSpeed,
|
responseSpeed,
|
||||||
topPhrases
|
topPhrases,
|
||||||
|
snsStats: snsStatsResult,
|
||||||
|
lostFriend
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: reportData }
|
return { success: true, data: reportData }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
@@ -62,6 +63,7 @@ export class ConfigService {
|
|||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 2,
|
exportDefaultConcurrency: 2,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
|
|||||||
456
electron/services/dualReportService.ts
Normal file
456
electron/services/dualReportService.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
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 DualReportStats {
|
||||||
|
totalMessages: number
|
||||||
|
totalWords: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
emojiCount: number
|
||||||
|
myTopEmojiMd5?: string
|
||||||
|
friendTopEmojiMd5?: string
|
||||||
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DualReportData {
|
||||||
|
year: number
|
||||||
|
selfName: string
|
||||||
|
friendUsername: string
|
||||||
|
friendName: string
|
||||||
|
firstChat: DualReportFirstChat | null
|
||||||
|
firstChatMessages?: DualReportMessage[]
|
||||||
|
yearFirstChat?: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
friendName: string
|
||||||
|
firstThreeMessages: DualReportMessage[]
|
||||||
|
} | null
|
||||||
|
stats: DualReportStats
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/&/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 safeBegin = Math.max(0, beginTimestamp || 0)
|
||||||
|
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) return []
|
||||||
|
try {
|
||||||
|
const rows: any[] = []
|
||||||
|
let hasMore = true
|
||||||
|
while (hasMore && rows.length < limit) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||||
|
if (!batch.success || !batch.rows) break
|
||||||
|
for (const row of batch.rows) {
|
||||||
|
rows.push(row)
|
||||||
|
if (rows.length >= limit) break
|
||||||
|
}
|
||||||
|
hasMore = batch.hasMore === true
|
||||||
|
}
|
||||||
|
return 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, 3, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstChatMessages: DualReportMessage[] = firstRows.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let yearFirstChat: DualReportData['yearFirstChat'] = 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
yearFirstChat = {
|
||||||
|
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 stats: DualReportStats = {
|
||||||
|
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)
|
||||||
|
stats.totalMessages += 1
|
||||||
|
|
||||||
|
if (localType === 3) stats.imageCount += 1
|
||||||
|
if (localType === 34) stats.voiceCount += 1
|
||||||
|
if (localType === 47) {
|
||||||
|
stats.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) {
|
||||||
|
stats.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)
|
||||||
|
|
||||||
|
stats.myTopEmojiMd5 = myTopEmojiMd5
|
||||||
|
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
||||||
|
stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
|
||||||
|
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
|
||||||
|
|
||||||
|
this.reportProgress('生成常用语词云...', 85, onProgress)
|
||||||
|
const topPhrases = Array.from(wordCountMap.entries())
|
||||||
|
.filter(([_, count]) => count >= 2)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 50)
|
||||||
|
.map(([phrase, count]) => ({ phrase, count }))
|
||||||
|
|
||||||
|
const reportData: DualReportData = {
|
||||||
|
year: reportYear,
|
||||||
|
selfName: myName,
|
||||||
|
friendUsername,
|
||||||
|
friendName,
|
||||||
|
firstChat,
|
||||||
|
firstChatMessages,
|
||||||
|
yearFirstChat,
|
||||||
|
stats,
|
||||||
|
topPhrases
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportProgress('双人报告生成完成', 100, onProgress)
|
||||||
|
return { success: true, data: reportData }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dualReportService = new DualReportService()
|
||||||
@@ -208,145 +208,18 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
|
|
||||||
* wxid_xxx<binary>群昵称<binary>wxid_yyy<binary>群昵称...
|
|
||||||
*/
|
|
||||||
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
|
|
||||||
const nicknameMap = new Map<string, string>()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 将 buffer 转为字符串,允许部分乱码
|
|
||||||
const raw = buffer.toString('utf8')
|
|
||||||
|
|
||||||
// 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线
|
|
||||||
const wxidPattern = /wxid_[a-z0-9_]+/gi
|
|
||||||
const wxids = raw.match(wxidPattern) || []
|
|
||||||
|
|
||||||
// 对每个 wxid,尝试提取其后的群昵称
|
|
||||||
for (const wxid of wxids) {
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
|
|
||||||
|
|
||||||
if (wxidIndex === -1) continue
|
|
||||||
|
|
||||||
// 从 wxid 结束位置开始查找
|
|
||||||
const afterWxid = raw.slice(wxidIndex + wxid.length)
|
|
||||||
|
|
||||||
// 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等)
|
|
||||||
// 跳过前面的不可打印字符和特定控制字符
|
|
||||||
let nickname = ''
|
|
||||||
let foundStart = false
|
|
||||||
|
|
||||||
for (let i = 0; i < afterWxid.length && i < 100; i++) {
|
|
||||||
const char = afterWxid[i]
|
|
||||||
const code = char.charCodeAt(0)
|
|
||||||
|
|
||||||
// 判断是否为可打印字符(中文、字母、数字、常见符号)
|
|
||||||
const isPrintable = (
|
|
||||||
(code >= 0x4E00 && code <= 0x9FFF) || // 中文
|
|
||||||
(code >= 0x3000 && code <= 0x303F) || // CJK 符号
|
|
||||||
(code >= 0xFF00 && code <= 0xFFEF) || // 全角字符
|
|
||||||
(code >= 0x20 && code <= 0x7E) // ASCII 可打印字符
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isPrintable && code !== 0x01 && code !== 0x18) {
|
|
||||||
foundStart = true
|
|
||||||
nickname += char
|
|
||||||
} else if (foundStart) {
|
|
||||||
// 遇到不可打印字符,停止
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理昵称:去除前后空白和特殊字符
|
|
||||||
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
|
|
||||||
|
|
||||||
// 只保存有效的群昵称(长度 > 0 且 < 50)
|
|
||||||
if (nickname && nickname.length > 0 && nickname.length < 50) {
|
|
||||||
nicknameMap.set(wxidLower, nickname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 解析失败时返回空 Map
|
|
||||||
console.error('Failed to parse ext_buffer:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nicknameMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 contact.db 的 chat_room 表获取群成员的群昵称
|
|
||||||
* @param chatroomId 群聊ID (如 "xxxxx@chatroom")
|
|
||||||
* @returns Map<wxid, 群昵称>
|
|
||||||
*/
|
*/
|
||||||
async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||||
console.log('========== getGroupNicknamesForRoom START ==========', chatroomId)
|
|
||||||
try {
|
try {
|
||||||
// 查询 contact.db 的 chat_room 表
|
const result = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
// path设为null,因为contact.db已经随handle一起打开了
|
if (result.success && result.nicknames) {
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
|
return new Map(Object.entries(result.nicknames))
|
||||||
console.log('执行SQL查询:', sql)
|
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
|
||||||
console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error })
|
|
||||||
|
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
|
||||||
console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error)
|
|
||||||
return new Map<string, string>()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let extBuffer = result.rows[0].ext_buffer
|
|
||||||
console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer))
|
|
||||||
|
|
||||||
// execQuery返回的二进制数据会被编码为字符串(hex或base64)
|
|
||||||
// 需要转换回Buffer
|
|
||||||
if (typeof extBuffer === 'string') {
|
|
||||||
console.log('🔄 ext_buffer是字符串,尝试转换为Buffer...')
|
|
||||||
|
|
||||||
// 尝试判断是hex还是base64
|
|
||||||
if (this.looksLikeHex(extBuffer)) {
|
|
||||||
console.log('✅ 检测到hex编码,使用hex解码')
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
|
||||||
} else if (this.looksLikeBase64(extBuffer)) {
|
|
||||||
console.log('✅ 检测到base64编码,使用base64解码')
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
|
||||||
} else {
|
|
||||||
// 默认尝试hex
|
|
||||||
console.log(' 无法判断编码格式,默认尝试hex')
|
|
||||||
try {
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
|
||||||
} catch (e) {
|
|
||||||
console.log('❌ hex解码失败,尝试base64')
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('✅ 转换后的Buffer长度:', extBuffer.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
|
|
||||||
console.log('❌ ext_buffer转换失败,不是Buffer类型:', typeof extBuffer)
|
|
||||||
return new Map<string, string>()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length)
|
|
||||||
const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer)
|
|
||||||
console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称')
|
|
||||||
|
|
||||||
// 打印前5个群昵称作为示例
|
|
||||||
let count = 0
|
|
||||||
for (const [wxid, nickname] of nicknamesMap.entries()) {
|
|
||||||
if (count++ < 5) {
|
|
||||||
console.log(` - ${wxid}: "${nickname}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nicknamesMap
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ getGroupNicknamesForRoom异常:', e)
|
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
} finally {
|
} catch (e) {
|
||||||
console.log('========== getGroupNicknamesForRoom END ==========')
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +305,15 @@ class ExportService {
|
|||||||
return /^[0-9a-fA-F]+$/.test(s)
|
return /^[0-9a-fA-F]+$/.test(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeGroupNickname(value: string): string {
|
||||||
|
const trimmed = (value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '')
|
||||||
|
if (!cleaned) return ''
|
||||||
|
if (/^[,"'“”‘’,、]+$/.test(cleaned)) return ''
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户偏好获取显示名称
|
* 根据用户偏好获取显示名称
|
||||||
*/
|
*/
|
||||||
@@ -1595,6 +1477,87 @@ class ExportService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出头像为外部文件(仅用于HTML格式)
|
||||||
|
* 将头像保存到 avatars/ 子目录,返回相对路径
|
||||||
|
*/
|
||||||
|
private async exportAvatarsToFiles(
|
||||||
|
members: Array<{ username: string; avatarUrl?: string }>,
|
||||||
|
outputDir: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
if (members.length === 0) return result
|
||||||
|
|
||||||
|
// 创建 avatars 子目录
|
||||||
|
const avatarsDir = path.join(outputDir, 'avatars')
|
||||||
|
if (!fs.existsSync(avatarsDir)) {
|
||||||
|
fs.mkdirSync(avatarsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const fileInfo = this.resolveAvatarFile(member.avatarUrl)
|
||||||
|
if (!fileInfo) continue
|
||||||
|
try {
|
||||||
|
let data: Buffer | null = null
|
||||||
|
let mime = fileInfo.mime
|
||||||
|
if (fileInfo.data) {
|
||||||
|
data = fileInfo.data
|
||||||
|
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
|
||||||
|
data = await fs.promises.readFile(fileInfo.sourcePath)
|
||||||
|
} else if (fileInfo.sourceUrl) {
|
||||||
|
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
|
||||||
|
if (downloaded) {
|
||||||
|
data = downloaded.data
|
||||||
|
mime = downloaded.mime || mime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data) continue
|
||||||
|
|
||||||
|
// 优先使用内容检测出的 MIME 类型
|
||||||
|
const detectedMime = this.detectMimeType(data)
|
||||||
|
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
|
||||||
|
|
||||||
|
// 根据 MIME 类型确定文件扩展名
|
||||||
|
const ext = this.getExtensionFromMime(finalMime)
|
||||||
|
|
||||||
|
// 清理用户名作为文件名(移除非法字符,限制长度)
|
||||||
|
const sanitizedUsername = member.username
|
||||||
|
.replace(/[<>:"/\\|?*@]/g, '_')
|
||||||
|
.substring(0, 100)
|
||||||
|
|
||||||
|
const filename = `${sanitizedUsername}${ext}`
|
||||||
|
const avatarPath = path.join(avatarsDir, filename)
|
||||||
|
|
||||||
|
// 保存头像文件
|
||||||
|
await fs.promises.writeFile(avatarPath, data)
|
||||||
|
|
||||||
|
// 返回相对路径
|
||||||
|
result.set(member.username, `avatars/${filename}`)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExtensionFromMime(mime: string): string {
|
||||||
|
switch (mime) {
|
||||||
|
case 'image/png':
|
||||||
|
return '.png'
|
||||||
|
case 'image/gif':
|
||||||
|
return '.gif'
|
||||||
|
case 'image/webp':
|
||||||
|
return '.webp'
|
||||||
|
case 'image/bmp':
|
||||||
|
return '.bmp'
|
||||||
|
case 'image/jpeg':
|
||||||
|
default:
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private detectMimeType(buffer: Buffer): string | null {
|
private detectMimeType(buffer: Buffer): string | null {
|
||||||
if (buffer.length < 4) return null
|
if (buffer.length < 4) return null
|
||||||
|
|
||||||
@@ -2034,7 +1997,7 @@ class ExportService {
|
|||||||
? contact.contact.nickName
|
? contact.contact.nickName
|
||||||
: (senderInfo.displayName || senderWxid)
|
: (senderInfo.displayName || senderWxid)
|
||||||
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
|
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
|
||||||
const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || ''
|
const senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '')
|
||||||
|
|
||||||
// 使用用户偏好的显示名称
|
// 使用用户偏好的显示名称
|
||||||
const senderDisplayName = this.getPreferredDisplayName(
|
const senderDisplayName = this.getPreferredDisplayName(
|
||||||
@@ -2080,7 +2043,7 @@ class ExportService {
|
|||||||
? sessionContact.contact.remark
|
? sessionContact.contact.remark
|
||||||
: ''
|
: ''
|
||||||
const sessionGroupNickname = isGroup
|
const sessionGroupNickname = isGroup
|
||||||
? (groupNicknamesMap.get(sessionId.toLowerCase()) || '')
|
? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '')
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
// 使用用户偏好的显示名称
|
// 使用用户偏好的显示名称
|
||||||
@@ -2320,11 +2283,9 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 预加载群昵称 (仅群聊且完整列模式)
|
// 预加载群昵称 (仅群聊且完整列模式)
|
||||||
console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId)
|
|
||||||
const groupNicknamesMap = (isGroup && !useCompactColumns)
|
const groupNicknamesMap = (isGroup && !useCompactColumns)
|
||||||
? await this.getGroupNicknamesForRoom(sessionId)
|
? await this.getGroupNicknamesForRoom(sessionId)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
console.log('群昵称Map大小:', groupNicknamesMap.size)
|
|
||||||
|
|
||||||
|
|
||||||
// 填充数据
|
// 填充数据
|
||||||
@@ -2447,7 +2408,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 获取群昵称 (仅群聊且完整列模式)
|
// 获取群昵称 (仅群聊且完整列模式)
|
||||||
if (isGroup && !useCompactColumns && senderWxid) {
|
if (isGroup && !useCompactColumns && senderWxid) {
|
||||||
senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || ''
|
senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2466,11 +2427,11 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
))
|
))
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
@@ -2715,11 +2676,11 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
))
|
))
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
@@ -2892,7 +2853,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avatarMap = options.exportAvatars
|
const avatarMap = options.exportAvatars
|
||||||
? await this.exportAvatars(
|
? await this.exportAvatarsToFiles(
|
||||||
[
|
[
|
||||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||||
username,
|
username,
|
||||||
@@ -2900,7 +2861,8 @@ class ExportService {
|
|||||||
})),
|
})),
|
||||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||||
]
|
],
|
||||||
|
path.dirname(outputPath)
|
||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
@@ -2917,7 +2879,7 @@ class ExportService {
|
|||||||
: (sessionInfo.displayName || sessionId))
|
: (sessionInfo.displayName || sessionId))
|
||||||
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
||||||
const avatarHtml = avatarData
|
const avatarHtml = avatarData
|
||||||
? `<img src="${this.escapeAttribute(avatarData)}" alt="${this.escapeAttribute(senderName)}" />`
|
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
|
||||||
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
||||||
|
|
||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface GroupMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
@@ -93,99 +97,16 @@ class GroupAnalyticsService {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
*/
|
|
||||||
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
|
|
||||||
const nicknameMap = new Map<string, string>()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = buffer.toString('utf8')
|
|
||||||
const wxidPattern = /wxid_[a-z0-9_]+/gi
|
|
||||||
const wxids = raw.match(wxidPattern) || []
|
|
||||||
|
|
||||||
for (const wxid of wxids) {
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
|
|
||||||
if (wxidIndex === -1) continue
|
|
||||||
|
|
||||||
const afterWxid = raw.slice(wxidIndex + wxid.length)
|
|
||||||
let nickname = ''
|
|
||||||
let foundStart = false
|
|
||||||
|
|
||||||
for (let i = 0; i < afterWxid.length && i < 100; i++) {
|
|
||||||
const char = afterWxid[i]
|
|
||||||
const code = char.charCodeAt(0)
|
|
||||||
const isPrintable = (
|
|
||||||
(code >= 0x4E00 && code <= 0x9FFF) ||
|
|
||||||
(code >= 0x3000 && code <= 0x303F) ||
|
|
||||||
(code >= 0xFF00 && code <= 0xFFEF) ||
|
|
||||||
(code >= 0x20 && code <= 0x7E)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isPrintable && code !== 0x01 && code !== 0x18) {
|
|
||||||
foundStart = true
|
|
||||||
nickname += char
|
|
||||||
} else if (foundStart) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
|
|
||||||
if (nickname && nickname.length < 50) {
|
|
||||||
nicknameMap.set(wxidLower, nickname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse ext_buffer:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nicknameMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 contact.db 的 chat_room 表获取群成员的群昵称
|
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||||
try {
|
try {
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
|
const result = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
if (result.success && result.nicknames) {
|
||||||
|
return new Map(Object.entries(result.nicknames))
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
|
||||||
return new Map<string, string>()
|
|
||||||
}
|
}
|
||||||
|
return new Map<string, string>()
|
||||||
let extBuffer = result.rows[0].ext_buffer
|
|
||||||
|
|
||||||
if (typeof extBuffer === 'string') {
|
|
||||||
if (this.looksLikeHex(extBuffer)) {
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
|
||||||
} else if (this.looksLikeBase64(extBuffer)) {
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
|
||||||
} catch {
|
|
||||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
|
|
||||||
return new Map<string, string>()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
@@ -294,14 +215,55 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
const members = result.members as { username: string; avatarUrl?: string }[]
|
||||||
const usernames = members.map((m) => m.username)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
const displayNames = await wcdbService.getDisplayNames(usernames)
|
|
||||||
|
|
||||||
const data: GroupMember[] = members.map((m) => ({
|
const [displayNames, groupNicknames] = await Promise.all([
|
||||||
username: m.username,
|
wcdbService.getDisplayNames(usernames),
|
||||||
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
|
this.getGroupNicknamesForRoom(chatroomId)
|
||||||
avatarUrl: m.avatarUrl
|
])
|
||||||
}))
|
|
||||||
|
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||||
|
const concurrency = 6
|
||||||
|
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||||
|
const contactResult = await wcdbService.getContact(username)
|
||||||
|
if (contactResult.success && contactResult.contact) {
|
||||||
|
const contact = contactResult.contact as any
|
||||||
|
contactMap.set(username, {
|
||||||
|
remark: contact.remark || '',
|
||||||
|
nickName: contact.nickName || contact.nick_name || '',
|
||||||
|
alias: contact.alias || ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
const data: GroupMember[] = members.map((m) => {
|
||||||
|
const wxid = m.username || ''
|
||||||
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
|
const contact = contactMap.get(wxid)
|
||||||
|
const nickname = contact?.nickName || ''
|
||||||
|
const remark = contact?.remark || ''
|
||||||
|
const alias = contact?.alias || ''
|
||||||
|
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||||
|
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const groupNickname = this.normalizeGroupNickname(
|
||||||
|
rawGroupNickname,
|
||||||
|
normalizedWxid === myWxid ? myWxid : wxid,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: wxid,
|
||||||
|
displayName,
|
||||||
|
nickname,
|
||||||
|
alias,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
avatarUrl: m.avatarUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetGroupMemberCount: any = null
|
private wcdbGetGroupMemberCount: any = null
|
||||||
private wcdbGetGroupMemberCounts: any = null
|
private wcdbGetGroupMemberCounts: any = null
|
||||||
private wcdbGetGroupMembers: any = null
|
private wcdbGetGroupMembers: any = null
|
||||||
|
private wcdbGetGroupNicknames: any = null
|
||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
@@ -57,6 +58,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
|
private wcdbGetSnsAnnualStats: any = null
|
||||||
private wcdbVerifyUser: any = null
|
private wcdbVerifyUser: any = null
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
@@ -333,6 +335,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetGroupNicknames = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -369,6 +378,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetAnnualReportExtras = null
|
this.wcdbGetAnnualReportExtras = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_logs(char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetLogs = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
try {
|
try {
|
||||||
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
@@ -431,6 +447,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsTimeline = null
|
this.wcdbGetSnsTimeline = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsAnnualStats = null
|
||||||
|
}
|
||||||
|
|
||||||
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
try {
|
try {
|
||||||
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
||||||
@@ -1002,6 +1025,28 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetGroupNicknames) {
|
||||||
|
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取群昵称失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
|
||||||
|
const nicknames = JSON.parse(jsonStr)
|
||||||
|
return { success: true, nicknames }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1343,13 +1388,31 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
if (!this.lib) return { success: false, error: 'DLL 未加载' }
|
||||||
|
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetLogs(outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取日志失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析日志失败' }
|
||||||
|
return { success: true, logs: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
return { success: false, error: `执行查询失败: ${result}` }
|
return { success: false, error: `执行查询失败: ${result}` }
|
||||||
}
|
}
|
||||||
@@ -1502,4 +1565,29 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.wcdbGetSnsAnnualStats) {
|
||||||
|
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
|
||||||
|
return { success: true, data: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getSnsAnnualStats 异常:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +229,11 @@ export class WcdbService {
|
|||||||
return this.callWorker('getGroupMembers', { chatroomId })
|
return this.callWorker('getGroupMembers', { chatroomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取群成员群名片昵称
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getGroupNicknames', { chatroomId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息表列表
|
* 获取消息表列表
|
||||||
*/
|
*/
|
||||||
@@ -369,6 +374,20 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈年度统计
|
||||||
|
*/
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 DLL 内部日志
|
||||||
|
*/
|
||||||
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getLogs')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Windows Hello
|
* 验证 Windows Hello
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ if (parentPort) {
|
|||||||
case 'getGroupMembers':
|
case 'getGroupMembers':
|
||||||
result = await core.getGroupMembers(payload.chatroomId)
|
result = await core.getGroupMembers(payload.chatroomId)
|
||||||
break
|
break
|
||||||
|
case 'getGroupNicknames':
|
||||||
|
result = await core.getGroupNicknames(payload.chatroomId)
|
||||||
|
break
|
||||||
case 'getMessageTables':
|
case 'getMessageTables':
|
||||||
result = await core.getMessageTables(payload.sessionId)
|
result = await core.getMessageTables(payload.sessionId)
|
||||||
break
|
break
|
||||||
@@ -119,6 +122,12 @@ if (parentPort) {
|
|||||||
case 'getSnsTimeline':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
|
case 'getSnsAnnualStats':
|
||||||
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'getLogs':
|
||||||
|
result = await core.getLogs()
|
||||||
|
break
|
||||||
case 'verifyUser':
|
case 'verifyUser':
|
||||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
break
|
break
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.4",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.4",
|
"version": "1.5.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.4",
|
"version": "1.5.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
|||||||
Binary file not shown.
@@ -10,6 +10,8 @@ import AnalyticsPage from './pages/AnalyticsPage'
|
|||||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||||
import AnnualReportPage from './pages/AnnualReportPage'
|
import AnnualReportPage from './pages/AnnualReportPage'
|
||||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||||
|
import DualReportPage from './pages/DualReportPage'
|
||||||
|
import DualReportWindow from './pages/DualReportWindow'
|
||||||
import AgreementPage from './pages/AgreementPage'
|
import AgreementPage from './pages/AgreementPage'
|
||||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
@@ -398,6 +400,8 @@ function App() {
|
|||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<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="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<ExportPage />} />
|
||||||
|
|||||||
@@ -47,6 +47,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -292,4 +310,185 @@
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除好友弹窗
|
||||||
|
.exclude-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal {
|
||||||
|
width: 560px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.exclude-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-body {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-loading,
|
||||||
|
.exclude-error,
|
||||||
|
.exclude-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 24px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: rgba(7, 193, 96, 0.4);
|
||||||
|
background: rgba(7, 193, 96, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
|
interface ExcludeCandidate {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadingStatus, setLoadingStatus] = useState('')
|
const [loadingStatus, setLoadingStatus] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
|
||||||
|
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
|
||||||
|
const [excludeQuery, setExcludeQuery] = useState('')
|
||||||
|
const [excludeLoading, setExcludeLoading] = useState(false)
|
||||||
|
const [excludeError, setExcludeError] = useState<string | null>(null)
|
||||||
|
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||||
|
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const themeMode = useThemeStore((state) => state.themeMode)
|
const themeMode = useThemeStore((state) => state.themeMode)
|
||||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||||
|
|
||||||
|
const loadExcludedUsernames = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.getExcludedUsernames()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
|
||||||
|
} else {
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('加载排除名单失败', e)
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -65,14 +96,89 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
|
loadExcludedUsernames()
|
||||||
loadData(true)
|
loadData(true)
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadData])
|
}, [loadData, loadExcludedUsernames])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExcludedUsernames()
|
||||||
|
}, [loadExcludedUsernames])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
|
||||||
|
const loadExcludeCandidates = useCallback(async () => {
|
||||||
|
setExcludeLoading(true)
|
||||||
|
setExcludeError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.getExcludeCandidates()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setExcludeCandidates(result.data)
|
||||||
|
} else {
|
||||||
|
setExcludeError(result.error || '加载好友列表失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setExcludeError(String(e))
|
||||||
|
} finally {
|
||||||
|
setExcludeLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openExcludeDialog = async () => {
|
||||||
|
setExcludeQuery('')
|
||||||
|
setDraftExcluded(new Set(excludedUsernames))
|
||||||
|
setIsExcludeDialogOpen(true)
|
||||||
|
await loadExcludeCandidates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExcluded = (username: string) => {
|
||||||
|
const key = normalizeUsername(username)
|
||||||
|
setDraftExcluded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key)
|
||||||
|
} else {
|
||||||
|
next.add(key)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyExcluded = async () => {
|
||||||
|
const payload = Array.from(draftExcluded)
|
||||||
|
setIsExcludeDialogOpen(false)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || '更新排除名单失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
|
||||||
|
clearCache()
|
||||||
|
await window.electronAPI.cache.clearAnalytics()
|
||||||
|
await loadData(true)
|
||||||
|
} catch (e) {
|
||||||
|
alert(`更新排除名单失败:${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleExcludeCandidates = excludeCandidates
|
||||||
|
.filter((candidate) => {
|
||||||
|
const query = excludeQuery.trim().toLowerCase()
|
||||||
|
if (!query) return true
|
||||||
|
const wechatId = candidate.wechatId || ''
|
||||||
|
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aSelected = draftExcluded.has(normalizeUsername(a.username))
|
||||||
|
const bSelected = draftExcluded.has(normalizeUsername(b.username))
|
||||||
|
if (aSelected !== bSelected) return aSelected ? -1 : 1
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh')
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (timestamp: number | null) => {
|
const formatDate = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-'
|
if (!timestamp) return '-'
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
@@ -247,10 +353,16 @@ function AnalyticsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>私聊分析</h1>
|
<h1>私聊分析</h1>
|
||||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
<div className="header-actions">
|
||||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||||
{isLoading ? '刷新中...' : '刷新'}
|
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
{isLoading ? '刷新中...' : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||||
|
<UserMinus size={16} />
|
||||||
|
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-scroll">
|
<div className="page-scroll">
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
@@ -316,6 +428,84 @@ function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{isExcludeDialogOpen && (
|
||||||
|
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="exclude-modal-header">
|
||||||
|
<h3>选择不统计的好友</h3>
|
||||||
|
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索好友"
|
||||||
|
value={excludeQuery}
|
||||||
|
onChange={e => setExcludeQuery(e.target.value)}
|
||||||
|
disabled={excludeLoading}
|
||||||
|
/>
|
||||||
|
{excludeQuery && (
|
||||||
|
<button className="clear-search" onClick={() => setExcludeQuery('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-body">
|
||||||
|
{excludeLoading && (
|
||||||
|
<div className="exclude-loading">
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在加载好友列表...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && excludeError && (
|
||||||
|
<div className="exclude-error">{excludeError}</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && !excludeError && (
|
||||||
|
<div className="exclude-list">
|
||||||
|
{visibleExcludeCandidates.map((candidate) => {
|
||||||
|
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
|
||||||
|
const wechatId = candidate.wechatId?.trim() || candidate.username
|
||||||
|
return (
|
||||||
|
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleExcluded(candidate.username)}
|
||||||
|
/>
|
||||||
|
<div className="exclude-avatar">
|
||||||
|
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="exclude-info">
|
||||||
|
<span className="exclude-name">{candidate.displayName}</span>
|
||||||
|
<span className="exclude-username">{wechatId}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{visibleExcludeCandidates.length === 0 && (
|
||||||
|
<div className="exclude-empty">
|
||||||
|
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-footer">
|
||||||
|
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||||
|
<div className="exclude-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
@@ -25,6 +26,63 @@
|
|||||||
margin: 0 0 48px;
|
margin: 0 0 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.year-grid {
|
.year-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -34,6 +92,12 @@
|
|||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-section .year-grid {
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.year-card {
|
.year-card {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -104,6 +168,13 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||||
import './AnnualReportPage.scss'
|
import './AnnualReportPage.scss'
|
||||||
|
|
||||||
|
type YearOption = number | 'all'
|
||||||
|
|
||||||
function AnnualReportPage() {
|
function AnnualReportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||||
|
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
@@ -22,7 +25,8 @@ function AnnualReportPage() {
|
|||||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
setAvailableYears(result.data)
|
setAvailableYears(result.data)
|
||||||
setSelectedYear(result.data[0])
|
setSelectedYear((prev) => prev ?? result.data[0])
|
||||||
|
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setLoadError(result.error || '加载年度数据失败')
|
setLoadError(result.error || '加载年度数据失败')
|
||||||
}
|
}
|
||||||
@@ -35,10 +39,11 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateReport = async () => {
|
const handleGenerateReport = async () => {
|
||||||
if (!selectedYear) return
|
if (selectedYear === null) return
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
try {
|
try {
|
||||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||||
|
navigate(`/annual-report/view?year=${yearParam}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('生成报告失败:', e)
|
console.error('生成报告失败:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,6 +51,12 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGenerateDualReport = () => {
|
||||||
|
if (selectedPairYear === null) return
|
||||||
|
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||||
|
navigate(`/dual-report?year=${yearParam}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
@@ -67,42 +78,98 @@ function AnnualReportPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const yearOptions: YearOption[] = availableYears.length > 0
|
||||||
|
? ['all', ...availableYears]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const getYearLabel = (value: YearOption | null) => {
|
||||||
|
if (!value) return ''
|
||||||
|
return value === 'all' ? '全部时间' : `${value} 年`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Sparkles size={32} className="header-icon" />
|
<Sparkles size={32} className="header-icon" />
|
||||||
<h1 className="page-title">年度报告</h1>
|
<h1 className="page-title">年度报告</h1>
|
||||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
||||||
|
|
||||||
<div className="year-grid">
|
<div className="report-sections">
|
||||||
{availableYears.map(year => (
|
<section className="report-section">
|
||||||
<div
|
<div className="section-header">
|
||||||
key={year}
|
<div>
|
||||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
<h2 className="section-title">总年度报告</h2>
|
||||||
onClick={() => setSelectedYear(year)}
|
<p className="section-desc">包含所有会话与消息</p>
|
||||||
>
|
</div>
|
||||||
<span className="year-number">{year}</span>
|
|
||||||
<span className="year-label">年</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="year-grid">
|
||||||
className="generate-btn"
|
{yearOptions.map(option => (
|
||||||
onClick={handleGenerateReport}
|
<div
|
||||||
disabled={!selectedYear || isGenerating}
|
key={option}
|
||||||
>
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||||
{isGenerating ? (
|
onClick={() => setSelectedYear(option)}
|
||||||
<>
|
>
|
||||||
<Loader2 size={20} className="spin" />
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
<span>正在生成...</span>
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
</>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<>
|
</div>
|
||||||
<Sparkles size={20} />
|
|
||||||
<span>生成 {selectedYear} 年度报告</span>
|
<button
|
||||||
</>
|
className="generate-btn"
|
||||||
)}
|
onClick={handleGenerateReport}
|
||||||
</button>
|
disabled={!selectedYear || isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在生成...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles size={20} />
|
||||||
|
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="report-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">双人年度报告</h2>
|
||||||
|
<p className="section-desc">选择一位好友,只看你们的私聊</p>
|
||||||
|
</div>
|
||||||
|
<div className="section-badge">
|
||||||
|
<Users size={16} />
|
||||||
|
<span>私聊</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="year-grid">
|
||||||
|
{yearOptions.map(option => (
|
||||||
|
<div
|
||||||
|
key={`pair-${option}`}
|
||||||
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedPairYear(option)}
|
||||||
|
>
|
||||||
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="generate-btn secondary"
|
||||||
|
onClick={handleGenerateDualReport}
|
||||||
|
disabled={!selectedPairYear}
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span>选择好友并生成报告</span>
|
||||||
|
</button>
|
||||||
|
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1279,3 +1279,134 @@
|
|||||||
color: var(--ar-text-sub) !important;
|
color: var(--ar-text-sub) !important;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
// 曾经的好朋友 视觉效果
|
||||||
|
.lost-friend-visual {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
margin: 64px auto 48px;
|
||||||
|
position: relative;
|
||||||
|
max-width: 480px;
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.avatar-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sender {
|
||||||
|
animation: fadeInRight 1s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.receiver {
|
||||||
|
animation: fadeInLeft 1s ease-out backwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fading-line {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.line-path {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
var(--ar-primary) 0%,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.4) 50%,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.05) 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px 0;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.2) 0%,
|
||||||
|
transparent 100%);
|
||||||
|
filter: blur(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-particle {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: flowAcross 4s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-desc.fading {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
line-height: 1.8;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
animation: fadeIn 1.5s ease-out 0.5s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flowAcross {
|
||||||
|
0% {
|
||||||
|
left: -20%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
10% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
left: 120%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ interface AnnualReportData {
|
|||||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||||
topPhrases?: { phrase: string; count: number }[]
|
topPhrases?: { phrase: string; count: number }[]
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionInfo {
|
interface SectionInfo {
|
||||||
@@ -274,6 +288,8 @@ function AnnualReportWindow() {
|
|||||||
responseSpeed: useRef<HTMLElement>(null),
|
responseSpeed: useRef<HTMLElement>(null),
|
||||||
topPhrases: useRef<HTMLElement>(null),
|
topPhrases: useRef<HTMLElement>(null),
|
||||||
ranking: useRef<HTMLElement>(null),
|
ranking: useRef<HTMLElement>(null),
|
||||||
|
sns: useRef<HTMLElement>(null),
|
||||||
|
lostFriend: useRef<HTMLElement>(null),
|
||||||
ending: useRef<HTMLElement>(null),
|
ending: useRef<HTMLElement>(null),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +298,8 @@ function AnnualReportWindow() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
const yearParam = params.get('year')
|
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)
|
generateReport(year)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -337,6 +354,11 @@ function AnnualReportWindow() {
|
|||||||
return `${Math.round(seconds / 3600)}小时`
|
return `${Math.round(seconds / 3600)}小时`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||||
|
if (value === 0) return '历史以来'
|
||||||
|
return withSuffix ? `${value}年` : `${value}`
|
||||||
|
}
|
||||||
|
|
||||||
// 获取可用的板块列表
|
// 获取可用的板块列表
|
||||||
const getAvailableSections = (): SectionInfo[] => {
|
const getAvailableSections = (): SectionInfo[] => {
|
||||||
if (!reportData) return []
|
if (!reportData) return []
|
||||||
@@ -367,10 +389,16 @@ function AnnualReportWindow() {
|
|||||||
if (reportData.responseSpeed) {
|
if (reportData.responseSpeed) {
|
||||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||||
}
|
}
|
||||||
|
if (reportData.lostFriend) {
|
||||||
|
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
|
||||||
|
}
|
||||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||||
}
|
}
|
||||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||||
|
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
|
||||||
|
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
|
||||||
|
}
|
||||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
@@ -595,7 +623,8 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||||
const link = document.createElement('a')
|
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
|
link.href = dataUrl
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
@@ -658,11 +687,12 @@ function AnnualReportWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExportProgress('正在写入文件...')
|
setExportProgress('正在写入文件...')
|
||||||
|
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||||
baseDir: dirResult.filePaths[0],
|
baseDir: dirResult.filePaths[0],
|
||||||
folderName: `${reportData?.year}年度报告_分模块`,
|
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||||
images: exportedImages.map((img) => ({
|
images: exportedImages.map((img) => ({
|
||||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||||
dataUrl: img.data
|
dataUrl: img.data
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -733,10 +763,14 @@ function AnnualReportWindow() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
|
||||||
const topFriend = coreFriends[0]
|
const topFriend = coreFriends[0]
|
||||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||||
const socialStoryName = topFriend?.displayName || '好友'
|
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 (
|
return (
|
||||||
<div className="annual-report-window">
|
<div className="annual-report-window">
|
||||||
@@ -827,7 +861,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<section className="section" ref={sectionRefs.cover}>
|
<section className="section" ref={sectionRefs.cover}>
|
||||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
<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" />
|
<hr className="divider" />
|
||||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -869,7 +903,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 月度好友 */}
|
{/* 月度好友 */}
|
||||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||||
<div className="label-text">月度好友</div>
|
<div className="label-text">月度好友</div>
|
||||||
<h2 className="hero-title">{year}年月度好友</h2>
|
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||||
<div className="monthly-orbit">
|
<div className="monthly-orbit">
|
||||||
{monthlyTopFriends.map((m, i) => (
|
{monthlyTopFriends.map((m, i) => (
|
||||||
@@ -1012,11 +1046,46 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 曾经的好朋友 */}
|
||||||
|
{lostFriend && (
|
||||||
|
<section className="section" ref={sectionRefs.lostFriend}>
|
||||||
|
<div className="label-text">曾经的好朋友</div>
|
||||||
|
<h2 className="hero-title">{lostFriend.displayName}</h2>
|
||||||
|
<div className="big-stat">
|
||||||
|
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
|
||||||
|
<span className="stat-unit">条消息</span>
|
||||||
|
</div>
|
||||||
|
<p className="hero-desc">
|
||||||
|
在 <span className="hl">{lostFriend.periodDesc}</span>
|
||||||
|
<br />你们曾有聊不完的话题
|
||||||
|
</p>
|
||||||
|
<div className="lost-friend-visual">
|
||||||
|
<div className="avatar-group sender">
|
||||||
|
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
|
||||||
|
<span className="avatar-label">TA</span>
|
||||||
|
</div>
|
||||||
|
<div className="fading-line">
|
||||||
|
<div className="line-path" />
|
||||||
|
<div className="line-glow" />
|
||||||
|
<div className="flow-particle" />
|
||||||
|
</div>
|
||||||
|
<div className="avatar-group receiver">
|
||||||
|
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||||
|
<span className="avatar-label">我</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="hero-desc fading">
|
||||||
|
人类发明后悔
|
||||||
|
<br />来证明拥有的珍贵
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 年度常用语 - 词云 */}
|
{/* 年度常用语 - 词云 */}
|
||||||
{topPhrases && topPhrases.length > 0 && (
|
{topPhrases && topPhrases.length > 0 && (
|
||||||
<section className="section" ref={sectionRefs.topPhrases}>
|
<section className="section" ref={sectionRefs.topPhrases}>
|
||||||
<div className="label-text">年度常用语</div>
|
<div className="label-text">年度常用语</div>
|
||||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
这一年,你说得最多的是:
|
这一年,你说得最多的是:
|
||||||
<br />
|
<br />
|
||||||
@@ -1029,6 +1098,57 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 朋友圈 */}
|
||||||
|
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
|
||||||
|
<section className="section" ref={sectionRefs.sns}>
|
||||||
|
<div className="label-text">朋友圈</div>
|
||||||
|
<h2 className="hero-title">记录生活时刻</h2>
|
||||||
|
<p className="hero-desc">
|
||||||
|
这一年,你发布了
|
||||||
|
</p>
|
||||||
|
<div className="big-stat">
|
||||||
|
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
|
||||||
|
<span className="stat-unit">条朋友圈</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
|
||||||
|
{reportData.snsStats.topLikers.length > 0 && (
|
||||||
|
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||||
|
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>更关心你的Ta</h3>
|
||||||
|
<div className="mini-ranking">
|
||||||
|
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
|
||||||
|
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||||
|
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reportData.snsStats.topLiked.length > 0 && (
|
||||||
|
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||||
|
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>你最关心的Ta</h3>
|
||||||
|
<div className="mini-ranking">
|
||||||
|
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
|
||||||
|
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||||
|
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 好友排行 */}
|
{/* 好友排行 */}
|
||||||
<section className="section" ref={sectionRefs.ranking}>
|
<section className="section" ref={sectionRefs.ranking}>
|
||||||
<div className="label-text">好友排行</div>
|
<div className="label-text">好友排行</div>
|
||||||
@@ -1085,7 +1205,7 @@ function AnnualReportWindow() {
|
|||||||
<br />愿新的一年,
|
<br />愿新的一年,
|
||||||
<br />所有期待,皆有回声。
|
<br />所有期待,皆有回声。
|
||||||
</p>
|
</p>
|
||||||
<div className="ending-year">{year}</div>
|
<div className="ending-year">{yearTitleShort}</div>
|
||||||
<div className="ending-brand">WEFLOW</div>
|
<div className="ending-brand">WEFLOW</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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
|
||||||
253
src/pages/DualReportWindow.scss
Normal file
253
src/pages/DualReportWindow.scss
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
color: var(--ar-text-main);
|
||||||
|
|
||||||
|
.amp {
|
||||||
|
color: var(--ar-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-info-card {
|
||||||
|
background: var(--ar-card-bg);
|
||||||
|
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
&.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ar-text-main);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-message-list {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-message {
|
||||||
|
background: var(--ar-card-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
|
||||||
|
&.received {
|
||||||
|
background: var(--ar-card-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ar-text-main);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(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: 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(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: 18px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--ar-card-bg);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-placeholder {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-cloud-empty {
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
472
src/pages/DualReportWindow.tsx
Normal file
472
src/pages/DualReportWindow.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
|
import './AnnualReportWindow.scss'
|
||||||
|
import './DualReportWindow.scss'
|
||||||
|
|
||||||
|
interface DualReportMessage {
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DualReportData {
|
||||||
|
year: number
|
||||||
|
selfName: string
|
||||||
|
friendUsername: string
|
||||||
|
friendName: string
|
||||||
|
firstChat: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
senderUsername?: string
|
||||||
|
} | null
|
||||||
|
firstChatMessages?: DualReportMessage[]
|
||||||
|
yearFirstChat?: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
friendName: string
|
||||||
|
firstThreeMessages: DualReportMessage[]
|
||||||
|
} | null
|
||||||
|
stats: {
|
||||||
|
totalMessages: number
|
||||||
|
totalWords: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
emojiCount: number
|
||||||
|
myTopEmojiMd5?: string
|
||||||
|
friendTopEmojiMd5?: string
|
||||||
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
}
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
|
||||||
|
if (!words || words.length === 0) {
|
||||||
|
return <div className="word-cloud-empty">暂无高频语句</div>
|
||||||
|
}
|
||||||
|
const sortedWords = [...words].sort((a, b) => b.count - a.count)
|
||||||
|
const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1
|
||||||
|
const topWords = sortedWords.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.stats
|
||||||
|
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="annual-report-window loading">
|
||||||
|
<div className="loading-ring">
|
||||||
|
<svg viewBox="0 0 100 100">
|
||||||
|
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
||||||
|
<circle
|
||||||
|
className="ring-progress"
|
||||||
|
cx="50" cy="50" r="42"
|
||||||
|
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="ring-text">{loadingProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="loading-stage">{loadingStage}</p>
|
||||||
|
<p className="loading-hint">进行中</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="annual-report-window error">
|
||||||
|
<p>生成报告失败: {error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reportData) {
|
||||||
|
return (
|
||||||
|
<div className="annual-report-window error">
|
||||||
|
<p>暂无数据</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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">
|
||||||
|
<div className="drag-region" />
|
||||||
|
|
||||||
|
<div className="bg-decoration">
|
||||||
|
<div className="deco-circle c1" />
|
||||||
|
<div className="deco-circle c2" />
|
||||||
|
<div className="deco-circle c3" />
|
||||||
|
<div className="deco-circle c4" />
|
||||||
|
<div className="deco-circle c5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-scroll-view">
|
||||||
|
<div className="report-container">
|
||||||
|
<section className="section">
|
||||||
|
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
||||||
|
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
||||||
|
<hr className="divider" />
|
||||||
|
<div className="dual-names">
|
||||||
|
<span>{reportData.selfName}</span>
|
||||||
|
<span className="amp">&</span>
|
||||||
|
<span>{reportData.friendName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<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">{formatFullDate(firstChat.createTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dual-info-card">
|
||||||
|
<div className="info-label">距今天数</div>
|
||||||
|
<div className="info-value">{daysSince} 天</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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{yearFirstChat ? (
|
||||||
|
<section className="section">
|
||||||
|
<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">{formatFullDate(yearFirstChat.createTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dual-info-card">
|
||||||
|
<div className="info-label">发起者</div>
|
||||||
|
<div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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} · {formatFullDate(msg.createTime)}
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{formatMessageContent(msg.content)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="label-text">常用语</div>
|
||||||
|
<h2 className="hero-title">{yearTitle}常用语</h2>
|
||||||
|
<WordCloud words={reportData.topPhrases} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="label-text">年度统计</div>
|
||||||
|
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
||||||
|
<div className="dual-stat-grid">
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="label-text">尾声</div>
|
||||||
|
<h2 className="hero-title">谢谢你一直在</h2>
|
||||||
|
<p className="hero-desc">愿我们继续把故事写下去</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DualReportWindow
|
||||||
@@ -16,6 +16,10 @@ interface GroupMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupMessageRank {
|
interface GroupMessageRank {
|
||||||
@@ -298,6 +302,10 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
const renderMemberModal = () => {
|
const renderMemberModal = () => {
|
||||||
if (!selectedMember) return null
|
if (!selectedMember) return null
|
||||||
|
const nickname = (selectedMember.nickname || '').trim()
|
||||||
|
const alias = (selectedMember.alias || '').trim()
|
||||||
|
const remark = (selectedMember.remark || '').trim()
|
||||||
|
const groupNickname = (selectedMember.groupNickname || '').trim()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||||
@@ -320,11 +328,40 @@ function GroupAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<span className="detail-label">昵称</span>
|
<span className="detail-label">昵称</span>
|
||||||
<span className="detail-value">{selectedMember.displayName}</span>
|
<span className="detail-value">{nickname || '未设置'}</span>
|
||||||
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}>
|
{nickname && (
|
||||||
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />}
|
<button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}>
|
||||||
</button>
|
{copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{alias && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">微信号</span>
|
||||||
|
<span className="detail-value">{alias}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}>
|
||||||
|
{copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupNickname && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">群昵称</span>
|
||||||
|
<span className="detail-value">{groupNickname}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}>
|
||||||
|
{copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{remark && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">备注</span>
|
||||||
|
<span className="detail-value">{remark}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}>
|
||||||
|
{copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -547,10 +547,41 @@
|
|||||||
.sns-content-wrapper {
|
.sns-content-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sns-notice-banner {
|
||||||
|
margin: 16px 24px 0 24px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes banner-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sns-content {
|
.sns-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react'
|
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import { ImagePreview } from '../components/ImagePreview'
|
import { ImagePreview } from '../components/ImagePreview'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
@@ -412,6 +412,10 @@ export default function SnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sns-content-wrapper">
|
<div className="sns-content-wrapper">
|
||||||
|
<div className="sns-notice-banner">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>由于技术限制,当前无法解密显示部分图片与视频等加密资源文件</span>
|
||||||
|
</div>
|
||||||
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||||
<div className="posts-list">
|
<div className="posts-list">
|
||||||
{loadingNewer && (
|
{loadingNewer && (
|
||||||
|
|||||||
75
src/types/electron.d.ts
vendored
75
src/types/electron.d.ts
vendored
@@ -175,6 +175,26 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getExcludedUsernames: () => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
setExcludedUsernames: (usernames: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getExcludeCandidates: () => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Array<{
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
|
}>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
cache: {
|
cache: {
|
||||||
@@ -199,6 +219,10 @@ export interface ElectronAPI {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -317,6 +341,57 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
dualReport: {
|
||||||
|
generateReport: (payload: { friendUsername: string; year: number }) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
year: number
|
||||||
|
selfName: string
|
||||||
|
friendUsername: string
|
||||||
|
friendName: string
|
||||||
|
firstChat: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
senderUsername?: string
|
||||||
|
} | null
|
||||||
|
firstChatMessages?: Array<{
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
}>
|
||||||
|
yearFirstChat?: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
friendName: string
|
||||||
|
firstThreeMessages: Array<{
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
}>
|
||||||
|
} | null
|
||||||
|
stats: {
|
||||||
|
totalMessages: number
|
||||||
|
totalWords: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
emojiCount: number
|
||||||
|
myTopEmojiMd5?: string
|
||||||
|
friendTopEmojiMd5?: string
|
||||||
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
}
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
|
}
|
||||||
export: {
|
export: {
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -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',
|
entry: 'electron/imageSearchWorker.ts',
|
||||||
vite: {
|
vite: {
|
||||||
|
|||||||
Reference in New Issue
Block a user