图片解密重构 #527 #522 #696;修复 #752

This commit is contained in:
cc
2026-04-14 23:02:06 +08:00
parent 9af1a0ad56
commit 419a53d6ec
27 changed files with 1161 additions and 1247 deletions

View File

@@ -154,6 +154,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
return value !== undefined && value !== null && String(value).length > 0
}
function toRenderableImageSrc(path?: string): string | undefined {
const raw = String(path || '').trim()
if (!raw) return undefined
if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw
const normalized = raw.replace(/\\/g, '/')
if (/^[a-zA-Z]:\//.test(normalized)) {
return encodeURI(`file:///${normalized}`)
}
if (normalized.startsWith('/')) {
return encodeURI(`file://${normalized}`)
}
return raw
}
function getChatRecordPreviewText(item: ChatRecordItem): string {
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
if (item.datatype === 17) {
@@ -4853,7 +4868,7 @@ function ChatPage(props: ChatPageProps) {
const candidates = [...head, ...tail]
const queued = preloadImageKeysRef.current
const seen = new Set<string>()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const msg of candidates) {
if (payloads.length >= maxPreload) break
if (msg.localType !== 3) continue
@@ -4867,11 +4882,14 @@ function ChatPage(props: ChatPageProps) {
payloads.push({
sessionId: currentSessionId,
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName
imageDatName: msg.imageDatName,
createTime: msg.createTime
})
}
if (payloads.length > 0) {
window.electronAPI.image.preload(payloads).catch(() => { })
window.electronAPI.image.preload(payloads, {
allowCacheIndex: false
}).catch(() => { })
}
}, [currentSessionId, messages])
@@ -5840,7 +5858,10 @@ function ChatPage(props: ChatPageProps) {
sessionId: session.username,
imageMd5: img.imageMd5,
imageDatName: img.imageDatName,
force: true
createTime: img.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true
})
if (r?.success) successCount++
else failCount++
@@ -7882,7 +7903,7 @@ function MessageBubble({
)
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
() => imageDataUrlCache.get(imageCacheKey)
() => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
)
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
const voiceCacheKey = `voice:${voiceIdentityKey}`
@@ -7904,6 +7925,7 @@ function MessageBubble({
const imageUpdateCheckedRef = useRef<string | null>(null)
const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const imageElementRef = useRef<HTMLImageElement | null>(null)
const emojiContainerRef = useRef<HTMLDivElement>(null)
const imageResizeBaselineRef = useRef<number | null>(null)
const emojiResizeBaselineRef = useRef<number | null>(null)
@@ -8260,19 +8282,27 @@ function MessageBubble({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName,
force: forceUpdate
createTime: message.createTime,
force: forceUpdate,
preferFilePath: true,
hardlinkOnly: true
}) as SharedImageDecryptResult
})
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
if (imageLocalPath !== result.localPath) {
const renderPath = toRenderableImageSrc(result.localPath)
if (!renderPath) {
if (!silent) setImageError(true)
return { success: false }
}
imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath(result.localPath)
setImageLocalPath(renderPath)
setImageHasUpdate(false)
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return result
return { ...result, localPath: renderPath }
}
}
@@ -8297,7 +8327,7 @@ function MessageBubble({
imageDecryptPendingRef.current = false
}
return { success: false }
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return
@@ -8352,24 +8382,29 @@ function MessageBubble({
const resolved = await window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true
})
if (resolved?.success && resolved.localPath) {
finalImagePath = resolved.localPath
const renderPath = toRenderableImageSrc(resolved.localPath)
if (!renderPath) return
finalImagePath = renderPath
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
if (imageLocalPath !== resolved.localPath) {
imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath(resolved.localPath)
setImageLocalPath(renderPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
setImageHasUpdate(Boolean(resolved.hasUpdate))
}
} catch { }
}
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
}, [
imageLiveVideoPath,
imageLocalPath,
@@ -8378,6 +8413,7 @@ function MessageBubble({
lockImageStageHeight,
message.imageDatName,
message.imageMd5,
message.createTime,
requestImageDecrypt,
session.username
])
@@ -8391,8 +8427,19 @@ function MessageBubble({
}, [])
useEffect(() => {
setImageLoaded(false)
}, [imageLocalPath])
if (!isImage) return
if (!imageLocalPath) {
setImageLoaded(false)
return
}
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
const img = imageElementRef.current
if (img && img.complete && img.naturalWidth > 0) {
setImageLoaded(true)
}
}, [isImage, imageLocalPath])
useEffect(() => {
if (imageLoading) return
@@ -8401,7 +8448,7 @@ function MessageBubble({
}, [imageError, imageLoading, imageLocalPath])
useEffect(() => {
if (!isImage || imageLoading) return
if (!isImage || imageLoading || !imageInView) return
if (!message.imageMd5 && !message.imageDatName) return
if (imageUpdateCheckedRef.current === imageCacheKey) return
imageUpdateCheckedRef.current = imageCacheKey
@@ -8409,15 +8456,21 @@ function MessageBubble({
window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: false
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
if (cancelled) return
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
if (!imageLocalPath || imageLocalPath !== result.localPath) {
const renderPath = toRenderableImageSrc(result.localPath)
if (!renderPath) return
imageDataUrlCache.set(imageCacheKey, renderPath)
if (!imageLocalPath || imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
setImageLocalPath(result.localPath)
setImageLocalPath(renderPath)
setImageError(false)
}
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
@@ -8427,7 +8480,7 @@ function MessageBubble({
return () => {
cancelled = true
}
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
useEffect(() => {
if (!isImage) return
@@ -8455,15 +8508,17 @@ function MessageBubble({
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
if (matchesCacheKey) {
const renderPath = toRenderableImageSrc(payload.localPath)
if (!renderPath) return
const cachedPath = imageDataUrlCache.get(imageCacheKey)
if (cachedPath !== payload.localPath) {
imageDataUrlCache.set(imageCacheKey, payload.localPath)
if (cachedPath !== renderPath) {
imageDataUrlCache.set(imageCacheKey, renderPath)
}
if (imageLocalPath !== payload.localPath) {
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
setImageError(false)
}
})
@@ -9093,6 +9148,7 @@ function MessageBubble({
<>
<div className="image-message-wrapper">
<img
ref={imageElementRef}
src={imageLocalPath}
alt="图片"
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}

View File

@@ -105,7 +105,6 @@ interface ExportOptions {
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
imageDeepSearchOnMiss: boolean
}
interface SessionRow extends AppChatSession {
@@ -336,6 +335,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
task.payload.scope === 'content' && task.payload.contentType === 'text'
)
const isImageExportTask = (task: ExportTask): boolean => {
if (task.payload.scope === 'sns') {
return Boolean(task.payload.snsOptions?.exportImages)
}
if (task.payload.scope !== 'content') return false
if (task.payload.contentType === 'image') return true
return Boolean(task.payload.options?.exportImages)
}
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write'
@@ -1705,6 +1713,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null
const imageTask = isImageExportTask(task)
const imageTimingElapsedMs = imageTask
? Math.max(0, (
typeof task.finishedAt === 'number'
? task.finishedAt
: nowTick
) - (task.startedAt || task.createdAt))
: 0
const imageTimingAvgMs = imageTask && mediaDoneFiles > 0
? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles))
: 0
const imageTimingLabel = imageTask
? (
mediaDoneFiles > 0
? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张`
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
)
: ''
return (
<div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main">
@@ -1734,6 +1760,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
</div>
</>
)}
{imageTimingLabel && task.status !== 'queued' && (
<div className="task-perf-summary">
<span>{imageTimingLabel}</span>
</div>
)}
{canShowPerfDetail && stageTotals && (
<div className="task-perf-summary">
<span> {formatDurationMs(stageTotalMs)}</span>
@@ -1903,7 +1934,6 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({
format: 'json',
@@ -1924,8 +1954,7 @@ function ExportPage() {
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2,
imageDeepSearchOnMiss: true
exportConcurrency: 2
})
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -2622,7 +2651,7 @@ function ExportPage() {
automationTasksReadyRef.current = false
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
@@ -2631,7 +2660,6 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
@@ -2671,7 +2699,6 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setAutomationTasks(automationTaskItem?.tasks || [])
automationTasksReadyRef.current = true
@@ -2709,8 +2736,7 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
}))
} catch (error) {
isReady = false
@@ -4491,8 +4517,7 @@ function ExportPage() {
maxFileSizeMb: prev.maxFileSizeMb,
exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
exportConcurrency: exportDefaultConcurrency
}
if (payload.scope === 'sns') {
@@ -4527,8 +4552,7 @@ function ExportPage() {
exportDefaultAvatars,
exportDefaultMedia,
exportDefaultVoiceAsText,
exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
exportDefaultConcurrency
])
const closeExportDialog = useCallback(() => {
@@ -4755,7 +4779,6 @@ function ExportPage() {
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
sessionNameWithTypePrefix,
@@ -5691,8 +5714,6 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
}
const openSingleExport = useCallback((session: SessionRow) => {
@@ -7393,14 +7414,6 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
isSessionScopeDialog ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -9710,30 +9723,6 @@ function ExportPage() {
</div>
)}
{shouldRenderImageDeepSearchToggle && (
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
<div className="dialog-collapse-inner">
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
</div>
</div>
)}
{isSessionScopeDialog && (
<div className="dialog-section">
<div className="dialog-switch-row">

View File

@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -548,18 +547,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {

View File

@@ -491,24 +491,35 @@ export interface ElectronAPI {
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
error?: string
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
@@ -1117,7 +1128,6 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
export interface ExportProgress {