From 1a9f27488b6b275f5b76fbc00b3a5f6fecac1d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Sun, 26 Jul 2026 20:46:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(image-decrypt):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20UI=20=E9=87=8D=E6=9E=84=20+=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=AD=A5=E9=AA=A4=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面拆分两个目录语义: - 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()), 不再被用户输入覆盖。 - 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。 该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。 UI 改动: - ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。 - ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped), 任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。 - AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。 后端改动: - testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false, 拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。 - inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot, 让状态面板始终显示当前识别到的默认目录。 - sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。 - useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会 把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。 - image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。 --- .../image-decryption-status-service.ts | 50 ++++++++--- src/main/services/image-key-config-service.ts | 7 +- src/renderer/src/assets/main.css | 59 +++++++++++++ .../AutoDetectImageKeySection.tsx | 4 + .../ImageKeyConfiguration.tsx | 11 +-- .../image-decryption/ImageTestSection.tsx | 86 +++++++++++++++++-- .../imageDecryptionReducer.ts | 8 +- .../settings/image-decryption/types.ts | 4 +- .../useImageDecryptionController.ts | 19 ++-- .../settings/image-decryption/utils.ts | 8 +- 10 files changed, 215 insertions(+), 41 deletions(-) diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts index 75f92bd..a3ab925 100644 --- a/src/main/services/image-decryption-status-service.ts +++ b/src/main/services/image-decryption-status-service.ts @@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status' export async function inspectImageDecryptionStatus( config: ImageKeyConfigResult ): Promise { - const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot + // 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录; + // 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。 + const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || '' const imageDirectoryFound = hasImageDirectory(accountRoot) const stickerCacheFound = fs.existsSync(path.join(accountRoot, 'cache')) || @@ -65,7 +67,10 @@ export function testImageDecryption( .reverse() .find((message) => message.contentData?.type === 'image') if (!imageMessage || imageMessage.contentData?.type !== 'image') { - return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息') + return failure( + 'NO_IMAGE_MESSAGE', + '所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话' + ) } const service = new ImageDecryptService( @@ -74,26 +79,51 @@ export function testImageDecryption( chat.getChatDb()?.getWcdb4Client() ) const image = imageMessage.contentData - let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false }) + // 测试时优先使用用户在下方"图片资源目录"输入框填写的目录; + // 找不到再退回默认 accountDir。 + const testAccountDir = normalized.resourceRoot || undefined + let filePath = service.findImageFile(image.md5, image.datName, { + allowThumbnail: false, + accountDir: testAccountDir + }) if (!filePath) - filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true }) + filePath = service.findImageFile(image.md5, image.datName, { + allowThumbnail: true, + accountDir: testAccountDir + }) if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在') const data = service.decryptImageToBase64(filePath) if (!data) { + // 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。 return { - ...failure('DECRYPT_FAILED', '无法解析媒体文件'), - fileFound: true + success: false, + code: 'DECRYPT_FAILED', + error: '无法解析媒体文件', + fileFound: false, + decrypted: false, + readable: false } } const readable = data.startsWith('data:image/') + if (!readable) { + // 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat), + // 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。 + return { + success: false, + code: 'DECRYPT_FAILED', + error: '图片解密结果不可读取', + fileFound: true, + decrypted: false, + readable: false, + isThumbnail: service.isThumbnailFile(filePath) + } + } return { - success: readable, - code: readable ? undefined : 'DECRYPT_FAILED', - error: readable ? undefined : '图片解密结果不可读取', + success: true, fileFound: true, decrypted: true, - readable, + readable: true, isThumbnail: service.isThumbnailFile(filePath) } } catch { diff --git a/src/main/services/image-key-config-service.ts b/src/main/services/image-key-config-service.ts index a7bc547..3c54b42 100644 --- a/src/main/services/image-key-config-service.ts +++ b/src/main/services/image-key-config-service.ts @@ -92,9 +92,10 @@ export class ImageKeyConfigService { if (!result.success || !result.entry) { return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' } } + // 注意:normalized.resourceRoot 不再写回 imageKeyRoot。 + // 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。 saveSettings({ ...settings, - imageKeyRoot: normalized.resourceRoot, imageXorKey: '', imageAesKey: '', imageKeyFallbackDisabled: false @@ -106,7 +107,9 @@ export class ImageKeyConfigService { encryptionAvailable: true, source: 'secure-storage', accountId: context.accountId, - resourceRoot: normalized.resourceRoot, + // 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致; + // 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。 + resourceRoot: context.resourceRoot, xorKey: result.entry.xorKey, aesKey: result.entry.aesKey, updatedAt: result.entry.updatedAt diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1d62bb4..3efe764 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -5791,6 +5791,59 @@ body { width: 100%; margin: 2px 0 0; } +.image-step-list { + list-style: none; + margin: 14px 0 0; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid #e5ecea; + background: #f7faf9; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; +} +.image-step { + display: flex; + align-items: center; + gap: 8px; +} +.image-step-icon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 12px; + font-weight: 600; +} +.image-step-ok .image-step-icon { + background: #2e765d; + color: #fff; +} +.image-step-fail .image-step-icon { + background: #a84444; + color: #fff; +} +.image-step-fail span:last-child { + color: #a84444; +} +.image-step-pending .image-step-icon { + background: #d6dde0; + color: #6a7378; +} +.image-step-pending span:last-child { + color: #98a1a4; +} +.image-step-skipped .image-step-icon { + background: transparent; + color: #b8c0c4; +} +.image-step-skipped span:last-child { + color: #b8c0c4; + text-decoration: line-through; +} .image-inline-error { margin: 14px 0 0; color: #a84444; @@ -5829,6 +5882,12 @@ body { border-radius: 7px; background: #f1f4f3; } +.image-auto-scope-hint { + display: inline-block; + margin-top: 4px; + color: #7d8c8a; + font-size: 11px; +} .image-auto-unavailable { padding-top: 20px; padding-bottom: 20px; diff --git a/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx index c89b7bd..0cc50dd 100644 --- a/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx +++ b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx @@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({ {state.status.platform === 'darwin' ? '扫描本机微信缓存并通过图片模板验证候选密钥。' : '扫描微信进程内存并通过本地图片模板验证候选密钥。'} +
+ + 仅支持 WeChat 4.0,V3 及以下无法解析 +

