Skip to content

Commit 751c252

Browse files
committed
improvement(import): 支持 wxdump 目录导入并增加导入保护
- 兼容 wxdump 的 output 目录、database/ 和 media/ 结构 - 缺少 account.json 时自动推断账号信息并补充导入预览 - 导入前展示目标账号状态,并拦截源目录与目标目录重叠的情况 - 支持取消导入、已有账号自动备份,以及失败/取消后的回滚恢复 - 补充资源查找兼容逻辑,适配 wxdump 导入后的媒体文件布局
1 parent 8c0eeca commit 751c252

3 files changed

Lines changed: 483 additions & 99 deletions

File tree

frontend/pages/import.vue

Lines changed: 140 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div class="import-page min-h-screen relative overflow-hidden">
33
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
44

5-
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
5+
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
66
<div class="w-full rounded-[28px] border border-[#EDEDED] bg-white/92 backdrop-blur-sm">
77
<div class="px-5 py-5 sm:px-7 sm:py-7">
88
<div class="mb-5 flex items-start justify-between gap-3">
@@ -13,9 +13,9 @@
1313
</svg>
1414
</div>
1515
<div class="min-w-0">
16-
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import backup</p>
16+
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入备份</p>
1717
<h1 class="mt-1 text-[24px] font-semibold leading-none text-[#000000e6]">数据导入</h1>
18-
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录,确认账号后即可写入当前工具。</p>
18+
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录,支持本项目导出和 wxdump 的 output/wxid_xxx 结构。</p>
1919
</div>
2020
</div>
2121

@@ -30,27 +30,26 @@
3030
</NuxtLink>
3131
</div>
3232

