规范化资源文件;修复消息气泡宽度异常的问题;优化资源管理页面性能

This commit is contained in:
cc
2026-04-07 20:53:45 +08:00
parent 0acad9927a
commit b356814ebb
40 changed files with 469 additions and 128 deletions

4
.gitignore vendored
View File

@@ -56,6 +56,8 @@ Thumbs.db
*.aps *.aps
wcdb/ wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/ xkey/
server/ server/
*info *info
@@ -73,4 +75,4 @@ pnpm-lock.yaml
wechat-research-site wechat-research-site
.codex .codex
weflow-web-offical weflow-web-offical
Insight Insight

View File

@@ -2558,7 +2558,13 @@ function registerIpcHandlers() {
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload) return imageDecryptService.decryptImage(payload)
}) })
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => { ipcMain.handle('image:resolveCache', async (_, payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => {
return imageDecryptService.resolveCachedImage(payload) return imageDecryptService.resolveCachedImage(payload)
}) })
ipcMain.handle( ipcMain.handle(
@@ -2566,13 +2572,14 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => { ) => {
const list = Array.isArray(payloads) ? payloads : [] const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => { const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({ return imageDecryptService.resolveCachedImage({
...payload, ...payload,
disableUpdateCheck: options?.disableUpdateCheck === true disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false
}) })
})) }))
return { success: true, rows } return { success: true, rows }
@@ -2583,7 +2590,7 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => { ) => {
imagePreloadService.enqueue(payloads || [], options) imagePreloadService.enqueue(payloads || [], options)
return true return true

View File

@@ -266,15 +266,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload), ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload), ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options), ) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)

View File

