Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha
2026-03-05 14:03:06 +08:00
committed by GitHub
51 changed files with 21690 additions and 3118 deletions

2
.gitignore vendored
View File

@@ -57,8 +57,8 @@ Thumbs.db
wcdb/
xkey/
server/
*info
概述.md
chatlab-format.md
*.bak
AGENTS.md

View File

@@ -3,7 +3,7 @@ import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads'
import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
import { existsSync } from 'fs'
import { ConfigService } from './services/config'
import { dbPathService } from './services/dbPathService'
@@ -21,6 +21,8 @@ import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
@@ -84,6 +86,7 @@ let agreementWindow: BrowserWindow | null = null
let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口
let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>()
const keyService = new KeyService()
let mainWindowReady = false
@@ -94,6 +97,98 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
interface AnnualReportYearsProgressPayload {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: AnnualReportYearsLoadStrategy
phase?: AnnualReportYearsLoadPhase
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AnnualReportYearsTaskState {
cacheKey: string
canceled: boolean
done: boolean
snapshot: AnnualReportYearsProgressPayload
updatedAt: number
}
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000
const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => {
const years = Array.isArray(snapshot.years) ? [...snapshot.years] : []
return { ...snapshot, years }
}
const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => {
return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}`
}
const pruneAnnualReportYearsSnapshotCache = (): void => {
const now = Date.now()
for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) {
if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) {
annualReportYearsSnapshotCache.delete(cacheKey)
}
}
}
const persistAnnualReportYearsSnapshot = (
cacheKey: string,
taskId: string,
snapshot: AnnualReportYearsProgressPayload
): void => {
annualReportYearsSnapshotCache.set(cacheKey, {
taskId,
snapshot: normalizeAnnualReportYearsSnapshot(snapshot),
updatedAt: Date.now()
})
pruneAnnualReportYearsSnapshotCache()
}
const getAnnualReportYearsSnapshot = (
cacheKey: string
): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => {
pruneAnnualReportYearsSnapshotCache()
const entry = annualReportYearsSnapshotCache.get(cacheKey)
if (!entry) return null
return {
taskId: entry.taskId,
snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot)
}
}
const broadcastAnnualReportYearsProgress = (
taskId: string,
payload: AnnualReportYearsProgressPayload
): void => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue
win.webContents.send('annualReport:availableYearsProgress', {
taskId,
...payload
})
}
}
const isYearsLoadCanceled = (taskId: string): boolean => {
const task = annualReportYearsLoadTasks.get(taskId)
return task?.canceled === true
}
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
@@ -590,6 +685,87 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
return win
}
/**
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
*/
function createSessionChatWindow(sessionId: string) {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null
const existing = sessionChatWindows.get(normalizedSessionId)
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) {
existing.restore()
}
existing.focus()
return existing
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const isDark = nativeTheme.shouldUseDarkColors
const win = new BrowserWindow({
width: 600,
height: 820,
minWidth: 420,
minHeight: 560,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
height: 40
},
show: false,
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
autoHideMenuBar: true
})
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${sessionParam}`
})
}
win.once('ready-to-show', () => {
win.show()
win.focus()
})
win.on('closed', () => {
const tracked = sessionChatWindows.get(normalizedSessionId)
if (tracked === win) {
sessionChatWindows.delete(normalizedSessionId)
}
})
sessionChatWindows.set(normalizedSessionId, win)
return win
}
function showMainWindow() {
shouldShowMain = true
if (mainWindowReady) {
@@ -597,6 +773,65 @@ function showMainWindow() {
}
}
const normalizeAccountId = (value: string): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
const buildAccountNameMatcher = (wxidCandidates: string[]) => {
const loweredCandidates = wxidCandidates
.map((item) => String(item || '').trim().toLowerCase())
.filter(Boolean)
return (name: string): boolean => {
const loweredName = String(name || '').trim().toLowerCase()
if (!loweredName) return false
return loweredCandidates.some((candidate) => (
loweredName === candidate ||
loweredName.startsWith(`${candidate}_`) ||
loweredName.includes(candidate)
))
}
}
const removePathIfExists = async (
targetPath: string,
removedPaths: string[],
warnings: string[]
): Promise<void> => {
if (!targetPath || !existsSync(targetPath)) return
try {
await rm(targetPath, { recursive: true, force: true })
removedPaths.push(targetPath)
} catch (error) {
warnings.push(`${targetPath}: ${String(error)}`)
}
}
const removeMatchedEntriesInDir = async (
rootDir: string,
shouldRemove: (name: string) => boolean,
removedPaths: string[],
warnings: string[]
): Promise<void> => {
if (!rootDir || !existsSync(rootDir)) return
try {
const entries = await readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (!shouldRemove(entry.name)) continue
const targetPath = join(rootDir, entry.name)
await removePathIfExists(targetPath, removedPaths, warnings)
}
} catch (error) {
warnings.push(`${rootDir}: ${String(error)}`)
}
}
// 注册 IPC 处理器
function registerIpcHandlers() {
registerNotificationHandlers()
@@ -665,6 +900,39 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
return exportCardDiagnosticsService.snapshot(options?.limit)
})
ipcMain.handle('diagnostics:clearExportCardLogs', async () => {
exportCardDiagnosticsService.clear()
return { success: true }
})
ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: {
filePath?: string
frontendLogs?: unknown[]
}) => {
const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : ''
if (!filePath) {
return { success: false, error: '导出路径不能为空' }
}
return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || [])
})
// 数据收集服务
ipcMain.handle('cloud:init', async () => {
await cloudControlService.init()
})
ipcMain.handle('cloud:recordPage', (_, pageName: string) => {
cloudControlService.recordPage(pageName)
})
ipcMain.handle('cloud:getLogs', async () => {
return cloudControlService.getLogs()
})
ipcMain.handle('app:checkForUpdates', async () => {
if (!AUTO_UPDATE_ENABLED) {
return { hasUpdate: false }
@@ -802,6 +1070,12 @@ function registerIpcHandlers() {
return true
})
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
const win = createSessionChatWindow(sessionId)
return Boolean(win)
})
// 根据视频尺寸调整窗口大小
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
const win = BrowserWindow.fromWebContents(event.sender)
@@ -912,8 +1186,27 @@ function registerIpcHandlers() {
return chatService.getSessions()
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
return chatService.enrichSessionsContactInfo(usernames)
ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
return chatService.getSessionStatuses(usernames)
})
ipcMain.handle('chat:getExportTabCounts', async () => {
return chatService.getExportTabCounts()
})
ipcMain.handle('chat:getContactTypeCounts', async () => {
return chatService.getContactTypeCounts()
})
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
return chatService.getSessionMessageCounts(sessionIds)
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
skipDisplayName?: boolean
onlyMissingAvatar?: boolean
}) => {
return chatService.enrichSessionsContactInfo(usernames, options)
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
@@ -970,10 +1263,161 @@ function registerIpcHandlers() {
return true
})
ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => {
const cfg = configService
if (!cfg) return { success: false, error: '配置服务未初始化' }
const clearCache = options?.clearCache === true
const clearExports = options?.clearExports === true
if (!clearCache && !clearExports) {
return { success: false, error: '请至少选择一项清理范围' }
}
const rawWxid = String(cfg.get('myWxid') || '').trim()
if (!rawWxid) {
return { success: false, error: '当前账号未登录或未识别,无法清理' }
}
const normalizedWxid = normalizeAccountId(rawWxid)
const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean)))
const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates)
const removedPaths: string[] = []
const warnings: string[] = []
try {
wcdbService.close()
chatService.close()
} catch (error) {
warnings.push(`关闭数据库连接失败: ${String(error)}`)
}
if (clearCache) {
const [analyticsResult, imageResult] = await Promise.all([
analyticsService.clearCache(),
imageDecryptService.clearCache()
])
const chatResult = chatService.clearCaches()
const cleanupResults = [analyticsResult, imageResult, chatResult]
for (const result of cleanupResults) {
if (!result.success && result.error) warnings.push(result.error)
}
const configuredCachePath = String(cfg.get('cachePath') || '').trim()
const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
const userDataCacheDir = join(app.getPath('userData'), 'cache')
const cacheRootCandidates = [
configuredCachePath,
join(documentsWeFlowDir, 'Images'),
join(documentsWeFlowDir, 'Voices'),
join(documentsWeFlowDir, 'Emojis'),
userDataCacheDir
].filter(Boolean)
for (const wxid of wxidCandidates) {
if (configuredCachePath) {
await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings)
await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings)
await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings)
await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings)
}
await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings)
await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings)
await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings)
await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings)
}
for (const cacheRoot of cacheRootCandidates) {
await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings)
}
}
if (clearExports) {
const configuredExportPath = String(cfg.get('exportPath') || '').trim()
const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
const exportRootCandidates = [
configuredExportPath,
join(documentsWeFlowDir, 'exports'),
join(documentsWeFlowDir, 'Exports')
].filter(Boolean)
for (const exportRoot of exportRootCandidates) {
await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings)
}
const resetConfigKeys = [
'exportSessionRecordMap',
'exportLastSessionRunMap',
'exportLastContentRunMap',
'exportSessionMessageCountCacheMap',
'exportSessionContentMetricCacheMap',
'exportSnsStatsCacheMap',
'snsPageCacheMap',
'contactsListCacheMap',
'contactsAvatarCacheMap',
'lastSession'
]
for (const key of resetConfigKeys) {
const defaultValue = key === 'lastSession' ? '' : {}
cfg.set(key as any, defaultValue as any)
}
}
if (clearCache) {
try {
const wxidConfigsRaw = cfg.get('wxidConfigs') as Record<string, any> | undefined
if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') {
const nextConfigs: Record<string, any> = { ...wxidConfigsRaw }
for (const key of Object.keys(nextConfigs)) {
if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) {
delete nextConfigs[key]
}
}
cfg.set('wxidConfigs' as any, nextConfigs as any)
}
cfg.set('myWxid' as any, '')
cfg.set('decryptKey' as any, '')
cfg.set('imageXorKey' as any, 0)
cfg.set('imageAesKey' as any, '')
cfg.set('dbPath' as any, '')
cfg.set('lastOpenedDb' as any, '')
cfg.set('onboardingDone' as any, false)
cfg.set('lastSession' as any, '')
} catch (error) {
warnings.push(`清理账号配置失败: ${String(error)}`)
}
}
return {
success: true,
removedPaths,
warning: warnings.length > 0 ? warnings.join('; ') : undefined
}
})
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
return chatService.getSessionDetail(sessionId)
})
ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => {
return chatService.getSessionDetailFast(sessionId)
})
ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => {
return chatService.getSessionDetailExtra(sessionId)
})
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
}) => {
return chatService.getExportSessionStats(sessionIds, options)
})
ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => {
return chatService.getGroupMyMessageCountHint(chatroomId)
})
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
return chatService.getImageData(sessionId, msgId)
})
@@ -990,6 +1434,9 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
return chatService.getMessageDates(sessionId)
})
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
return chatService.getMessageDateCounts(sessionId)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId)
})
@@ -1016,6 +1463,14 @@ function registerIpcHandlers() {
return snsService.getSnsUsernames()
})
ipcMain.handle('sns:getExportStats', async () => {
return snsService.getExportStats()
})
ipcMain.handle('sns:getExportStatsFast', async () => {
return snsService.getExportStatsFast()
})
ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url)
})
@@ -1063,11 +1518,17 @@ function registerIpcHandlers() {
})
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
return snsService.exportTimeline(options, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress)
const exportOptions = { ...(options || {}) }
delete exportOptions.taskId
return snsService.exportTimeline(
exportOptions,
(progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress)
}
}
})
)
})
ipcMain.handle('sns:selectExportDir', async () => {
@@ -1196,6 +1657,7 @@ function registerIpcHandlers() {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
})
@@ -1285,6 +1747,16 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMembers(chatroomId)
})
ipcMain.handle(
'groupAnalytics:getGroupMembersPanelData',
async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => {
const normalizedOptions = typeof options === 'boolean'
? { forceRefresh: options }
: options
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions)
}
)
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
})
@@ -1365,6 +1837,194 @@ function registerIpcHandlers() {
})
})
ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid)
const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey)
if (runningTaskId) {
const runningTask = annualReportYearsLoadTasks.get(runningTaskId)
if (runningTask && !runningTask.done) {
return {
success: true,
taskId: runningTaskId,
reused: true,
snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot)
}
}
annualReportYearsTaskByCacheKey.delete(cacheKey)
}
const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey)
if (cachedSnapshot && cachedSnapshot.snapshot.done) {
return {
success: true,
taskId: cachedSnapshot.taskId,
reused: true,
snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot)
}
}
const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done
? {
...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot),
done: false,
canceled: false,
error: undefined
}
: {
years: [],
done: false,
strategy: 'native',
phase: 'native',
statusText: '准备使用原生快速模式加载年份...',
nativeElapsedMs: 0,
scanElapsedMs: 0,
totalElapsedMs: 0,
switched: false,
nativeTimedOut: false
}
const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => {
const task = annualReportYearsLoadTasks.get(taskId)
if (!task) return null
const hasPayloadYears = Array.isArray(payload.years)
const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0))
? [...(payload.years || [])]
: Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : []
const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({
...task.snapshot,
...payload,
years: nextYears
})
task.snapshot = nextSnapshot
task.done = nextSnapshot.done === true
task.updatedAt = Date.now()
annualReportYearsLoadTasks.set(taskId, task)
persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot)
return nextSnapshot
}
annualReportYearsLoadTasks.set(taskId, {
cacheKey,
canceled: false,
done: false,
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot),
updatedAt: Date.now()
})
annualReportYearsTaskByCacheKey.set(cacheKey, taskId)
persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot)
void (async () => {
try {
const result = await annualReportService.getAvailableYears({
dbPath,
decryptKey,
wxid,
nativeTimeoutMs: 5000,
onProgress: (progress) => {
if (isYearsLoadCanceled(taskId)) return
const snapshot = updateTaskSnapshot({
...progress,
done: false
})
if (!snapshot) return
broadcastAnnualReportYearsProgress(taskId, snapshot)
},
shouldCancel: () => isYearsLoadCanceled(taskId)
})
const canceled = isYearsLoadCanceled(taskId)
if (canceled) {
const snapshot = updateTaskSnapshot({
done: true,
canceled: true,
phase: 'done',
statusText: '已取消年份加载'
})
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
}
return
}
const completionPayload: AnnualReportYearsProgressPayload = result.success
? {
years: result.data || [],
done: true,
strategy: result.meta?.strategy,
phase: 'done',
statusText: result.meta?.statusText || '年份数据加载完成',
nativeElapsedMs: result.meta?.nativeElapsedMs,
scanElapsedMs: result.meta?.scanElapsedMs,
totalElapsedMs: result.meta?.totalElapsedMs,
switched: result.meta?.switched,
nativeTimedOut: result.meta?.nativeTimedOut
}
: {
years: result.data || [],
done: true,
error: result.error || '加载年度数据失败',
strategy: result.meta?.strategy,
phase: 'done',
statusText: result.meta?.statusText || '年份数据加载失败',
nativeElapsedMs: result.meta?.nativeElapsedMs,
scanElapsedMs: result.meta?.scanElapsedMs,
totalElapsedMs: result.meta?.totalElapsedMs,
switched: result.meta?.switched,
nativeTimedOut: result.meta?.nativeTimedOut
}
const snapshot = updateTaskSnapshot(completionPayload)
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
}
} catch (e) {
const snapshot = updateTaskSnapshot({
done: true,
error: String(e),
phase: 'done',
statusText: '年份数据加载失败',
strategy: 'hybrid'
})
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
}
} finally {
const task = annualReportYearsLoadTasks.get(taskId)
if (task) {
annualReportYearsTaskByCacheKey.delete(task.cacheKey)
}
annualReportYearsLoadTasks.delete(taskId)
}
})()
return {
success: true,
taskId,
reused: false,
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot)
}
})
ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => {
const key = String(taskId || '').trim()
if (!key) return { success: false, error: '任务ID不能为空' }
const task = annualReportYearsLoadTasks.get(key)
if (!task) return { success: true }
task.canceled = true
annualReportYearsLoadTasks.set(key, task)
return { success: true }
})
ipcMain.handle('annualReport:generateReport', async (_, year: number) => {
const cfg = configService || new ConfigService()
configService = cfg
@@ -1528,7 +2188,7 @@ function registerIpcHandlers() {
// 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(60_000, (message, level) => {
return keyService.autoGetDbKey(180_000, (message, level) => {
event.sender.send('key:dbKeyStatus', { message, level })
})
})

View File

@@ -73,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
diagnostics: {
getExportCardLogs: (options?: { limit?: number }) =>
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
clearExportCardLogs: () =>
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
},
// 窗口控制
window: {
minimize: () => ipcRenderer.send('window:minimize'),
@@ -89,7 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
},
// 数据库路径
@@ -130,8 +141,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
@@ -149,14 +166,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
@@ -227,6 +255,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMembersPanelData: (
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
@@ -238,9 +270,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 年度报告
annualReport: {
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload),
onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => {
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
},
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:progress')
@@ -265,7 +317,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
@@ -287,6 +339,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
@@ -303,6 +357,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
},
// 数据收集
cloud: {
init: () => ipcRenderer.invoke('cloud:init'),
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
},
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),

