架构优化

This commit is contained in:
cc committed 2026-07-07 14:11:02 +08:00
1 parent 147f3b7045
commit ccbd154668
67 files changed
+1643 -338

No files matched your search

+3 -9
View File
@@ -145,15 +145,9 @@ if (config.userDataPath) {
}
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
const shouldUseWeliveEngine = () => {
if (config.mode === 'contacts') return false
const explicit = String(config.options?.exportEngine || config.options?.engine || '').trim().toLowerCase()
if (explicit === 'welive') return true
if (explicit === 'legacy') return false
const env = String(process.env.WEFLOW_EXPORT_ENGINE || '').trim().toLowerCase()
if (env === 'legacy') return false
return true
}
// 消息导出强制走 WeLive 引擎(不提供 legacy 回退开关);
// 仅联系人导出仍由 worker 内置流程处理。
const shouldUseWeliveEngine = () => config.mode !== 'contacts'
const normalizeImageXorKey = (value: unknown): string | number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) return value
+36
View File
@@ -0,0 +1,36 @@
import { parentPort, workerData } from 'worker_threads'
import { decryptDatViaNative, nativeAddonLocation } from './services/nativeImageDecrypt'
// worker_threads 中没有 Electron 注入的 resourcesPath,由主线程通过 workerData 传入,
// 供 nativeImageDecrypt 定位 .node 原生模块
if (workerData?.resourcesPath && !process.resourcesPath) {
;(process as NodeJS.Process & { resourcesPath?: string }).resourcesPath = workerData.resourcesPath
}
type DecryptRequest = {
id: number
datPath: string
xorKey: number
aesKey?: string
}
parentPort?.on('message', (req: DecryptRequest) => {
const { id, datPath, xorKey, aesKey } = req
try {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
if (!result) {
// addonMissing 让主线程直接放弃 worker 路径,避免每次解密都白跑一次线程往返
parentPort?.postMessage({ id, ok: false, addonMissing: !nativeAddonLocation() })
return
}
// 拷贝出独立 ArrayBuffer 后转移所有权,避免结构化克隆再复制一次
const data = new ArrayBuffer(result.data.byteLength)
new Uint8Array(data).set(result.data)
parentPort?.postMessage(
{ id, ok: true, data, ext: result.ext, isWxgf: result.isWxgf, meta: result.meta },
[data]
)
} catch (error) {
parentPort?.postMessage({ id, ok: false, error: String(error) })
}
})
+5 -5
View File
@@ -1095,8 +1095,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
nodeIntegration: false,
webSecurity: false // Allow loading local files (video playback)
},
titleBarStyle: 'hidden',
titleBarOverlay: false,
frame: false,
show: false
})
setupCustomTitleBarWindow(win)
@@ -1574,8 +1573,7 @@ function createChatHistoryRouteWindow(route: string) {
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: false,
frame: false,
show: false,
backgroundColor: '#FFFFFF',
autoHideMenuBar: true
@@ -4690,7 +4688,9 @@ app.whenReady().then(async () => {
}
updateSplashProgress(75, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
// 只预热前 600 个会话的头像:覆盖首屏与常用会话,其余由 ChatPage
// 的渐进式补齐队列按需加载,降低启动耗时与常驻内存
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 600)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
+117
View File
@@ -0,0 +1,117 @@
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
// 条件导入 electron(Worker 环境中不可用)
let app: any = null
try {
app = require('electron').app
} catch {
// Worker 环境
}
/**
* 大体积 UI 缓存(*CacheMap 键)的独立存储。
*
* 这些键此前存放在 electron-store 主配置中,而 conf 库每次 get/set
* 都会同步读写整个配置文件(曾膨胀到 3.2MB),主线程单次阻塞可达
* 数十毫秒且高频发生。此处改为:内存 Map 直接服务读写 + 防抖异步
* 落盘 + 退出时同步 flush,读写路径完全不再碰磁盘。
*/
export class CacheMapStore {
private readonly filePath: string
private data = new Map<string, unknown>()
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
constructor(userDataPath: string) {
this.filePath = join(userDataPath, 'WeFlow-cache-maps.json')
this.load()
app?.once?.('will-quit', () => this.flushSync())
}
private load(): void {
if (!existsSync(this.filePath)) return
try {
const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown
if (parsed && typeof parsed === 'object') {
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
this.data.set(key, value)
}
}
} catch (error) {
console.error('CacheMapStore: 载入缓存失败', error)
}
}
get(key: string): unknown {
return this.data.get(key)
}
set(key: string, value: unknown): void {
if (value === undefined) {
this.data.delete(key)
} else {
this.data.set(key, value)
}
this.persist()
}
entries(): Record<string, unknown> {
return Object.fromEntries(this.data)
}
clear(): void {
this.data.clear()
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.filePath, { force: true })
} catch (error) {
console.error('CacheMapStore: 清理缓存失败', error)
}
}
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 500)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.data)), 'utf8')
} catch (error) {
console.error('CacheMapStore: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 有待写改动时同步写入(应用退出前或迁移完成后调用) */
flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.data)), 'utf8')
} catch (error) {
console.error('CacheMapStore: 保存缓存失败', error)
}
}
}
+9 -4
View File
@@ -384,7 +384,8 @@ class ChatService {
private mediaDbsCache: string[] | null = null
private mediaDbsCacheTime = 0
private readonly mediaDbsCacheTtl = 300000 // 5分钟
private readonly voiceWavCacheMaxEntries = 50
// WAV 解码产物每条可达 ~2MB,50 条上限最坏 100MB;12 条足够覆盖连续回放场景
private readonly voiceWavCacheMaxEntries = 12
// 缓存 media.db 的表结构信息
private mediaDbSchemaCache = new Map<string, {
voiceTable: string
@@ -1085,8 +1086,9 @@ class ChatService {
for (const row of contactResult.contacts as Record<string, any>[]) {
const username = String(row.username || '').trim()
if (!username || !this.isAntiRevokeContactRow(username, row)) continue
const displayName = String(row.remark || row.nick_name || row.nickName || row.alias || username)
map.set(username, {
displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim()
displayName: displayName.trim() || displayName
})
}
} catch {
@@ -7904,7 +7906,8 @@ class ChatService {
const nicknameBuckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = nicknameBuckets.get(memberId)
if (slot) {
@@ -11127,7 +11130,9 @@ class ChatService {
latest_ts: this.toSafeInt(item?.latest_ts, 0),
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
displayName: String(item?.displayName || '').trim() || undefined,
displayName: typeof item?.displayName === 'string' && item.displayName.length > 0
? (item.displayName.trim() || item.displayName)
: undefined,
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
+46 -1
View File
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, statSync } from 'fs'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
import { CacheMapStore } from './cacheMapStore'
// 条件导入 electron(Worker 环境中不可用)
let app: any = null
@@ -161,9 +162,18 @@ const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
/**
* 大体积 UI 缓存键(以 CacheMap 结尾),存入独立的 CacheMapStore。
* conf 库对主配置文件的每次 get/set 都是全量同步读写,
* 这些缓存曾把配置文件撑到 3.2MB,导致高频主线程阻塞。
*/
const isCacheMapKey = (key: string): boolean => key.endsWith('CacheMap')
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
// Worker 环境不创建(CacheMap 键仅主进程访问,避免多进程并发写同一文件)
private cacheMapStore: CacheMapStore | null = null
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
@@ -305,6 +315,29 @@ export class ConfigService {
}
this.migrateAuthFields()
this.migrateAiConfig()
if (!runningInWorker) {
this.cacheMapStore = new CacheMapStore(this.getUserDataPath())
this.migrateCacheMapKeys()
}
}
/** 一次性迁移:把主配置中的 *CacheMap 大键搬到旁路存储,缩小配置文件 */
private migrateCacheMapKeys(): void {
if (!this.cacheMapStore) return
try {
const all = this.store.store as unknown as Record<string, unknown>
const cacheKeys = Object.keys(all).filter(isCacheMapKey)
if (cacheKeys.length === 0) return
for (const key of cacheKeys) {
this.cacheMapStore.set(key, all[key])
delete all[key]
}
// 先落盘旁路存储,再整体重写主配置(各一次全量写)
this.cacheMapStore.flushSync()
;(this.store as any).store = all
} catch (error) {
console.error('ConfigService: 迁移 CacheMap 键失败', error)
}
}
// === 状态查询 ===
@@ -321,6 +354,9 @@ export class ConfigService {
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
return this.cacheMapStore.get(key as string) as ConfigSchema[K]
}
const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
@@ -362,6 +398,10 @@ export class ConfigService {
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
this.cacheMapStore.set(key as string, value)
return
}
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
@@ -1076,11 +1116,16 @@ export class ConfigService {
}
getAll(): Partial<ConfigSchema> {
return this.store.store
const all = { ...this.store.store } as Record<string, unknown>
if (this.cacheMapStore) {
Object.assign(all, this.cacheMapStore.entries())
}
return all as Partial<ConfigSchema>
}
clear(): void {
this.store.clear()
this.cacheMapStore?.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
}
+42
View File
@@ -1,5 +1,6 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
@@ -12,6 +13,9 @@ export interface ContactCacheEntry {
export class ContactCacheService {
private readonly cacheFilePath: string
private cache: Record<string, ContactCacheEntry> = {}
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
@@ -20,6 +24,7 @@ export class ContactCacheService {
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir() {
@@ -74,7 +79,40 @@ export class ContactCacheService {
}
}
/** 防抖异步落盘:启动阶段批量补全联系人时避免连续同步写盘阻塞主线程 */
private persist() {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('ContactCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
@@ -84,6 +122,10 @@ export class ContactCacheService {
clear(): void {
this.cache = {}
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -4419,7 +4419,15 @@ export class ExportContext {
control,
onCollectProgress
)
if (weliveCollected) return weliveCollected
if (weliveCollected) {
// WeLive 原始数据收集报错(文件缺失 / JSONL 解析失败)意味着数据不完整。
// 调用方(各 formatter)不会检查 error 字段,这里必须直接抛错让会话导出失败,
// 禁止把截断或空结果当作成功输出(issue #1129 的 WeFlow 侧防线)。
if (weliveCollected.error) {
throw new Error(`WeLive 原始导出数据不完整: ${weliveCollected.error}`)
}
return weliveCollected
}
const rows: any[] = [];
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>();
@@ -1,5 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
const CACHE_VERSION = 1
@@ -39,6 +41,9 @@ function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
export class GroupMyMessageCountCacheService {
private readonly cacheFilePath: string
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
private store: GroupMyMessageCountCacheStore = {
version: CACHE_VERSION,
scopes: {}
@@ -51,6 +56,7 @@ export class GroupMyMessageCountCacheService {
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
this.ensureCacheDir()
this.load()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir(): void {
@@ -158,6 +164,10 @@ export class GroupMyMessageCountCacheService {
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -194,7 +204,40 @@ export class GroupMyMessageCountCacheService {
this.store.scopes = trimmedScopes
}
/** 防抖异步落盘:批量刷新群统计时避免连续同步写盘阻塞主线程 */
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
+4 -2
View File
@@ -328,7 +328,8 @@ class GroupSummaryService {
return { success: false, message: '手动总结时段不能超过 48 小时' }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummaryForPeriod({
sessionId,
@@ -369,7 +370,8 @@ class GroupSummaryService {
return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummariesForPeriods({
sessionId,
+2 -1
View File
@@ -2048,7 +2048,8 @@ class HttpService {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
+3 -2
View File
@@ -7,7 +7,7 @@ import { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
import { decryptDatViaNativeAsync, nativeAddonLocation } from './nativeImageDecrypt'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
@@ -1949,7 +1949,8 @@ export class ImageDecryptService {
xorKey: number,
aesKey?: string
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
// worker 线程内执行原生解密,避免滚动预载时批量同步调用阻塞主线程
const result = await decryptDatViaNativeAsync(datPath, xorKey, aesKey)
if (!this.nativeLogged) {
this.nativeLogged = true
if (result) {
+26 -10
View File
@@ -681,7 +681,8 @@ class InsightService {
}
this.dbConnected = true
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const rawDisplayName = typeof params?.displayName === 'string' ? params.displayName : ''
const displayName = rawDisplayName.length > 0 ? (rawDisplayName.trim() || rawDisplayName) : sessionId
insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`)
return await this.generateInsightForSession({
sessionId,
@@ -739,7 +740,10 @@ class InsightService {
const topPrivateText = privateSegments.length > 0
? privateSegments
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim()
const rawName = typeof item.displayName === 'string' && item.displayName.length > 0
? item.displayName
: String(item.session_id || `联系人${idx + 1}`)
const name = rawName.trim() || rawName
const inbound = Number(item.incoming_count) || 0
const outbound = Number(item.outgoing_count) || 0
const total = Math.max(Number(item.message_count) || 0, inbound + outbound)
@@ -751,7 +755,10 @@ class InsightService {
const topMentionText = mentionGroups.length > 0
? mentionGroups
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim()
const rawName = typeof item.displayName === 'string' && item.displayName.length > 0
? item.displayName
: String(item.session_id || `群聊${idx + 1}`)
const name = rawName.trim() || rawName
const count = Number(item.count) || 0
return `${idx + 1}. ${name}(@我 ${count} 次)`
})
@@ -1080,7 +1087,8 @@ ${afterText}
}
private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise<string> {
const fallback = String(fallbackDisplayName || '').trim()
const rawFallback = typeof fallbackDisplayName === 'string' ? fallbackDisplayName : ''
const fallback = rawFallback.trim() || rawFallback
if (fallback && !this.looksLikeWxid(fallback)) {
return fallback
}
@@ -1088,7 +1096,8 @@ ${afterText}
try {
const sessions = await this.getSessionsCached()
const matched = sessions.find((session) => String(session.username || '').trim() === sessionId)
const cachedDisplayName = String(matched?.displayName || '').trim()
const rawCachedDisplayName = typeof matched?.displayName === 'string' ? matched.displayName : ''
const cachedDisplayName = rawCachedDisplayName.trim() || rawCachedDisplayName
if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) {
return cachedDisplayName
}
@@ -1098,7 +1107,8 @@ ${afterText}
try {
const contact = await chatService.getContactAvatar(sessionId)
const contactDisplayName = String(contact?.displayName || '').trim()
const rawContactDisplayName = typeof contact?.displayName === 'string' ? contact.displayName : ''
const contactDisplayName = rawContactDisplayName.trim() || rawContactDisplayName
if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) {
return contactDisplayName
}
@@ -1387,11 +1397,14 @@ ${afterText}
silentCount++
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
const displayName = typeof session.displayName === 'string' && session.displayName.length > 0
? (session.displayName.trim() || session.displayName)
: sessionId
insightLog('INFO', `发现沉默联系人:${displayName},已沉默 ${silentDays} 天`)
await this.generateInsightForSession({
sessionId,
displayName: session.displayName || session.username,
displayName,
triggerReason: 'silence',
silentDays
})
@@ -1504,12 +1517,15 @@ ${afterText}
if (cooldownMs - (now - lastAnalysis) > 0) continue
}
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
const displayName = typeof session.displayName === 'string' && session.displayName.length > 0
? (session.displayName.trim() || session.displayName)
: sessionId
insightLog('INFO', `${displayName} 有新消息,准备生成见解...`)
this.lastActivityAnalysis.set(sessionId, now)
await this.generateInsightForSession({
sessionId,
displayName: session.displayName || session.username,
displayName,
triggerReason: 'activity'
})
break
+4 -2
View File
@@ -13,8 +13,10 @@ export class MessageCacheService {
private static readonly CACHE_VERSION = 3
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
private readonly maxSessionEntries = 48
// 每会话 80 条已覆盖首屏渲染(DB 拉取会随后补全),48→24 个会话、150→80 条
// 可把该缓存的常驻内存压到原来的 1/4 左右
private readonly sessionLimit = 80
private readonly maxSessionEntries = 24
private persistTimer: ReturnType<typeof setTimeout> | null = null
private persistInFlight = false
private persistQueued = false
+2 -1
View File
@@ -1396,7 +1396,8 @@ class MessagePushService {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
const rawNickname = typeof nicknameRaw === 'string' ? nicknameRaw : String(nicknameRaw || '')
const nickname = rawNickname.trim() || rawNickname
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
+117
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { Worker } from 'worker_threads'
type NativeDecryptResult = {
data: Buffer
@@ -152,3 +153,119 @@ export function encryptDatViaNative(
return null
}
}
// ─── Worker 化解密:原生解密是同步 CPU/IO 调用,滚动时批量触发会阻塞主线程 ───
type WorkerDecryptResult = { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null
type PendingJob = {
resolve: (value: WorkerDecryptResult) => void
}
let decryptWorker: Worker | null = null
let workerFailedPermanently = false
let workerJobSeq = 0
const pendingJobs = new Map<number, PendingJob>()
function resolveWorkerPath(): string | null {
const isDev = process.env.NODE_ENV === 'development'
const candidates = isDev
? [join(__dirname, '../dist-electron/imageDecryptWorker.js'), join(__dirname, 'imageDecryptWorker.js')]
: [join(__dirname, 'imageDecryptWorker.js')]
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return null
}
function flushPendingJobs(): void {
for (const job of pendingJobs.values()) job.resolve(null)
pendingJobs.clear()
}
function ensureDecryptWorker(): Worker | null {
if (workerFailedPermanently) return null
if (decryptWorker) return decryptWorker
const workerPath = resolveWorkerPath()
if (!workerPath) {
workerFailedPermanently = true
return null
}
try {
const worker = new Worker(workerPath, {
workerData: { resourcesPath: process.resourcesPath || '' }
})
worker.on('message', (msg: { id: number; ok: boolean; addonMissing?: boolean; data?: ArrayBuffer; ext?: string; isWxgf?: boolean; meta?: NativeDatMeta }) => {
const job = pendingJobs.get(msg.id)
if (!job) return
pendingJobs.delete(msg.id)
if (msg.addonMissing) {
// worker 中找不到原生模块:停用 worker 路径,后续直接走主线程同步逻辑
workerFailedPermanently = true
terminateDecryptWorker()
job.resolve(null)
return
}
if (!msg.ok || !msg.data) {
job.resolve(null)
return
}
job.resolve({
data: Buffer.from(msg.data),
ext: msg.ext || '',
isWxgf: Boolean(msg.isWxgf),
meta: msg.meta || {}
})
})
const handleWorkerGone = () => {
flushPendingJobs()
decryptWorker = null
}
worker.on('error', handleWorkerGone)
worker.on('exit', handleWorkerGone)
worker.unref()
decryptWorker = worker
return worker
} catch {
workerFailedPermanently = true
return null
}
}
/**
* 在 worker 线程中执行原生 DAT 解密,不阻塞主线程。
* worker 不可用时回退为主线程同步解密(行为与旧版一致)。
*/
export async function decryptDatViaNativeAsync(
inputPath: string,
xorKey: number,
aesKey?: string
): Promise<WorkerDecryptResult> {
if (!shouldEnableNative()) return null
const worker = ensureDecryptWorker()
if (!worker) {
return decryptDatViaNative(inputPath, xorKey, aesKey)
}
const id = ++workerJobSeq
return new Promise<WorkerDecryptResult>((resolve) => {
pendingJobs.set(id, { resolve })
try {
worker.postMessage({ id, datPath: inputPath, xorKey, aesKey })
} catch {
pendingJobs.delete(id)
resolve(decryptDatViaNative(inputPath, xorKey, aesKey))
}
})
}
export function terminateDecryptWorker(): void {
if (decryptWorker) {
void decryptWorker.terminate()
decryptWorker = null
}
flushPendingJobs()
}
@@ -1,5 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { app } from 'electron'
import { ConfigService } from './config'
/** 缓存版本号。增加/修改 SessionStatsCacheStats 字段后必须提升,避免旧缓存被误用。 */
@@ -131,6 +133,9 @@ function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private persistTimer: NodeJS.Timeout | null = null
private persistInFlight = false
private persistDirty = false
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
@@ -143,6 +148,7 @@ export class SessionStatsCacheService {
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
app?.once('will-quit', () => this.flushSync())
}
private ensureCacheDir(): void {
@@ -251,6 +257,10 @@ export class SessionStatsCacheService {
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
@@ -289,7 +299,43 @@ export class SessionStatsCacheService {
this.store.scopes = trimmedScopes
}
/**
* 防抖异步落盘:批量刷新会话统计时 set() 会被连续调用,
* 同步写盘会反复阻塞主线程(文件可达数百 KB)
*/
private persist(): void {
if (this.persistTimer) return
this.persistTimer = setTimeout(() => {
this.persistTimer = null
void this.persistNow()
}, 1000)
this.persistTimer.unref?.()
}
private async persistNow(): Promise<void> {
if (this.persistInFlight) {
this.persistDirty = true
return
}
this.persistInFlight = true
try {
await writeFile(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
} finally {
this.persistInFlight = false
if (this.persistDirty) {
this.persistDirty = false
void this.persistNow()
}
}
}
/** 退出前把尚未落盘的改动同步写入 */
private flushSync(): void {
if (!this.persistTimer) return
clearTimeout(this.persistTimer)
this.persistTimer = null
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
+50 -8
View File
@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync, unlinkSync } from 'fs'
import { existsSync, mkdirSync, unlinkSync, renameSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
@@ -908,12 +908,53 @@ class SnsService {
return emojiDir
}
/**
* 计算稳定的缓存标识:剥离 token、idx 等会随微信重新同步而变化的鉴权参数,
* 仅保留 host + path + 稳定查询参数。避免 token 变化后缓存无法命中,
* 导致对方删除朋友圈时回源 404 而误判为"已删除"。
*/
private normalizeCacheUrl(url: string): string {
if (!url) return url
try {
const u = new URL(url)
u.searchParams.delete('token')
u.searchParams.delete('idx')
const query = u.searchParams.toString()
return `${u.host}${u.pathname}${query ? `?${query}` : ''}`
} catch {
return url
.replace(/([?&])(?:token|idx)=[^&]*/gi, '$1')
.replace(/[?&]+$/g, '')
.replace(/&&+/g, '&')
.replace(/\?&/g, '?')
}
}
private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex')
const hash = crypto.createHash('md5').update(this.normalizeCacheUrl(url)).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
return join(this.getSnsCacheDir(), `${hash}${ext}`)
}
/**
* 历史缓存一次性迁移:旧版本用完整 URL(含 token)的 md5 命名缓存文件,
* 改为规范化 URL 命名后会全部失配。这里按需将当前 URL 对应的旧命名文件
* 重命名为新命名,仅针对单个 URL、不遍历目录,迁移一次后不再触发,避免反复扫描。
*/
private migrateLegacyCacheFile(url: string, newPath: string): void {
try {
if (existsSync(newPath)) return
const legacyHash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
const legacyPath = join(this.getSnsCacheDir(), `${legacyHash}${ext}`)
if (legacyPath !== newPath && existsSync(legacyPath)) {
renameSync(legacyPath, newPath)
}
} catch (e) {
console.warn('[SnsService] 迁移历史缓存失败:', e)
}
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.getSnsUsernames()
if (!result.success) {
@@ -1325,9 +1366,9 @@ class SnsService {
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> {
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; status?: number; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
const cacheKey = `${this.normalizeCacheUrl(url)}|${key ?? ''}`
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
if (cachedDataUrl) {
@@ -1370,7 +1411,7 @@ class SnsService {
return { success: true, dataUrl }
}
}
return { success: false, error: result.error }
return { success: false, status: result.status, error: result.error }
}
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
@@ -2052,11 +2093,12 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
</html>`
}
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; status?: number; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url)
this.migrateLegacyCacheFile(url, cachePath)
// 1. 优先尝试从当前缓存目录读取
if (existsSync(cachePath)) {
@@ -2107,7 +2149,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
if (res.statusCode !== 200 && res.statusCode !== 206) {
fileStream.close()
fs.unlink(tmpPath, () => { }) // 删除临时文件
resolve({ success: false, error: `HTTP ${res.statusCode}` })
resolve({ success: false, status: res.statusCode, error: `HTTP ${res.statusCode}` })
return
}
@@ -2208,7 +2250,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200 && res.statusCode !== 206) {
console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`)
resolve({ success: false, error: `HTTP ${res.statusCode}` })
resolve({ success: false, status: res.statusCode, error: `HTTP ${res.statusCode}` })
return
}
+64 -5
View File
@@ -59,6 +59,8 @@ export class WcdbCore {
private wcdbInitProtection: any = null
private wcdbInit: any = null
private wcdbShutdown: any = null
private wcdbPurgeMemory: any = null
private purgeTimer: ReturnType<typeof setInterval> | null = null
private wcdbOpenAccount: any = null
private wcdbCloseAccount: any = null
private wcdbSetMyWxid: any = null
@@ -680,6 +682,21 @@ export class WcdbCore {
return ''
}
private pickFirstDisplayNameField(row: Record<string, any>, candidates: string[]): string {
for (const key of candidates) {
const value = row[key]
if (typeof value === 'string') {
if (value.length > 0) return value.trim() || value
continue
}
if (value !== null && value !== undefined) {
const converted = String(value)
if (converted.length > 0) return converted.trim() || converted
}
}
return ''
}
private escapeSqlString(value: string): string {
return String(value || '').replace(/'/g, "''")
}
@@ -854,6 +871,13 @@ export class WcdbCore {
// wcdb_status wcdb_shutdown()
this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()')
// wcdb_status wcdb_purge_memory()(旧版 DLL 无此导出,容错处理)
try {
this.wcdbPurgeMemory = this.lib.func('int32 wcdb_purge_memory()')
} catch {
this.wcdbPurgeMemory = null
}
// wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle)
// wcdb_handle 是 int64_t
this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)')
@@ -1616,7 +1640,8 @@ export class WcdbCore {
private writeDisplayNameCache(username: string, displayName: string, now = Date.now()): void {
const normalizedUsername = String(username || '').trim()
const normalizedDisplayName = String(displayName || '').trim()
const rawDisplayName = typeof displayName === 'string' ? displayName : String(displayName || '')
const normalizedDisplayName = rawDisplayName.trim() || rawDisplayName
if (!normalizedUsername || !normalizedDisplayName) return
this.displayNameCache.set(normalizedUsername, {
displayName: normalizedDisplayName,
@@ -1865,6 +1890,7 @@ export class WcdbCore {
if (this.isLogEnabled()) {
this.startLogPolling()
}
this.startPeriodicPurge()
this.writeLog(`open ok handle=${handle}`, true)
await this.dumpDbStatus('open')
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
@@ -1881,11 +1907,36 @@ export class WcdbCore {
* 关闭数据库
* 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替
*/
/**
* 周期性回收 WCDB 空闲连接与页缓存:批量查询(会话统计/分析)会让多库多连接
* 的页缓存驻留,purgeAll 只关闭空闲连接,热连接不受影响,代价可忽略
*/
private startPeriodicPurge(): void {
if (this.purgeTimer || !this.wcdbPurgeMemory) return
this.purgeTimer = setInterval(() => {
if (!this.initialized) return
try {
this.wcdbPurgeMemory()
} catch {
// 忽略:purge 失败不影响功能
}
}, 60 * 1000)
this.purgeTimer.unref?.()
}
private stopPeriodicPurge(): void {
if (this.purgeTimer) {
clearInterval(this.purgeTimer)
this.purgeTimer = null
}
}
close(): void {
if (this.handle !== null || this.initialized) {
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
try { this.stopMonitor() } catch {}
try { this.cloudStop() } catch {}
this.stopPeriodicPurge()
try {
// 不调用 closeAccount,直接 shutdown
this.wcdbShutdown()
@@ -2696,7 +2747,7 @@ export class WcdbCore {
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
displayName: this.pickFirstDisplayNameField(row, ['displayName', 'display_name', 'remark']),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
@@ -2876,7 +2927,10 @@ export class WcdbCore {
const displayNameRes = await this.getDisplayNames(unresolvedSessionIds)
if (displayNameRes.success && displayNameRes.map) {
unresolvedSessionIds.forEach((sessionId) => {
const display = String(displayNameRes.map?.[sessionId] || '').trim()
const rawDisplay = displayNameRes.map?.[sessionId]
const display = typeof rawDisplay === 'string'
? (rawDisplay.trim() || rawDisplay)
: ''
if (display) sessionNameMap.set(sessionId, display)
})
items = items.map((item) => ({
@@ -2949,7 +3003,7 @@ export class WcdbCore {
for (const row of (q.rows || []) as Array<Record<string, any>>) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
if (!username) continue
const display = this.pickFirstStringField(row, [
const display = this.pickFirstDisplayNameField(row, [
'remark', 'Remark',
'nick_name', 'nickName', 'nickname', 'NickName',
'alias', 'Alias'
@@ -2992,7 +3046,12 @@ export class WcdbCore {
const map = JSON.parse(jsonStr)
if (map && typeof map === 'object') {
for (const username of toFetch) {
const display = String((map as Record<string, unknown>)[username] || '').trim()
const rawDisplay = (map as Record<string, unknown>)[username]
const display = typeof rawDisplay === 'string'
? rawDisplay
: rawDisplay === null || rawDisplay === undefined
? ''
: String(rawDisplay).trim()
if (!display) continue
resultMap[username] = display
this.writeDisplayNameCache(username, display, now)
+31
View File
@@ -38,6 +38,32 @@ export function setNotificationNavigateHandler(
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
// 空闲销毁:隐藏的通知窗口(含渲染进程)常驻占用 ~120MB 工作集,
// 通知稀少时不值得养着。隐藏后闲置超时即销毁,下一条通知重新冷启动
const IDLE_DESTROY_DELAY_MS = 3 * 60 * 1000;
let idleDestroyTimer: NodeJS.Timeout | null = null;
function cancelIdleDestroy() {
if (idleDestroyTimer) {
clearTimeout(idleDestroyTimer);
idleDestroyTimer = null;
}
}
function scheduleIdleDestroy() {
cancelIdleDestroy();
idleDestroyTimer = setTimeout(() => {
idleDestroyTimer = null;
// 可见期间不销毁(cancel/schedule 时序兜底)
if (notificationWindow && !notificationWindow.isDestroyed() && notificationWindow.isVisible()) {
scheduleIdleDestroy();
return;
}
destroyNotificationWindow();
}, IDLE_DESTROY_DELAY_MS);
idleDestroyTimer.unref?.();
}
// 原生玻璃面板:与通知窗口一样常驻复用(创建后隐藏待命),
// 展示期跟随渲染层上报的卡片实测矩形(notification:glassRect)
let glassPanel: import("@hicccc77/electron-liquid-glass").GlassPanel | null = null;
@@ -138,6 +164,7 @@ function refreshDesktopSourceId(): Promise<void> {
}
export function destroyNotificationWindow() {
cancelIdleDestroy();
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
@@ -290,6 +317,7 @@ export async function showNotification(data: any) {
return;
}
cancelIdleDestroy();
let win = notificationWindow;
if (!win || win.isDestroyed()) {
win = createNotificationWindow();
@@ -469,6 +497,7 @@ export async function registerNotificationHandlers() {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
scheduleIdleDestroy();
});
// —— 原生玻璃面板生命周期(仅 nativeGlass 可用时渲染层才会发这些消息)——
@@ -555,6 +584,8 @@ export async function registerNotificationHandlers() {
[],
);
}
// 预热窗口若迟迟没有通知到来,按空闲策略回收
scheduleIdleDestroy();
}, 3000);
if (!nativeGlass) {
// 显示器增删后源 ID 可能失效,重新预热
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
// 构建前清空 dist-electron:vite 产物带内容 hash(如 config-XXXX.js),
// 不清理会让旧版本 chunk 无限堆积并被误打进安装包
const { rmSync, existsSync } = require('fs')
const { join } = require('path')
const dir = join(__dirname, '..', 'dist-electron')
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
console.log('[clean] dist-electron removed')
}
+3 -2
View File
@@ -20,8 +20,9 @@
position: fixed;
top: 0;
left: 0;
right: 150px;
height: 40px;
right: 0;
height: 48px;
app-region: drag;
-webkit-app-region: drag;
pointer-events: auto;
z-index: 2000;
+86 -28
View File
@@ -3,31 +3,13 @@ import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard'
import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
import AgreementPage from './pages/AgreementPage'
import SettingsPage from './pages/SettingsPage'
import MyFootprintPage from './pages/MyFootprintPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import BizPage from './pages/BizPage'
import ContactsPage from './pages/ContactsPage'
import ResourcesPage from './pages/ResourcesPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AccountManagementPage from './pages/AccountManagementPage'
import BackupPage from './pages/BackupPage'
import InsightInboxPage from './pages/InsightInboxPage'
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 { Shield } from 'lucide-react'
import './App.scss'
import UpdateDialog from './components/UpdateDialog'
@@ -35,9 +17,29 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import WindowCloseDialog from './components/WindowCloseDialog'
import { resolveAutomationScopeKey } from './pages/Export/hooks/useAutomation'
// 重型页面懒加载:echarts 分析页、年度/双人报告(含大字体样式)、导出页模块树,
// 从首屏 bundle 中拆出,显著缩短主窗口首次渲染时间
// 全部页面懒加载:主窗口首屏只解析 App 壳 + HomePage;
// 常驻的通知窗口等独立窗口路由也因此只加载各自的小 chunk,
// 显著降低每个渲染进程的 JS 堆占用与启动时间
const WelcomePage = lazy(() => import('./pages/WelcomePage'))
const ChatPage = lazy(() => import('./pages/ChatPage'))
const AnalyticsWelcomePage = lazy(() => import('./pages/AnalyticsWelcomePage'))
const ChatAnalyticsHubPage = lazy(() => import('./pages/ChatAnalyticsHubPage'))
const AgreementPage = lazy(() => import('./pages/AgreementPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const MyFootprintPage = lazy(() => import('./pages/MyFootprintPage'))
const VideoWindow = lazy(() => import('./pages/VideoWindow'))
const ImageWindow = lazy(() => import('./pages/ImageWindow'))
const SnsPage = lazy(() => import('./pages/SnsPage'))
const BizPage = lazy(() => import('./pages/BizPage'))
const ContactsPage = lazy(() => import('./pages/ContactsPage'))
const ResourcesPage = lazy(() => import('./pages/ResourcesPage'))
const ChatHistoryPage = lazy(() => import('./pages/ChatHistoryPage'))
const NotificationWindow = lazy(() => import('./pages/NotificationWindow'))
const AccountManagementPage = lazy(() => import('./pages/AccountManagementPage'))
const BackupPage = lazy(() => import('./pages/BackupPage'))
const InsightInboxPage = lazy(() => import('./pages/InsightInboxPage'))
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
const GroupAnalyticsPage = lazy(() => import('./pages/GroupAnalyticsPage'))
const AnnualReportPage = lazy(() => import('./pages/AnnualReportPage'))
@@ -93,6 +95,8 @@ function App() {
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
: location
const isExportRoute = routeLocation.pathname === '/export'
// Export 模块按需挂载:首次进入导出页,或存在启用的自动化任务(调度器在导出页内)时才挂载
const [exportMounted, setExportMounted] = useState(false)
const [themeHydrated, setThemeHydrated] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showCloseDialog, setShowCloseDialog] = useState(false)
@@ -121,6 +125,32 @@ function App() {
}
}, [location])
const isStandaloneWindow =
isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow || isChatHistoryWindow ||
isStandaloneChatWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow ||
location.pathname === '/image-viewer-window'
useEffect(() => {
if (isExportRoute && !exportMounted) setExportMounted(true)
}, [isExportRoute, exportMounted])
// 存在启用的自动化导出任务时,即使未访问导出页也需挂载(30s 调度器运行在导出页内)
useEffect(() => {
if (exportMounted || isStandaloneWindow) return
let cancelled = false
;(async () => {
try {
const scopeKey = await resolveAutomationScopeKey()
const item = await configService.getExportAutomationTasks(scopeKey)
if (!cancelled && item?.tasks?.some(task => task.enabled)) setExportMounted(true)
} catch {
// 读取失败视为无自动化任务
}
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportMounted, isStandaloneWindow])
useEffect(() => {
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
@@ -485,27 +515,47 @@ function App() {
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
return (
<Suspense fallback={null}>
<AgreementPage />
</Suspense>
)
}
if (isOnboardingWindow) {
return <WelcomePage standalone />
return (
<Suspense fallback={null}>
<WelcomePage standalone />
</Suspense>
)
}
// 独立视频播放窗口
if (isVideoPlayerWindow) {
return <VideoWindow />
return (
<Suspense fallback={null}>
<VideoWindow />
</Suspense>
)
}
// 独立图片查看窗口
const isImageViewerWindow = location.pathname === '/image-viewer-window'
if (isImageViewerWindow) {
return <ImageWindow />
return (
<Suspense fallback={null}>
<ImageWindow />
</Suspense>
)
}
// 独立聊天记录窗口
if (isChatHistoryWindow) {
return <ChatHistoryPage />
return (
<Suspense fallback={null}>
<ChatHistoryPage />
</Suspense>
)
}
// 独立会话聊天窗口(仅显示聊天内容区域)
@@ -517,6 +567,7 @@ function App() {
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<Suspense fallback={null}>
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
@@ -525,12 +576,17 @@ function App() {
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
</Suspense>
)
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
return (
<Suspense fallback={null}>
<NotificationWindow />
</Suspense>
)
}
// 独立年度报告全屏窗口
@@ -707,12 +763,14 @@ function App() {
<Sidebar collapsed={sidebarCollapsed} />
<main className="content">
<RouteGuard>
{/* 独立 Suspense 边界:Export chunk 异步加载时不阻塞首屏路由渲染 */}
{/* Export 模块按需挂载(首次访问或有自动化任务时),挂载后 keepalive 保持任务/调度状态 */}
{exportMounted && (
<Suspense fallback={null}>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
</Suspense>
)}
<Suspense fallback={null}>
<Routes location={routeLocation}>
@@ -292,6 +292,33 @@
}
}
.media-size-limit-control {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
color: var(--text-secondary);
font-size: 13px;
input {
width: 84px;
height: 34px;
padding: 0 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
}
.log-status {
font-size: 13px;
color: var(--text-secondary);
@@ -431,6 +458,15 @@
margin-bottom: 0;
}
.media-size-limit-control {
max-width: 360px;
}
.media-setting-group .form-control {
flex-direction: column;
align-items: stretch;
}
.concurrency-inline-options {
max-width: 360px;
margin-bottom: 0;
@@ -488,6 +524,7 @@
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.media-size-limit-control,
.concurrency-inline-options,
.format-grid {
max-width: none;
@@ -557,6 +594,7 @@
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.media-size-limit-control,
.concurrency-inline-options {
max-width: none;
width: 100%;
@@ -631,6 +669,11 @@
}
}
}
.media-setting-group .form-control {
flex-direction: column;
align-items: stretch;
}
}
.select-dropdown-floating {
@@ -125,7 +125,8 @@ export function ExportDefaultsSettingsForm({
videos: true,
voices: true,
emojis: true,
files: true
files: true,
maxFileSizeMb: 200
})
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
@@ -156,7 +157,8 @@ export function ExportDefaultsSettingsForm({
videos: true,
voices: true,
emojis: true,
files: true
files: true,
maxFileSizeMb: 200
})
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
@@ -548,6 +550,24 @@ export function ExportDefaultsSettingsForm({
<span>文件</span>
</label>
</div>
<div className="media-size-limit-control">
<span>视频/文件最大体积</span>
<input
type="number"
min={1}
max={4096}
value={exportDefaultMedia.maxFileSizeMb}
onChange={async (e) => {
const maxFileSizeMb = Math.max(1, Math.min(4096, Math.floor(Number(e.target.value) || 1)))
const next = { ...exportDefaultMedia, maxFileSizeMb }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已将最大文件限制设为 ${maxFileSizeMb} MB`, true)
}}
/>
<span>MB</span>
</div>
</div>
</div>
+46 -20
View File
@@ -1,18 +1,14 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models'
import { useNavigate } from 'react-router-dom'
import { useSessionMetrics } from '../pages/Export/hooks/useSessionMetrics'
import { buildNewMessagesCursor } from '../pages/Chat/messageCursor'
import { displayNameOrFallback, pickDisplayName } from '../utils/displayName'
export function GlobalSessionMonitor() {
const navigate = useNavigate()
const {
sessions,
setSessions,
currentSessionId,
appendMessages,
messages
appendMessages
} = useChatStore()
const sessionsRef = useRef(sessions)
@@ -27,8 +23,30 @@ export function GlobalSessionMonitor() {
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}
// 处理数据库变更
// 处理数据库变更(防抖 + 串行:微信同步期间 Session 表变更事件会连续触发,
// 每次全量 getSessions + 富化的 IPC 成本高,合并突发事件只刷新一次)
useEffect(() => {
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let refreshing = false
let pendingRefresh = false
const runRefresh = async () => {
if (refreshing) {
pendingRefresh = true
return
}
refreshing = true
try {
await refreshSessions()
} finally {
refreshing = false
if (pendingRefresh) {
pendingRefresh = false
void runRefresh()
}
}
}
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
try {
const payload = JSON.parse(data.json)
@@ -36,7 +54,11 @@ export function GlobalSessionMonitor() {
// 只关注 Session 表
if (tableName === 'Session' || tableName === 'session') {
refreshSessions()
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void runRefresh()
}, 400)
}
} catch (e) {
console.error('解析数据库变更失败:', e)
@@ -46,6 +68,7 @@ export function GlobalSessionMonitor() {
if (window.electronAPI.chat.onWcdbChange) {
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
return () => {
if (debounceTimer) clearTimeout(debounceTimer)
removeListener()
}
}
@@ -70,9 +93,9 @@ export function GlobalSessionMonitor() {
// 2. 更新 store
setSessions(newSessions)
// 2.5 预加载导出相关的会话消息数量统计 (避免进入导出页面时显示为 0)
const sessionIds = newSessions.map(s => s.username)
useSessionMetrics.getState().fetchMetrics(sessionIds).catch(console.error)
// 注意:不再在每次 Session 变更时全量预载导出统计
// (2000+ 会话 × 多库统计查询的 IPC 风暴是卡顿主因之一;
// 导出页会按需拉取可见行的统计,主进程有磁盘缓存兜底)
// 3. 如果在活跃会话中,增量刷新消息
const currentId = useChatStore.getState().currentSessionId
@@ -167,7 +190,7 @@ export function GlobalSessionMonitor() {
continue
}
let title = newSession.displayName || newSession.username
let title = displayNameOrFallback(newSession.username, newSession.displayName)
let avatarUrl = newSession.avatarUrl
let content = newSession.summary || '[新消息]'
@@ -194,22 +217,24 @@ export function GlobalSessionMonitor() {
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
// 标题保持为群聊名称 (title 变量)
if (newSession.lastSenderDisplayName) {
content = `${newSession.lastSenderDisplayName}: ${content}`
const lastSenderDisplayName = pickDisplayName(newSession.lastSenderDisplayName)
if (lastSenderDisplayName) {
content = `${lastSenderDisplayName}: ${content}`
}
}
// 修复 "Random User" 的逻辑 (缺少具体信息)
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
const needsEnrichment = !pickDisplayName(newSession.displayName) || !newSession.avatarUrl || newSession.displayName === newSession.username
if (needsEnrichment && newSession.username) {
try {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
const contactDisplayName = pickDisplayName(contact.remark, contact.nickName)
if (contactDisplayName) {
title = contactDisplayName
}
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
@@ -221,8 +246,9 @@ export function GlobalSessionMonitor() {
if (enrichResult.success && enrichResult.contacts) {
const enrichedContact = enrichResult.contacts[newSession.username]
if (enrichedContact) {
if (enrichedContact.displayName) {
title = enrichedContact.displayName
const enrichedDisplayName = pickDisplayName(enrichedContact.displayName)
if (enrichedDisplayName) {
title = enrichedDisplayName
}
if (enrichedContact.avatarUrl) {
avatarUrl = enrichedContact.avatarUrl
@@ -233,7 +259,7 @@ 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
title = displayNameOrFallback(title, retried.remark, retried.nickName)
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
+2 -1
View File
@@ -73,7 +73,8 @@
.notification-body {
font-size: 13px;
color: var(--noti-body-color, #4d4d4d);
// 与标题同色(近黑),层级靠字重区分:标题 600、正文默认 400
color: var(--noti-body-color, #2c2c2c);
text-shadow: var(--noti-body-halo, none);
transition: color 0.08s linear, text-shadow 0.08s linear;
line-height: 1.4;
+4
View File
@@ -169,6 +169,10 @@
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
}
.sidebar-lock-action {
justify-content: center;
}
// ---- Footer ----
.sidebar-footer {
padding: 4px 10px;
+5 -3
View File
@@ -309,6 +309,7 @@ function Sidebar({ collapsed }: SidebarProps) {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
const lockActionLabel = authEnabled ? '锁定应用' : '开启应用锁'
return (
<>
@@ -437,7 +438,7 @@ function Sidebar({ collapsed }: SidebarProps) {
<div className="sidebar-footer">
<button
className="nav-item"
className="nav-item sidebar-lock-action"
onClick={() => {
if (authEnabled) {
setLocked(true)
@@ -450,10 +451,11 @@ function Sidebar({ collapsed }: SidebarProps) {
}
})
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
title={collapsed ? lockActionLabel : undefined}
aria-label={lockActionLabel}
>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
<span className="nav-label">{lockActionLabel}</span>
</button>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
@@ -9,6 +9,7 @@ import {
type ContactSnsTimelineTarget,
getAvatarLetter
} from './contactSnsTimeline'
import { displayNameOrFallback, pickDisplayName } from '../../utils/displayName'
import './ContactSnsTimelineDialog.scss'
const TIMELINE_PAGE_SIZE = 20
@@ -54,7 +55,7 @@ const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[
const comments = Array.isArray(post?.comments) ? post.comments : []
for (const likeNameRaw of likes) {
const name = String(likeNameRaw || '').trim() || '未知用户'
const name = pickDisplayName(likeNameRaw) || '未知用户'
const current = likeMap.get(name)
if (current) {
current.count += 1
@@ -65,7 +66,7 @@ const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[
}
for (const comment of comments) {
const name = String(comment?.nickname || '').trim() || '未知用户'
const name = pickDisplayName(comment?.nickname) || '未知用户'
const current = commentMap.get(name)
if (current) {
current.count += 1
@@ -119,7 +120,7 @@ export function ContactSnsTimelineDialog({
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
const targetUsername = String(target?.username || '').trim()
const targetDisplayName = target?.displayName || targetUsername
const targetDisplayName = displayNameOrFallback(targetUsername, target?.displayName)
const targetAvatarUrl = target?.avatarUrl
useEffect(() => {
+38 -7
View File
@@ -100,8 +100,9 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
const skipDecrypt = postType === 7
// 视频重试:失败时重试最多2次,耗尽才标记删除
const videoRetryOrDelete = () => {
// 视频重试:服务器明确不存在(404/410)判定已删除;其余失败重试最多2次,耗尽才标记删除
const videoRetryOrDelete = (status?: number) => {
if (status === 404 || status === 410) { markDeleted(); return }
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
@@ -110,11 +111,30 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
}
}
// Simple effect to load image/decrypt
// 图片失败处理:服务器明确不存在(404/410)判定已删除;其余视为临时失败,
// 有限重试后显示可重试的加载失败态,避免把网络波动误判为"已删除"。
const imageRetryOrFail = (status?: number) => {
if (status === 404 || status === 410) { markDeleted(); return }
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
} else {
setError(true)
}
}
const handleRetry = (e: React.MouseEvent) => {
e.stopPropagation()
retryCount.current = 0
setError(false)
setRetryKey(k => k + 1)
}
// Simple effect to load image/decrypt
React.useEffect(() => {
let cancelled = false
setLoading(true)
setError(false)
const load = async () => {
try {
@@ -130,7 +150,7 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
markDeleted()
imageRetryOrFail(result.status)
}
// Pre-load live photo video if needed
@@ -171,7 +191,7 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
if (!cancelled) setThumbSrc(localPath)
}
} else {
videoRetryOrDelete()
videoRetryOrDelete(result.status)
}
setIsGeneratingCover(false)
@@ -183,7 +203,7 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
imageRetryOrFail()
}
setLoading(false)
setIsGeneratingCover(false)
@@ -281,6 +301,17 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
)
}
if (error) {
return (
<div className="sns-media-item failed-media" onClick={handleRetry} title="点击重试">
<div className="failed-placeholder">
<RefreshCw size={24} />
<span>加载失败,点击重试</span>
</div>
</div>
)
}
return (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
@@ -305,7 +336,7 @@ const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsM
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => { if (!loading && !isVideo) markDeleted() }}
onError={() => { if (!loading && !isVideo) imageRetryOrFail() }}
alt=""
/>
) : null}
+25 -2
View File
@@ -6,6 +6,7 @@
justify-content: space-between;
padding-left: 16px;
padding-right: 8px;
app-region: drag;
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
@@ -17,6 +18,23 @@
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.title-custom-controls {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
app-region: no-drag;
-webkit-app-region: no-drag;
}
.title-drag-spacer {
flex: 1 1 auto;
align-self: stretch;
min-width: 12px;
app-region: drag;
-webkit-app-region: drag;
}
.title-logo {
@@ -45,6 +63,7 @@
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
app-region: no-drag;
-webkit-app-region: no-drag;
&:hover {
@@ -57,7 +76,9 @@
display: inline-flex;
align-items: center;
gap: 2px;
-webkit-app-region: no-drag;
flex: 0 0 auto;
app-region: drag;
-webkit-app-region: drag;
}
.title-window-control-btn {
@@ -73,6 +94,8 @@
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
app-region: no-drag;
-webkit-app-region: no-drag;
&:hover {
background: var(--bg-hover);
@@ -89,8 +112,8 @@
display: flex;
align-items: center;
gap: 8px;
margin-right: auto;
padding-left: 16px;
app-region: no-drag;
-webkit-app-region: no-drag;
button {
+5
View File
@@ -50,7 +50,12 @@ function TitleBar({
</button>
) : null}
</div>
{customControls ? (
<div className="title-custom-controls">
{customControls}
</div>
) : null}
<div className="title-drag-spacer" aria-hidden="true" />
{showWindowControls ? (
<div className="title-window-controls">
<button
+2 -1
View File
@@ -17,6 +17,7 @@ import {
import { Avatar } from '../../components/Avatar'
import type { ChatSession } from '../../types/models'
import type { BatchVoiceTaskType } from '../../stores/batchTranscribeStore'
import { displayNameOrFallback } from '../../utils/displayName'
export interface ChatHeaderProps {
session: ChatSession
@@ -95,7 +96,7 @@ function ChatHeader({
onRefreshMessages,
onToggleDetailPanel
}: ChatHeaderProps) {
const sessionName = session.displayName || session.username
const sessionName = displayNameOrFallback(session.username, session.displayName)
const exportTitle = isCurrentSessionExporting
? '导出中'
: isPreparingExportDialog
+4 -2
View File
@@ -2,6 +2,7 @@ import React from 'react'
import { Check } from 'lucide-react'
import { Avatar } from '../../components/Avatar'
import type { ChatSession, Message } from '../../types/models'
import { displayNameOrFallback } from '../../utils/displayName'
export interface ChatMessageBubbleProps {
message: Message
@@ -34,6 +35,7 @@ export interface ChatMessageBubbleProps {
export interface MessageAvatarProfile {
username?: string
displayName: string
groupNickname?: string
avatarUrl?: string
isSelf?: boolean
isGroupMember?: boolean
@@ -76,7 +78,7 @@ function ChatMessageBubble({
}: ChatMessageBubbleProps) {
const bubbleClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const avatarName = !isSent
? (isGroupChat ? (resolvedSenderName || '?') : (session.displayName || session.username))
? (isGroupChat ? displayNameOrFallback('?', resolvedSenderName) : displayNameOrFallback(session.username, session.displayName))
: '我'
return (
@@ -116,7 +118,7 @@ function ChatMessageBubble({
{isGroupChat && !isSent && (
<div className="sender-line">
<div className="sender-name">
{resolvedSenderName || '群成员'}
{displayNameOrFallback('群成员', resolvedSenderName)}
</div>
{actionNode}
</div>
+133 -73
View File
@@ -10,6 +10,7 @@ import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
import { displayNameOrFallback, pickDisplayName } from '../utils/displayName'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
@@ -105,12 +106,13 @@ const MESSAGE_VIRTUAL_OVERSCAN_PX = 140
const QUOTED_MESSAGE_PROBE_LIMIT = 180
const QUOTED_MESSAGE_MISSING_TIP = '引用消息不存在'
const BYTES_PER_MEGABYTE = 1024 * 1024
// 图片/表情缓存值现在几乎都是文件路径(小字符串),字节上限只防御 base64 兜底路径
const EMOJI_CACHE_MAX_ENTRIES = 260
const EMOJI_CACHE_MAX_BYTES = 32 * BYTES_PER_MEGABYTE
const EMOJI_CACHE_MAX_BYTES = 16 * BYTES_PER_MEGABYTE
const IMAGE_CACHE_MAX_ENTRIES = 360
const IMAGE_CACHE_MAX_BYTES = 64 * BYTES_PER_MEGABYTE
const IMAGE_CACHE_MAX_BYTES = 32 * BYTES_PER_MEGABYTE
const VOICE_CACHE_MAX_ENTRIES = 120
const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE
const VOICE_CACHE_MAX_BYTES = 16 * BYTES_PER_MEGABYTE
const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800
const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE
const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000
@@ -478,6 +480,10 @@ function normalizeSearchIdentityText(value?: string | null): string | undefined
return normalized
}
function normalizeDisplayIdentityText(value?: string | null): string | undefined {
return pickDisplayName(value)
}
function normalizeSearchAvatarUrl(value?: string | null): string | undefined {
const normalized = String(value || '').trim()
if (!normalized) return undefined
@@ -493,7 +499,7 @@ function resolveSessionDisplayName(
sessionId?: string | null
): string | undefined {
const normalizedSessionId = String(sessionId || '').trim()
const normalizedDisplayName = normalizeSearchIdentityText(displayName)
const normalizedDisplayName = normalizeDisplayIdentityText(displayName)
if (!normalizedDisplayName) return undefined
if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) return undefined
return normalizedDisplayName
@@ -516,7 +522,7 @@ function resolveSearchSenderDisplayName(
senderUsername?: string | null,
sessionId?: string | null
): string | undefined {
const normalizedDisplayName = normalizeSearchIdentityText(displayName)
const normalizedDisplayName = normalizeDisplayIdentityText(displayName)
if (!normalizedDisplayName) return undefined
const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername)
@@ -1053,15 +1059,15 @@ function normalizeQuotedGroupMember(member: Partial<GroupPanelMember> | null | u
const username = String(member?.username || '').trim()
if (!username) return null
const displayName = String(member?.displayName || '').trim()
const nickname = String(member?.nickname || '').trim()
const remark = String(member?.remark || '').trim()
const alias = String(member?.alias || '').trim()
const groupNickname = String(member?.groupNickname || '').trim()
const displayName = pickDisplayName(member?.displayName) || ''
const nickname = pickDisplayName(member?.nickname) || ''
const remark = pickDisplayName(member?.remark) || ''
const alias = pickDisplayName(member?.alias) || ''
const groupNickname = pickDisplayName(member?.groupNickname) || ''
return {
username,
displayName: displayName || groupNickname || remark || nickname || alias || username,
displayName: displayNameOrFallback(username, displayName, groupNickname, remark, nickname, alias),
avatarUrl: member?.avatarUrl,
nickname,
alias,
@@ -1103,34 +1109,34 @@ function resolveQuotedSenderUsername(
}
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
const groupNickname = normalizeDisplayIdentityText(member.groupNickname)
if (groupNickname) return groupNickname
const remark = normalizeSearchIdentityText(member.remark)
const remark = normalizeDisplayIdentityText(member.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(member.nickname)
const nickname = normalizeDisplayIdentityText(member.nickname)
if (nickname) return nickname
const displayName = resolveSearchSenderDisplayName(member.displayName, member.username)
if (displayName) return displayName
const alias = normalizeSearchIdentityText(member.alias)
const alias = normalizeDisplayIdentityText(member.alias)
if (alias) return alias
return resolveSearchSenderUsernameFallback(member.username)
}
function resolveQuotedPrivateDisplayName(contact: any): string | undefined {
const remark = normalizeSearchIdentityText(contact?.remark)
const remark = normalizeDisplayIdentityText(contact?.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(
const nickname = normalizeDisplayIdentityText(
contact?.nickName || contact?.nick_name || contact?.nickname
)
if (nickname) return nickname
const alias = normalizeSearchIdentityText(contact?.alias)
const alias = normalizeDisplayIdentityText(contact?.alias)
if (alias) return alias
return undefined
@@ -1490,6 +1496,7 @@ const SessionItem = React.memo(function SessionItem({
}
return <span className="session-summary">{session.summary || '暂无消息'}</span>
}, [session.matchedField, session.username, session.alias, session.summary, searchKeyword])
const sessionDisplayName = displayNameOrFallback(session.username, session.displayName)
return (
<div
@@ -1498,7 +1505,7 @@ const SessionItem = React.memo(function SessionItem({
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
name={sessionDisplayName}
size={48}
className={session.username.includes('@chatroom') ? 'group' : ''}
/>
@@ -1508,9 +1515,9 @@ const SessionItem = React.memo(function SessionItem({
{(() => {
const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword
return shouldHighlight ? (
<HighlightText text={session.displayName || session.username} keyword={searchKeyword} />
<HighlightText text={sessionDisplayName} keyword={searchKeyword} />
) : (
session.displayName || session.username
sessionDisplayName
)
})()}
</span>
@@ -1914,7 +1921,13 @@ function ChatPage(props: ChatPageProps) {
const sessionDisplayName = resolveSessionDisplayName(session.displayName, username)
const previousDisplayName = resolveSessionDisplayName(previousSession?.displayName, username)
const profileDisplayName = resolveSessionDisplayName(profile?.displayName, username)
const resolvedDisplayName = sessionDisplayName || previousDisplayName || profileDisplayName || session.displayName || username
const resolvedDisplayName = displayNameOrFallback(
username,
sessionDisplayName,
previousDisplayName,
profileDisplayName,
session.displayName
)
const sessionAvatarUrl = normalizeSearchAvatarUrl(session.avatarUrl)
const previousAvatarUrl = normalizeSearchAvatarUrl(previousSession?.avatarUrl)
@@ -2131,7 +2144,7 @@ function ChatPage(props: ChatPageProps) {
}
const result = await window.electronAPI.groupSummary.triggerManual({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
displayName: displayNameOrFallback(sessionId, sessionInfo?.displayName),
avatarUrl: sessionInfo?.avatarUrl,
startTime,
endTime
@@ -2147,7 +2160,7 @@ function ChatPage(props: ChatPageProps) {
} else {
const result = await window.electronAPI.groupSummary.triggerDay({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
displayName: displayNameOrFallback(sessionId, sessionInfo?.displayName),
avatarUrl: sessionInfo?.avatarUrl,
date: selectedDate
})
@@ -2541,7 +2554,7 @@ function ChatPage(props: ChatPageProps) {
const taskId = registerBackgroundTask({
sourcePage: 'chat',
title: '聊天页会话详情统计',
detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`,
detail: `准备读取 ${displayNameOrFallback(normalizedSessionId, sessionMapRef.current.get(normalizedSessionId)?.displayName)} 的详情`,
progressText: '基础信息',
cancelable: true
})
@@ -2558,7 +2571,7 @@ function ChatPage(props: ChatPageProps) {
const sameSession = prev?.wxid === normalizedSessionId
return {
wxid: normalizedSessionId,
displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId,
displayName: displayNameOrFallback(normalizedSessionId, mappedSession?.displayName, prev?.displayName),
remark: sameSession ? prev?.remark : undefined,
nickName: sameSession ? prev?.nickName : undefined,
alias: sameSession ? prev?.alias : undefined,
@@ -2628,7 +2641,7 @@ function ChatPage(props: ChatPageProps) {
if (result.success && result.detail) {
setSessionDetail((prev) => ({
wxid: normalizedSessionId,
displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId,
displayName: displayNameOrFallback(normalizedSessionId, result.detail!.displayName, prev?.displayName),
remark: result.detail!.remark,
nickName: result.detail!.nickName,
alias: result.detail!.alias,
@@ -2895,12 +2908,12 @@ function ChatPage(props: ChatPageProps) {
.map((member: GroupPanelMember): GroupPanelMember | null => {
const username = String(member.username || '').trim()
if (!username) return null
const preferredName = String(
member.groupNickname ||
member.remark ||
member.displayName ||
member.nickname ||
username
const preferredName = displayNameOrFallback(
username,
member.groupNickname,
member.remark,
member.displayName,
member.nickname
)
const rawStatus = member.messageCountStatus
const normalizedStatus: GroupMessageCountStatus = options?.messageCountStatus
@@ -3763,7 +3776,7 @@ function ChatPage(props: ChatPageProps) {
const updatedSessions = currentSessions.map(session => {
const update = updates.get(session.username)
if (update) {
const newDisplayName = update.displayName || session.displayName || session.username
const newDisplayName = displayNameOrFallback(session.username, update.displayName, session.displayName)
const newAvatarUrl = update.avatarUrl || session.avatarUrl
const newAlias = update.alias || session.alias
if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl || newAlias !== session.alias) {
@@ -4312,7 +4325,7 @@ function ChatPage(props: ChatPageProps) {
currentSearchSession.username === normalizedInitialSessionId
? {
...currentSearchSession,
displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username,
displayName: displayNameOrFallback(currentSearchSession.username, currentSearchSession.displayName, fallbackDisplayName),
avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined
}
: currentSearchSession
@@ -4321,7 +4334,7 @@ function ChatPage(props: ChatPageProps) {
normalizedSessionId
? {
username: normalizedSessionId,
displayName: fallbackDisplayName || normalizedSessionId,
displayName: displayNameOrFallback(normalizedSessionId, fallbackDisplayName),
avatarUrl: fallbackAvatarUrl || undefined
} as ChatSession
: undefined
@@ -6087,7 +6100,7 @@ function ChatPage(props: ChatPageProps) {
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
displayName: '公众号',
summary: latestOfficial
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
? `${displayNameOrFallback(latestOfficial.username, latestOfficial.displayName)}: ${latestOfficial.summary || '查看公众号历史消息'}`
: '查看公众号历史消息',
type: 0,
sortTimestamp: officialLatestTime,
@@ -6113,7 +6126,7 @@ function ChatPage(props: ChatPageProps) {
const foldEntry: ChatSession = {
username: 'placeholder_foldgroup',
displayName: '折叠的聊天',
summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`,
summary: `${displayNameOrFallback(latestFolded.username, latestFolded.displayName)}: ${latestFolded.summary}`,
type: 0,
sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp,
lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp,
@@ -6253,7 +6266,7 @@ function ChatPage(props: ChatPageProps) {
) {
return {
...found,
displayName: found.displayName || fallbackDisplayName || found.username,
displayName: displayNameOrFallback(found.username, found.displayName, fallbackDisplayName),
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
}
}
@@ -6268,7 +6281,7 @@ function ChatPage(props: ChatPageProps) {
sortTimestamp: 0,
lastTimestamp: 0,
lastMsgType: 0,
displayName: fallbackDisplayName || currentSessionId,
displayName: displayNameOrFallback(currentSessionId, fallbackDisplayName),
avatarUrl: fallbackAvatarUrl || undefined,
} as ChatSession
})()
@@ -6309,7 +6322,7 @@ function ChatPage(props: ChatPageProps) {
if (!currentSession || !isCurrentSessionPrivateSnsSupported) return
setChatSnsTimelineTarget({
username: currentSession.username,
displayName: currentSession.displayName || currentSession.username,
displayName: displayNameOrFallback(currentSession.username, currentSession.displayName),
avatarUrl: currentSession.avatarUrl
})
}, [currentSession, isCurrentSessionPrivateSnsSupported])
@@ -6684,7 +6697,7 @@ function ChatPage(props: ChatPageProps) {
if (inProgressExportSessionIds.has(currentSessionId) || isPreparingExportDialog) return
const requestId = `chat-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const sessionName = currentSession?.displayName || currentSession?.username || currentSessionId
const sessionName = displayNameOrFallback(currentSessionId, currentSession?.displayName, currentSession?.username)
pendingExportRequestIdRef.current = requestId
setIsPreparingExportDialog(true)
setExportPrepareHint('')
@@ -6713,7 +6726,7 @@ function ChatPage(props: ChatPageProps) {
try {
const result = await window.electronAPI.insight.triggerSessionInsight({
sessionId,
displayName: session?.displayName || sessionId,
displayName: displayNameOrFallback(sessionId, session?.displayName),
avatarUrl: session?.avatarUrl
})
if (currentSessionRef.current !== sessionId) return
@@ -6817,7 +6830,7 @@ function ChatPage(props: ChatPageProps) {
}
}
startTranscribe(totalVoices, session.displayName || session.username, taskType, 'chat', {
startTranscribe(totalVoices, displayNameOrFallback(session.username, session.displayName), taskType, 'chat', {
cancelable: true,
resumable: true,
onPause: () => {
@@ -7038,7 +7051,7 @@ function ChatPage(props: ChatPageProps) {
}
}
startDecrypt(totalImages, session.displayName || session.username, 'chat', {
startDecrypt(totalImages, displayNameOrFallback(session.username, session.displayName), 'chat', {
cancelable: true,
resumable: true,
onPause: () => {
@@ -7328,7 +7341,7 @@ function ChatPage(props: ChatPageProps) {
const nextDetail = detailResult.value.detail
detail = {
wxid: nextDetail.wxid || username,
displayName: nextDetail.displayName || target.displayName || username,
displayName: displayNameOrFallback(username, nextDetail.displayName, target.displayName),
remark: nextDetail.remark,
nickName: nextDetail.nickName,
alias: nextDetail.alias,
@@ -7397,14 +7410,14 @@ function ChatPage(props: ChatPageProps) {
? cachedSelfProfile!.alias
: undefined
const displayName = profile.isSelf
? (selfDisplayName || selfAlias || profile.displayName || username || '微信用户')
: (normalizeSearchIdentityText(profile.displayName) || profile.displayName || username || '微信用户')
? displayNameOrFallback('微信用户', selfDisplayName, selfAlias, profile.displayName, username)
: displayNameOrFallback('微信用户', normalizeDisplayIdentityText(profile.displayName), profile.displayName, username)
const avatarUrl = normalizeSearchAvatarUrl(profile.avatarUrl) ||
(profile.isSelf ? normalizeSearchAvatarUrl(cachedSelfProfile?.avatarUrl) || myAvatarUrl : undefined)
const sourceLabel = profile.isSelf
? '我发送的消息'
: profile.isGroupMember
? (currentSession?.displayName || currentSessionId || '群聊')
? displayNameOrFallback('群聊', currentSession?.displayName, currentSessionId)
: '当前聊天'
const target: AvatarProfileTarget = {
...profile,
@@ -7457,7 +7470,13 @@ function ChatPage(props: ChatPageProps) {
const detail = avatarProfileCard.detail
setChatSnsTimelineTarget({
username: avatarProfileCard.target.username,
displayName: detail?.remark || detail?.nickName || detail?.displayName || avatarProfileCard.target.displayName || avatarProfileCard.target.username,
displayName: displayNameOrFallback(
avatarProfileCard.target.username,
detail?.remark,
detail?.nickName,
detail?.displayName,
avatarProfileCard.target.displayName
),
avatarUrl: detail?.avatarUrl || avatarProfileCard.target.avatarUrl
})
closeAvatarProfileCard()
@@ -7699,6 +7718,11 @@ function ChatPage(props: ChatPageProps) {
const groupSenderDisplayName = matchedGroupMember
? resolveQuotedGroupMemberDisplayName(matchedGroupMember)
: undefined
const groupSenderNickname = pickDisplayName(
matchedGroupMember?.groupNickname,
groupSenderDisplayName,
isCurrentSessionGroup ? msg.senderDisplayName : undefined
)
return (
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
@@ -7715,6 +7739,7 @@ function ChatPage(props: ChatPageProps) {
myWxid={myWxid}
isGroupChat={isCurrentSessionGroup}
groupSenderDisplayName={groupSenderDisplayName}
groupSenderNickname={groupSenderNickname}
quoteLayout={quoteLayout}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
@@ -7942,12 +7967,12 @@ function ChatPage(props: ChatPageProps) {
>
<Avatar
src={session?.avatarUrl}
name={session?.displayName || sessionId}
name={displayNameOrFallback(sessionId, session?.displayName)}
size={48}
/>
<div className="session-content">
<div className="session-top">
<span className="session-name">{session?.displayName || sessionId}</span>
<span className="session-name">{displayNameOrFallback(sessionId, session?.displayName)}</span>
</div>
<div className="session-preview">
<HighlightTextNoTruncate text={firstMsg.parsedContent || firstMsg.content || ''} keyword={globalMsgQuery} />
@@ -8218,10 +8243,10 @@ function ChatPage(props: ChatPageProps) {
)
const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername)
const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl)
const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) ||
const resolvedCurrentSessionName = normalizeDisplayIdentityText(currentSession?.displayName) ||
resolveSearchSenderUsernameFallback(currentSession?.username) ||
resolveSearchSenderUsernameFallback(currentSessionId)
const senderName = resolvedSenderDisplayName || (
const senderName = pickDisplayName(resolvedSenderDisplayName) || (
msg.isSend === 1
? '我'
: (isCurrentSessionPrivateSnsSupported
@@ -8364,19 +8389,21 @@ function ChatPage(props: ChatPageProps) {
<div className="detail-empty">{groupMemberSearchKeyword.trim() ? '暂无匹配成员' : '暂无群成员数据'}</div>
) : (
<div className="group-members-list">
{filteredGroupPanelMembers.map((member) => (
{filteredGroupPanelMembers.map((member) => {
const memberDisplayName = displayNameOrFallback(member.username, member.displayName)
return (
<div key={member.username} className="group-member-item">
<div className="group-member-main">
<Avatar
src={member.avatarUrl}
name={member.displayName || member.username}
name={memberDisplayName}
size={34}
className="group-member-avatar"
/>
<div className="group-member-meta">
<div className="group-member-name-row">
<span className="group-member-name" title={member.displayName || member.username}>
{member.displayName || member.username}
<span className="group-member-name" title={memberDisplayName}>
{memberDisplayName}
</span>
<div className="group-member-badges">
{member.isOwner && (
@@ -8404,7 +8431,8 @@ function ChatPage(props: ChatPageProps) {
: `${member.messageCount.toLocaleString()} 条`}
</span>
</div>
))}
)
})}
</div>
)}
</div>
@@ -8415,7 +8443,7 @@ function ChatPage(props: ChatPageProps) {
<div className="detail-header">
<div className="detail-title-wrap">
<h4>AI 群聊总结</h4>
<span className="detail-title-sub">{currentSession?.displayName || currentSessionId}</span>
<span className="detail-title-sub">{displayNameOrFallback(currentSessionId || '', currentSession?.displayName)}</span>
</div>
<button className="close-btn" onClick={() => setShowGroupSummaryPanel(false)}>
<X size={16} />
@@ -8555,13 +8583,24 @@ function ChatPage(props: ChatPageProps) {
<div className="detail-overview-card">
<Avatar
src={currentSession?.avatarUrl}
name={sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.wxid}
name={displayNameOrFallback(
sessionDetail.wxid,
sessionDetail.remark,
sessionDetail.nickName,
currentSession?.displayName
)}
size={42}
className="detail-overview-avatar"
/>
<div className="detail-overview-meta">
<span className="detail-overview-name">
{sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.alias || sessionDetail.wxid}
{displayNameOrFallback(
sessionDetail.wxid,
sessionDetail.remark,
sessionDetail.nickName,
currentSession?.displayName,
sessionDetail.alias
)}
</span>
<span className="detail-overview-sub">
{sessionDetail.alias || sessionDetail.wxid}
@@ -9650,13 +9689,13 @@ function MessageInsightControl({
try {
const result = await window.electronAPI.insight.generateMessageInsight({
sessionId: session.username,
displayName: displayName || session.displayName || session.username,
displayName: displayNameOrFallback(session.username, displayName, session.displayName),
avatarUrl: avatarUrl || session.avatarUrl,
targetLocalId: message.localId,
targetCreateTime: message.createTime,
targetMessageKey: messageKey,
targetText,
targetSenderName: senderName || displayName || session.displayName || session.username,
targetSenderName: displayNameOrFallback(session.username, senderName, displayName, session.displayName),
contextCount,
forceRefresh
})
@@ -9782,7 +9821,15 @@ function AvatarProfileCard({
}) {
const { target, detail } = card
const username = detail?.wxid || target.username || ''
const displayName = detail?.remark || detail?.nickName || detail?.displayName || target.displayName || username || '微信用户'
const displayName = displayNameOrFallback(
'微信用户',
detail?.remark,
detail?.nickName,
detail?.displayName,
target.displayName,
username
)
const groupNickname = target.isGroupMember ? pickDisplayName(target.groupNickname) : ''
const nickname = detail?.nickName && detail.nickName !== displayName ? detail.nickName : ''
const alias = detail?.alias || ''
const avatarUrl = detail?.avatarUrl || target.avatarUrl
@@ -9793,6 +9840,7 @@ function AvatarProfileCard({
: (target.canOpenMoments ? '暂无可预览媒体' : '不支持朋友圈')
const messageDate = target.messageTime ? formatYmdHmDateTimeFromSeconds(target.messageTime) : ''
const profileRows = [
groupNickname ? { label: '群昵称', value: groupNickname } : null,
nickname ? { label: '昵称', value: nickname } : null,
detail?.remark ? { label: '备注', value: detail.remark } : null,
alias ? { label: '微信号', value: alias, copyField: 'avatarProfileAlias' } : null,
@@ -9908,6 +9956,7 @@ function MessageBubble({
myWxid,
isGroupChat,
groupSenderDisplayName,
groupSenderNickname,
quoteLayout,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
@@ -9928,6 +9977,7 @@ function MessageBubble({
myWxid?: string;
isGroupChat?: boolean;
groupSenderDisplayName?: string;
groupSenderNickname?: string;
quoteLayout: configService.QuoteLayout;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
@@ -10268,7 +10318,11 @@ function MessageBubble({
(!isGroupChat || Date.now() - (cached.updatedAt || 0) < SENDER_AVATAR_CACHE_TTL_MS)
)
setSenderAvatarUrl((cachedFresh ? cached?.avatarUrl : undefined) || message.senderAvatarUrl || undefined)
setSenderName(groupSenderDisplayName || (cachedFresh ? cached?.displayName : undefined) || message.senderDisplayName || undefined)
setSenderName(pickDisplayName(
groupSenderDisplayName,
cachedFresh ? cached?.displayName : undefined,
message.senderDisplayName
))
if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return
@@ -11145,8 +11199,8 @@ function MessageBubble({
// - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救)
// - 群聊中对方发的:使用发送者头像
// - 私聊中对方发的:使用会话头像
const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined
const resolvedSenderName = groupSenderDisplayName || senderName || fallbackSenderName
const fallbackSenderName = pickDisplayName(message.senderDisplayName, message.senderUsername)
const resolvedSenderName = pickDisplayName(groupSenderDisplayName, senderName, fallbackSenderName)
const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl
const avatarUrl = isSent
? (myAvatarUrl || resolvedSenderAvatarUrl)
@@ -11160,8 +11214,11 @@ function MessageBubble({
displayName: isSent
? '我'
: (isGroupChat
? (resolvedSenderName || message.senderDisplayName || message.senderUsername || '群成员')
: (session.displayName || session.username || '微信用户')),
? displayNameOrFallback('群成员', resolvedSenderName, message.senderDisplayName, message.senderUsername)
: displayNameOrFallback('微信用户', session.displayName, session.username)),
groupNickname: isGroupChat && !isSent
? pickDisplayName(groupSenderNickname, groupSenderDisplayName)
: undefined,
avatarUrl,
isSelf: isSent,
isGroupMember: Boolean(isGroupChat && !isSent)
@@ -11170,6 +11227,8 @@ function MessageBubble({
avatarUrl,
isGroupChat,
isSent,
groupSenderDisplayName,
groupSenderNickname,
message.senderDisplayName,
message.senderUsername,
myWxid,
@@ -11197,9 +11256,9 @@ function MessageBubble({
message={message}
messageKey={messageKey}
session={session}
displayName={session.displayName || session.username}
displayName={displayNameOrFallback(session.username, session.displayName)}
avatarUrl={avatarUrl}
senderName={resolvedSenderName || session.displayName || session.username}
senderName={displayNameOrFallback(session.username, resolvedSenderName, session.displayName)}
targetText={cleanedParsedContent}
contextCount={aiMessageInsightContextCount || 50}
compact={!isGroupChat}
@@ -11208,7 +11267,7 @@ function MessageBubble({
// 是否有引用消息
const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
const displayQuotedSenderName = pickDisplayName(quotedSenderName, quotedSenderFallbackName)
const quotedJumpTarget = useMemo<QuotedMessageJumpTarget | null>(() => {
if (!hasQuote) return null
@@ -12572,6 +12631,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.myWxid !== nextProps.myWxid) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.groupSenderDisplayName !== nextProps.groupSenderDisplayName) return false
if (prevProps.groupSenderNickname !== nextProps.groupSenderNickname) return false
if (prevProps.quoteLayout !== nextProps.quoteLayout) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
+16 -5
View File
@@ -7,6 +7,7 @@ import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import { displayNameOrFallback } from '../utils/displayName'
import './ContactsPage.scss'
interface ContactEnrichInfo {
@@ -220,7 +221,7 @@ function ContactsPage() {
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const displayName = displayNameOrFallback(contact.displayName, enriched.displayName)
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
@@ -239,7 +240,7 @@ function ContactsPage() {
if (!prev) return prev
const enriched = enrichedMap[prev.username]
if (!enriched) return prev
const displayName = enriched.displayName || prev.displayName
const displayName = displayNameOrFallback(prev.displayName, enriched.displayName)
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
return prev
@@ -303,7 +304,7 @@ function ContactsPage() {
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
displayName: displayNameOrFallback(prev.displayName, enriched.displayName),
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
@@ -647,7 +648,12 @@ function ContactsPage() {
const selectedContactTitle = useMemo(() => {
if (!selectedContact) return ''
return selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username
return displayNameOrFallback(
selectedContact.username,
selectedContact.displayName,
selectedContact.remark,
selectedContact.nickname
)
}, [selectedContact])
const selectedContactSubtitle = useMemo(() => {
@@ -688,7 +694,12 @@ function ContactsPage() {
}
setSnsTimelineTarget({
username: selectedContact.username,
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
displayName: displayNameOrFallback(
selectedContact.username,
selectedContact.displayName,
selectedContact.remark,
selectedContact.nickname
),
avatarUrl: selectedContact.avatarUrl
})
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
+55 -14
View File
@@ -13,7 +13,6 @@ import ExportDialog from './components/ExportDialog'
import TaskCenter from './components/TaskCenter'
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../../components/Export/ExportDefaultsSettingsForm'
import { X } from 'lucide-react'
import SessionDetail from './components/SessionDetail'
import { useContactsLoader } from './hooks/useContactsLoader'
import { useExportSessions } from './hooks/useExportSessions'
@@ -26,8 +25,10 @@ import { useAutomationRunner, useAutomationStore } from './hooks/useAutomation'
import { AutomationModal } from './components/Automation/AutomationModal'
import { AutomationTaskForm } from './components/Automation/AutomationTaskForm'
import type { SessionRow, TextExportFormat } from './types'
import type { TextExportFormat } from './types'
import { getSelectionScopeFromRows, resolveScopeDisplayNames } from './utils/session'
import { serializeExportDateRangeConfig } from '../../utils/exportDateRange'
import { displayNameOrFallback, pickDisplayName } from '../../utils/displayName'
import {
emitSingleExportDialogStatus,
onOpenSingleExport,
@@ -45,6 +46,7 @@ function ExportPage() {
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
const [draftAutomationPayload, setDraftAutomationPayload] = useState<any>(null)
const [pendingSingleExportRequest, setPendingSingleExportRequest] = useState<OpenSingleExportPayload | null>(null)
const [metricsMapForSort, setMetricsMapForSort] = useState<Record<string, any>>({})
const {
isLoaded: isConfigLoaded,
@@ -61,7 +63,6 @@ function ExportPage() {
// ── 2. Load contacts and sessions ──
const {
contactMap,
avatarEntries,
isLoading: isContactsLoading,
loadContacts,
abort: abortContacts
@@ -74,6 +75,7 @@ function ExportPage() {
sessions,
filteredSessions,
isLoading: isSessionsLoading,
error: sessionsError,
activeTab,
setActiveTab,
searchQuery,
@@ -81,7 +83,7 @@ function ExportPage() {
loadSessions,
sortConfig,
setSortConfig
} = useExportSessions(contactMap, 'private', metricsMap)
} = useExportSessions(contactMap, 'private', loadingRefs.size > 0 ? metricsMapForSort : metricsMap)
// Initial load
useEffect(() => {
@@ -94,7 +96,20 @@ function ExportPage() {
// Use filteredSessions directly (hook already handles tab + search filtering)
const displayedSessions = filteredSessions
useEffect(() => {
if (loadingRefs.size === 0) {
setMetricsMapForSort(metricsMap)
}
}, [loadingRefs.size, metricsMap])
useEffect(() => {
if (selectedSessionIds.size === 0) return
const visibleIds = new Set(displayedSessions.map(session => session.username))
const nextSelectedIds = new Set(Array.from(selectedSessionIds).filter(id => visibleIds.has(id)))
if (nextSelectedIds.size !== selectedSessionIds.size) {
setSelectedSessionIds(nextSelectedIds)
}
}, [displayedSessions, selectedSessionIds])
useEffect(() => {
if (displayedSessions.length > 0) {
@@ -104,9 +119,9 @@ function ExportPage() {
}, [displayedSessions, fetchMetrics])
const handleRefreshStats = useCallback(() => {
if (sessions.length === 0 || loadingRefs.size > 0) return
void fetchMetrics(sessions.map(s => s.username), { forceRefresh: true })
}, [fetchMetrics, loadingRefs.size, sessions])
if (displayedSessions.length === 0 || loadingRefs.size > 0) return
void fetchMetrics(displayedSessions.map(s => s.username), { forceRefresh: true })
}, [displayedSessions, fetchMetrics, loadingRefs.size])
// ── 4. Manage Tasks ──
const {
@@ -137,9 +152,9 @@ function ExportPage() {
const resolveSingleExportName = useCallback((payload: OpenSingleExportPayload) => {
const sessionId = String(payload.sessionId || '').trim()
const payloadName = typeof payload.sessionName === 'string' ? payload.sessionName.trim() : ''
const payloadName = pickDisplayName(payload.sessionName)
const sessionRow = sessions.find(s => s.username === sessionId)
return payloadName || sessionRow?.displayName || sessionRow?.remark || sessionRow?.nickname || sessionId
return displayNameOrFallback(sessionId, payloadName, sessionRow?.displayName, sessionRow?.remark, sessionRow?.nickname)
}, [sessions])
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
@@ -147,8 +162,23 @@ function ExportPage() {
...(patch.format ? { format: patch.format as TextExportFormat } : {}),
...(patch.avatars !== undefined ? { exportAvatars: patch.avatars } : {}),
...(patch.fileNamingMode !== undefined ? { fileNamingMode: patch.fileNamingMode } : {}),
...(patch.media ? {
exportImages: patch.media.images,
exportVideos: patch.media.videos,
exportVoices: patch.media.voices,
exportEmojis: patch.media.emojis,
exportFiles: patch.media.files,
maxFileSizeMb: patch.media.maxFileSizeMb
} : {}),
...(patch.voiceAsText !== undefined ? { exportVoiceAsText: patch.voiceAsText } : {}),
...(patch.excelCompactColumns !== undefined ? { excelCompactColumns: patch.excelCompactColumns } : {}),
...(patch.concurrency !== undefined ? { exportConcurrency: patch.concurrency } : {}),
})
}, [updateOptions])
if (patch.dateRange) {
setRawDateRangeConfig(serializeExportDateRangeConfig(patch.dateRange))
}
}, [setRawDateRangeConfig, updateOptions])
const handleExportSelected = useCallback(() => {
const selectedRows = displayedSessions.filter(s => selectedSessionIds.has(s.username))
@@ -256,13 +286,25 @@ function ExportPage() {
sessionIds: dialogState.sessionIds,
sessionNames: dialogState.sessionNames,
scope: dialogState.scope,
source: dialogState.intent === 'automation-create' ? 'automation' : 'manual',
source: 'manual',
outputDir: exportPath,
options: finalOptions
})
closeDialog()
}, [dialogState, exportPath, startTask, closeDialog])
const handleAutomationCreateFromDialog = useCallback((finalOptions: any) => {
setDraftAutomationPayload({
sessionIds: dialogState.sessionIds,
sessionNames: dialogState.sessionNames,
scope: dialogState.scope,
contentType: dialogState.contentType,
outputDir: exportPath,
options: finalOptions
})
closeDialog()
}, [closeDialog, dialogState, exportPath])
const handleAutomationSave = useCallback((task: any) => {
void addTask(task)
setDraftAutomationPayload(null)
@@ -291,6 +333,7 @@ function ExportPage() {
onSearchChange={setSearchQuery}
sortConfig={sortConfig}
onSortChange={setSortConfig}
error={sessionsError}
metricsMap={metricsMap}
loadingRefs={loadingRefs}
metricsLoadingCount={loadingRefs.size}
@@ -322,13 +365,11 @@ function ExportPage() {
dialogState={dialogState}
onClose={closeDialog}
options={options}
onOptionsChange={updateOptions}
exportPath={exportPath}
onSelectPath={() => void window.electronAPI.dialog.openFile({ properties: ['openDirectory'] }).then(res => !res.canceled && res.filePaths[0] && setExportPath(res.filePaths[0]))}
rawDateRangeConfig={rawDateRangeConfig}
onDateRangeConfigChange={setRawDateRangeConfig}
onConfirm={handleConfirmExport}
onAutomationCreate={handleConfirmExport}
onAutomationCreate={handleAutomationCreateFromDialog}
/>
{isSettingsModalOpen && (
@@ -29,11 +29,9 @@ interface ExportDialogProps {
dialogState: ExportDialogState
onClose: () => void
options: ExportOptions
onOptionsChange: (patch: Partial<ExportOptions>) => void
exportPath: string
onSelectPath: () => void
rawDateRangeConfig: ExportDefaultDateRangeConfig | string | null
onDateRangeConfigChange: (config: ExportDefaultDateRangeConfig | string | null) => void
onConfirm: (finalOptions: ExportOptions) => void
onAutomationCreate: (finalOptions: ExportOptions) => void
}
@@ -42,19 +40,26 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
dialogState,
onClose,
options,
onOptionsChange,
exportPath,
onSelectPath,
rawDateRangeConfig,
onDateRangeConfigChange,
onConfirm,
onAutomationCreate
}) => {
const [isDateRangeOpen, setIsDateRangeOpen] = useState(false)
const [draftOptions, setDraftOptions] = useState<ExportOptions>(options)
const [draftDateRangeConfig, setDraftDateRangeConfig] = useState<ExportDefaultDateRangeConfig | string | null>(rawDateRangeConfig)
useEffect(() => {
if (!dialogState.open) return
setDraftOptions(options)
setDraftDateRangeConfig(rawDateRangeConfig)
setIsDateRangeOpen(false)
}, [dialogState.open, options, rawDateRangeConfig])
const currentSelection = React.useMemo(() => {
return resolveExportDateRangeConfig(rawDateRangeConfig)
}, [rawDateRangeConfig])
return resolveExportDateRangeConfig(draftDateRangeConfig)
}, [draftDateRangeConfig])
const dateRangeLabel = React.useMemo(() => {
return getExportDateRangeLabel(currentSelection)
@@ -62,20 +67,32 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
if (!dialogState.open) return null
// Local state for UI toggles that might not strictly map 1:1 to config immediately
// But we'll just use the global options for now
const { intent, scope, sessionNames, title } = dialogState
const isAutomation = intent === 'automation-create'
const updateDraftOptions = (patch: Partial<ExportOptions>) => {
setDraftOptions(prev => {
const next = { ...prev, ...patch }
next.exportMedia = (
next.exportImages ||
next.exportVideos ||
next.exportVoices ||
next.exportEmojis ||
next.exportFiles
)
return next
})
}
const handleFormatSelect = (format: TextExportFormat) => {
onOptionsChange({ format })
updateDraftOptions({ format })
}
const handleConfirm = () => {
if (isAutomation) {
onAutomationCreate(options)
onAutomationCreate(draftOptions)
} else {
onConfirm(options)
onConfirm(draftOptions)
}
}
@@ -162,7 +179,7 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
{formatOptions.map(fmt => (
<button
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
className={`format-card ${draftOptions.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatSelect(fmt.value)}
>
<div className="format-name">{fmt.label}</div>
@@ -181,8 +198,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportImages}
onChange={e => onOptionsChange({ exportImages: e.target.checked })}
checked={draftOptions.exportImages}
onChange={e => updateDraftOptions({ exportImages: e.target.checked })}
/>
<ImageIcon size={16} /> 导出图片
</label>
@@ -190,8 +207,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVideos}
onChange={e => onOptionsChange({ exportVideos: e.target.checked })}
checked={draftOptions.exportVideos}
onChange={e => updateDraftOptions({ exportVideos: e.target.checked })}
/>
<Video size={16} /> 导出视频
</label>
@@ -199,8 +216,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoices}
onChange={e => onOptionsChange({ exportVoices: e.target.checked })}
checked={draftOptions.exportVoices}
onChange={e => updateDraftOptions({ exportVoices: e.target.checked })}
/>
<Mic size={16} /> 导出语音
</label>
@@ -208,8 +225,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportFiles}
onChange={e => onOptionsChange({ exportFiles: e.target.checked })}
checked={draftOptions.exportFiles}
onChange={e => updateDraftOptions({ exportFiles: e.target.checked })}
/>
<FileBox size={16} /> 导出文件
</label>
@@ -217,22 +234,24 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportEmojis}
onChange={e => onOptionsChange({ exportEmojis: e.target.checked })}
checked={draftOptions.exportEmojis}
onChange={e => updateDraftOptions({ exportEmojis: e.target.checked })}
/>
<Smile size={16} /> 导出表情包
</label>
</div>
{(options.exportVideos || options.exportFiles) && (
{(draftOptions.exportVideos || draftOptions.exportFiles) && (
<div className="file-size-limit">
<span>最大文件限制 (MB):</span>
<input
type="number"
min={1}
max={MAX_EXPORT_FILE_SIZE_MB_LIMIT}
value={options.maxFileSizeMb}
onChange={e => onOptionsChange({ maxFileSizeMb: Number(e.target.value) })}
value={draftOptions.maxFileSizeMb}
onChange={e => updateDraftOptions({
maxFileSizeMb: Math.max(1, Math.min(MAX_EXPORT_FILE_SIZE_MB_LIMIT, Math.floor(Number(e.target.value) || 1)))
})}
/>
</div>
)}
@@ -247,8 +266,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => onOptionsChange({ exportAvatars: e.target.checked })}
checked={draftOptions.exportAvatars}
onChange={e => updateDraftOptions({ exportAvatars: e.target.checked })}
/>
包含联系人头像
</label>
@@ -256,8 +275,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => onOptionsChange({ exportVoiceAsText: e.target.checked })}
checked={draftOptions.exportVoiceAsText}
onChange={e => updateDraftOptions({ exportVoiceAsText: e.target.checked })}
/>
语音转文字 (若已转换)
</label>
@@ -273,8 +292,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<button
key={item.value}
type="button"
className={options.exportPathStyle === item.value ? 'active' : ''}
onClick={() => onOptionsChange({ exportPathStyle: item.value as ExportOptions['exportPathStyle'] })}
className={draftOptions.exportPathStyle === item.value ? 'active' : ''}
onClick={() => updateDraftOptions({ exportPathStyle: item.value as ExportOptions['exportPathStyle'] })}
>
{item.label}
</button>
@@ -289,9 +308,9 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<button
key={item.value}
type="button"
className={options.exportConflictStrategy === item.value ? 'active' : ''}
className={draftOptions.exportConflictStrategy === item.value ? 'active' : ''}
title={item.desc}
onClick={() => onOptionsChange({ exportConflictStrategy: item.value })}
onClick={() => updateDraftOptions({ exportConflictStrategy: item.value })}
>
{item.label}
</button>
@@ -302,8 +321,8 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
<div className="concurrency-control">
<span>导出并发数:</span>
<select
value={options.exportConcurrency}
onChange={e => onOptionsChange({ exportConcurrency: Number(e.target.value) })}
value={draftOptions.exportConcurrency}
onChange={e => updateDraftOptions({ exportConcurrency: Number(e.target.value) })}
>
<option value={1}>1 (最稳定)</option>
<option value={3}>3 (推荐)</option>
@@ -333,7 +352,12 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
value={currentSelection}
onClose={() => setIsDateRangeOpen(false)}
onConfirm={(nextSelection) => {
onDateRangeConfigChange(serializeExportDateRangeConfig(nextSelection))
const nextConfig = serializeExportDateRangeConfig(nextSelection)
setDraftDateRangeConfig(nextConfig)
updateDraftOptions({
useAllTime: nextSelection.useAllTime,
dateRange: nextSelection.dateRange
})
setIsDateRangeOpen(false)
}}
/>
@@ -1,10 +1,13 @@
.session-table-container {
--session-table-min-width: 980px;
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-surface, var(--bg-primary)); // Fallback
border-radius: 16px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
box-shadow: 0 4px 20px color-mix(in srgb, var(--shadow-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
@@ -21,6 +24,7 @@
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
gap: 24px;
-webkit-app-region: no-drag;
min-width: var(--session-table-min-width);
.st-tabs {
display: flex;
@@ -104,6 +108,73 @@
}
}
.st-selection-actions {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.st-selection-count {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
.st-selection-btn {
height: 28px;
padding: 0 16px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition:
background 0.16s ease,
border-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
&:hover {
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
&.primary {
border: 1px solid var(--primary);
background: var(--primary);
color: var(--on-primary, #fff);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 14%, transparent);
&:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
}
&.secondary {
border: 1px solid color-mix(in srgb, var(--primary) 62%, transparent);
background: color-mix(in srgb, var(--primary) 7%, var(--bg-surface, var(--bg-primary)));
color: var(--primary);
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, var(--bg-surface, var(--bg-primary)));
}
}
}
}
.st-refresh-stats-btn {
width: 34px;
height: 34px;
@@ -148,6 +219,7 @@
align-items: center;
gap: 8px;
padding: 0 24px;
min-width: var(--session-table-min-width);
}
// ── Header Row ──────────────────────────────────────────────────────────
@@ -193,6 +265,32 @@
font-size: 14px;
}
.error-state {
height: 100%;
min-width: var(--session-table-min-width);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
text-align: center;
color: var(--text-secondary);
.error-title {
font-size: 15px;
font-weight: 600;
color: #ef4444;
}
.error-message {
max-width: 520px;
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
}
.st-metrics-status {
position: absolute;
left: 50%;
@@ -6,6 +6,7 @@ import type { SessionContentMetric } from '../../hooks/useSessionMetrics'
import { conversationTabLabels, exportKindPriority } from '../../constants'
import { getAvatarLetter } from '../../utils/format'
import { formatLatestMessageTimeFromSeconds } from '../../utils/format'
import { displayNameOrFallback } from '../../../../utils/displayName'
import './SessionTable.scss'
interface SessionTableProps {
@@ -22,6 +23,7 @@ interface SessionTableProps {
onBatchExport?: () => void
onAutomationExport?: () => void
isLoading?: boolean
error?: string | null
metricsMap?: Record<string, SessionContentMetric>
loadingRefs?: Set<string>
metricsLoadingCount?: number
@@ -43,6 +45,7 @@ const SessionTable: React.FC<SessionTableProps> = ({
onBatchExport,
onAutomationExport,
isLoading,
error,
metricsMap,
loadingRefs,
metricsLoadingCount = 0,
@@ -123,15 +126,15 @@ const SessionTable: React.FC<SessionTableProps> = ({
</div>
{selectedSessionIds.size > 0 && (
<div className="st-selection-actions" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>已选 {selectedSessionIds.size} 项</span>
<div className="st-selection-actions">
<span className="st-selection-count">已选 {selectedSessionIds.size} 项</span>
{onBatchExport && (
<button className="primary-btn" onClick={onBatchExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
<button className="st-selection-btn primary" onClick={onBatchExport}>
批量导出
</button>
)}
{onAutomationExport && (
<button className="secondary-btn" onClick={onAutomationExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
<button className="st-selection-btn secondary" onClick={onAutomationExport}>
创建自动化
</button>
)}
@@ -159,8 +162,8 @@ const SessionTable: React.FC<SessionTableProps> = ({
className={`st-refresh-stats-btn ${isRefreshingStats ? 'refreshing' : ''}`}
onClick={onRefreshStats}
disabled={isRefreshingStats || isLoading || sessions.length === 0}
title="重建导出统计缓存"
aria-label="重建导出统计缓存"
title="重新统计当前列表"
aria-label="重新统计当前列表"
>
<RefreshCw size={16} />
</button>
@@ -201,7 +204,12 @@ const SessionTable: React.FC<SessionTableProps> = ({
{/* ─── Virtualized List ─────────────────────────────────────────── */}
<div className="table-body">
{isLoading && sessions.length === 0 ? (
{error ? (
<div className="error-state" role="alert">
<div className="error-title">会话加载失败</div>
<div className="error-message">{error}</div>
</div>
) : isLoading && sessions.length === 0 ? (
// 初始加载骨架屏,风格与「我的足迹」保持一致
<div className="st-skeleton-body" aria-busy="true" aria-live="polite">
<div className="st-skeleton-header">
@@ -241,6 +249,7 @@ const SessionTable: React.FC<SessionTableProps> = ({
const metrics = metricsMap?.[session.username]
const isMetricsLoading = !metrics && loadingRefs?.has(session.username)
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
const sessionDisplayName = displayNameOrFallback(session.username, session.displayName)
return (
<div className="st-row" key={session.username}>
@@ -258,11 +267,11 @@ const SessionTable: React.FC<SessionTableProps> = ({
{session.avatarUrl ? (
<img src={session.avatarUrl} alt="avatar" />
) : (
<span className="avatar-letter">{getAvatarLetter(session.displayName || session.username)}</span>
<span className="avatar-letter">{getAvatarLetter(sessionDisplayName)}</span>
)}
</div>
<div className="st-text">
<span className="st-name" title={session.displayName || session.username}>{session.displayName || session.username}</span>
<span className="st-name" title={sessionDisplayName}>{sessionDisplayName}</span>
<span className="st-id" title={session.username}>{session.username}</span>
</div>
</div>
+5 -1
View File
@@ -105,6 +105,9 @@ export function useExportConfig(): ExportConfigResult {
newOptions.exportVoices = media.voices !== false
newOptions.exportEmojis = media.emojis !== false
newOptions.exportFiles = media.files !== false
if (typeof media.maxFileSizeMb === 'number' && media.maxFileSizeMb > 0) {
newOptions.maxFileSizeMb = media.maxFileSizeMb
}
// The old page had `exportMedia: true` derived from whether any media was checked
newOptions.exportMedia = (
newOptions.exportImages ||
@@ -177,7 +180,8 @@ export function useExportConfig(): ExportConfigResult {
videos: next.exportVideos,
voices: next.exportVoices,
emojis: next.exportEmojis,
files: next.exportFiles
files: next.exportFiles,
maxFileSizeMb: next.maxFileSizeMb
})
}
if (patch.exportVoiceAsText !== undefined) {
+53 -1
View File
@@ -19,6 +19,7 @@ export interface SessionContentMetric {
}
const METRICS_CHUNK_SIZE = 160
const backgroundRefreshingIds = new Set<string>()
function chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = []
@@ -36,6 +37,47 @@ interface SessionMetricsState {
fetchMetrics: (sessionIds: string[], options?: { forceRefresh?: boolean }) => Promise<void>
}
async function refreshMetricsInBackground(sessionIds: string[]): Promise<void> {
const targetIds = Array.from(new Set(sessionIds.map(id => String(id || '').trim()).filter(Boolean)))
.filter(id => !backgroundRefreshingIds.has(id))
if (targetIds.length === 0) return
targetIds.forEach(id => backgroundRefreshingIds.add(id))
try {
const chunks = chunkArray(targetIds, METRICS_CHUNK_SIZE)
for (const chunk of chunks) {
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, {
includeRelations: false,
forceRefresh: true
})
const newMetrics: Record<string, SessionContentMetric> = {}
if (stats.success && stats.data) {
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
newMetrics[sessionId] = {
totalMessages: sessionStat.totalMessages,
voiceMessages: sessionStat.voiceMessages,
imageMessages: sessionStat.imageMessages,
videoMessages: sessionStat.videoMessages,
emojiMessages: sessionStat.emojiMessages,
fileMessages: sessionStat.fileMessages ?? 0
}
}
}
if (Object.keys(newMetrics).length > 0) {
useSessionMetrics.setState(state => ({
metricsMap: { ...state.metricsMap, ...newMetrics }
}))
}
}
} catch (error) {
console.error('Failed to refresh session metrics in background:', error)
} finally {
targetIds.forEach(id => backgroundRefreshingIds.delete(id))
}
}
export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
metricsMap: {},
isLoading: false,
@@ -74,10 +116,12 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
try {
// 将会话分批顺序请求,每批返回后立即更新 UI,实现「扫出一个放一个」的渐进式效果。
const chunks = chunkArray(targetIds, METRICS_CHUNK_SIZE)
const staleIds = new Set<string>()
for (const chunk of chunks) {
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, {
includeRelations: false,
forceRefresh
forceRefresh,
allowStaleCache: !forceRefresh
})
const newMetrics: Record<string, SessionContentMetric> = {}
@@ -97,6 +141,14 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
set(state => ({
metricsMap: { ...state.metricsMap, ...newMetrics }
}))
if (!forceRefresh && Array.isArray(stats.needsRefresh)) {
stats.needsRefresh.forEach(id => staleIds.add(id))
}
}
if (!forceRefresh && staleIds.size > 0) {
void refreshMetricsInBackground(Array.from(staleIds))
}
} catch (err) {
console.error('Failed to fetch session metrics:', err)
+6 -5
View File
@@ -5,6 +5,7 @@
import type { ChatSession as AppChatSession, ContactInfo } from '../../../types/models'
import type { ConversationTab, SessionRow } from '../types'
import { displayNameOrFallback, pickDisplayName } from '../../../utils/displayName'
// ─── Session type classification ─────────────────────────────
@@ -83,7 +84,7 @@ export const toSessionRowsWithContacts = (
username: contact.username,
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
displayName: displayNameOrFallback(contact.username, contact.displayName, session?.displayName),
avatarUrl: session?.avatarUrl || contact.avatarUrl,
remark: contact.remark,
nickname: contact.nickname,
@@ -94,7 +95,7 @@ export const toSessionRowsWithContacts = (
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
if (latestA !== latestB) return latestB - latestA
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
return displayNameOrFallback(a.username, a.displayName).localeCompare(displayNameOrFallback(b.username, b.displayName), 'zh-Hans-CN')
})
}
@@ -105,7 +106,7 @@ export const toSessionRowsWithContacts = (
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
displayName: displayNameOrFallback(session.username, contact?.displayName, session.displayName),
avatarUrl: session.avatarUrl || contact?.avatarUrl,
remark: contact?.remark,
nickname: contact?.nickname,
@@ -136,7 +137,7 @@ export const resolveScopeDisplayNames = (
rows: SessionRow[],
pref: import('../types').DisplayNamePreference
): string[] => {
return rows.map(r => r.remark || r.nickname || r.displayName || r.username)
return rows.map(r => displayNameOrFallback(r.username, r.remark, r.nickname, r.displayName))
}
// ─── Name comparison helper ──────────────────────────────────
@@ -144,7 +145,7 @@ export const resolveScopeDisplayNames = (
export const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>()
for (const value of values) {
const normalized = String(value || '').trim()
const normalized = pickDisplayName(value)?.trim() || ''
if (!normalized) continue
set.add(normalized)
}
+49 -8
View File
@@ -914,30 +914,52 @@
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease,
opacity 0.15s ease;
svg {
flex-shrink: 0;
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-primary);
background: var(--primary);
color: var(--on-primary, #fff);
border: 1px solid var(--primary);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 16%, transparent);
&:hover:not(:disabled) {
opacity: 0.85;
background: var(--primary-hover);
border-color: var(--primary-hover);
transform: translateY(-1px);
}
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 46%, var(--border-color));
&:hover:not(:disabled) {
background: var(--bg-tertiary);
background: color-mix(in srgb, var(--primary) 11%, var(--bg-primary));
border-color: var(--primary);
transform: translateY(-1px);
}
}
@@ -953,6 +975,25 @@
border-radius: 4px;
}
.image-key-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
.btn {
min-height: 30px;
border-radius: 7px;
white-space: nowrap;
}
.btn-secondary {
background: var(--bg-primary);
}
}
.btn-row {
display: flex;
gap: 10px;
+39 -25
View File
@@ -18,6 +18,7 @@ import {
Sparkles, Loader2, CheckCircle2, XCircle
} from 'lucide-react'
import { Avatar } from '../components/Avatar'
import { displayNameOrFallback } from '../utils/displayName'
import './SettingsPage.scss'
type SettingsTab =
@@ -54,6 +55,9 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint' | 'aiMessageIns
{ id: 'about', label: '关于', icon: Info }
]
const getSessionDisplayName = (session: Pick<ChatSession, 'username' | 'displayName'>): string =>
displayNameOrFallback(session.username, session.displayName)
const filteredTabs = tabs.filter(tab => {
if (tab.id === 'autoDownload') {
return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64'
@@ -2097,7 +2101,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
<div className="filter-panel-list">
{notificationAvailableSessions.length > 0 ? (
notificationAvailableSessions.map(session => (
notificationAvailableSessions.map(session => {
const displayName = getSessionDisplayName(session)
return (
<div
key={session.username}
className="filter-panel-item"
@@ -2105,14 +2111,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
name={displayName}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-name">{displayName}</span>
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
<span className="filter-item-action">+</span>
</div>
))
)
})
) : (
<div className="filter-panel-empty">
{filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
@@ -2345,11 +2352,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
name={getSessionDisplayName(session)}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
<span className="name">{getSessionDisplayName(session)}</span>
</div>
</label>
<div className="anti-revoke-row-status">
@@ -2531,7 +2538,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}}
/>
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<div className="image-key-actions">
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
</button>
@@ -2872,7 +2879,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
optionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
displayName: getSessionDisplayName(session),
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
@@ -2884,7 +2891,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const existing = optionMap.get(contact.username)
optionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
displayName: displayNameOrFallback(contact.username, existing?.displayName, contact.displayName, contact.remark, contact.nickname),
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
@@ -3328,7 +3335,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try {
const result = await window.electronAPI.insight.generateProfile({
sessionId,
displayName: session.displayName || session.username,
displayName: getSessionDisplayName(session),
avatarUrl: session.avatarUrl
})
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
@@ -4003,11 +4010,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
name={getSessionDisplayName(session)}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
<span className="name">{getSessionDisplayName(session)}</span>
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div>
</label>
@@ -4062,7 +4069,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
onClick={() => void handleSaveWeiboBinding(session.username, getSessionDisplayName(session))}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
@@ -4354,18 +4361,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
<div className="filter-panel-list">
{groupSummaryAvailableSessions.length > 0 ? (
groupSummaryAvailableSessions.map(session => (
groupSummaryAvailableSessions.map(session => {
const displayName = getSessionDisplayName(session)
return (
<div
key={session.username}
className="filter-panel-item"
onClick={() => { void addToFilterList(session.username) }}
>
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
<span className="filter-item-name">{session.displayName || session.username}</span>
<Avatar src={session.avatarUrl} name={displayName} size={28} />
<span className="filter-item-name">{displayName}</span>
<span className="filter-item-type">群聊</span>
<span className="filter-item-action">+</span>
</div>
))
)
})
) : (
<div className="filter-panel-empty">
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
@@ -4733,7 +4743,9 @@ JSON 输出格式:
</div>
<div className="filter-panel-list">
{messagePushAvailableSessions.length > 0 ? (
messagePushAvailableSessions.map(session => (
messagePushAvailableSessions.map(session => {
const displayName = getSessionDisplayName(session)
return (
<div
key={session.username}
className="filter-panel-item"
@@ -4741,14 +4753,15 @@ JSON 输出格式:
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
name={displayName}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-name">{displayName}</span>
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
<span className="filter-item-action">+</span>
</div>
))
)
})
) : (
<div className="filter-panel-empty">
{messagePushFilterSearchKeyword || messagePushTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
@@ -4777,6 +4790,7 @@ JSON 输出格式:
{messagePushFilterList.length > 0 ? (
messagePushFilterList.map(username => {
const session = getSessionFilterOptionInfo(username)
const displayName = displayNameOrFallback(username, session.displayName)
return (
<div
key={username}
@@ -4785,10 +4799,10 @@ JSON 输出格式:
>
<Avatar
src={session.avatarUrl}
name={session.displayName || username}
name={displayName}
size={28}
/>
<span className="filter-item-name">{session.displayName || username}</span>
<span className="filter-item-name">{displayName}</span>
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
<span className="filter-item-action">×</span>
</div>
@@ -5331,9 +5345,9 @@ JSON 输出格式:
<Check size={12} />
</span>
</span>
<Avatar src={session.avatarUrl} name={session.displayName} size={30} />
<Avatar src={session.avatarUrl} name={getSessionDisplayName(session)} size={30} />
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
<span className="name">{getSessionDisplayName(session)}</span>
</div>
</label>
<div className="anti-revoke-row-status">
+22 -1
View File
@@ -939,7 +939,8 @@
aspect-ratio: auto;
border-radius: var(--sns-border-radius-md);
&.deleted-media {
&.deleted-media,
&.failed-media {
width: 200px;
aspect-ratio: 1;
}
@@ -1080,6 +1081,26 @@
font-size: 12px;
}
}
&.failed-media {
cursor: pointer;
.failed-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
transition: color 0.2s ease;
}
&:hover .failed-placeholder {
color: var(--text-secondary);
}
}
}
}
+36 -22
View File
@@ -21,6 +21,7 @@ import {
getExportDateRangeLabel,
type ExportDateRangeSelection
} from '../utils/exportDateRange'
import { displayNameForCompare, displayNameOrFallback } from '../utils/displayName'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
@@ -135,7 +136,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
if (!parsed || typeof parsed !== 'object') return null
return {
wxid: String(parsed.wxid || '').trim(),
displayName: String(parsed.displayName || '').trim(),
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
alias: parsed.alias ? String(parsed.alias).trim() : undefined,
avatarUrl: parsed.avatarUrl ? String(parsed.avatarUrl).trim() : undefined
}
@@ -155,7 +156,7 @@ const normalizeAccountId = (value?: string | null): string => {
return (suffixMatch ? suffixMatch[1] : trimmed).toLowerCase()
}
const normalizeNameForCompare = (value?: string | null): string => String(value || '').trim().toLowerCase()
const normalizeNameForCompare = (value?: string | null): string => displayNameForCompare(value)
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
@@ -431,7 +432,7 @@ export default function SnsPage() {
const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0)
if (tsDiff !== 0) return tsDiff
return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN')
return displayNameOrFallback(a.username, a.displayName).localeCompare(displayNameOrFallback(b.username, b.displayName), 'zh-Hans-CN')
}, [normalizePostCount])
const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => {
@@ -472,7 +473,7 @@ export default function SnsPage() {
const exportSelectedContactsSummary = useMemo(() => {
if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return ''
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username)
const names = exportScope.usernames.map((username) => displayNameOrFallback(username, contactMap.get(username)?.displayName))
if (names.length <= 2) return names.join('、')
return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人`
}, [contacts, exportScope])
@@ -480,7 +481,7 @@ export default function SnsPage() {
const selectedFeedContactsSummary = useMemo(() => {
if (selectedContactUsernames.length === 0) return ''
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username)
const names = selectedContactUsernames.map((username) => displayNameOrFallback(username, contactMap.get(username)?.displayName))
if (names.length <= 2) return names.join('、')
return `${names.slice(0, 2).join('、')} 等 ${names.length} 人`
}, [contacts, selectedContactUsernames])
@@ -538,7 +539,7 @@ export default function SnsPage() {
if (!resolvedCurrentUserContact) return
setAuthorTimelineTarget({
username: resolvedCurrentUserContact.username,
displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username,
displayName: displayNameOrFallback(resolvedCurrentUserContact.username, resolvedCurrentUserContact.displayName, currentUserProfile.displayName),
avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl
})
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
@@ -599,19 +600,21 @@ export default function SnsPage() {
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
const hydrateSnsPageCache = useCallback(async (): Promise<boolean> => {
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
if (!cached) return false
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return false
const cachedOverview = cached.overviewStats
let hasReadyOverviewCache = false
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
hasReadyOverviewCache = hasOverviewData || !hasCachedPosts
setOverviewStats({
totalPosts: cachedTotalPosts,
totalFriends: cachedTotalFriends,
@@ -622,7 +625,7 @@ export default function SnsPage() {
latestTime: cachedOverview.latestTime ?? null
})
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready,避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
setOverviewStatsStatus(hasReadyOverviewCache ? 'ready' : 'loading')
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
@@ -641,16 +644,21 @@ export default function SnsPage() {
setHasNewer(false)
}
}
return hasReadyOverviewCache
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
return false
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async (options?: { force?: boolean }) => {
const loadOverviewStats = useCallback(async (options?: { force?: boolean; background?: boolean }) => {
const background = options?.background === true
if (!background) {
setOverviewStatsStatus('loading')
}
try {
const statsResult = await window.electronAPI.sns.getExportStats(
options?.force ? { forceRefresh: true } : undefined
options?.force ? { forceRefresh: true } : { preferCache: true }
)
if (!statsResult.success || !statsResult.data) {
throw new Error(statsResult.error || '获取朋友圈统计失败')
@@ -692,8 +700,10 @@ export default function SnsPage() {
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) {
console.error('Failed to load SNS overview stats:', error)
if (!background) {
setOverviewStatsStatus('error')
}
}
}, [persistSnsPageCache])
const markCacheMigrationPrompted = useCallback(() => {
@@ -1452,7 +1462,7 @@ export default function SnsPage() {
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
return {
username: contact.username,
displayName: contact.displayName || contact.username,
displayName: displayNameOrFallback(contact.username, contact.displayName),
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
remark: contact.remark,
nickname: contact.nickname,
@@ -1588,7 +1598,7 @@ export default function SnsPage() {
if (!extra) return contact
return {
...contact,
displayName: extra.displayName || contact.displayName,
displayName: displayNameOrFallback(contact.displayName, extra.displayName),
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
})
@@ -1666,7 +1676,7 @@ export default function SnsPage() {
const openContactTimeline = useCallback((contact: Contact) => {
setAuthorTimelineTarget({
username: contact.username,
displayName: contact.displayName || contact.username,
displayName: displayNameOrFallback(contact.username, contact.displayName),
avatarUrl: contact.avatarUrl
})
}, [])
@@ -1744,9 +1754,11 @@ export default function SnsPage() {
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
void (async () => {
const hasOverviewCache = await hydrateSnsPageCache()
loadContacts()
loadOverviewStats()
loadOverviewStats(hasOverviewCache ? { background: true } : undefined)
})()
void checkCacheMigrationStatus()
}, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats])
@@ -1756,7 +1768,7 @@ export default function SnsPage() {
if (cachedProfile) {
setCurrentUserProfile((prev) => ({
wxid: cachedProfile.wxid || prev.wxid,
displayName: cachedProfile.displayName || prev.displayName,
displayName: displayNameOrFallback(prev.displayName, cachedProfile.displayName),
alias: cachedProfile.alias || prev.alias,
avatarUrl: cachedProfile.avatarUrl || prev.avatarUrl
}))
@@ -1768,7 +1780,7 @@ export default function SnsPage() {
if (!resolvedWxid && !cachedProfile) return
setCurrentUserProfile((prev) => ({
wxid: resolvedWxid || prev.wxid,
displayName: prev.displayName || cachedProfile?.displayName || resolvedWxid || '未识别用户',
displayName: displayNameOrFallback('未识别用户', prev.displayName, cachedProfile?.displayName, resolvedWxid),
alias: prev.alias || cachedProfile?.alias,
avatarUrl: prev.avatarUrl || cachedProfile?.avatarUrl
}))
@@ -1815,10 +1827,12 @@ export default function SnsPage() {
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedContactUsernames([])
setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
void (async () => {
const hasOverviewCache = await hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
loadOverviewStats(hasOverviewCache ? { background: true } : undefined);
loadPosts({ reset: true });
})()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
@@ -1922,7 +1936,7 @@ export default function SnsPage() {
onClick={openCurrentUserTimeline}
disabled={!resolvedCurrentUserContact}
title={resolvedCurrentUserContact
? `打开${resolvedCurrentUserContact.displayName || '我'}的朋友圈详情`
? `打开${displayNameOrFallback('我', resolvedCurrentUserContact.displayName)}的朋友圈详情`
: '未在右侧联系人列表中匹配到当前账号'}
>
<span className="feed-my-timeline-label">我的朋友圈</span>
+12 -8
View File
@@ -63,16 +63,20 @@ const DARK_VEIL: RGB = [22, 20, 18]
const WHITE_VEIL_ALPHA: readonly [number, number] = [0.1, 0.38]
const DARK_VEIL_ALPHA: readonly [number, number] = [0.35, 0.6]
/** 文字锚点:dark = 深色文字(浅背景用),light = 浅色文字(深背景用) */
const ANCHORS: Record<'title' | 'body' | 'tertiary', { dark: TextAnchor; light: TextAnchor }> = {
title: {
/**
* 标题与正文共用同一组主文字锚点:灰色正文在蓝色等彩色背景上即使对比度达标,
* 主观上也显得发虚,层级改由字重区分(标题 600 / 正文 400)。
* 分区采样仍使两者随各自区域背景独立求解
*/
const PRIMARY_TEXT: { dark: TextAnchor; light: TextAnchor } = {
dark: { relaxed: [44, 44, 44], strong: [10, 10, 10] },
light: { relaxed: [240, 238, 233], strong: [255, 255, 255] }
},
body: {
dark: { relaxed: [77, 77, 77], strong: [17, 17, 17] },
light: { relaxed: [217, 214, 208], strong: [255, 255, 255] }
},
}
/** 文字锚点:dark = 深色文字(浅背景用),light = 浅色文字(深背景用) */
const ANCHORS: Record<'title' | 'body' | 'tertiary', { dark: TextAnchor; light: TextAnchor }> = {
title: PRIMARY_TEXT,
body: PRIMARY_TEXT,
tertiary: {
dark: { relaxed: [122, 122, 122], strong: [61, 61, 61] },
light: { relaxed: [176, 172, 166], strong: [216, 213, 207] }
+14 -4
View File
@@ -158,6 +158,7 @@ export interface ExportDefaultMediaConfig {
voices: boolean
emojis: boolean
files: boolean
maxFileSizeMb: number
}
export type ExportFileNamingMode = 'classic' | 'date-range'
@@ -172,7 +173,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
videos: true,
voices: true,
emojis: true,
files: true
files: true,
maxFileSizeMb: 200
}
// 获取解密密钥
@@ -544,17 +546,22 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
videos: value,
voices: value,
emojis: value,
files: value
files: value,
maxFileSizeMb: DEFAULT_EXPORT_MEDIA_CONFIG.maxFileSizeMb
}
}
if (value && typeof value === 'object') {
const raw = value as Partial<Record<keyof ExportDefaultMediaConfig, unknown>>
const rawMaxFileSizeMb = Number(raw.maxFileSizeMb)
return {
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis,
files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files
files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files,
maxFileSizeMb: Number.isFinite(rawMaxFileSizeMb) && rawMaxFileSizeMb > 0
? Math.max(1, Math.min(4096, Math.floor(rawMaxFileSizeMb)))
: DEFAULT_EXPORT_MEDIA_CONFIG.maxFileSizeMb
}
}
return null
@@ -567,7 +574,10 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr
videos: media.videos,
voices: media.voices,
emojis: media.emojis,
files: media.files
files: media.files,
maxFileSizeMb: Number.isFinite(media.maxFileSizeMb)
? Math.max(1, Math.min(4096, Math.floor(media.maxFileSizeMb)))
: DEFAULT_EXPORT_MEDIA_CONFIG.maxFileSizeMb
})
}
+1 -1
View File
@@ -1580,7 +1580,7 @@ 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; videoPath?: string; error?: string }>
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: string; status?: number; error?: string }>
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
+44
View File
@@ -0,0 +1,44 @@
export function normalizeDisplayNameValue(value?: unknown): string | undefined {
if (typeof value !== 'string') {
if (value === null || value === undefined) return undefined
const converted = String(value)
return converted.length > 0 ? converted.trim() || converted : undefined
}
if (value.length === 0) return undefined
const trimmed = value.trim()
if (!trimmed) return value
const lower = trimmed.toLowerCase()
if (
trimmed === '未知' ||
lower === 'unknown' ||
lower === 'null' ||
lower === 'undefined' ||
trimmed === '微信用户' ||
trimmed === '用户' ||
lower === 'wechat user' ||
lower.startsWith('unknown_sender_')
) {
return undefined
}
return trimmed
}
export function pickDisplayName(...values: Array<unknown>): string | undefined {
for (const value of values) {
const normalized = normalizeDisplayNameValue(value)
if (normalized !== undefined) return normalized
}
return undefined
}
export function displayNameOrFallback(fallback: string, ...values: Array<unknown>): string {
return pickDisplayName(...values) ?? fallback
}
export function displayNameForCompare(value?: unknown): string {
return normalizeDisplayNameValue(value)?.trim().toLowerCase() || ''
}
+23 -2
View File
@@ -45,9 +45,15 @@ const exportWorkerElectronShimPlugin = () => {
getPath,
getAppPath: () => process.cwd(),
getName: () => 'WeFlow',
getVersion: () => process.env.npm_package_version || '0.0.0'
getVersion: () => process.env.npm_package_version || '0.0.0',
// Worker 中不存在 app 生命周期事件(如 will-quit),no-op 兼容注册退出钩子的服务
on: () => app,
once: () => app,
off: () => app,
removeListener: () => app,
removeAllListeners: () => app
}
export const BrowserWindow = { getAllWindows: () => [] }
export const BrowserWindow = { getAllWindows: () => [], getFocusedWindow: () => null }
export const dialog = { showMessageBox: async () => ({ response: 0, checkboxChecked: false }) }
export const shell = { openExternal: async () => false, showItemInFolder: () => {} }
export const ipcMain = { on: () => {}, handle: () => {}, removeHandler: () => {} }
@@ -173,6 +179,21 @@ export default defineConfig({
}
}
},
{
entry: 'electron/imageDecryptWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
output: {
entryFileNames: 'imageDecryptWorker.js',
codeSplitting: false
}
}
}
}
},
{
entry: 'electron/wcdbWorker.ts',
onstart: handleElectronOnStart,