33-
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4">
34-
<div class="flex items-center gap-2 text-[13px] font-semibold text-[#000000d9]">
35-
<svg class="h-4 w-4 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36-
<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" />
37-
</svg>
38-
<span>目录要求</span>
39-
</div>
40-
<div class="mt-3 grid gap-2 sm:grid-cols-3">
41-
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
42-
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Target</p>
43-
<p class="mt-1 text-sm leading-6 text-[#000000d9]">请选择 `output / wxid_xxxxx` 这一层目录。</p>
44-
</div>
45-
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
46-
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Database</p>
47-
<p class="mt-1 text-sm leading-6 text-[#000000d9]">目录内需要包含 `databases/`,用于存放 `.db` 文件。</p>
33+
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4 sm:px-5">
34+
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
35+
<div class="flex min-w-0 items-start gap-3">
36+
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white text-[#07C160] ring-1 ring-[#E7F1E8]">
37+
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38+
<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" />
39+
</svg>
40+
</div>
41+
<div class="min-w-0">
42+
<div class="text-[13px] font-semibold text-[#000000d9]">目录要求</div>
43+
<p class="mt-1 text-sm leading-6 text-[#6F6F6F]">支持本项目导出和 wxdump 导出。优先选择账号目录;若 output 下只有一个账号,也可直接选 output。</p>
44+
</div>
4845
</div>
49-
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
50-
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Account</p>
51-
<p class="mt-1 text-sm leading-6 text-[#000000d9]">`account.json` 会作为账号识别与信息校验依据。</p>
46+
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
47+
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">databases/</span>
48+
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">database/</span>
49+
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">media/</span>
5250
</div>
5351
</div>
52+
5453
</div>
5554

5655
<div v-if="!importPreview && !importError && !importing" class="animate-fade-in">
@@ -64,7 +63,7 @@
6463
</svg>
6564
</div>
6665
<h3 class="mt-4 text-lg font-semibold text-[#000000e6]">选择解密备份目录</h3>
67-
<p class="mt-2 text-sm text-[#7F7F7F]">建议直接选择 `wxid_xxxxx` 层级,减少后续校验失败。</p>
66+
<p class="mt-2 text-sm text-[#7F7F7F]">建议选择 `wxid_xxxxx` 层级;wxdump 的 `output` 根目录在单账号时也支持。</p>
6867
<div class="mt-5 inline-flex items-center rounded-full bg-[#07C160] px-4 py-2 text-sm font-medium text-white transition-colors duration-200 group-hover:bg-[#06AD56]">
6968
点击开始选择
7069
</div>
@@ -81,7 +80,7 @@
8180

8281
<div class="mt-5 text-center">
8382
<p class="text-xl font-semibold text-[#000000e6]">{{ importMessage }}</p>
84-
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行,导入完成后会自动进入聊天页面。</p>
83+
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行,导入完成后可手动进入聊天页面。</p>
8584
</div>
8685

8786
<div class="mt-6 overflow-hidden rounded-full bg-[#EDF3EE]">
@@ -95,19 +94,62 @@
9594
<span>已连接导入任务</span>
9695
<span>{{ importProgress }}%</span>
9796
</div>
97+
98+
<button
99+
class="mt-5 inline-flex w-full items-center justify-center rounded-2xl border border-[#F0D7D7] bg-white px-4 py-3 text-sm font-medium text-[#D64A4A] transition-colors hover:bg-[#FFF7F7]"
100+
@click="cancelImport"
101+
>
102+
取消导入
103+
</button>
98104
</div>
99105
</div>
100106

101-
<div v-if="importPreview && !importing" class="animate-fade-in space-y-4">
107+
<div v-if="importComplete && !importing" class="animate-fade-in space-y-4">
108+
<div class="rounded-[24px] border border-[#DCEFE2] bg-[#F7FCF8] px-5 py-7 text-center sm:px-6">
109+
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
110+
<svg class="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
111+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
112+
</svg>
113+
</div>
114+
<h2 class="mt-4 text-xl font-semibold text-[#000000e6]">导入完成</h2>
115+
<p class="mt-2 text-sm leading-6 text-[#6F6F6F]">{{ importComplete.message || '账号数据已成功导入。' }}</p>
116+
<div class="mt-4 rounded-2xl border border-[#E2EFE5] bg-white px-4 py-3 text-left text-sm text-[#4A4A4A]">
117+
<div class="flex items-center justify-between gap-3">
118+
<span class="text-[#7F7F7F]">账号</span>
119+
<span class="min-w-0 truncate font-mono text-xs">{{ importComplete.account }}</span>
120+
</div>
121+
<div v-if="importComplete.backup_dir" class="mt-2 flex items-start justify-between gap-3">
122+
<span class="shrink-0 text-[#7F7F7F]">旧数据备份</span>
123+
<span class="min-w-0 break-all text-right text-xs">{{ importComplete.backup_dir }}</span>
124+
</div>
125+
</div>
126+
<div class="mt-5 grid gap-3 sm:grid-cols-2">
127+
<button
128+
class="inline-flex items-center justify-center rounded-2xl border border-[#E2E2E2] bg-white px-4 py-3 text-sm font-medium text-[#4A4A4A] transition-colors hover:bg-[#F8F8F8]"
129+
@click="retryPickDirectory"
130+
>
131+
继续导入其他目录
132+
</button>
133+
<button
134+
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56]"
135+
@click="navigateTo('/chat')"
136+
>
137+
进入聊天页面
138+
</button>
139+
</div>
140+
</div>
141+
</div>
142+
143+
<div v-if="importPreview && !importing && !importComplete" class="animate-fade-in space-y-4">
102144
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-5 sm:px-6">
103145
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
104146
<div class="flex min-w-0 items-center gap-4">
105147
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-[#EDEDED] bg-white">
106-
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="Avatar" />
148+
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="头像" />
107149
</div>
108150

109151
<div class="min-w-0">
110-
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Detected account</p>
152+
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">检测到的账号</p>
111153
<div class="mt-1 truncate text-xl font-semibold text-[#000000e6]">{{ importPreview.nick || '未命名账号' }}</div>
112154
<div class="mt-2 inline-flex max-w-full items-center rounded-full border border-[#EDEDED] bg-white px-3 py-1 text-xs font-mono text-[#7F7F7F]">
113155
<span class="truncate">{{ importPreview.username }}</span>
@@ -121,7 +163,7 @@
121163
</div>
122164

123165
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#EDEDED] bg-white px-3 py-3">
124-
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import path</p>
166+
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入路径</p>
125167
<p class="mt-1 break-all text-sm text-[#000000d9]">{{ selectedImportPath }}</p>
126168
</div>
127169

@@ -145,7 +187,7 @@
145187
重新选择目录
146188
</button>
147189
<button
148-
:disabled="importing"
190+
:disabled="importing || importPreview?.source_overlaps_target"
149191
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56] disabled:cursor-not-allowed disabled:bg-[#8FD9AE]"
150192
@click="confirmImport"
151193
>
@@ -169,7 +211,7 @@
169211
</div>
170212

171213
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#F1E3E3] bg-white/80 px-3 py-3">
172-
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">Current path</p>
214+
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">当前路径</p>
173215
<p class="mt-1 break-all text-sm text-[#7A4B4B]">{{ selectedImportPath }}</p>
174216
</div>
175217
</div>
@@ -197,7 +239,9 @@ const importProgress = ref(0)
197239
const importMessage = ref('正在准备...')
198240
const importPreview = ref(null)
199241
const importError = ref('')
242+
const importComplete = ref(null)
200243
const selectedImportPath = ref('')
244+
const importJobId = ref('')
201245
202246
let eventSource = null
203247
@@ -220,23 +264,24 @@ const isDesktopShell = () => {
220264
const resetImport = () => {
221265
closeEventSource()
222266
importPreview.value = null
267+
importComplete.value = null
223268
importError.value = ''
224269
selectedImportPath.value = ''
225270
importing.value = false
226271
importProgress.value = 0
227272
importMessage.value = '正在准备...'
273+
importJobId.value = ''
228274
}
229275
230276
const { importDecryptedPreview, pickSystemDirectory } = useApi()
231277
const apiBase = useApiBase()
232-
233278
const handlePickDirectory = async () => {
234279
let path = ''
235280
236281
if (isDesktopShell()) {
237282
try {
238283
const res = await window.wechatDesktop.chooseDirectory({
239-
title: '请选择解密输出目录 (如: output/wxid_xxxxx)'
284+
title: '请选择解密输出目录 (如: output/wxid_xxxxx 或单账号 output)'
240285
})
241286
if (!res || res.canceled || !res.filePaths?.length) return
242287
path = res.filePaths[0]
@@ -246,7 +291,7 @@ const handlePickDirectory = async () => {
246291
}
247292
} else {
248293
try {
249-
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
294+
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (建议选到 wxid_xxx 层级)' })
250295
if (!res || !res.path) return
251296
path = res.path
252297
} catch (e) {
@@ -260,7 +305,7 @@ const handlePickDirectory = async () => {
260305
const isOk = window.confirm(`你选择的目录为:
261306
${path}
262307
263-
该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
308+
该目录似乎不是 "wxid_xxxxx" 账号目录。如果这是 wxdump 的单账号 output 根目录,可以继续。确定要继续吗?`)
264309
if (!isOk) return
265310
}
266311
@@ -271,7 +316,7 @@ ${path}
271316
try {
272317
importPreview.value = await importDecryptedPreview({ import_path: path })
273318
} catch (e) {
274-
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
319+
importError.value = e.message || '目录格式不正确,请确保包含 databases/database 目录;wxdump 格式可不含 account.json'
275320
}
276321
}
277322
@@ -280,16 +325,65 @@ const retryPickDirectory = async () => {
280325
await handlePickDirectory()
281326
}
282327
328+
const makeImportJobId = () => {
329+
const randomPart = typeof crypto !== 'undefined' && crypto.randomUUID
330+
? crypto.randomUUID()
331+
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
332+
return `import-${randomPart}`
333+
}
334+
335+
const cancelImport = async () => {
336+
const jobId = importJobId.value
337+
closeEventSource()
338+
importing.value = false
339+
importProgress.value = 0
340+
importMessage.value = '正在准备...'
341+
importComplete.value = null
342+
importError.value = ''
343+
344+
if (!jobId) return
345+
try {
346+
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted/cancel`, window.location.origin)
347+
url.searchParams.set('job_id', jobId)
348+
await fetch(url.toString(), { method: 'POST' })
349+
} catch (e) {
350+
console.error('取消导入失败:', e)
351+
} finally {
352+
importJobId.value = ''
353+
}
354+
}
355+
356+
283357
const confirmImport = async () => {
284358
if (!selectedImportPath.value) return
285359
360+
if (importPreview.value?.source_overlaps_target) {
361+
importError.value = '导入源目录与目标数据目录相同或相互包含,请重新选择外部备份目录。'
362+
return
363+
}
364+
365+
if (importPreview.value?.target_exists) {
366+
const ok = window.confirm(`当前账号已存在:${importPreview.value.username}
367+
368+
继续导入会先自动备份旧目录,然后导入新数据。
369+
旧数据库数量:${importPreview.value.existing_db_count || 0}
370+
新数据库数量:${importPreview.value.incoming_db_count || 0}
371+
372+
确定继续吗?`)
373+
if (!ok) return
374+
}
375+
286376
importing.value = true
377+
378+
importComplete.value = null
287379
importError.value = ''
288380
importProgress.value = 0
289381
importMessage.value = '启动导入程序...'
382+
importJobId.value = makeImportJobId()
290383
291384
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
292385
url.searchParams.set('import_path', selectedImportPath.value)
386+
url.searchParams.set('job_id', importJobId.value)
293387
294388
closeEventSource()
295389
eventSource = new EventSource(url.toString())
@@ -304,15 +398,17 @@ const confirmImport = async () => {
304398
} else if (data.type === 'complete') {
305399
importProgress.value = 100
306400
importMessage.value = '导入完成!'
401+
importComplete.value = data
402+
importError.value = ''
403+
importing.value = false
307404
closeEventSource()
308-
309-
setTimeout(async () => {
310-
await navigateTo('/chat')
311-
}, 1000)
405+
importJobId.value = ''
312406
} else if (data.type === 'error') {
313407
importError.value = data.message || '导入失败'
408+
importComplete.value = null
314409
importing.value = false
315410
closeEventSource()
411+
importJobId.value = ''
316412
}
317413
} catch (e) {
318414
console.error('解析 SSE 数据失败:', e)
@@ -321,9 +417,15 @@ const confirmImport = async () => {
321417
322418
eventSource.onerror = (e) => {
323419
console.error('EventSource 错误:', e)
420+
if (!importing.value) {
421+
closeEventSource()
422+
return
423+
}
424+
importComplete.value = null
324425
importError.value = '与服务器连接断开或发生错误'
325426
importing.value = false
326427
closeEventSource()
428+
importJobId.value = ''
327429
}
328430
}
329431
</script>

src/wechat_decrypt_tool/media_helpers.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3051,14 +3051,26 @@ def _try_find_decrypted_resource(account_dir: Path, md5: str) -> Optional[Path]:
30513051
if not resource_dir.exists():
30523052
return None
30533053
sub_dir = md5[:2] if len(md5) >= 2 else "00"
3054+
3055+
# Prefer the standard layout: resource/{md5-prefix}/{md5}.{ext}
30543056
target_dir = resource_dir / sub_dir
3055-
if not target_dir.exists():
3056-
return None
3057-
# 查找匹配MD5的文件(可能有不同扩展名)
3058-
for ext in ["jpg", "png", "gif", "webp", "mp4", "dat"]:
3059-
p = target_dir / f"{md5}.{ext}"
3060-
if p.exists():
3061-
return p
3057+
search_dirs = [target_dir]
3058+
3059+
# Support wxdump flat media layout after it is imported as resource.
3060+
# Typical files: resource/{md5}.jpg, resource/{md5}_t.jpg, or resource/{md5}.wxgf.
3061+
if resource_dir not in search_dirs:
3062+
search_dirs.append(resource_dir)
3063+
3064+
exts = ["jpg", "png", "gif", "webp", "mp4", "dat", "wxgf", "wxgf.jpg"]
3065+
suffixes = ["", "_t", "_b", "_h"]
3066+
for directory in search_dirs:
3067+
if not directory.exists():
3068+
continue
3069+
for suffix in suffixes:
3070+
for ext in exts:
3071+
candidate = directory / f"{md5}{suffix}.{ext}"
3072+
if candidate.exists():
3073+
return candidate
30623074
return None
30633075

30643076

0 commit comments

Comments
 (0)