View File

@@ -85,7 +85,34 @@ export interface AnnualReportData {
} | null
}
export interface AvailableYearsLoadProgress {
years: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AvailableYearsLoadMeta {
strategy: 'cache' | 'native' | 'hybrid'
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched: boolean
nativeTimedOut: boolean
statusText: string
}
class AnnualReportService {
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
private readonly availableYearsScanConcurrency = 4
private readonly availableYearsColumnCache = new Map<string, string>()
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
constructor() {
}
@@ -181,6 +208,234 @@ class AnnualReportService {
}
}
private quoteSqlIdentifier(identifier: string): string {
return `"${String(identifier || '').replace(/"/g, '""')}"`
}
private toUnixTimestamp(value: any): number {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return 0
// 兼容毫秒级时间戳
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
return seconds > 0 ? seconds : 0
}
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
let changed = false
const currentYear = new Date().getFullYear()
const minTs = firstTs > 0 ? firstTs : lastTs
const maxTs = lastTs > 0 ? lastTs : firstTs
if (minTs <= 0 || maxTs <= 0) return changed
const minYear = new Date(minTs * 1000).getFullYear()
const maxYear = new Date(maxTs * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= currentYear && !years.has(y)) {
years.add(y)
changed = true
}
}
return changed
}
private normalizeAvailableYears(years: Iterable<number>): number[] {
return Array.from(new Set(Array.from(years)))
.filter((y) => Number.isFinite(y))
.map((y) => Math.floor(y))
.sort((a, b) => b - a)
}
private async forEachWithConcurrency<T>(
items: T[],
concurrency: number,
handler: (item: T, index: number) => Promise<void>,
shouldStop?: () => boolean
): Promise<void> {
if (!items.length) return
const workerCount = Math.max(1, Math.min(concurrency, items.length))
let nextIndex = 0
const workers: Promise<void>[] = []
for (let i = 0; i < workerCount; i++) {
workers.push((async () => {
while (true) {
if (shouldStop?.()) break
const current = nextIndex
nextIndex += 1
if (current >= items.length) break
await handler(items[current], current)
}
})())
}
await Promise.all(workers)
}
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
if (this.availableYearsColumnCache.has(cacheKey)) {
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
return cached || null
}
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
const columns = new Set<string>()
for (const row of result.rows as Record<string, any>[]) {
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
if (name) columns.add(name)
}
for (const candidate of candidates) {
if (columns.has(candidate)) {
this.availableYearsColumnCache.set(cacheKey, candidate)
return candidate
}
}
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
const tried = new Set<string>()
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
const row = result.rows[0] as Record<string, any>
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
return { first, last }
}
tried.add(initialColumn)
const quick = await queryByColumn(initialColumn)
if (quick) {
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
return quick
}
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
if (!detectedColumn || tried.has(detectedColumn)) {
return null
}
return queryByColumn(detectedColumn)
}
private async getAvailableYearsByTableScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
const shouldCancel = () => options?.shouldCancel?.() === true
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
if (shouldCancel()) return
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
return
}
for (const table of tableStats.tables as Record<string, any>[]) {
if (shouldCancel()) return
const tableName = String(table.table_name || table.name || '').trim()
const dbPath = String(table.db_path || table.dbPath || '').trim()
if (!tableName || !dbPath) continue
const range = await this.getTableTimeRange(dbPath, tableName)
if (!range) continue
const changed = this.addYearsFromRange(years, range.first, range.last)
if (changed) emitIfChanged()
}
}, shouldCancel)
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private async getAvailableYearsByEdgeScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const shouldCancel = () => options?.shouldCancel?.() === true
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
for (const sessionId of sessionIds) {
if (shouldCancel()) break
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
const changed = this.addYearsFromRange(years, first || 0, last || 0)
if (changed) emitIfChanged()
}
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
return `${dbPath}\u0001${cleanedWxid}`
}
private getCachedAvailableYears(cacheKey: string): number[] | null {
const cached = this.availableYearsCache.get(cacheKey)
if (!cached) return null
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
this.availableYearsCache.delete(cacheKey)
return null
}
return [...cached.years]
}
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
const normalized = this.normalizeAvailableYears(years)
this.availableYearsCache.set(cacheKey, {
years: normalized,
updatedAt: Date.now()
})
if (this.availableYearsCache.size > 8) {
let oldestKey = ''
let oldestTime = Number.POSITIVE_INFINITY
for (const [key, val] of this.availableYearsCache) {
if (val.updatedAt < oldestTime) {
oldestTime = val.updatedAt
oldestKey = key
}
}
if (oldestKey) this.availableYearsCache.delete(oldestKey)
}
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
@@ -359,38 +614,226 @@ class AnnualReportService {
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
}
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
async getAvailableYears(params: {
dbPath: string
decryptKey: string
wxid: string
onProgress?: (payload: AvailableYearsLoadProgress) => void
shouldCancel?: () => boolean
nativeTimeoutMs?: number
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
try {
const isCancelled = () => params.shouldCancel?.() === true
const totalStartedAt = Date.now()
let nativeElapsedMs = 0
let scanElapsedMs = 0
let switched = false
let nativeTimedOut = false
let latestYears: number[] = []
const emitProgress = (payload: {
years?: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
switched?: boolean
nativeTimedOut?: boolean
}) => {
if (!params.onProgress) return
if (Array.isArray(payload.years)) latestYears = payload.years
params.onProgress({
years: latestYears,
strategy: payload.strategy,
phase: payload.phase,
statusText: payload.statusText,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched: payload.switched ?? switched,
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
})
}
const buildMeta = (
strategy: 'cache' | 'native' | 'hybrid',
statusText: string
): AvailableYearsLoadMeta => ({
strategy,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched,
nativeTimedOut,
statusText
})
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const fastYears = await wcdbService.getAvailableYears(sessionIds)
if (fastYears.success && fastYears.data) {
return { success: true, data: fastYears.data }
}
const years = new Set<number>()
for (const sessionId of sessionIds) {
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
if (!first && !last) continue
const minYear = new Date((first || last || 0) * 1000).getFullYear()
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
const cached = this.getCachedAvailableYears(cacheKey)
if (cached) {
latestYears = cached
emitProgress({
years: cached,
strategy: 'cache',
phase: 'cache',
statusText: '命中缓存,已快速加载年份数据'
})
return {
success: true,
data: cached,
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
}
}
const sortedYears = Array.from(years).sort((a, b) => b - a)
return { success: true, data: sortedYears }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
}
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
const nativeStartedAt = Date.now()
let nativeTicker: ReturnType<typeof setInterval> | null = null
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
nativeTicker = setInterval(() => {
nativeElapsedMs = Date.now() - nativeStartedAt
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
}, 120)
const nativeRace = await Promise.race([
wcdbService.getAvailableYears(sessionIds)
.then((result) => ({ kind: 'result' as const, result }))
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
])
if (nativeTicker) {
clearInterval(nativeTicker)
nativeTicker = null
}
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
const years = this.normalizeAvailableYears(nativeRace.result.data)
latestYears = years
this.setCachedAvailableYears(cacheKey, years)
emitProgress({
years,
strategy: 'native',
phase: 'native',
statusText: '原生快速模式加载完成'
})
return {
success: true,
data: years,
meta: buildMeta('native', '原生快速模式加载完成')
}
}
switched = true
nativeTimedOut = nativeRace.kind === 'timeout'
emitProgress({
strategy: 'hybrid',
phase: 'native',
statusText: nativeTimedOut
? '原生快速模式超时,已自动切换到扫表兼容模式...'
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
switched: true,
nativeTimedOut
})
const scanStartedAt = Date.now()
let scanTicker: ReturnType<typeof setInterval> | null = null
scanTicker = setInterval(() => {
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
}, 120)
let years = await this.getAvailableYearsByTableScan(sessionIds, {
onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel
})
if (isCancelled()) {
if (scanTicker) clearInterval(scanTicker)
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
}
if (years.length === 0) {
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表结果为空,正在执行游标兜底扫描...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel
})
}
if (scanTicker) {
clearInterval(scanTicker)
scanTicker = null
}
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
this.setCachedAvailableYears(cacheKey, years)
latestYears = years
emitProgress({
years,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表兼容模式加载完成',
switched: true,
nativeTimedOut
})
return {
success: true,
data: years,
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
}
} catch (e) {
return { success: false, error: String(e) }
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { app } from 'electron'
import { wcdbService } from './wcdbService'
interface UsageStats {
appVersion: string
platform: string
deviceId: string
timestamp: number
online: boolean
pages: string[]
}
class CloudControlService {
private deviceId: string = ''
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
async init() {
this.deviceId = this.getDeviceId()
await wcdbService.cloudInit(300)
await this.reportOnline()
this.timer = setInterval(() => {
this.reportOnline()
}, 300000)
}
private getDeviceId(): string {
const crypto = require('crypto')
const os = require('os')
const machineId = os.hostname() + os.platform() + os.arch()
return crypto.createHash('md5').update(machineId).digest('hex')
}
private async reportOnline() {
const data: UsageStats = {
appVersion: app.getVersion(),
platform: process.platform,
deviceId: this.deviceId,
timestamp: Date.now(),
online: true,
pages: Array.from(this.pages)
}
await wcdbService.cloudReport(JSON.stringify(data))
this.pages.clear()
}
recordPage(pageName: string) {
this.pages.add(pageName)
}
stop() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
wcdbService.cloudStop()
}
async getLogs() {
return wcdbService.getLogs()
}
}
export const cloudControlService = new CloudControlService()

View File

@@ -105,7 +105,7 @@ export class ConfigService {
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -671,4 +671,4 @@ export class ConfigService {
this.unlockedKeys.clear()
this.unlockPassword = null
}
}
}

View File

@@ -0,0 +1,354 @@
import { mkdir, writeFile } from 'fs/promises'
import { basename, dirname, extname, join } from 'path'
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
export interface ExportCardDiagLogEntry {
id: string
ts: number
source: ExportCardDiagSource
level: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
interface ActiveStepState {
key: string
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
startedAt: number
lastUpdatedAt: number
message?: string
}
interface StepStartInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
}
interface StepEndInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
durationMs?: number
}
interface LogInput {
ts?: number
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
export interface ExportCardDiagSnapshot {
logs: ExportCardDiagLogEntry[]
activeSteps: Array<{
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
elapsedMs: number
stallMs: number
startedAt: number
lastUpdatedAt: number
message?: string
}>
summary: {
totalLogs: number
activeStepCount: number
errorCount: number
warnCount: number
timeoutCount: number
lastUpdatedAt: number
}
}
export class ExportCardDiagnosticsService {
private readonly maxLogs = 6000
private logs: ExportCardDiagLogEntry[] = []
private activeSteps = new Map<string, ActiveStepState>()
private seq = 0
private nextId(ts: number): string {
this.seq += 1
return `export-card-diag-${ts}-${this.seq}`
}
private trimLogs() {
if (this.logs.length <= this.maxLogs) return
const drop = this.logs.length - this.maxLogs
this.logs.splice(0, drop)
}
log(input: LogInput): ExportCardDiagLogEntry {
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
const entry: ExportCardDiagLogEntry = {
id: this.nextId(ts),
ts,
source: input.source,
level: input.level || 'info',
message: input.message,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status,
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
data: input.data
}
this.logs.push(entry)
this.trimLogs()
if (entry.traceId && entry.stepId && entry.stepName) {
const key = `${entry.traceId}::${entry.stepId}`
if (entry.status === 'running') {
const previous = this.activeSteps.get(key)
this.activeSteps.set(key, {
key,
traceId: entry.traceId,
stepId: entry.stepId,
stepName: entry.stepName,
source: entry.source,
startedAt: previous?.startedAt || entry.ts,
lastUpdatedAt: entry.ts,
message: entry.message
})
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
this.activeSteps.delete(key)
}
}
return entry
}
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || 'info',
message: input.message || `${input.stepName} 开始`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: 'running',
data: input.data
})
}
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status || 'done',
durationMs: input.durationMs,
data: input.data
})
}
clear() {
this.logs = []
this.activeSteps.clear()
}
snapshot(limit = 1200): ExportCardDiagSnapshot {
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
const logs = this.logs.slice(-capped)
const now = Date.now()
const activeSteps = Array.from(this.activeSteps.values())
.map(step => ({
traceId: step.traceId,
stepId: step.stepId,
stepName: step.stepName,
source: step.source,
startedAt: step.startedAt,
lastUpdatedAt: step.lastUpdatedAt,
elapsedMs: Math.max(0, now - step.startedAt),
stallMs: Math.max(0, now - step.lastUpdatedAt),
message: step.message
}))
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
}
return {
logs,
activeSteps,
summary: {
totalLogs: this.logs.length,
activeStepCount: activeSteps.length,
errorCount,
warnCount,
timeoutCount,
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
}
}
}
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
const result: ExportCardDiagLogEntry[] = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const tsRaw = row.ts ?? row.timestamp
const tsNum = Number(tsRaw)
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
const sourceRaw = String(row.source || 'frontend')
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
? sourceRaw
: 'frontend'
const levelRaw = String(row.level || 'info')
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
? levelRaw
: 'info'
const statusRaw = String(row.status || '')
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
? statusRaw
: undefined
const durationRaw = Number(row.durationMs)
result.push({
id: String(row.id || this.nextId(ts)),
ts,
source,
level,
message: String(row.message || ''),
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
status,
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
})
}
return result
}
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
return JSON.stringify(log)
}
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
const total = logs.length
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
let frontendCount = 0
let backendCount = 0
let mainCount = 0
let workerCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
if (item.source === 'frontend') frontendCount += 1
if (item.source === 'backend') backendCount += 1
if (item.source === 'main') mainCount += 1
if (item.source === 'worker') workerCount += 1
}
const lines: string[] = []
lines.push('WeFlow 导出卡片诊断摘要')
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push(`日志总数: ${total}`)
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
lines.push(`当前活跃步骤: ${activeSteps.length}`)
if (activeSteps.length > 0) {
lines.push('')
lines.push('活跃步骤:')
for (const step of activeSteps.slice(0, 12)) {
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
}
}
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
if (latestErrors.length > 0) {
lines.push('')
lines.push('最近异常:')
for (const item of latestErrors) {
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
}
}
return lines.join('\n')
}
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
success: boolean
filePath?: string
summaryPath?: string
count?: number
error?: string
}> {
try {
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
const merged = [...this.logs, ...normalizedFrontend]
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
const ext = extname(filePath)
const baseName = ext ? basename(filePath, ext) : basename(filePath)
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
const snapshot = this.snapshot(1500)
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
await writeFile(summaryPath, summaryText, 'utf8')
return {
success: true,
filePath,
summaryPath,
count: merged.length
}
} catch (error) {
return {
success: false,
error: String(error)
}
}
}
}
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()

View File

