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" >
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
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" >
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 >
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]" >
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 >
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
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 >
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)
197239const importMessage = ref (' 正在准备...' )
198240const importPreview = ref (null )
199241const importError = ref (' ' )
242+ const importComplete = ref (null )
200243const selectedImportPath = ref (' ' )
244+ const importJobId = ref (' ' )
201245
202246let eventSource = null
203247
@@ -220,23 +264,24 @@ const isDesktopShell = () => {
220264const 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
230276const { importDecryptedPreview , pickSystemDirectory } = useApi ()
231277const apiBase = useApiBase ()
232-
233278const 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+
283357const 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>
0 commit comments