mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
6 Commits
@@ -176,6 +176,10 @@ npm run dist
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 导出
|
||||
|
||||
侧边栏提供“导出”入口,点击下载图标可打开统一导出弹窗。当前导出界面只保留两个选项:导出数据库、导出资源文件;用户选择导出目录后会生成一个账号归档 ZIP。当两个选项都勾选时,导出会按账号目录直接归档,使用 ZIP 存储模式打包,尽量避免二次压缩带来的耗时。
|
||||
|
||||
### 获取解密密钥
|
||||
|
||||
在使用本工具之前,您需要先获取微信数据库的解密密钥。推荐使用以下工具:
|
||||
@@ -195,31 +199,14 @@ npm run dist
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
1. **[echotrace](https://github.com/ycccccccy/echotrace)** - 微信数据解析/取证工具
|
||||
- 本项目大量功能参考并复用其实现思路,提供了重要技术支持
|
||||
|
||||
2. **[WeFlow](https://github.com/hicccc77/WeFlow)** - 微信数据分析工具
|
||||
- 提供了重要的功能参考和技术支持
|
||||
|
||||
3. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具
|
||||
- 支持获取微信 4.x 数据库密钥与缓存图片密钥
|
||||
- 本项目推荐使用此工具获取密钥
|
||||
|
||||
4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具
|
||||
- 提供了SQLCipher 4.0解密的正确实现参考
|
||||
- 本项目的HMAC验证和页面处理逻辑基于此项目的实现
|
||||
|
||||
5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具
|
||||
- 提供了优秀的聊天记录界面设计参考
|
||||
- 本项目的聊天界面风格参考了此项目的实现
|
||||
|
||||
6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3)
|
||||
- 提供了聊天记录展示与交互的实现参考
|
||||
|
||||
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)** - 微信图片密钥获取工具
|
||||
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存与点击朋友圈大图
|
||||
1. **[echotrace](https://github.com/ycccccccy/echotrace)**
|
||||
2. **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
3. **[wx_key](https://github.com/ycccccccy/wx_key)**
|
||||
4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)**
|
||||
5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)**
|
||||
6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)**
|
||||
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)**
|
||||
8. **[Ritsu](https://xhslink.com/m/7YJUsd1sgyF)**
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div v-if="open" class="fixed inset-0 z-[135] flex items-center justify-center overflow-y-auto bg-black/40 px-4 py-6">
|
||||
<div class="absolute inset-0" @click="requestClose"></div>
|
||||
|
||||
<div class="relative flex max-h-[90vh] w-full max-w-[680px] flex-col overflow-hidden rounded-lg border border-[#e5e7eb] bg-white">
|
||||
<header class="flex items-start gap-3 border-b border-[#e5e7eb] px-5 py-4">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-[#d9f3e3] bg-[#f0fdf4] text-[#07C160]">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v11" />
|
||||
<path d="M7.5 10.5L12 15l4.5-4.5" />
|
||||
<path d="M4 19h16" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-[16px] font-semibold text-[#111827]">导出账号归档</h2>
|
||||
<span class="rounded-md border border-[#d1fae5] bg-[#f0fdf4] px-2 py-0.5 text-[11px] font-medium text-[#047857]">ZIP 归档</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#6b7280]">
|
||||
账号归档会直接打包已解密数据库和本地资源目录,适合备份、迁移或后续重新分析;普通导出会生成 HTML / JSON / TXT 等可阅读结果,更适合查看和分享。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-[#6b7280] transition hover:bg-[#f3f4f6] hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="running"
|
||||
title="关闭"
|
||||
@click="requestClose"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div class="space-y-4">
|
||||
<div v-if="!selectedAccount" class="rounded-md border border-[#fde68a] bg-[#fffbeb] px-3 py-2.5 text-[13px] leading-5 text-[#92400e]">
|
||||
当前未选择账号,请先导入或切换到一个已解密账号后再导出。
|
||||
</div>
|
||||
|
||||
<div v-if="globalError" class="rounded-md border border-[#fecaca] bg-[#fef2f2] px-3 py-2.5 text-[13px] leading-5 text-[#b91c1c] whitespace-pre-wrap">
|
||||
{{ globalError }}
|
||||
</div>
|
||||
|
||||
<div v-if="globalMessage" class="rounded-md border border-[#bbf7d0] bg-[#f0fdf4] px-3 py-2.5 text-[13px] leading-5 text-[#15803d] whitespace-pre-wrap">
|
||||
{{ globalMessage }}
|
||||
</div>
|
||||
|
||||
<section class="rounded-lg border border-[#e5e7eb] bg-white">
|
||||
<div class="border-b border-[#e5e7eb] px-4 py-3">
|
||||
<div class="text-[14px] font-medium text-[#111827]">导出目录</div>
|
||||
<div class="mt-0.5 text-[12px] text-[#6b7280]">选择 ZIP 文件的保存位置。</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="min-w-0 flex-1 rounded-md border border-dashed px-3 py-2.5 text-[12px] leading-5" :class="exportFolder ? 'border-[#86efac] bg-[#f0fdf4] text-[#166534]' : 'border-[#d1d5db] bg-[#f9fafb] text-[#6b7280]'">
|
||||
<div class="truncate" :title="exportFolder || '尚未选择导出目录'">{{ exportFolder || '尚未选择导出目录' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 whitespace-nowrap rounded-md border border-[#d1d5db] bg-white px-3 py-2.5 text-[13px] font-medium text-[#111827] transition hover:bg-[#f9fafb] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="running"
|
||||
@click="chooseExportFolder"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M9.5 13.5H14.5" />
|
||||
</svg>
|
||||
选择目录
|
||||
</button>
|
||||
<button
|
||||
v-if="exportFolder"
|
||||
type="button"
|
||||
class="inline-flex items-center whitespace-nowrap rounded-md border border-[#d1d5db] bg-white px-3 py-2.5 text-[13px] font-medium text-[#374151] transition hover:bg-[#f9fafb] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="running"
|
||||
@click="clearExportFolderSelection"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-[#e5e7eb] bg-white">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-[#e5e7eb] px-4 py-3">
|
||||
<div>
|
||||
<div class="text-[14px] font-medium text-[#111827]">导出内容</div>
|
||||
<div class="mt-0.5 text-[12px] text-[#6b7280]">勾选要包含在归档中的内容。</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-[#f3f4f6] px-2 py-1 text-[12px] text-[#4b5563]">{{ contentSummary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 sm:grid-cols-2">
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-md border p-3 transition"
|
||||
:class="includeDatabases ? 'border-[#22c55e] bg-[#f0fdf4]' : 'border-[#e5e7eb] bg-white hover:bg-[#f9fafb]'"
|
||||
>
|
||||
<input v-model="includeDatabases" type="checkbox" class="sr-only" />
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border" :class="includeDatabases ? 'border-[#22c55e] bg-white text-[#16a34a]' : 'border-[#e5e7eb] bg-[#f9fafb] text-[#6b7280]'">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
||||
<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
|
||||
<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[14px] font-medium text-[#111827]">导出数据库</span>
|
||||
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border" :class="includeDatabases ? 'border-[#22c55e] bg-[#22c55e] text-white' : 'border-[#d1d5db] text-transparent'">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M16.704 5.29a1 1 0 010 1.42l-7.25 7.25a1 1 0 01-1.42 0L3.296 9.22a1 1 0 111.414-1.414l4.03 4.03 6.543-6.543a1 1 0 011.421 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#6b7280]">包含 .db / .sqlite 数据库,以及必要的账号元信息文件。</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-md border p-3 transition"
|
||||
:class="includeResources ? 'border-[#22c55e] bg-[#f0fdf4]' : 'border-[#e5e7eb] bg-white hover:bg-[#f9fafb]'"
|
||||
>
|
||||
<input v-model="includeResources" type="checkbox" class="sr-only" />
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border" :class="includeResources ? 'border-[#22c55e] bg-white text-[#16a34a]' : 'border-[#e5e7eb] bg-[#f9fafb] text-[#6b7280]'">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z" />
|
||||
<path d="M3.3 7L12 12l8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[14px] font-medium text-[#111827]">导出资源文件</span>
|
||||
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border" :class="includeResources ? 'border-[#22c55e] bg-[#22c55e] text-white' : 'border-[#d1d5db] text-transparent'">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M16.704 5.29a1 1 0 010 1.42l-7.25 7.25a1 1 0 01-1.42 0L3.296 9.22a1 1 0 111.414-1.414l4.03 4.03 6.543-6.543a1 1 0 011.421 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#6b7280]">包含 resource、sns_resource 及朋友圈媒体缓存目录。</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="task" class="rounded-lg border border-[#e5e7eb] bg-white">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-[#e5e7eb] px-4 py-3">
|
||||
<div>
|
||||
<div class="text-[14px] font-medium text-[#111827]">{{ task.label }}</div>
|
||||
<div v-if="task.message" class="mt-0.5 text-[12px] text-[#6b7280]">{{ task.message }}</div>
|
||||
</div>
|
||||
<span class="rounded-md px-2 py-1 text-[11px] font-medium" :class="statusClass(task.status)">{{ statusLabel(task.status) }}</span>
|
||||
</div>
|
||||
<div class="space-y-3 p-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[12px] text-[#6b7280]">
|
||||
<span>{{ taskProgressLabel }}</span>
|
||||
<span class="font-medium text-[#374151]">{{ taskProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-sm bg-[#f3f4f6]">
|
||||
<div class="h-full rounded-sm transition-all duration-300" :class="progressBarClass" :style="{ width: `${taskProgress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.detail" class="rounded-md bg-[#f9fafb] px-3 py-2 text-[12px] leading-5 text-[#4b5563]">{{ task.detail }}</div>
|
||||
<div v-if="task.outputPath" class="break-all rounded-md border border-[#e5e7eb] bg-white px-3 py-2 text-[12px] leading-5 text-[#374151]">
|
||||
{{ task.outputPath }}
|
||||
</div>
|
||||
<div v-if="task.error" class="whitespace-pre-wrap rounded-md bg-[#fef2f2] px-3 py-2 text-[12px] leading-5 text-[#b91c1c]">{{ task.error }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-[#e5e7eb] bg-white px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-[12px] leading-5 text-[#6b7280]">
|
||||
<span class="font-medium text-[#374151]">当前账号:</span>{{ selectedAccount || '未选择' }}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-w-[112px] items-center justify-center gap-2 rounded-md px-4 py-2 text-[13px] font-medium transition disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="running ? 'border border-[#fecaca] bg-white text-[#b91c1c] hover:bg-[#fef2f2]' : (canStartExport ? 'bg-[#07C160] text-white hover:bg-[#06ad56]' : 'bg-[#d1d5db] text-white')"
|
||||
:disabled="running ? cancelRequested : !canStartExport"
|
||||
@click="running ? cancelExport() : startExport()"
|
||||
>
|
||||
<svg v-if="running && cancelRequested" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
{{ running ? (cancelRequested ? '正在取消...' : '取消导出') : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const api = useApi()
|
||||
const apiBase = useApiBase()
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const running = ref(false)
|
||||
const globalError = ref('')
|
||||
const globalMessage = ref('')
|
||||
const exportFolder = ref('')
|
||||
const exportFolderHandle = ref(null)
|
||||
const includeDatabases = ref(true)
|
||||
const includeResources = ref(true)
|
||||
const task = ref(null)
|
||||
const currentExportId = ref('')
|
||||
const cancelRequested = ref(false)
|
||||
const cancelSent = ref(false)
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && typeof window !== 'undefined' && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
|
||||
const isWebDirectoryPickerSupported = () => {
|
||||
return !!(process.client && typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function')
|
||||
}
|
||||
|
||||
const hasExportTarget = computed(() => {
|
||||
return isDesktopExportRuntime()
|
||||
? !!String(exportFolder.value || '').trim()
|
||||
: !!exportFolderHandle.value
|
||||
})
|
||||
|
||||
const hasSelectedContent = computed(() => !!includeDatabases.value || !!includeResources.value)
|
||||
|
||||
const contentSummary = computed(() => {
|
||||
if (includeDatabases.value && includeResources.value) return '数据库 + 资源文件'
|
||||
if (includeDatabases.value) return '仅数据库'
|
||||
if (includeResources.value) return '仅资源文件'
|
||||
return '未选择内容'
|
||||
})
|
||||
|
||||
const canStartExport = computed(() => {
|
||||
if (running.value) return false
|
||||
if (!selectedAccount.value) return false
|
||||
if (!hasExportTarget.value) return false
|
||||
return hasSelectedContent.value
|
||||
})
|
||||
|
||||
const taskProgress = computed(() => {
|
||||
const value = Number(task.value?.progress || 0)
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
})
|
||||
|
||||
const taskProgressLabel = computed(() => {
|
||||
if (task.value?.status === 'done') return '\u5bfc\u51fa\u8fdb\u5ea6'
|
||||
if (task.value?.status === 'cancelled') return '\u5df2\u53d6\u6d88'
|
||||
if (task.value?.status === 'error') return '\u5bfc\u51fa\u5931\u8d25'
|
||||
if (cancelRequested.value) return '\u6b63\u5728\u53d6\u6d88'
|
||||
const processed = Number(task.value?.processedBytes || 0)
|
||||
const total = Number(task.value?.totalBytes || 0)
|
||||
if (total > 0) return `\u5bfc\u51fa\u8fdb\u5ea6\uff1a${formatBytes(processed)} / ${formatBytes(total)}`
|
||||
return '\u5bfc\u51fa\u8fdb\u5ea6'
|
||||
})
|
||||
|
||||
const progressBarClass = computed(() => {
|
||||
if (task.value?.status === 'error') return 'bg-[#ef4444]'
|
||||
if (task.value?.status === 'cancelled') return 'bg-[#9ca3af]'
|
||||
return 'bg-[#07C160]'
|
||||
})
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'running') return '导出中'
|
||||
if (status === 'done') return '已完成'
|
||||
if (status === 'error') return '失败'
|
||||
return '等待中'
|
||||
}
|
||||
|
||||
const statusClass = (status) => {
|
||||
if (status === 'running') return 'bg-blue-100 text-blue-700'
|
||||
if (status === 'done') return 'bg-emerald-100 text-emerald-700'
|
||||
if (status === 'error') return 'bg-red-100 text-red-700'
|
||||
if (status === 'cancelled') return 'bg-gray-100 text-gray-600'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value)
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let index = 0
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024
|
||||
index += 1
|
||||
}
|
||||
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
|
||||
return `${size.toFixed(digits)} ${units[index]}`
|
||||
}
|
||||
|
||||
const buildExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
}
|
||||
|
||||
const sanitizeFileNamePart = (value, fallback = 'export') => {
|
||||
const cleaned = String(value || '')
|
||||
.trim()
|
||||
.replace(/[^0-9A-Za-z._-]+/g, '_')
|
||||
.replace(/^[._-]+|[._-]+$/g, '')
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
const buildBrowserOutputLabel = (fileName) => {
|
||||
const folderLabel = String(exportFolder.value || '浏览器目录').trim()
|
||||
return `${folderLabel}/${fileName}`
|
||||
}
|
||||
|
||||
const saveResponseToBrowserFolder = async ({ response, fileName }) => {
|
||||
if (!exportFolderHandle.value || typeof exportFolderHandle.value.getFileHandle !== 'function') {
|
||||
throw new Error('请先选择浏览器导出目录。')
|
||||
}
|
||||
|
||||
const safeName = sanitizeFileNamePart(fileName, 'wechat_archive.zip')
|
||||
const fileHandle = await exportFolderHandle.value.getFileHandle(safeName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
|
||||
const totalBytes = Number(response.headers.get('Content-Length') || 0)
|
||||
let writtenBytes = 0
|
||||
|
||||
if (response.body && typeof response.body.getReader === 'function') {
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (!value || !value.byteLength) continue
|
||||
await writable.write(value)
|
||||
writtenBytes += value.byteLength
|
||||
if (task.value) {
|
||||
task.value.detail = totalBytes > 0
|
||||
? `正在保存到浏览器目录:${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`
|
||||
: `正在保存到浏览器目录:${formatBytes(writtenBytes)}`
|
||||
}
|
||||
}
|
||||
await writable.close()
|
||||
} catch (error) {
|
||||
try { await reader.cancel() } catch {}
|
||||
try { await writable.abort() } catch {}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
const blob = await response.blob()
|
||||
writtenBytes = Number(blob.size || 0)
|
||||
if (task.value) task.value.detail = `正在保存到浏览器目录:${formatBytes(writtenBytes)}`
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
return buildBrowserOutputLabel(safeName)
|
||||
}
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
globalError.value = ''
|
||||
globalMessage.value = ''
|
||||
try {
|
||||
if (isDesktopExportRuntime()) {
|
||||
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
|
||||
if (result?.canceled) return
|
||||
const selected = Array.isArray(result?.filePaths) ? result.filePaths[0] : ''
|
||||
if (selected) {
|
||||
exportFolder.value = String(selected || '').trim()
|
||||
exportFolderHandle.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWebDirectoryPickerSupported()) {
|
||||
globalError.value = '当前环境不支持选择导出目录。'
|
||||
return
|
||||
}
|
||||
|
||||
const handle = await window.showDirectoryPicker({ mode: 'readwrite' })
|
||||
exportFolderHandle.value = handle
|
||||
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return
|
||||
globalError.value = error?.message || '选择导出目录失败。'
|
||||
}
|
||||
}
|
||||
|
||||
const clearExportFolderSelection = () => {
|
||||
exportFolder.value = ''
|
||||
exportFolderHandle.value = null
|
||||
}
|
||||
|
||||
const validateSelections = () => {
|
||||
const errors = []
|
||||
if (!selectedAccount.value) errors.push('未选择账号。')
|
||||
if (!hasExportTarget.value) errors.push('请先选择导出目录。')
|
||||
if (!hasSelectedContent.value) errors.push('请至少选择数据库或资源文件。')
|
||||
return errors
|
||||
}
|
||||
|
||||
const translateArchiveMessage = (message) => {
|
||||
const text = String(message || '')
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace('Waiting to start...', '等待开始...')
|
||||
.replace('Preparing export...', '正在准备导出...')
|
||||
.replace('Scanning export content...', '正在扫描导出内容...')
|
||||
.replace('Calculating total archive size.', '\u6b63\u5728\u8ba1\u7b97\u5f52\u6863\u603b\u5927\u5c0f\u3002')
|
||||
.replace('Preparing database and resource file list.', '正在准备数据库和资源文件列表。')
|
||||
.replace('Scanning resource files...', '正在扫描资源文件...')
|
||||
.replace(/Scanned (\d+) resource files\./, '已扫描 $1 个资源文件。')
|
||||
.replace('Writing ZIP archive...', '正在写入 ZIP 归档...')
|
||||
.replace('Packing account folder directly.', '\u6b63\u5728\u76f4\u63a5\u6253\u5305\u8d26\u53f7\u6587\u4ef6\u5939\u3002')
|
||||
.replace(/Ready to pack (\d+) files \(([0-9.]+) MB\)\./, '\u5df2\u8ba1\u7b97\u603b\u5927\u5c0f\uff1a$1 \u4e2a\u6587\u4ef6\uff08$2 MB\uff09\u3002')
|
||||
.replace(/Packed (\d+)\/(\d+) files \(([0-9.]+)\/([0-9.]+) MB\)\./, '\u5df2\u6253\u5305 $1/$2 \u4e2a\u6587\u4ef6\uff08$3/$4 MB\uff09\u3002')
|
||||
.replace(/Packed (\d+) files \(([0-9.]+) MB\)\./, '\u5df2\u6253\u5305 $1 \u4e2a\u6587\u4ef6\uff08$2 MB\uff09\u3002')
|
||||
.replace(/Found (\d+) database files and (\d+) resource files\./, '发现 $1 个数据库文件和 $2 个资源文件。')
|
||||
.replace(/Processed (\d+)\/(\d+) files\./, '已处理 $1/$2 个文件。')
|
||||
.replace('Finalizing ZIP archive...', '正在完成 ZIP 归档...')
|
||||
.replace('Moving archive to target folder.', '正在移动归档到目标目录。')
|
||||
.replace('Export completed.', '导出完成。')
|
||||
.replace(/Exported (\d+) database files and (\d+) resource files\./, '已导出 $1 个数据库文件和 $2 个资源文件。')
|
||||
.replace('Cancelling export...', '正在取消导出...')
|
||||
.replace('Waiting for the current file operation to stop.', '正在等待当前文件写入停止。')
|
||||
.replace('Export cancelled.', '导出已取消。')
|
||||
.replace('Temporary archive has been removed.', '临时归档文件已删除。')
|
||||
.replace('Export failed.', '导出失败。')
|
||||
}
|
||||
|
||||
const shouldHideArchiveProgressDetail = (detail) => {
|
||||
const text = String(detail || '').trim()
|
||||
return /^Ready to pack \d+ files/.test(text) || /^Packed \d+\/\d+ files/.test(text) || /^Packed \d+ files/.test(text)
|
||||
}
|
||||
|
||||
const normalizeArchiveJob = (job = {}) => {
|
||||
const rawDetail = String(job.detail || '')
|
||||
return {
|
||||
label: '\u8d26\u53f7\u5f52\u6863',
|
||||
status: String(job.status || 'running'),
|
||||
message: job.message ? translateArchiveMessage(job.message) : '\u6b63\u5728\u6253\u5305...',
|
||||
detail: rawDetail && !shouldHideArchiveProgressDetail(rawDetail) ? translateArchiveMessage(rawDetail) : '',
|
||||
outputPath: String(job.zipPath || ''),
|
||||
error: job.error || '',
|
||||
progress: Number(job.progress || 0),
|
||||
databaseCount: Number(job.databaseCount || 0),
|
||||
resourceFileCount: Number(job.resourceFileCount || 0),
|
||||
totalBytes: Number(job.totalBytes || 0),
|
||||
processedBytes: Number(job.processedBytes || 0),
|
||||
fileName: String(job.fileName || '')
|
||||
}
|
||||
}
|
||||
|
||||
const waitForArchiveJob = async (exportId) => {
|
||||
while (true) {
|
||||
const response = await api.getAccountArchiveExport(exportId)
|
||||
const job = response?.job || {}
|
||||
task.value = normalizeArchiveJob(job)
|
||||
|
||||
if (job.status === 'done' || job.status === 'error' || job.status === 'cancelled') {
|
||||
return job
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
}
|
||||
}
|
||||
|
||||
const cancelExport = async () => {
|
||||
if (!currentExportId.value || cancelSent.value) return
|
||||
cancelRequested.value = true
|
||||
cancelSent.value = true
|
||||
if (task.value) {
|
||||
task.value.message = '正在取消导出...'
|
||||
task.value.detail = '正在等待当前文件写入停止。'
|
||||
}
|
||||
try {
|
||||
await api.cancelAccountArchiveExport(currentExportId.value)
|
||||
} catch (error) {
|
||||
cancelSent.value = false
|
||||
cancelRequested.value = false
|
||||
globalError.value = error?.message || '取消导出失败。'
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = async () => {
|
||||
globalError.value = ''
|
||||
globalMessage.value = ''
|
||||
|
||||
const errors = validateSelections()
|
||||
if (errors.length > 0) {
|
||||
globalError.value = errors.join('\n')
|
||||
return
|
||||
}
|
||||
|
||||
running.value = true
|
||||
currentExportId.value = ''
|
||||
cancelRequested.value = false
|
||||
cancelSent.value = false
|
||||
task.value = {
|
||||
label: '账号归档',
|
||||
status: 'running',
|
||||
message: '正在创建导出任务...',
|
||||
detail: '',
|
||||
outputPath: '',
|
||||
error: '',
|
||||
progress: 1
|
||||
}
|
||||
|
||||
try {
|
||||
const stamp = buildExportTimestamp()
|
||||
const fileName = `wechat_archive_${sanitizeFileNamePart(selectedAccount.value, 'account')}_${stamp}.zip`
|
||||
const createResponse = await api.createAccountArchiveExport({
|
||||
account: selectedAccount.value,
|
||||
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
|
||||
include_databases: !!includeDatabases.value,
|
||||
include_resources: !!includeResources.value,
|
||||
file_name: fileName
|
||||
})
|
||||
|
||||
const createdJob = createResponse?.job || {}
|
||||
currentExportId.value = String(createdJob.exportId || '').trim()
|
||||
if (!currentExportId.value) throw new Error('创建导出任务失败。')
|
||||
task.value = normalizeArchiveJob(createdJob)
|
||||
|
||||
const finalJob = await waitForArchiveJob(currentExportId.value)
|
||||
task.value = normalizeArchiveJob(finalJob)
|
||||
|
||||
if (finalJob.status === 'cancelled') {
|
||||
globalMessage.value = '导出已取消。'
|
||||
return
|
||||
}
|
||||
if (finalJob.status === 'error') {
|
||||
throw new Error(finalJob.error || '导出失败。')
|
||||
}
|
||||
|
||||
const resultFileName = String(finalJob.fileName || fileName).trim()
|
||||
task.value.detail = `打包完成:数据库 ${Number(finalJob.databaseCount || 0)} 个,资源文件 ${Number(finalJob.resourceFileCount || 0)} 个,总计 ${formatBytes(finalJob.totalBytes || 0)}。`
|
||||
|
||||
if (isDesktopExportRuntime()) {
|
||||
task.value.outputPath = String(finalJob.zipPath || '').trim()
|
||||
} else {
|
||||
task.value.message = '正在保存到浏览器目录...'
|
||||
task.value.progress = 98
|
||||
const zipPath = String(finalJob.zipPath || '').trim()
|
||||
const query = new URLSearchParams()
|
||||
query.set('path', zipPath)
|
||||
const downloadUrl = `${apiBase}/account/archive_export/download?${query.toString()}`
|
||||
const downloadResponse = await fetch(downloadUrl)
|
||||
if (!downloadResponse.ok) {
|
||||
throw new Error(`下载导出文件失败(${downloadResponse.status})。`)
|
||||
}
|
||||
task.value.outputPath = await saveResponseToBrowserFolder({
|
||||
response: downloadResponse,
|
||||
fileName: resultFileName
|
||||
})
|
||||
task.value.progress = 100
|
||||
}
|
||||
|
||||
task.value.status = 'done'
|
||||
task.value.message = '导出完成。'
|
||||
globalMessage.value = '导出完成。'
|
||||
} catch (error) {
|
||||
if (task.value) {
|
||||
task.value.status = 'error'
|
||||
task.value.error = error?.message || '导出失败。'
|
||||
}
|
||||
globalError.value = error?.message || '导出失败。'
|
||||
} finally {
|
||||
running.value = false
|
||||
cancelRequested.value = false
|
||||
cancelSent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const requestClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onWindowKeydown = (event) => {
|
||||
if (event?.key !== 'Escape') return
|
||||
if (!props.open) return
|
||||
event.preventDefault()
|
||||
if (running.value) {
|
||||
cancelExport()
|
||||
return
|
||||
}
|
||||
requestClose()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return
|
||||
globalError.value = ''
|
||||
globalMessage.value = ''
|
||||
task.value = null
|
||||
await chatAccounts.ensureLoaded()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onWindowKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
window.removeEventListener('keydown', onWindowKeydown)
|
||||
})
|
||||
</script>
|
||||
@@ -144,6 +144,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div
|
||||
v-if="showGlobalExportEntry"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="导出"
|
||||
@click="openExportDialog"
|
||||
>
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': exportDialogOpen }">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v11" />
|
||||
<path d="M7.5 10.5L12 15l4.5-4.5" />
|
||||
<path d="M4 19h16" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Realtime -->
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
@@ -333,6 +360,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalExportDialog v-if="showGlobalExportEntry" :open="exportDialogOpen" @close="closeExportDialog" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -358,7 +387,9 @@ const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realti
|
||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||
const { getChatAccountInfo, deleteChatAccount } = useApi()
|
||||
|
||||
const showGlobalExportEntry = true
|
||||
const accountDialogOpen = ref(false)
|
||||
const exportDialogOpen = ref(false)
|
||||
const accountInfoLoading = ref(false)
|
||||
const accountInfoError = ref('')
|
||||
const accountInfo = ref(null)
|
||||
@@ -460,11 +491,19 @@ const openAccountDialog = async () => {
|
||||
await loadAccountInfo()
|
||||
}
|
||||
|
||||
const openExportDialog = () => {
|
||||
exportDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeAccountDialog = () => {
|
||||
if (accountDeleteLoading.value) return
|
||||
accountDialogOpen.value = false
|
||||
}
|
||||
|
||||
const closeExportDialog = () => {
|
||||
exportDialogOpen.value = false
|
||||
}
|
||||
|
||||
watch(selectedAccount, () => {
|
||||
if (!accountDialogOpen.value) return
|
||||
void loadAccountInfo()
|
||||
@@ -508,9 +547,13 @@ const goSettings = () => { openSettingsDialog() }
|
||||
|
||||
const onWindowKeydown = (event) => {
|
||||
if (event?.key !== 'Escape') return
|
||||
if (!accountDialogOpen.value) return
|
||||
if (exportDialogOpen.value) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
closeAccountDialog()
|
||||
if (accountDialogOpen.value) {
|
||||
closeAccountDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCurrentAccountData = async () => {
|
||||
@@ -605,3 +648,4 @@ const toggleRealtime = async () => {
|
||||
color: var(--sidebar-rail-icon-active-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -510,6 +510,30 @@ export const useApi = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Account archive export (databases + resource files)
|
||||
const createAccountArchiveExport = async (payload = {}) => {
|
||||
return await request('/account/archive_export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: payload.account || null,
|
||||
output_dir: payload.output_dir == null ? null : String(payload.output_dir || '').trim(),
|
||||
include_databases: payload.include_databases == null ? true : !!payload.include_databases,
|
||||
include_resources: payload.include_resources == null ? true : !!payload.include_resources,
|
||||
file_name: payload.file_name || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getAccountArchiveExport = async (exportId) => {
|
||||
if (!exportId) throw new Error('Missing exportId')
|
||||
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`)
|
||||
}
|
||||
|
||||
const cancelAccountArchiveExport = async (exportId) => {
|
||||
if (!exportId) throw new Error('Missing exportId')
|
||||
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// WeChat Wrapped(年度总结)
|
||||
const getWrappedAnnual = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -662,6 +686,9 @@ export const useApi = () => {
|
||||
cancelSnsExport,
|
||||
listChatContacts,
|
||||
exportChatContacts,
|
||||
createAccountArchiveExport,
|
||||
getAccountArchiveExport,
|
||||
cancelAccountArchiveExport,
|
||||
getWrappedAnnual,
|
||||
getWrappedAnnualMeta,
|
||||
getWrappedAnnualCard,
|
||||
|
||||
+226
-234
@@ -1,297 +1,289 @@
|
||||
<template>
|
||||
<div class="detection-result-page min-h-screen relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute top-20 left-20 w-72 h-72 bg-[#07C160] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute top-40 right-20 w-96 h-96 bg-[#10AEEF] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div class="relative z-10 w-full max-w-5xl mx-auto px-4 sm:px-5 py-6 sm:py-8 animate-fade-in">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-[22px] font-bold leading-none">
|
||||
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">检测结果</span>
|
||||
</h2>
|
||||
<div class="detection-result-page relative min-h-screen overflow-hidden px-3 py-4 text-[#000000e6] sm:px-5 sm:py-5">
|
||||
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
|
||||
|
||||
<main class="relative z-10 mx-auto w-full max-w-6xl">
|
||||
<div class="mb-3 flex flex-col gap-3 rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12px] font-medium tracking-[0.16em] text-[#07C160]">本地检测</p>
|
||||
<h1 class="mt-1.5 text-[30px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[38px]">
|
||||
{{ loading ? '正在检测微信数据' : detectionResult?.error ? '需要手动指定目录' : '找到可操作的微信账号' }}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-3xl text-[14px] leading-6 text-[#6B7280]">
|
||||
{{ loading ? '正在检查微信安装信息、账号目录和数据库文件。' : detectionResult?.error ? '自动检测没有找到可用数据,可以在下方手动选择 xwechat_files 目录后重试。' : '选择要处理的账号进入解密提取。如果结果不完整,可以手动指定数据根目录后重新检测。' }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs text-[#07C160] hover:text-[#06AD56] hover:bg-white/80 font-medium transition-colors">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex shrink-0 items-center rounded-lg border border-[#CFEEDB] bg-[#F7FDF9]/82 px-2.5 py-1.5 text-xs font-medium text-[#07C160] transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回首页
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detectionResult && !loading && !detectionResult.error"
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mb-4"
|
||||
>
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">微信版本</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6] truncate">{{ detectionResult.data?.wechat_version || '未知' }}</p>
|
||||
<div v-if="loading" class="flex min-h-[48vh] items-center justify-center">
|
||||
<div class="w-full max-w-3xl rounded-lg border border-[#DDF4E7] bg-[#F7FDF9]/76 p-4 backdrop-blur sm:p-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#07C160]">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" class="opacity-20"></circle>
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
<span>正在检测</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-[22px] font-semibold leading-tight tracking-[-0.03em] text-[#000000e6] sm:text-[28px]">
|
||||
正在寻找可用的微信数据
|
||||
</h3>
|
||||
<p class="mt-1.5 max-w-2xl text-[14px] leading-6 text-[#6B7280]">
|
||||
这一步会自动检查本机环境、匹配账号并统计数据库文件。检测期间请保持当前页面打开。
|
||||
</p>
|
||||
</div>
|
||||
<span class="hidden shrink-0 rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-medium text-[#07C160] sm:inline-flex">
|
||||
自动进行
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#07C160]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">检测账号</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_accounts || 0 }} 个</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#10AEEF]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283-.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2.5 rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-3">
|
||||
<div class="h-2 overflow-hidden rounded-full border border-[#DDF4E7] bg-[#F7FCF8]/86">
|
||||
<div class="h-full w-1/2 rounded-full bg-[#07C160] animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">数据库文件</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_databases || 0 }} 个</p>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>检查环境</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">读取微信安装与数据目录</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>匹配账号</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">找到可操作的本地账号</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>汇总数据</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">整理后续解密所需信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#91D300]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#91D300]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
|
||||
</svg>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-[#DDF4E7] pt-3 text-[12px] leading-5 text-[#7F7F7F] sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>如果等待时间较长,通常是本地文件较多或磁盘读取较慢。</span>
|
||||
<span class="font-medium text-[#07C160]">请稍候</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第一步检测:数据目录与微信安装目录都在这里设置 -->
|
||||
<div v-if="!loading" class="bg-white/90 backdrop-blur rounded-xl p-3.5 md:p-4 border border-[#EDEDED] mb-4 space-y-3">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-semibold text-[#000000e6] flex items-center">
|
||||
未找到想要的账号?
|
||||
<!-- <span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-normal">深度检测兜底</span>-->
|
||||
</h3>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-1">
|
||||
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
|
||||
<span v-else>如果自动检测漏了,您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描。</span>
|
||||
</p>
|
||||
<section v-else class="grid gap-4 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<aside class="space-y-3">
|
||||
<div
|
||||
v-if="detectionResult && !detectionResult.error"
|
||||
class="grid grid-cols-1 gap-2.5 sm:grid-cols-3 lg:grid-cols-3"
|
||||
>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EFFAF3]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">微信版本</p>
|
||||
<p class="mt-1.5 truncate text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.wechat_version || '未知' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">检测账号</p>
|
||||
<p class="mt-1.5 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.total_accounts || 0 }} 个</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#F1FAF4]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">数据库文件</p>
|
||||
<p class="mt-1.5 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.total_databases || 0 }} 个</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handlePickDirectory" :disabled="loading"
|
||||
class="shrink-0 px-4 py-2.5 bg-[#07C160] text-white rounded-xl text-xs font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" 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>
|
||||
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-[#F3F3F3]">
|
||||
<label for="wechatInstallPath" class="block text-[13px] font-medium text-[#000000e6] mb-2">
|
||||
微信安装目录(第一步先填这里)
|
||||
</label>
|
||||
<div class="flex flex-col lg:flex-row gap-3">
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div>
|
||||
<div class="text-[15px] font-medium text-[#000000e6]">手动补充路径</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">自动检测不完整时,可以指定微信数据根目录重新扫描。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customPath" class="mt-3 rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">当前检测路径</p>
|
||||
<p class="mt-1 break-all font-mono text-[12px] leading-5 text-[#000000d9]">{{ customPath }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-[#07C160] px-3 py-2.5 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25 disabled:opacity-50"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
<svg v-if="!loading" class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
<svg v-else class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-dasharray="28 72" pathLength="100" transform="rotate(-90 24 24)"></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '选择 xwechat_files 目录' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<label for="wechatInstallPath" class="block text-[15px] font-medium text-[#000000e6]">
|
||||
微信安装目录
|
||||
</label>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">
|
||||
一键获取数据库密钥会优先使用这里的路径。
|
||||
</p>
|
||||
<input
|
||||
id="wechatInstallPath"
|
||||
v-model="wechatInstallPath"
|
||||
type="text"
|
||||
placeholder="例如: D:\Program Files\Tencent\WeChat 或 D:\Program Files\Tencent\WeChat\Weixin.exe"
|
||||
class="flex-1 px-4 py-2.5 bg-white border border-[#EDEDED] rounded-lg font-mono text-[13px] focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
class="mt-3 w-full rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-3 py-2 font-mono text-[13px] text-[#000000d9] transition focus:border-[#07C160] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
@blur="persistWechatInstallPath"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="pickWechatInstallDirectory"
|
||||
:disabled="isPickingWechatInstallPath"
|
||||
class="shrink-0 px-4 py-2.5 bg-white border border-[#EDEDED] text-[#000000e6] rounded-xl text-xs font-medium hover:bg-gray-50 disabled:opacity-50 disabled:cursor-wait transition-all duration-200"
|
||||
class="mt-2 inline-flex w-full items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-3 py-2 text-sm font-medium text-[#4A4A4A] transition hover:bg-[#F1FAF4] focus:outline-none focus:ring-2 focus:ring-[#07C160]/15 disabled:cursor-wait disabled:opacity-50"
|
||||
@click="pickWechatInstallDirectory"
|
||||
>
|
||||
{{ isPickingWechatInstallPath ? '选择中...' : '选择微信目录' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-2">
|
||||
一键获取数据库密钥会优先使用这里填写的路径。支持填写安装目录,也支持直接填写 <span class="font-mono">Weixin.exe</span> / <span class="font-mono">WeChat.exe</span> 路径。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div :class="loading ? 'flex min-h-[52vh] items-center justify-center' : ''">
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="w-full max-w-3xl rounded-[24px] border border-[#EDEDED] bg-white/92 px-5 py-6 sm:px-8 sm:py-7">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative flex h-12 w-12 items-center justify-center rounded-2xl bg-[#07C160]/10">
|
||||
<span class="absolute inset-0 rounded-2xl border border-[#07C160]/10"></span>
|
||||
<svg class="h-5 w-5 animate-spin text-[#07C160]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" class="opacity-20"></circle>
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-[#07C160]/10 px-2.5 py-1 text-[11px] font-medium text-[#07C160]">
|
||||
检测中
|
||||
</span>
|
||||
<span class="text-[11px] text-[#7F7F7F]">正在自动读取本机微信环境</span>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-3 text-[20px] font-semibold text-[#000000e6] leading-tight">正在检查账号与数据库文件</h3>
|
||||
<p class="mt-2 max-w-[560px] text-[13px] leading-6 text-[#7F7F7F]">
|
||||
会依次确认微信安装信息、最近登录账号以及可用数据库,通常几秒内完成。
|
||||
</p>
|
||||
|
||||
<div class="mt-5 h-1.5 w-full max-w-[620px] overflow-hidden rounded-full bg-[#F3F4F6]">
|
||||
<div class="h-full w-2/5 rounded-full bg-gradient-to-r from-[#07C160] via-[#34D17A] to-[#8CE0AF] animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center justify-center gap-2.5">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>安装信息</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>账号匹配</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>数据库汇总</span>
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<div v-if="detectionResult?.error" class="flex min-h-[340px] flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#D64A4A]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
<span>未找到微信数据</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[28px]">可以手动指定目录重试</h2>
|
||||
<p class="mt-2 text-[14px] leading-6 text-[#9C5F5F]">{{ detectionResult.error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#F4D6D6] bg-[#FFF7F7] p-3 text-[13px] leading-6 text-[#9C5F5F]">
|
||||
请尝试选择微信数据根目录,通常名为 <span class="font-mono">xwechat_files</span>。
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-[#07C160] px-3 py-2.5 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
重新选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- detection result content -->
|
||||
<div v-if="detectionResult && !loading">
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-6">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-8 h-8 text-red-500 mr-3 shrink-0" 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 v-else-if="detectionResult?.data?.accounts && detectionResult.data.accounts.length > 0" class="space-y-3">
|
||||
<div class="flex flex-col gap-2 border-b border-[#DDEBE0] pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-lg font-bold text-red-800">未找到微信数据</p>
|
||||
<p class="text-red-600 mt-1 text-sm">{{ detectionResult.error }}</p>
|
||||
<h2 class="text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">可操作的微信账号</h2>
|
||||
<p class="mt-1 text-[13px] leading-6 text-[#7F7F7F]">点击解密提取,会将该账号信息带入下一步。</p>
|
||||
</div>
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-medium text-[#07C160]">
|
||||
{{ sortedAccounts.length }} 个账号
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功结果 -->
|
||||
<div v-else class="space-y-3">
|
||||
<!-- 账户列表 -->
|
||||
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
|
||||
class="bg-white/92 backdrop-blur rounded-2xl border border-[#EDEDED] overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-[#EDEDED] bg-[#fafafa] flex items-center justify-between">
|
||||
<h3 class="text-[15px] font-semibold text-[#000000e6]">可操作的微信账户</h3>
|
||||
<span class="text-[11px] text-gray-500">点击解密即可提取数据</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[#EDEDED] max-h-[420px] overflow-y-auto">
|
||||
<div v-for="(account, index) in sortedAccounts" :key="index"
|
||||
:class="['px-4 py-3.5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
|
||||
|
||||
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-3 py-1 rounded-bl-xl flex items-center">
|
||||
<span class="text-xs text-[#07C160] font-bold flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" 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>
|
||||
</div>
|
||||
<div class="max-h-[460px] space-y-2.5 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(account, index) in sortedAccounts"
|
||||
:key="index"
|
||||
:class="[
|
||||
'rounded-lg border p-3 transition',
|
||||
isCurrentAccount(account.account_name)
|
||||
? 'border-[#AEE6C4] bg-[#EAF8EF]/86'
|
||||
: 'border-[#E1EFE5] bg-[#F7FCF8]/86 hover:border-[#CFEEDB] hover:bg-[#F1FAF4]'
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
|
||||
<img :src="currentAccountInfo.avatar" class="h-12 w-12 shrink-0 rounded-md border border-[#AEE6C4] bg-white object-cover" alt="" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86">
|
||||
<span class="text-[17px] font-semibold text-[#07C160]">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 mt-1">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
|
||||
<img :src="currentAccountInfo.avatar" class="w-12 h-12 rounded-xl border-2 border-[#07C160]/30 mr-3 object-cover bg-white" alt=""/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
|
||||
<p class="truncate text-[18px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ currentAccountInfo.nickname }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-3">
|
||||
<span class="text-[#07C160] font-bold text-lg">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
<p class="truncate text-[16px] font-semibold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
|
||||
<p class="text-lg font-bold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-0.5 font-mono">wxid: {{ account.account_name }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-[15px] font-semibold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname" class="mt-1 truncate font-mono text-[12px] text-[#7F7F7F]">
|
||||
wxid: {{ account.account_name }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center mt-1.5 space-x-3 text-[12px] text-[#7F7F7F]">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
|
||||
</svg>
|
||||
{{ account.database_count }} 个库文件
|
||||
</span>
|
||||
<span v-if="account.data_dir" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#07C160]" 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-2 text-[12px] text-[#7F7F7F]">
|
||||
<span class="rounded-md border border-[#E1EFE5] bg-[#F4FAF6]/82 px-2 py-1">{{ account.database_count }} 个库文件</span>
|
||||
<span v-if="isCurrentAccount(account.account_name)" class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2 py-1 font-medium text-[#07C160]">最近登录</span>
|
||||
<span v-if="account.data_dir" class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2 py-1 text-[#07C160]">路径已确认</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToDecrypt(account)"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#07C160] text-white rounded-lg font-semibold hover:bg-[#06AD56] hover:-translate-y-0.5 transition-all duration-200 text-xs shrink-0 z-10">
|
||||
解密提取
|
||||
<svg class="w-4 h-4 ml-1" 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="mt-3 pt-2.5 border-t border-dashed border-gray-200 text-sm text-gray-400">
|
||||
<p v-if="account.data_dir" class="font-mono text-[11px] truncate" title="复制路径">
|
||||
📂 {{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-lg bg-[#07C160] px-3 py-2 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="goToDecrypt(account)"
|
||||
>
|
||||
解密提取
|
||||
<svg class="ml-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="account.data_dir" class="mt-3 border-t border-dashed border-[#DDEBE0] pt-2.5">
|
||||
<p class="truncate font-mono text-[12px] text-[#7F7F7F]" :title="account.data_dir">
|
||||
{{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无账户提示 -->
|
||||
<div v-else class="bg-white rounded-2xl p-8 text-center border border-[#EDEDED]">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-base text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
|
||||
<p class="text-sm text-gray-500 mt-2">您可以尝试通过上方的按钮手动指定 "xwechat_files" 文件夹路径。</p>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[340px] flex-col items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 p-5 text-center">
|
||||
<svg class="mb-3 h-10 w-10 text-[#9CA3AF]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-[18px] font-semibold tracking-[-0.03em] text-[#000000e6]">没有发现微信数据</p>
|
||||
<p class="mt-1.5 max-w-md text-[13px] leading-6 text-[#7F7F7F]">可以尝试手动指定 <span class="font-mono">xwechat_files</span> 文件夹后重新检测。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {useApi} from '~/composables/useApi'
|
||||
|
||||
+293
-136
@@ -1,152 +1,249 @@
|
||||
<template>
|
||||
<div class="import-page min-h-screen relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
<div class="import-page relative min-h-screen overflow-hidden px-4 py-5 text-[#000000e6] sm:px-6 sm:py-5">
|
||||
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
|
||||
|
||||
<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">
|
||||
<div class="w-full rounded-[28px] border border-[#EDEDED] bg-white/92 backdrop-blur-sm">
|
||||
<div class="px-5 py-5 sm:px-7 sm:py-7">
|
||||
<div class="mb-5 flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import backup</p>
|
||||
<h1 class="mt-1 text-[24px] font-semibold leading-none text-[#000000e6]">数据导入</h1>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录,确认账号后即可写入当前工具。</p>
|
||||
</div>
|
||||
</div>
|
||||
<main class="relative z-10 mx-auto flex min-h-[calc(100vh-40px)] w-full max-w-6xl flex-col justify-start">
|
||||
<div class="mb-4 flex items-start justify-between gap-4 rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-5 backdrop-blur">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12px] font-medium tracking-[0.16em] text-[#07C160]">导入备份</p>
|
||||
<h1 class="mt-2 text-[30px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[38px]">
|
||||
接入已准备好的本地数据
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-[14px] leading-7 text-[#6B7280]">
|
||||
选择已解密的微信备份目录,先检查账号与文件结构,确认后再导入到本地数据区。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex shrink-0 items-center rounded-lg px-3 py-1.5 text-xs font-medium text-[#07C160] transition-colors hover:bg-[#F3FBF6] hover:text-[#06AD56]"
|
||||
>
|
||||
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex shrink-0 items-center rounded-lg border border-[#CFEEDB] bg-[#F7FDF9]/82 px-2.5 py-1.5 text-xs font-medium text-[#07C160] transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回首页
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-5 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EFFAF3]/82 p-5 backdrop-blur">
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
返回首页
|
||||
</NuxtLink>
|
||||
<span>选择哪个目录</span>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-6 text-[#6B7280]">
|
||||
建议直接选择账号目录。如果是 wxdump 导出,且 output 下只有一个账号,也可以选择 output 根目录。
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-mono text-[#4A4A4A]">wxid_xxxxx/</span>
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-mono text-[#4A4A4A]">databases/</span>
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-mono text-[#4A4A4A]">database/</span>
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-mono text-[#4A4A4A]">media/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4">
|
||||
<div class="flex items-center gap-2 text-[13px] font-semibold text-[#000000d9]">
|
||||
<svg class="h-4 w-4 text-[#07C160]" 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>
|
||||
<span>目录要求</span>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Target</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">请选择 `output / wxid_xxxxx` 这一层目录。</p>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#F1FAF4]/82 p-5 backdrop-blur">
|
||||
<div class="text-[15px] font-medium text-[#000000e6]">导入流程</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<span class="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[#CFEEDB] text-[11px] font-medium text-[#07C160]">1</span>
|
||||
<div>
|
||||
<div class="text-[13px] font-medium text-[#000000d9]">选择目录</div>
|
||||
<p class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">使用系统目录选择器定位备份。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Database</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">目录内需要包含 `databases/`,用于存放 `.db` 文件。</p>
|
||||
<div class="flex gap-3">
|
||||
<span class="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[#CFEEDB] text-[11px] font-medium text-[#07C160]">2</span>
|
||||
<div>
|
||||
<div class="text-[13px] font-medium text-[#000000d9]">预览确认</div>
|
||||
<p class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">先识别账号、数据库和资源文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.08em] text-[#7F7F7F]">Account</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#000000d9]">`account.json` 会作为账号识别与信息校验依据。</p>
|
||||
<div class="flex gap-3">
|
||||
<span class="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[#CFEEDB] text-[11px] font-medium text-[#07C160]">3</span>
|
||||
<div>
|
||||
<div class="text-[13px] font-medium text-[#000000d9]">执行导入</div>
|
||||
<p class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">如果账号已存在,会先备份旧数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!importPreview && !importError && !importing" class="animate-fade-in">
|
||||
<div
|
||||
class="group cursor-pointer rounded-[24px] border border-dashed border-[#D8E5DA] bg-[#FCFDFC] px-6 py-10 text-center transition-colors duration-200 hover:border-[#07C160] hover:bg-white"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-white text-[#07C160] ring-1 ring-[#EDEDED]">
|
||||
<svg class="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 text-[12px] leading-6 text-[#5F6F66] backdrop-blur">
|
||||
<span class="font-medium text-[#2F6F4A]">提示:</span>
|
||||
导入会复制数据到应用的本地数据区,不会直接在原备份目录上修改。
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-5 backdrop-blur">
|
||||
<div v-if="!importPreview && !importError && !importing && !importComplete" class="animate-fade-in">
|
||||
<div class="flex min-h-[340px] flex-col justify-between gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#07C160]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>第一步</span>
|
||||
</div>
|
||||
<h2 class="mt-4 text-[24px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[30px]">选择备份目录</h2>
|
||||
<p class="mt-3 max-w-xl text-[14px] leading-7 text-[#6B7280]">
|
||||
点击下方按钮后,选择已解密的账号目录。系统会先做结构检查,不会立即导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-h-[132px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-[#BFE6CF] bg-[#F3FFF8] px-5 py-6 text-center transition hover:border-[#07C160] hover:bg-[#EFFAF3] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
<svg class="h-8 w-8 text-[#07C160] transition group-hover:-translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-[#000000e6]">选择解密备份目录</h3>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">建议直接选择 `wxid_xxxxx` 层级,减少后续校验失败。</p>
|
||||
<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]">
|
||||
点击开始选择
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-[#A3A3A3]">桌面端优先使用系统目录选择器,异常时会自动回退到手动输入。</p>
|
||||
<span class="mt-4 text-[15px] font-medium text-[#000000e6]">打开目录选择器</span>
|
||||
<span class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">建议选到 wxid_xxxxx 账号层级</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importing" class="animate-fade-in">
|
||||
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-8 sm:px-6">
|
||||
<div class="mx-auto flex w-fit items-center gap-2 rounded-full bg-[#07C160]/10 px-3 py-1 text-xs font-medium text-[#07C160]">
|
||||
<span class="inline-flex h-2 w-2 rounded-full bg-current animate-pulse"></span>
|
||||
正在导入
|
||||
<div class="flex min-h-[340px] flex-col justify-between gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#07C160]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>正在导入</span>
|
||||
</div>
|
||||
<h2 class="mt-4 text-[24px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[30px]">{{ importMessage }}</h2>
|
||||
<p class="mt-3 text-[14px] leading-7 text-[#6B7280]">请保持程序运行,导入完成后可以进入聊天页面查看。</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<p class="text-xl font-semibold text-[#000000e6]">{{ importMessage }}</p>
|
||||
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行,导入完成后会自动进入聊天页面。</p>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EAF8EF]/82 p-4 sm:p-5">
|
||||
<div class="h-2 overflow-hidden rounded-full border border-[#DDF4E7] bg-[#F7FCF8]/86">
|
||||
<div
|
||||
class="h-full rounded-full bg-[#07C160] transition-all duration-500"
|
||||
:style="{ width: `${Math.min(Math.max(importProgress, 0), 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between text-[12px] text-[#7F7F7F]">
|
||||
<span>已连接导入任务</span>
|
||||
<span class="font-medium text-[#07C160]">{{ importProgress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-full bg-[#EDF3EE]">
|
||||
<div
|
||||
class="h-2 rounded-full bg-[#07C160] transition-all duration-500"
|
||||
:style="{ width: `${Math.min(Math.max(importProgress, 0), 100)}%` }"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg border border-[#F0D7D7] bg-[#FFF7F7]/76 px-4 py-3 text-sm font-medium text-[#D64A4A] transition hover:bg-[#FFF7F7] focus:outline-none focus:ring-2 focus:ring-[#D64A4A]/15"
|
||||
@click="cancelImport"
|
||||
>
|
||||
取消导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importComplete && !importing" class="animate-fade-in">
|
||||
<div class="flex min-h-[340px] flex-col justify-between gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#07C160]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>导入完成</span>
|
||||
</div>
|
||||
<h2 class="mt-4 text-[24px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[30px]">数据已就绪</h2>
|
||||
<p class="mt-3 text-[14px] leading-7 text-[#6B7280]">{{ importComplete.message || '账号数据已成功导入。' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between text-xs text-[#7F7F7F]">
|
||||
<span>已连接导入任务</span>
|
||||
<span>{{ importProgress }}%</span>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EAF8EF]/82 p-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<span class="text-[#7F7F7F]">账号</span>
|
||||
<span class="min-w-0 truncate font-mono text-xs text-[#000000d9]">{{ importComplete.account }}</span>
|
||||
</div>
|
||||
<div v-if="importComplete.backup_dir" class="mt-2 flex items-start justify-between gap-2.5 border-t border-[#DDF4E7] pt-2.5">
|
||||
<span class="shrink-0 text-[#7F7F7F]">旧数据备份</span>
|
||||
<span class="min-w-0 break-all text-right text-xs text-[#000000d9]">{{ importComplete.backup_dir }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-4 py-3 text-sm font-medium text-[#4A4A4A] transition hover:bg-[#F1FAF4] focus:outline-none focus:ring-2 focus:ring-[#07C160]/15"
|
||||
@click="retryPickDirectory"
|
||||
>
|
||||
继续导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="navigateTo('/chat')"
|
||||
>
|
||||
进入聊天页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importPreview && !importing" class="animate-fade-in space-y-4">
|
||||
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-5 sm:px-6">
|
||||
<div v-if="importPreview && !importing && !importComplete" class="animate-fade-in space-y-3">
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EAF8EF]/82 p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-[#EDEDED] bg-white">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="Avatar" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-16 w-16 shrink-0 rounded-md border border-[#EDEDED] bg-white object-cover" alt="头像" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Detected account</p>
|
||||
<div class="mt-1 truncate text-xl font-semibold text-[#000000e6]">{{ importPreview.nick || '未命名账号' }}</div>
|
||||
<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]">
|
||||
<span class="truncate">{{ importPreview.username }}</span>
|
||||
</div>
|
||||
<p class="text-[12px] font-medium tracking-[0.12em] text-[#07C160]">检测到的账号</p>
|
||||
<div class="mt-1 truncate text-[24px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ importPreview.nick || '未命名账号' }}</div>
|
||||
<div class="mt-1.5 truncate font-mono text-[12px] text-[#7F7F7F]">{{ importPreview.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex w-fit items-center rounded-full bg-[#07C160]/10 px-3 py-1 text-xs font-medium text-[#07C160]">
|
||||
可导入
|
||||
</div>
|
||||
<span class="inline-flex w-fit rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-medium text-[#07C160]">可导入</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#EDEDED] bg-white px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">Import path</p>
|
||||
<p class="mt-1 break-all text-sm text-[#000000d9]">{{ selectedImportPath }}</p>
|
||||
<div v-if="selectedImportPath" class="mt-4 rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-3 py-3">
|
||||
<p class="text-[12px] font-medium text-[#7F7F7F]">导入路径</p>
|
||||
<p class="mt-1 break-all text-[13px] leading-6 text-[#000000d9]">{{ selectedImportPath }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-white px-3 py-1.5 text-xs text-[#4A4A4A]">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-[#E1EFE5] bg-[#F7FCF8]/86 p-4">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160]"></span>
|
||||
数据库已就绪
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-white px-3 py-1.5 text-xs text-[#4A4A4A]">
|
||||
<span class="h-2 w-2 rounded-full" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-[#C9D2CB]'"></span>
|
||||
资源文件{{ importPreview.has_resource ? '已发现' : '未发现' }}
|
||||
</span>
|
||||
<span>数据库</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">已检测到可导入的数据库文件。</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[#E1EFE5] bg-[#F7FCF8]/86 p-4">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-2 w-2 rounded-full" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-[#C9D2CB]'"></span>
|
||||
<span>资源文件</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[12px] leading-5 text-[#7F7F7F]">{{ importPreview.has_resource ? '已发现图片、视频等资源文件。' : '未发现资源文件,仍可导入数据库。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importPreview.source_overlaps_target" class="rounded-lg border border-[#F4D6D6] bg-[#FFF7F7] p-4 text-[13px] leading-6 text-[#9C5F5F]">
|
||||
导入源目录与目标数据目录相同或相互包含,请重新选择外部备份目录。
|
||||
</div>
|
||||
<div v-else-if="importPreview.target_exists" class="rounded-lg border border-[#CFEEDB] bg-[#F1FAF4] p-4 text-[13px] leading-6 text-[#5F6F66]">
|
||||
该账号在本地已存在。确认导入时会先备份旧目录,再写入新数据。
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)]">
|
||||
<button
|
||||
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]"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-4 py-3 text-sm font-medium text-[#4A4A4A] transition hover:bg-[#F1FAF4] focus:outline-none focus:ring-2 focus:ring-[#07C160]/15"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
重新选择目录
|
||||
重新选择
|
||||
</button>
|
||||
<button
|
||||
:disabled="importing"
|
||||
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]"
|
||||
type="button"
|
||||
:disabled="importing || importPreview?.source_overlaps_target"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25 disabled:cursor-not-allowed disabled:bg-[#8FD9AE]"
|
||||
@click="confirmImport"
|
||||
>
|
||||
确认导入此账号
|
||||
@@ -154,36 +251,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importError && !importing" class="animate-fade-in space-y-4">
|
||||
<div class="rounded-[22px] border border-[#F4D6D6] bg-[#FFF7F7] px-5 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-[#E05A5A] ring-1 ring-[#F0D7D7]">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div v-if="importError && !importing" class="animate-fade-in">
|
||||
<div class="flex min-h-[340px] flex-col justify-between gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#D64A4A]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
<span>导入失败</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-base font-semibold text-[#B64545]">导入失败</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[#9C5F5F]">{{ importError }}</p>
|
||||
</div>
|
||||
<h2 class="mt-4 text-[24px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[30px]">目录暂时无法识别</h2>
|
||||
<p class="mt-2 text-[14px] leading-6 text-[#9C5F5F]">{{ importError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#F1E3E3] bg-white/80 px-3 py-3">
|
||||
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">Current path</p>
|
||||
<p class="mt-1 break-all text-sm text-[#7A4B4B]">{{ selectedImportPath }}</p>
|
||||
<div v-if="selectedImportPath" class="rounded-lg border border-[#F1E3E3] bg-[#FFF7F7] px-2.5 py-2.5">
|
||||
<p class="text-[12px] font-medium text-[#B26B6B]">当前路径</p>
|
||||
<p class="mt-1 break-all text-[13px] leading-6 text-[#7A4B4B]">{{ selectedImportPath }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-4 py-3 text-sm font-medium text-[#4A4A4A] transition hover:bg-[#F1FAF4] focus:outline-none focus:ring-2 focus:ring-[#07C160]/15"
|
||||
@click="retryPickDirectory"
|
||||
>
|
||||
重新选择目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="inline-flex w-full 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]"
|
||||
@click="retryPickDirectory"
|
||||
>
|
||||
重新选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,7 +294,9 @@ const importProgress = ref(0)
|
||||
const importMessage = ref('正在准备...')
|
||||
const importPreview = ref(null)
|
||||
const importError = ref('')
|
||||
const importComplete = ref(null)
|
||||
const selectedImportPath = ref('')
|
||||
const importJobId = ref('')
|
||||
|
||||
let eventSource = null
|
||||
|
||||
@@ -220,23 +319,24 @@ const isDesktopShell = () => {
|
||||
const resetImport = () => {
|
||||
closeEventSource()
|
||||
importPreview.value = null
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
selectedImportPath.value = ''
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
importJobId.value = ''
|
||||
}
|
||||
|
||||
const { importDecryptedPreview, pickSystemDirectory } = useApi()
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
let path = ''
|
||||
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
const res = await window.wechatDesktop.chooseDirectory({
|
||||
title: '请选择解密输出目录 (如: output/wxid_xxxxx)'
|
||||
title: '请选择解密输出目录 (如: output/wxid_xxxxx 或单账号 output)'
|
||||
})
|
||||
if (!res || res.canceled || !res.filePaths?.length) return
|
||||
path = res.filePaths[0]
|
||||
@@ -246,7 +346,7 @@ const handlePickDirectory = async () => {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
|
||||
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (建议选到 wxid_xxx 层级)' })
|
||||
if (!res || !res.path) return
|
||||
path = res.path
|
||||
} catch (e) {
|
||||
@@ -260,7 +360,7 @@ const handlePickDirectory = async () => {
|
||||
const isOk = window.confirm(`你选择的目录为:
|
||||
${path}
|
||||
|
||||
该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
|
||||
该目录似乎不是 "wxid_xxxxx" 账号目录。如果这是 wxdump 的单账号 output 根目录,可以继续。确定要继续吗?`)
|
||||
if (!isOk) return
|
||||
}
|
||||
|
||||
@@ -271,7 +371,7 @@ ${path}
|
||||
try {
|
||||
importPreview.value = await importDecryptedPreview({ import_path: path })
|
||||
} catch (e) {
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases/database 目录;wxdump 格式可不含 account.json'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,16 +380,65 @@ const retryPickDirectory = async () => {
|
||||
await handlePickDirectory()
|
||||
}
|
||||
|
||||
const makeImportJobId = () => {
|
||||
const randomPart = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return `import-${randomPart}`
|
||||
}
|
||||
|
||||
const cancelImport = async () => {
|
||||
const jobId = importJobId.value
|
||||
closeEventSource()
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
|
||||
if (!jobId) return
|
||||
try {
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted/cancel`, window.location.origin)
|
||||
url.searchParams.set('job_id', jobId)
|
||||
await fetch(url.toString(), { method: 'POST' })
|
||||
} catch (e) {
|
||||
console.error('取消导入失败:', e)
|
||||
} finally {
|
||||
importJobId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!selectedImportPath.value) return
|
||||
|
||||
if (importPreview.value?.source_overlaps_target) {
|
||||
importError.value = '导入源目录与目标数据目录相同或相互包含,请重新选择外部备份目录。'
|
||||
return
|
||||
}
|
||||
|
||||
if (importPreview.value?.target_exists) {
|
||||
const ok = window.confirm(`当前账号已存在:${importPreview.value.username}
|
||||
|
||||
继续导入会先自动备份旧目录,然后导入新数据。
|
||||
旧数据库数量:${importPreview.value.existing_db_count || 0}
|
||||
新数据库数量:${importPreview.value.incoming_db_count || 0}
|
||||
|
||||
确定继续吗?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
|
||||
importComplete.value = null
|
||||
importError.value = ''
|
||||
importProgress.value = 0
|
||||
importMessage.value = '启动导入程序...'
|
||||
importJobId.value = makeImportJobId()
|
||||
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
|
||||
url.searchParams.set('import_path', selectedImportPath.value)
|
||||
url.searchParams.set('job_id', importJobId.value)
|
||||
|
||||
closeEventSource()
|
||||
eventSource = new EventSource(url.toString())
|
||||
@@ -304,15 +453,17 @@ const confirmImport = async () => {
|
||||
} else if (data.type === 'complete') {
|
||||
importProgress.value = 100
|
||||
importMessage.value = '导入完成!'
|
||||
closeEventSource()
|
||||
|
||||
setTimeout(async () => {
|
||||
await navigateTo('/chat')
|
||||
}, 1000)
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importComplete.value = data
|
||||
importError.value = ''
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importComplete.value = null
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e)
|
||||
@@ -321,9 +472,15 @@ const confirmImport = async () => {
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('EventSource 错误:', e)
|
||||
if (!importing.value) {
|
||||
closeEventSource()
|
||||
return
|
||||
}
|
||||
importComplete.value = null
|
||||
importError.value = '与服务器连接断开或发生错误'
|
||||
importing.value = false
|
||||
closeEventSource()
|
||||
importJobId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+140
-96
@@ -1,77 +1,146 @@
|
||||
<template>
|
||||
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute top-20 left-20 w-72 h-72 bg-[#07C160] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-40 right-20 w-96 h-96 bg-[#10AEEF] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl"></div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="relative z-10 text-center">
|
||||
<!-- Logo和标题部分 -->
|
||||
<div class="mb-12 animate-fade-in">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<img src="/logo.png" alt="微信解密助手Logo" class="w-48 h-48 object-contain">
|
||||
<div class="landing-page relative h-full min-h-0 overflow-hidden px-4 py-6 text-[#000000e6] sm:px-6 sm:py-8">
|
||||
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
|
||||
|
||||
<main class="relative z-10 mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col justify-center">
|
||||
<section class="space-y-5">
|
||||
<div class="flex flex-col gap-5 rounded-lg border border-[#EDEDED] bg-white/78 p-6 backdrop-blur sm:p-8 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-start gap-4 text-left">
|
||||
<img src="/logo.png" alt="微信解密助手Logo" class="h-16 w-16 shrink-0 object-contain" />
|
||||
<div>
|
||||
<p class="text-[13px] font-medium tracking-[0.16em] text-[#07C160]">本地整理·安心备份</p>
|
||||
<h1 class="mt-3 text-[34px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[46px]">
|
||||
把微信记录留在本地
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-[15px] leading-7 text-[#6B7280]">
|
||||
从检测开始,再查看聊天、导入备份或导出归档。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-[#07C160] px-6 py-3 text-[14px] font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="startDetection"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>开始检测</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl font-bold text-[#000000e6] mb-4">
|
||||
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">微信</span>
|
||||
<span class="text-[#000000e6]">解密助手</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[#7F7F7F] font-normal">轻松解锁你的聊天记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 主要按钮 -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center animate-slide-up">
|
||||
<button @click="startDetection"
|
||||
class="group inline-flex items-center px-12 py-4 bg-[#07C160] text-white rounded-lg text-lg font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 group-hover:rotate-12 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<span>开始检测</span>
|
||||
</button>
|
||||
|
||||
|
||||
<NuxtLink to="/import"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#91D300] border border-[#91D300] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<span>数据导入</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/chat"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/>
|
||||
</svg>
|
||||
<span>聊天预览</span>
|
||||
</NuxtLink>
|
||||
<div class="grid gap-4 lg:grid-cols-[1.25fr_0.75fr]">
|
||||
<button
|
||||
type="button"
|
||||
class="group min-h-[300px] rounded-lg border border-[#CFEEDB] bg-[#F3FFF8] p-6 text-left transition hover:border-[#AEE6C4] hover:bg-[#EFFAF3] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20 sm:p-8"
|
||||
@click="startDetection"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-between gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 text-[#07C160]">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-medium">推荐第一步</span>
|
||||
</div>
|
||||
<h2 class="mt-5 text-[28px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[36px]">检测本机微信数据</h2>
|
||||
<p class="mt-3 max-w-xl text-[14px] leading-7 text-[#6B7280]">
|
||||
找到可用账号、数据库文件和本地路径,后续查看、解密和归档都从这里开始。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/wrapped"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#B37800] border border-[#F2AA00] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2.5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 16v-5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 16v-8" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M16 16v-3" />
|
||||
</svg>
|
||||
<span>年度总结</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-[#DDF4E7] pt-5">
|
||||
<span class="text-[13px] font-medium text-[#07C160]">开始检测</span>
|
||||
<svg class="h-5 w-5 text-[#07C160] transition group-hover:translate-x-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<NuxtLink
|
||||
to="/import"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>导入备份</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">接入已准备好的本地备份目录。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
@click="openExportDialog"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.5 10.5L12 15l4.5-4.5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 19h16" />
|
||||
</svg>
|
||||
<span>导出归档</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">打包当前账号的数据库和资源文件。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
to="/chat"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
<span>回看聊天</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">查看会话、搜索片段,找到需要的内容。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<GlobalExportDialog :open="exportDialogOpen" @close="closeExportDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
const { listChatAccounts } = useApi()
|
||||
const exportDialogOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
@@ -88,47 +157,22 @@ onMounted(async () => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// 开始检测并跳转到结果页面
|
||||
const openExportDialog = () => {
|
||||
exportDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeExportDialog = () => {
|
||||
exportDialogOpen.value = false
|
||||
}
|
||||
|
||||
const startDetection = async () => {
|
||||
// 直接跳转到检测结果页面,让该页面处理检测
|
||||
await navigateTo('/detection-result')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
/* 网格背景 */
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
background-image:
|
||||
linear-gradient(rgba(7, 193, 96, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.7.12"
|
||||
version = "1.7.20"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.7.12"
|
||||
__version__ = "1.7.20"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -28,6 +28,7 @@ from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.import_decrypted import router as _import_decrypted_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.admin import router as _admin_router
|
||||
from .routers.account_archive_export import router as _account_archive_export_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
@@ -65,6 +66,7 @@ async def _log_server_errors(request: Request, call_next):
|
||||
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_account_archive_export_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
app.include_router(_import_decrypted_router)
|
||||
app.include_router(_decrypt_router)
|
||||
|
||||
@@ -3051,14 +3051,26 @@ def _try_find_decrypted_resource(account_dir: Path, md5: str) -> Optional[Path]:
|
||||
if not resource_dir.exists():
|
||||
return None
|
||||
sub_dir = md5[:2] if len(md5) >= 2 else "00"
|
||||
|
||||
# Prefer the standard layout: resource/{md5-prefix}/{md5}.{ext}
|
||||
target_dir = resource_dir / sub_dir
|
||||
if not target_dir.exists():
|
||||
return None
|
||||
# 查找匹配MD5的文件(可能有不同扩展名)
|
||||
for ext in ["jpg", "png", "gif", "webp", "mp4", "dat"]:
|
||||
p = target_dir / f"{md5}.{ext}"
|
||||
if p.exists():
|
||||
return p
|
||||
search_dirs = [target_dir]
|
||||
|
||||
# Support wxdump flat media layout after it is imported as resource.
|
||||
# Typical files: resource/{md5}.jpg, resource/{md5}_t.jpg, or resource/{md5}.wxgf.
|
||||
if resource_dir not in search_dirs:
|
||||
search_dirs.append(resource_dir)
|
||||
|
||||
exts = ["jpg", "png", "gif", "webp", "mp4", "dat", "wxgf", "wxgf.jpg"]
|
||||
suffixes = ["", "_t", "_b", "_h"]
|
||||
for directory in search_dirs:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for suffix in suffixes:
|
||||
for ext in exts:
|
||||
candidate = directory / f"{md5}{suffix}.{ext}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..chat_helpers import _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
class AccountArchiveExportRequest(BaseModel):
|
||||
account: Optional[str] = Field(None, description="Account directory name. Defaults to the first available account.")
|
||||
output_dir: Optional[str] = Field(None, description="Absolute output directory. Defaults to output/exports/{account}.")
|
||||
include_databases: bool = Field(True, description="Whether to include decrypted database files.")
|
||||
include_resources: bool = Field(True, description="Whether to include resource folders.")
|
||||
file_name: Optional[str] = Field(None, description="Optional zip file name, with or without .zip.")
|
||||
|
||||
|
||||
class AccountArchiveCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountArchiveFile:
|
||||
path: Path
|
||||
arcname: str
|
||||
kind: str
|
||||
size: int
|
||||
mtime: float
|
||||
mode: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountArchiveExportJob:
|
||||
export_id: str
|
||||
account: str = ""
|
||||
status: str = "queued"
|
||||
progress: int = 0
|
||||
message: str = "Waiting to start..."
|
||||
detail: str = ""
|
||||
error: str = ""
|
||||
zip_path: str = ""
|
||||
file_name: str = ""
|
||||
database_count: int = 0
|
||||
resource_file_count: int = 0
|
||||
total_bytes: int = 0
|
||||
processed_bytes: int = 0
|
||||
created_at: int = field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = field(default_factory=lambda: int(time.time()))
|
||||
cancel_requested: bool = False
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"exportId": self.export_id,
|
||||
"account": self.account,
|
||||
"status": self.status,
|
||||
"progress": max(0, min(100, int(self.progress or 0))),
|
||||
"message": self.message,
|
||||
"detail": self.detail,
|
||||
"error": self.error,
|
||||
"zipPath": self.zip_path,
|
||||
"fileName": self.file_name,
|
||||
"databaseCount": int(self.database_count or 0),
|
||||
"resourceFileCount": int(self.resource_file_count or 0),
|
||||
"totalBytes": int(self.total_bytes or 0),
|
||||
"processedBytes": int(self.processed_bytes or 0),
|
||||
"createdAt": int(self.created_at or 0),
|
||||
"updatedAt": int(self.updated_at or 0),
|
||||
"cancelRequested": bool(self.cancel_requested),
|
||||
}
|
||||
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^0-9A-Za-z._-]+")
|
||||
# 账号归档以账号目录为边界。数据库通常在账号目录顶层,资源文件通常在子目录中。
|
||||
_DB_SUFFIXES = {".db", ".sqlite", ".sqlite3", ".db3"}
|
||||
_META_FILE_NAMES = {"_source.json", "_media_keys.json", "_sns_realtime_sync_state.json"}
|
||||
_JOBS: dict[str, AccountArchiveExportJob] = {}
|
||||
_JOBS_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _safe_file_name(value: object, fallback: str) -> str:
|
||||
text = str(value or "").strip().replace("\\", "/").split("/")[-1]
|
||||
text = _SAFE_NAME_RE.sub("_", text).strip("._-")
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _normalize_zip_name(value: object, fallback: str) -> str:
|
||||
name = _safe_file_name(value, fallback)
|
||||
if not name.lower().endswith(".zip"):
|
||||
name += ".zip"
|
||||
return name
|
||||
|
||||
|
||||
def _resolve_output_dir(account_dir: Path, output_dir_raw: object) -> Path:
|
||||
raw = str(output_dir_raw or "").strip()
|
||||
if raw:
|
||||
return Path(raw).expanduser().resolve()
|
||||
return (account_dir.parents[1] / "exports" / account_dir.name).resolve()
|
||||
|
||||
|
||||
def _iter_database_files(account_dir: Path) -> list[Path]:
|
||||
return sorted(
|
||||
(
|
||||
item
|
||||
for item in account_dir.iterdir()
|
||||
if item.is_file()
|
||||
and (
|
||||
item.suffix.lower() in _DB_SUFFIXES
|
||||
or item.name in _META_FILE_NAMES
|
||||
)
|
||||
),
|
||||
key=lambda p: p.name.lower(),
|
||||
)
|
||||
|
||||
|
||||
def _get_job(export_id: str) -> Optional[AccountArchiveExportJob]:
|
||||
key = str(export_id or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
with _JOBS_LOCK:
|
||||
return _JOBS.get(key)
|
||||
|
||||
|
||||
def _update_job(export_id: str, **changes: Any) -> Optional[AccountArchiveExportJob]:
|
||||
with _JOBS_LOCK:
|
||||
job = _JOBS.get(str(export_id or "").strip())
|
||||
if not job:
|
||||
return None
|
||||
for key, value in changes.items():
|
||||
if hasattr(job, key):
|
||||
setattr(job, key, value)
|
||||
job.updated_at = int(time.time())
|
||||
return job
|
||||
|
||||
|
||||
def _check_cancel(job: AccountArchiveExportJob, tmp_path: Optional[Path] = None) -> None:
|
||||
with _JOBS_LOCK:
|
||||
cancelled = bool(job.cancel_requested)
|
||||
if not cancelled:
|
||||
return
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise AccountArchiveCancelled()
|
||||
|
||||
|
||||
def _add_file(zip_file: zipfile.ZipFile, item: AccountArchiveFile) -> Optional[int]:
|
||||
try:
|
||||
modified = time.localtime(item.mtime)[:6]
|
||||
if modified[0] < 1980:
|
||||
modified = (1980, 1, 1, 0, 0, 0)
|
||||
info = zipfile.ZipInfo(item.arcname, modified)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.file_size = item.size
|
||||
info.external_attr = (item.mode & 0xFFFF) << 16
|
||||
with item.path.open("rb") as source, zip_file.open(info, "w", force_zip64=True) as target:
|
||||
shutil.copyfileobj(source, target, length=1024 * 1024)
|
||||
return int(item.size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_database_or_meta_file(path: Path) -> bool:
|
||||
return path.is_file() and (path.suffix.lower() in _DB_SUFFIXES or path.name in _META_FILE_NAMES)
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(parent)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _iter_selected_account_files(
|
||||
*,
|
||||
job: AccountArchiveExportJob,
|
||||
account_dir: Path,
|
||||
include_databases: bool,
|
||||
include_resources: bool,
|
||||
tmp_path: Optional[Path],
|
||||
zip_path: Optional[Path],
|
||||
output_dir: Optional[Path],
|
||||
):
|
||||
"""Fast metadata scan for selected account files.
|
||||
|
||||
Folder size is not stored as one reliable value by the filesystem. To show an
|
||||
accurate total before packing, we still have to enumerate files, but os.scandir
|
||||
reuses directory-entry metadata and avoids the heavier Path/os.walk/resolve path.
|
||||
"""
|
||||
|
||||
account_prefix = _safe_file_name(account_dir.name, "account")
|
||||
pack_whole_account_folder = include_databases and include_resources
|
||||
account_dir_str = os.path.abspath(os.fspath(account_dir))
|
||||
excluded_files = set()
|
||||
for candidate in (tmp_path, zip_path):
|
||||
if candidate is None:
|
||||
continue
|
||||
try:
|
||||
excluded_files.add(os.path.normcase(os.path.abspath(os.fspath(candidate))))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
skipped_output_dir: Optional[str] = None
|
||||
if output_dir is not None:
|
||||
try:
|
||||
output_dir_str = os.path.abspath(os.fspath(output_dir))
|
||||
# 如果用户把导出目录选在账号目录内部,避免把正在生成的导出文件再次打包进去。
|
||||
if output_dir_str != account_dir_str and os.path.commonpath([account_dir_str, output_dir_str]) == account_dir_str:
|
||||
skipped_output_dir = os.path.normcase(output_dir_str)
|
||||
except (OSError, ValueError):
|
||||
skipped_output_dir = None
|
||||
|
||||
stack: list[tuple[str, bool]] = [(account_dir_str, True)]
|
||||
while stack:
|
||||
root, is_account_root = stack.pop()
|
||||
_check_cancel(job, tmp_path)
|
||||
normalized_root = os.path.normcase(os.path.abspath(root))
|
||||
if skipped_output_dir is not None and normalized_root == skipped_output_dir:
|
||||
continue
|
||||
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entry_list = list(entries)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entry_list:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if is_account_root and not pack_whole_account_folder and not include_resources:
|
||||
continue
|
||||
stack.append((entry.path, False))
|
||||
continue
|
||||
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
|
||||
file_path_str = entry.path
|
||||
if os.path.normcase(os.path.abspath(file_path_str)) in excluded_files:
|
||||
continue
|
||||
|
||||
name = entry.name
|
||||
suffix = os.path.splitext(name)[1].lower()
|
||||
is_top_level_database = is_account_root and (suffix in _DB_SUFFIXES or name in _META_FILE_NAMES)
|
||||
if pack_whole_account_folder:
|
||||
kind = "database" if is_top_level_database else "resource"
|
||||
elif include_databases and is_top_level_database:
|
||||
kind = "database"
|
||||
elif include_resources and not is_account_root:
|
||||
kind = "resource"
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
continue
|
||||
|
||||
try:
|
||||
rel = os.path.relpath(file_path_str, account_dir_str).replace(os.sep, "/")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
yield AccountArchiveFile(
|
||||
path=Path(file_path_str),
|
||||
arcname=f"{account_prefix}/{rel}",
|
||||
kind=kind,
|
||||
size=int(st.st_size),
|
||||
mtime=float(st.st_mtime),
|
||||
mode=int(st.st_mode),
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def _run_account_archive_export(export_id: str, payload: dict[str, Any]) -> None:
|
||||
job = _update_job(export_id, status="running", progress=1, message="Preparing export...", detail="")
|
||||
if not job:
|
||||
return
|
||||
|
||||
zip_path: Optional[Path] = None
|
||||
tmp_path: Optional[Path] = None
|
||||
|
||||
try:
|
||||
include_databases = bool(payload.get("include_databases"))
|
||||
include_resources = bool(payload.get("include_resources"))
|
||||
if not include_databases and not include_resources:
|
||||
raise ValueError("Please select at least one export option.")
|
||||
|
||||
_check_cancel(job)
|
||||
account_dir = _resolve_account_dir(payload.get("account"))
|
||||
account_name = account_dir.name
|
||||
output_dir = _resolve_output_dir(account_dir, payload.get("output_dir"))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
fallback_name = f"wechat_archive_{_safe_file_name(account_name, 'account')}_{stamp}.zip"
|
||||
zip_name = _normalize_zip_name(payload.get("file_name"), fallback_name)
|
||||
zip_path = (output_dir / zip_name).resolve()
|
||||
tmp_path = zip_path.with_suffix(zip_path.suffix + ".tmp")
|
||||
|
||||
_update_job(
|
||||
export_id,
|
||||
account=account_name,
|
||||
file_name=zip_name,
|
||||
zip_path=str(zip_path),
|
||||
progress=1,
|
||||
message="Scanning export content...",
|
||||
detail="Calculating total archive size.",
|
||||
total_bytes=0,
|
||||
processed_bytes=0,
|
||||
)
|
||||
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
selected_files = list(_iter_selected_account_files(
|
||||
job=job,
|
||||
account_dir=account_dir,
|
||||
include_databases=include_databases,
|
||||
include_resources=include_resources,
|
||||
tmp_path=tmp_path,
|
||||
zip_path=zip_path,
|
||||
output_dir=output_dir,
|
||||
))
|
||||
if not selected_files:
|
||||
raise FileNotFoundError("No exportable files found for this account.")
|
||||
|
||||
planned_db_count = sum(1 for item in selected_files if item.kind == "database")
|
||||
planned_resource_count = sum(1 for item in selected_files if item.kind != "database")
|
||||
total_files = len(selected_files)
|
||||
total_bytes = sum(max(0, int(item.size or 0)) for item in selected_files)
|
||||
if include_databases and not include_resources and planned_db_count <= 0:
|
||||
raise FileNotFoundError("No database files found for this account.")
|
||||
if include_resources and not include_databases and planned_resource_count <= 0:
|
||||
raise FileNotFoundError("No resource files found for this account.")
|
||||
|
||||
_update_job(
|
||||
export_id,
|
||||
progress=5,
|
||||
database_count=planned_db_count,
|
||||
resource_file_count=planned_resource_count,
|
||||
total_bytes=total_bytes,
|
||||
processed_bytes=0,
|
||||
message="Writing ZIP archive...",
|
||||
detail=f"Ready to pack {total_files} files ({total_bytes / 1024 / 1024:.1f} MB).",
|
||||
)
|
||||
|
||||
db_count = 0
|
||||
resource_file_count = 0
|
||||
processed_bytes = 0
|
||||
processed = 0
|
||||
last_progress_at = time.monotonic()
|
||||
|
||||
# Use ZIP_STORED intentionally: account archives are mostly SQLite,
|
||||
# images, videos and cache files. Re-compressing them is CPU-heavy and
|
||||
# often saves little space. This makes archive export behave like a fast
|
||||
# folder pack/copy operation.
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for item in selected_files:
|
||||
_check_cancel(job, tmp_path)
|
||||
added_size = _add_file(zf, item)
|
||||
if added_size is not None:
|
||||
processed += 1
|
||||
if item.kind == "database":
|
||||
db_count += 1
|
||||
else:
|
||||
resource_file_count += 1
|
||||
processed_bytes += added_size
|
||||
|
||||
now = time.monotonic()
|
||||
if processed <= 5 or processed % 20 == 0 or (now - last_progress_at) >= 0.5:
|
||||
last_progress_at = now
|
||||
if total_bytes > 0:
|
||||
progress = min(95, 5 + int((processed_bytes / total_bytes) * 90))
|
||||
else:
|
||||
progress = min(95, 5 + int((processed / max(1, total_files)) * 90))
|
||||
_update_job(
|
||||
export_id,
|
||||
progress=progress,
|
||||
database_count=db_count,
|
||||
resource_file_count=resource_file_count,
|
||||
total_bytes=total_bytes,
|
||||
processed_bytes=processed_bytes,
|
||||
message="Writing ZIP archive...",
|
||||
detail=(
|
||||
f"Packed {processed}/{total_files} files "
|
||||
f"({processed_bytes / 1024 / 1024:.1f}/{total_bytes / 1024 / 1024:.1f} MB)."
|
||||
),
|
||||
)
|
||||
|
||||
_check_cancel(job, tmp_path)
|
||||
_update_job(export_id, progress=97, message="Finalizing ZIP archive...", detail="Moving archive to target folder.")
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
shutil.move(str(tmp_path), str(zip_path))
|
||||
|
||||
_update_job(
|
||||
export_id,
|
||||
status="done",
|
||||
progress=100,
|
||||
message="Export completed.",
|
||||
detail=f"Exported {db_count} database files and {resource_file_count} resource files.",
|
||||
database_count=db_count,
|
||||
resource_file_count=resource_file_count,
|
||||
total_bytes=total_bytes,
|
||||
processed_bytes=processed_bytes,
|
||||
zip_path=str(zip_path),
|
||||
file_name=zip_path.name,
|
||||
)
|
||||
except AccountArchiveCancelled:
|
||||
try:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
_update_job(export_id, status="cancelled", message="Export cancelled.", detail="Temporary archive has been removed.")
|
||||
except Exception as exc:
|
||||
try:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
_update_job(export_id, status="error", error=str(exc), message="Export failed.", detail="")
|
||||
|
||||
|
||||
@router.post("/api/account/archive_export", summary="Create account archive export job")
|
||||
async def export_account_archive(req: AccountArchiveExportRequest):
|
||||
if not req.include_databases and not req.include_resources:
|
||||
raise HTTPException(status_code=400, detail="Please select at least one export option.")
|
||||
|
||||
payload = {
|
||||
"account": req.account,
|
||||
"output_dir": req.output_dir,
|
||||
"include_databases": bool(req.include_databases),
|
||||
"include_resources": bool(req.include_resources),
|
||||
"file_name": req.file_name,
|
||||
}
|
||||
export_id = uuid.uuid4().hex
|
||||
job = AccountArchiveExportJob(export_id=export_id)
|
||||
with _JOBS_LOCK:
|
||||
_JOBS[export_id] = job
|
||||
|
||||
thread = threading.Thread(target=_run_account_archive_export, args=(export_id, payload), daemon=True)
|
||||
thread.start()
|
||||
return {"status": "success", "job": job.to_public_dict()}
|
||||
|
||||
|
||||
@router.get("/api/account/archive_export/download", summary="Download account archive by file path")
|
||||
async def download_account_archive(path: str):
|
||||
zip_path = Path(str(path or "").strip()).expanduser().resolve()
|
||||
if not zip_path.exists() or not zip_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Export file not found.")
|
||||
if zip_path.suffix.lower() != ".zip":
|
||||
raise HTTPException(status_code=400, detail="Invalid export file.")
|
||||
return FileResponse(str(zip_path), media_type="application/zip", filename=zip_path.name)
|
||||
|
||||
|
||||
@router.get("/api/account/archive_export/{export_id}", summary="Get account archive export job")
|
||||
async def get_account_archive_export(export_id: str):
|
||||
job = _get_job(export_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Export not found.")
|
||||
return {"status": "success", "job": job.to_public_dict()}
|
||||
|
||||
|
||||
@router.delete("/api/account/archive_export/{export_id}", summary="Cancel account archive export job")
|
||||
async def cancel_account_archive_export(export_id: str):
|
||||
job = _get_job(export_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Export not found.")
|
||||
|
||||
with _JOBS_LOCK:
|
||||
if job.status in {"done", "error", "cancelled"}:
|
||||
return {"status": "success", "job": job.to_public_dict()}
|
||||
job.cancel_requested = True
|
||||
job.message = "Cancelling export..."
|
||||
job.detail = "Waiting for the current file operation to stop."
|
||||
job.updated_at = int(time.time())
|
||||
|
||||
return {"status": "success", "job": job.to_public_dict()}
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import sqlite3
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
@@ -20,6 +22,12 @@ logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
_IMPORT_CANCEL_EVENTS: dict[str, asyncio.Event] = {}
|
||||
|
||||
|
||||
class ImportCancelled(Exception):
|
||||
pass
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
|
||||
@@ -33,46 +41,180 @@ def _is_valid_sqlite(path: Path) -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
"""
|
||||
验证导入目录结构:
|
||||
- databases/ (必须包含 contact.db, session.db)
|
||||
- resource/ (可选)
|
||||
- account.json (必须包含 username, nick)
|
||||
"""
|
||||
db_dir = import_path / "databases"
|
||||
account_json_path = import_path / "account.json"
|
||||
|
||||
if not db_dir.exists() or not db_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="未找到 databases 目录")
|
||||
|
||||
if not account_json_path.exists():
|
||||
raise HTTPException(status_code=400, detail="未找到 account.json 文件")
|
||||
|
||||
# 验证关键数据库
|
||||
required_dbs = ["contact.db", "session.db"]
|
||||
for db_name in required_dbs:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"databases 目录中未找到有效的 {db_name}")
|
||||
|
||||
# 解析 account.json
|
||||
def _clean_profile_text(value: object) -> str:
|
||||
text = str(value or "").replace("\u3164", "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def _pick_import_account_dir(import_path: Path) -> Path:
|
||||
"""Resolve the actual account directory; supports selecting output root or wxid_xxx."""
|
||||
if (import_path / "databases").is_dir() or (import_path / "database").is_dir():
|
||||
return import_path
|
||||
account_dirs: list[Path] = []
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
for child in import_path.iterdir():
|
||||
if child.is_dir() and ((child / "databases").is_dir() or (child / "database").is_dir()):
|
||||
account_dirs.append(child)
|
||||
except Exception:
|
||||
account_dirs = []
|
||||
if len(account_dirs) == 1:
|
||||
return account_dirs[0]
|
||||
if len(account_dirs) > 1:
|
||||
names = ", ".join(p.name for p in account_dirs[:5])
|
||||
raise HTTPException(status_code=400, detail=f"Multiple account directories found. Please select one account directory: {names}")
|
||||
return import_path
|
||||
|
||||
|
||||
def _pick_database_dir(account_dir: Path) -> Path:
|
||||
"""Support both this app's databases/ and wxdump's database/ directory names."""
|
||||
for name in ("databases", "database"):
|
||||
db_dir = account_dir / name
|
||||
if db_dir.exists() and db_dir.is_dir():
|
||||
return db_dir
|
||||
raise HTTPException(status_code=400, detail="Missing databases or database directory")
|
||||
|
||||
|
||||
def _pick_resource_dir(account_dir: Path) -> Optional[Path]:
|
||||
"""Support both this app's resource/ and wxdump's media/ directory names."""
|
||||
for name in ("resource", "media"):
|
||||
resource_dir = account_dir / name
|
||||
if resource_dir.exists() and resource_dir.is_dir():
|
||||
return resource_dir
|
||||
return None
|
||||
|
||||
|
||||
def _read_contact_profile(db_dir: Path, username: str) -> dict:
|
||||
"""Best-effort account profile inference from contact.db."""
|
||||
contact_db = db_dir / "contact.db"
|
||||
if not _is_valid_sqlite(contact_db):
|
||||
return {}
|
||||
try:
|
||||
conn = sqlite3.connect(str(contact_db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
row = conn.execute("""
|
||||
SELECT username, remark, nick_name, alias, big_head_url, small_head_url
|
||||
FROM contact
|
||||
WHERE username = ?
|
||||
LIMIT 1
|
||||
""", (username,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return {}
|
||||
nick = _clean_profile_text(row["nick_name"]) or _clean_profile_text(row["remark"]) or _clean_profile_text(row["alias"]) or username
|
||||
return {"username": _clean_profile_text(row["username"]) or username, "nick": nick, "avatar_url": str(row["big_head_url"] or row["small_head_url"] or "").strip(), "alias": _clean_profile_text(row["alias"])}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"解析 account.json 失败: {e}")
|
||||
|
||||
username = account_info.get("username")
|
||||
nick = account_info.get("nick")
|
||||
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json 中缺少 username 或 nick")
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"nick": nick,
|
||||
"avatar_url": account_info.get("avatar_url", ""),
|
||||
"has_resource": (import_path / "resource").exists()
|
||||
}
|
||||
logger.warning(f"Failed to read account profile from contact.db: {contact_db}, {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _load_or_infer_account_info(account_dir: Path, db_dir: Path) -> tuple[dict, Optional[Path], bool]:
|
||||
"""Read account.json; if missing in wxdump output, infer from folder name and contact.db."""
|
||||
account_json_path = account_dir / "account.json"
|
||||
if account_json_path.exists():
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse account.json: {e}")
|
||||
username = _clean_profile_text(account_info.get("username"))
|
||||
nick = _clean_profile_text(account_info.get("nick") or account_info.get("nickname"))
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json is missing username or nick")
|
||||
account_info["username"] = username
|
||||
account_info["nick"] = nick
|
||||
account_info.setdefault("avatar_url", "")
|
||||
return account_info, account_json_path, False
|
||||
inferred_username = _clean_profile_text(account_dir.name)
|
||||
if not inferred_username:
|
||||
raise HTTPException(status_code=400, detail="Missing account.json and cannot infer account from directory name")
|
||||
profile = _read_contact_profile(db_dir, inferred_username)
|
||||
username = _clean_profile_text(profile.get("username")) or inferred_username
|
||||
nick = _clean_profile_text(profile.get("nick")) or _clean_profile_text(profile.get("alias")) or username
|
||||
return {"username": username, "nick": nick, "avatar_url": str(profile.get("avatar_url") or ""), "alias": str(profile.get("alias") or "")}, None, True
|
||||
|
||||
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
account_dir = _pick_import_account_dir(import_path)
|
||||
db_dir = _pick_database_dir(account_dir)
|
||||
resource_dir = _pick_resource_dir(account_dir)
|
||||
for db_name in ["contact.db", "session.db"]:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"Missing valid {db_name} in {db_dir.name}")
|
||||
account_info, account_json_path, inferred_account = _load_or_infer_account_info(account_dir, db_dir)
|
||||
return {"username": account_info["username"], "nick": account_info["nick"], "avatar_url": account_info.get("avatar_url", ""), "alias": account_info.get("alias", ""), "has_resource": resource_dir is not None, "source_format": "wxdump" if db_dir.name == "database" or inferred_account else "wechat_data_analysis", "inferred_account": inferred_account, "account_dir": str(account_dir), "db_dir": str(db_dir), "resource_dir": str(resource_dir) if resource_dir else "", "account_json_path": str(account_json_path) if account_json_path else ""}
|
||||
|
||||
|
||||
def _count_db_files(db_dir: Path) -> int:
|
||||
try:
|
||||
return sum(1 for f in db_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_dir_nonempty(path: Path) -> bool:
|
||||
try:
|
||||
return path.exists() and path.is_dir() and any(path.iterdir())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _paths_overlap(a: Path, b: Path) -> bool:
|
||||
try:
|
||||
ar = a.resolve()
|
||||
br = b.resolve()
|
||||
except Exception:
|
||||
ar = a.absolute()
|
||||
br = b.absolute()
|
||||
return ar == br or ar in br.parents or br in ar.parents
|
||||
|
||||
|
||||
def _build_target_state(info: dict) -> dict:
|
||||
output_base = get_output_databases_dir()
|
||||
account_name = str(info.get("username") or "").strip()
|
||||
target_dir = output_base / account_name if account_name else output_base
|
||||
resource_dir = target_dir / "resource"
|
||||
db_files: list[str] = []
|
||||
try:
|
||||
if target_dir.exists() and target_dir.is_dir():
|
||||
db_files = sorted(f.name for f in target_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
|
||||
except Exception:
|
||||
db_files = []
|
||||
paths = [Path(str(info.get("account_dir") or "")), Path(str(info.get("db_dir") or ""))]
|
||||
if info.get("resource_dir"):
|
||||
paths.append(Path(str(info.get("resource_dir"))))
|
||||
return {"target_dir": str(target_dir), "target_exists": target_dir.exists(), "target_nonempty": _is_dir_nonempty(target_dir), "existing_db_count": len(db_files), "existing_db_files": db_files[:50], "incoming_db_count": _count_db_files(Path(str(info.get("db_dir") or ""))), "target_has_resource": resource_dir.exists(), "will_replace_resource": bool(resource_dir.exists() and info.get("resource_dir")), "source_overlaps_target": any(_paths_overlap(x, target_dir) for x in paths if str(x))}
|
||||
|
||||
|
||||
def _next_backup_dir(account_output_dir: Path) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
base = account_output_dir.with_name(f"{account_output_dir.name}.backup-{stamp}")
|
||||
candidate = base
|
||||
i = 1
|
||||
while candidate.exists():
|
||||
candidate = account_output_dir.with_name(f"{base.name}-{i}")
|
||||
i += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _backup_existing_account_dir(account_output_dir: Path) -> Optional[Path]:
|
||||
if not account_output_dir.exists():
|
||||
return None
|
||||
backup_dir = _next_backup_dir(account_output_dir)
|
||||
shutil.move(str(account_output_dir), str(backup_dir))
|
||||
return backup_dir
|
||||
|
||||
|
||||
def _rollback_account_backup(account_output_dir: Path, backup_dir: Optional[Path]) -> None:
|
||||
if not backup_dir or not backup_dir.exists():
|
||||
return
|
||||
if account_output_dir.exists():
|
||||
if account_output_dir.is_symlink() or account_output_dir.is_file():
|
||||
account_output_dir.unlink()
|
||||
else:
|
||||
shutil.rmtree(account_output_dir)
|
||||
shutil.move(str(backup_dir), str(account_output_dir))
|
||||
|
||||
|
||||
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
|
||||
async def preview_import(request: ImportRequest):
|
||||
@@ -80,18 +222,42 @@ async def preview_import(request: ImportRequest):
|
||||
if not import_path.exists() or not import_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
|
||||
|
||||
return _validate_import_structure(import_path)
|
||||
info = _validate_import_structure(import_path)
|
||||
info.update(_build_target_state(info))
|
||||
return info
|
||||
|
||||
@router.post("/api/import_decrypted/cancel", summary="取消正在执行的导入任务")
|
||||
async def cancel_import_decrypted(job_id: str = Query(..., description="导入任务 ID")):
|
||||
cancel_event = _IMPORT_CANCEL_EVENTS.get(str(job_id or ""))
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return {"status": "cancel_requested"}
|
||||
return {"status": "not_found"}
|
||||
|
||||
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
|
||||
async def import_decrypted_directory(
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径"),
|
||||
job_id: str = Query("", description="导入任务 ID,用于取消导入")
|
||||
):
|
||||
import_path_obj = Path(import_path.strip())
|
||||
account_output_dir: Optional[Path] = None
|
||||
backup_dir: Optional[Path] = None
|
||||
backup_restored = False
|
||||
job_key = str(job_id or "").strip()
|
||||
cancel_event: Optional[asyncio.Event] = None
|
||||
if job_key:
|
||||
cancel_event = _IMPORT_CANCEL_EVENTS.setdefault(job_key, asyncio.Event())
|
||||
cancel_event.clear()
|
||||
|
||||
def _sse(data: dict):
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
def _check_cancel():
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise ImportCancelled("用户已取消导入")
|
||||
|
||||
async def generate_progress():
|
||||
nonlocal account_output_dir, backup_dir, backup_restored
|
||||
try:
|
||||
if not import_path_obj.exists() or not import_path_obj.is_dir():
|
||||
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
|
||||
@@ -108,22 +274,34 @@ async def import_decrypted_directory(
|
||||
yield _sse({"type": "error", "message": f"验证失败: {e}"})
|
||||
return
|
||||
|
||||
_check_cancel()
|
||||
info.update(_build_target_state(info))
|
||||
if info.get("source_overlaps_target"):
|
||||
yield _sse({"type": "error", "message": "导入源目录与目标数据目录相同或相互包含,请选择外部备份目录。"})
|
||||
return
|
||||
|
||||
account_name = info["username"]
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功:{account_name}"})
|
||||
|
||||
# 2. 准备输出目录
|
||||
# 2. 准备目标目录;如果已有账号数据,先整体备份再替换。
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
if account_output_dir.exists():
|
||||
yield _sse({"type": "progress", "percent": 12, "message": "检测到已有账号数据,正在创建备份..."})
|
||||
backup_dir = await asyncio.to_thread(_backup_existing_account_dir, account_output_dir)
|
||||
if backup_dir:
|
||||
yield _sse({"type": "progress", "percent": 14, "message": f"已创建备份:{backup_dir.name}"})
|
||||
await asyncio.to_thread(account_output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
yield _sse({"type": "progress", "percent": 15, "message": "正在准备目标目录..."})
|
||||
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path_obj / "databases"
|
||||
db_src_dir = Path(info["db_dir"])
|
||||
db_files = [f for f in db_src_dir.iterdir() if f.is_file() and f.suffix == ".db"]
|
||||
imported_files = []
|
||||
|
||||
for i, item in enumerate(db_files):
|
||||
_check_cancel()
|
||||
target = account_output_dir / item.name
|
||||
def _do_import_db(src, dst):
|
||||
if dst.exists():
|
||||
@@ -143,24 +321,79 @@ async def import_decrypted_directory(
|
||||
yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"})
|
||||
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path_obj / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
resource_src = Path(info["resource_dir"]) if info.get("resource_dir") else None
|
||||
if resource_src and resource_src.exists() and resource_src.is_dir():
|
||||
yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."})
|
||||
resource_dst = account_output_dir / "resource"
|
||||
|
||||
def _do_import_resource(src, dst):
|
||||
def _reset_resource_dst(dst: Path) -> None:
|
||||
if dst.exists():
|
||||
if dst.is_symlink() or dst.is_file():
|
||||
dst.unlink()
|
||||
else:
|
||||
shutil.rmtree(dst)
|
||||
|
||||
def _try_link_resource(src: Path, dst: Path) -> bool:
|
||||
try:
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
return True
|
||||
except Exception:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
return False
|
||||
|
||||
def _collect_resource_files(src: Path) -> list[tuple[Path, Path]]:
|
||||
files: list[tuple[Path, Path]] = []
|
||||
for root, _, names in os.walk(src):
|
||||
root_path = Path(root)
|
||||
for name in names:
|
||||
file_path = root_path / name
|
||||
try:
|
||||
if file_path.is_file():
|
||||
files.append((file_path, file_path.relative_to(src)))
|
||||
except Exception:
|
||||
continue
|
||||
return files
|
||||
|
||||
def _copy_resource_batch(batch: list[tuple[Path, Path]], dst_root: Path) -> int:
|
||||
copied = 0
|
||||
for src_file, rel_path in batch:
|
||||
dst_file = dst_root / rel_path
|
||||
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_file, dst_file)
|
||||
copied += 1
|
||||
return copied
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
|
||||
prefer_copy_resource = info.get("source_format") == "wxdump"
|
||||
await asyncio.to_thread(_reset_resource_dst, resource_dst)
|
||||
|
||||
if not prefer_copy_resource:
|
||||
linked = await asyncio.to_thread(_try_link_resource, resource_src, resource_dst)
|
||||
if linked:
|
||||
yield _sse({"type": "progress", "percent": 48, "message": "资源目录已通过快捷链接导入。"})
|
||||
else:
|
||||
prefer_copy_resource = True
|
||||
|
||||
if prefer_copy_resource:
|
||||
yield _sse({"type": "progress", "percent": 31, "message": "正在扫描资源文件数量..."})
|
||||
resource_files = await asyncio.to_thread(_collect_resource_files, resource_src)
|
||||
total_resources = len(resource_files)
|
||||
if total_resources <= 0:
|
||||
await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
|
||||
yield _sse({"type": "progress", "percent": 48, "message": "资源目录为空,已跳过资源复制。"})
|
||||
else:
|
||||
await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
|
||||
batch_size = 300
|
||||
copied_resources = 0
|
||||
for batch_start in range(0, total_resources, batch_size):
|
||||
_check_cancel()
|
||||
batch = resource_files[batch_start:batch_start + batch_size]
|
||||
copied_resources += await asyncio.to_thread(_copy_resource_batch, batch, resource_dst)
|
||||
percent = 31 + int(min(copied_resources, total_resources) / total_resources * 17)
|
||||
yield _sse({
|
||||
"type": "progress",
|
||||
"percent": min(percent, 48),
|
||||
"message": f"正在复制资源文件:{copied_resources}/{total_resources}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
|
||||
@@ -183,6 +416,7 @@ async def import_decrypted_directory(
|
||||
total_wxgf = len(wxgf_files)
|
||||
converted_count = 0
|
||||
for i, wxgf_path in enumerate(wxgf_files):
|
||||
_check_cancel()
|
||||
def _convert_one(p):
|
||||
jpg_p = p.with_suffix(".wxgf.jpg")
|
||||
if not jpg_p.exists():
|
||||
@@ -209,10 +443,26 @@ async def import_decrypted_directory(
|
||||
|
||||
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
|
||||
|
||||
# 6. 复制 account.json
|
||||
# 6. Copy or generate account.json
|
||||
def _write_imported_account_json(dst: Path, info: dict) -> None:
|
||||
src = Path(str(info.get("account_json_path") or ""))
|
||||
target = dst / "account.json"
|
||||
if src.exists() and src.is_file():
|
||||
shutil.copy2(src, target)
|
||||
return
|
||||
payload = {
|
||||
"username": info.get("username") or dst.name,
|
||||
"nick": info.get("nick") or info.get("username") or dst.name,
|
||||
"avatar_url": info.get("avatar_url") or "",
|
||||
"alias": info.get("alias") or "",
|
||||
"generated_by": "manual_import",
|
||||
"source_format": info.get("source_format") or "unknown",
|
||||
}
|
||||
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
|
||||
try:
|
||||
await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
|
||||
await asyncio.to_thread(_write_imported_account_json, account_output_dir, info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -233,7 +483,7 @@ async def import_decrypted_directory(
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, Path(info.get("account_dir") or import_path_obj), info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -255,12 +505,32 @@ async def import_decrypted_directory(
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})",
|
||||
"backup_dir": str(backup_dir) if backup_dir else ""
|
||||
})
|
||||
|
||||
except ImportCancelled:
|
||||
try:
|
||||
if account_output_dir is not None and backup_dir is not None:
|
||||
await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
|
||||
backup_restored = True
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"取消导入后恢复备份失败: {rollback_error}", exc_info=True)
|
||||
suffix = ",已恢复导入前备份" if backup_restored else ""
|
||||
yield _sse({"type": "error", "message": f"导入已取消{suffix}"})
|
||||
except Exception as e:
|
||||
logger.error(f"导入过程中发生异常: {e}", exc_info=True)
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(e)}"})
|
||||
logger.error(f"导入失败: {e}", exc_info=True)
|
||||
try:
|
||||
if account_output_dir is not None and backup_dir is not None:
|
||||
await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
|
||||
backup_restored = True
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"导入失败后恢复备份失败: {rollback_error}", exc_info=True)
|
||||
suffix = ",已恢复导入前备份" if backup_restored else ""
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(e)}{suffix}"})
|
||||
finally:
|
||||
if job_key:
|
||||
_IMPORT_CANCEL_EVENTS.pop(job_key, None)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
||||
Reference in New Issue
Block a user