@@ -0,0 +1,229 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_SCOPE_ENTRIES = 12
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
export interface ExportContentSessionStatsEntry {
updatedAt: number
hasAny: boolean
hasVoice: boolean
hasImage: boolean
hasVideo: boolean
hasEmoji: boolean
mediaReady: boolean
}
export interface ExportContentScopeStatsEntry {
updatedAt: number
sessions: Record<string, ExportContentSessionStatsEntry>
}
interface ExportContentStatsStore {
version: number
scopes: Record<string, ExportContentScopeStatsEntry>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function toBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value
return fallback
}
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
return {
updatedAt,
hasAny: toBoolean(source.hasAny, false),
hasVoice: toBoolean(source.hasVoice, false),
hasImage: toBoolean(source.hasImage, false),
hasVideo: toBoolean(source.hasVideo, false),
hasEmoji: toBoolean(source.hasEmoji, false),
mediaReady: toBoolean(source.mediaReady, false)
}
}
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
const sessionsRaw = source.sessions
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
return {
updatedAt,
sessions: {}
}
}
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
const normalized = normalizeSessionStatsEntry(entryRaw)
if (!normalized) continue
sessions[sessionId] = normalized
}
return {
updatedAt,
sessions
}
}
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
return {
updatedAt: scope.updatedAt,
sessions: Object.fromEntries(
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
)
}
}
export class ExportContentStatsCacheService {
private readonly cacheFilePath: string
private store: ExportContentStatsStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'export-content-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
if (!normalizedScope) continue
scopes[scopeKey] = normalizedScope
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
if (!scopeKey) return undefined
const rawScope = this.store.scopes[scopeKey]
if (!rawScope) return undefined
const normalizedScope = normalizeScopeStatsEntry(rawScope)
if (!normalizedScope) {
delete this.store.scopes[scopeKey]
this.persist()
return undefined
}
this.store.scopes[scopeKey] = normalizedScope
return cloneScope(normalizedScope)
}
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
if (!scopeKey) return
const normalized = normalizeScopeStatsEntry(scope)
if (!normalized) return
this.store.scopes[scopeKey] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
deleteSession(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope.sessions)) return
delete scope.sessions[sessionId]
if (Object.keys(scope.sessions).length === 0) {
delete this.store.scopes[scopeKey]
} else {
scope.updatedAt = Date.now()
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope.sessions)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
}
private persist(): void {
try {
this.ensureCacheDir()
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
}
}
}

View File