@@ -63,6 +63,7 @@ type CachedImagePayload = {
imageDatName?: string imageDatName?: string
preferFilePath?: boolean preferFilePath?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean
} }
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
@@ -116,7 +117,9 @@ export class ImageDecryptService {
} }
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> { async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed() if (payload.allowCacheIndex !== false) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload) const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0] const cacheKey = cacheKeys[0]
if (!cacheKey) { if (!cacheKey) {

View File

@@ -8,11 +8,13 @@ type PreloadImagePayload = {
type PreloadOptions = { type PreloadOptions = {
allowDecrypt?: boolean allowDecrypt?: boolean
allowCacheIndex?: boolean
} }
type PreloadTask = PreloadImagePayload & { type PreloadTask = PreloadImagePayload & {
key: string key: string
allowDecrypt: boolean allowDecrypt: boolean
allowCacheIndex: boolean
} }
export class ImagePreloadService { export class ImagePreloadService {
@@ -27,6 +29,7 @@ export class ImagePreloadService {
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== false
for (const payload of payloads) { for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName const cacheKey = payload.imageMd5 || payload.imageDatName
@@ -34,7 +37,7 @@ export class ImagePreloadService {
const key = `${payload.sessionId || 'unknown'}|${cacheKey}` const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue if (this.pending.has(key)) continue
this.pending.add(key) this.pending.add(key)
this.queue.push({ ...payload, key, allowDecrypt }) this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
} }
this.processQueue() this.processQueue()
} }
@@ -71,7 +74,8 @@ export class ImagePreloadService {
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName, imageDatName: task.imageDatName,
disableUpdateCheck: !task.allowDecrypt disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex
}) })
if (cached.success) return if (cached.success) return
if (!task.allowDecrypt) return if (!task.allowDecrypt) return

View File

@@ -61,6 +61,7 @@ export class KeyService {
private getDllPath(): string { private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DLL_PATH) { if (process.env.WX_KEY_DLL_PATH) {
@@ -68,11 +69,20 @@ export class KeyService {
} }
if (isPackaged) { if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else { } else {
const cwd = process.cwd() const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'wx_key.dll')) candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll')) candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
} }

View File

@@ -25,13 +25,23 @@ export class KeyServiceLinux {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) { if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else { } else {
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
} }
for (const p of candidates) { for (const p of candidates) {

View File

@@ -27,6 +27,7 @@ export class KeyServiceMac {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) { if (process.env.WX_KEY_HELPER_PATH) {
@@ -34,12 +35,21 @@ export class KeyServiceMac {
} }
if (isPackaged) { if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper')) candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'xkey_helper')) candidates.push(join(process.resourcesPath, 'xkey_helper'))
} else { } else {
const cwd = process.cwd() const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'xkey_helper')) candidates.push(join(cwd, 'resources', 'xkey_helper'))
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper')) candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
} }
@@ -52,14 +62,24 @@ export class KeyServiceMac {
private getImageScanHelperPath(): string { private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (isPackaged) { if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else { } else {
const cwd = process.cwd() const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'image_scan_helper')) candidates.push(join(cwd, 'resources', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper')) candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
} }
@@ -72,6 +92,7 @@ export class KeyServiceMac {
private getDylibPath(): string { private getDylibPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DYLIB_PATH) { if (process.env.WX_KEY_DYLIB_PATH) {
@@ -79,11 +100,20 @@ export class KeyServiceMac {
} }
if (isPackaged) { if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
} else { } else {
const cwd = process.cwd() const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'libwx_key.dylib')) candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib')) candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
} }

View File

@@ -121,6 +121,9 @@ export class WcdbCore {
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map() private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly hardlinkCacheMaxEntries = 20000 private readonly hardlinkCacheMaxEntries = 20000
private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null
private mediaStreamSessionCacheAt = 0
private readonly mediaStreamSessionCacheTtlMs = 12 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null private lastResolvedLogPath: string | null = null
@@ -277,7 +280,9 @@ export class WcdbCore {
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64' const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32')
const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64')
const envDllPath = process.env.WCDB_DLL_PATH const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) { if (envDllPath && envDllPath.length > 0) {
@@ -287,20 +292,33 @@ export class WcdbCore {
// 基础路径探测 // 基础路径探测
const isPackaged = typeof process['resourcesPath'] !== 'undefined' const isPackaged = typeof process['resourcesPath'] !== 'undefined'
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
const roots = [
const candidates = [ process.env.WCDB_RESOURCES_PATH || null,
// 环境变量指定 resource 目录 this.resourcesPath || null,
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, join(resourcesPath, 'resources'),
// 显式 setPaths 设置的路径 resourcesPath,
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, join(process.cwd(), 'resources')
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
join(resourcesPath, 'resources', subDir, libName),
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
join(resourcesPath, subDir, libName),
// CWD fallback
join(process.cwd(), 'resources', subDir, libName)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64'
const relativeCandidates = [
join('wcdb', platformDir, archDir, libName),
join('wcdb', platformDir, normalizedArch, libName),
join('wcdb', platformDir, 'x64', libName),
join('wcdb', platformDir, 'universal', libName),
join('wcdb', platformDir, libName)
]
const candidates: string[] = []
for (const root of roots) {
for (const relativePath of relativeCandidates) {
candidates.push(join(root, relativePath))
}
// 兼容旧目录resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
candidates.push(join(root, legacySubDir, libName))
candidates.push(join(root, libName))
}
for (const path of candidates) { for (const path of candidates) {
if (existsSync(path)) return path if (existsSync(path)) return path
} }
@@ -1465,6 +1483,11 @@ export class WcdbCore {
this.videoHardlinkCache.clear() this.videoHardlinkCache.clear()
} }
private clearMediaStreamSessionCache(): void {
this.mediaStreamSessionCache = null
this.mediaStreamSessionCacheAt = 0
}
isReady(): boolean { isReady(): boolean {
return this.ensureReady() return this.ensureReady()
} }
@@ -1580,6 +1603,7 @@ export class WcdbCore {
this.currentDbStoragePath = null this.currentDbStoragePath = null
this.initialized = false this.initialized = false
this.clearHardlinkCaches() this.clearHardlinkCaches()
this.clearMediaStreamSessionCache()
this.stopLogPolling() this.stopLogPolling()
} }
} }
@@ -1957,7 +1981,7 @@ export class WcdbCore {
error?: string error?: string
}> { }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' } if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' }
try { try {
const toInt = (value: unknown): number => { const toInt = (value: unknown): number => {
const n = Number(value || 0) const n = Number(value || 0)
@@ -2168,37 +2192,64 @@ export class WcdbCore {
const offset = Math.max(0, toInt(options?.offset)) const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
const sessionsRes = await this.getSessions() const getSessionRows = async (): Promise<{
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { success: boolean
return { success: false, error: sessionsRes.error || '读取会话失败' } rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }>
error?: string
}> => {
const now = Date.now()
const cachedRows = this.mediaStreamSessionCache
if (
cachedRows &&
now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs
) {
return { success: true, rows: cachedRows }
}
const sessionsRes = await this.getSessions()
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
return { success: false, error: sessionsRes.error || '读取会话失败' }
}
const rows = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
this.mediaStreamSessionCache = rows
this.mediaStreamSessionCacheAt = now
return { success: true, rows }
} }
const sessions = (sessionsRes.sessions || []) let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
.map((row: any) => ({ if (requestedSessionId) {
sessionId: String( sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
row.username || } else {
row.user_name || const sessionsRowsRes = await getSessionRows()
row.userName || if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
row.usrName || return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
row.UsrName || }
row.talker || sessionRows = sessionsRowsRes.rows
'' }
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
return { success: true, items: [], hasMore: false, nextOffset: offset } return { success: true, items: [], hasMore: false, nextOffset: offset }
} }
@@ -2219,10 +2270,10 @@ export class WcdbCore {
outHasMore outHasMore
) )
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `扫描媒体流失败: ${result}` } return { success: false, error: `扫描资源失败: ${result}` }
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体流失败' } if (!jsonStr) return { success: false, error: '解析资源失败' }
const rows = JSON.parse(jsonStr) const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : [] const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
@@ -2254,19 +2305,39 @@ export class WcdbCore {
rawMessageContent && rawMessageContent &&
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
) )
const content = useRawMessageContent const decodeContentIfNeeded = (): string => {
? rawMessageContent if (useRawMessageContent) return rawMessageContent
: decodeMessageContent(rawMessageContent, rawCompressContent) if (!rawMessageContent && !rawCompressContent) return ''
return decodeMessageContent(rawMessageContent, rawCompressContent)
}
const packedPayload = extractPackedPayload(row) const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const imageMd5 = localType === 3
? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const videoMd5 = localType === 43
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined) let content = ''
: undefined let imageMd5: string | undefined
let imageDatName: string | undefined
let videoMd5: string | undefined
if (localType === 3) {
imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined
imageDatName = extractImageDatName(row, '') || undefined
if (!imageMd5 || !imageDatName) {
content = decodeContentIfNeeded()
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
}
} else if (localType === 43) {
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
if (!videoMd5) {
content = decodeContentIfNeeded()
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
} else if (useRawMessageContent) {
// 占位态标题只依赖简单 XML已带 md5 时不做额外解压
content = rawMessageContent
}
}
return { return {
sessionId, sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
@@ -2280,7 +2351,7 @@ export class WcdbCore {
imageMd5, imageMd5,
imageDatName, imageDatName,
videoMd5, videoMd5,
content: content || undefined content: localType === 43 ? (content || undefined) : undefined
} }
}) })

