Files
WeChatDataAnalysis/frontend/pages/decrypt.vue
2977094657 c1712ba6dd feat(keys): 自动保存密钥并支持前端回填
- 新增 output/account_keys.json 账号密钥存储(db_key / image_xor_key / image_aes_key)
- 新增 /api/keys 查询已保存密钥;缺失时兜底从账号目录 _media_keys.json 读取图片密钥

- 数据库解密成功后按账号写入 db_key;保存图片密钥时同步写入 store(失败静默不影响主流程)
- 前端解密页进入图片密钥步骤自动回填;进入下一步/跳过时自动保存一次
2026-01-01 16:29:16 +08:00

736 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen flex items-center justify-center py-8">
<div class="max-w-4xl mx-auto px-6 w-full">
<!-- 步骤指示器 -->
<div class="mb-8">
<Stepper :steps="steps" :current-step="currentStep" />
</div>
<!-- 步骤1: 数据库解密 -->
<div v-if="currentStep === 0" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center mb-6">
<div class="w-12 h-12 bg-[#07C160] rounded-lg flex items-center justify-center mr-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">数据库解密</h2>
<p class="text-sm text-[#7F7F7F]">输入密钥和路径开始解密</p>
</div>
</div>
<form @submit.prevent="handleDecrypt" class="space-y-6">
<!-- 密钥输入 -->
<div>
<label for="key" class="block text-sm font-medium text-[#000000e6] mb-2">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
解密密钥 <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
id="key"
v-model="formData.key"
type="text"
placeholder="请输入64位十六进制密钥"
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
:class="{ 'border-red-500': formErrors.key }"
required
/>
<div v-if="formData.key" class="absolute right-3 top-1/2 transform -translate-y-1/2">
<span class="text-xs text-[#7F7F7F]">{{ formData.key.length }}/64</span>
</div>
</div>
<p v-if="formErrors.key" class="mt-1 text-sm text-red-600 flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ formErrors.key }}
</p>
<p class="mt-2 text-xs text-[#7F7F7F] flex items-center">
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 等工具获取的64位十六进制字符串
</p>
</div>
<!-- 数据库路径输入 -->
<div>
<label for="dbPath" class="block text-sm font-medium text-[#000000e6] mb-2">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
数据库存储路径 <span class="text-red-500">*</span>
</label>
<input
id="dbPath"
v-model="formData.db_storage_path"
type="text"
placeholder="例如: D:\wechatMSG\xwechat_files\wxid_xxx\db_storage"
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
:class="{ 'border-red-500': formErrors.db_storage_path }"
required
/>
<p v-if="formErrors.db_storage_path" class="mt-1 text-sm text-red-600 flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ formErrors.db_storage_path }}
</p>
<p class="mt-2 text-xs text-[#7F7F7F] flex items-center">
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
请输入数据库文件所在的绝对路径
</p>
</div>
<!-- 提交按钮 -->
<div class="pt-4 border-t border-[#EDEDED]">
<div class="flex items-center justify-center">
<button
type="submit"
:disabled="loading"
class="inline-flex items-center px-8 py-3 bg-[#07C160] text-white rounded-lg text-base font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="!loading" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
</svg>
<svg v-if="loading" class="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ loading ? '解密中...' : '开始解密' }}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- 步骤2: 填写图片密钥 -->
<div v-if="currentStep === 1" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center mb-6">
<div class="w-12 h-12 bg-[#10AEEF] rounded-lg flex items-center justify-center mr-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">图片密钥</h2>
<p class="text-sm text-[#7F7F7F]">填写后会自动保存并下次回填</p>
</div>
</div>
<!-- 填写密钥 -->
<div class="mb-6">
<div class="bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR必填</label>
<input
v-model="manualKeys.xor_key"
type="text"
placeholder="例如0xA5"
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
/>
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
</div>
<div>
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES可选</label>
<input
v-model="manualKeys.aes_key"
type="text"
placeholder="16 个字符V4-V2 需要)"
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
/>
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
</div>
</div>
<p class="mt-3 text-xs text-[#7F7F7F] flex items-center">
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 获取图片密钥AES 可选V4-V2 需要
</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
<button
@click="goToMediaDecryptStep"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200"
>
下一步
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
<!-- 跳过按钮 -->
<div class="text-center mt-4">
<button @click="skipToChat" class="text-sm text-[#7F7F7F] hover:text-[#07C160] transition-colors">
跳过图片解密直接查看聊天记录
</button>
</div>
</div>
</div>
<!-- 步骤3: 批量解密图片 -->
<div v-if="currentStep === 2" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-[#91D300] rounded-lg flex items-center justify-center mr-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">批量解密图片</h2>
<p class="text-sm text-[#7F7F7F]">仅解密加密的图片文件(.dat)其他文件无需解密</p>
</div>
</div>
<!-- 进度计数 -->
<div v-if="mediaDecrypting && decryptProgress.total > 0" class="text-right">
<div class="text-lg font-bold text-[#91D300]">{{ decryptProgress.current }} / {{ decryptProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">已处理 / 总图片</div>
</div>
</div>
<!-- 实时进度条 -->
<div v-if="mediaDecrypting || decryptProgress.total > 0" class="mb-6">
<!-- 进度条 -->
<div class="mb-3">
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
<span>解密进度</span>
<span>{{ progressPercent }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-300 ease-out"
:class="decryptProgress.status === 'complete' ? 'bg-[#07C160]' : 'bg-[#91D300]'"
:style="{ width: progressPercent + '%' }"
></div>
</div>
</div>
<!-- 当前文件名 -->
<div v-if="decryptProgress.current_file" class="flex items-center text-sm text-[#7F7F7F] mb-3">
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14"/>
</svg>
<span class="truncate font-mono text-xs">{{ decryptProgress.current_file }}</span>
<span
class="ml-2 px-2 py-0.5 rounded text-xs"
:class="{
'bg-green-100 text-green-700': decryptProgress.fileStatus === 'success',
'bg-gray-100 text-gray-600': decryptProgress.fileStatus === 'skip',
'bg-red-100 text-red-700': decryptProgress.fileStatus === 'fail'
}"
>
{{ decryptProgress.fileStatus === 'success' ? '解密成功' : decryptProgress.fileStatus === 'skip' ? '已存在' : decryptProgress.fileStatus === 'fail' ? '失败' : '' }}
</span>
</div>
<!-- 实时统计 -->
<div class="grid grid-cols-4 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div>
<div class="text-xl font-bold text-[#10AEEF]">{{ decryptProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">总图片</div>
</div>
<div>
<div class="text-xl font-bold text-[#07C160]">{{ decryptProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
</div>
<div>
<div class="text-xl font-bold text-[#7F7F7F]">{{ decryptProgress.skip_count }}</div>
<div class="text-xs text-[#7F7F7F]">跳过(已解密)</div>
</div>
<div>
<div class="text-xl font-bold text-[#FA5151]">{{ decryptProgress.fail_count }}</div>
<div class="text-xs text-[#7F7F7F]">失败</div>
</div>
</div>
</div>
<!-- 完成后的结果 -->
<div v-if="mediaDecryptResult && !mediaDecrypting" class="mb-6">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">解密完成</span>
</div>
<div class="text-sm text-green-600">
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ mediaDecryptResult.output_dir }}</code>
</div>
</div>
</div>
<!-- 失败原因说明 -->
<div v-if="decryptProgress.fail_count > 0" class="mb-6">
<details class="text-sm">
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
<span class="ml-1">查看失败原因说明</span>
</summary>
<div class="mt-2 bg-gray-50 rounded-lg p-3 text-xs text-[#7F7F7F]">
<p class="mb-2">可能的失败原因</p>
<ul class="list-disc list-inside space-y-1">
<li><strong>解密后非有效图片</strong>文件不是图片格式(如视频缩略图损坏)</li>
<li><strong>V4-V2版本需要AES密钥</strong>请使用 wx_key 获取 AES 密钥后再重试解密</li>
<li><strong>未知加密版本</strong>新版微信使用了不支持的加密方式</li>
<li><strong>文件为空</strong>原始文件损坏或为空文件</li>
</ul>
</div>
</details>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
<button
@click="decryptAllImages"
:disabled="mediaDecrypting"
class="inline-flex items-center px-6 py-3 bg-[#91D300] text-white rounded-lg font-medium hover:bg-[#82BD00] transition-all duration-200 disabled:opacity-50"
>
<svg v-if="mediaDecrypting" class="w-5 h-5 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14"/>
</svg>
{{ mediaDecrypting ? '解密中...' : (mediaDecryptResult ? '重新解密' : '开始解密图片') }}
</button>
<button
@click="skipToChat"
:disabled="mediaDecrypting"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50"
>
查看聊天记录
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
</div>
<!-- 错误提示 -->
<transition name="fade">
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mt-6 animate-shake flex items-start">
<svg class="h-5 w-5 mr-2 flex-shrink-0 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="font-semibold text-red-700">操作失败</p>
<p class="text-sm mt-1 text-red-600">{{ error }}</p>
</div>
</div>
</transition>
</div>
</div>
</template>
<style scoped>
/* 动画效果 */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
const { decryptDatabase, saveMediaKeys, getSavedKeys } = useApi()
const loading = ref(false)
const error = ref('')
const currentStep = ref(0)
const mediaAccount = ref('')
// 步骤定义
const steps = [
{ title: '数据库解密' },
{ title: '填写图片密钥' },
{ title: '图片解密' }
]
// 表单数据
const formData = reactive({
key: '',
db_storage_path: ''
})
// 表单错误
const formErrors = reactive({
key: '',
db_storage_path: ''
})
// 图片密钥相关
const mediaKeys = reactive({
xor_key: '',
aes_key: ''
})
// 手动输入密钥(从 wx_key 获取)
const manualKeys = reactive({
xor_key: '',
aes_key: ''
})
const manualKeyErrors = reactive({
xor_key: '',
aes_key: ''
})
const normalizeXorKey = (value) => {
const raw = String(value || '').trim()
if (!raw) return { ok: false, value: '', message: '请输入 XOR 密钥' }
const hex = raw.toLowerCase().replace(/^0x/, '')
if (!/^[0-9a-f]{1,2}$/.test(hex)) return { ok: false, value: '', message: 'XOR 密钥格式无效(如 0xA5 或 A5' }
const n = parseInt(hex, 16)
if (!Number.isFinite(n) || n < 0 || n > 255) return { ok: false, value: '', message: 'XOR 密钥必须在 0x00-0xFF 范围' }
return { ok: true, value: `0x${n.toString(16).toUpperCase().padStart(2, '0')}`, message: '' }
}
const normalizeAesKey = (value) => {
const raw = String(value || '').trim()
if (!raw) return { ok: true, value: '', message: '' }
if (raw.length < 16) return { ok: false, value: '', message: 'AES 密钥长度不足(至少 16 个字符)' }
return { ok: true, value: raw.slice(0, 16), message: '' }
}
const prefillKeysForAccount = async (account) => {
const acc = String(account || '').trim()
if (!acc) return
try {
const resp = await getSavedKeys({ account: acc })
if (!resp || resp.status !== 'success') return
const keys = resp.keys || {}
const dbKey = String(keys.db_key || '').trim()
if (dbKey && !String(formData.key || '').trim()) {
formData.key = dbKey
}
const xorKey = String(keys.image_xor_key || '').trim()
if (xorKey && !String(manualKeys.xor_key || '').trim()) {
manualKeys.xor_key = xorKey
}
const aesKey = String(keys.image_aes_key || '').trim()
if (aesKey && !String(manualKeys.aes_key || '').trim()) {
manualKeys.aes_key = aesKey
}
} catch (e) {
// ignore
}
}
const applyManualKeys = () => {
manualKeyErrors.xor_key = ''
manualKeyErrors.aes_key = ''
error.value = ''
const aes = normalizeAesKey(manualKeys.aes_key)
if (!aes.ok) {
manualKeyErrors.aes_key = aes.message
return false
}
mediaKeys.aes_key = aes.value
const rawXor = String(manualKeys.xor_key || '').trim()
if (!rawXor) {
mediaKeys.xor_key = ''
return true
}
const xor = normalizeXorKey(rawXor)
if (!xor.ok) {
manualKeyErrors.xor_key = xor.message
return false
}
mediaKeys.xor_key = xor.value
return true
}
const clearManualKeys = () => {
manualKeys.xor_key = ''
manualKeys.aes_key = ''
manualKeyErrors.xor_key = ''
manualKeyErrors.aes_key = ''
mediaKeys.xor_key = ''
mediaKeys.aes_key = ''
}
// 图片解密相关
const mediaDecryptResult = ref(null)
const mediaDecrypting = ref(false)
// 实时解密进度
const decryptProgress = reactive({
current: 0,
total: 0,
success_count: 0,
skip_count: 0,
fail_count: 0,
current_file: '',
fileStatus: '',
status: ''
})
// 进度百分比
const progressPercent = computed(() => {
if (decryptProgress.total === 0) return 0
return Math.round((decryptProgress.current / decryptProgress.total) * 100)
})
// 解密结果存储
const decryptResult = ref(null)
// 验证表单
const validateForm = () => {
let isValid = true
formErrors.key = ''
formErrors.db_storage_path = ''
// 验证密钥
if (!formData.key) {
formErrors.key = '请输入解密密钥'
isValid = false
} else if (formData.key.length !== 64) {
formErrors.key = '密钥必须是64位十六进制字符串'
isValid = false
} else if (!/^[0-9a-fA-F]+$/.test(formData.key)) {
formErrors.key = '密钥必须是有效的十六进制字符串'
isValid = false
}
// 验证路径
if (!formData.db_storage_path) {
formErrors.db_storage_path = '请输入数据库存储路径'
isValid = false
}
return isValid
}
// 处理解密
const handleDecrypt = async () => {
if (!validateForm()) {
return
}
loading.value = true
error.value = ''
try {
const result = await decryptDatabase({
key: formData.key,
db_storage_path: formData.db_storage_path
})
if (result.status === 'completed') {
// 解密成功,保存结果并进入下一步
decryptResult.value = result
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('decryptResult', JSON.stringify(result))
}
// 记录当前账号(用于图片解密/密钥保存)
try {
const accounts = Object.keys(result.account_results || {})
if (accounts.length > 0) mediaAccount.value = accounts[0]
} catch (e) {
// ignore
}
// 进入图片密钥填写步骤
clearManualKeys()
currentStep.value = 1
await prefillKeysForAccount(mediaAccount.value)
} else if (result.status === 'failed') {
if (result.failure_count > 0 && result.success_count === 0) {
error.value = result.message || '所有文件解密失败'
} else {
error.value = '部分文件解密失败,请检查密钥是否正确'
}
} else {
error.value = result.message || '解密失败,请检查输入信息'
}
} catch (err) {
error.value = err.message || '解密过程中发生错误'
} finally {
loading.value = false
}
}
// 批量解密所有图片使用SSE实时进度
const decryptAllImages = async () => {
mediaDecrypting.value = true
mediaDecryptResult.value = null
error.value = ''
// 重置进度
decryptProgress.current = 0
decryptProgress.total = 0
decryptProgress.success_count = 0
decryptProgress.skip_count = 0
decryptProgress.fail_count = 0
decryptProgress.current_file = ''
decryptProgress.fileStatus = ''
decryptProgress.status = ''
try {
// 构建SSE URL
const params = new URLSearchParams()
if (mediaAccount.value) params.set('account', mediaAccount.value)
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
const url = `http://localhost:8000/api/media/decrypt_all_stream?${params.toString()}`
// 使用EventSource接收SSE
const eventSource = new EventSource(url)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'scanning') {
decryptProgress.current_file = '正在扫描文件...'
} else if (data.type === 'start') {
decryptProgress.total = data.total
} else if (data.type === 'progress') {
decryptProgress.current = data.current
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
decryptProgress.current_file = data.current_file
decryptProgress.fileStatus = data.status
} else if (data.type === 'complete') {
decryptProgress.status = 'complete'
decryptProgress.current = data.total
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
mediaDecryptResult.value = data
eventSource.close()
mediaDecrypting.value = false
} else if (data.type === 'error') {
error.value = data.message
eventSource.close()
mediaDecrypting.value = false
}
} catch (e) {
console.error('解析SSE消息失败:', e)
}
}
eventSource.onerror = (e) => {
console.error('SSE连接错误:', e)
eventSource.close()
if (mediaDecrypting.value) {
error.value = 'SSE连接中断请重试'
mediaDecrypting.value = false
}
}
} catch (err) {
error.value = err.message || '图片解密过程中发生错误'
mediaDecrypting.value = false
}
}
// 从密钥步骤进入图片解密步骤
const goToMediaDecryptStep = async () => {
error.value = ''
// 校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示)
const ok = applyManualKeys()
if (!ok || manualKeyErrors.xor_key || manualKeyErrors.aes_key) return
// 用户已输入 XOR 时,自动保存一次,避免下次重复输入(失败不影响继续)
if (mediaKeys.xor_key) {
try {
const aesVal = String(mediaKeys.aes_key || '').trim()
await saveMediaKeys({
account: mediaAccount.value || null,
xor_key: mediaKeys.xor_key,
aes_key: aesVal ? aesVal : null
})
} catch (e) {
// ignore
}
}
currentStep.value = 2
}
// 跳过图片解密,直接查看聊天记录
const skipToChat = async () => {
try {
const ok = applyManualKeys()
if (ok && mediaKeys.xor_key) {
const aesVal = String(mediaKeys.aes_key || '').trim()
await saveMediaKeys({
account: mediaAccount.value || null,
xor_key: mediaKeys.xor_key,
aes_key: aesVal ? aesVal : null
})
}
} catch (e) {
// ignore
}
navigateTo('/chat')
}
// 页面加载时检查是否有选中的账户
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const selectedAccount = sessionStorage.getItem('selectedAccount')
if (selectedAccount) {
try {
const account = JSON.parse(selectedAccount)
// 填充数据路径
if (account.data_dir) {
formData.db_storage_path = account.data_dir + '\\db_storage'
}
if (account.account_name) {
mediaAccount.value = account.account_name
}
// 清除sessionStorage
sessionStorage.removeItem('selectedAccount')
await prefillKeysForAccount(mediaAccount.value)
} catch (e) {
console.error('解析账户信息失败:', e)
}
}
}
})
</script>