From 14b41e9d4e9dfb97b897d4a4fff6c37b7cb0a03c Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Tue, 21 Apr 2026 21:35:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E8=83=BD=E5=8A=9B=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BB=8E=20rawContent=20=E6=8F=90=E5=8F=96=E5=A4=87=E7=94=A8?= =?UTF-8?q?=20MD5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:部分视频消息的 videoMd5 字段为空或与实际文件名不匹配, 导致视频显示「不可用」。 变更内容: electron/services/videoService.ts - getVideoInfo 新增 rawContent 参数,从消息原始 XML 中提取 newmd5 / rawmd5 等备用 MD5 字段作为候选 - 返回 diagnostics 诊断信息(candidateMd5s、matchedMd5、reason、summary 等), 便于前端展示失败原因和调试 electron/services/chatService.ts - extractVideoMd5 新增对 newmd5 / rawmd5 XML 字段的提取支持 electron/services/exportService.ts / httpApiFacade.ts / httpApiService.ts - getVideoInfo 调用处透传 rawContent 参数 electron/services/mcp/readService.ts - getVideoLocalPath 支持 rawContent 为空时 videoMd5 也为空的情况 electron/services/imageDecryptService.ts - 新增 hdNotFoundCache,避免高清图重复查询 electron/main.ts / electron/preload.ts / src/types/electron.d.ts - IPC 接口同步更新,透传 rawContent 和 diagnostics src/pages/ChatPage.tsx - 视频缓存 key 改为 videoMd5 || local:localId,兼容无 MD5 的消息 - 视频不可用时展示 diagnostics.summary 诊断文案 - 详情面板新增关闭动画(closing 状态 + 220ms 延迟) - 视频播放按钮图标调整 src/pages/ChatPage.scss - 详情面板改为绝对定位浮层,新增 slideOutRight 关闭动画 - 工具栏按钮改为圆形,悬停加 scale 效果 - 视频不可用区域新增 .video-reason 样式 src/pages/VideoWindow.tsx - 播放图标尺寸调整 --- electron/main.ts | 17 +- electron/preload.ts | 2 +- electron/services/chatService.ts | 4 + electron/services/exportService.ts | 2 +- electron/services/httpApiFacade.ts | 2 +- electron/services/httpApiService.ts | 2 +- electron/services/imageDecryptService.ts | 5 + electron/services/mcp/readService.ts | 4 +- electron/services/videoService.ts | 390 +++++++++++++++++++---- src/pages/ChatPage.scss | 50 ++- src/pages/ChatPage.tsx | 127 ++++++-- src/pages/VideoWindow.tsx | 4 +- src/types/electron.d.ts | 14 +- 13 files changed, 517 insertions(+), 106 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c3d4cbd..974c36b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2278,11 +2278,24 @@ function registerIpcHandlers() { }) // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, rawContent?: string) => { try { - const result = videoService.getVideoInfo(videoMd5) + console.log('[VideoIPC] getVideoInfo request', { + videoMd5, + hasRawContent: Boolean(rawContent) + }) + const result = videoService.getVideoInfo(videoMd5, rawContent) + console.log('[VideoIPC] getVideoInfo response', { + videoMd5, + exists: result.exists, + diagnostics: result.diagnostics + }) return { success: true, ...result } } catch (e) { + console.error('[VideoIPC] getVideoInfo error', { + videoMd5, + error: String(e) + }) return { success: false, error: String(e), exists: false } } }) diff --git a/electron/preload.ts b/electron/preload.ts index 47c70cc..6377aba 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -286,7 +286,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // 视频 video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + getVideoInfo: (videoMd5: string, rawContent?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, rawContent), readFile: (videoPath: string) => ipcRenderer.invoke('video:readFile', videoPath), parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content), parseChannelVideo: (content: string) => ipcRenderer.invoke('video:parseChannelVideo', content), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 575fb54..29a9e27 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2682,6 +2682,10 @@ class ChatService extends EventEmitter { const md5 = this.extractXmlValue(content, 'md5') || this.extractXmlAttribute(content, 'videomsg', 'md5') || + this.extractXmlValue(content, 'newmd5') || + this.extractXmlAttribute(content, 'videomsg', 'newmd5') || + this.extractXmlValue(content, 'rawmd5') || + this.extractXmlAttribute(content, 'videomsg', 'rawmd5') || undefined return md5?.toLowerCase() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 460fcad..a29667b 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2394,7 +2394,7 @@ class ExportService { try { const videoMd5 = videoService.parseVideoMd5(content) if (videoMd5) { - const videoInfo = videoService.getVideoInfo(videoMd5) + const videoInfo = videoService.getVideoInfo(videoMd5, content) if (videoInfo.exists && videoInfo.videoUrl) { const videoPath = videoInfo.videoUrl.replace(/^file:\/\/\//i, '').replace(/\//g, path.sep) if (fs.existsSync(videoPath)) { diff --git a/electron/services/httpApiFacade.ts b/electron/services/httpApiFacade.ts index eadcf7e..712992b 100644 --- a/electron/services/httpApiFacade.ts +++ b/electron/services/httpApiFacade.ts @@ -667,7 +667,7 @@ export async function queryMessages(input: QueryMessagesInput) { if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) { try { - const videoInfo = videoService.getVideoInfo(String(base.videoMd5)) + const videoInfo = videoService.getVideoInfo(String(base.videoMd5), String(base.rawContent || '')) if (videoInfo.exists && videoInfo.videoUrl) { media.videoCachePath = fileUrlToPathMaybe(videoInfo.videoUrl) } diff --git a/electron/services/httpApiService.ts b/electron/services/httpApiService.ts index 8d072e0..7eddc08 100644 --- a/electron/services/httpApiService.ts +++ b/electron/services/httpApiService.ts @@ -923,7 +923,7 @@ class HttpApiService { if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) { try { - const videoInfo = videoService.getVideoInfo(String(base.videoMd5)) + const videoInfo = videoService.getVideoInfo(String(base.videoMd5), String(base.rawContent || '')) if (videoInfo.exists && videoInfo.videoUrl) { media.videoCachePath = this.fileUrlToPathMaybe(videoInfo.videoUrl) } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 651ea6c..1ce83d4 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -67,6 +67,7 @@ export class ImageDecryptService { private cacheIndexing: Promise | null = null private updateFlags = new Map() private notFoundCache = new Set() // 失败缓存,避免重复查询 + private hdNotFoundCache = new Set() // 高清图失败缓存 private nativeLogged = false async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { @@ -138,6 +139,9 @@ export class ImageDecryptService { // 即使 force=true,也先检查是否有高清图缓存 if (payload.force) { + if (this.hdNotFoundCache.has(cacheKey)) { + return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } + } // 快速查找高清图缓存 const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) || this.findCachedOutput(cacheKey, payload.sessionId, true) @@ -220,6 +224,7 @@ export class ImageDecryptService { // 如果要求高清图但没找到,直接返回提示 if (!datPath && payload.force) { + this.hdNotFoundCache.add(cacheKey) console.warn(`[ImageDecrypt] 未找到高清图: ${payload.imageDatName || payload.imageMd5}`) this.logDecryptTiming({ cacheKey, diff --git a/electron/services/mcp/readService.ts b/electron/services/mcp/readService.ts index 45ac3dc..12bd020 100644 --- a/electron/services/mcp/readService.ts +++ b/electron/services/mcp/readService.ts @@ -1047,10 +1047,10 @@ async function getImageLocalPath(sessionId: string, message: Message): Promise = {}): void { + console.log(`[VideoLookup] ${stage}`, payload) + } + + private warnVideoLookup(stage: string, payload: Record = {}): void { + console.warn(`[VideoLookup] ${stage}`, payload) + } + + private previewRawContent(content?: string): string | undefined { + if (!content) return undefined + const normalized = content.replace(/\s+/g, ' ').trim() + if (!normalized) return undefined + return normalized.slice(0, 220) + } + /** * 获取数据库根目录 */ @@ -95,10 +124,147 @@ class VideoService { return trimmed } + private normalizeMd5(value?: string | null): string | undefined { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized || !/^[a-f0-9]{8,}$/.test(normalized)) return undefined + return normalized + } + + private addMd5Candidate(candidates: string[], value?: string | null): void { + const normalized = this.normalizeMd5(value) + if (!normalized || candidates.includes(normalized)) return + candidates.push(normalized) + } + + private extractVideoMsgAttribute(content: string, attrName: string): string | undefined { + const match = new RegExp(`]*\\s${attrName}\\s*=\\s*['"]([a-fA-F0-9]+)['"]`, 'i').exec(content) + return this.normalizeMd5(match?.[1]) + } + + private extractVideoXmlValue(content: string, tagName: string): string | undefined { + const match = new RegExp(`<${tagName}>\\s*([a-fA-F0-9]+)\\s*<\\/${tagName}>`, 'i').exec(content) + return this.normalizeMd5(match?.[1]) + } + + private collectVideoMd5Candidates(content?: string, preferredMd5?: string): string[] { + const candidates: string[] = [] + + this.addMd5Candidate(candidates, preferredMd5) + if (!content) return candidates + + this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'newmd5')) + this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'md5')) + this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'rawmd5')) + this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'newmd5')) + this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'md5')) + this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'rawmd5')) + + return candidates + } + + private formatMd5CandidateSummary(values: string[]): string { + return values + .filter(Boolean) + .slice(0, 3) + .map(value => value.slice(0, 8)) + .join(' / ') + } + + private buildLookupSummary( + reason: VideoLookupDiagnostics['reason'], + diagnostics: Pick + ): string { + switch (reason) { + case 'missing_input': + return '缺少视频 MD5' + case 'missing_config': + return '缺少数据库路径或微信账号' + case 'account_dir_not_found': + return '未找到账号目录' + case 'video_dir_missing': + return '未找到 msg/video 目录' + case 'local_file_missing': { + if (diagnostics.hardlinkMatchedMd5) { + const resolved = this.formatMd5CandidateSummary(diagnostics.searchedFileKeys || []) + return resolved ? `hardlink 已命中,但本地文件缺失 ${resolved}` : 'hardlink 已命中,但本地文件缺失' + } + + const summary = this.formatMd5CandidateSummary(diagnostics.candidateMd5s || []) + return summary ? `未命中本地缓存,候选 ${summary}` : '未命中本地缓存' + } + default: + return '视频不可用' + } + } + + private isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } + } + + private isAccountDir(path: string): boolean { + return ( + existsSync(join(path, 'msg')) || + existsSync(join(path, 'db_storage')) || + existsSync(join(path, 'hardlink.db')) + ) + } + + private resolveAccountDir(dbPath: string, wxid: string): string | null { + const normalized = dbPath.replace(/[\\/]+$/, '') + const cleanedWxid = this.cleanWxid(wxid) + + const directCandidates = new Set([ + normalized, + join(normalized, wxid) + ]) + + if (cleanedWxid !== wxid) { + directCandidates.add(join(normalized, cleanedWxid)) + } + + for (const candidate of directCandidates) { + if (this.isAccountDir(candidate)) return candidate + } + + if (!this.isDirectory(normalized)) return null + + try { + const entries = readdirSync(normalized) + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + + const lowerEntry = entry.toLowerCase() + const cleanedEntry = this.cleanWxid(entry).toLowerCase() + if ( + lowerEntry === wxidLower || + lowerEntry === cleanedWxidLower || + lowerEntry.startsWith(`${wxidLower}_`) || + lowerEntry.startsWith(`${cleanedWxidLower}_`) || + cleanedEntry === wxidLower || + cleanedEntry === cleanedWxidLower + ) { + if (this.isAccountDir(entryPath)) return entryPath + } + } + } catch { + // ignore + } + + return null + } + /** * �?video_hardlink_info_v4 表查询视频文件名 */ - private queryVideoFileName(md5: string): string | undefined { + private resolveHardlinkDbPath(): string | undefined { const cachePath = this.getCachePath() const wxid = this.getMyWxid() const cleanedWxid = this.cleanWxid(wxid) @@ -135,29 +301,62 @@ class VideoService { } } - if (!hardlinkDbPath) return undefined + return hardlinkDbPath + } - try { - const db = new Database(hardlinkDbPath, { readonly: true }) - - // 查询视频文件�? - const row = db.prepare(` - SELECT file_name, md5 FROM video_hardlink_info_v4 - WHERE md5 = ? - LIMIT 1 - `).get(md5) as { file_name: string; md5: string } | undefined - - db.close() - - if (row?.file_name) { - // 提取不带扩展名的文件名作�?MD5 - return row.file_name.replace(/\.[^.]+$/, '') - } - } catch { - // 忽略错误 + private queryVideoFileNames(md5Candidates: string[]): { + fileKeys: string[] + hardlinkDbPath?: string + hardlinkMatchedMd5?: string + } { + const hardlinkDbPath = this.resolveHardlinkDbPath() + if (!hardlinkDbPath || md5Candidates.length === 0) { + return { fileKeys: [], hardlinkDbPath } } - return undefined + let db: Database.Database | null = null + + try { + db = new Database(hardlinkDbPath, { readonly: true }) + const stmt = db.prepare(` + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? + LIMIT 1 + `) + + const fileKeys: string[] = [] + let hardlinkMatchedMd5: string | undefined + + for (const md5 of md5Candidates) { + const row = stmt.get(md5) as { file_name?: string; md5?: string } | undefined + const normalizedFileKey = this.normalizeMd5(row?.file_name?.replace(/\.[^.]+$/, '')) + if (!normalizedFileKey) continue + + if (!hardlinkMatchedMd5) { + hardlinkMatchedMd5 = this.normalizeMd5(row?.md5) || md5 + } + + this.addMd5Candidate(fileKeys, normalizedFileKey) + } + + this.logVideoLookup('hardlink-query', { + hardlinkDbPath, + md5Candidates, + fileKeys, + hardlinkMatchedMd5 + }) + + return { fileKeys, hardlinkDbPath, hardlinkMatchedMd5 } + } catch (error) { + this.warnVideoLookup('hardlink-query-error', { + hardlinkDbPath, + md5Candidates, + error: String(error) + }) + return { fileKeys: [], hardlinkDbPath } + } finally { + db?.close() + } } /** @@ -178,21 +377,83 @@ class VideoService { * 视频存放�? {数据库根目录}/{用户wxid}/msg/video/{年月}/ * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg */ - getVideoInfo(videoMd5: string): VideoInfo { + getVideoInfo(videoMd5: string, rawContent?: string): VideoInfo { const dbPath = this.getDbPath() const wxid = this.getMyWxid() - - if (!dbPath || !wxid || !videoMd5) { - return { exists: false } + const requestedMd5 = this.normalizeMd5(videoMd5) + const candidateMd5s = this.collectVideoMd5Candidates(rawContent, requestedMd5) + const diagnostics: VideoLookupDiagnostics = { + requestedMd5, + candidateMd5s } - // 先尝试从数据库查询真正的视频文件�? - const realVideoMd5 = this.queryVideoFileName(videoMd5) || videoMd5 + this.logVideoLookup('request', { + requestedMd5, + candidateMd5s, + hasRawContent: Boolean(rawContent), + rawPreview: this.previewRawContent(rawContent), + dbPath, + wxid + }) - const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + if (candidateMd5s.length === 0) { + diagnostics.reason = 'missing_input' + diagnostics.summary = this.buildLookupSummary('missing_input', diagnostics) + this.warnVideoLookup('missing-input', diagnostics) + return { exists: false, diagnostics } + } + + if (!dbPath || !wxid) { + diagnostics.reason = 'missing_config' + diagnostics.summary = this.buildLookupSummary('missing_config', diagnostics) + this.warnVideoLookup('missing-config', diagnostics) + return { exists: false, diagnostics } + } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + diagnostics.accountDir = accountDir || undefined + + this.logVideoLookup('account-dir', { + requestedMd5, + accountDir, + dbPath, + wxid + }) + + if (!accountDir) { + diagnostics.reason = 'account_dir_not_found' + diagnostics.summary = this.buildLookupSummary('account_dir_not_found', diagnostics) + this.warnVideoLookup('account-dir-not-found', diagnostics) + return { exists: false, diagnostics } + } + + const videoBaseDir = join(accountDir, 'msg', 'video') + diagnostics.videoBaseDir = videoBaseDir + + const hardlinkResult = this.queryVideoFileNames(candidateMd5s) + diagnostics.hardlinkDbPath = hardlinkResult.hardlinkDbPath + diagnostics.hardlinkMatchedMd5 = hardlinkResult.hardlinkMatchedMd5 + + const fileKeys = [...candidateMd5s] + for (const fileKey of hardlinkResult.fileKeys) { + this.addMd5Candidate(fileKeys, fileKey) + } + diagnostics.searchedFileKeys = fileKeys + + this.logVideoLookup('search-plan', { + requestedMd5, + candidateMd5s, + hardlinkDbPath: diagnostics.hardlinkDbPath, + hardlinkMatchedMd5: diagnostics.hardlinkMatchedMd5, + searchedFileKeys: diagnostics.searchedFileKeys, + videoBaseDir + }) if (!existsSync(videoBaseDir)) { - return { exists: false } + diagnostics.reason = 'video_dir_missing' + diagnostics.summary = this.buildLookupSummary('video_dir_missing', diagnostics) + this.warnVideoLookup('video-dir-missing', diagnostics) + return { exists: false, diagnostics } } // 遍历年月目录查找视频文件 @@ -210,25 +471,45 @@ class VideoService { for (const yearMonth of yearMonthDirs) { const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - const coverPath = join(dirPath, `${realVideoMd5}.jpg`) - const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + for (const fileKey of fileKeys) { + const videoPath = join(dirPath, `${fileKey}.mp4`) + const coverPath = join(dirPath, `${fileKey}.jpg`) + const thumbPath = join(dirPath, `${fileKey}_thumb.jpg`) - // 检查视频文件是否存�? - if (existsSync(videoPath)) { - return { - videoUrl: `file:///${videoPath.replace(/\\/g, '/')}`, // 转换为 file:// 协议 - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true + // 检查视频文件是否存�? + if (existsSync(videoPath)) { + diagnostics.matchedMd5 = fileKey + this.logVideoLookup('local-file-hit', { + requestedMd5, + matchedMd5: fileKey, + yearMonth, + videoPath, + coverExists: existsSync(coverPath), + thumbExists: existsSync(thumbPath) + }) + return { + videoUrl: `file:///${videoPath.replace(/\\/g, '/')}`, // 转换为 file:// 协议 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true, + diagnostics + } } } } - } catch { - // 忽略错误 + } catch (error) { + this.warnVideoLookup('scan-error', { + requestedMd5, + videoBaseDir, + searchedFileKeys: diagnostics.searchedFileKeys, + error: String(error) + }) } - return { exists: false } + diagnostics.reason = 'local_file_missing' + diagnostics.summary = this.buildLookupSummary('local_file_missing', diagnostics) + this.warnVideoLookup('local-file-missing', diagnostics) + return { exists: false, diagnostics } } /** @@ -238,23 +519,14 @@ class VideoService { if (!content) return undefined try { - // 尝试从XML中提取md5 - // 格式可能�? xxx �?md5="xxx" - const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5Match) { - return md5Match[1].toLowerCase() - } - - const attrMatch = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (attrMatch) { - return attrMatch[1].toLowerCase() - } - - // 尝试从videomsg标签中提�? - const videoMsgMatch = /]*md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMatch) { - return videoMsgMatch[1].toLowerCase() - } + return ( + this.extractVideoXmlValue(content, 'md5') || + this.extractVideoMsgAttribute(content, 'md5') || + this.extractVideoXmlValue(content, 'newmd5') || + this.extractVideoMsgAttribute(content, 'newmd5') || + this.extractVideoXmlValue(content, 'rawmd5') || + this.extractVideoMsgAttribute(content, 'rawmd5') + ) } catch (e) { console.error('解析视频MD5失败:', e) } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index c54464f..ef7a7aa 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -311,17 +311,20 @@ height: 32px; border: none; background: var(--bg-tertiary); - border-radius: 8px; + border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); transition: all 0.2s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); &:hover { background: var(--bg-hover); color: var(--text-primary); + transform: scale(1.1); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); } &.active { @@ -351,6 +354,7 @@ flex: 1; display: flex; overflow: hidden; + position: relative; } .message-list { @@ -2741,14 +2745,23 @@ // 会话详情面板 .detail-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; width: 280px; - min-width: 280px; background: var(--card-bg); border-left: 1px solid var(--border-color); display: flex; flex-direction: column; overflow: hidden; - animation: slideInRight 0.2s ease; + animation: slideInRight 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94); + z-index: 10; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15), -2px 0 8px rgba(0, 0, 0, 0.08); + + &.closing { + animation: slideOutRight 0.22s cubic-bezier(0.55, 0.06, 0.68, 0.19) forwards; + } .detail-header { display: flex; @@ -2942,7 +2955,7 @@ @keyframes slideInRight { from { opacity: 0; - transform: translateX(20px); + transform: translateX(40px); } to { @@ -2951,6 +2964,18 @@ } } +@keyframes slideOutRight { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(40px); + } +} + // 消息中的链接样式 .message-link { @@ -3051,6 +3076,13 @@ opacity: 0.8; } + .video-reason { + max-width: 176px; + line-height: 1.35; + text-align: center; + word-break: break-all; + } + .video-action { font-size: 11px; opacity: 0.6; @@ -3068,7 +3100,6 @@ &:hover { .video-play-button { - background: rgba(0, 0, 0, 0.7); transform: translate(-50%, -50%) scale(1.1); } } @@ -3103,18 +3134,11 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 56px; - height: 56px; - background: rgba(0, 0, 0, 0.5); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; transition: all 0.2s ease; svg { color: white; - margin-left: 4px; // 视觉居中调整 + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5)); } } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 29bf431..cb92bb0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { createPortal } from 'react-dom' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, PlayCircle, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react' import { Qwen } from '@lobehub/icons' import { useChatStore } from '../stores/chatStore' import { useUpdateStatusStore } from '../stores/updateStatusStore' @@ -299,6 +299,7 @@ function ChatPage(_props: ChatPageProps) { const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false) + const [isDetailClosing, setIsDetailClosing] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [hasImageKey, setHasImageKey] = useState(null) @@ -527,12 +528,22 @@ function ChatPage(_props: ChatPageProps) { }, []) // 切换详情面板 + const closeDetailPanel = useCallback(() => { + setIsDetailClosing(true) + setTimeout(() => { + setShowDetailPanel(false) + setIsDetailClosing(false) + }, 220) + }, []) + const toggleDetailPanel = useCallback(() => { - if (!showDetailPanel && currentSessionId) { - loadSessionDetail(currentSessionId) + if (showDetailPanel) { + closeDetailPanel() + } else { + if (currentSessionId) loadSessionDetail(currentSessionId) + setShowDetailPanel(true) } - setShowDetailPanel(!showDetailPanel) - }, [showDetailPanel, currentSessionId, loadSessionDetail]) + }, [showDetailPanel, currentSessionId, loadSessionDetail, closeDetailPanel]) // 连接数据库 const connect = useCallback(async () => { @@ -2136,10 +2147,10 @@ function ChatPage(_props: ChatPageProps) { {/* 会话详情面板 */} {showDetailPanel && ( -
+