View File

@@ -98,7 +98,7 @@
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist", "entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"win": { "win": {
"target": [ "target": [
@@ -107,19 +107,19 @@
"icon": "public/icon.ico", "icon": "public/icon.ico",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/msvcp140.dll", "from": "resources/runtime/win32/msvcp140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/msvcp140_1.dll", "from": "resources/runtime/win32/msvcp140_1.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140.dll", "from": "resources/runtime/win32/vcruntime140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140_1.dll", "from": "resources/runtime/win32/vcruntime140_1.dll",
"to": "." "to": "."
} }
] ]
@@ -135,7 +135,7 @@
"synopsis": "WeFlow for Linux", "synopsis": "WeFlow for Linux",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/linux/install.sh", "from": "resources/installer/linux/install.sh",
"to": "install.sh" "to": "install.sh"
} }
] ]
@@ -190,7 +190,7 @@
"node_modules/sherpa-onnx-*/**/*", "node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*"
], ],
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"overrides": { "overrides": {
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1965,6 +1965,10 @@
color: var(--on-primary); color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
} }
.bubble-body {
align-items: flex-end;
}
} }
// 对方发送的消息 - 左侧白色 // 对方发送的消息 - 左侧白色
@@ -1974,6 +1978,10 @@
color: var(--text-primary); color: var(--text-primary);
border-radius: 18px 18px 18px 4px; border-radius: 18px 18px 18px 4px;
} }
.bubble-body {
align-items: flex-start;
}
} }
&.system { &.system {
@@ -2038,6 +2046,12 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
.message-bubble:not(.system) .bubble-content {
width: fit-content;
max-width: 100%;
}
// 表情包消息 // 表情包消息
.message-bubble.emoji { .message-bubble.emoji {
.bubble-content { .bubble-content {

View File

@@ -1,6 +1,7 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso' import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss' import './ResourcesPage.scss'
type MediaTab = 'image' | 'video' type MediaTab = 'image' | 'video'
@@ -35,10 +36,14 @@ type DialogState = {
onConfirm?: (() => void) | null onConfirm?: (() => void) | null
} }
const PAGE_SIZE = 120 const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18 const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36 const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4 const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) { const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props const { className = '', ...rest } = props
@@ -409,7 +414,13 @@ function ResourcesPage() {
} }
try { try {
await window.electronAPI.chat.connect() if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset const requestOffset = reset ? 0 : nextOffset
const streamResult = await window.electronAPI.chat.getMediaStream({ const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact, sessionId: selectedContact === 'all' ? undefined : selectedContact,
@@ -524,7 +535,6 @@ function ResourcesPage() {
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
try { try {
await window.electronAPI.chat.connect()
const sessionResult = await window.electronAPI.chat.getSessions() const sessionResult = await window.electronAPI.chat.getSessions()
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) { if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
const initialNameMap: Record<string, string> = {} const initialNameMap: Record<string, string> = {}
@@ -674,7 +684,10 @@ function ResourcesPage() {
resolvingImageCacheBatchRef.current = true resolvingImageCacheBatchRef.current = true
void (async () => { void (async () => {
try { try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true }) const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: false
})
const rows = Array.isArray(result?.rows) ? result.rows : [] const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {} const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
@@ -741,7 +754,10 @@ function ResourcesPage() {
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
} }
if (payloads.length === 0) return if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, { allowDecrypt: false }) void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
})
}, [displayItems]) }, [displayItems])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => { const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
@@ -813,14 +829,18 @@ function ResourcesPage() {
if (!pending) return if (!pending) return
pendingRangeRef.current = null pendingRangeRef.current = null
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(pending.start - 8, pending.end + 32) preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 2, pending.end + 8) resolveImageCacheRange(pending.start - 1, pending.end + 6)
return return
} }
resolvePosterRange(pending.start, pending.end) resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) }, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => { const scheduleRangeResolve = useCallback((start: number, end: number) => {
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
return
}
pendingRangeRef.current = { start, end } pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) { if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current) window.clearTimeout(rangeTimerRef.current)
@@ -832,8 +852,8 @@ function ResourcesPage() {
useEffect(() => { useEffect(() => {
if (displayItems.length === 0) return if (displayItems.length === 0) return
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80)) preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20)) resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
return return
} }
resolvePosterRange(0, Math.min(displayItems.length - 1, 12)) resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
setBatchBusy(true) setBatchBusy(true)
let success = 0 let success = 0
let failed = 0
const previewPatch: Record<string, string> = {} const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
sourcePage: 'other',
title: '资源页图片批量解密',
detail: `正在解密图片0/${imageItems.length}`,
progressText: `0 / ${imageItems.length}`,
cancelable: false
})
try { try {
let completed = 0
const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS))
let lastProgressBucket = 0
let lastProgressUpdateAt = Date.now()
const updateTaskProgress = (force: boolean = false) => {
const now = Date.now()
const bucket = Math.floor(completed / progressStep)
const crossedBucket = bucket !== lastProgressBucket
const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS
if (!force && !crossedBucket && !intervalReached) return
updateBackgroundTask(taskId, {
detail: `正在解密图片(${completed}/${imageItems.length}`,
progressText: `${completed} / ${imageItems.length}`
})
lastProgressBucket = bucket
lastProgressUpdateAt = now
}
for (const item of imageItems) { for (const item of imageItems) {
if (!item.imageMd5 && !item.imageDatName) continue if (!item.imageMd5 && !item.imageDatName) {
failed += 1
completed += 1
updateTaskProgress()
continue
}
const result = await window.electronAPI.image.decrypt({ const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId, sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined, imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined, imageDatName: item.imageDatName || undefined,
force: true force: true
}) })
if (!result?.success) continue if (!result?.success) {
success += 1 failed += 1
if (result.localPath) { } else {
const key = getItemKey(item) success += 1
previewPatch[key] = result.localPath if (result.localPath) {
updatePatch[key] = isLikelyThumbnailPreview(result.localPath) const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
} }
completed += 1
updateTaskProgress()
} }
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) { if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch })) setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
if (Object.keys(updatePatch).length > 0) { if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
} }
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`) setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成') showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
progressText: `成功 ${success} / 失败 ${failed}`
})
} catch (e) {
finishBackgroundTask(taskId, 'failed', {
detail: `资源页图片批量解密失败:${String(e)}`
})
showAlert(`批量解密失败:${String(e)}`, '批量解密失败')
} finally { } finally {
setBatchBusy(false) setBatchBusy(false)
} }

View File

@@ -1,4 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
export interface BatchImageDecryptState { export interface BatchImageDecryptState {
isBatchDecrypting: boolean isBatchDecrypting: boolean
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
result: { success: number; fail: number } result: { success: number; fail: number }
startTime: number startTime: number
sessionName: string sessionName: string
taskId: string | null
startDecrypt: (total: number, sessionName: string) => void startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
updateProgress: (current: number, total: number) => void updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void setShowToast: (show: boolean) => void
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
reset: () => void reset: () => void
} }
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({ const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
isBatchDecrypting: false, isBatchDecrypting: false,
progress: { current: 0, total: 0 }, progress: { current: 0, total: 0 },
showToast: false, showToast: false,
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
result: { success: 0, fail: 0 }, result: { success: 0, fail: 0 },
startTime: 0, startTime: 0,
sessionName: '', sessionName: '',
taskId: null,
startDecrypt: (total, sessionName) => set({ startDecrypt: (total, sessionName, sourcePage = 'chat') => {
isBatchDecrypting: true, const previousTaskId = get().taskId
progress: { current: 0, total }, if (previousTaskId) {
showToast: true, taskProgressUpdateMeta.delete(previousTaskId)
showResultToast: false, finishBackgroundTask(previousTaskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '已被新的批量解密任务替换',
startTime: Date.now(), progressText: '已替换'
sessionName })
}), }
updateProgress: (current, total) => set({ const normalizedProgress = clampProgress(0, total)
progress: { current, total } const normalizedSessionName = String(sessionName || '').trim()
}), const title = normalizedSessionName
? `图片批量解密(${normalizedSessionName}`
: '图片批量解密'
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: false
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
finishDecrypt: (success, fail) => set({ set({
isBatchDecrypting: false, isBatchDecrypting: true,
showToast: false, progress: normalizedProgress,
showResultToast: true, showToast: true,
result: { success, fail }, showResultToast: false,
startTime: 0 result: { success: 0, fail: 0 },
}), startTime: Date.now(),
sessionName: normalizedSessionName,
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
if (crossedBucket || intervalReached || isFinal) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
) {
set({
progress: normalizedProgress
})
}
},
finishDecrypt: (success, fail) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
finishBackgroundTask(taskId, status, {
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }), setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }), setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({ reset: () => {
isBatchDecrypting: false, const taskId = get().taskId
progress: { current: 0, total: 0 }, if (taskId) {
showToast: false, taskProgressUpdateMeta.delete(taskId)
showResultToast: false, finishBackgroundTask(taskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '批量解密任务已重置',
startTime: 0, progressText: '已停止'
sessionName: '' })
}) }
}))
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null
})
}
}))

View File

@@ -403,10 +403,16 @@ export interface ElectronAPI {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -414,7 +420,7 @@ export interface ElectronAPI {
}> }>
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean> ) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void