mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
计划优化 P2/5
This commit is contained in:
@@ -242,12 +242,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||||
ipcRenderer.invoke('image:preload', payloads),
|
ipcRenderer.invoke('image:preload', payloads),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||||
},
|
},
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
ipcRenderer.on('image:cacheResolved', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2332,7 +2332,8 @@ class ExportService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
force: true // 导出优先高清,失败再回退缩略图
|
force: true, // 导出优先高清,失败再回退缩略图
|
||||||
|
preferFilePath: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success || !result.localPath) {
|
if (!result.success || !result.localPath) {
|
||||||
@@ -2341,7 +2342,8 @@ class ExportService {
|
|||||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName
|
imageDatName,
|
||||||
|
preferFilePath: true
|
||||||
})
|
})
|
||||||
if (thumbResult.success && thumbResult.localPath) {
|
if (thumbResult.success && thumbResult.localPath) {
|
||||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||||
@@ -2404,6 +2406,55 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async preloadMediaLookupCaches(
|
||||||
|
_sessionId: string,
|
||||||
|
messages: any[],
|
||||||
|
options: { exportImages?: boolean; exportVideos?: boolean },
|
||||||
|
control?: ExportTaskControl
|
||||||
|
): Promise<void> {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return
|
||||||
|
|
||||||
|
const md5Pattern = /^[a-f0-9]{32}$/i
|
||||||
|
const imageMd5Set = new Set<string>()
|
||||||
|
const videoMd5Set = new Set<string>()
|
||||||
|
|
||||||
|
let scanIndex = 0
|
||||||
|
for (const msg of messages) {
|
||||||
|
if ((scanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.exportImages && msg?.localType === 3) {
|
||||||
|
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
|
||||||
|
if (imageMd5) {
|
||||||
|
imageMd5Set.add(imageMd5)
|
||||||
|
} else {
|
||||||
|
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
|
||||||
|
if (md5Pattern.test(imageDatName)) {
|
||||||
|
imageMd5Set.add(imageDatName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.exportVideos && msg?.localType === 43) {
|
||||||
|
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
|
||||||
|
if (videoMd5) videoMd5Set.add(videoMd5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preloadTasks: Array<Promise<void>> = []
|
||||||
|
if (imageMd5Set.size > 0) {
|
||||||
|
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
|
||||||
|
}
|
||||||
|
if (videoMd5Set.size > 0) {
|
||||||
|
preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set)))
|
||||||
|
}
|
||||||
|
if (preloadTasks.length === 0) return
|
||||||
|
|
||||||
|
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出语音文件
|
* 导出语音文件
|
||||||
*/
|
*/
|
||||||
@@ -3644,6 +3695,10 @@ class ExportService {
|
|||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
@@ -4127,6 +4182,10 @@ class ExportService {
|
|||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
@@ -4934,6 +4993,10 @@ class ExportService {
|
|||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
@@ -5600,6 +5663,10 @@ class ExportService {
|
|||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
@@ -5938,6 +6005,10 @@ class ExportService {
|
|||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
@@ -6344,6 +6415,10 @@ class ExportService {
|
|||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVideos: options.exportVideos
|
||||||
|
}, control)
|
||||||
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
|
||||||
if (voiceMediaMessages.length > 0) {
|
if (voiceMediaMessages.length > 0) {
|
||||||
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ type DecryptResult = {
|
|||||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CachedImagePayload = {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
preferFilePath?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecryptImagePayload = CachedImagePayload & {
|
||||||
|
force?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class ImageDecryptService {
|
export class ImageDecryptService {
|
||||||
private configService = new ConfigService()
|
private configService = new ConfigService()
|
||||||
private resolvedCache = new Map<string, string>()
|
private resolvedCache = new Map<string, string>()
|
||||||
@@ -100,7 +111,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||||
await this.ensureCacheIndexed()
|
await this.ensureCacheIndexed()
|
||||||
const cacheKeys = this.getCacheKeys(payload)
|
const cacheKeys = this.getCacheKeys(payload)
|
||||||
const cacheKey = cacheKeys[0]
|
const cacheKey = cacheKeys[0]
|
||||||
@@ -110,7 +121,7 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
const isThumb = this.isThumbnailPath(cached)
|
const isThumb = this.isThumbnailPath(cached)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
if (isThumb) {
|
if (isThumb) {
|
||||||
@@ -118,8 +129,8 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
return { success: true, localPath, hasUpdate }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(key)
|
this.resolvedCache.delete(key)
|
||||||
@@ -130,7 +141,7 @@ export class ImageDecryptService {
|
|||||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
if (isThumb) {
|
if (isThumb) {
|
||||||
@@ -138,15 +149,15 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
return { success: true, localPath, hasUpdate }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
return { success: false, error: '未找到缓存图片' }
|
return { success: false, error: '未找到缓存图片' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
|
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||||
await this.ensureCacheIndexed()
|
await this.ensureCacheIndexed()
|
||||||
const cacheKeys = this.getCacheKeys(payload)
|
const cacheKeys = this.getCacheKeys(payload)
|
||||||
const cacheKey = cacheKeys[0]
|
const cacheKey = cacheKeys[0]
|
||||||
@@ -160,9 +171,8 @@ export class ImageDecryptService {
|
|||||||
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
|
||||||
return { success: true, localPath }
|
return { success: true, localPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
@@ -175,9 +185,8 @@ export class ImageDecryptService {
|
|||||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
const dataUrl = this.fileToDataUrl(existingHd)
|
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(existingHd)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
|
||||||
return { success: true, localPath }
|
return { success: true, localPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,9 +194,8 @@ export class ImageDecryptService {
|
|||||||
if (!payload.force) {
|
if (!payload.force) {
|
||||||
const cached = this.resolvedCache.get(cacheKey)
|
const cached = this.resolvedCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
|
||||||
return { success: true, localPath }
|
return { success: true, localPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
@@ -207,8 +215,44 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
|
const normalizedList = Array.from(
|
||||||
|
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||||
|
)
|
||||||
|
if (normalizedList.length === 0) return
|
||||||
|
|
||||||
|
const wxid = this.configService.get('myWxid')
|
||||||
|
const dbPath = this.configService.get('dbPath')
|
||||||
|
if (!wxid || !dbPath) return
|
||||||
|
|
||||||
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
|
if (!accountDir) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await this.ensureWcdbReady()
|
||||||
|
if (!ready) return
|
||||||
|
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
|
||||||
|
const result = await wcdbService.resolveImageHardlinkBatch(requests)
|
||||||
|
if (!result.success || !Array.isArray(result.rows)) return
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||||
|
if (!md5) continue
|
||||||
|
const fullPath = String(row?.data?.full_path || '').trim()
|
||||||
|
if (!fullPath || !existsSync(fullPath)) continue
|
||||||
|
this.cacheDatPath(accountDir, md5, fullPath)
|
||||||
|
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||||
|
if (fileName) {
|
||||||
|
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore preload failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async decryptImageInternal(
|
private async decryptImageInternal(
|
||||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
payload: DecryptImagePayload,
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
): Promise<DecryptResult> {
|
): Promise<DecryptResult> {
|
||||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||||
@@ -248,10 +292,9 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||||
const dataUrl = this.fileToDataUrl(datPath)
|
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(datPath)
|
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +306,9 @@ export class ImageDecryptService {
|
|||||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||||
if (!(payload.force && !isHd)) {
|
if (!(payload.force && !isHd)) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,9 +368,11 @@ export class ImageDecryptService {
|
|||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
}
|
}
|
||||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
const localPath = payload.preferFilePath
|
||||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
? outputPath
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||||
|
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||||
|
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
@@ -1494,6 +1538,16 @@ export class ImageDecryptService {
|
|||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
|
||||||
|
if (preferFilePath) return filePath
|
||||||
|
return this.resolveEmitPath(filePath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
|
||||||
|
if (preferFilePath) return this.filePathToUrl(filePath)
|
||||||
|
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
private fileToDataUrl(filePath: string): string | null {
|
private fileToDataUrl(filePath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const ext = extname(filePath).toLowerCase()
|
const ext = extname(filePath).toLowerCase()
|
||||||
|
|||||||
@@ -5,310 +5,539 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
coverUrl?: string // 封面 data URL
|
coverUrl?: string // 封面 data URL
|
||||||
thumbUrl?: string // 缩略图 data URL
|
thumbUrl?: string // 缩略图 data URL
|
||||||
exists: boolean
|
exists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoIndexEntry {
|
||||||
|
videoPath?: string
|
||||||
|
coverPath?: string
|
||||||
|
thumbPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
|
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||||
|
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||||
|
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||||
|
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||||
|
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||||
|
private readonly maxCacheEntries = 2000
|
||||||
|
private readonly maxIndexEntries = 6
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (!hit) return undefined
|
||||||
|
if (hit.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return hit.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeTimedCache<T>(
|
||||||
|
cache: Map<string, TimedCacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
ttlMs: number,
|
||||||
|
maxEntries: number
|
||||||
|
): void {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||||
|
if (cache.size <= maxEntries) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [cacheKey, entry] of cache) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
cache.delete(cacheKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(message: string, meta?: Record<string, unknown>): void {
|
while (cache.size > maxEntries) {
|
||||||
try {
|
const oldestKey = cache.keys().next().value as string | undefined
|
||||||
const timestamp = new Date().toISOString()
|
if (!oldestKey) break
|
||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
cache.delete(oldestKey)
|
||||||
const logDir = join(app.getPath('userData'), 'logs')
|
}
|
||||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
}
|
||||||
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
|
||||||
} catch {}
|
/**
|
||||||
|
* 获取数据库根目录
|
||||||
|
*/
|
||||||
|
private getDbPath(): string {
|
||||||
|
return this.configService.get('dbPath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的wxid
|
||||||
|
*/
|
||||||
|
private getMyWxid(): string {
|
||||||
|
return this.configService.get('myWxid') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
|
*/
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const trimmed = wxid.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
* 获取数据库根目录
|
if (suffixMatch) return suffixMatch[1]
|
||||||
*/
|
|
||||||
private getDbPath(): string {
|
return trimmed
|
||||||
return this.configService.get('dbPath') || ''
|
}
|
||||||
|
|
||||||
|
private getScopeKey(dbPath: string, wxid: string): string {
|
||||||
|
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return join(dbPath, 'msg', 'video')
|
||||||
|
}
|
||||||
|
return join(dbPath, wxid, 'msg', 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return [
|
||||||
* 获取当前用户的wxid
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
*/
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
private getMyWxid(): string {
|
]
|
||||||
return this.configService.get('myWxid') || ''
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||||
|
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
||||||
|
*/
|
||||||
|
private async resolveVideoHardlinks(
|
||||||
|
md5List: string[],
|
||||||
|
dbPath: string,
|
||||||
|
wxid: string,
|
||||||
|
cleanedWxid: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const normalizedList = Array.from(
|
||||||
|
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||||
|
)
|
||||||
|
const resolvedMap = new Map<string, string>()
|
||||||
|
let unresolved = [...normalizedList]
|
||||||
|
|
||||||
|
for (const md5 of normalizedList) {
|
||||||
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
|
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||||
|
if (cached === undefined) continue
|
||||||
|
if (cached) resolvedMap.set(md5, cached)
|
||||||
|
unresolved = unresolved.filter((item) => item !== md5)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (unresolved.length === 0) return resolvedMap
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
|
||||||
*/
|
|
||||||
private getCachePath(): string {
|
|
||||||
return this.configService.getCacheBasePath()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
for (const p of encryptedDbPaths) {
|
||||||
*/
|
if (!existsSync(p) || unresolved.length === 0) continue
|
||||||
private cleanWxid(wxid: string): string {
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
const trimmed = wxid.trim()
|
try {
|
||||||
if (!trimmed) return trimmed
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
|
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
for (const row of batchResult.rows) {
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||||
if (match) return match[1]
|
const inputMd5 = index >= 0 && index < requests.length
|
||||||
return trimmed
|
? requests[index].md5
|
||||||
}
|
: String(row?.md5 || '').trim().toLowerCase()
|
||||||
|
if (!inputMd5) continue
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||||
if (suffixMatch) return suffixMatch[1]
|
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||||
|
: ''
|
||||||
return trimmed
|
if (!resolvedMd5) continue
|
||||||
}
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
/**
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
}
|
||||||
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
|
||||||
*/
|
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
|
||||||
|
|
||||||
if (!wxid) {
|
|
||||||
this.log('queryVideoFileName: wxid 为空')
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
if (dbPath) {
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
|
||||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
|
||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
|
||||||
if (dbPathContainsWxid) {
|
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
} else {
|
|
||||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
this.log('尝试加密 hardlink.db', { path: p })
|
|
||||||
const result = await wcdbService.resolveVideoHardlinkMd5(md5, p)
|
|
||||||
if (result.success && result.data?.resolved_md5) {
|
|
||||||
const realMd5 = String(result.data.resolved_md5)
|
|
||||||
this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 })
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
|
||||||
} catch (e) {
|
|
||||||
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.log('加密 hardlink.db 不存在', { path: p })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将文件转换为 data URL
|
|
||||||
*/
|
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
|
||||||
try {
|
|
||||||
if (!existsSync(filePath)) return undefined
|
|
||||||
const buffer = readFileSync(filePath)
|
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据视频MD5获取视频文件信息
|
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
|
||||||
*/
|
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
|
|
||||||
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
|
||||||
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
|
||||||
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
let videoBaseDir: string
|
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
|
||||||
} else {
|
} else {
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
|
for (const req of requests) {
|
||||||
|
try {
|
||||||
|
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
|
||||||
|
const resolvedMd5 = single.success && single.data?.resolved_md5
|
||||||
|
? String(single.data.resolved_md5).trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
if (!resolvedMd5) continue
|
||||||
|
const cacheKey = `${scopeKey}|${req.md5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
|
resolvedMap.set(req.md5, resolvedMd5)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
unresolved = unresolved.filter((md5) => !resolvedMap.has(md5))
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
|
||||||
this.log('getVideoInfo: videoBaseDir 不存在')
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
|
||||||
try {
|
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
|
||||||
const yearMonthDirs = allDirs
|
|
||||||
.filter(dir => {
|
|
||||||
const dirPath = join(videoBaseDir, dir)
|
|
||||||
return statSync(dirPath).isDirectory()
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.localeCompare(a))
|
|
||||||
|
|
||||||
this.log('扫描目录', { dirs: yearMonthDirs })
|
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
|
||||||
|
|
||||||
if (existsSync(videoPath)) {
|
|
||||||
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带)
|
|
||||||
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
|
||||||
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
|
|
||||||
const allFiles = readdirSync(dirPath)
|
|
||||||
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
|
|
||||||
this.log('找到视频,相关文件列表', {
|
|
||||||
videoPath,
|
|
||||||
coverExists: existsSync(coverPath),
|
|
||||||
thumbExists: existsSync(thumbPath),
|
|
||||||
relatedFiles,
|
|
||||||
coverPath,
|
|
||||||
thumbPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoUrl: videoPath,
|
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
|
||||||
exists: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
|
|
||||||
this.log('未找到视频,开始全目录扫描', {
|
|
||||||
lookingForOriginal: `${videoMd5}.mp4`,
|
|
||||||
lookingForResolved: `${realVideoMd5}.mp4`,
|
|
||||||
hardlinkResolved: realVideoMd5 !== videoMd5
|
|
||||||
})
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
try {
|
|
||||||
const allFiles = readdirSync(dirPath)
|
|
||||||
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
|
|
||||||
// 检查原始 md5 是否部分匹配(前8位)
|
|
||||||
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
|
|
||||||
this.log(`目录 ${yearMonth} 扫描结果`, {
|
|
||||||
totalFiles: allFiles.length,
|
|
||||||
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
|
|
||||||
sampleMp4: mp4Files,
|
|
||||||
partialMatchByOriginalMd5: partialMatch
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
|
||||||
return { exists: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const md5 of unresolved) {
|
||||||
* 根据消息内容解析视频MD5
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
*/
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
parseVideoMd5(content: string): string | undefined {
|
}
|
||||||
if (!content) return undefined
|
|
||||||
|
|
||||||
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
return resolvedMap
|
||||||
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
}
|
||||||
|
|
||||||
|
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
|
||||||
|
|
||||||
|
if (!normalizedMd5 || !wxid || !dbPath) {
|
||||||
|
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
|
||||||
|
const resolved = resolvedMap.get(normalizedMd5)
|
||||||
|
if (resolved) {
|
||||||
|
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
if (!dbPath || !wxid) return
|
||||||
|
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件转换为 data URL
|
||||||
|
*/
|
||||||
|
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||||
|
try {
|
||||||
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||||
|
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const index = new Map<string, VideoIndexEntry>()
|
||||||
|
const ensureEntry = (key: string): VideoIndexEntry => {
|
||||||
|
let entry = index.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
entry = {}
|
||||||
|
index.set(key, entry)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
let files: string[] = []
|
||||||
try {
|
try {
|
||||||
// 收集所有 md5 相关属性,方便对比
|
files = readdirSync(dirPath)
|
||||||
const allMd5Attrs: string[] = []
|
} catch {
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
continue
|
||||||
let match
|
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
|
||||||
allMd5Attrs.push(match[0])
|
|
||||||
}
|
|
||||||
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
|
||||||
|
|
||||||
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
|
||||||
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMd5Match) {
|
|
||||||
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
|
||||||
return videoMsgMd5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
|
||||||
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (rawMd5Match) {
|
|
||||||
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
|
||||||
return rawMd5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
|
||||||
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (attrMatch) {
|
|
||||||
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
|
||||||
return attrMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法4:<md5>...</md5> 标签
|
|
||||||
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
|
||||||
if (md5TagMatch) {
|
|
||||||
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
|
||||||
return md5TagMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法5:兜底取 rawmd5 属性(任意位置)
|
|
||||||
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (rawMd5Fallback) {
|
|
||||||
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
|
||||||
return rawMd5Fallback[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
|
||||||
} catch (e) {
|
|
||||||
this.log('parseVideoMd5 异常', { error: String(e) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
for (const file of files) {
|
||||||
|
const lower = file.toLowerCase()
|
||||||
|
const fullPath = join(dirPath, file)
|
||||||
|
|
||||||
|
if (lower.endsWith('.mp4')) {
|
||||||
|
const md5 = lower.slice(0, -4)
|
||||||
|
const entry = ensureEntry(md5)
|
||||||
|
if (!entry.videoPath) entry.videoPath = fullPath
|
||||||
|
if (md5.endsWith('_raw')) {
|
||||||
|
const baseMd5 = md5.replace(/_raw$/, '')
|
||||||
|
const baseEntry = ensureEntry(baseMd5)
|
||||||
|
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower.endsWith('.jpg')) continue
|
||||||
|
const jpgBase = lower.slice(0, -4)
|
||||||
|
if (jpgBase.endsWith('_thumb')) {
|
||||||
|
const baseMd5 = jpgBase.slice(0, -6)
|
||||||
|
const entry = ensureEntry(baseMd5)
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = fullPath
|
||||||
|
} else {
|
||||||
|
const entry = ensureEntry(jpgBase)
|
||||||
|
if (!entry.coverPath) entry.coverPath = fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of index) {
|
||||||
|
if (!key.endsWith('_raw')) continue
|
||||||
|
const baseKey = key.replace(/_raw$/, '')
|
||||||
|
const baseEntry = index.get(baseKey)
|
||||||
|
if (!baseEntry) continue
|
||||||
|
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.writeTimedCache(
|
||||||
|
this.videoDirIndexCache,
|
||||||
|
videoBaseDir,
|
||||||
|
index,
|
||||||
|
this.videoIndexCacheTtlMs,
|
||||||
|
this.maxIndexEntries
|
||||||
|
)
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string): VideoInfo | null {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedMd5) return null
|
||||||
|
|
||||||
|
const candidates = [normalizedMd5]
|
||||||
|
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
|
||||||
|
if (baseMd5 !== normalizedMd5) {
|
||||||
|
candidates.push(baseMd5)
|
||||||
|
} else {
|
||||||
|
candidates.push(`${normalizedMd5}_raw`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entry = index.get(key)
|
||||||
|
if (!entry?.videoPath) continue
|
||||||
|
if (!existsSync(entry.videoPath)) continue
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null {
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
|
if (!existsSync(videoPath)) continue
|
||||||
|
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||||
|
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据视频MD5获取视频文件信息
|
||||||
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
|
*/
|
||||||
|
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||||
|
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||||
|
|
||||||
|
if (!dbPath || !wxid || !normalizedMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const cacheKey = `${scopeKey}|${normalizedMd5}`
|
||||||
|
|
||||||
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
|
if (cachedInfo) return cachedInfo
|
||||||
|
|
||||||
|
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
|
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||||
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
|
|
||||||
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return miss
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||||
|
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5)
|
||||||
|
if (indexed) {
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5)
|
||||||
|
if (fallback) {
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||||
|
return miss
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.pendingVideoInfo.set(cacheKey, task)
|
||||||
|
try {
|
||||||
|
return await task
|
||||||
|
} finally {
|
||||||
|
this.pendingVideoInfo.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据消息内容解析视频MD5
|
||||||
|
*/
|
||||||
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收集所有 md5 相关属性,方便对比
|
||||||
|
const allMd5Attrs: string[] = []
|
||||||
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
|
let match
|
||||||
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
|
allMd5Attrs.push(match[0])
|
||||||
|
}
|
||||||
|
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||||
|
|
||||||
|
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||||
|
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (videoMsgMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||||
|
return videoMsgMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
|
return rawMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4:<md5>...</md5> 标签
|
||||||
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
if (md5TagMatch) {
|
||||||
|
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||||
|
return md5TagMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||||
|
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Fallback) {
|
||||||
|
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||||
|
return rawMd5Fallback[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||||
|
} catch (e) {
|
||||||
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videoService = new VideoService()
|
export const videoService = new VideoService()
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export class WcdbCore {
|
|||||||
|
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private imageHardlinkCache: 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 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
|
||||||
@@ -1281,6 +1284,52 @@ export class WcdbCore {
|
|||||||
return { begin: normalizedBegin, end: normalizedEnd }
|
return { begin: normalizedBegin, end: normalizedEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private makeHardlinkCacheKey(primary: string, secondary?: string | null): string {
|
||||||
|
const a = String(primary || '').trim().toLowerCase()
|
||||||
|
const b = String(secondary || '').trim().toLowerCase()
|
||||||
|
return `${a}\u001f${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private readHardlinkCache(
|
||||||
|
cache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }>,
|
||||||
|
key: string
|
||||||
|
): { success: boolean; data?: any; error?: string } | null {
|
||||||
|
const entry = cache.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) {
|
||||||
|
cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.cloneHardlinkResult(entry.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeHardlinkCache(
|
||||||
|
cache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }>,
|
||||||
|
key: string,
|
||||||
|
result: { success: boolean; data?: any; error?: string }
|
||||||
|
): void {
|
||||||
|
cache.set(key, {
|
||||||
|
result: this.cloneHardlinkResult(result),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } {
|
||||||
|
const data = result.data && typeof result.data === 'object'
|
||||||
|
? { ...result.data }
|
||||||
|
: result.data
|
||||||
|
return {
|
||||||
|
success: result.success === true,
|
||||||
|
data,
|
||||||
|
error: result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHardlinkCaches(): void {
|
||||||
|
this.imageHardlinkCache.clear()
|
||||||
|
this.videoHardlinkCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
isReady(): boolean {
|
isReady(): boolean {
|
||||||
return this.ensureReady()
|
return this.ensureReady()
|
||||||
}
|
}
|
||||||
@@ -1388,6 +1437,7 @@ export class WcdbCore {
|
|||||||
this.currentWxid = null
|
this.currentWxid = null
|
||||||
this.currentDbStoragePath = null
|
this.currentDbStoragePath = null
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
this.clearHardlinkCaches()
|
||||||
this.stopLogPolling()
|
this.stopLogPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2751,13 +2801,22 @@ export class WcdbCore {
|
|||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' }
|
if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' }
|
||||||
try {
|
try {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
const normalizedAccountDir = String(accountDir || '').trim()
|
||||||
|
if (!normalizedMd5) return { success: false, error: 'md5 为空' }
|
||||||
|
const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir)
|
||||||
|
const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr)
|
const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` }
|
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` }
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' }
|
if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' }
|
||||||
const data = JSON.parse(jsonStr) || {}
|
const data = JSON.parse(jsonStr) || {}
|
||||||
return { success: true, data }
|
const finalResult = { success: true, data }
|
||||||
|
this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult)
|
||||||
|
return finalResult
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
@@ -2767,13 +2826,80 @@ export class WcdbCore {
|
|||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' }
|
if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' }
|
||||||
try {
|
try {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
const normalizedDbPath = String(dbPath || '').trim()
|
||||||
|
if (!normalizedMd5) return { success: false, error: 'md5 为空' }
|
||||||
|
const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath)
|
||||||
|
const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr)
|
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` }
|
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` }
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' }
|
if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' }
|
||||||
const data = JSON.parse(jsonStr) || {}
|
const data = JSON.parse(jsonStr) || {}
|
||||||
return { success: true, data }
|
const finalResult = { success: true, data }
|
||||||
|
this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult)
|
||||||
|
return finalResult
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlinkBatch(
|
||||||
|
requests: Array<{ md5: string; accountDir?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
||||||
|
try {
|
||||||
|
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
|
||||||
|
for (let i = 0; i < requests.length; i += 1) {
|
||||||
|
const req = requests[i] || { md5: '' }
|
||||||
|
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedMd5) {
|
||||||
|
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = await this.resolveImageHardlink(normalizedMd5, req.accountDir)
|
||||||
|
rows.push({
|
||||||
|
index: i,
|
||||||
|
md5: normalizedMd5,
|
||||||
|
success: result.success === true,
|
||||||
|
data: result.data,
|
||||||
|
error: result.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { success: true, rows }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5Batch(
|
||||||
|
requests: Array<{ md5: string; dbPath?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
||||||
|
try {
|
||||||
|
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
|
||||||
|
for (let i = 0; i < requests.length; i += 1) {
|
||||||
|
const req = requests[i] || { md5: '' }
|
||||||
|
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedMd5) {
|
||||||
|
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = await this.resolveVideoHardlinkMd5(normalizedMd5, req.dbPath)
|
||||||
|
rows.push({
|
||||||
|
index: i,
|
||||||
|
md5: normalizedMd5,
|
||||||
|
success: result.success === true,
|
||||||
|
data: result.data,
|
||||||
|
error: result.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { success: true, rows }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,10 +505,22 @@ export class WcdbService {
|
|||||||
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlinkBatch(
|
||||||
|
requests: Array<{ md5: string; accountDir?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveImageHardlinkBatch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5Batch(
|
||||||
|
requests: Array<{ md5: string; dbPath?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取朋友圈
|
* 获取朋友圈
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -200,9 +200,15 @@ if (parentPort) {
|
|||||||
case 'resolveImageHardlink':
|
case 'resolveImageHardlink':
|
||||||
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
||||||
break
|
break
|
||||||
|
case 'resolveImageHardlinkBatch':
|
||||||
|
result = await core.resolveImageHardlinkBatch(payload.requests)
|
||||||
|
break
|
||||||
case 'resolveVideoHardlinkMd5':
|
case 'resolveVideoHardlinkMd5':
|
||||||
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
||||||
break
|
break
|
||||||
|
case 'resolveVideoHardlinkMd5Batch':
|
||||||
|
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
|
||||||
|
break
|
||||||
case 'getSnsTimeline':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1777,6 +1777,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-virtuoso {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-messages.loading-overlay {
|
.loading-messages.loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1894,6 +1898,7 @@
|
|||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
margin-bottom: 16px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
&.sent {
|
&.sent {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||||
@@ -774,7 +776,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
isConnecting,
|
isConnecting,
|
||||||
connectionError,
|
connectionError,
|
||||||
sessions,
|
sessions,
|
||||||
filteredSessions,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
isLoadingSessions,
|
isLoadingSessions,
|
||||||
messages,
|
messages,
|
||||||
@@ -786,7 +787,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setConnecting,
|
setConnecting,
|
||||||
setConnectionError,
|
setConnectionError,
|
||||||
setSessions,
|
setSessions,
|
||||||
setFilteredSessions,
|
|
||||||
setCurrentSession,
|
setCurrentSession,
|
||||||
setLoadingSessions,
|
setLoadingSessions,
|
||||||
setMessages,
|
setMessages,
|
||||||
@@ -797,11 +797,46 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
hasMoreLater,
|
hasMoreLater,
|
||||||
setHasMoreLater,
|
setHasMoreLater,
|
||||||
setSearchKeyword
|
setSearchKeyword
|
||||||
} = useChatStore()
|
} = useChatStore(useShallow((state) => ({
|
||||||
|
isConnected: state.isConnected,
|
||||||
|
isConnecting: state.isConnecting,
|
||||||
|
connectionError: state.connectionError,
|
||||||
|
sessions: state.sessions,
|
||||||
|
currentSessionId: state.currentSessionId,
|
||||||
|
isLoadingSessions: state.isLoadingSessions,
|
||||||
|
messages: state.messages,
|
||||||
|
isLoadingMessages: state.isLoadingMessages,
|
||||||
|
isLoadingMore: state.isLoadingMore,
|
||||||
|
hasMoreMessages: state.hasMoreMessages,
|
||||||
|
searchKeyword: state.searchKeyword,
|
||||||
|
setConnected: state.setConnected,
|
||||||
|
setConnecting: state.setConnecting,
|
||||||
|
setConnectionError: state.setConnectionError,
|
||||||
|
setSessions: state.setSessions,
|
||||||
|
setCurrentSession: state.setCurrentSession,
|
||||||
|
setLoadingSessions: state.setLoadingSessions,
|
||||||
|
setMessages: state.setMessages,
|
||||||
|
appendMessages: state.appendMessages,
|
||||||
|
setLoadingMessages: state.setLoadingMessages,
|
||||||
|
setLoadingMore: state.setLoadingMore,
|
||||||
|
setHasMoreMessages: state.setHasMoreMessages,
|
||||||
|
hasMoreLater: state.hasMoreLater,
|
||||||
|
setHasMoreLater: state.setHasMoreLater,
|
||||||
|
setSearchKeyword: state.setSearchKeyword
|
||||||
|
})))
|
||||||
|
|
||||||
const messageListRef = useRef<HTMLDivElement>(null)
|
const messageListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [messageListScrollParent, setMessageListScrollParent] = useState<HTMLDivElement | null>(null)
|
||||||
|
const messageVirtuosoRef = useRef<VirtuosoHandle | null>(null)
|
||||||
|
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
|
||||||
|
const topRangeLoadLockRef = useRef(false)
|
||||||
|
const bottomRangeLoadLockRef = useRef(false)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
messageListRef.current = node
|
||||||
|
setMessageListScrollParent(node)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const getMessageKey = useCallback((msg: Message): string => {
|
const getMessageKey = useCallback((msg: Message): string => {
|
||||||
if (msg.messageKey) return msg.messageKey
|
if (msg.messageKey) return msg.messageKey
|
||||||
@@ -857,6 +892,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
|
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
|
||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
|
const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
||||||
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
|
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
|
||||||
@@ -877,8 +913,36 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [tempFields, setTempFields] = useState<XmlField[]>([])
|
const [tempFields, setTempFields] = useState<XmlField[]>([])
|
||||||
|
|
||||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||||
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
const {
|
||||||
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
|
isBatchTranscribing,
|
||||||
|
batchTranscribeProgress,
|
||||||
|
startTranscribe,
|
||||||
|
updateProgress,
|
||||||
|
finishTranscribe,
|
||||||
|
setShowBatchProgress
|
||||||
|
} = useBatchTranscribeStore(useShallow((state) => ({
|
||||||
|
isBatchTranscribing: state.isBatchTranscribing,
|
||||||
|
batchTranscribeProgress: state.progress,
|
||||||
|
startTranscribe: state.startTranscribe,
|
||||||
|
updateProgress: state.updateProgress,
|
||||||
|
finishTranscribe: state.finishTranscribe,
|
||||||
|
setShowBatchProgress: state.setShowToast
|
||||||
|
})))
|
||||||
|
const {
|
||||||
|
isBatchDecrypting,
|
||||||
|
batchDecryptProgress,
|
||||||
|
startDecrypt,
|
||||||
|
updateDecryptProgress,
|
||||||
|
finishDecrypt,
|
||||||
|
setShowBatchDecryptToast
|
||||||
|
} = useBatchImageDecryptStore(useShallow((state) => ({
|
||||||
|
isBatchDecrypting: state.isBatchDecrypting,
|
||||||
|
batchDecryptProgress: state.progress,
|
||||||
|
startDecrypt: state.startDecrypt,
|
||||||
|
updateDecryptProgress: state.updateProgress,
|
||||||
|
finishDecrypt: state.finishDecrypt,
|
||||||
|
setShowBatchDecryptToast: state.setShowToast
|
||||||
|
})))
|
||||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||||
@@ -943,8 +1007,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const sessionSwitchRequestSeqRef = useRef(0)
|
const sessionSwitchRequestSeqRef = useRef(0)
|
||||||
const initialLoadRequestedSessionRef = useRef<string | null>(null)
|
const initialLoadRequestedSessionRef = useRef<string | null>(null)
|
||||||
const prevSessionRef = useRef<string | null>(null)
|
const prevSessionRef = useRef<string | null>(null)
|
||||||
const isLoadingMessagesRef = useRef(false)
|
|
||||||
const isLoadingMoreRef = useRef(false)
|
|
||||||
const isConnectedRef = useRef(false)
|
const isConnectedRef = useRef(false)
|
||||||
const isRefreshingRef = useRef(false)
|
const isRefreshingRef = useRef(false)
|
||||||
const searchKeywordRef = useRef('')
|
const searchKeywordRef = useRef('')
|
||||||
@@ -2224,7 +2286,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
setCurrentSession(null)
|
setCurrentSession(null)
|
||||||
setSessions([])
|
setSessions([])
|
||||||
setFilteredSessions([])
|
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setConnectionError(null)
|
setConnectionError(null)
|
||||||
@@ -2243,7 +2304,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setConnecting,
|
setConnecting,
|
||||||
setConnectionError,
|
setConnectionError,
|
||||||
setCurrentSession,
|
setCurrentSession,
|
||||||
setFilteredSessions,
|
|
||||||
setHasMoreLater,
|
setHasMoreLater,
|
||||||
setHasMoreMessages,
|
setHasMoreMessages,
|
||||||
setMessages,
|
setMessages,
|
||||||
@@ -2254,6 +2314,24 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setSessions
|
setSessions
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false
|
||||||
|
void configService.getAutoTranscribeVoice()
|
||||||
|
.then((enabled) => {
|
||||||
|
if (!canceled) {
|
||||||
|
setAutoTranscribeVoiceEnabled(Boolean(enabled))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!canceled) {
|
||||||
|
setAutoTranscribeVoiceEnabled(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
canceled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -2270,6 +2348,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 同步 currentSessionId 到 ref
|
// 同步 currentSessionId 到 ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentSessionRef.current = currentSessionId
|
currentSessionRef.current = currentSessionId
|
||||||
|
topRangeLoadLockRef.current = false
|
||||||
|
bottomRangeLoadLockRef.current = false
|
||||||
}, [currentSessionId])
|
}, [currentSessionId])
|
||||||
|
|
||||||
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
|
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
|
||||||
@@ -2611,7 +2691,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
flashNewMessages(newOnes.map(getMessageKey))
|
flashNewMessages(newOnes.map(getMessageKey))
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (messageListRef.current) {
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
|
const lastIndex = latestMessages.length - 1
|
||||||
|
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
|
||||||
|
} else if (messageListRef.current) {
|
||||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2660,7 +2744,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
flashNewMessages(newMessages.map(getMessageKey))
|
flashNewMessages(newMessages.map(getMessageKey))
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (messageListRef.current) {
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
|
const lastIndex = currentMessages.length - 1
|
||||||
|
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
|
||||||
|
} else if (messageListRef.current) {
|
||||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2739,7 +2827,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录加载前的第一条消息元素
|
const visibleRange = visibleMessageRangeRef.current
|
||||||
|
const visibleStartIndex = Math.min(
|
||||||
|
Math.max(visibleRange.startIndex, 0),
|
||||||
|
Math.max(messages.length - 1, 0)
|
||||||
|
)
|
||||||
|
const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0
|
||||||
|
? getMessageKey(messages[visibleStartIndex])
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 记录加载前的第一条消息元素(非虚拟列表回退路径)
|
||||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2792,13 +2889,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
// 日期跳转时滚动到顶部,否则滚动到底部
|
// 日期跳转时滚动到顶部,否则滚动到底部
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (messageListRef.current) {
|
if (isDateJumpRef.current) {
|
||||||
if (isDateJumpRef.current) {
|
if (messageVirtuosoRef.current && result.messages.length > 0) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' })
|
||||||
|
} else if (messageListRef.current) {
|
||||||
messageListRef.current.scrollTop = 0
|
messageListRef.current.scrollTop = 0
|
||||||
isDateJumpRef.current = false
|
|
||||||
} else {
|
|
||||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
isDateJumpRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = result.messages.length - 1
|
||||||
|
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
|
||||||
|
} else if (messageListRef.current) {
|
||||||
|
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -2816,12 +2921,27 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
|
// 加载更早消息后保持视口锚点,避免跳屏
|
||||||
if (firstMsgEl && listEl) {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
if (messageVirtuosoRef.current) {
|
||||||
|
if (anchorMessageKeyBeforePrepend) {
|
||||||
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
|
const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend)
|
||||||
|
if (anchorIndex >= 0) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.messages.length > 0) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({ index: result.messages.length, align: 'start', behavior: 'auto' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstMsgEl && listEl) {
|
||||||
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
// 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的
|
// 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的
|
||||||
if (ascending) {
|
if (ascending) {
|
||||||
@@ -3775,43 +3895,66 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setGlobalMsgAuthoritativeSessionCount(0)
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
|
||||||
const scrollTimeoutRef = useRef<number | null>(null)
|
visibleMessageRangeRef.current = range
|
||||||
const handleScroll = useCallback(() => {
|
const total = messages.length
|
||||||
if (!messageListRef.current) return
|
if (total <= 0) {
|
||||||
|
setShowScrollToBottom(prev => (prev ? false : prev))
|
||||||
// 节流:延迟执行,避免滚动时频繁计算
|
return
|
||||||
if (scrollTimeoutRef.current) {
|
|
||||||
cancelAnimationFrame(scrollTimeoutRef.current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTimeoutRef.current = requestAnimationFrame(() => {
|
const remaining = (total - 1) - range.endIndex
|
||||||
if (!messageListRef.current) return
|
const shouldShowScrollButton = remaining > 3
|
||||||
|
setShowScrollToBottom(prev => (prev === shouldShowScrollButton ? prev : shouldShowScrollButton))
|
||||||
|
|
||||||
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
|
if (
|
||||||
|
range.startIndex <= 2 &&
|
||||||
|
!topRangeLoadLockRef.current &&
|
||||||
|
!isLoadingMore &&
|
||||||
|
!isLoadingMessages &&
|
||||||
|
hasMoreMessages &&
|
||||||
|
currentSessionId
|
||||||
|
) {
|
||||||
|
topRangeLoadLockRef.current = true
|
||||||
|
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||||
|
}
|
||||||
|
|
||||||
// 显示回到底部按钮:距离底部超过 300px
|
if (
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
range.endIndex >= total - 3 &&
|
||||||
setShowScrollToBottom(distanceFromBottom > 300)
|
!bottomRangeLoadLockRef.current &&
|
||||||
|
!isLoadingMore &&
|
||||||
|
!isLoadingMessages &&
|
||||||
|
hasMoreLater &&
|
||||||
|
currentSessionId
|
||||||
|
) {
|
||||||
|
bottomRangeLoadLockRef.current = true
|
||||||
|
void loadLaterMessages()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
messages.length,
|
||||||
|
isLoadingMore,
|
||||||
|
isLoadingMessages,
|
||||||
|
hasMoreMessages,
|
||||||
|
hasMoreLater,
|
||||||
|
currentSessionId,
|
||||||
|
currentOffset,
|
||||||
|
jumpStartTime,
|
||||||
|
jumpEndTime,
|
||||||
|
loadMessages,
|
||||||
|
loadLaterMessages
|
||||||
|
])
|
||||||
|
|
||||||
// 预加载:当滚动到顶部 30% 区域时开始加载
|
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
|
||||||
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
if (!atBottom) {
|
||||||
const threshold = clientHeight * 0.3
|
bottomRangeLoadLockRef.current = false
|
||||||
if (scrollTop < threshold) {
|
}
|
||||||
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
}, [])
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预加载更晚的消息
|
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||||
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
|
if (!atTop) {
|
||||||
const threshold = clientHeight * 0.3
|
topRangeLoadLockRef.current = false
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
}
|
||||||
if (distanceFromBottom < threshold) {
|
}, [])
|
||||||
loadLaterMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
|
|
||||||
|
|
||||||
|
|
||||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||||
@@ -3885,13 +4028,22 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const lastIndex = messages.length - 1
|
||||||
|
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
||||||
|
messageVirtuosoRef.current.scrollToIndex({
|
||||||
|
index: lastIndex,
|
||||||
|
align: 'end',
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (messageListRef.current) {
|
if (messageListRef.current) {
|
||||||
messageListRef.current.scrollTo({
|
messageListRef.current.scrollTo({
|
||||||
top: messageListRef.current.scrollHeight,
|
top: messageListRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [messages.length])
|
||||||
|
|
||||||
// 拖动调节侧边栏宽度
|
// 拖动调节侧边栏宽度
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -4021,9 +4173,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isLoadingMessagesRef.current = isLoadingMessages
|
if (!isLoadingMore) {
|
||||||
isLoadingMoreRef.current = isLoadingMore
|
topRangeLoadLockRef.current = false
|
||||||
}, [isLoadingMessages, isLoadingMore])
|
bottomRangeLoadLockRef.current = false
|
||||||
|
}
|
||||||
|
}, [isLoadingMore])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialRevealTimerRef.current !== null) {
|
if (initialRevealTimerRef.current !== null) {
|
||||||
@@ -4195,17 +4349,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [sessions, persistSessionListCache])
|
}, [sessions, persistSessionListCache])
|
||||||
|
|
||||||
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
|
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
|
||||||
useEffect(() => {
|
const filteredSessions = useMemo(() => {
|
||||||
if (!Array.isArray(sessions)) {
|
if (!Array.isArray(sessions)) {
|
||||||
setFilteredSessions([])
|
return []
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有折叠的群聊
|
// 检查是否有折叠的群聊
|
||||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
const hasFoldedGroups = foldedGroups.length > 0
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
let visible = sessions.filter(s => {
|
const visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -4246,37 +4399,34 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(visible)
|
return visible
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const lower = searchKeyword.toLowerCase()
|
const lower = searchKeyword.toLowerCase()
|
||||||
setFilteredSessions(visible
|
return visible
|
||||||
.filter(s => {
|
.filter(s => {
|
||||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||||
return matchedByName || matchedByUsername || matchedByAlias
|
return matchedByName || matchedByUsername || matchedByAlias
|
||||||
})
|
})
|
||||||
.map(s => {
|
.map(s => {
|
||||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||||
|
|
||||||
let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined
|
let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined
|
||||||
|
|
||||||
if (matchedByUsername && !matchedByName && !matchedByAlias) {
|
if (matchedByUsername && !matchedByName && !matchedByAlias) {
|
||||||
matchedField = 'wxid'
|
matchedField = 'wxid'
|
||||||
} else if (matchedByAlias && !matchedByName && !matchedByUsername) {
|
} else if (matchedByAlias && !matchedByName && !matchedByUsername) {
|
||||||
matchedField = 'alias'
|
matchedField = 'alias'
|
||||||
} else if (matchedByName && !matchedByUsername && !matchedByAlias) {
|
} else if (matchedByName && !matchedByUsername && !matchedByAlias) {
|
||||||
matchedField = 'name'
|
matchedField = 'name'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 关键点:返回一个新对象,解耦全局状态
|
return { ...s, matchedField }
|
||||||
return { ...s, matchedField }
|
})
|
||||||
})
|
}, [sessions, searchKeyword])
|
||||||
)
|
|
||||||
}, [sessions, searchKeyword, setFilteredSessions])
|
|
||||||
|
|
||||||
// 折叠群列表(独立计算,供折叠 panel 使用)
|
// 折叠群列表(独立计算,供折叠 panel 使用)
|
||||||
const foldedSessions = useMemo(() => {
|
const foldedSessions = useMemo(() => {
|
||||||
@@ -4314,6 +4464,25 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}, [sessions, searchKeyword, foldedView])
|
}, [sessions, searchKeyword, foldedView])
|
||||||
|
|
||||||
|
const sessionLookupMap = useMemo(() => {
|
||||||
|
const map = new Map<string, ChatSession>()
|
||||||
|
for (const session of sessions) {
|
||||||
|
const username = String(session.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
map.set(username, session)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [sessions])
|
||||||
|
const groupedGlobalMsgResults = useMemo(() => {
|
||||||
|
const grouped = globalMsgResults.reduce((acc, msg) => {
|
||||||
|
const sessionId = (msg as any).sessionId || '未知'
|
||||||
|
if (!acc[sessionId]) acc[sessionId] = []
|
||||||
|
acc[sessionId].push(msg)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Message[]>)
|
||||||
|
return Object.entries(grouped)
|
||||||
|
}, [globalMsgResults])
|
||||||
|
|
||||||
const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0
|
const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0
|
||||||
const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords
|
const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords
|
||||||
const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords
|
const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords
|
||||||
@@ -5080,6 +5249,85 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageVirtuosoComponents = useMemo(() => ({
|
||||||
|
Header: () => (
|
||||||
|
hasMoreMessages ? (
|
||||||
|
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} />
|
||||||
|
<span>加载更多...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>向上滚动加载更多</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
),
|
||||||
|
Footer: () => (
|
||||||
|
hasMoreLater ? (
|
||||||
|
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} />
|
||||||
|
<span>正在加载后续消息...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>向下滚动查看更新消息</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
}), [hasMoreMessages, hasMoreLater, isLoadingMore])
|
||||||
|
|
||||||
|
const renderMessageListItem = useCallback((index: number, msg: Message) => {
|
||||||
|
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||||
|
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||||
|
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
||||||
|
const isSent = msg.isSend === 1
|
||||||
|
const isSystem = isSystemMessage(msg.localType)
|
||||||
|
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
||||||
|
const messageKey = getMessageKey(msg)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
|
||||||
|
{showDateDivider && (
|
||||||
|
<div className="date-divider">
|
||||||
|
<span>{formatDateDivider(msg.createTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MemoMessageBubble
|
||||||
|
message={msg}
|
||||||
|
session={currentSession}
|
||||||
|
showTime={!showDateDivider && showTime}
|
||||||
|
myAvatarUrl={myAvatarUrl}
|
||||||
|
isGroupChat={isCurrentSessionGroup}
|
||||||
|
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
|
||||||
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
isSelectionMode={isSelectionMode}
|
||||||
|
messageKey={messageKey}
|
||||||
|
isSelected={selectedMessages.has(messageKey)}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
messages,
|
||||||
|
highlightedMessageSet,
|
||||||
|
getMessageKey,
|
||||||
|
formatDateDivider,
|
||||||
|
currentSession,
|
||||||
|
myAvatarUrl,
|
||||||
|
isCurrentSessionGroup,
|
||||||
|
autoTranscribeVoiceEnabled,
|
||||||
|
handleRequireModelDownload,
|
||||||
|
handleContextMenu,
|
||||||
|
isSelectionMode,
|
||||||
|
selectedMessages,
|
||||||
|
handleToggleSelection
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-page ${isResizing ? 'resizing' : ''} ${standaloneSessionWindow ? 'standalone session-only' : ''}`}>
|
<div className={`chat-page ${isResizing ? 'resizing' : ''} ${standaloneSessionWindow ? 'standalone session-only' : ''}`}>
|
||||||
{/* 自定义删除确认对话框 */}
|
{/* 自定义删除确认对话框 */}
|
||||||
@@ -5232,17 +5480,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="search-results-list">
|
<div className="search-results-list">
|
||||||
{Object.entries(
|
{groupedGlobalMsgResults.map(([sessionId, messages]) => {
|
||||||
globalMsgResults.reduce((acc, msg) => {
|
const session = sessionLookupMap.get(sessionId)
|
||||||
const sessionId = (msg as any).sessionId || '未知';
|
const firstMsg = messages[0]
|
||||||
if (!acc[sessionId]) acc[sessionId] = [];
|
const count = messages.length
|
||||||
acc[sessionId].push(msg);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Message[]>)
|
|
||||||
).map(([sessionId, messages]) => {
|
|
||||||
const session = sessions.find(s => s.username === sessionId);
|
|
||||||
const firstMsg = messages[0];
|
|
||||||
const count = messages.length;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sessionId}
|
key={sessionId}
|
||||||
@@ -5276,7 +5517,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -5661,77 +5902,27 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
||||||
ref={messageListRef}
|
ref={handleMessageListScrollParentRef}
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
>
|
||||||
{hasMoreMessages && (
|
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
|
||||||
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
|
|
||||||
{isLoadingMore ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={14} />
|
|
||||||
<span>加载更多...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>向上滚动加载更多</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
|
|
||||||
<div className="empty-chat-inline">
|
<div className="empty-chat-inline">
|
||||||
<MessageSquare size={32} />
|
<MessageSquare size={32} />
|
||||||
<span>该联系人没有聊天记录</span>
|
<span>该联系人没有聊天记录</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<Virtuoso
|
||||||
{(messages || []).map((msg, index) => {
|
ref={messageVirtuosoRef}
|
||||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
className="message-virtuoso"
|
||||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
customScrollParent={messageListScrollParent ?? undefined}
|
||||||
|
data={messages}
|
||||||
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
overscan={360}
|
||||||
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
atBottomStateChange={handleMessageAtBottomStateChange}
|
||||||
const isSent = msg.isSend === 1
|
atTopStateChange={handleMessageAtTopStateChange}
|
||||||
const isSystem = isSystemMessage(msg.localType)
|
rangeChanged={handleMessageRangeChanged}
|
||||||
|
computeItemKey={(_, msg) => getMessageKey(msg)}
|
||||||
// 系统消息居中显示
|
components={messageVirtuosoComponents}
|
||||||
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
itemContent={renderMessageListItem}
|
||||||
|
/>
|
||||||
const messageKey = getMessageKey(msg)
|
|
||||||
return (
|
|
||||||
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
|
|
||||||
{showDateDivider && (
|
|
||||||
<div className="date-divider">
|
|
||||||
<span>{formatDateDivider(msg.createTime)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<MessageBubble
|
|
||||||
message={msg}
|
|
||||||
session={currentSession}
|
|
||||||
showTime={!showDateDivider && showTime}
|
|
||||||
myAvatarUrl={myAvatarUrl}
|
|
||||||
isGroupChat={isCurrentSessionGroup}
|
|
||||||
onRequireModelDownload={handleRequireModelDownload}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
isSelectionMode={isSelectionMode}
|
|
||||||
messageKey={messageKey}
|
|
||||||
isSelected={selectedMessages.has(messageKey)}
|
|
||||||
onToggleSelection={handleToggleSelection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{hasMoreLater && (
|
|
||||||
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
|
|
||||||
{isLoadingMore ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={14} />
|
|
||||||
<span>正在加载后续消息...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>向下滚动查看更新消息</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 回到底部按钮 */}
|
{/* 回到底部按钮 */}
|
||||||
@@ -6665,6 +6856,7 @@ function MessageBubble({
|
|||||||
showTime,
|
showTime,
|
||||||
myAvatarUrl,
|
myAvatarUrl,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
|
autoTranscribeVoiceEnabled,
|
||||||
onRequireModelDownload,
|
onRequireModelDownload,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
@@ -6677,6 +6869,7 @@ function MessageBubble({
|
|||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
myAvatarUrl?: string;
|
myAvatarUrl?: string;
|
||||||
isGroupChat?: boolean;
|
isGroupChat?: boolean;
|
||||||
|
autoTranscribeVoiceEnabled?: boolean;
|
||||||
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
@@ -6737,7 +6930,6 @@ function MessageBubble({
|
|||||||
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
|
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
|
||||||
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
|
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
|
||||||
const voiceTranscriptRequestedRef = useRef(false)
|
const voiceTranscriptRequestedRef = useRef(false)
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
|
|
||||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
||||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
@@ -6787,15 +6979,6 @@ function MessageBubble({
|
|||||||
}
|
}
|
||||||
}, [isVideo, message.videoMd5, message.content, message.parsedContent])
|
}, [isVideo, message.videoMd5, message.content, message.parsedContent])
|
||||||
|
|
||||||
// 加载自动转文字配置
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
const enabled = await configService.getAutoTranscribeVoice()
|
|
||||||
setAutoTranscribeVoice(enabled)
|
|
||||||
}
|
|
||||||
loadConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formatTime = (timestamp: number): string => {
|
const formatTime = (timestamp: number): string => {
|
||||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
@@ -7427,25 +7610,14 @@ function MessageBubble({
|
|||||||
void requestVideoInfo()
|
void requestVideoInfo()
|
||||||
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
||||||
|
|
||||||
|
|
||||||
// 根据设置决定是否自动转写
|
|
||||||
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
|
if (!autoTranscribeVoiceEnabled) return
|
||||||
setAutoTranscribeEnabled(value === true)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoTranscribeEnabled) return
|
|
||||||
if (!isVoice) return
|
if (!isVoice) return
|
||||||
if (!voiceDataUrl) return
|
if (!voiceDataUrl) return
|
||||||
if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字
|
|
||||||
if (voiceTranscriptError) return
|
if (voiceTranscriptError) return
|
||||||
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
|
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
|
||||||
void requestVoiceTranscript()
|
void requestVoiceTranscript()
|
||||||
}, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
|
}, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
|
||||||
|
|
||||||
// Selection mode handling removed from here to allow normal rendering
|
// Selection mode handling removed from here to allow normal rendering
|
||||||
// We will wrap the output instead
|
// We will wrap the output instead
|
||||||
@@ -8689,4 +8861,24 @@ function MessageBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||||
|
if (prevProps.message !== nextProps.message) return false
|
||||||
|
if (prevProps.messageKey !== nextProps.messageKey) return false
|
||||||
|
if (prevProps.showTime !== nextProps.showTime) return false
|
||||||
|
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
|
||||||
|
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
|
||||||
|
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
|
||||||
|
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
|
||||||
|
if (prevProps.isSelected !== nextProps.isSelected) return false
|
||||||
|
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
|
||||||
|
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
|
||||||
|
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
prevProps.session.username === nextProps.session.username &&
|
||||||
|
prevProps.session.displayName === nextProps.session.displayName &&
|
||||||
|
prevProps.session.avatarUrl === nextProps.session.avatarUrl
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export default ChatPage
|
export default ChatPage
|
||||||
|
|||||||
Reference in New Issue
Block a user