fix: 增强视频定位能力,支持从 rawContent 提取备用 MD5

问题:部分视频消息的 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
- 播放图标尺寸调整
This commit is contained in:
ILoveBingLu
2026-04-21 21:35:22 +08:00
parent 46a284cba9
commit 14b41e9d4e
13 changed files with 517 additions and 106 deletions
+15 -2
View File
@@ -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 }
}
})
+1 -1
View File
@@ -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),
+4
View File
@@ -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()
+1 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+5
View File
@@ -67,6 +67,7 @@ export class ImageDecryptService {
private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>()
private notFoundCache = new Set<string>() // 失败缓存,避免重复查询
private hdNotFoundCache = new Set<string>() // 高清图失败缓存
private nativeLogged = false
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
@@ -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,
+2 -2
View File
@@ -1047,10 +1047,10 @@ async function getImageLocalPath(sessionId: string, message: Message): Promise<s
}
function getVideoLocalPath(message: Message): string | null {
if (!message.videoMd5) return null
if (!message.videoMd5 && !message.rawContent) return null
try {
const info = videoService.getVideoInfo(String(message.videoMd5))
const info = videoService.getVideoInfo(String(message.videoMd5 || ''), String(message.rawContent || ''))
return info.exists ? info.videoUrl || null : null
} catch {
return null
+331 -59
View File
@@ -14,6 +14,20 @@ export interface VideoInfo {
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略?data URL
exists: boolean
diagnostics?: VideoLookupDiagnostics
}
export interface VideoLookupDiagnostics {
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
}
export interface ChannelVideoInfo {
@@ -50,6 +64,21 @@ class VideoService {
this.configService = new ConfigService()
}
private logVideoLookup(stage: string, payload: Record<string, unknown> = {}): void {
console.log(`[VideoLookup] ${stage}`, payload)
}
private warnVideoLookup(stage: string, payload: Record<string, unknown> = {}): 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(`<videomsg[^>]*\\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<VideoLookupDiagnostics, 'candidateMd5s' | 'searchedFileKeys' | 'hardlinkMatchedMd5'>
): 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<string>([
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
// 格式可能? <md5>xxx</md5> ?md5="xxx"
const md5Match = /<md5>([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 = /<videomsg[^>]*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)
}
+37 -13
View File
@@ -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));
}
}
+104 -23
View File
@@ -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<SessionDetail | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [hasImageKey, setHasImageKey] = useState<boolean | null>(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 && (
<div className="detail-panel">
<div className={`detail-panel${isDetailClosing ? ' closing' : ''}`}>
<div className="detail-header">
<h4></h4>
<button className="close-btn" onClick={() => setShowDetailPanel(false)}>
<button className="close-btn" onClick={closeDetailPanel}>
<X size={16} />
</button>
</div>
@@ -2791,13 +2802,29 @@ function enqueueDecrypt(fn: () => Promise<void>) {
}
// 视频信息缓存(带时间戳)
const videoInfoCache = new Map<string, {
type VideoLookupDiagnostics = {
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
}
type CachedVideoInfo = {
videoUrl?: string
coverUrl?: string
thumbUrl?: string
exists: boolean
cachedAt: number // 缓存时间戳
}>()
diagnostics?: VideoLookupDiagnostics
}
const videoInfoCache = new Map<string, CachedVideoInfo>()
// 最后一次增量更新时间戳
let lastIncrementalUpdateTime = 0
@@ -2938,9 +2965,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
const imageContainerRef = useRef<HTMLDivElement>(null)
// 视频相关状态
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
const [videoInfo, setVideoInfo] = useState<CachedVideoInfo | null>(null)
const [videoLoading, setVideoLoading] = useState(false)
const videoContainerRef = useRef<HTMLDivElement>(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
<button
className="video-unavailable"
ref={videoContainerRef as unknown as React.RefObject<HTMLButtonElement>}
title={videoInfo?.diagnostics?.summary || '点击重试'}
onClick={() => {
// 清除缓存并重新加载
if (message.videoMd5) {
videoInfoCache.delete(message.videoMd5)
}
console.log('[Video][Renderer] retry-click', {
localId: message.localId,
sessionId: session.username,
videoCacheKey,
diagnostics: videoInfo?.diagnostics
})
videoInfoCache.delete(videoCacheKey)
setVideoInfo(null)
setVideoLoading(false)
}}
@@ -4008,6 +4086,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
>
<Video size={24} />
<span></span>
{videoInfo?.diagnostics?.summary && (
<span className="video-reason">{videoInfo.diagnostics.summary}</span>
)}
<span className="video-action"></span>
</button>
)
@@ -4025,7 +4106,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
</div>
)}
<div className="video-play-button">
<Play size={32} fill="white" />
<Play size={36} fill="currentColor" />
</div>
{message.videoDuration && message.videoDuration > 0 && (
<span className="video-duration-tag">
+2 -2
View File
@@ -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 && (
<div className="play-overlay">
<Play size={64} fill="white" />
<Play size={48} fill="currentColor" />
</div>
)}
+13 -1
View File
@@ -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