架构优化
This commit is contained in:
1 parent
147f3b7045
commit
ccbd154668
67 files changed
+1643
-338
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user