refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动

页面拆分两个目录语义:
- 上方「图片解密状态」的「图片资源目录」始终等于 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,下方输入框只用于校验,不再落盘。
This commit is contained in:
电摇小子
2026-07-26 20:46:20 +08:00
parent 3b64e18b5e
commit 1a9f27488b
10 changed files with 215 additions and 41 deletions
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
export async function inspectImageDecryptionStatus(
config: ImageKeyConfigResult
): Promise<ImageDecryptionStatus> {
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 {
@@ -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
+59
View File
@@ -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;
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
{state.status.platform === 'darwin'
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
<br />
<small className="image-auto-scope-hint">
WeChat 4.0V3
</small>
</p>
</div>
<button
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
}: {
state: ImageDecryptionState
disabled: boolean
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
}): React.ReactElement {
return (
<section className="settings-card image-key-editor">
<label>
<span></span>
<input
value={state.resourceRoot}
disabled={disabled}
title={state.resourceRoot}
onChange={(event) => onEdit('resourceRoot', event.target.value)}
/>
</label>
<div className="image-key-grid">
<label>
<span>XOR Key</span>
@@ -1,5 +1,58 @@
import type { ImageDecryptionState } from './types'
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
function stepClass(step: StepState): string {
switch (step) {
case 'ok':
return 'image-step image-step-ok'
case 'fail':
return 'image-step image-step-fail'
case 'skipped':
return 'image-step image-step-skipped'
default:
return 'image-step image-step-pending'
}
}
function stepIcon(step: StepState): string {
switch (step) {
case 'ok':
return '✓'
case 'fail':
return '×'
case 'skipped':
return '·'
default:
return '○'
}
}
function pickSteps(result: {
fileFound: boolean
decrypted: boolean
readable: boolean
success: boolean
}): { find: StepState; decrypt: StepState; read: StepState } {
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped
// 解密成功但不可读 → 读取 fail。
if (!result.fileFound) {
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.success && !result.decrypted && !result.readable) {
// 后端把 fileFound=false 的情况也用 success:false 表达;
// 此时第一步直接 fail,后两步 skip。
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.decrypted) {
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
}
if (!result.readable) {
return { find: 'ok', decrypt: 'ok', read: 'fail' }
}
return { find: 'ok', decrypt: 'ok', read: 'ok' }
}
export function ImageTestSection({
state,
disabled,
@@ -16,6 +69,14 @@ export function ImageTestSection({
onSave: () => void
}): React.ReactElement {
const result = state.testResult
const steps = result
? pickSteps({
fileFound: result.fileFound,
decrypted: result.decrypted,
readable: result.readable,
success: result.success
})
: null
return (
<section className="settings-card image-test-section">
<div>
@@ -48,16 +109,27 @@ export function ImageTestSection({
</button>
</div>
{result ? (
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
<span>{result.fileFound ? '✓' : '×'} </span>
<span>{result.decrypted ? '✓' : '×'} </span>
<span>{result.readable ? '✓' : '×'} </span>
{!result.success ? <p>{result.error}</p> : null}
</div>
{steps ? (
<ol className="image-step-list">
<li className={stepClass(steps.find)}>
<span className="image-step-icon">{stepIcon(steps.find)}</span>
<span></span>
</li>
<li className={stepClass(steps.decrypt)}>
<span className="image-step-icon">{stepIcon(steps.decrypt)}</span>
<span></span>
</li>
<li className={stepClass(steps.read)}>
<span className="image-step-icon">{stepIcon(steps.read)}</span>
<span></span>
</li>
</ol>
) : state.error ? (
<p className="image-inline-error">{state.error}</p>
) : null}
{result && !result.success && result.error ? (
<p className="image-inline-error">{result.error}</p>
) : null}
</section>
)
}
@@ -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
@@ -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<void>
save: () => Promise<void>
@@ -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({
@@ -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 {