- {result ? ( -
- {result.fileFound ? '✓' : '×'} 找到图片文件 - {result.decrypted ? '✓' : '×'} 解密成功 - {result.readable ? '✓' : '×'} 图片可以读取 - {!result.success ?

{result.error}

: null} -
+ {steps ? ( +
    +
  1. + {stepIcon(steps.find)} + 找到图片文件 +
  2. +
  3. + {stepIcon(steps.decrypt)} + 解密成功 +
  4. +
  5. + {stepIcon(steps.read)} + 图片可以读取 +
  6. +
) : state.error ? (

{state.error}

) : null} + {result && !result.success && result.error ? ( +

{result.error}

+ ) : null} ) } diff --git a/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts index 36c6dd9..7378b04 100644 --- a/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts +++ b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts @@ -27,7 +27,11 @@ export function imageDecryptionReducer( config: action.config, status: action.status, contacts: action.contacts, - resourceRoot: action.config.resourceRoot, + // 下方"图片资源目录"跟随状态面板的默认目录同步; + // 一旦用户手动编辑过(dirty),就不再被刷新覆盖。 + resourceRoot: state.dirty && state.resourceRoot + ? state.resourceRoot + : action.status.resourceRoot || action.config.resourceRoot, xorKey: action.config.xorKey || '0x40', aesKey: action.config.aesKey || '', error: action.config.success ? undefined : action.config.error, @@ -126,7 +130,7 @@ export function imageDecryptionReducer( config: action.config, status: action.status, contacts: state.contacts, - resourceRoot: action.config.resourceRoot + resourceRoot: action.status.resourceRoot || action.config.resourceRoot } default: return state diff --git a/src/renderer/src/features/settings/image-decryption/types.ts b/src/renderer/src/features/settings/image-decryption/types.ts index bedb6f2..2121469 100644 --- a/src/renderer/src/features/settings/image-decryption/types.ts +++ b/src/renderer/src/features/settings/image-decryption/types.ts @@ -53,7 +53,7 @@ export type ImageDecryptionAction = contacts: Contact[] } | { type: 'LOAD_ERROR'; error: string } - | { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string } + | { type: 'EDIT'; field: 'xorKey' | 'aesKey'; value: string } | { type: 'SELECT_CHAT'; userMd5: string } | { type: 'TEST_START' } | { type: 'TEST_DONE'; result: ImageDecryptionTestResult } @@ -80,7 +80,7 @@ export interface ImageDecryptionController { pageStatus: 'configured' | 'unconfigured' | 'partial' busy: boolean canSave: boolean - edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void + edit: (field: 'xorKey' | 'aesKey', value: string) => void selectChat: (userMd5: string) => void test: () => Promise save: () => Promise diff --git a/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts index 6582bc4..033af85 100644 --- a/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts +++ b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts @@ -33,7 +33,7 @@ export function useImageDecryptionController({ }) }, [refresh]) - const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => { + const edit = useCallback((field: 'xorKey' | 'aesKey', value: string): void => { dispatch({ type: 'EDIT', field, value }) }, []) @@ -84,12 +84,17 @@ export function useImageDecryptionController({ dispatch({ type: 'AUTO_START' }) const result = await window.api.autoGetImageKey({ save: false }) if (!result.success || !result.aesKey || !result.verified) { - dispatch({ - type: 'AUTO_ERROR', - error: result.success - ? '获取到候选密钥,但未通过图片验证' - : sanitizeImageError(result.error) - }) + // 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。 + // sanitizeImageError 是给"测试图片解析"设计的字典,会把 + // "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等 + // 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。 + const rawError = (result.error || '').toString().trim() + const errorMessage = !result.success + ? rawError || '自动获取图片密钥失败' + : !result.aesKey + ? '自动获取未返回 AES 密钥' + : '获取到候选密钥,但未通过图片验证' + dispatch({ type: 'AUTO_ERROR', error: errorMessage }) return } dispatch({ diff --git a/src/renderer/src/features/settings/image-decryption/utils.ts b/src/renderer/src/features/settings/image-decryption/utils.ts index 21e5785..2ec6645 100644 --- a/src/renderer/src/features/settings/image-decryption/utils.ts +++ b/src/renderer/src/features/settings/image-decryption/utils.ts @@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string { export function sanitizeImageError(error?: string): string { const value = String(error || '').toLowerCase() + if (value.includes('no_image_message') || value.includes('300')) { + return '当前会话最近 300 条消息内没有图片,请换一个含图片的聊天再测试' + } + if (value.includes('unsupported') || value.includes('dat version')) { + return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析' + } if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配' if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在' if (value.includes('账号')) return '当前账号不匹配' if (value.includes('目录')) return '图片资源目录不可用' - return error ? '无法解析媒体文件' : '图片解析测试未通过' + return error ? '无法解析媒体文件(仅支持 WeChat 4.0)' : '图片解析测试未通过' } export function normalizeAutoXorKey(value?: number, formatted?: string): string {