会话详情

-
@@ -2791,13 +2802,29 @@ function enqueueDecrypt(fn: () => Promise) { } // 视频信息缓存(带时间戳) -const videoInfoCache = new Map() + diagnostics?: VideoLookupDiagnostics +} + +const videoInfoCache = new Map() // 最后一次增量更新时间戳 let lastIncrementalUpdateTime = 0 @@ -2938,9 +2965,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h const imageContainerRef = useRef(null) // 视频相关状态 - const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null) + const [videoInfo, setVideoInfo] = useState(null) const [videoLoading, setVideoLoading] = useState(false) const videoContainerRef = useRef(null) + const videoCacheKey = message.videoMd5 || `local:${message.localId}` // 从缓存获取表情包 data URL const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' @@ -3136,48 +3164,93 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h // 加载视频信息 useEffect(() => { if (!isVideo || !isVisible || videoInfo || videoLoading) return - if (!message.videoMd5) return + if (!message.videoMd5 && !message.rawContent) return // 先检查缓存 - const cached = videoInfoCache.get(message.videoMd5) + const cached = videoInfoCache.get(videoCacheKey) if (cached) { // 智能缓存失效:如果视频不存在,且缓存时间早于最后一次增量更新,则重新获取 const shouldRefetch = !cached.exists && cached.cachedAt < lastIncrementalUpdateTime + console.log('[Video][Renderer] cache-check', { + localId: message.localId, + sessionId: session.username, + videoCacheKey, + videoMd5: message.videoMd5, + hasCached: true, + cachedExists: cached.exists, + shouldRefetch, + diagnostics: cached.diagnostics + }) + if (!shouldRefetch) { setVideoInfo(cached) return } // 需要重新获取,清除旧缓存 - videoInfoCache.delete(message.videoMd5) + videoInfoCache.delete(videoCacheKey) } setVideoLoading(true) - window.electronAPI.video.getVideoInfo(message.videoMd5).then((result) => { + console.log('[Video][Renderer] request-start', { + localId: message.localId, + sessionId: session.username, + videoCacheKey, + videoMd5: message.videoMd5, + rawPreview: String(message.rawContent || '').replace(/\s+/g, ' ').slice(0, 220) + }) + window.electronAPI.video.getVideoInfo(message.videoMd5 || '', message.rawContent).then((result) => { if (result && result.success) { const info = { exists: result.exists, videoUrl: result.videoUrl, coverUrl: result.coverUrl, thumbUrl: result.thumbUrl, + diagnostics: result.diagnostics, cachedAt: Date.now() // 记录缓存时间 } - videoInfoCache.set(message.videoMd5!, info) + videoInfoCache.set(videoCacheKey, info) setVideoInfo(info) + console.log('[Video][Renderer] request-success', { + localId: message.localId, + sessionId: session.username, + videoCacheKey, + exists: result.exists, + videoUrl: result.videoUrl, + diagnostics: result.diagnostics + }) + if (!result.exists && result.diagnostics) { + console.warn('[Video] 视频定位失败:', { + localId: message.localId, + diagnostics: result.diagnostics + }) + } } else { const info = { exists: false, cachedAt: Date.now() } - videoInfoCache.set(message.videoMd5!, info) + videoInfoCache.set(videoCacheKey, info) setVideoInfo(info) + console.warn('[Video][Renderer] request-unsuccessful', { + localId: message.localId, + sessionId: session.username, + videoCacheKey, + result + }) } - }).catch(() => { + }).catch((error) => { const info = { exists: false, cachedAt: Date.now() } - videoInfoCache.set(message.videoMd5!, info) + videoInfoCache.set(videoCacheKey, info) setVideoInfo(info) + console.error('[Video][Renderer] request-error', { + localId: message.localId, + sessionId: session.username, + videoCacheKey, + error: String(error) + }) }).finally(() => { setVideoLoading(false) }) - }, [isVideo, isVisible, videoInfo, videoLoading, message.videoMd5]) + }, [isVideo, isVisible, videoInfo, videoLoading, message.videoMd5, message.rawContent, message.localId, videoCacheKey]) // 播放视频 - 打开独立窗口 const handlePlayVideo = useCallback(async () => { @@ -3996,11 +4069,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h ) @@ -4025,7 +4106,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
)}
- +
{message.videoDuration && message.videoDuration > 0 && ( diff --git a/src/pages/VideoWindow.tsx b/src/pages/VideoWindow.tsx index b6d7a4f..83abb99 100644 --- a/src/pages/VideoWindow.tsx +++ b/src/pages/VideoWindow.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react' +import { Play, Pause, PlayCircle, Volume2, VolumeX, RotateCcw } from 'lucide-react' import './VideoWindow.scss' export default function VideoWindow() { @@ -147,7 +147,7 @@ export default function VideoWindow() { /> {!isPlaying && !isLoading && !error && (
- +
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 45aae45..c439b86 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -409,13 +409,25 @@ export interface ElectronAPI { countThumbnails: () => Promise<{ success: boolean; count: number; error?: string }> } video: { - getVideoInfo: (videoMd5: string) => Promise<{ + getVideoInfo: (videoMd5: string, rawContent?: string) => Promise<{ success: boolean error?: string exists: boolean videoUrl?: string coverUrl?: string thumbUrl?: string + diagnostics?: { + requestedMd5?: string + candidateMd5s?: string[] + searchedFileKeys?: string[] + matchedMd5?: string + hardlinkMatchedMd5?: string + hardlinkDbPath?: string + accountDir?: string + videoBaseDir?: string + reason?: 'missing_input' | 'missing_config' | 'account_dir_not_found' | 'video_dir_missing' | 'local_file_missing' + summary?: string + } }> readFile: (videoPath: string) => Promise<{ success: boolean