@@ -0,0 +1,95 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
export interface ExportRecord {
exportTime: number
format: string
messageCount: number
sourceLatestMessageTimestamp?: number
outputPath?: string
}
type RecordStore = Record<string, ExportRecord[]>
class ExportRecordService {
private filePath: string | null = null
private loaded = false
private store: RecordStore = {}
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app.getPath('userData')
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.store = parsed as RecordStore
}
} catch {
this.store = {}
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
} catch {
// ignore persist errors to avoid blocking export flow
}
}
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
this.ensureLoaded()
const records = this.store[sessionId]
if (!records || records.length === 0) return null
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]
if (record && record.format === format) return record
}
return null
}
saveRecord(
sessionId: string,
format: string,
messageCount: number,
extra?: {
sourceLatestMessageTimestamp?: number
outputPath?: string
}
): void {
this.ensureLoaded()
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
if (!this.store[normalizedSessionId]) {
this.store[normalizedSessionId] = []
}
const list = this.store[normalizedSessionId]
list.push({
exportTime: Date.now(),
format,
messageCount,
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
outputPath: extra?.outputPath
})
// keep the latest 30 records per session
if (list.length > 30) {
this.store[normalizedSessionId] = list.slice(-30)
}
this.persist()
}
}
export const exportRecordService = new ExportRecordService()

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,12 @@ export interface GroupMember {
alias?: string
remark?: string
groupNickname?: string
isOwner?: boolean
}
export interface GroupMembersPanelEntry extends GroupMember {
isFriend: boolean
messageCount: number
}
export interface GroupMessageRank {
@@ -43,8 +49,28 @@ export interface GroupMediaStats {
total: number
}
interface GroupMemberContactInfo {
remark: string
nickName: string
alias: string
username: string
userName: string
encryptUsername: string
encryptUserName: string
localType: number
}
class GroupAnalyticsService {
private configService: ConfigService
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
private readonly groupMembersPanelInFlight = new Map<
string,
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
>()
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
constructor() {
this.configService = new ConfigService()
@@ -89,6 +115,128 @@ class GroupAnalyticsService {
return cleaned
}
private resolveMemberUsername(
candidate: unknown,
memberLookup: Map<string, string>
): string | null {
if (typeof candidate !== 'string') return null
const raw = candidate.trim()
if (!raw) return null
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
const cleaned = this.cleanAccountDirName(raw)
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
for (const part of parts) {
if (memberLookup.has(part)) return memberLookup.get(part) || null
const normalizedPart = this.cleanAccountDirName(part)
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
}
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
try {
const parsed = JSON.parse(raw)
return this.extractOwnerUsername(parsed, memberLookup, 0)
} catch {
return null
}
}
return null
}
private extractOwnerUsername(
value: unknown,
memberLookup: Map<string, string>,
depth: number
): string | null {
if (depth > 4 || value == null) return null
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
if (typeof value === 'string') {
return this.resolveMemberUsername(value, memberLookup)
}
if (Array.isArray(value)) {
for (const item of value) {
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
if (owner) return owner
}
return null
}
if (typeof value !== 'object') return null
const row = value as Record<string, unknown>
for (const [key, entry] of Object.entries(row)) {
const keyLower = key.toLowerCase()
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
continue
}
if (typeof entry === 'boolean') {
if (entry && typeof row.username === 'string') {
const owner = this.resolveMemberUsername(row.username, memberLookup)
if (owner) return owner
}
continue
}
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
if (owner) return owner
}
return null
}
private async detectGroupOwnerUsername(
chatroomId: string,
members: Array<{ username: string; [key: string]: unknown }>
): Promise<string | undefined> {
const memberLookup = new Map<string, string>()
for (const member of members) {
const username = String(member.username || '').trim()
if (!username) continue
const cleaned = this.cleanAccountDirName(username)
memberLookup.set(username, username)
memberLookup.set(cleaned, username)
}
if (memberLookup.size === 0) return undefined
const tryResolve = (candidate: unknown): string | undefined => {
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
return owner || undefined
}
for (const member of members) {
const owner = tryResolve(member)
if (owner) return owner
}
try {
const groupContact = await wcdbService.getContact(chatroomId)
if (groupContact.success && groupContact.contact) {
const owner = tryResolve(groupContact.contact)
if (owner) return owner
}
} catch {
// ignore
}
try {
const escapedChatroomId = chatroomId.replace(/'/g, "''")
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
const owner = tryResolve(roomResult.rows[0])
if (owner) return owner
}
} catch {
// ignore
}
return undefined
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -296,6 +444,203 @@ class GroupAnalyticsService {
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
for (const key of keys) {
const value = row[key]
if (value == null) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
for (const key of keys) {
const value = row[key]
if (value == null || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return Math.floor(parsed)
}
return fallback
}
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
const mode = includeMessageCounts ? 'full' : 'members'
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
}
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
if (this.groupMembersPanelCache.size <= maxEntries) return
const entries = Array.from(this.groupMembersPanelCache.entries())
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
const removeCount = this.groupMembersPanelCache.size - maxEntries
for (let i = 0; i < removeCount; i += 1) {
this.groupMembersPanelCache.delete(entries[i][0])
}
}
private async withPromiseTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutResult: T
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return promise
}
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<T>((resolve) => {
timeoutTimer = setTimeout(() => {
resolve(timeoutResult)
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
}
}
}
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
const lookup = new Map<string, GroupMemberContactInfo>()
const candidates = this.buildIdCandidates(usernames)
if (candidates.length === 0) return lookup
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
for (const row of rows) {
const contact: GroupMemberContactInfo = {
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
}
const lookupKeys = this.buildIdCandidates([
contact.username,
contact.userName,
contact.encryptUsername,
contact.encryptUserName,
contact.alias
])
for (const key of lookupKeys) {
const normalized = key.toLowerCase()
if (!lookup.has(normalized)) {
lookup.set(normalized, contact)
}
}
}
}
const batchSize = 200
for (let i = 0; i < candidates.length; i += batchSize) {
const batch = candidates.slice(i, i + batchSize)
if (batch.length === 0) continue
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
const lightweightSql = `
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
FROM contact
WHERE username IN (${inList})
`
let result = await wcdbService.execQuery('contact', null, lightweightSql)
if (!result.success || !result.rows) {
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
}
if (!result.success || !result.rows) continue
appendContactsToLookup(result.rows as Record<string, unknown>[])
}
return lookup
}
private resolveContactByCandidates(
lookup: Map<string, GroupMemberContactInfo>,
candidates: Array<string | undefined | null>
): GroupMemberContactInfo | undefined {
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const hit = lookup.get(id.toLowerCase())
if (hit) return hit
}
return undefined
}
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
const lookup = new Map<string, number>()
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
if (!result.success || !result.data) return lookup
const sessionData = result.data?.sessions?.[chatroomId]
if (!sessionData || !sessionData.senders) return lookup
const idMap = result.data.idMap || {}
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
const username = String(idMap[senderId] || senderId || '').trim()
if (!username) continue
const count = this.toNonNegativeInteger(rawCount)
const keys = this.buildIdCandidates([username])
for (const key of keys) {
const normalized = key.toLowerCase()
const prev = lookup.get(normalized) || 0
if (count > prev) {
lookup.set(normalized, count)
}
}
}
return lookup
}
private resolveMessageCountByCandidates(
lookup: Map<string, number>,
candidates: Array<string | undefined | null>
): number {
let maxCount = 0
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const count = lookup.get(id.toLowerCase())
if (typeof count === 'number' && count > maxCount) {
maxCount = count
}
}
return maxCount
}
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
const normalizedWxid = String(wxid || '').trim().toLowerCase()
if (!normalizedWxid) return false
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
if (this.friendExcludeNames.has(normalizedWxid)) return false
if (!contact) return false
return contact.localType === 1
}
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
return members.sort((a, b) => {
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
if (ownerDiff !== 0) return ownerDiff
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
if (friendDiff !== 0) return friendDiff
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
})
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
if (idCandidates.length === 0) return ''
@@ -483,6 +828,167 @@ class GroupAnalyticsService {
}
}
private async loadGroupMembersPanelDataFresh(
chatroomId: string,
includeMessageCounts: boolean
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as Array<{
username: string
avatarUrl?: string
originalName?: string
[key: string]: unknown
}>
if (members.length === 0) return { success: true, data: [] }
const usernames = members
.map((member) => String(member.username || '').trim())
.filter(Boolean)
if (usernames.length === 0) return { success: true, data: [] }
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
const messageCountLookupPromise = includeMessageCounts
? this.buildGroupMessageCountLookup(chatroomId)
: Promise.resolve(new Map<string, number>())
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
displayNamesPromise,
contactLookupPromise,
ownerPromise,
messageCountLookupPromise
])
const nicknameCandidates = this.buildIdCandidates([
...members.map((member) => member.username),
...members.map((member) => member.originalName),
...Array.from(contactLookup.values()).map((contact) => contact?.username),
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
let myGroupMessageCountHint: number | undefined
const data: GroupMembersPanelEntry[] = members
.map((member) => {
const wxid = String(member.username || '').trim()
if (!wxid) return null
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
const nickname = contact?.nickName || ''
const remark = contact?.remark || ''
const alias = contact?.alias || ''
const normalizedWxid = this.cleanAccountDirName(wxid)
const lookupCandidates = this.buildIdCandidates([
wxid,
member.originalName as string | undefined,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias
])
if (normalizedWxid === myWxid) {
lookupCandidates.push(myWxid)
}
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
return {
username: wxid,
displayName,
nickname,
alias,
remark,
groupNickname,
avatarUrl: member.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
isFriend: this.isFriendMember(wxid, contact),
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
}
})
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
if (includeMessageCounts && myWxid) {
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
}
}
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
}
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
}
async getGroupMembersPanelData(
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
try {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
const forceRefresh = Boolean(options?.forceRefresh)
const includeMessageCounts = options?.includeMessageCounts !== false
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
const now = Date.now()
const cached = this.groupMembersPanelCache.get(cacheKey)
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
}
if (!forceRefresh) {
const pending = this.groupMembersPanelInFlight.get(cacheKey)
if (pending) return pending
}
const requestPromise = (async () => {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const timeoutMs = includeMessageCounts
? this.groupMembersPanelFullTimeoutMs
: this.groupMembersPanelMembersTimeoutMs
const fresh = await this.withPromiseTimeout(
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
timeoutMs,
{
success: false,
error: includeMessageCounts
? '群成员发言统计加载超时,请稍后重试'
: '群成员列表加载超时,请稍后重试'
}
)
if (!fresh.success || !fresh.data) {
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
}
const updatedAt = Date.now()
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
this.pruneGroupMembersPanelCache()
return { success: true, data: fresh.data, fromCache: false, updatedAt }
})().finally(() => {
this.groupMembersPanelInFlight.delete(cacheKey)
})
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
return await requestPromise
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
try {
const conn = await this.ensureConnected()
@@ -497,6 +1003,7 @@ class GroupAnalyticsService {
username: string
avatarUrl?: string
originalName?: string
[key: string]: unknown
}>
const usernames = members.map((m) => m.username).filter(Boolean)
@@ -543,6 +1050,7 @@ class GroupAnalyticsService {
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
const data: GroupMember[] = members.map((m) => {
const wxid = m.username || ''
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
@@ -572,7 +1080,8 @@ class GroupAnalyticsService {
alias,
remark,
groupNickname,
avatarUrl: m.avatarUrl
avatarUrl: m.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
}
})

View File

@@ -0,0 +1,204 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
const MAX_SCOPE_ENTRIES = 12
export interface GroupMyMessageCountCacheEntry {
updatedAt: number
messageCount: number
}
interface GroupMyMessageCountScopeMap {
[chatroomId: string]: GroupMyMessageCountCacheEntry
}
interface GroupMyMessageCountCacheStore {
version: number
scopes: Record<string, GroupMyMessageCountScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const messageCount = toNonNegativeInt(source.messageCount)
if (updatedAt === undefined || messageCount === undefined) return null
return {
updatedAt,
messageCount
}
}
export class GroupMyMessageCountCacheService {
private readonly cacheFilePath: string
private store: GroupMyMessageCountCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[chatroomId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
if (!scopeKey || !chatroomId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[chatroomId])
if (!entry) {
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
if (!scopeKey || !chatroomId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
const existing = this.store.scopes[scopeKey][chatroomId]
if (existing && existing.updatedAt > normalized.updatedAt) {
return
}
this.store.scopes[scopeKey][chatroomId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, chatroomId: string): void {
if (!scopeKey || !chatroomId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(chatroomId in scope)) return
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
trimmed[chatroomId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -509,6 +509,58 @@ export class KeyService {
return false
}
private isLoginRelatedText(value: string): boolean {
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
if (!normalized) return false
const keywords = [
'登录',
'扫码',
'二维码',
'请在手机上确认',
'手机确认',
'切换账号',
'wechatlogin',
'qrcode',
'scan'
]
return keywords.some((keyword) => normalized.includes(keyword))
}
private async detectWeChatLoginRequired(pid: number): Promise<boolean> {
if (!this.ensureUser32()) return false
let loginRequired = false
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const windowPid = pidBuf.readUInt32LE(0)
if (windowPid !== pid) return true
if (this.isLoginRelatedText(title)) {
loginRequired = true
return false
}
const children = this.collectChildWindowInfos(hWnd)
for (const child of children) {
if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) {
loginRequired = true
return false
}
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return loginRequired
}
private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise<boolean> {
if (!this.ensureUser32()) return true
const startTime = Date.now()
@@ -605,6 +657,7 @@ export class KeyService {
const keyBuffer = Buffer.alloc(128)
const start = Date.now()
let loginRequiredDetected = false
try {
while (Date.now() - start < timeoutMs) {
@@ -624,6 +677,9 @@ export class KeyService {
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
if (this.isLoginRelatedText(msg)) {
loginRequiredDetected = true
}
onStatus?.(msg, level)
}
}
@@ -635,6 +691,15 @@ export class KeyService {
} catch { }
}
const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid)
if (loginRequired) {
return {
success: false,
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
logs
}
}
return { success: false, error: '获取密钥超时', logs }
}
@@ -983,4 +1048,4 @@ export class KeyService {
return false
} catch { return false }
}
}
}

View File

@@ -0,0 +1,293 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 2
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
const MAX_SCOPE_ENTRIES = 12
export interface SessionStatsCacheStats {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
export interface SessionStatsCacheEntry {
updatedAt: number
includeRelations: boolean
stats: SessionStatsCacheStats
}
interface SessionStatsScopeMap {
[sessionId: string]: SessionStatsCacheEntry
}
interface SessionStatsCacheStore {
version: number
scopes: Record<string, SessionStatsScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const totalMessages = toNonNegativeInt(source.totalMessages)
const voiceMessages = toNonNegativeInt(source.voiceMessages)
const imageMessages = toNonNegativeInt(source.imageMessages)
const videoMessages = toNonNegativeInt(source.videoMessages)
const emojiMessages = toNonNegativeInt(source.emojiMessages)
const transferMessages = toNonNegativeInt(source.transferMessages)
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
const callMessages = toNonNegativeInt(source.callMessages)
if (
totalMessages === undefined ||
voiceMessages === undefined ||
imageMessages === undefined ||
videoMessages === undefined ||
emojiMessages === undefined ||
transferMessages === undefined ||
redPacketMessages === undefined ||
callMessages === undefined
) {
return null
}
const normalized: SessionStatsCacheStats = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
}
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
return normalized
}
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
const stats = normalizeStats(source.stats)
if (updatedAt === undefined || !stats) {
return null
}
return {
updatedAt,
includeRelations,
stats
}
}
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const version = Number(payload.version)
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: SessionStatsScopeMap = {}
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[sessionId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('SessionStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
if (!scopeKey || !sessionId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[sessionId])
if (!entry) {
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
if (!scopeKey || !sessionId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
this.store.scopes[scopeKey][sessionId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope)) return
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('SessionStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: SessionStatsScopeMap = {}
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
trimmed[sessionId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -44,6 +44,68 @@ export interface SnsPost {
linkUrl?: string
}
interface SnsContactIdentity {
username: string
wxid: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
}
interface ParsedLikeUser {
username?: string
nickname?: string
}
interface ParsedCommentItem {
id: string
nickname: string
username?: string
content: string
refCommentId: string
refUsername?: string
refNickname?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
}
interface ArkmeLikeDetail {
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
source: 'xml' | 'legacy'
}
interface ArkmeCommentDetail {
id: string
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
content: string
refCommentId: string
refNickname?: string
refUsername?: string
refWxid?: string
refAlias?: string
refWechatId?: string
refRemark?: string
refNickName?: string
refDisplayName?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
source: 'xml' | 'legacy'
}
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
@@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => {
/**
* 从 XML 中解析评论信息(含表情包、回复关系)
*/
function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] {
function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
if (!xml) return []
type CommentItem = {
@@ -229,12 +291,262 @@ class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
private lastTimelineFallbackAt = 0
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
constructor() {
this.configService = new ConfigService()
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
}
private toOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
private async resolveContactIdentity(
username: string,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<SnsContactIdentity | null> {
const normalized = String(username || '').trim()
if (!normalized) return null
let pending = identityCache.get(normalized)
if (!pending) {
pending = (async () => {
const cached = this.contactCache.get(normalized)
let alias: string | undefined
let remark: string | undefined
let nickName: string | undefined
try {
const contactResult = await wcdbService.getContact(normalized)
if (contactResult.success && contactResult.contact) {
const contact = contactResult.contact
alias = this.toOptionalString(contact.alias ?? contact.Alias)
remark = this.toOptionalString(contact.remark ?? contact.Remark)
nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName)
}
} catch {
// 联系人补全失败不影响导出
}
const displayName = remark || nickName || alias || cached?.displayName || normalized
return {
username: normalized,
wxid: normalized,
alias,
wechatId: alias,
remark,
nickName,
displayName
}
})()
identityCache.set(normalized, pending)
}
return pending
}
private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] {
if (!xml) return []
const likes: ParsedLikeUser[] = []
try {
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
if (!likeListMatch) return likes
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
let m: RegExpExecArray | null
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
const block = m[1]
const username = this.toOptionalString(block.match(/<username>([^<]*)<\/username>/i)?.[1])
const nickname = this.toOptionalString(
block.match(/<nickname>([^<]*)<\/nickname>/i)?.[1]
|| block.match(/<nickName>([^<]*)<\/nickName>/i)?.[1]
)
if (username || nickname) {
likes.push({ username, nickname })
}
}
} catch (e) {
console.error('[SnsService] 解析点赞用户失败:', e)
}
return likes
}
private async buildArkmeInteractionDetails(
post: SnsPost,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> {
const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '')
const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0
? xmlLikes
: (post.likes || []).map((nickname) => ({ nickname }))
const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy'
const likesDetail: ArkmeLikeDetail[] = []
const likeSeen = new Set<string>()
for (const like of likeCandidates) {
const identity = like.username
? await this.resolveContactIdentity(like.username, identityCache)
: null
const nickname = like.nickname || identity?.displayName || like.username || ''
const username = identity?.username || like.username
const key = `${username || ''}|${nickname}`
if (likeSeen.has(key)) continue
likeSeen.add(key)
likesDetail.push({
nickname,
username,
wxid: username,
alias: identity?.alias,
wechatId: identity?.wechatId,
remark: identity?.remark,
nickName: identity?.nickName,
displayName: identity?.displayName || nickname || username || '',
source: likeSource
})
}
const xmlComments = parseCommentsFromXml(post.rawXml || '')
const commentMap = new Map<string, SnsPost['comments'][number]>()
for (const comment of post.comments || []) {
if (comment.id) commentMap.set(comment.id, comment)
}
const commentsBase: ParsedCommentItem[] = xmlComments.length > 0
? xmlComments.map((comment) => {
const fallback = comment.id ? commentMap.get(comment.id) : undefined
return {
id: comment.id || fallback?.id || '',
nickname: comment.nickname || fallback?.nickname || '',
username: comment.username,
content: comment.content || fallback?.content || '',
refCommentId: comment.refCommentId || fallback?.refCommentId || '',
refUsername: comment.refUsername,
refNickname: comment.refNickname || fallback?.refNickname,
emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis
}
})
: (post.comments || []).map((comment) => ({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
}))
if (xmlComments.length > 0) {
const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean))
for (const comment of post.comments || []) {
if (comment.id && mappedIds.has(comment.id)) continue
commentsBase.push({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
})
}
}
const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy'
const commentsDetail: ArkmeCommentDetail[] = []
for (const comment of commentsBase) {
const actor = comment.username
? await this.resolveContactIdentity(comment.username, identityCache)
: null
const refActor = comment.refUsername
? await this.resolveContactIdentity(comment.refUsername, identityCache)
: null
const nickname = comment.nickname || actor?.displayName || comment.username || ''
const username = actor?.username || comment.username
const refUsername = refActor?.username || comment.refUsername
commentsDetail.push({
id: comment.id || '',
nickname,
username,
wxid: username,
alias: actor?.alias,
wechatId: actor?.wechatId,
remark: actor?.remark,
nickName: actor?.nickName,
displayName: actor?.displayName || nickname || username || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname || refActor?.displayName,
refUsername,
refWxid: refUsername,
refAlias: refActor?.alias,
refWechatId: refActor?.wechatId,
refRemark: refActor?.remark,
refNickName: refActor?.nickName,
refDisplayName: refActor?.displayName,
emojis: comment.emojis,
source: commentSource
})
}
return { likesDetail, commentsDetail }
}
private parseCountValue(row: any): number {
if (!row || typeof row !== 'object') return 0
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
const num = Number(raw)
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
}
private pickTimelineUsername(post: any): string {
const raw = post?.username ?? post?.user_name ?? post?.userName ?? ''
if (typeof raw !== 'string') return ''
return raw.trim()
}
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
const pageSize = 500
const uniqueUsers = new Set<string>()
let totalPosts = 0
let myPosts = 0
let offset = 0
const normalizedMyWxid = this.toOptionalString(myWxid)
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈统计失败')
}
const rows = result.timeline
if (rows.length === 0) break
totalPosts += rows.length
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (username) uniqueUsers.add(username)
if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return {
totalPosts,
totalFriends: uniqueUsers.size,
myPosts: normalizedMyWxid ? myPosts : null
}
}
private parseLikesFromXml(xml: string): string[] {
if (!xml) return []
const likes: string[] = []
@@ -349,14 +661,207 @@ class SnsService {
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
if (!result.success || !result.rows) {
// 尝试 userName 列名
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
const collect = (rows?: any[]): string[] => {
if (!Array.isArray(rows)) return []
const usernames: string[] = []
for (const row of rows) {
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
if (username) usernames.push(username)
}
return usernames
}
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
const primary = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
const fallback = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
const merged = Array.from(new Set([
...collect(primary.rows),
...collect(fallback.rows)
]))
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
if (merged.length > 0) {
return { success: true, usernames: merged }
}
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
if (primary.success || fallback.success) {
return { success: true, usernames: [] }
}
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
}
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
let totalPosts = 0
let totalFriends = 0
let myPosts: number | null = null
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
totalPosts = this.parseCountValue(postCountResult.rows[0])
}
if (totalPosts > 0) {
const friendCountPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
} else {
const friendCountFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
}
}
}
const normalizedMyWxid = this.toOptionalString(myWxid)
if (normalizedMyWxid) {
const myPostPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
[normalizedMyWxid]
)
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
myPosts = this.parseCountValue(myPostPrimary.rows[0])
} else {
const myPostFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
[normalizedMyWxid]
)
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
myPosts = this.parseCountValue(myPostFallback.rows[0])
}
}
}
return { totalPosts, totalFriends, myPosts }
}
async getExportStats(options?: {
allowTimelineFallback?: boolean
preferCache?: boolean
}): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
const allowTimelineFallback = options?.allowTimelineFallback ?? true
const preferCache = options?.preferCache ?? false
const now = Date.now()
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
try {
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid)
let fallbackAttempted = false
let fallbackError = ''
// 某些环境下 SnsTimeLine 统计查询会返回 0这里在允许时回退到与导出同源的 timeline 接口统计。
if (
allowTimelineFallback &&
(totalPosts <= 0 || totalFriends <= 0) &&
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
) {
fallbackAttempted = true
try {
const timelineStats = await this.getExportStatsFromTimeline(myWxid)
this.lastTimelineFallbackAt = Date.now()
if (timelineStats.totalPosts > 0) {
totalPosts = timelineStats.totalPosts
}
if (timelineStats.totalFriends > 0) {
totalFriends = timelineStats.totalFriends
}
if (timelineStats.myPosts !== null) {
myPosts = timelineStats.myPosts
}
} catch (error) {
fallbackError = String(error)
console.error('[SnsService] getExportStats timeline fallback failed:', error)
}
}
const normalizedStats = {
totalPosts: Math.max(0, Number(totalPosts || 0)),
totalFriends: Math.max(0, Number(totalFriends || 0)),
myPosts: myWxid
? (myPosts === null ? null : Math.max(0, Number(myPosts || 0)))
: null
}
const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0
const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0)
// 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。
if (!computedHasData && cacheHasData && this.exportStatsCache) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
// 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0
if (!computedHasData && fallbackAttempted && fallbackError) {
return { success: false, error: fallbackError }
}
this.exportStatsCache = {
totalPosts: normalizedStats.totalPosts,
totalFriends: normalizedStats.totalFriends,
myPosts: normalizedStats.myPosts,
updatedAt: Date.now()
}
return { success: true, data: normalizedStats }
} catch (e) {
if (this.exportStatsCache) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
return { success: false, error: String(e) }
}
}
async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
return this.getExportStats({
allowTimelineFallback: false,
preferCache: true
})
}
// 安装朋友圈删除拦截
@@ -431,20 +936,6 @@ class SnsService {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (!result.success || !result.timeline || result.timeline.length === 0) return result
// 诊断:测试 execQuery 查 content 字段
try {
const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1')
if (testResult.success && testResult.rows?.[0]) {
const r = testResult.rows[0]
console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200))
console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList'))
} else {
console.log('[SnsService] execQuery 诊断失败:', testResult.error)
}
} catch (e) {
console.log('[SnsService] execQuery 诊断异常:', e)
}
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
@@ -577,14 +1068,44 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html'
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
shouldPause?: () => boolean
shouldStop?: () => boolean
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
const { outputDir, format, usernames, keyword, startTime, endTime } = options
const hasExplicitMediaSelection =
typeof options.exportImages === 'boolean' ||
typeof options.exportLivePhotos === 'boolean' ||
typeof options.exportVideos === 'boolean'
const shouldExportImages = hasExplicitMediaSelection
? options.exportImages === true
: options.exportMedia === true
const shouldExportLivePhotos = hasExplicitMediaSelection
? options.exportLivePhotos === true
: options.exportMedia === true
const shouldExportVideos = hasExplicitMediaSelection
? options.exportVideos === true
: options.exportMedia === true
const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos
const getControlState = (): 'paused' | 'stopped' | null => {
if (control?.shouldStop?.()) return 'stopped'
if (control?.shouldPause?.()) return 'paused'
return null
}
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
state === 'stopped'
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
: { success: true, paused: true, filePath: '', postCount, mediaCount }
)
try {
// 确保输出目录存在
@@ -601,6 +1122,10 @@ class SnsService {
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
while (hasMore) {
const controlState = getControlState()
if (controlState) {
return buildInterruptedResult(controlState, allPosts.length, 0)
}
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
if (result.success && result.timeline && result.timeline.length > 0) {
allPosts.push(...result.timeline)
@@ -628,15 +1153,54 @@ class SnsService {
let mediaCount = 0
const mediaDir = join(outputDir, 'media')
if (exportMedia) {
if (shouldExportMedia) {
if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true })
}
// 收集所有媒体下载任务
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
const mediaTasks: Array<{
kind: 'image' | 'video' | 'livephoto'
media: SnsMedia
url: string
key?: string
postId: string
mi: number
}> = []
for (const post of allPosts) {
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
post.media.forEach((media, mi) => {
const isVideo = isVideoUrl(media.url)
if (shouldExportImages && !isVideo && media.url) {
mediaTasks.push({
kind: 'image',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportVideos && isVideo && media.url) {
mediaTasks.push({
kind: 'video',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportLivePhotos && media.livePhoto?.url) {
mediaTasks.push({
kind: 'livephoto',
media,
url: media.livePhoto.url,
key: media.livePhoto.key || media.key,
postId: post.id,
mi
})
}
})
}
// 并发下载5路
@@ -645,29 +1209,42 @@ class SnsService {
const runTask = async (task: typeof mediaTasks[0]) => {
const { media, postId, mi } = task
try {
const isVideo = isVideoUrl(media.url)
const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url)
const ext = isVideo ? 'mp4' : 'jpg'
const fileName = `${postId}_${mi}.${ext}`
const suffix = task.kind === 'livephoto' ? '_live' : ''
const fileName = `${postId}_${mi}${suffix}.${ext}`
const filePath = join(mediaDir, fileName)
if (existsSync(filePath)) {
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
} else {
const result = await this.fetchAndDecryptImage(media.url, media.key)
const result = await this.fetchAndDecryptImage(task.url, task.key)
if (result.success && result.data) {
await writeFile(filePath, result.data)
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
} else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath)
await writeFile(filePath, cachedData)
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
}
}
} catch (e) {
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e)
}
done++
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
@@ -677,11 +1254,18 @@ class SnsService {
const queue = [...mediaTasks]
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const controlState = getControlState()
if (controlState) return controlState
const task = queue.shift()!
await runTask(task)
}
return null
})
await Promise.all(workers)
const workerResults = await Promise.all(workers)
const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped')
if (interruptedState) {
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
}
}
// 2.5 下载头像
@@ -693,6 +1277,8 @@ class SnsService {
const avatarQueue = [...uniqueUsers]
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
while (avatarQueue.length > 0) {
const controlState = getControlState()
if (controlState) return controlState
const post = avatarQueue.shift()!
try {
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
@@ -710,11 +1296,20 @@ class SnsService {
avatarDone++
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
}
return null
})
await Promise.all(avatarWorkers)
const avatarWorkerResults = await Promise.all(avatarWorkers)
const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped')
if (interruptedState) {
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
}
}
// 3. 生成输出文件
const finalControlState = getControlState()
if (finalControlState) {
return buildInterruptedResult(finalControlState, allPosts.length, mediaCount)
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
let outputFilePath: string
@@ -747,6 +1342,92 @@ class SnsService {
}))
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'arkmejson') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' })
const identityCache = new Map<string, Promise<SnsContactIdentity | null>>()
const posts: any[] = []
let built = 0
for (const post of allPosts) {
const controlState = getControlState()
if (controlState) {
return buildInterruptedResult(controlState, allPosts.length, mediaCount)
}
const authorIdentity = await this.resolveContactIdentity(post.username, identityCache)
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache)
posts.push({
id: post.id,
username: post.username,
nickname: post.nickname,
author: authorIdentity
? {
...authorIdentity
}
: {
username: post.username,
wxid: post.username,
displayName: post.nickname || post.username
},
createTime: post.createTime,
createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'),
contentDesc: post.contentDesc,
type: post.type,
media: post.media.map(m => ({
url: m.url,
thumb: m.thumb,
localPath: (m as any).localPath || undefined,
livePhoto: m.livePhoto ? {
url: m.livePhoto.url,
thumb: m.livePhoto.thumb,
localPath: (m.livePhoto as any).localPath || undefined
} : undefined
})),
likes: post.likes,
comments: post.comments,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
linkUrl: (post as any).linkUrl
})
built++
if (built % 20 === 0 || built === allPosts.length) {
progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` })
}
}
const ownerWxid = this.toOptionalString(this.configService.get('myWxid'))
const ownerIdentity = ownerWxid
? await this.resolveContactIdentity(ownerWxid, identityCache)
: null
const recordOwner = ownerIdentity
? { ...ownerIdentity }
: ownerWxid
? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid }
: { username: '', wxid: '', displayName: '' }
const exportData = {
exportTime: new Date().toISOString(),
format: 'arkmejson',
schemaVersion: '1.0.0',
recordOwner,
mediaSelection: {
images: shouldExportImages,
livePhotos: shouldExportLivePhotos,
videos: shouldExportVideos
},
totalPosts: allPosts.length,
filters: {
usernames: usernames || [],
keyword: keyword || ''
},
posts
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename } from 'path'
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
@@ -114,6 +114,9 @@ export class WcdbCore {
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private wcdbGetMonitorPipeName: any = null
private wcdbCloudInit: any = null
private wcdbCloudReport: any = null
private wcdbCloudStop: any = null
private monitorPipeClient: any = null
private monitorCallback: ((type: string, json: string) => void) | null = null
@@ -702,6 +705,26 @@ export class WcdbCore {
this.wcdbVerifyUser = null
}
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
try {
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
} catch {
this.wcdbCloudInit = null
}
// wcdb_status wcdb_cloud_report(const char* stats_json)
try {
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
} catch {
this.wcdbCloudReport = null
}
// void wcdb_cloud_stop()
try {
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
} catch {
this.wcdbCloudStop = null
}
// 初始化
@@ -1144,6 +1167,40 @@ export class WcdbCore {
}
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
const normalizedSessionIds = Array.from(
new Set(
(sessionIds || [])
.map((id) => String(id || '').trim())
.filter(Boolean)
)
)
if (normalizedSessionIds.length === 0) {
return { success: true, counts: {} }
}
try {
const counts: Record<string, number> = {}
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
const sessionId = normalizedSessionIds[i]
const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
if (i > 0 && i % 160 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
return { success: true, counts }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1841,8 +1898,57 @@ export class WcdbCore {
}
/**
* 验证 Windows Hello
* 数据收集初始化
*/
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudInit) {
return { success: false, error: 'Cloud init API not supported by DLL' }
}
try {
const result = this.wcdbCloudInit(intervalSeconds)
if (result !== 0) {
return { success: false, error: `Cloud init failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudReport) {
return { success: false, error: 'Cloud report API not supported by DLL' }
}
try {
const result = this.wcdbCloudReport(statsJson || '')
if (result !== 0) {
return { success: false, error: `Cloud report failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
cloudStop(): { success: boolean; error?: string } {
if (!this.wcdbCloudStop) {
return { success: false, error: 'Cloud stop API not supported by DLL' }
}
try {
this.wcdbCloudStop()
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
@@ -2093,4 +2199,3 @@ export class WcdbCore {
})
}
}

View File

@@ -218,6 +218,10 @@ export class WcdbService {
return this.callWorker('getMessageCount', { sessionId })
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getMessageCounts', { sessionIds })
}
/**
* 获取联系人昵称
*/
@@ -479,6 +483,27 @@ export class WcdbService {
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
}
/**
* 数据收集:初始化
*/
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudInit', { intervalSeconds })
}
/**
* 数据收集:上报数据
*/
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudReport', { statsJson })
}
/**
* 数据收集:停止
*/
cloudStop(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudStop', {})
}
}

View File

@@ -54,6 +54,9 @@ if (parentPort) {
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -171,7 +174,15 @@ if (parentPort) {
case 'deleteMessage':
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
break
case 'cloudInit':
result = await core.cloudInit(payload.intervalSeconds)
break
case 'cloudReport':
result = await core.cloudReport(payload.statsJson)
break
case 'cloudStop':
result = core.cloudStop()
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}

View File

@@ -13,6 +13,7 @@
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild",
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",

Binary file not shown.

View File

@@ -69,6 +69,19 @@
flex: 1;
overflow: auto;
padding: 24px;
position: relative;
}
.export-keepalive-page {
height: 100%;
&.hidden {
display: none;
}
}
.export-route-anchor {
display: none;
}
@keyframes appFadeIn {

View File

@@ -26,6 +26,7 @@ import NotificationWindow from './pages/NotificationWindow'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
import * as configService from './services/config'
import * as cloudControl from './services/cloudControl'
import { Download, X, Shield } from 'lucide-react'
import './App.scss'
@@ -60,7 +61,9 @@ function App() {
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isExportRoute = location.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
@@ -75,6 +78,9 @@ function App() {
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
const root = document.documentElement
const body = document.body
@@ -170,6 +176,12 @@ function App() {
const agreed = await configService.getAgreementAccepted()
if (!agreed) {
setShowAgreement(true)
} else {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
if (consent === null) {
setShowAnalyticsConsent(true)
}
}
} catch (e) {
console.error('检查协议状态失败:', e)
@@ -180,16 +192,44 @@ function App() {
checkAgreement()
}, [])
// 初始化数据收集
useEffect(() => {
cloudControl.initCloudControl()
}, [])
// 记录页面访问
useEffect(() => {
const path = location.pathname
if (path && path !== '/') {
cloudControl.recordPage(path)
}
}, [location.pathname])
const handleAgree = async () => {
if (!agreementChecked) return
await configService.setAgreementAccepted(true)
setShowAgreement(false)
// 协议同意后,检查数据收集同意
const consent = await configService.getAnalyticsConsent()
if (consent === null) {
setShowAnalyticsConsent(true)
}
}
const handleDisagree = () => {
window.electronAPI.window.close()
}
const handleAnalyticsAllow = async () => {
await configService.setAnalyticsConsent(true)
setShowAnalyticsConsent(false)
}
const handleAnalyticsDeny = async () => {
await configService.setAnalyticsConsent(false)
window.electronAPI.window.close()
}
// 监听启动时的更新通知
useEffect(() => {
if (isNotificationWindow) return // Skip updates in notification window
@@ -360,6 +400,12 @@ function App() {
return <ChatHistoryPage />
}
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
@@ -439,6 +485,42 @@ function App() {
</div>
)}
{/* 数据收集同意弹窗 */}
{showAnalyticsConsent && !agreementLoading && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2>使</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p> WeFlow 使</p>
<h4></h4>
<p> 使使使</p>
<p> </p>
<p> </p>
<h4></h4>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}></button>
<button className="btn btn-primary" onClick={handleAnalyticsAllow}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}
@@ -454,6 +536,10 @@ function App() {
<Sidebar />
<main className="content">
<RouteGuard>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
@@ -468,7 +554,7 @@ function App() {
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />

View File

@@ -198,11 +198,12 @@ export function GlobalSessionMonitor() {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickname) {
title = contact.remark || contact.nickname
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
}
if (contact.avatarUrl) {
avatarUrl = contact.avatarUrl
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
} else {
// 如果不在缓存/数据库中
@@ -222,8 +223,11 @@ export function GlobalSessionMonitor() {
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickname || title
avatarUrl = retried.avatarUrl || avatarUrl
title = retried.remark || retried.nickName || title
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
}
}
}
}

View File

@@ -0,0 +1,166 @@
.jump-date-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 312px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
opacity: 1;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
mix-blend-mode: normal;
isolation: isolate;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
padding: 12px;
z-index: 1600;
}
.jump-date-popover .calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.jump-date-popover .current-month {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.jump-date-popover .nav-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .nav-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .status-line {
min-height: 16px;
margin-bottom: 6px;
}
.jump-date-popover .status-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: 11px;
}
.jump-date-popover .calendar-grid .weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 6px;
}
.jump-date-popover .calendar-grid .weekday {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
}
.jump-date-popover .calendar-grid .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
}
.jump-date-popover .day-cell {
position: relative;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
padding: 0;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .day-cell .day-number {
position: relative;
z-index: 1;
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.jump-date-popover .day-cell.empty {
cursor: default;
background: transparent;
}
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
.jump-date-popover .day-cell.today {
border-color: var(--primary-light);
color: var(--primary);
}
.jump-date-popover .day-cell.selected {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell.no-message {
opacity: 0.5;
cursor: default;
}
.jump-date-popover .day-count {
position: static;
margin-top: 1px;
font-size: 13px;
line-height: 1;
color: #16a34a;
font-weight: 700;
}
.jump-date-popover .day-cell.selected .day-count {
color: #86efac;
}
.jump-date-popover .day-count-loading {
position: static;
margin-top: 1px;
color: #22c55e;
}
.jump-date-popover .spin {
animation: jump-date-spin 1s linear infinite;
}
@keyframes jump-date-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import './JumpToDatePopover.scss'
interface JumpToDatePopoverProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
className?: string
style?: React.CSSProperties
currentDate?: Date
messageDates?: Set<string>
hasLoadedMessageDates?: boolean
messageDateCounts?: Record<string, number>
loadingDates?: boolean
loadingDateCounts?: boolean
}
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen,
onClose,
onSelect,
className,
style,
currentDate = new Date(),
messageDates,
hasLoadedMessageDates = false,
messageDateCounts,
loadingDates = false,
loadingDateCounts = false
}) => {
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
useEffect(() => {
if (!isOpen) return
const normalized = new Date(currentDate)
setCalendarDate(normalized)
setSelectedDate(normalized)
}, [isOpen, currentDate])
if (!isOpen) return null
const getDaysInMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const toDateKey = (day: number): string => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
const hasMessage = (day: number): boolean => {
if (!hasLoadedMessageDates) return true
if (!messageDates || messageDates.size === 0) return false
return messageDates.has(toDateKey(day))
}
const isToday = (day: number): boolean => {
const today = new Date()
return day === today.getDate()
&& calendarDate.getMonth() === today.getMonth()
&& calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number): boolean => {
return day === selectedDate.getDate()
&& calendarDate.getMonth() === selectedDate.getMonth()
&& calendarDate.getFullYear() === selectedDate.getFullYear()
}
const generateCalendar = (): Array<number | null> => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: Array<number | null> = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateClick = (day: number) => {
if (hasLoadedMessageDates && !hasMessage(day)) return
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(targetDate)
onSelect(targetDate)
onClose()
}
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isToday(day)) classes.push('today')
if (isSelected(day)) classes.push('selected')
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
aria-label="下一月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="status-line">
{loadingDates && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
{!loadingDates && loadingDateCounts && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
</div>
<div className="calendar-grid">
<div className="weekdays">
{weekdays.map(day => (
<div key={day} className="weekday">{day}</div>
))}
</div>
<div className="days">
{days.map((day, index) => {
if (day === null) return <div key={index} className="day-cell empty" />
const dateKey = toDateKey(day)
const hasMessageOnDay = hasMessage(day)
const count = Number(messageDateCounts?.[dateKey] || 0)
const showCount = count > 0
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
return (
<button
key={index}
className={getDayClassName(day)}
onClick={() => handleDateClick(day)}
disabled={hasLoadedMessageDates && !hasMessageOnDay}
type="button"
>
<span className="day-number">{day}</span>
{showCount && <span className="day-count">{count}</span>}
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
</button>
)
})}
</div>
</div>
</div>
)
}
export default JumpToDatePopover

View File

@@ -10,6 +10,19 @@
&.collapsed {
width: 64px;
.sidebar-user-card-wrap {
margin: 0 8px 8px;
}
.sidebar-user-card {
padding: 8px 0;
justify-content: center;
.user-meta {
display: none;
}
}
.nav-menu,
.sidebar-footer {
padding: 0 8px;
@@ -27,6 +40,119 @@
}
}
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
}
.sidebar-user-clear-trigger {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 8px);
z-index: 12;
border: 1px solid rgba(255, 59, 48, 0.28);
border-radius: 10px;
background: var(--bg-secondary);
color: #d93025;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover {
background: rgba(255, 59, 48, 0.08);
border-color: rgba(255, 59, 48, 0.46);
}
}
.sidebar-user-card {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.menu-open {
border-color: rgba(99, 102, 241, 0.44);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
flex: 1;
}
.user-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 2px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-caret {
color: var(--text-tertiary);
display: inline-flex;
transition: transform 0.2s ease, color 0.2s ease;
&.open {
transform: rotate(180deg);
color: var(--text-secondary);
}
}
}
.nav-menu {
flex: 1;
display: flex;
@@ -70,11 +196,44 @@
flex-shrink: 0;
}
.nav-icon-with-badge {
position: relative;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 999px;
padding: 0 6px;
background: #ff3b30;
color: #ffffff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
}
.nav-badge.icon-badge {
position: absolute;
top: -7px;
right: -10px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--bg-secondary);
}
.sidebar-footer {
padding: 0 12px;
border-top: 1px solid var(--border-color);
@@ -105,6 +264,82 @@
}
}
.sidebar-clear-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 20px;
}
.sidebar-clear-dialog {
width: min(460px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
.sidebar-clear-options {
margin-top: 14px;
display: flex;
gap: 14px;
flex-wrap: wrap;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
}
}
.sidebar-clear-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.danger {
border-color: #ef4444;
background: #ef4444;
color: #fff;
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
[data-theme="blossom-dream"] .sidebar {
background: rgba(255, 255, 255, 0.6);
@@ -130,4 +365,4 @@
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}
}

View File

@@ -1,23 +1,325 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
import './Sidebar.scss'
interface SidebarUserProfile {
wxid: string
displayName: string
avatarUrl?: string
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as SidebarUserProfileCache
if (!parsed || typeof parsed !== 'object') return null
if (!parsed.wxid || !parsed.displayName) return null
return {
wxid: parsed.wxid,
displayName: parsed.displayName,
avatarUrl: parsed.avatarUrl
}
} catch {
return null
}
}
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
if (!profile.wxid || !profile.displayName) return
try {
const payload: SidebarUserProfileCache = {
...profile,
updatedAt: Date.now()
}
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
} catch {
// 忽略本地缓存失败,不影响主流程
}
}
const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
function Sidebar() {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
const [shouldClearExportData, setShouldClearExportData] = useState(false)
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
}, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!isAccountMenuOpen) return
const target = event.target as Node | null
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
setIsAccountMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isAccountMenuOpen])
useEffect(() => {
const unsubscribe = onExportSessionStatus((payload) => {
const countFromPayload = typeof payload?.activeTaskCount === 'number'
? payload.activeTaskCount
: Array.isArray(payload?.inProgressSessionIds)
? payload.inProgressSessionIds.length
: 0
const normalized = Math.max(0, Math.floor(countFromPayload))
setActiveExportTaskCount(normalized)
})
requestExportSessionStatus()
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
return () => {
unsubscribe()
window.clearTimeout(timer)
}
}, [])
useEffect(() => {
const loadCurrentUser = async () => {
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
setUserProfile(prev => {
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
return prev
}
const next: SidebarUserProfile = {
...prev,
...patch
}
if (!next.displayName) {
next.displayName = next.wxid || '未识别用户'
}
writeSidebarUserProfileCache(next)
return next
})
}
try {
const wxid = await configService.getMyWxid()
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
resolvedWxid.trim().toLowerCase(),
cleanedWxid.trim().toLowerCase()
].filter(Boolean))
const normalizeName = (value?: string | null): string | undefined => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const lowered = trimmed.toLowerCase()
if (lowered === 'self') return undefined
if (lowered.startsWith('wxid_')) return undefined
if (wxidCandidates.has(lowered)) return undefined
return trimmed
}
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
for (const candidate of candidates) {
const normalized = normalizeName(candidate)
if (normalized) return normalized
}
return undefined
}
const fallbackDisplayName = resolvedWxid || '未识别用户'
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
patchUserProfile({
wxid: resolvedWxid,
displayName: fallbackDisplayName
})
if (!resolvedWxidRaw && !resolvedWxid) return
// 第二阶段:后台补齐名称(不会阻塞首屏)。
void (async () => {
try {
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (!contact) continue
if (!myContact) myContact = contact
if (contact.remark || contact.nickName || contact.alias) {
myContact = contact
break
}
}
const fromContact = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
)
if (fromContact) {
patchUserProfile({ displayName: fromContact }, resolvedWxid)
return
}
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
const enrichedDisplayName = pickFirstValidName(
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
enrichedResult.contacts?.[resolvedWxid]?.displayName,
enrichedResult.contacts?.[cleanedWxid]?.displayName,
enrichedResult.contacts?.self?.displayName,
myContact?.alias
)
const bestName = enrichedDisplayName
if (bestName) {
patchUserProfile({ displayName: bestName }, resolvedWxid)
}
} catch (nameError) {
console.error('加载侧边栏用户昵称失败:', nameError)
}
})()
// 第二阶段:后台补齐头像(不会阻塞首屏)。
void (async () => {
try {
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
}
} catch (avatarError) {
console.error('加载侧边栏用户头像失败:', avatarError)
}
})()
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
}
const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) {
setUserProfile(prev => ({
...prev,
...cachedProfile
}))
}
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
}, [])
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
const canConfirmClear = shouldClearCacheData || shouldClearExportData
const resetClearDialogState = () => {
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(false)
}
const openClearAccountDialog = () => {
setIsAccountMenuOpen(false)
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(true)
}
const handleConfirmClearAccountData = async () => {
if (!canConfirmClear || isClearingAccountData) return
setIsClearingAccountData(true)
try {
const result = await window.electronAPI.chat.clearCurrentAccountData({
clearCache: shouldClearCacheData,
clearExports: shouldClearExportData
})
if (!result.success) {
window.alert(result.error || '清理失败,请稍后重试。')
return
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
setUserProfile({ wxid: '', displayName: '未识别用户' })
window.dispatchEvent(new Event('wxid-changed'))
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
const selectedScopes = [
shouldClearCacheData ? '缓存数据' : '',
shouldClearExportData ? '导出数据' : ''
].filter(Boolean)
const detailLines: string[] = [
`清理范围:${selectedScopes.join('、') || '未选择'}`,
`已清理项目:${removedPaths.length}`
]
if (removedPaths.length > 0) {
detailLines.push('', '清理明细(最多显示 8 项):')
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
detailLines.push(`${index + 1}. ${path}`)
}
if (removedPaths.length > 8) {
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
}
}
if (result.warning) {
detailLines.push('', `注意:${result.warning}`)
}
const followupHint = shouldClearCacheData
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
: '你可以继续使用当前登录状态,无需重新登录。'
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
resetClearDialogState()
if (shouldClearCacheData) {
window.location.reload()
}
} catch (error) {
console.error('清理账号数据失败:', error)
window.alert('清理失败,请稍后重试。')
} finally {
setIsClearingAccountData(false)
}
}
return (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
@@ -98,14 +400,61 @@ function Sidebar() {
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon"><Download size={20} /></span>
<span className="nav-icon nav-icon-with-badge">
<Download size={20} />
{collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
)}
</span>
<span className="nav-label"></span>
{!collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge">{exportTaskBadge}</span>
)}
</NavLink>
</nav>
<div className="sidebar-footer">
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && (
<button
className="sidebar-user-clear-trigger"
onClick={openClearAccountDialog}
type="button"
>
<Trash2 size={14} />
<span></span>
</button>
)}
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${userProfile.wxid ? `\n${userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.wxid || 'wxid 未识别'}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
{authEnabled && (
<button
className="nav-item"
@@ -136,6 +485,49 @@ function Sidebar() {
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{showClearAccountDialog && (
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p>
weflow
weflow
</p>
<div className="sidebar-clear-options">
<label>
<input
type="checkbox"
checked={shouldClearCacheData}
onChange={(event) => setShouldClearCacheData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
<label>
<input
type="checkbox"
checked={shouldClearExportData}
onChange={(event) => setShouldClearExportData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
</div>
<div className="sidebar-clear-actions">
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}></button>
<button
type="button"
className="danger"
disabled={!canConfirmClear || isClearingAccountData}
onClick={handleConfirmClearAccountData}
>
{isClearingAccountData ? '清除中...' : '确认清除'}
</button>
</div>
</div>
</div>
)}
</aside>
)
}

View File

@@ -57,6 +57,16 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
setJumpTargetDate(undefined)
}
const getEmptyStateText = () => {
if (loading && contacts.length === 0) {
return '正在加载联系人...'
}
if (contacts.length === 0) {
return '暂无好友或曾经的好友'
}
return '没有找到联系人'
}
return (
<aside className="sns-filter-panel">
<div className="filter-header">
@@ -143,18 +153,22 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => (
{filteredContacts.map(contact => {
return (
<div
key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
</div>
))}
)
})}
{filteredContacts.length === 0 && (
<div className="empty-state"></div>
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>
</div>

View File

@@ -26,6 +26,48 @@
margin: 0 0 48px;
}
.page-desc.load-summary {
margin: 0 0 28px;
}
.page-desc.load-summary.complete {
color: var(--text-secondary);
}
.load-telemetry {
width: min(760px, 100%);
padding: 12px 14px;
margin: 0 0 28px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
text-align: left;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
p {
margin: 4px 0;
}
.label {
color: var(--text-tertiary);
}
}
.load-telemetry.loading {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.load-telemetry.complete {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
}
.load-telemetry.compact {
margin: 12px 0 0;
width: min(560px, 100%);
}
.report-sections {
display: flex;
flex-direction: column;
@@ -83,6 +125,14 @@
color: var(--text-tertiary);
}
.year-grid-with-status {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.year-grid {
display: flex;
flex-wrap: wrap;
@@ -95,7 +145,39 @@
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
margin-bottom: 24px;
margin-bottom: 0;
}
.year-grid-with-status .year-grid {
flex: 1;
}
.year-load-status {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
margin-top: 6px;
flex-shrink: 0;
}
.year-load-status.complete {
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
}
.dot-ellipsis {
display: inline-block;
width: 0;
overflow: hidden;
vertical-align: bottom;
animation: dot-ellipsis 1.2s steps(4, end) infinite;
}
.year-load-status.complete .dot-ellipsis,
.page-desc.load-summary.complete .dot-ellipsis {
animation: none;
width: 0;
}
.year-card {
@@ -185,3 +267,7 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dot-ellipsis {
to { width: 1.4em; }
}

View File

@@ -4,6 +4,28 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
type YearsLoadPayload = {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
function AnnualReportPage() {
const navigate = useNavigate()
@@ -11,32 +33,117 @@ function AnnualReportPage() {
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
loadAvailableYears()
}, [])
let disposed = false
let taskId = ''
const loadAvailableYears = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
}
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
}
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
}
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
const years = Array.isArray(payload.years) ? payload.years : []
if (years.length > 0) {
setAvailableYears(years)
setSelectedYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setSelectedPairYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setIsLoading(false)
}
if (payload.error && !payload.canceled) {
setLoadError(payload.error || '加载年度数据失败')
}
if (payload.done) {
setIsLoading(false)
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
})
const startLoad = async () => {
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
setLoadStrategy('native')
setLoadPhase('native')
setLoadStatusText('准备使用原生快速模式加载年份...')
setNativeElapsedMs(0)
setScanElapsedMs(0)
setTotalElapsedMs(0)
setHasSwitchedStrategy(false)
setNativeTimedOut(false)
setLoadError(null)
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
return
}
taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
}
}
void startLoad()
return () => {
disposed = true
stopListen()
}
}, [])
const handleGenerateReport = async () => {
if (selectedYear === null) return
@@ -57,16 +164,25 @@ function AnnualReportPage() {
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
if (isLoading && availableYears.length === 0) {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<div className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</div>
)
}
if (availableYears.length === 0) {
if (availableYears.length === 0 && !isLoadingMoreYears) {
return (
<div className="annual-report-page">
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
@@ -87,11 +203,50 @@ function AnnualReportPage() {
return value === 'all' ? '全部时间' : `${value}`
}
const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<></>
) : (
<>
<span className="dot-ellipsis" aria-hidden="true">...</span>
</>
)}
</div>
)
return (
<div className="annual-report-page">
<Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1>
<p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections">
<section className="report-section">
@@ -102,17 +257,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -146,17 +304,20 @@ function AnnualReportPage() {
</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 className="year-grid-with-status">
<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>
{renderYearLoadStatus()}
</div>
<button
@@ -174,4 +335,23 @@ function AnnualReportPage() {
)
}
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage

View File

@@ -490,6 +490,18 @@
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn {
width: 34px;
height: 34px;
@@ -534,6 +546,22 @@
overflow: hidden;
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
-webkit-app-region: no-drag;
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
flex: 1;
background: var(--chat-pattern);
@@ -815,6 +843,24 @@
min-width: 0;
}
.session-sync-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
border: 1px solid var(--border-color);
flex-shrink: 0;
.spin {
animation: spin 0.9s linear infinite;
}
}
.search-box {
flex: 1;
display: flex;
@@ -1592,6 +1638,13 @@
display: flex;
align-items: center;
gap: 8px;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
}
}
.icon-btn {
@@ -1624,6 +1677,10 @@
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
}
@@ -1651,6 +1708,33 @@
opacity: 0;
pointer-events: none;
}
&.switching .message-list {
opacity: 0.42;
transform: scale(0.995);
filter: saturate(0.72) blur(1px);
pointer-events: none;
}
&.switching .loading-overlay {
background: rgba(127, 127, 127, 0.18);
backdrop-filter: blur(4px);
}
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
@@ -1666,7 +1750,7 @@
background-color: var(--bg-tertiary);
position: relative;
-webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease;
// 滚动条样式
&::-webkit-scrollbar {
@@ -2662,6 +2746,13 @@
opacity: 0.7;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -2699,6 +2790,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;
@@ -2736,6 +2847,14 @@
gap: 8px;
}
.detail-table-placeholder {
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.table-item {
display: flex;
align-items: center;
@@ -2757,6 +2876,188 @@
}
}
.group-members-panel {
.group-members-toolbar {
padding: 12px 16px 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.group-members-count {
font-size: 12px;
color: var(--text-secondary);
}
.group-members-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.group-members-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.spin {
animation: spin 1s linear infinite;
}
&.warning {
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
}
.group-members-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
}
.group-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
}
.group-member-main {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.group-member-avatar {
flex-shrink: 0;
}
.group-member-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-member-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.group-member-name {
font-size: 13px;
color: var(--text-primary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-id {
font-size: 11px;
color: var(--text-tertiary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-badges {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.member-flag {
width: 18px;
height: 18px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
&.owner {
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 16%, transparent);
border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color));
}
&.friend {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
}
}
.group-member-count {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
&.loading {
color: var(--text-tertiary);
font-weight: 500;
}
&.failed {
color: #b45309;
font-weight: 600;
}
}
}
@keyframes slideInRight {
from {
opacity: 0;
@@ -4133,7 +4434,6 @@
font-weight: 500;
}
}
// 消息信息弹窗
.message-info-overlay {
position: fixed;
@@ -4298,4 +4598,4 @@
user-select: text;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {
@@ -177,6 +188,22 @@
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
.contacts-cache-meta {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
&.syncing {
color: var(--primary);
}
}
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
}
.selection-toolbar {
@@ -213,10 +240,103 @@
}
}
.load-issue-state {
flex: 1;
padding: 14px 14px 18px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar {
width: 6px;
@@ -229,15 +349,31 @@
}
}
.contacts-list-virtual {
position: relative;
min-height: 100%;
}
.contact-row {
position: absolute;
left: 0;
right: 0;
height: 76px;
padding-bottom: 4px;
will-change: transform;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px;
margin-bottom: 0;
&:hover {
background: var(--bg-hover);

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config'
import './ContactsPage.scss'
interface ContactInfo {
@@ -13,12 +15,43 @@ interface ContactInfo {
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo {
displayName?: string
avatarUrl?: string
}
const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
type ContactsDataSource = 'cache' | 'network' | null
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
@@ -39,79 +72,495 @@ function ContactsPage() {
const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const loadVersionRef = useRef(0)
const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
})
const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const loadAttemptRef = useRef(0)
const loadTimeoutTimerRef = useRef<number | null>(null)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsCacheScopeRef = useRef('default')
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsResult.success && contactsResult.contacts) {
const ensureContactsCacheScope = useCallback(async () => {
if (contactsCacheScopeRef.current !== 'default') {
return contactsCacheScopeRef.current
}
const [dbPath, myWxid] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
const avatarCache = contactsAvatarCacheRef.current
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}, [])
const upsertAvatarCacheFromContacts = useCallback((
scopeKey: string,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[] }
) => {
if (!scopeKey) return
const nextCache = { ...contactsAvatarCacheRef.current }
const now = Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
if (!changed) return
contactsAvatarCacheRef.current = nextCache
setAvatarCacheUpdatedAt(now)
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
console.error('写入通讯录头像缓存失败:', error)
})
}, [])
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContacts(prev => {
let changed = false
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
displayName,
avatarUrl
}
})
return changed ? next : prev
})
setSelectedContact(prev => {
if (!prev) return prev
const enriched = enrichedMap[prev.username]
if (!enriched) return prev
const displayName = enriched.displayName || prev.displayName
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
return prev
}
return {
...prev,
displayName,
avatarUrl
}
})
}, [])
const enrichContactsInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
})
const total = usernames.length
setAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
})
if (total === 0) return
for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) {
if (loadVersionRef.current !== loadVersion) return
const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
if (loadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
applyEnrichedContacts(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
markCheckedUsernames: batch
})
} catch (e) {
console.error('分批补全头像失败:', e)
}
const loaded = Math.min(i + batch.length, total)
setAvatarEnrichProgress({
loaded,
total,
running: loaded < total
})
await new Promise(resolve => setTimeout(resolve, 0))
}
}, [applyEnrichedContacts, upsertAvatarCacheFromContacts])
// 加载通讯录
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
const loadVersion = loadVersionRef.current + 1
loadVersionRef.current = loadVersion
loadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
setLoadSession({
requestId,
startedAt,
attempt: loadAttemptRef.current,
timeoutMs
})
setLoadIssue(null)
setShowDiagnostics(false)
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (loadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'timeout',
title: '通讯录加载超时',
message: `等待超过 ${timeoutMs}ms联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
loadTimeoutTimerRef.current = timeoutTimerId
setIsLoading(true)
setAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
setContacts(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setSelectedUsernames(new Set())
setSelectedContact(prev => {
if (!prev) return prev
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
})
const now = Date.now()
setContactsDataSource('network')
setContactsUpdatedAt(now)
setLoadIssue(null)
setIsLoading(false)
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
})
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
return
}
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (e) {
console.error('加载通讯录失败:', e)
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(e),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
if (loadVersionRef.current === loadVersion) {
setIsLoading(false)
}
}
}, [
ensureContactsCacheScope,
enrichContactsInBackground,
mergeAvatarCacheIntoContacts,
syncContactTypeCounts,
upsertAvatarCacheFromContacts
])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
let cancelled = false
void (async () => {
const scopeKey = await ensureContactsCacheScope()
if (cancelled) return
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContacts(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsLoading(false)
}
} catch (error) {
console.error('读取通讯录缓存失败:', error)
}
if (!cancelled) {
void loadContacts({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
useEffect(() => {
return () => {
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
loadVersionRef.current += 1
}
}, [])
useEffect(() => {
if (!loadIssue || contacts.length > 0) return
if (!(isLoading && loadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contacts.length, isLoading, loadIssue])
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchKeyword])
const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
if (debouncedSearchKeyword) {
filtered = filtered.filter(contact =>
contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.username.toLowerCase().includes(debouncedSearchKeyword)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
return filtered
}, [contacts, contactTypes, debouncedSearchKeyword])
// 点击外部关闭下拉菜单
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => {
if (!listRef.current) return
listRef.current.scrollTop = 0
setScrollTop(0)
}, [debouncedSearchKeyword, contactTypes])
useEffect(() => {
const node = listRef.current
if (!node) return
const updateViewportHeight = () => {
setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT))
}
updateViewportHeight()
const observer = new ResizeObserver(() => updateViewportHeight())
observer.observe(node)
return () => observer.disconnect()
}, [filteredContacts.length, isLoading])
useEffect(() => {
const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight)
if (scrollTop <= maxScroll) return
setScrollTop(maxScroll)
if (listRef.current) {
listRef.current.scrollTop = maxScroll
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
// 搜索和类型过滤
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
@@ -123,11 +572,85 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
const selectedInFilteredCount = useMemo(() => {
return filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
}
const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT)
const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT)
const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN)
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2)
return {
startIndex: nextStart,
endIndex: nextEnd
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
const visibleContacts = useMemo(() => {
return filteredContacts.slice(startIndex, endIndex)
}, [filteredContacts, startIndex, endIndex])
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop)
}, [])
const issueElapsedMs = useMemo(() => {
if (!loadIssue) return 0
if (isLoading && loadSession) {
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
}
return loadIssue.elapsedMs
}, [diagnosticTick, isLoading, loadIssue, loadSession])
const diagnosticsText = useMemo(() => {
if (!loadIssue || !loadSession) return ''
return [
`请求ID: ${loadSession.requestId}`,
`请求序号: 第 ${loadSession.attempt}`,
`阈值配置: ${loadSession.timeoutMs}ms`,
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
`阶段: chat.getContacts`,
`原因: ${loadIssue.reason}`,
`错误详情: ${loadIssue.errorDetail || '无'}`
].join('\n')
}, [issueElapsedMs, loadIssue, loadSession])
const copyDiagnostics = useCallback(async () => {
if (!diagnosticsText) return
try {
await navigator.clipboard.writeText(diagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [diagnosticsText])
const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString()
}, [contactsUpdatedAt])
const avatarCachedCount = useMemo(() => {
return contacts.reduce((count, contact) => (
contact.avatarUrl ? count + 1 : count
), 0)
}, [contacts])
const avatarCacheUpdatedAtLabel = useMemo(() => {
if (!avatarCacheUpdatedAt) return ''
return new Date(avatarCacheUpdatedAt).toLocaleString()
}, [avatarCacheUpdatedAt])
const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
@@ -256,7 +779,7 @@ function ContactsPage() {
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
@@ -280,24 +803,51 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
<User size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
<Users size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
<MessageSquare size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
<UserX size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
{filteredContacts.length} / {contacts.length}
{contactsUpdatedAt && (
<span className="contacts-cache-meta">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · {contactsUpdatedAtLabel}
</span>
)}
{contacts.length > 0 && (
<span className="contacts-cache-meta">
{avatarCachedCount}/{contacts.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{isLoading && contacts.length > 0 && (
<span className="contacts-cache-meta syncing">...</span>
)}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
{avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div>
{exportMode && (
@@ -315,61 +865,105 @@ function ContactsPage() {
</div>
)}
{isLoading ? (
{contacts.length === 0 && loadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<AlertTriangle size={18} />
<span>{loadIssue.title}</span>
</div>
<p className="issue-message">{loadIssue.message}</p>
<p className="issue-reason">{loadIssue.reason}</p>
<ul className="issue-hints">
<li>1</li>
<li>2contact.db </li>
<li>3 IPC </li>
</ul>
<div className="issue-actions">
<button className="issue-btn primary" onClick={() => void loadContacts()}>
<RefreshCw size={14} />
<span></span>
</button>
<button className="issue-btn" onClick={() => setShowDiagnostics(prev => !prev)}>
<ClipboardList size={14} />
<span>{showDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
</button>
<button className="issue-btn" onClick={copyDiagnostics}>
<span></span>
</button>
</div>
{showDiagnostics && (
<pre className="issue-diagnostics">{diagnosticsText}</pre>
)}
</div>
</div>
) : isLoading && contacts.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => {
<div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
<div
className="contacts-list-virtual"
style={{ height: filteredContacts.length * VIRTUAL_ROW_HEIGHT }}
>
{visibleContacts.map((contact, idx) => {
const absoluteIndex = startIndex + idx
const top = absoluteIndex * VIRTUAL_ROW_HEIGHT
const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return (
<div
key={contact.username}
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
className="contact-row"
style={{ transform: `translateY(${top}px)` }}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
<div
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
</div>
)
})}
})}
</div>
</div>
)}
</div>

View File

@@ -107,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },

View File

@@ -36,6 +36,18 @@ interface WxidOption {
modifiedTime: number
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function SettingsPage() {
const {
isDbConnected,
@@ -103,12 +115,12 @@ function SettingsPage() {
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultFormat, setExportDefaultFormat] = useState('json')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(4)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
@@ -286,7 +298,6 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
@@ -327,12 +338,13 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultFormat('json')
await configService.setExportDefaultFormat('json')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 4)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
@@ -725,7 +737,10 @@ function SettingsPage() {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
showMessage(result.error || '自动获取密钥失败', false)
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
showMessage(formatDbKeyFailureMessage(result.error, result.logs), false)
}
}
} catch (e: any) {
@@ -1546,6 +1561,7 @@ function SettingsPage() {
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },

View File

@@ -23,7 +23,7 @@
========================================= */
.sns-main-viewport {
flex: 1;
overflow-y: scroll;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
@@ -35,7 +35,9 @@
padding: 20px 24px 60px 24px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
min-height: 0;
height: 100%;
}
.feed-header {
@@ -44,12 +46,50 @@
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
z-index: 2;
background: var(--sns-bg-color);
border-bottom: 1px solid var(--border-color);
padding-top: 10px;
padding-bottom: 10px;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
.feed-header-main {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
}
.feed-stats-line {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
&.loading {
opacity: 0.7;
}
&.error {
color: #d94f45;
}
}
.feed-stats-retry {
border: none;
background: transparent;
color: inherit;
font-size: 13px;
padding: 0;
line-height: 1.4;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.header-actions {
@@ -85,6 +125,13 @@
}
}
.sns-posts-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 16px;
}
.posts-list {
display: flex;
flex-direction: column;
@@ -1031,9 +1078,11 @@
margin-bottom: 0;
/* Remove margin to merge */
.contact-name {
color: var(--primary);
font-weight: 600;
.contact-meta {
.contact-name {
color: var(--primary);
font-weight: 600;
}
}
/* If the NEXT item is also selected */
@@ -1056,13 +1105,20 @@
/* Compensate for missing border */
}
.contact-name {
.contact-meta {
flex: 1;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.contact-name {
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@@ -1909,10 +1965,31 @@
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
padding-left: 24px;
line-height: 1.4;
}
.export-media-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
input[type='checkbox'] {
margin: 0;
}
}
.export-progress {
display: flex;
flex-direction: column;
@@ -2091,4 +2168,4 @@
cursor: not-allowed;
}
}
}
}

View File

@@ -5,6 +5,11 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
interface Contact {
username: string
@@ -13,11 +18,29 @@ interface Contact {
type?: 'friend' | 'former_friend' | 'sns_only'
}
interface SnsOverviewStats {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
const [overviewStats, setOverviewStats] = useState<SnsOverviewStats>({
totalPosts: 0,
totalFriends: 0,
myPosts: null,
earliestTime: null,
latestTime: null
})
const [overviewStatsStatus, setOverviewStatsStatus] = useState<OverviewStatsStatus>('loading')
// Filter states
const [searchKeyword, setSearchKeyword] = useState('')
@@ -35,9 +58,11 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html')
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportMedia, setExportMedia] = useState(false)
const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
const [exportVideos, setExportVideos] = useState(false)
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
@@ -56,12 +81,34 @@ export default function SnsPage() {
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
// Sync posts ref
useEffect(() => {
postsRef.current = posts
}, [posts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current;
@@ -75,6 +122,163 @@ export default function SnsPage() {
}
}, [posts])
const formatDateOnly = (timestamp: number | null): string => {
if (!timestamp || timestamp <= 0) return '--'
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return '--'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
if (cacheScopeKeyRef.current) return cacheScopeKeyRef.current
const wxid = (await configService.getMyWxid())?.trim() || SNS_PAGE_CACHE_SCOPE_FALLBACK
const scopeKey = `sns_page:${wxid}`
cacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return
const existingCache = await configService.getSnsPageCache(scopeKey)
let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) {
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[]
}
}
const overviewToStore = patch?.overviewStats
?? (overviewStatsStatusRef.current === 'ready'
? overviewStatsRef.current
: existingCache?.overviewStats ?? overviewStatsRef.current)
await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
})
} catch (error) {
console.error('Failed to persist SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
try {
const scopeKey = await ensureSnsCacheScopeKey()
const cached = await configService.getSnsPageCache(scopeKey)
if (!cached) return
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return
const cachedOverview = cached.overviewStats
if (cachedOverview) {
const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0))
const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0))
const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0
const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0
setOverviewStats({
totalPosts: cachedTotalPosts,
totalFriends: cachedTotalFriends,
myPosts: typeof cachedOverview.myPosts === 'number' && Number.isFinite(cachedOverview.myPosts) && cachedOverview.myPosts >= 0
? Math.floor(cachedOverview.myPosts)
: null,
earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null
})
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
const cachedPosts = cached.posts
.filter((raw): raw is SnsPost => {
if (!raw || typeof raw !== 'object') return false
const row = raw as Record<string, unknown>
return typeof row.id === 'string' && typeof row.createTime === 'number'
})
.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
.sort((a, b) => b.createTime - a.createTime)
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setHasMore(true)
setHasNewer(false)
}
}
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => {
setOverviewStatsStatus('loading')
try {
const statsResult = await window.electronAPI.sns.getExportStats()
if (!statsResult.success || !statsResult.data) {
throw new Error(statsResult.error || '获取朋友圈统计失败')
}
const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0))
const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0))
const myPosts = (typeof statsResult.data.myPosts === 'number' && Number.isFinite(statsResult.data.myPosts) && statsResult.data.myPosts >= 0)
? Math.floor(statsResult.data.myPosts)
: null
let earliestTime: number | null = null
let latestTime: number | null = null
if (totalPosts > 0) {
const [latestResult, earliestResult] = await Promise.all([
window.electronAPI.sns.getTimeline(1, 0),
window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0))
])
const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0)
const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0)
if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) {
latestTime = Math.floor(latestTs)
}
if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) {
earliestTime = Math.floor(earliestTs)
}
}
const nextOverviewStats = {
totalPosts,
totalFriends,
myPosts,
earliestTime,
latestTime
}
setOverviewStats(nextOverviewStats)
setOverviewStatsStatus('ready')
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) {
console.error('Failed to load SNS overview stats:', error)
setOverviewStatsStatus('error')
}
}, [persistSnsPageCache])
const renderOverviewStats = () => {
if (overviewStatsStatus === 'error') {
return (
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
</button>
)
}
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
return `${overviewStats.totalPosts} 我的朋友圈 ${myPostsLabel} ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} ${overviewStats.totalFriends} 位好友`
}
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return
@@ -119,7 +323,9 @@ export default function SnsPage() {
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime));
const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime)
setPosts(merged);
void persistSnsPageCache({ posts: merged })
}
setHasNewer(result.timeline.length >= limit);
} else {
@@ -149,6 +355,7 @@ export default function SnsPage() {
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
void persistSnsPageCache({ posts: result.timeline })
setHasMore(result.timeline.length >= limit)
// Check for newer items above topTs
@@ -165,7 +372,9 @@ export default function SnsPage() {
}
} else {
if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime))
const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)
setPosts(merged)
void persistSnsPageCache({ posts: merged })
}
if (result.timeline.length < limit) {
setHasMore(false)
@@ -179,22 +388,16 @@ export default function SnsPage() {
setLoadingNewer(false)
loadingRef.current = false
}
}, [selectedUsernames, searchKeyword, jumpTargetDate])
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
// Load Contacts合并好友+曾经好友+朋友圈发布者enrichSessionsContactInfo 补充头像
// Load Contacts仅加载好友/曾经好友,不再统计朋友圈条数
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
setContactsLoading(true)
try {
// 并行获取联系人列表和朋友圈发布者列表
const [contactsResult, snsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.sns.getSnsUsernames()
])
// 以联系人为基础,按 username 去重
const contactsResult = await window.electronAPI.chat.getContacts()
const contactMap = new Map<string, Contact>()
// 好友和曾经的好友
if (contactsResult.success && contactsResult.contacts) {
for (const c of contactsResult.contacts) {
if (c.type === 'friend' || c.type === 'former_friend') {
@@ -208,55 +411,61 @@ export default function SnsPage() {
}
}
// 朋友圈发布者(补充不在联系人列表中的用户)
if (snsResult.success && snsResult.usernames) {
for (const u of snsResult.usernames) {
if (!contactMap.has(u)) {
contactMap.set(u, { username: u, displayName: u, type: 'sns_only' })
}
}
}
let contactsList = Array.from(contactMap.values())
const allUsernames = Array.from(contactMap.keys())
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
const allUsernames = contactsList.map(c => c.username)
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) {
const c = contactMap.get(username)
if (c) {
c.displayName = extra.displayName || c.displayName
c.avatarUrl = extra.avatarUrl || c.avatarUrl
contactsList = contactsList.map(contact => {
const extra = enriched.contacts?.[contact.username]
if (!extra) return contact
return {
...contact,
displayName: extra.displayName || contact.displayName,
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
}
})
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
}
}
setContacts(Array.from(contactMap.values()))
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
}
}, [])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
loadContacts()
}, [loadContacts])
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
const handleChange = () => {
cacheScopeKeyRef.current = ''
// wxid changed, reset everything
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
loadPosts({ reset: true });
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
useEffect(() => {
const timer = setTimeout(() => {
@@ -285,10 +494,15 @@ export default function SnsPage() {
return (
<div className="sns-page-layout">
<div className="sns-main-viewport" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="sns-main-viewport">
<div className="sns-feed-container">
<div className="feed-header">
<h2></h2>
<div className="feed-header-main">
<h2></h2>
<div className={`feed-stats-line ${overviewStatsStatus}`}>
{renderOverviewStats()}
</div>
</div>
<div className="header-actions">
<button
onClick={async () => {
@@ -325,6 +539,7 @@ export default function SnsPage() {
onClick={() => {
setRefreshSpin(true)
loadPosts({ reset: true })
loadOverviewStats()
setTimeout(() => setRefreshSpin(false), 800)
}}
disabled={loading || loadingNewer}
@@ -336,75 +551,84 @@ export default function SnsPage() {
</div>
</div>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</div>
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>...</span>
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
</div>
)}
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
/>
))}
</div>
)}
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>...</span>
</div>
</div>
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</div>
@@ -597,6 +821,15 @@ export default function SnsPage() {
<span>JSON</span>
<small></small>
</button>
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
<small></small>
</button>
</div>
</div>
@@ -658,22 +891,40 @@ export default function SnsPage() {
{/* 媒体导出 */}
<div className="export-section">
<div className="export-toggle-row">
<div className="toggle-label">
<Image size={16} />
<span>/</span>
</div>
<button
className={`toggle-switch${exportMedia ? ' active' : ''}`}
onClick={() => !isExporting && setExportMedia(!exportMedia)}
disabled={isExporting}
>
<span className="toggle-knob" />
</button>
<label className="export-label">
<Image size={14} />
</label>
<div className="export-media-check-grid">
<label>
<input
type="checkbox"
checked={exportImages}
onChange={(e) => setExportImages(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
/>
</label>
</div>
{exportMedia && (
<p className="export-media-hint"> media </p>
)}
<p className="export-media-hint"></p>
</div>
{/* 同步提示 */}
@@ -723,7 +974,9 @@ export default function SnsPage() {
format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined,
exportMedia,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
})

View File

@@ -23,6 +23,18 @@ interface WelcomePageProps {
standalone?: boolean
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -292,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
setError(result.error || '自动获取密钥失败')
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
setError(formatDbKeyFailureMessage(result.error, result.logs))
}
}
} catch (e) {

View File

@@ -0,0 +1,9 @@
// 数据收集服务前端接口
export async function initCloudControl() {
return window.electronAPI.cloud.init()
}
export function recordPage(pageName: string) {
window.electronAPI.cloud.recordPage(pageName)
}

View File

@@ -32,6 +32,19 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
// 安全
AUTH_ENABLED: 'authEnabled',
@@ -48,7 +61,10 @@ export const CONFIG_KEYS = {
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
// 数据收集
ANALYTICS_CONSENT: 'analyticsConsent'
} as const
export interface WxidConfig {
@@ -386,6 +402,594 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT)
if (value === 'A' || value === 'B' || value === 'C') return value
return 'B'
}
export async function setExportWriteLayout(layout: ExportWriteLayout): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
}
export async function getExportSessionNamePrefixEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED)
if (typeof value === 'boolean') return value
return true
}
export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled)
}
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [sessionId, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[sessionId] = raw
}
}
return map
}
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
}
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [key, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[key] = raw
}
}
return map
}
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
}
export interface ExportSessionRecordEntry {
exportTime: number
content: string
outputDir: string
}
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
if (!value || typeof value !== 'object') return {}
const map: Record<string, ExportSessionRecordEntry[]> = {}
const entries = Object.entries(value as Record<string, unknown>)
for (const [sessionId, rawList] of entries) {
if (!Array.isArray(rawList)) continue
const normalizedList: ExportSessionRecordEntry[] = []
for (const rawItem of rawList) {
if (!rawItem || typeof rawItem !== 'object') continue
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
const content = String((rawItem as Record<string, unknown>).content || '').trim()
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
if (!content || !outputDir) continue
normalizedList.push({
exportTime: Math.floor(exportTime),
content,
outputDir
})
}
if (normalizedList.length > 0) {
map[sessionId] = normalizedList
}
}
return map
}
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
}
export async function getExportLastSnsPostCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return Math.floor(value)
}
return 0
}
export async function setExportLastSnsPostCount(count: number): Promise<void> {
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
}
export interface ExportSessionMessageCountCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface ExportSessionContentMetricCacheEntry {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
}
export interface ExportSessionContentMetricCacheItem {
updatedAt: number
metrics: Record<string, ExportSessionContentMetricCacheEntry>
}
export interface ExportSnsStatsCacheItem {
updatedAt: number
totalPosts: number
totalFriends: number
}
export interface SnsPageOverviewCache {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
export interface SnsPageCacheItem {
updatedAt: number
overviewStats: SnsPageOverviewCache
posts: unknown[]
}
export interface ContactsListCacheContact {
username: string
displayName: string
remark?: string
nickname?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
export interface ContactsListCacheItem {
updatedAt: number
contacts: ContactsListCacheContact[]
}
export interface ContactsAvatarCacheEntry {
avatarUrl: string
updatedAt: number
checkedAt: number
}
export interface ContactsAvatarCacheItem {
updatedAt: number
avatars: Record<string, ContactsAvatarCacheEntry>
}
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawCounts = (rawItem as Record<string, unknown>).counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
counts[sessionId] = Math.floor(countRaw)
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
counts
}
}
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
normalized[sessionId] = Math.floor(countRaw)
}
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
}
export async function getExportSessionContentMetricCache(scopeKey: string): Promise<ExportSessionContentMetricCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawMetrics = (rawItem as Record<string, unknown>).metrics
if (!rawMetrics || typeof rawMetrics !== 'object') return null
const metrics: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record<string, unknown>)) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const source = rawMetric as Record<string, unknown>
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) {
metric.totalMessages = Math.floor(source.totalMessages)
}
if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(source.voiceMessages)
}
if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) {
metric.imageMessages = Math.floor(source.imageMessages)
}
if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) {
metric.videoMessages = Math.floor(source.videoMessages)
}
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(source.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
metrics[sessionId] = metric
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
metrics
}
}
export async function setExportSessionContentMetricCache(
scopeKey: string,
metrics: Record<string, ExportSessionContentMetricCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(metrics || {})) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) {
metric.totalMessages = Math.floor(rawMetric.totalMessages)
}
if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(rawMetric.voiceMessages)
}
if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) {
metric.imageMessages = Math.floor(rawMetric.imageMessages)
}
if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) {
metric.videoMessages = Math.floor(rawMetric.videoMessages)
}
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
normalized[sessionId] = metric
}
map[scopeKey] = {
updatedAt: Date.now(),
metrics: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map)
}
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
? Math.floor(raw.totalPosts)
: 0
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
? Math.floor(raw.totalFriends)
: 0
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, totalPosts, totalFriends }
}
export async function setExportSnsStatsCache(
scopeKey: string,
stats: { totalPosts: number; totalFriends: number }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
map[scopeKey] = {
updatedAt: Date.now(),
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
}
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
}
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawOverview = raw.overviewStats
const rawPosts = raw.posts
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
const overviewObj = rawOverview as Record<string, unknown>
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
return {
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
overviewStats: {
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
myPosts: normalizeNullableCount(overviewObj.myPosts),
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
},
posts: rawPosts
}
}
export async function setSnsPageCache(
scopeKey: string,
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
map[scopeKey] = {
updatedAt: Date.now(),
overviewStats: {
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
myPosts: normalizeNullableCount(payload?.overviewStats?.myPosts),
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
},
posts: Array.isArray(payload?.posts) ? payload.posts : []
}
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
}
// 获取通讯录加载超时阈值(毫秒)
export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value)
}
return 3000
}
// 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
}
export async function getContactsListCache(scopeKey: string): Promise<ContactsListCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawContacts = (rawItem as Record<string, unknown>).contacts
if (!Array.isArray(rawContacts)) return null
const contacts: ContactsListCacheContact[] = []
for (const raw of rawContacts) {
if (!raw || typeof raw !== 'object') continue
const item = raw as Record<string, unknown>
const username = typeof item.username === 'string' ? item.username.trim() : ''
if (!username) continue
const displayName = typeof item.displayName === 'string' ? item.displayName : username
const type = typeof item.type === 'string' ? item.type : 'other'
contacts.push({
username,
displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type
: 'other'
})
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
contacts
}
}
export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: ContactsListCacheContact[] = []
for (const contact of contacts || []) {
const username = String(contact?.username || '').trim()
if (!username) continue
const displayName = String(contact?.displayName || username)
const type = contact?.type || 'other'
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
continue
}
normalized.push({
username,
displayName,
remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined,
type
})
}
map[scopeKey] = {
updatedAt: Date.now(),
contacts: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map)
}
export async function getContactsAvatarCache(scopeKey: string): Promise<ContactsAvatarCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawAvatars = (rawItem as Record<string, unknown>).avatars
if (!rawAvatars || typeof rawAvatars !== 'object') return null
const avatars: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record<string, unknown>)) {
const username = rawUsername.trim()
if (!username) continue
if (typeof rawEntry === 'string') {
const avatarUrl = rawEntry.trim()
if (!avatarUrl) continue
avatars[username] = {
avatarUrl,
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0
}
continue
}
if (!rawEntry || typeof rawEntry !== 'object') continue
const entry = rawEntry as Record<string, unknown>
const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : ''
if (!avatarUrl) continue
const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0
const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt)
? entry.checkedAt
: updatedAt
avatars[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
avatars
}
}
export async function setContactsAvatarCache(
scopeKey: string,
avatars: Record<string, ContactsAvatarCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) {
const username = String(rawUsername || '').trim()
if (!username || !rawEntry || typeof rawEntry !== 'object') continue
const avatarUrl = String(rawEntry.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = Number.isFinite(rawEntry.updatedAt)
? Math.max(0, Math.floor(rawEntry.updatedAt))
: Date.now()
const checkedAt = Number.isFinite(rawEntry.checkedAt)
? Math.max(0, Math.floor(rawEntry.checkedAt))
: updatedAt
normalized[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
map[scopeKey] = {
updatedAt: Date.now(),
avatars: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map)
}
// === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> {
@@ -482,3 +1086,15 @@ export async function getWordCloudExcludeWords(): Promise<string[]> {
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
}
// 获取数据收集同意状态
export async function getAnalyticsConsent(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.ANALYTICS_CONSENT)
if (typeof value === 'boolean') return value
return null
}
// 设置数据收集同意状态
export async function setAnalyticsConsent(consent: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_CONSENT, consent)
}

View File

@@ -0,0 +1,85 @@
export interface OpenSingleExportPayload {
sessionId: string
sessionName?: string
requestId?: string
}
export interface ExportSessionStatusPayload {
inProgressSessionIds: string[]
activeTaskCount: number
}
export interface SingleExportDialogStatusPayload {
requestId: string
status: 'initializing' | 'opened' | 'failed'
message?: string
}
const OPEN_SINGLE_EXPORT_EVENT = 'weflow:open-single-export'
const EXPORT_SESSION_STATUS_EVENT = 'weflow:export-session-status'
const EXPORT_SESSION_STATUS_REQUEST_EVENT = 'weflow:export-session-status-request'
const SINGLE_EXPORT_DIALOG_STATUS_EVENT = 'weflow:single-export-dialog-status'
export const emitOpenSingleExport = (payload: OpenSingleExportPayload) => {
window.dispatchEvent(new CustomEvent<OpenSingleExportPayload>(OPEN_SINGLE_EXPORT_EVENT, {
detail: payload
}))
}
export const onOpenSingleExport = (
listener: (payload: OpenSingleExportPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<OpenSingleExportPayload>
listener(customEvent.detail)
}
window.addEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
return () => window.removeEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
}
export const emitExportSessionStatus = (payload: ExportSessionStatusPayload) => {
window.dispatchEvent(new CustomEvent<ExportSessionStatusPayload>(EXPORT_SESSION_STATUS_EVENT, {
detail: payload
}))
}
export const onExportSessionStatus = (
listener: (payload: ExportSessionStatusPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<ExportSessionStatusPayload>
listener(customEvent.detail)
}
window.addEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
return () => window.removeEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
}
export const requestExportSessionStatus = () => {
window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_REQUEST_EVENT))
}
export const onExportSessionStatusRequest = (listener: () => void): (() => void) => {
const handler = () => listener()
window.addEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
return () => window.removeEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
}
export const emitSingleExportDialogStatus = (payload: SingleExportDialogStatusPayload) => {
window.dispatchEvent(new CustomEvent<SingleExportDialogStatusPayload>(SINGLE_EXPORT_DIALOG_STATUS_EVENT, {
detail: payload
}))
}
export const onSingleExportDialogStatus = (
listener: (payload: SingleExportDialogStatusPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<SingleExportDialogStatusPayload>
listener(customEvent.detail)
}
window.addEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
return () => window.removeEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
}

View File

@@ -32,7 +32,7 @@ export interface ChatState {
setConnectionError: (error: string | null) => void
setSessions: (sessions: ChatSession[]) => void
setFilteredSessions: (sessions: ChatSession[]) => void
setCurrentSession: (sessionId: string | null) => void
setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void
setLoadingSessions: (loading: boolean) => void
setMessages: (messages: Message[]) => void
appendMessages: (messages: Message[], prepend?: boolean) => void
@@ -69,12 +69,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
setCurrentSession: (sessionId) => set({
setCurrentSession: (sessionId, options) => set((state) => ({
currentSessionId: sessionId,
messages: [],
messages: options?.preserveMessages ? state.messages : [],
hasMoreMessages: true,
hasMoreLater: false
}),
})),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),

View File

@@ -0,0 +1,115 @@
import { create } from 'zustand'
import type { ContactInfo } from '../types/models'
export interface ContactTypeTabCounts {
private: number
group: number
official: number
former_friend: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
deletedFriends: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
return {
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
}
}
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
const next = { ...emptyTabCounts }
for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1
if (contact.type === 'former_friend') next.former_friend += 1
}
return next
}
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
return {
friends: counts.private,
groups: counts.group,
officials: counts.official,
deletedFriends: counts.former_friend
}
}
interface ContactTypeCountsState {
tabCounts: ContactTypeTabCounts
isLoading: boolean
isReady: boolean
updatedAt: number
setTabCounts: (counts: ContactTypeTabCounts) => void
syncFromContacts: (contacts: ContactInfo[]) => void
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
}
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
tabCounts: { ...emptyTabCounts },
isLoading: false,
isReady: false,
updatedAt: 0,
setTabCounts: (counts) => {
const normalized = normalizeCounts(counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
},
syncFromContacts: (contacts) => {
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
get().setTabCounts(fromContacts)
},
ensureLoaded: async (options) => {
if (!options?.force && get().isReady) {
return get().tabCounts
}
if (inflightPromise) {
return inflightPromise
}
set({ isLoading: true })
inflightPromise = (async () => {
try {
const result = await window.electronAPI.chat.getContactTypeCounts()
if (result?.success && result.counts) {
const normalized = normalizeCounts(result.counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
return normalized
}
} catch (error) {
console.error('加载联系人类型计数失败:', error)
}
return get().tabCounts
})().finally(() => {
inflightPromise = null
set({ isLoading: false })
})
return inflightPromise
}
}))

View File

@@ -13,6 +13,7 @@ export interface ElectronAPI {
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
openSessionChatWindow: (sessionId: string) => Promise<boolean>
}
config: {
get: (key: string) => Promise<unknown>
@@ -48,9 +49,65 @@ export interface ElectronAPI {
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
close: () => Promise<void>
click: (sessionId: string) => void
ready: () => void
resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void
}
log: {
getPath: () => Promise<string>
read: () => Promise<{ success: boolean; content?: string; error?: string }>
debug: (data: any) => void
}
diagnostics: {
getExportCardLogs: (options?: { limit?: number }) => Promise<{
logs: Array<{
id: string
ts: number
source: 'frontend' | 'main' | 'backend' | 'worker'
level: 'debug' | 'info' | 'warn' | 'error'
message: string
traceId?: string
stepId?: string
stepName?: string
status?: 'running' | 'done' | 'failed' | 'timeout'
durationMs?: number
data?: Record<string, unknown>
}>
activeSteps: Array<{
traceId: string
stepId: string
stepName: string
source: 'frontend' | 'main' | 'backend' | 'worker'
elapsedMs: number
stallMs: number
startedAt: number
lastUpdatedAt: number
message?: string
}>
summary: {
totalLogs: number
activeStepCount: number
errorCount: number
warnCount: number
timeoutCount: number
lastUpdatedAt: number
}
}>
clearExportCardLogs: () => Promise<{ success: boolean }>
exportExportCardLogs: (payload: {
filePath: string
frontendLogs?: unknown[]
}) => Promise<{
success: boolean
filePath?: string
summaryPath?: string
count?: number
error?: string
}>
}
dbPath: {
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
@@ -74,7 +131,40 @@ export interface ElectronAPI {
chat: {
connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
getSessionStatuses: (usernames: string[]) => Promise<{
success: boolean
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
error?: string
}>
getExportTabCounts: () => Promise<{
success: boolean
counts?: {
private: number
group: number
official: number
former_friend: number
}
error?: string
}>
getContactTypeCounts: () => Promise<{
success: boolean
counts?: {
private: number
group: number
official: number
former_friend: number
}
error?: string
}>
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
success: boolean
counts?: Record<string, number>
error?: string
}>
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
@@ -88,6 +178,7 @@ export interface ElectronAPI {
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
success: boolean
messages?: Message[]
hasMore?: boolean
error?: string
}>
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
@@ -95,6 +186,17 @@ export interface ElectronAPI {
messages?: Message[]
error?: string
}>
getCachedMessages: (sessionId: string) => Promise<{
success: boolean
messages?: Message[]
error?: string
}>
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => Promise<{
success: boolean
removedPaths?: string[]
warning?: string
error?: string
}>
getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
@@ -124,6 +226,66 @@ export interface ElectronAPI {
}
error?: string
}>
getSessionDetailFast: (sessionId: string) => Promise<{
success: boolean
detail?: {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
}
error?: string
}>
getSessionDetailExtra: (sessionId: string) => Promise<{
success: boolean
detail?: {
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
error?: string
}>
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
) => Promise<{
success: boolean
data?: Record<string, {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}>
cache?: Record<string, {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}>
needsRefresh?: string[]
error?: string
}>
getGroupMyMessageCountHint: (chatroomId: string) => Promise<{
success: boolean
count?: number
updatedAt?: number
source?: 'memory' | 'disk'
error?: string
}>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
@@ -132,6 +294,8 @@ export interface ElectronAPI {
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
error?: string
}>
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
@@ -141,7 +305,7 @@ export interface ElectronAPI {
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
@@ -253,9 +417,31 @@ export interface ElectronAPI {
alias?: string
remark?: string
groupNickname?: string
isOwner?: boolean
}>
error?: string
}>
getGroupMembersPanelData: (
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
) => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
isOwner?: boolean
isFriend: boolean
messageCount: number
}>
fromCache?: boolean
updatedAt?: number
error?: string
}>
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: Array<{
@@ -310,6 +496,30 @@ export interface ElectronAPI {
data?: number[]
error?: string
}>
startAvailableYearsLoad: () => Promise<{
success: boolean
taskId?: string
reused?: boolean
snapshot?: {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
error?: string
}>
cancelAvailableYearsLoad: (taskId: string) => Promise<{
success: boolean
error?: string
}>
generateReport: (year: number) => Promise<{
success: boolean
data?: {
@@ -372,6 +582,20 @@ export interface ElectronAPI {
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
}
error?: string
}>
@@ -380,6 +604,21 @@ export interface ElectronAPI {
dir?: string
error?: string
}>
onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => () => void
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
dualReport: {
@@ -427,15 +666,26 @@ export interface ElectronAPI {
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
response?: { avg: number; fastest: number; slowest?: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; slowest?: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
error?: string
}>
@@ -455,6 +705,9 @@ export interface ElectronAPI {
success: boolean
successCount?: number
failCount?: number
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
error?: string
}>
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
@@ -507,26 +760,35 @@ export interface ElectronAPI {
error?: string
}>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }>
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
format: 'json' | 'html'
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
}
cloud: {
init: () => Promise<void>
recordPage: (pageName: string) => Promise<void>
getLogs: () => Promise<string[]>
}
http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }>
@@ -535,7 +797,8 @@ export interface ElectronAPI {
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
@@ -549,6 +812,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
}
@@ -557,6 +821,7 @@ export interface ExportProgress {
current: number
total: number
currentSession: string
currentSessionId?: string
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
phaseProgress?: number
phaseTotal?: number

View File

@@ -7,6 +7,7 @@ export interface ChatSession {
sortTimestamp: number // 用于排序
lastTimestamp: number // 用于显示时间
lastMsgType: number
messageCountHint?: number // 会话总消息数提示(若底层直接可取)
displayName?: string
avatarUrl?: string
lastMsgSender?: string

21
src/vite-env.d.ts vendored
View File

@@ -1,22 +1 @@
/// <reference types="vite/client" />
interface Window {
electronAPI: {
// ... other methods ...
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
setHelloSecret: (password: string) => Promise<{ success: boolean }>
clearHelloSecret: () => Promise<{ success: boolean }>
isLockMode: () => Promise<boolean>
}
// For brevity, using 'any' for other parts or properly importing types if available.
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
// or import a shared type definition.
[key: string]: any
}
}