mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
19 Commits
@@ -195,31 +195,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.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.7.12",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "1.7.12",
|
||||
"version": "1.8.0",
|
||||
"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"
|
||||
@@ -168,6 +195,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ImgHelper (Auto download large images) -->
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
:class="imgHelperBusy ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
:title="imgHelperTitle"
|
||||
@click="toggleImgHelper"
|
||||
>
|
||||
<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">
|
||||
<svg
|
||||
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="{ 'sidebar-rail-icon-active': imgHelperEnabled }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
<path d="M12 9v5m-2-2l2 2 2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@@ -333,12 +387,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalExportDialog v-if="showGlobalExportEntry" :open="exportDialogOpen" @close="closeExportDialog" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { useImgHelperStore } from '~/stores/imgHelper'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
|
||||
@@ -355,10 +412,16 @@ themeStore.init()
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
|
||||
const imgHelperStore = useImgHelperStore()
|
||||
const { enabled: imgHelperEnabled, checking: imgHelperChecking, toggling: imgHelperToggling, error: imgHelperError } = storeToRefs(imgHelperStore)
|
||||
|
||||
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 +523,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 +579,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 () => {
|
||||
@@ -577,6 +652,18 @@ const toggleRealtime = async () => {
|
||||
if (realtimeBusy.value) return
|
||||
await realtimeStore.toggle({ silent: false })
|
||||
}
|
||||
|
||||
const imgHelperBusy = computed(() => !!imgHelperChecking.value || !!imgHelperToggling.value)
|
||||
|
||||
const imgHelperTitle = computed(() => {
|
||||
if (imgHelperEnabled.value) return '关闭自动下载大图'
|
||||
return imgHelperError.value || '开启自动下载大图'
|
||||
})
|
||||
|
||||
const toggleImgHelper = async () => {
|
||||
if (imgHelperBusy.value) return
|
||||
await imgHelperStore.toggle()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -605,3 +692,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()
|
||||
@@ -612,8 +636,21 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const getImgHelperStatus = async () => {
|
||||
return await request('/system/img_helper/status')
|
||||
}
|
||||
|
||||
const toggleImgHelper = async (enabled) => {
|
||||
return await request('/system/img_helper/toggle', {
|
||||
method: 'POST',
|
||||
body: { enabled: !!enabled }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
pickSystemDirectory,
|
||||
getImgHelperStatus,
|
||||
toggleImgHelper,
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
decryptDatabase,
|
||||
@@ -662,6 +699,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,4 +1,4 @@
|
||||
import { createPerfTrace, getLatestResourceTiming } from '~/lib/chat/perf-logger'
|
||||
import { createPerfTrace, getLatestResourceTiming, logPerfChannel, nowPerfMs } from '~/lib/chat/perf-logger'
|
||||
|
||||
const CHAT_LAZY_SRC_EVENT = 'chat-lazy-src:start'
|
||||
const CHAT_LAZY_ROOT_MARGIN = '240px 0px 520px 0px'
|
||||
@@ -17,6 +17,12 @@ const nextRenderTick = (callback) => {
|
||||
})
|
||||
}
|
||||
|
||||
const roundPerfMs = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return null
|
||||
return Number(numeric.toFixed(1))
|
||||
}
|
||||
|
||||
const readImageSrc = (element) => {
|
||||
return String(
|
||||
element?.currentSrc
|
||||
@@ -44,7 +50,8 @@ const ensurePerfState = (element) => {
|
||||
finalized: true,
|
||||
onLoad: null,
|
||||
onError: null,
|
||||
onLazyStart: null
|
||||
onLazyStart: null,
|
||||
lazyPendingLoggedSrc: ''
|
||||
}
|
||||
}
|
||||
return element.__chatMediaPerfState
|
||||
@@ -63,7 +70,11 @@ const ensureLazySrcState = (element) => {
|
||||
src: '',
|
||||
loadedSrc: '',
|
||||
observer: null,
|
||||
timer: null
|
||||
timer: null,
|
||||
requestedAt: 0,
|
||||
observerStartedAt: 0,
|
||||
appliedAt: 0,
|
||||
lastApplyReason: ''
|
||||
}
|
||||
}
|
||||
return element.__chatLazySrcState
|
||||
@@ -88,11 +99,22 @@ const applyLazySrc = (element, reason = '') => {
|
||||
if (!element || !src) return
|
||||
if (state.loadedSrc === src && readImageSrc(element) === src) return
|
||||
|
||||
const appliedAt = nowPerfMs()
|
||||
state.loadedSrc = src
|
||||
state.appliedAt = appliedAt
|
||||
state.lastApplyReason = String(reason || '')
|
||||
element.setAttribute('src', src)
|
||||
try {
|
||||
element.dispatchEvent(new CustomEvent(CHAT_LAZY_SRC_EVENT, {
|
||||
detail: { src, reason }
|
||||
detail: {
|
||||
src,
|
||||
reason,
|
||||
requestedAt: state.requestedAt || 0,
|
||||
observerStartedAt: state.observerStartedAt || 0,
|
||||
appliedAt,
|
||||
waitSinceRequestMs: state.requestedAt ? roundPerfMs(appliedAt - state.requestedAt) : null,
|
||||
waitSinceObserverMs: state.observerStartedAt ? roundPerfMs(appliedAt - state.observerStartedAt) : null
|
||||
}
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
@@ -103,6 +125,10 @@ const updateLazySrc = (element, binding, reason = '') => {
|
||||
|
||||
cleanupLazySrcObserver(element)
|
||||
state.src = nextSrc
|
||||
state.requestedAt = nowPerfMs()
|
||||
state.observerStartedAt = 0
|
||||
state.appliedAt = 0
|
||||
state.lastApplyReason = ''
|
||||
|
||||
if (!nextSrc) {
|
||||
state.loadedSrc = ''
|
||||
@@ -121,6 +147,7 @@ const updateLazySrc = (element, binding, reason = '') => {
|
||||
return
|
||||
}
|
||||
|
||||
state.observerStartedAt = nowPerfMs()
|
||||
state.observer = new window.IntersectionObserver((entries) => {
|
||||
const entry = entries?.[0]
|
||||
if (!entry?.isIntersecting) return
|
||||
@@ -150,7 +177,30 @@ const finalizeTracking = (element, status, reason = '') => {
|
||||
state.finalized = true
|
||||
}
|
||||
|
||||
const beginTracking = (element, binding, reason = '') => {
|
||||
const logPendingLazy = (element, binding, reason = '') => {
|
||||
const perfState = ensurePerfState(element)
|
||||
const lazyState = element?.__chatLazySrcState
|
||||
const src = String(lazyState?.src || '').trim()
|
||||
if (!src || readImageSrc(element)) return
|
||||
const logKey = `${src}:${reason}`
|
||||
if (perfState.lazyPendingLoggedSrc === logKey) return
|
||||
perfState.lazyPendingLoggedSrc = logKey
|
||||
|
||||
const { kind, meta } = normalizeBindingValue(binding?.value)
|
||||
const now = nowPerfMs()
|
||||
logPerfChannel('chat-media-ui', 'lazy:pending', {
|
||||
kind,
|
||||
src,
|
||||
...meta,
|
||||
reason,
|
||||
hasObserver: !!lazyState?.observer,
|
||||
hasTimer: !!lazyState?.timer,
|
||||
waitSinceRequestMs: lazyState?.requestedAt ? roundPerfMs(now - lazyState.requestedAt) : null,
|
||||
waitSinceObserverMs: lazyState?.observerStartedAt ? roundPerfMs(now - lazyState.observerStartedAt) : null
|
||||
})
|
||||
}
|
||||
|
||||
const beginTracking = (element, binding, reason = '', lazyDetail = null) => {
|
||||
const state = ensurePerfState(element)
|
||||
const src = readImageSrc(element)
|
||||
if (!src) return
|
||||
@@ -164,11 +214,16 @@ const beginTracking = (element, binding, reason = '') => {
|
||||
src,
|
||||
...meta
|
||||
})
|
||||
const lazyState = element?.__chatLazySrcState
|
||||
state.trace.log('resource:start', {
|
||||
reason,
|
||||
complete: !!element?.complete,
|
||||
loading: String(element?.getAttribute?.('loading') || '').trim(),
|
||||
decoding: String(element?.getAttribute?.('decoding') || '').trim()
|
||||
decoding: String(element?.getAttribute?.('decoding') || '').trim(),
|
||||
lazyTriggerReason: String(lazyDetail?.reason || lazyState?.lastApplyReason || '').trim(),
|
||||
waitSinceLazyRequestMs: lazyDetail?.waitSinceRequestMs ?? (lazyState?.requestedAt ? roundPerfMs(nowPerfMs() - lazyState.requestedAt) : null),
|
||||
waitSinceLazyObserverMs: lazyDetail?.waitSinceObserverMs ?? (lazyState?.observerStartedAt ? roundPerfMs(nowPerfMs() - lazyState.observerStartedAt) : null),
|
||||
waitSinceLazyApplyMs: lazyState?.appliedAt ? roundPerfMs(nowPerfMs() - lazyState.appliedAt) : null
|
||||
})
|
||||
|
||||
if (element?.complete) {
|
||||
@@ -182,16 +237,20 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
const state = ensurePerfState(element)
|
||||
state.onLoad = () => finalizeTracking(element, 'load', 'load-event')
|
||||
state.onError = () => finalizeTracking(element, 'error', 'error-event')
|
||||
state.onLazyStart = () => beginTracking(element, binding, 'lazy-src')
|
||||
state.onLazyStart = (event) => beginTracking(element, binding, 'lazy-src', event?.detail || null)
|
||||
element.addEventListener('load', state.onLoad)
|
||||
element.addEventListener('error', state.onError)
|
||||
element.addEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
|
||||
beginTracking(element, binding, 'mounted')
|
||||
logPendingLazy(element, binding, 'mounted')
|
||||
},
|
||||
updated(element, binding) {
|
||||
const state = ensurePerfState(element)
|
||||
const nextSrc = readImageSrc(element)
|
||||
if (!nextSrc) return
|
||||
if (!nextSrc) {
|
||||
logPendingLazy(element, binding, 'updated-no-src')
|
||||
return
|
||||
}
|
||||
if (nextSrc !== state.src) {
|
||||
beginTracking(element, binding, 'updated-src')
|
||||
return
|
||||
@@ -199,6 +258,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
if (element?.complete && !state.finalized) {
|
||||
nextRenderTick(() => finalizeTracking(element, 'load', 'updated-complete'))
|
||||
}
|
||||
logPendingLazy(element, binding, 'updated')
|
||||
},
|
||||
beforeUnmount(element) {
|
||||
const state = element?.__chatMediaPerfState
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useImgHelperStore = defineStore('imgHelper', () => {
|
||||
const enabled = ref(false)
|
||||
const checking = ref(false)
|
||||
const toggling = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!process.client) return
|
||||
const api = useApi()
|
||||
checking.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await api.getImgHelperStatus()
|
||||
enabled.value = !!resp?.enabled
|
||||
} catch (e) {
|
||||
error.value = e?.message || '获取插件状态失败'
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
if (toggling.value) return
|
||||
|
||||
const targetState = !enabled.value
|
||||
|
||||
if (targetState) {
|
||||
// Show warning for first time or every time? User said "首次开启提示hook可能存在风控风险"
|
||||
// We can use localStorage to track if it's the first time.
|
||||
const hasWarned = localStorage.getItem('img_helper_warned')
|
||||
if (!hasWarned) {
|
||||
const confirmed = window.confirm('【安全提示】\n开启“自动下载大图”功能将使用 Hook 技术修改微信内存逻辑。这可能存在一定的风控风险,建议仅在需要时开启。\n\n确认开启吗?')
|
||||
if (!confirmed) return
|
||||
localStorage.setItem('img_helper_warned', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
toggling.value = true
|
||||
error.value = ''
|
||||
const api = useApi()
|
||||
try {
|
||||
const resp = await api.toggleImgHelper(targetState)
|
||||
enabled.value = !!resp?.enabled
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e?.message || '操作失败'
|
||||
if (process.client) {
|
||||
window.alert(error.value)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize status
|
||||
if (process.client) {
|
||||
fetchStatus()
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
checking,
|
||||
toggling,
|
||||
error,
|
||||
fetchStatus,
|
||||
toggle
|
||||
}
|
||||
})
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.7.12"
|
||||
version = "1.8.0"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"jieba>=0.42.1",
|
||||
"wx_key>=2.0.0",
|
||||
"wx_key>=2.0.1",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.7.12"
|
||||
__version__ = "1.8.0"
|
||||
__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
|
||||
@@ -36,6 +37,7 @@ from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .routers.wrapped import router as _wrapped_router
|
||||
from .request_logging import log_server_errors_middleware
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
from .img_helper import IMG_HELPER
|
||||
from .routers.biz import router as _biz_router
|
||||
from .routers.system import router as _system_router
|
||||
|
||||
@@ -65,6 +67,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)
|
||||
@@ -186,6 +189,13 @@ async def _shutdown_wcdb_realtime() -> None:
|
||||
CHAT_REALTIME_AUTOSYNC.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Uninstall img_helper hook if enabled
|
||||
try:
|
||||
IMG_HELPER.disable()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
close_ok = False
|
||||
lock_timeout_s: float | None = 0.2
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ImgHelper:
|
||||
def __init__(self):
|
||||
self._lib: Optional[ctypes.CDLL] = None
|
||||
self._enabled = False
|
||||
self._lock = __import__("threading").Lock()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_dll_path() -> Path:
|
||||
# 1. Default (source code layout)
|
||||
base = Path(__file__).resolve().parent
|
||||
path = base / "native" / "img_helper.dll"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# 2. Frozen (bundled exe)
|
||||
import sys
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
# Try native subfolder or same folder as exe
|
||||
for p in [exe_dir / "native" / "img_helper.dll", exe_dir / "img_helper.dll"]:
|
||||
if p.exists():
|
||||
return p
|
||||
|
||||
# 3. Current working directory
|
||||
for p in [Path.cwd() / "native" / "img_helper.dll", Path.cwd() / "img_helper.dll"]:
|
||||
if p.exists():
|
||||
return p
|
||||
|
||||
return path # Fallback to default for error message
|
||||
|
||||
def _load_lib(self):
|
||||
if self._lib is not None:
|
||||
return self._lib
|
||||
|
||||
dll_path = self._resolve_dll_path()
|
||||
if not dll_path.exists():
|
||||
raise FileNotFoundError(f"Missing img_helper.dll at: {dll_path}")
|
||||
|
||||
try:
|
||||
# On Windows, ensure the DLL's directory is in the search path for dependencies
|
||||
if hasattr(os, 'add_dll_directory'):
|
||||
try:
|
||||
os.add_dll_directory(str(dll_path.parent))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lib = ctypes.CDLL(str(dll_path))
|
||||
|
||||
lib.InitImgHelper.argtypes = [ctypes.c_uint32]
|
||||
lib.InitImgHelper.restype = ctypes.c_bool
|
||||
|
||||
lib.UninstallImgHelper.argtypes = []
|
||||
lib.UninstallImgHelper.restype = None
|
||||
|
||||
lib.GetImgHelperError.argtypes = []
|
||||
lib.GetImgHelperError.restype = ctypes.c_char_p
|
||||
|
||||
self._lib = lib
|
||||
return lib
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load img_helper.dll: {e}")
|
||||
raise
|
||||
|
||||
def enable(self, pid: int) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
try:
|
||||
lib = self._load_lib()
|
||||
if self._enabled:
|
||||
# If already enabled, we uninstall first to be safe as per DLL docs suggestion
|
||||
# about being designed to hook one process at a time.
|
||||
lib.UninstallImgHelper()
|
||||
|
||||
if lib.InitImgHelper(pid):
|
||||
self._enabled = True
|
||||
logger.info(f"ImgHelper hook applied to PID {pid}")
|
||||
return True, "Success"
|
||||
else:
|
||||
err_ptr = lib.GetImgHelperError()
|
||||
err_msg = err_ptr.decode('utf-8', errors='ignore') if err_ptr else "Unknown error"
|
||||
logger.error(f"ImgHelper hook failed: {err_msg}")
|
||||
return False, err_msg
|
||||
except Exception as e:
|
||||
logger.error(f"ImgHelper enable exception: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def disable(self) -> bool:
|
||||
with self._lock:
|
||||
if not self._enabled:
|
||||
return True
|
||||
try:
|
||||
lib = self._load_lib()
|
||||
lib.UninstallImgHelper()
|
||||
self._enabled = False
|
||||
logger.info("ImgHelper hook uninstalled")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to uninstall img helper: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
|
||||
IMG_HELPER = ImgHelper()
|
||||
@@ -51,10 +51,10 @@ def _summarize_key_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _resolve_wxid_dir_for_image_key(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Path:
|
||||
explicit_wxid_dir = str(wxid_dir or "").strip()
|
||||
if explicit_wxid_dir:
|
||||
@@ -193,15 +193,26 @@ class WeChatKeyFetcher:
|
||||
process = subprocess.Popen(normalized_exe_path)
|
||||
time.sleep(2)
|
||||
candidates = []
|
||||
target_process_name = Path(normalized_exe_path).name.lower()
|
||||
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
||||
proc_name = str(proc.info.get('name') or "").strip().lower()
|
||||
if proc_name == target_process_name or self._is_wechat_process(proc_name):
|
||||
candidates.append(proc)
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'exe', 'cmdline']):
|
||||
try:
|
||||
p_name = proc.info.get('name')
|
||||
if p_name and p_name.lower() in self.process_names:
|
||||
cmdline_list = proc.info.get('cmdline') or []
|
||||
cmdline_str = " ".join(cmdline_list).lower()
|
||||
|
||||
if any(target.lower() in cmdline_str for target in WECHAT_EXECUTABLE_NAMES):
|
||||
candidates.append({
|
||||
"pid": proc.info['pid'],
|
||||
"cmd_len": len(cmdline_str)
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x.info['create_time'], reverse=True)
|
||||
target_pid = candidates[0].info['pid']
|
||||
# 选择命令行最短的一个作为主进程
|
||||
main_proc = min(candidates, key=lambda x: x['cmd_len'])
|
||||
target_pid = main_proc["pid"]
|
||||
return target_pid
|
||||
|
||||
return process.pid
|
||||
@@ -275,6 +286,7 @@ class WeChatKeyFetcher:
|
||||
"db_key": found_db_key
|
||||
}
|
||||
|
||||
|
||||
def get_db_key_workflow(wechat_install_path: Optional[str] = None):
|
||||
fetcher = WeChatKeyFetcher()
|
||||
return fetcher.fetch_db_key(wechat_install_path=wechat_install_path)
|
||||
@@ -295,13 +307,13 @@ def try_get_local_image_keys() -> List[Dict[str, Any]]:
|
||||
if wx_key is None or not hasattr(wx_key, 'get_image_key'):
|
||||
logger.info("[image_key] 本地算法不可用:wx_key.get_image_key 缺失")
|
||||
return []
|
||||
|
||||
|
||||
try:
|
||||
res_json = wx_key.get_image_key()
|
||||
if not res_json:
|
||||
logger.info("[image_key] 本地算法返回空结果")
|
||||
return []
|
||||
|
||||
|
||||
data = json.loads(res_json)
|
||||
accounts = data.get('accounts', [])
|
||||
results = []
|
||||
@@ -329,10 +341,10 @@ def try_get_local_image_keys() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
async def get_image_key_integrated_workflow(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
集成图片密钥获取流程:
|
||||
@@ -341,7 +353,7 @@ async def get_image_key_integrated_workflow(
|
||||
"""
|
||||
# 1. 尝试本地提取
|
||||
local_keys = try_get_local_image_keys()
|
||||
|
||||
|
||||
target_account_wxid = None
|
||||
if account or wxid_dir or db_storage_path:
|
||||
try:
|
||||
@@ -409,10 +421,10 @@ async def get_image_key_integrated_workflow(
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wx_id_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
|
||||
@@ -1184,6 +1184,131 @@ def _load_account_source_info(account_dir: Path) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _clean_weflow_account_dir_name(dir_name: str) -> str:
|
||||
"""按 WeFlow 的账号目录规则清理 wxid。
|
||||
|
||||
WeFlow 在连接 WCDB 前会把形如 `xxx_abcd` 的账号目录清理为 `xxx`,
|
||||
再传给 native `wcdb_set_my_wxid`。这里保持同样规则,避免 suffix 目录名
|
||||
影响实时读取。
|
||||
"""
|
||||
trimmed = str(dir_name or "").strip()
|
||||
if not trimmed:
|
||||
return trimmed
|
||||
|
||||
if trimmed.lower().startswith("wxid_"):
|
||||
match = re.match(r"^(wxid_[^_]+)", trimmed, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return trimmed
|
||||
|
||||
suffix_match = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", trimmed)
|
||||
return suffix_match.group(1) if suffix_match else trimmed
|
||||
|
||||
|
||||
def _find_db_storage_recursive(dir_path: Path, max_depth: int) -> Optional[Path]:
|
||||
"""有限深度递归查找 db_storage,逻辑对齐 WeFlow。"""
|
||||
if max_depth <= 0:
|
||||
return None
|
||||
try:
|
||||
entries = list(dir_path.iterdir())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir() and entry.name.lower() == "db_storage":
|
||||
return entry
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
found = _find_db_storage_recursive(entry, max_depth - 1)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_db_storage_path_like_weflow(base_path: str | Path, account_name: str) -> Optional[Path]:
|
||||
"""按 WeFlow 的 resolveDbStoragePath 规则解析 db_storage。"""
|
||||
raw = str(base_path or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
normalized = Path(raw).expanduser()
|
||||
except Exception:
|
||||
normalized = Path(raw)
|
||||
|
||||
def existing_dir(candidate: Path) -> Optional[Path]:
|
||||
try:
|
||||
return candidate if candidate.exists() and candidate.is_dir() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
direct_self = existing_dir(normalized)
|
||||
if direct_self is not None and direct_self.name.lower() == "db_storage":
|
||||
return direct_self
|
||||
|
||||
direct_child = existing_dir(normalized / "db_storage")
|
||||
if direct_child is not None:
|
||||
return direct_child
|
||||
|
||||
wxid_candidates: list[str] = []
|
||||
for item in (account_name, _clean_weflow_account_dir_name(account_name)):
|
||||
item = str(item or "").strip()
|
||||
if item and item not in wxid_candidates:
|
||||
wxid_candidates.append(item)
|
||||
|
||||
for wxid in wxid_candidates:
|
||||
via_wxid = existing_dir(normalized / wxid / "db_storage")
|
||||
if via_wxid is not None:
|
||||
return via_wxid
|
||||
|
||||
# 兼容目录名包含额外后缀(如 wxid_xxx_1234)。
|
||||
try:
|
||||
entries = list(normalized.iterdir())
|
||||
except Exception:
|
||||
entries = []
|
||||
lower_wxid = wxid.lower()
|
||||
for entry in entries:
|
||||
try:
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
lower_entry = entry.name.lower()
|
||||
if lower_entry == lower_wxid or lower_entry.startswith(f"{lower_wxid}_"):
|
||||
candidate = existing_dir(entry / "db_storage")
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
# 兜底:向上查找 db_storage(最多 2 级),处理用户选择了子目录的情况。
|
||||
try:
|
||||
parent = normalized
|
||||
for _ in range(2):
|
||||
up = parent.parent
|
||||
if up == parent:
|
||||
break
|
||||
parent = up
|
||||
candidate_up = existing_dir(parent / "db_storage")
|
||||
if candidate_up is not None:
|
||||
return candidate_up
|
||||
for wxid in wxid_candidates:
|
||||
via_wxid_up = existing_dir(parent / wxid / "db_storage")
|
||||
if via_wxid_up is not None:
|
||||
return via_wxid_up
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _find_db_storage_recursive(normalized, 3)
|
||||
|
||||
|
||||
def _guess_wxid_dir_from_common_paths(account_name: str) -> Optional[Path]:
|
||||
try:
|
||||
home = Path.home()
|
||||
@@ -1195,14 +1320,18 @@ def _guess_wxid_dir_from_common_paths(account_name: str) -> Optional[Path]:
|
||||
home / "Documents" / "WeChat Files",
|
||||
]
|
||||
|
||||
candidates = [account_name, _clean_weflow_account_dir_name(account_name)]
|
||||
candidates = [x for i, x in enumerate(candidates) if x and x not in candidates[:i]]
|
||||
|
||||
# Exact match first
|
||||
for root in roots:
|
||||
c = root / account_name
|
||||
try:
|
||||
if c.exists() and c.is_dir():
|
||||
return c
|
||||
except Exception:
|
||||
continue
|
||||
for name in candidates:
|
||||
c = root / name
|
||||
try:
|
||||
if c.exists() and c.is_dir():
|
||||
return c
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Then try prefix match: wxid_xxx_yyyy
|
||||
for root in roots:
|
||||
@@ -1212,8 +1341,9 @@ def _guess_wxid_dir_from_common_paths(account_name: str) -> Optional[Path]:
|
||||
for p in root.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if p.name.startswith(account_name + "_"):
|
||||
return p
|
||||
for name in candidates:
|
||||
if p.name.startswith(name + "_"):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
@@ -1236,21 +1366,15 @@ def _resolve_account_db_storage_dir(account_dir: Path) -> Optional[Path]:
|
||||
info = _load_account_source_info(account_dir)
|
||||
db_storage_path = str(info.get("db_storage_path") or "").strip()
|
||||
if db_storage_path:
|
||||
try:
|
||||
p = Path(db_storage_path)
|
||||
if p.exists() and p.is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
resolved = _resolve_db_storage_path_like_weflow(db_storage_path, account_dir.name)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wxid_dir:
|
||||
c = wxid_dir / "db_storage"
|
||||
try:
|
||||
if c.exists() and c.is_dir():
|
||||
return c
|
||||
except Exception:
|
||||
pass
|
||||
resolved = _resolve_db_storage_path_like_weflow(wxid_dir, account_dir.name)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
@@ -3051,14 +3175,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
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()}
|
||||
@@ -3246,19 +3246,20 @@ def _append_full_messages_from_rows(
|
||||
create_time=create_time,
|
||||
)
|
||||
|
||||
# Some WeChat builds store the on-disk thumbnail basename (32-hex) in packed_info_data (protobuf),
|
||||
# while the message XML only carries a long cdnthumburl file_id. Prefer packed_info_data when present.
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
# Match WeFlow's video strategy: packed_info_data often stores the local msg/video basename.
|
||||
# Prefer this token for video lookup; keep XML CDN/file_id as fallback query parameters.
|
||||
try:
|
||||
packed_val = r["packed_info_data"]
|
||||
except Exception:
|
||||
try:
|
||||
packed_val = r["packed_info_data"]
|
||||
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
try:
|
||||
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
packed_val = None
|
||||
packed_md5 = _extract_md5_from_packed_info(packed_val)
|
||||
if packed_md5:
|
||||
video_thumb_md5 = packed_md5
|
||||
packed_val = None
|
||||
packed_video_token = _extract_md5_from_packed_info(packed_val)
|
||||
if packed_video_token:
|
||||
video_md5 = packed_video_token
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
video_thumb_md5 = packed_video_token
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
@@ -3823,6 +3824,7 @@ def _postprocess_full_messages(
|
||||
m["videoThumbUrl"] = (
|
||||
base_url
|
||||
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(video_thumb_md5)}&username={quote(username)}"
|
||||
+ (f"&file_id={quote(video_thumb_file_id)}" if video_thumb_file_id else "")
|
||||
)
|
||||
elif video_thumb_file_id:
|
||||
m["videoThumbUrl"] = (
|
||||
@@ -3838,6 +3840,7 @@ def _postprocess_full_messages(
|
||||
m["videoUrl"] = (
|
||||
base_url
|
||||
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(video_md5)}&username={quote(username)}"
|
||||
+ (f"&file_id={quote(video_file_id)}" if video_file_id else "")
|
||||
)
|
||||
elif video_file_id:
|
||||
m["videoUrl"] = (
|
||||
@@ -4509,9 +4512,10 @@ def _collect_chat_messages(
|
||||
contact_conn = None
|
||||
|
||||
for db_path in db_paths:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
table_name = _resolve_msg_table_name(conn, username)
|
||||
if not table_name:
|
||||
continue
|
||||
@@ -4830,6 +4834,20 @@ def _collect_chat_messages(
|
||||
packed_md5 = _extract_md5_from_packed_info(r["packed_info_data"])
|
||||
if packed_md5:
|
||||
video_thumb_md5 = packed_md5
|
||||
# Match WeFlow video lookup: packed_info_data may be the local msg/video basename.
|
||||
# Keep XML md5/file_id as fallback, but prefer the packed token for local playback.
|
||||
try:
|
||||
packed_val = r["packed_info_data"]
|
||||
except Exception:
|
||||
try:
|
||||
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
packed_val = None
|
||||
packed_video_token = _extract_md5_from_packed_info(packed_val)
|
||||
if packed_video_token:
|
||||
video_md5 = packed_video_token
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
video_thumb_md5 = packed_video_token
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
@@ -5007,8 +5025,20 @@ def _collect_chat_messages(
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
)
|
||||
except sqlite3.DatabaseError as e:
|
||||
# 单个解密库损坏时不要让整个聊天详情接口 500;保留诊断日志,继续尝试其他 message_*.db。
|
||||
logger.warning(
|
||||
"[chat.messages] malformed message db skipped account=%s username=%s db=%s error=%s diag=%s",
|
||||
account_dir.name,
|
||||
username,
|
||||
str(db_path),
|
||||
str(e),
|
||||
format_sqlite_diagnostics(collect_sqlite_diagnostics(db_path, quick_check=True)),
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
if contact_conn is not None:
|
||||
try:
|
||||
@@ -5782,6 +5812,20 @@ def list_chat_messages(
|
||||
local_id=local_id,
|
||||
create_time=create_time,
|
||||
)
|
||||
# Match WeFlow video lookup: packed_info_data may be the local msg/video basename.
|
||||
# Keep XML md5/file_id as fallback, but prefer the packed token for local playback.
|
||||
try:
|
||||
packed_val = r["packed_info_data"]
|
||||
except Exception:
|
||||
try:
|
||||
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
packed_val = None
|
||||
packed_video_token = _extract_md5_from_packed_info(packed_val)
|
||||
if packed_video_token:
|
||||
video_md5 = packed_video_token
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
video_thumb_md5 = packed_video_token
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
@@ -6214,6 +6258,7 @@ def list_chat_messages(
|
||||
m["videoThumbUrl"] = (
|
||||
base_url
|
||||
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(video_thumb_md5)}&username={quote(username)}"
|
||||
+ (f"&file_id={quote(video_thumb_file_id)}" if video_thumb_file_id else "")
|
||||
)
|
||||
elif video_thumb_file_id:
|
||||
m["videoThumbUrl"] = (
|
||||
@@ -6229,6 +6274,7 @@ def list_chat_messages(
|
||||
m["videoUrl"] = (
|
||||
base_url
|
||||
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(video_md5)}&username={quote(username)}"
|
||||
+ (f"&file_id={quote(video_file_id)}" if video_file_id else "")
|
||||
)
|
||||
elif video_file_id:
|
||||
m["videoUrl"] = (
|
||||
|
||||
@@ -7,6 +7,8 @@ import mimetypes
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -61,7 +63,7 @@ from ..media_helpers import (
|
||||
from ..chat_helpers import _extract_md5_from_packed_info, _load_contact_rows, _pick_avatar_url
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..perf_trace import create_perf_trace
|
||||
from ..wcdb_realtime import WCDB_REALTIME, get_avatar_urls as _wcdb_get_avatar_urls
|
||||
from ..wcdb_realtime import WCDB_REALTIME, exec_query as _wcdb_exec_query, get_avatar_urls as _wcdb_get_avatar_urls
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -70,6 +72,244 @@ router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
CHAT_MEDIA_BROWSER_CACHE_SECONDS = 24 * 60 * 60
|
||||
|
||||
VIDEO_DIR_INDEX_TTL_SECONDS = 90.0
|
||||
_VIDEO_DIR_INDEX_CACHE: dict[str, tuple[float, dict[str, dict[str, str]]]] = {}
|
||||
_VIDEO_DIR_INDEX_MAX_ENTRIES = 32
|
||||
|
||||
|
||||
def _normalize_video_lookup_key(value: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\\", "/").split("/")[-1]
|
||||
text = re.sub(r"\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|webp|dat)$", "", text, flags=re.I)
|
||||
text = re.sub(r"_thumb$", "", text, flags=re.I)
|
||||
direct = re.fullmatch(r"([a-f0-9]{16,64})(?:_raw)?", text, flags=re.I)
|
||||
if direct:
|
||||
suffix = "_raw" if text.endswith("_raw") else ""
|
||||
return f"{direct.group(1).lower()}{suffix}"
|
||||
preferred32 = re.search(r"([a-f0-9]{32})(?![a-f0-9])", text, flags=re.I)
|
||||
if preferred32:
|
||||
return preferred32.group(1).lower()
|
||||
fallback = re.search(r"([a-f0-9]{16,64})(?![a-f0-9])", text, flags=re.I)
|
||||
return fallback.group(1).lower() if fallback else ""
|
||||
|
||||
|
||||
def _is_video_month_dir_name(name: str) -> bool:
|
||||
n = str(name or "")
|
||||
return len(n) == 7 and n[4] == "-" and n[:4].isdigit() and n[5:7].isdigit()
|
||||
|
||||
|
||||
def _get_or_build_video_dir_index(video_base_dir: Path) -> dict[str, dict[str, str]]:
|
||||
"""Build a WeFlow-style index for msg/video/YYYY-MM files."""
|
||||
try:
|
||||
base = video_base_dir.resolve()
|
||||
except Exception:
|
||||
base = video_base_dir
|
||||
cache_key = str(base)
|
||||
now = time.monotonic()
|
||||
cached = _VIDEO_DIR_INDEX_CACHE.get(cache_key)
|
||||
if cached and (now - cached[0]) < VIDEO_DIR_INDEX_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
index: dict[str, dict[str, str]] = {}
|
||||
|
||||
def ensure_entry(key: str) -> dict[str, str]:
|
||||
entry = index.get(key)
|
||||
if entry is None:
|
||||
entry = {}
|
||||
index[key] = entry
|
||||
return entry
|
||||
|
||||
try:
|
||||
if not base.exists() or not base.is_dir():
|
||||
return {}
|
||||
month_dirs: list[Path] = []
|
||||
try:
|
||||
for child in base.iterdir():
|
||||
try:
|
||||
if child.is_dir() and _is_video_month_dir_name(child.name):
|
||||
month_dirs.append(child)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
month_dirs = []
|
||||
month_dirs.sort(key=lambda x: x.name, reverse=True)
|
||||
dirs_to_scan = [*month_dirs, base]
|
||||
for d in dirs_to_scan:
|
||||
try:
|
||||
files = list(d.iterdir())
|
||||
except Exception:
|
||||
continue
|
||||
for file_path in files:
|
||||
try:
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
lower = file_path.name.lower()
|
||||
if lower.endswith((".mp4", ".m4v", ".mov")):
|
||||
stem = lower.rsplit(".", 1)[0]
|
||||
key = _normalize_video_lookup_key(stem)
|
||||
if not key:
|
||||
continue
|
||||
entry = ensure_entry(key)
|
||||
entry.setdefault("video", str(file_path))
|
||||
if key.endswith("_raw"):
|
||||
base_key = key[:-4]
|
||||
ensure_entry(base_key).setdefault("video", str(file_path))
|
||||
else:
|
||||
ensure_entry(f"{key}_raw").setdefault("video", str(file_path))
|
||||
continue
|
||||
|
||||
if not lower.endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||
continue
|
||||
stem = lower.rsplit(".", 1)[0]
|
||||
is_thumb = stem.endswith("_thumb")
|
||||
if is_thumb:
|
||||
stem = stem[:-6]
|
||||
key = _normalize_video_lookup_key(stem)
|
||||
if not key:
|
||||
continue
|
||||
entry = ensure_entry(key)
|
||||
entry.setdefault("thumb" if is_thumb else "cover", str(file_path))
|
||||
if key.endswith("_raw"):
|
||||
base_key = key[:-4]
|
||||
ensure_entry(base_key).setdefault("thumb" if is_thumb else "cover", str(file_path))
|
||||
finally:
|
||||
if len(_VIDEO_DIR_INDEX_CACHE) >= _VIDEO_DIR_INDEX_MAX_ENTRIES:
|
||||
try:
|
||||
oldest_key = min(_VIDEO_DIR_INDEX_CACHE.items(), key=lambda kv: kv[1][0])[0]
|
||||
_VIDEO_DIR_INDEX_CACHE.pop(oldest_key, None)
|
||||
except Exception:
|
||||
_VIDEO_DIR_INDEX_CACHE.clear()
|
||||
_VIDEO_DIR_INDEX_CACHE[cache_key] = (now, index)
|
||||
return index
|
||||
|
||||
|
||||
def _resolve_video_path_from_weflow_index(
|
||||
*,
|
||||
md5: str,
|
||||
wxid_dir: Optional[Path],
|
||||
db_storage_dir: Optional[Path],
|
||||
want_thumb: bool,
|
||||
) -> Optional[Path]:
|
||||
lookup_key = _normalize_video_lookup_key(md5)
|
||||
if not lookup_key:
|
||||
return None
|
||||
bases: list[Path] = []
|
||||
for root in [wxid_dir, db_storage_dir]:
|
||||
if not root:
|
||||
continue
|
||||
bases.extend([root / "msg" / "video", root / "video"])
|
||||
|
||||
seen: set[str] = set()
|
||||
keys = [lookup_key]
|
||||
if lookup_key.endswith("_raw"):
|
||||
keys.append(lookup_key[:-4])
|
||||
else:
|
||||
keys.append(f"{lookup_key}_raw")
|
||||
|
||||
for base in bases:
|
||||
try:
|
||||
base_key = str(base.resolve())
|
||||
except Exception:
|
||||
base_key = str(base)
|
||||
if base_key in seen:
|
||||
continue
|
||||
seen.add(base_key)
|
||||
try:
|
||||
if not base.exists() or not base.is_dir():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
index = _get_or_build_video_dir_index(base)
|
||||
for key in keys:
|
||||
entry = index.get(key) or {}
|
||||
candidates = [entry.get("thumb"), entry.get("cover")] if want_thumb else [entry.get("video")]
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
p = Path(candidate)
|
||||
try:
|
||||
if p.exists() and p.is_file():
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
_REALTIME_VIDEO_HARDLINK_CACHE_TTL_SECONDS = 120.0
|
||||
_REALTIME_VIDEO_HARDLINK_CACHE: dict[tuple[str, str], tuple[float, str]] = {}
|
||||
|
||||
|
||||
def _sql_quote(value: str) -> str:
|
||||
return "'" + str(value or "").replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _resolve_video_file_token_from_realtime_hardlink(account_dir: Path, md5: str) -> str:
|
||||
"""Resolve XML video md5 to the real local msg/video basename via encrypted hardlink.db."""
|
||||
md5_norm = _normalize_video_lookup_key(md5)
|
||||
if not md5_norm:
|
||||
return ""
|
||||
|
||||
cache_key = (str(account_dir.name), md5_norm)
|
||||
now = time.monotonic()
|
||||
cached = _REALTIME_VIDEO_HARDLINK_CACHE.get(cache_key)
|
||||
if cached and (now - cached[0]) < _REALTIME_VIDEO_HARDLINK_CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
resolved = ""
|
||||
try:
|
||||
conn = WCDB_REALTIME.ensure_connected(account_dir, timeout=5.0)
|
||||
hardlink_db_path = Path(conn.db_storage_dir) / "hardlink" / "hardlink.db"
|
||||
if not hardlink_db_path.exists():
|
||||
return ""
|
||||
md5_lit = _sql_quote(md5_norm)
|
||||
sql = (
|
||||
"SELECT md5, file_name, file_size, modify_time, dir1, dir2 "
|
||||
"FROM video_hardlink_info_v4 "
|
||||
f"WHERE md5 = {md5_lit} OR file_name LIKE '%' || {md5_lit} || '%' "
|
||||
"ORDER BY modify_time DESC, dir1 DESC, rowid DESC LIMIT 1"
|
||||
)
|
||||
rows = _wcdb_exec_query(conn.handle, kind="hardlink", path=str(hardlink_db_path), sql=sql) or []
|
||||
if rows:
|
||||
file_name = str((rows[0] or {}).get("file_name") or "").strip()
|
||||
resolved = _normalize_video_lookup_key(file_name) or file_name.lower()
|
||||
except Exception:
|
||||
resolved = ""
|
||||
|
||||
_REALTIME_VIDEO_HARDLINK_CACHE[cache_key] = (now, resolved)
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_video_path_from_realtime_hardlink(
|
||||
*,
|
||||
account_dir: Path,
|
||||
md5: str,
|
||||
wxid_dir: Optional[Path],
|
||||
db_storage_dir: Optional[Path],
|
||||
want_thumb: bool,
|
||||
) -> tuple[Optional[Path], str]:
|
||||
token = _resolve_video_file_token_from_realtime_hardlink(account_dir, md5)
|
||||
if not token:
|
||||
return None, ""
|
||||
path = _resolve_video_path_from_weflow_index(
|
||||
md5=token,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=want_thumb,
|
||||
)
|
||||
if path is not None:
|
||||
return path, token
|
||||
path = _fast_probe_video_path_by_md5(
|
||||
md5=token,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=want_thumb,
|
||||
)
|
||||
return path, token
|
||||
|
||||
|
||||
def _build_cached_media_response(request: Optional[Request], data: bytes, media_type: str) -> Response:
|
||||
payload = bytes(data or b"")
|
||||
@@ -1481,11 +1721,28 @@ async def get_chat_image(
|
||||
|
||||
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
|
||||
if md5:
|
||||
cache_started_at = time.perf_counter()
|
||||
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
|
||||
trace(
|
||||
"decrypted-cache:path-lookup",
|
||||
hasPath=bool(decrypted_path),
|
||||
path=str(decrypted_path or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - cache_started_at) * 1000.0, 1),
|
||||
)
|
||||
if decrypted_path:
|
||||
read_started_at = time.perf_counter()
|
||||
data = decrypted_path.read_bytes()
|
||||
media_type = _detect_image_media_type(data[:32])
|
||||
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
|
||||
valid_image = bool(media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type))
|
||||
trace(
|
||||
"decrypted-cache:read-validate",
|
||||
path=str(decrypted_path),
|
||||
bytes=len(data or b""),
|
||||
mediaType=media_type,
|
||||
validImage=valid_image,
|
||||
elapsedMsLocal=round((time.perf_counter() - read_started_at) * 1000.0, 1),
|
||||
)
|
||||
if valid_image:
|
||||
cached_path = decrypted_path
|
||||
cached_data = data
|
||||
cached_media_type = media_type
|
||||
@@ -1512,6 +1769,7 @@ async def get_chat_image(
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
|
||||
# 回退:从微信数据目录实时定位并解密
|
||||
roots_started_at = time.perf_counter()
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
hardlink_db_path = account_dir / "hardlink.db"
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
@@ -1521,6 +1779,7 @@ async def get_chat_image(
|
||||
hasWxidDir=bool(wxid_dir),
|
||||
hasDbStorageDir=bool(db_storage_dir),
|
||||
hardlinkHasImageTable=bool(hardlink_has_image_table),
|
||||
elapsedMsLocal=round((time.perf_counter() - roots_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
roots: list[Path] = []
|
||||
@@ -1544,6 +1803,7 @@ async def get_chat_image(
|
||||
allow_deep_scan = False
|
||||
|
||||
if md5:
|
||||
hardlink_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_media_path_from_hardlink,
|
||||
hardlink_db_path,
|
||||
@@ -1553,12 +1813,42 @@ async def get_chat_image(
|
||||
username=username,
|
||||
extra_roots=roots[1:],
|
||||
)
|
||||
trace(
|
||||
"source:hardlink-lookup",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - hardlink_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
# Fast fallback for thumbnails not indexed by hardlink.db: scan only this chat's attach directory.
|
||||
# Keep this before the file_id fallback: file_id search can be very expensive on large WeChat folders,
|
||||
# while md5 + conversation-scoped attach probing usually resolves current chat images in milliseconds.
|
||||
if (not p) and wxid_dir and username:
|
||||
fast_probe_started_at = time.perf_counter()
|
||||
hit = await asyncio.to_thread(
|
||||
_fast_probe_image_path_in_chat_attach,
|
||||
wxid_dir_str=str(wxid_dir),
|
||||
username=str(username),
|
||||
md5=str(md5),
|
||||
)
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
trace(
|
||||
"source:chat-attach-fast-probe",
|
||||
found=bool(hit),
|
||||
path=str(hit or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - fast_probe_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
# Some WeChat versions send both md5 + file_id; md5 may be missing from hardlink.db while file_id still works.
|
||||
# Only run this broader fallback after the scoped md5 probe misses.
|
||||
if (not p) and file_id:
|
||||
file_id_started_at = time.perf_counter()
|
||||
file_id_roots_checked = 0
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
file_id_roots_checked += 1
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
@@ -1569,24 +1859,27 @@ async def get_chat_image(
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
break
|
||||
|
||||
# Fast fallback for thumbnails not indexed by hardlink.db: scan only this chat's attach directory.
|
||||
if (not p) and wxid_dir and username:
|
||||
hit = await asyncio.to_thread(
|
||||
_fast_probe_image_path_in_chat_attach,
|
||||
wxid_dir_str=str(wxid_dir),
|
||||
username=str(username),
|
||||
md5=str(md5),
|
||||
trace(
|
||||
"source:file-id-fallback-after-md5",
|
||||
found=bool(p),
|
||||
rootsChecked=file_id_roots_checked,
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - file_id_started_at) * 1000.0, 1),
|
||||
)
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
|
||||
# Deep scan is extremely expensive for misses (~seconds per md5). Only enable when:
|
||||
# - user explicitly requests `deep_scan=1`, OR
|
||||
# - hardlink.db doesn't have the image table (older/partial data).
|
||||
allow_deep_scan = bool(deep_scan) or (not hardlink_has_image_table)
|
||||
if (not p) and wxid_dir and allow_deep_scan:
|
||||
deep_scan_started_at = time.perf_counter()
|
||||
hit = await asyncio.to_thread(_fallback_search_media_by_md5, str(wxid_dir), str(md5), kind="image")
|
||||
trace(
|
||||
"source:deep-scan",
|
||||
found=bool(hit),
|
||||
path=str(hit or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - deep_scan_started_at) * 1000.0, 1),
|
||||
)
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
try:
|
||||
@@ -1594,10 +1887,13 @@ async def get_chat_image(
|
||||
except Exception:
|
||||
pass
|
||||
elif file_id:
|
||||
# 一些版本图片消息无 MD5,仅提供 cdnthumburl 等“文件标识”
|
||||
# Some image messages have no MD5 and only provide a cdnthumburl-like file identifier.
|
||||
file_id_started_at = time.perf_counter()
|
||||
file_id_roots_checked = 0
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
file_id_roots_checked += 1
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
@@ -1608,6 +1904,13 @@ async def get_chat_image(
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
break
|
||||
trace(
|
||||
"source:file-id-lookup",
|
||||
found=bool(p),
|
||||
rootsChecked=file_id_roots_checked,
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - file_id_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
if not p:
|
||||
if cached_path:
|
||||
@@ -1621,7 +1924,9 @@ async def get_chat_image(
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Image not found.")
|
||||
|
||||
candidates_started_at = time.perf_counter()
|
||||
candidates.extend(await asyncio.to_thread(_iter_media_source_candidates, p))
|
||||
candidate_count_before_order = len(candidates)
|
||||
candidates = await asyncio.to_thread(_order_media_candidates, candidates)
|
||||
trace(
|
||||
"candidates:resolved",
|
||||
@@ -1629,6 +1934,8 @@ async def get_chat_image(
|
||||
candidateCount=len(candidates),
|
||||
hasCachedPath=bool(cached_path),
|
||||
allowDeepScan=bool(allow_deep_scan),
|
||||
candidateCountBeforeOrder=candidate_count_before_order,
|
||||
elapsedMsLocal=round((time.perf_counter() - candidates_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
if cached_path:
|
||||
@@ -1661,8 +1968,11 @@ async def get_chat_image(
|
||||
chosen: Optional[Path] = None
|
||||
decode_attempts = 0
|
||||
trace("decode:start", candidateCount=len(candidates))
|
||||
slow_decode_logged = 0
|
||||
for src_path in candidates:
|
||||
decode_attempts += 1
|
||||
decode_one_started_at = time.perf_counter()
|
||||
decode_error = ""
|
||||
try:
|
||||
data, media_type = await asyncio.to_thread(
|
||||
_read_and_maybe_decrypt_media,
|
||||
@@ -1670,10 +1980,30 @@ async def get_chat_image(
|
||||
account_dir=account_dir,
|
||||
weixin_root=wxid_dir,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
decode_error = str(e)
|
||||
data = b""
|
||||
media_type = "application/octet-stream"
|
||||
|
||||
decode_elapsed_ms = round((time.perf_counter() - decode_one_started_at) * 1000.0, 1)
|
||||
valid_image = not (media_type.startswith("image/") and (not _is_probably_valid_image(data, media_type)))
|
||||
should_log_attempt = bool(decode_error) or decode_attempts <= 3 or decode_elapsed_ms >= 100 or media_type != "application/octet-stream"
|
||||
if should_log_attempt and slow_decode_logged < 8:
|
||||
trace(
|
||||
"decode:attempt",
|
||||
attempt=decode_attempts,
|
||||
path=str(src_path),
|
||||
mediaType=media_type,
|
||||
bytes=len(data or b""),
|
||||
validImage=bool(valid_image),
|
||||
error=decode_error[:200],
|
||||
elapsedMsLocal=decode_elapsed_ms,
|
||||
)
|
||||
slow_decode_logged += 1
|
||||
if decode_error:
|
||||
continue
|
||||
|
||||
if media_type.startswith("image/") and (not _is_probably_valid_image(data, media_type)):
|
||||
if not valid_image:
|
||||
continue
|
||||
|
||||
if media_type != "application/octet-stream":
|
||||
@@ -1803,7 +2133,7 @@ async def get_chat_emoji(
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源")
|
||||
@router.get("/api/chat/media/video_thumb", summary="Get video thumbnail media")
|
||||
async def get_chat_video_thumb(
|
||||
md5: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
@@ -1814,16 +2144,47 @@ async def get_chat_video_thumb(
|
||||
if (not md5) and (not file_id):
|
||||
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||
account_dir = _resolve_account_dir(account)
|
||||
md5_norm = str(md5 or "").strip().lower() if md5 else ""
|
||||
file_id_norm = str(file_id or "").strip()
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.video_thumb",
|
||||
account=account_dir.name,
|
||||
username=str(username or ""),
|
||||
md5=md5_norm,
|
||||
fileId=file_id_norm,
|
||||
deepScan=bool(deep_scan),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
# 优先从解密资源目录读取(更快)
|
||||
if md5:
|
||||
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
|
||||
# Fast path: cached decoded thumbnail resource.
|
||||
if md5_norm:
|
||||
cache_started_at = time.perf_counter()
|
||||
decrypted_path = _try_find_decrypted_resource(account_dir, md5_norm)
|
||||
trace(
|
||||
"decrypted-cache:path-lookup",
|
||||
hasPath=bool(decrypted_path),
|
||||
path=str(decrypted_path or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - cache_started_at) * 1000.0, 1),
|
||||
)
|
||||
if decrypted_path:
|
||||
read_started_at = time.perf_counter()
|
||||
data = decrypted_path.read_bytes()
|
||||
media_type = _detect_image_media_type(data[:32])
|
||||
trace(
|
||||
"decrypted-cache:read-validate",
|
||||
path=str(decrypted_path),
|
||||
bytes=len(data or b""),
|
||||
mediaType=media_type,
|
||||
elapsedMsLocal=round((time.perf_counter() - read_started_at) * 1000.0, 1),
|
||||
)
|
||||
trace("response:ready", result="decrypted-cache-hit", mediaType=media_type, bytes=len(data or b""))
|
||||
return Response(content=data, media_type=media_type)
|
||||
else:
|
||||
trace("decrypted-cache:skipped", reason="missing-md5")
|
||||
|
||||
# 回退到原始逻辑
|
||||
# Fallback: locate and decode from WeChat data directories.
|
||||
roots_started_at = time.perf_counter()
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
hardlink_db_path = account_dir / "hardlink.db"
|
||||
extra_roots: list[Path] = []
|
||||
@@ -1837,53 +2198,149 @@ async def get_chat_video_thumb(
|
||||
roots.append(wxid_dir)
|
||||
if db_storage_dir:
|
||||
roots.append(db_storage_dir)
|
||||
trace(
|
||||
"roots:resolved",
|
||||
hasWxidDir=bool(wxid_dir),
|
||||
wxidDir=str(wxid_dir or ""),
|
||||
hasDbStorageDir=bool(db_storage_dir),
|
||||
dbStorageDir=str(db_storage_dir or ""),
|
||||
hardlinkHasVideoTable=bool(hardlink_has_video_table),
|
||||
elapsedMsLocal=round((time.perf_counter() - roots_started_at) * 1000.0, 1),
|
||||
)
|
||||
if not roots:
|
||||
trace("response:error", result="roots-not-found")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
||||
)
|
||||
|
||||
p: Optional[Path] = None
|
||||
if md5:
|
||||
p = _resolve_media_path_from_hardlink(
|
||||
allow_deep_scan = False
|
||||
if md5_norm:
|
||||
hardlink_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_media_path_from_hardlink,
|
||||
hardlink_db_path,
|
||||
roots[0],
|
||||
md5=str(md5),
|
||||
md5=md5_norm,
|
||||
kind="video_thumb",
|
||||
username=username,
|
||||
extra_roots=roots[1:],
|
||||
)
|
||||
trace(
|
||||
"source:hardlink-lookup",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - hardlink_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
# Many WeChat builds store video thumbnails directly as `{md5}_thumb.jpg` under msg/video/YYYY-MM.
|
||||
# This fast probe avoids an expensive recursive scan on misses.
|
||||
# WeFlow-style lookup: build a short-lived index of msg/video/YYYY-MM and resolve by local file token.
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
p = _fast_probe_video_path_by_md5(
|
||||
md5=str(md5),
|
||||
index_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_video_path_from_weflow_index,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=True,
|
||||
)
|
||||
trace(
|
||||
"source:weflow-video-index",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - index_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
# Many WeChat builds store video thumbnails directly as `{md5}_thumb.jpg` under msg/video/YYYY-MM.
|
||||
# This direct probe is retained as a cheap fallback when the index misses.
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
fast_probe_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_fast_probe_video_path_by_md5,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=True,
|
||||
)
|
||||
trace(
|
||||
"source:fast-probe",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - fast_probe_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
realtime_started_at = time.perf_counter()
|
||||
p, resolved_token = await asyncio.to_thread(
|
||||
_resolve_video_path_from_realtime_hardlink,
|
||||
account_dir=account_dir,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=True,
|
||||
)
|
||||
trace(
|
||||
"source:realtime-hardlink",
|
||||
found=bool(p),
|
||||
resolvedToken=str(resolved_token or ""),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - realtime_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
allow_deep_scan = bool(deep_scan) or (not hardlink_has_video_table)
|
||||
if (not p) and wxid_dir and allow_deep_scan:
|
||||
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="video_thumb")
|
||||
deep_scan_started_at = time.perf_counter()
|
||||
hit = await asyncio.to_thread(_fallback_search_media_by_md5, str(wxid_dir), md5_norm, kind="video_thumb")
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
if (not p) and file_id:
|
||||
trace(
|
||||
"source:deep-scan",
|
||||
found=bool(hit),
|
||||
path=str(hit or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - deep_scan_started_at) * 1000.0, 1),
|
||||
)
|
||||
if (not p) and file_id_norm:
|
||||
file_id_started_at = time.perf_counter()
|
||||
file_id_roots_checked = 0
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video_thumb", username=str(username or ""))
|
||||
file_id_roots_checked += 1
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
file_id_norm,
|
||||
kind="video_thumb",
|
||||
username=str(username or ""),
|
||||
)
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
break
|
||||
trace(
|
||||
"source:file-id-lookup",
|
||||
found=bool(p),
|
||||
rootsChecked=file_id_roots_checked,
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - file_id_started_at) * 1000.0, 1),
|
||||
)
|
||||
if not p:
|
||||
trace("response:error", result="source-not-found", allowDeepScan=bool(allow_deep_scan))
|
||||
raise HTTPException(status_code=404, detail="Video thumbnail not found.")
|
||||
|
||||
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
read_started_at = time.perf_counter()
|
||||
data, media_type = await asyncio.to_thread(_read_and_maybe_decrypt_media, p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
trace(
|
||||
"decode:done",
|
||||
path=str(p),
|
||||
mediaType=media_type,
|
||||
bytes=len(data or b""),
|
||||
elapsedMsLocal=round((time.perf_counter() - read_started_at) * 1000.0, 1),
|
||||
)
|
||||
trace("response:ready", result="decoded", mediaType=media_type, bytes=len(data or b""))
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/api/chat/media/video", summary="获取视频资源")
|
||||
@router.get("/api/chat/media/video", summary="Get video media")
|
||||
async def get_chat_video(
|
||||
md5: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
@@ -1895,14 +2352,36 @@ async def get_chat_video(
|
||||
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||
account_dir = _resolve_account_dir(account)
|
||||
md5_norm = str(md5 or "").strip().lower() if md5 else ""
|
||||
file_id_norm = str(file_id or "").strip()
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.video",
|
||||
account=account_dir.name,
|
||||
username=str(username or ""),
|
||||
md5=md5_norm,
|
||||
fileId=file_id_norm,
|
||||
deepScan=bool(deep_scan),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
if md5_norm:
|
||||
# 优先从解密资源目录读取(更快,且支持 Range)
|
||||
# Fast path Range?
|
||||
cache_started_at = time.perf_counter()
|
||||
decrypted_path = _try_find_decrypted_resource(account_dir, md5_norm)
|
||||
trace(
|
||||
"decrypted-cache:path-lookup",
|
||||
hasPath=bool(decrypted_path),
|
||||
path=str(decrypted_path or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - cache_started_at) * 1000.0, 1),
|
||||
)
|
||||
if decrypted_path:
|
||||
mt = _guess_media_type_by_path(decrypted_path, fallback="video/mp4")
|
||||
trace("response:ready", result="decrypted-cache-hit", mediaType=mt, path=str(decrypted_path))
|
||||
return FileResponse(str(decrypted_path), media_type=mt)
|
||||
else:
|
||||
trace("decrypted-cache:skipped", reason="missing-md5")
|
||||
|
||||
roots_started_at = time.perf_counter()
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
hardlink_db_path = account_dir / "hardlink.db"
|
||||
extra_roots: list[Path] = []
|
||||
@@ -1916,14 +2395,28 @@ async def get_chat_video(
|
||||
roots.append(wxid_dir)
|
||||
if db_storage_dir:
|
||||
roots.append(db_storage_dir)
|
||||
trace(
|
||||
"roots:resolved",
|
||||
hasWxidDir=bool(wxid_dir),
|
||||
wxidDir=str(wxid_dir or ""),
|
||||
hasDbStorageDir=bool(db_storage_dir),
|
||||
dbStorageDir=str(db_storage_dir or ""),
|
||||
hardlinkHasVideoTable=bool(hardlink_has_video_table),
|
||||
elapsedMsLocal=round((time.perf_counter() - roots_started_at) * 1000.0, 1),
|
||||
)
|
||||
if not roots:
|
||||
trace("response:error", result="roots-not-found")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
||||
)
|
||||
|
||||
p: Optional[Path] = None
|
||||
allow_deep_scan = False
|
||||
if md5_norm:
|
||||
p = _resolve_media_path_from_hardlink(
|
||||
hardlink_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_media_path_from_hardlink,
|
||||
hardlink_db_path,
|
||||
roots[0],
|
||||
md5=md5_norm,
|
||||
@@ -1931,58 +2424,163 @@ async def get_chat_video(
|
||||
username=username,
|
||||
extra_roots=roots[1:],
|
||||
)
|
||||
trace(
|
||||
"source:hardlink-lookup",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - hardlink_started_at) * 1000.0, 1),
|
||||
)
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
p = _fast_probe_video_path_by_md5(
|
||||
index_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_video_path_from_weflow_index,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=False,
|
||||
)
|
||||
trace(
|
||||
"source:weflow-video-index",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - index_started_at) * 1000.0, 1),
|
||||
)
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
fast_probe_started_at = time.perf_counter()
|
||||
p = await asyncio.to_thread(
|
||||
_fast_probe_video_path_by_md5,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=False,
|
||||
)
|
||||
trace(
|
||||
"source:fast-probe",
|
||||
found=bool(p),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - fast_probe_started_at) * 1000.0, 1),
|
||||
)
|
||||
if (not p) and (wxid_dir or db_storage_dir):
|
||||
realtime_started_at = time.perf_counter()
|
||||
p, resolved_token = await asyncio.to_thread(
|
||||
_resolve_video_path_from_realtime_hardlink,
|
||||
account_dir=account_dir,
|
||||
md5=md5_norm,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_dir=db_storage_dir,
|
||||
want_thumb=False,
|
||||
)
|
||||
trace(
|
||||
"source:realtime-hardlink",
|
||||
found=bool(p),
|
||||
resolvedToken=str(resolved_token or ""),
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - realtime_started_at) * 1000.0, 1),
|
||||
)
|
||||
allow_deep_scan = bool(deep_scan) or (not hardlink_has_video_table)
|
||||
if (not p) and wxid_dir and allow_deep_scan:
|
||||
hit = _fallback_search_media_by_md5(str(wxid_dir), md5_norm, kind="video")
|
||||
deep_scan_started_at = time.perf_counter()
|
||||
hit = await asyncio.to_thread(_fallback_search_media_by_md5, str(wxid_dir), md5_norm, kind="video")
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
if (not p) and file_id:
|
||||
trace(
|
||||
"source:deep-scan",
|
||||
found=bool(hit),
|
||||
path=str(hit or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - deep_scan_started_at) * 1000.0, 1),
|
||||
)
|
||||
if (not p) and file_id_norm:
|
||||
file_id_started_at = time.perf_counter()
|
||||
file_id_roots_checked = 0
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video", username=str(username or ""))
|
||||
file_id_roots_checked += 1
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
file_id_norm,
|
||||
kind="video",
|
||||
username=str(username or ""),
|
||||
)
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
break
|
||||
trace(
|
||||
"source:file-id-lookup",
|
||||
found=bool(p),
|
||||
rootsChecked=file_id_roots_checked,
|
||||
path=str(p or ""),
|
||||
elapsedMsLocal=round((time.perf_counter() - file_id_started_at) * 1000.0, 1),
|
||||
)
|
||||
if not p:
|
||||
trace("response:error", result="source-not-found", allowDeepScan=bool(allow_deep_scan))
|
||||
raise HTTPException(status_code=404, detail="Video not found.")
|
||||
|
||||
# 直接可播放的 MP4:直接 FileResponse(支持 Range)
|
||||
# Fast path MP4??? FileResponse??? Range?
|
||||
probe_started_at = time.perf_counter()
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
head = f.read(8)
|
||||
if len(head) >= 8 and head[4:8] == b"ftyp":
|
||||
is_plain_mp4 = bool(len(head) >= 8 and head[4:8] == b"ftyp")
|
||||
trace(
|
||||
"decode:probe-plain-mp4",
|
||||
path=str(p),
|
||||
isPlainMp4=is_plain_mp4,
|
||||
elapsedMsLocal=round((time.perf_counter() - probe_started_at) * 1000.0, 1),
|
||||
)
|
||||
if is_plain_mp4:
|
||||
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
|
||||
trace("response:ready", result="plain-file", mediaType=media_type, path=str(p))
|
||||
return FileResponse(str(p), media_type=media_type)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
trace(
|
||||
"decode:probe-plain-mp4",
|
||||
path=str(p),
|
||||
error=str(e)[:200],
|
||||
elapsedMsLocal=round((time.perf_counter() - probe_started_at) * 1000.0, 1),
|
||||
)
|
||||
|
||||
# 尝试解密/去前缀并落盘(避免一次性返回大文件 bytes)
|
||||
# Fast path/????????????????? bytes?
|
||||
if md5_norm:
|
||||
materialize_started_at = time.perf_counter()
|
||||
try:
|
||||
materialized = _ensure_decrypted_resource_for_md5(
|
||||
materialized = await asyncio.to_thread(
|
||||
_ensure_decrypted_resource_for_md5,
|
||||
account_dir,
|
||||
md5=md5_norm,
|
||||
source_path=p,
|
||||
weixin_root=wxid_dir,
|
||||
)
|
||||
except Exception:
|
||||
materialize_error = ""
|
||||
except Exception as e:
|
||||
materialized = None
|
||||
materialize_error = str(e)
|
||||
trace(
|
||||
"decode:materialize",
|
||||
found=bool(materialized),
|
||||
path=str(materialized or ""),
|
||||
error=materialize_error[:200],
|
||||
elapsedMsLocal=round((time.perf_counter() - materialize_started_at) * 1000.0, 1),
|
||||
)
|
||||
if materialized:
|
||||
media_type = _guess_media_type_by_path(materialized, fallback="video/mp4")
|
||||
trace("response:ready", result="materialized", mediaType=media_type, path=str(materialized))
|
||||
return FileResponse(str(materialized), media_type=media_type)
|
||||
|
||||
# 最后兜底:直接返回处理后的 bytes(不支持 Range)
|
||||
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
# Fast path bytes???? Range?
|
||||
read_started_at = time.perf_counter()
|
||||
data, media_type = await asyncio.to_thread(_read_and_maybe_decrypt_media, p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
if media_type == "application/octet-stream":
|
||||
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
|
||||
trace(
|
||||
"decode:bytes-fallback",
|
||||
path=str(p),
|
||||
mediaType=media_type,
|
||||
bytes=len(data or b""),
|
||||
elapsedMsLocal=round((time.perf_counter() - read_started_at) * 1000.0, 1),
|
||||
)
|
||||
trace("response:ready", result="bytes-fallback", mediaType=media_type, bytes=len(data or b""))
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
|
||||
|
||||
@@ -403,6 +403,7 @@ async def decrypt_databases_stream(
|
||||
if (
|
||||
(not bool(db_diagnostic.get("success", ok)))
|
||||
or int(db_diagnostic.get("failed_pages") or 0) > 0
|
||||
or int(db_diagnostic.get("hmac_warning_pages") or 0) > 0
|
||||
or str(db_diagnostic.get("diagnostic_status") or "") != "ok"
|
||||
):
|
||||
account_diagnostic_warning_count += 1
|
||||
@@ -434,8 +435,11 @@ async def decrypt_databases_stream(
|
||||
if db_diagnostic:
|
||||
payload["diagnostic_status"] = str(db_diagnostic.get("diagnostic_status") or "")
|
||||
payload["page_failures"] = int(db_diagnostic.get("failed_pages") or 0)
|
||||
payload["hmac_warning_pages"] = int(db_diagnostic.get("hmac_warning_pages") or 0)
|
||||
if db_diagnostic.get("failed_page_samples"):
|
||||
payload["failed_page_samples"] = db_diagnostic.get("failed_page_samples")
|
||||
if db_diagnostic.get("hmac_warning_samples"):
|
||||
payload["hmac_warning_samples"] = db_diagnostic.get("hmac_warning_samples")
|
||||
if db_diagnostic.get("diagnostics"):
|
||||
payload["diagnostics"] = db_diagnostic.get("diagnostics")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from ..img_helper import IMG_HELPER
|
||||
from .wechat_detection import check_wechat_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -32,4 +34,35 @@ async def pick_directory(title: str = "请选择目录", initial_dir: str = ""):
|
||||
# 在子线程中执行 GUI 操作
|
||||
folder_path = await loop.run_in_executor(pool, _open_folder_dialog, title, initial_dir)
|
||||
|
||||
return {"path": folder_path}
|
||||
return {"path": folder_path}
|
||||
|
||||
|
||||
@router.get("/api/system/img_helper/status", summary="获取大图下载辅助插件状态")
|
||||
async def get_img_helper_status():
|
||||
return {
|
||||
"enabled": IMG_HELPER.is_enabled
|
||||
}
|
||||
|
||||
|
||||
class ImgHelperToggleRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.post("/api/system/img_helper/toggle", summary="开启/关闭大图下载辅助插件")
|
||||
async def toggle_img_helper(req: ImgHelperToggleRequest):
|
||||
if not req.enabled:
|
||||
IMG_HELPER.disable()
|
||||
return {"status": "success", "enabled": False}
|
||||
|
||||
# Attempt to enable
|
||||
status_res = await check_wechat_status()
|
||||
wx_status = status_res.get("wx_status", {})
|
||||
if not wx_status.get("is_running") or not wx_status.get("pid"):
|
||||
raise HTTPException(status_code=400, detail="未检测到微信正在运行,请先打开微信再尝试!")
|
||||
|
||||
pid = wx_status["pid"]
|
||||
ok, err = IMG_HELPER.enable(pid)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=f"开启失败: {err}")
|
||||
|
||||
return {"status": "success", "enabled": True}
|
||||
|
||||
@@ -90,8 +90,11 @@ async def detect_current_account(data_root_path: Optional[str] = None):
|
||||
@router.get("/api/wechat/status", summary="检查微信运行状态")
|
||||
async def check_wechat_status():
|
||||
"""
|
||||
检查系统中是否有 Weixin.exe 或 WeChat.exe 进程在运行
|
||||
返回: status=0 成功, wx_status={is_running: bool, pid: int, ...}
|
||||
检查系统微信主进程状态
|
||||
逻辑:
|
||||
1. 匹配进程名 Weixin.exe 或 WeChat.exe
|
||||
2. 校验命令行必须包含 exe 名称(排除崩溃后的残留/无效进程)
|
||||
3. 在有效进程中选择命令行最短的一个作为主进程
|
||||
"""
|
||||
process_name_targets = ["Weixin.exe", "WeChat.exe"]
|
||||
|
||||
@@ -103,21 +106,37 @@ async def check_wechat_status():
|
||||
}
|
||||
|
||||
try:
|
||||
for proc in psutil.process_iter(['pid', 'name', 'exe', 'memory_info']):
|
||||
candidates = []
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'exe', 'memory_info', 'cmdline']):
|
||||
try:
|
||||
if proc.info['name'] and proc.info['name'] in process_name_targets:
|
||||
wx_status["is_running"] = True
|
||||
wx_status["pid"] = proc.info['pid']
|
||||
wx_status["exe_path"] = proc.info['exe']
|
||||
p_name = proc.info.get('name')
|
||||
if p_name and p_name in process_name_targets:
|
||||
# 获取命令行并合并为字符串
|
||||
cmdline_list = proc.info.get('cmdline') or []
|
||||
cmdline_str = " ".join(cmdline_list).lower()
|
||||
|
||||
mem = proc.info['memory_info']
|
||||
if mem:
|
||||
wx_status["memory_usage_mb"] = round(mem.rss / (1024 * 1024), 2)
|
||||
|
||||
break
|
||||
if any(target.lower() in cmdline_str for target in process_name_targets):
|
||||
candidates.append({
|
||||
"pid": proc.info['pid'],
|
||||
"exe_path": proc.info['exe'],
|
||||
"cmd_len": len(cmdline_str),
|
||||
"memory_info": proc.info['memory_info']
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
if candidates:
|
||||
main_proc = min(candidates, key=lambda x: x['cmd_len'])
|
||||
|
||||
wx_status["is_running"] = True
|
||||
wx_status["pid"] = main_proc["pid"]
|
||||
wx_status["exe_path"] = main_proc["exe_path"]
|
||||
|
||||
mem = main_proc["memory_info"]
|
||||
if mem:
|
||||
wx_status["memory_usage_mb"] = round(mem.rss / (1024 * 1024), 2)
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"errmsg": "ok",
|
||||
@@ -125,9 +144,8 @@ async def check_wechat_status():
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 即使出错也返回 JSON,但 status 非 0
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"检查进程失败: {str(e)}",
|
||||
"errmsg": f"检查微信主进程失败: {str(e)}",
|
||||
"wx_status": wx_status
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ from .chat_helpers import (
|
||||
_should_keep_session,
|
||||
)
|
||||
from .logging_config import get_logger
|
||||
from .sqlite_diagnostics import collect_sqlite_diagnostics, format_sqlite_diagnostics
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -241,11 +242,13 @@ def build_session_last_message_table(
|
||||
|
||||
best: dict[str, tuple[tuple[int, int, int], dict[str, Any]]] = {}
|
||||
|
||||
skipped_dbs = 0
|
||||
for db_path in db_paths:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.text_factory = bytes
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.text_factory = bytes
|
||||
trows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
md5_to_table: dict[str, str] = {}
|
||||
for tr in trows:
|
||||
@@ -414,11 +417,22 @@ def build_session_last_message_table(
|
||||
"table_name": str(table_name),
|
||||
},
|
||||
)
|
||||
except sqlite3.DatabaseError as e:
|
||||
skipped_dbs += 1
|
||||
logger.warning(
|
||||
"[session_last_message] malformed message db skipped account=%s db=%s error=%s diag=%s",
|
||||
account_dir.name,
|
||||
str(db_path),
|
||||
str(e),
|
||||
format_sqlite_diagnostics(collect_sqlite_diagnostics(db_path, quick_check=True)),
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: always have a non-empty preview for UI.
|
||||
for r in sessions:
|
||||
@@ -493,7 +507,7 @@ def build_session_last_message_table(
|
||||
duration = max(0.0, time.time() - started)
|
||||
logger.info(
|
||||
f"[session_last_message] build done account={account_dir.name} sessions={len(best)} "
|
||||
f"durationSec={round(duration, 3)} table={_TABLE_NAME}"
|
||||
f"durationSec={round(duration, 3)} table={_TABLE_NAME} skippedDbs={skipped_dbs}"
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -501,4 +515,5 @@ def build_session_last_message_table(
|
||||
"built": len(best),
|
||||
"table": _TABLE_NAME,
|
||||
"durationSec": round(duration, 3),
|
||||
"skippedDbs": int(skipped_dbs),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,46 @@ class WCDBRealtimeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _clean_weflow_account_dir_name(dir_name: str) -> str:
|
||||
"""调用 WCDB 前使用与 WeFlow 相同的账号/wxid 清理规则。"""
|
||||
trimmed = str(dir_name or "").strip()
|
||||
if not trimmed:
|
||||
return trimmed
|
||||
|
||||
if trimmed.lower().startswith("wxid_"):
|
||||
match = re.match(r"^(wxid_[^_]+)", trimmed, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return trimmed
|
||||
|
||||
suffix_match = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", trimmed)
|
||||
return suffix_match.group(1) if suffix_match else trimmed
|
||||
|
||||
|
||||
def _derive_weflow_wcdb_wxid(account: str, db_storage_dir: Optional[Path] = None) -> str:
|
||||
"""推导传给 native WCDB 的 wxid,语义对齐 WeFlow。
|
||||
|
||||
output 账号目录可能带随机后缀,例如 `Murderers_0e5d`。
|
||||
WeFlow 在调用 `wcdb_set_my_wxid` 前会去掉这个后缀;如果传带后缀的名字,
|
||||
native 会话/消息查询可能只返回很少结果。
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
if db_storage_dir is not None:
|
||||
try:
|
||||
parent_name = Path(db_storage_dir).parent.name
|
||||
if parent_name:
|
||||
candidates.append(parent_name)
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append(str(account or ""))
|
||||
|
||||
for item in candidates:
|
||||
cleaned = _clean_weflow_account_dir_name(item)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return str(account or "").strip()
|
||||
|
||||
|
||||
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
|
||||
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
||||
_WCDB_API_DLL_SELECTED: Optional[Path] = None
|
||||
@@ -1459,6 +1499,7 @@ def _resolve_session_db_path(db_storage_dir: Path) -> Path:
|
||||
@dataclass(frozen=True)
|
||||
class WCDBRealtimeConnection:
|
||||
account: str
|
||||
native_wxid: str
|
||||
handle: int
|
||||
db_storage_dir: Path
|
||||
session_db_path: Path
|
||||
@@ -1484,13 +1525,16 @@ class WCDBRealtimeManager:
|
||||
|
||||
db_storage_dir = None
|
||||
session_db_path = None
|
||||
native_wxid = ""
|
||||
err = ""
|
||||
try:
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
if db_storage_dir is not None:
|
||||
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
|
||||
|
||||
dll_path = _resolve_wcdb_api_dll_path()
|
||||
try:
|
||||
@@ -1503,6 +1547,7 @@ class WCDBRealtimeManager:
|
||||
"dll_present": bool(dll_ok),
|
||||
"wcdb_api_dll": str(dll_path),
|
||||
"key_present": bool(key_ok),
|
||||
"native_wxid": native_wxid,
|
||||
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
|
||||
"session_db_path": str(session_db_path) if session_db_path else "",
|
||||
"connected": bool(connected),
|
||||
@@ -1565,6 +1610,7 @@ class WCDBRealtimeManager:
|
||||
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
|
||||
|
||||
# Run open_account in a daemon thread with a timeout to avoid
|
||||
# blocking indefinitely when the native library hangs (locked DB).
|
||||
@@ -1609,14 +1655,16 @@ class WCDBRealtimeManager:
|
||||
raise WCDBRealtimeError("open_account returned no handle.")
|
||||
|
||||
handle = _handle_box[0]
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
# 对齐 WeFlow:传清理后的 wxid/account 名称给 native WCDB,
|
||||
# 不传带 4 位随机后缀的导出目录名。
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
set_my_wxid(handle, native_wxid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = WCDBRealtimeConnection(
|
||||
account=account,
|
||||
native_wxid=native_wxid,
|
||||
handle=handle,
|
||||
db_storage_dir=db_storage_dir,
|
||||
session_db_path=session_db_path,
|
||||
@@ -1627,7 +1675,13 @@ class WCDBRealtimeManager:
|
||||
with self._mu:
|
||||
self._conns[account] = conn
|
||||
self._failed.pop(account, None)
|
||||
logger.info("[wcdb] connected account=%s handle=%s session_db=%s", account, int(handle), session_db_path)
|
||||
logger.info(
|
||||
"[wcdb] connected account=%s native_wxid=%s handle=%s session_db=%s",
|
||||
account,
|
||||
native_wxid,
|
||||
int(handle),
|
||||
session_db_path,
|
||||
)
|
||||
return conn
|
||||
finally:
|
||||
with self._mu:
|
||||
|
||||
@@ -13,12 +13,13 @@ import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from .app_paths import get_output_databases_dir
|
||||
from .database_filters import should_skip_source_database
|
||||
@@ -28,6 +29,270 @@ from .sqlite_diagnostics import collect_sqlite_diagnostics, sqlite_diagnostics_s
|
||||
|
||||
# SQLite文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
PAGE_SIZE = 4096
|
||||
KEY_SIZE = 32
|
||||
SALT_SIZE = 16
|
||||
IV_SIZE = 16
|
||||
HMAC_SIZE = 64
|
||||
# WeChat 4.x SQLCipher/WCDB pages reserve IV + HMAC at the tail.
|
||||
# When exporting to plain SQLite, do not keep encrypted IV/HMAC bytes in output pages.
|
||||
RESERVE_SIZE = IV_SIZE + HMAC_SIZE
|
||||
|
||||
|
||||
def _derive_mac_key(enc_key: bytes, salt: bytes) -> bytes:
|
||||
"""Derive SQLCipher/WCDB page HMAC key."""
|
||||
mac_salt = bytes(b ^ 0x3A for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _derive_sqlcipher_enc_key(key_material: bytes, salt: bytes) -> bytes:
|
||||
"""Derive AES enc_key from SQLCipher passphrase/base key."""
|
||||
return hashlib.pbkdf2_hmac("sha512", key_material, salt, 256000, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(page[offset:data_end])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return mac.digest()
|
||||
|
||||
|
||||
def _compute_page_hmac_variant(
|
||||
mac_key: bytes,
|
||||
page: bytes,
|
||||
page_num: int,
|
||||
*,
|
||||
endian: str = "little",
|
||||
include_iv: bool = True,
|
||||
) -> bytes:
|
||||
"""用于诊断的 HMAC 变体计算,不参与实际解密决策。"""
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
data_end = PAGE_SIZE - RESERVE_SIZE + (IV_SIZE if include_iv else 0)
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(page[offset:data_end])
|
||||
mac.update(page_num.to_bytes(4, endian))
|
||||
return mac.digest()
|
||||
|
||||
|
||||
def _hash_prefix(data: bytes, *, length: int = 16) -> str:
|
||||
"""返回 SHA256 前缀,避免日志输出明文数据。"""
|
||||
try:
|
||||
return hashlib.sha256(bytes(data or b"")).hexdigest()[: max(int(length), 8)]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _hex_prefix(data: bytes, *, length: int = 32) -> str:
|
||||
try:
|
||||
return bytes(data or b"")[: max(int(length), 0)].hex()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_file_snapshot(path: str | Path) -> dict[str, Any]:
|
||||
"""采集源/输出文件与 WAL 旁路文件信息,用于定位解密时文件是否变化。"""
|
||||
p = Path(path)
|
||||
out: dict[str, Any] = {"path": str(p), "exists": False}
|
||||
try:
|
||||
st = p.stat()
|
||||
out.update(
|
||||
{
|
||||
"exists": True,
|
||||
"size": int(st.st_size),
|
||||
"mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
out["stat_error"] = f"{type(exc).__name__}: {' '.join(str(exc).split())[:180]}"
|
||||
|
||||
siblings: dict[str, Any] = {}
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
sp = Path(str(p) + suffix)
|
||||
try:
|
||||
st = sp.stat()
|
||||
siblings[suffix] = {
|
||||
"exists": True,
|
||||
"size": int(st.st_size),
|
||||
"mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
|
||||
}
|
||||
except FileNotFoundError:
|
||||
siblings[suffix] = {"exists": False}
|
||||
except Exception as exc:
|
||||
siblings[suffix] = {
|
||||
"exists": False,
|
||||
"stat_error": f"{type(exc).__name__}: {' '.join(str(exc).split())[:180]}",
|
||||
}
|
||||
out["siblings"] = siblings
|
||||
return out
|
||||
|
||||
|
||||
def _read_plain_sqlite_header_debug(path: str | Path) -> dict[str, Any]:
|
||||
"""解析明文 SQLite 头部关键字段,帮助定位输出库结构问题。"""
|
||||
p = Path(path)
|
||||
out: dict[str, Any] = {"path": str(p)}
|
||||
try:
|
||||
with p.open("rb") as f:
|
||||
header = f.read(100)
|
||||
out["header_len"] = len(header)
|
||||
out["header_ok"] = header.startswith(SQLITE_HEADER)
|
||||
out["header_hex"] = header[:32].hex()
|
||||
if len(header) >= 100:
|
||||
raw_page_size = struct.unpack(">H", header[16:18])[0]
|
||||
out.update(
|
||||
{
|
||||
"page_size_header": 65536 if raw_page_size == 1 else int(raw_page_size),
|
||||
"write_version": int(header[18]),
|
||||
"read_version": int(header[19]),
|
||||
"reserved_space": int(header[20]),
|
||||
"max_payload_fraction": int(header[21]),
|
||||
"min_payload_fraction": int(header[22]),
|
||||
"leaf_payload_fraction": int(header[23]),
|
||||
"file_change_counter": int.from_bytes(header[24:28], "big"),
|
||||
"db_size_pages_header": int.from_bytes(header[28:32], "big"),
|
||||
"freelist_trunk_page": int.from_bytes(header[32:36], "big"),
|
||||
"freelist_pages": int.from_bytes(header[36:40], "big"),
|
||||
"schema_cookie": int.from_bytes(header[40:44], "big"),
|
||||
"schema_format": int.from_bytes(header[44:48], "big"),
|
||||
"text_encoding": int.from_bytes(header[56:60], "big"),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
out["error"] = f"{type(exc).__name__}: {' '.join(str(exc).split())[:180]}"
|
||||
return out
|
||||
|
||||
|
||||
def _plain_page_btree_debug(page_plain: bytes, page_num: int) -> dict[str, Any]:
|
||||
"""解析明文页 B-tree 页头摘要,不输出任何业务明文。"""
|
||||
out: dict[str, Any] = {"page": int(page_num), "plain_sha256": _hash_prefix(page_plain, length=24)}
|
||||
try:
|
||||
hdr = 100 if int(page_num) == 1 else 0
|
||||
if len(page_plain) >= hdr + 12:
|
||||
page_type = int(page_plain[hdr])
|
||||
out["btree_header_offset"] = int(hdr)
|
||||
out["btree_page_type"] = page_type
|
||||
out["btree_page_type_name"] = {
|
||||
2: "interior_index",
|
||||
5: "interior_table",
|
||||
10: "leaf_index",
|
||||
13: "leaf_table",
|
||||
}.get(page_type, "unknown")
|
||||
out["first_freeblock"] = int.from_bytes(page_plain[hdr + 1 : hdr + 3], "big")
|
||||
out["cell_count"] = int.from_bytes(page_plain[hdr + 3 : hdr + 5], "big")
|
||||
out["cell_content_area"] = int.from_bytes(page_plain[hdr + 5 : hdr + 7], "big")
|
||||
out["fragmented_free_bytes"] = int(page_plain[hdr + 7])
|
||||
if page_type in (2, 5):
|
||||
out["right_most_pointer"] = int.from_bytes(page_plain[hdr + 8 : hdr + 12], "big")
|
||||
except Exception as exc:
|
||||
out["btree_parse_error"] = f"{type(exc).__name__}: {' '.join(str(exc).split())[:160]}"
|
||||
return out
|
||||
|
||||
|
||||
def _build_page_anomaly_debug(
|
||||
enc_key: bytes,
|
||||
mac_key: bytes,
|
||||
page: bytes,
|
||||
page_num: int,
|
||||
*,
|
||||
stored_hmac: bytes | None = None,
|
||||
expected_hmac: bytes | None = None,
|
||||
reason: str = "hmac",
|
||||
) -> dict[str, Any]:
|
||||
"""构造异常页诊断信息,默认只记录哈希/页头摘要。"""
|
||||
page = bytes(page or b"")
|
||||
stored = stored_hmac if stored_hmac is not None else page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
|
||||
expected = expected_hmac if expected_hmac is not None else _compute_page_hmac(mac_key, page, page_num)
|
||||
iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
|
||||
encrypted_payload = page[SALT_SIZE if page_num == 1 else 0 : PAGE_SIZE - RESERVE_SIZE]
|
||||
out: dict[str, Any] = {
|
||||
"reason": str(reason),
|
||||
"page": int(page_num),
|
||||
"byte_start": int((int(page_num) - 1) * PAGE_SIZE),
|
||||
"byte_end_exclusive": int(int(page_num) * PAGE_SIZE),
|
||||
"page_size": int(len(page)),
|
||||
"page_sha256": _hash_prefix(page, length=24),
|
||||
"encrypted_payload_sha256": _hash_prefix(encrypted_payload, length=24),
|
||||
"iv_hex": _hex_prefix(iv, length=16),
|
||||
"stored_hmac_prefix": _hex_prefix(stored, length=16),
|
||||
"expected_hmac_prefix": _hex_prefix(expected, length=16),
|
||||
"hmac_match_current": bool(hmac.compare_digest(stored, expected)),
|
||||
}
|
||||
|
||||
variants: dict[str, bool] = {}
|
||||
for candidate_page in (page_num - 1, page_num, page_num + 1):
|
||||
if candidate_page <= 0:
|
||||
continue
|
||||
for endian in ("little", "big"):
|
||||
for include_iv in (True, False):
|
||||
key = f"page={candidate_page};endian={endian};include_iv={int(include_iv)}"
|
||||
try:
|
||||
variants[key] = bool(
|
||||
hmac.compare_digest(
|
||||
stored,
|
||||
_compute_page_hmac_variant(
|
||||
mac_key,
|
||||
page,
|
||||
int(candidate_page),
|
||||
endian=endian,
|
||||
include_iv=include_iv,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
variants[key] = False
|
||||
out["hmac_variant_matches"] = [k for k, v in variants.items() if v]
|
||||
|
||||
try:
|
||||
plain_page = _decrypt_page(enc_key, page, int(page_num))
|
||||
out["aes_decrypt_ok"] = True
|
||||
out["plain"] = _plain_page_btree_debug(plain_page, int(page_num))
|
||||
except Exception as exc:
|
||||
out["aes_decrypt_ok"] = False
|
||||
out["aes_error"] = f"{type(exc).__name__}: {' '.join(str(exc).split())[:180]}"
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_page1_key_material(key_material: bytes, page1: bytes) -> tuple[bytes, bytes, str] | None:
|
||||
"""Detect whether input key is raw enc_key or SQLCipher passphrase by page-1 HMAC."""
|
||||
if len(page1) < PAGE_SIZE:
|
||||
return None
|
||||
|
||||
salt = page1[:SALT_SIZE]
|
||||
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
|
||||
candidates = [
|
||||
("raw_enc_key", key_material, _derive_mac_key(key_material, salt)),
|
||||
]
|
||||
|
||||
derived_key = _derive_sqlcipher_enc_key(key_material, salt)
|
||||
candidates.append(("sqlcipher_passphrase", derived_key, _derive_mac_key(derived_key, salt)))
|
||||
|
||||
for mode, enc_key, mac_key in candidates:
|
||||
if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)):
|
||||
return enc_key, mac_key, mode
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _decrypt_page(enc_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
iv = page[PAGE_SIZE - RESERVE_SIZE: PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
encrypted_page = page[offset: PAGE_SIZE - RESERVE_SIZE]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(enc_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
|
||||
|
||||
# Plain SQLite pages do not carry SQLCipher/WCDB IV/HMAC reserve bytes.
|
||||
# Keep page size stable by zero-filling the reserve tail.
|
||||
if page_num == 1:
|
||||
return SQLITE_HEADER + decrypted_page + (b"\x00" * RESERVE_SIZE)
|
||||
return decrypted_page + (b"\x00" * RESERVE_SIZE)
|
||||
|
||||
|
||||
def _normalize_account_name(name: str) -> str:
|
||||
@@ -161,8 +426,13 @@ def _resolve_db_storage_roots(storage_path: Path) -> list[Path]:
|
||||
|
||||
|
||||
def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
storage_path = Path(str(db_storage_path or "").strip())
|
||||
logger.info("[decrypt.scan] start db_storage_path=%s", str(storage_path))
|
||||
if not storage_path.exists():
|
||||
logger.warning("[decrypt.scan] path_not_exists db_storage_path=%s", str(storage_path))
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
@@ -172,6 +442,10 @@ def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
}
|
||||
|
||||
db_roots = _resolve_db_storage_roots(storage_path)
|
||||
logger.info(
|
||||
"[decrypt.scan] resolved_roots %s",
|
||||
json.dumps([str(x) for x in db_roots], ensure_ascii=False),
|
||||
)
|
||||
if not db_roots:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -223,6 +497,30 @@ def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[decrypt.scan] databases_found %s",
|
||||
json.dumps(
|
||||
{
|
||||
"account": account_name,
|
||||
"db_storage_path": str(db_root),
|
||||
"wxid_dir": str(db_root.parent),
|
||||
"count": len(databases),
|
||||
"files": [
|
||||
{
|
||||
"name": str(item.get("name") or ""),
|
||||
"relative": str(Path(str(item.get("path") or "")).relative_to(db_root))
|
||||
if str(item.get("path") or "").startswith(str(db_root))
|
||||
else str(item.get("path") or ""),
|
||||
}
|
||||
for item in databases[:80]
|
||||
],
|
||||
"truncated": max(0, len(databases) - 80),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
|
||||
if not databases:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -303,6 +601,18 @@ class WeChatDatabaseDecryptor:
|
||||
"failed_pages": 0,
|
||||
"failed_page_samples": [],
|
||||
"failure_reasons": {},
|
||||
"hmac_warning_pages": 0,
|
||||
"hmac_warning_samples": [],
|
||||
"hmac_debug_samples": [],
|
||||
"aes_debug_samples": [],
|
||||
"source_snapshot_before": {},
|
||||
"source_snapshot_after": {},
|
||||
"source_changed_during_read": False,
|
||||
"read_ms": 0,
|
||||
"key_mode": "",
|
||||
"input_layout": {},
|
||||
"expected_output_size": 0,
|
||||
"output_header_debug": {},
|
||||
"diagnostics": {},
|
||||
"diagnostic_status": "not_run",
|
||||
"error": "",
|
||||
@@ -319,6 +629,14 @@ class WeChatDatabaseDecryptor:
|
||||
item["error"] = err[:200]
|
||||
result["failed_page_samples"].append(item)
|
||||
|
||||
def _append_hmac_warning_page(page_num: int) -> None:
|
||||
# 非首页 HMAC 异常不再直接丢弃页面:部分微信 4.x 大库在 1GiB 边界会出现
|
||||
# 单页 HMAC 不匹配,但页面本身仍可正常解密。丢页会导致后续页号整体错位。
|
||||
result["hmac_warning_pages"] = int(result.get("hmac_warning_pages") or 0) + 1
|
||||
if len(result["hmac_warning_samples"]) >= 8:
|
||||
return
|
||||
result["hmac_warning_samples"].append({"page": int(page_num), "reason": "hmac"})
|
||||
|
||||
def _finalize(success: bool, error: str = "") -> bool:
|
||||
normalized_success = bool(success)
|
||||
result["success"] = normalized_success
|
||||
@@ -335,6 +653,7 @@ class WeChatDatabaseDecryptor:
|
||||
diagnostics = collect_sqlite_diagnostics(output_file, quick_check=True)
|
||||
result["diagnostics"] = diagnostics
|
||||
result["diagnostic_status"] = sqlite_diagnostics_status(diagnostics)
|
||||
result["output_header_debug"] = _read_plain_sqlite_header_debug(output_file)
|
||||
|
||||
if normalized_success:
|
||||
failure_message = _build_decrypt_failure_message(result)
|
||||
@@ -362,6 +681,18 @@ class WeChatDatabaseDecryptor:
|
||||
"failed_pages": result["failed_pages"],
|
||||
"failure_reasons": result["failure_reasons"],
|
||||
"failed_page_samples": result["failed_page_samples"],
|
||||
"hmac_warning_pages": result["hmac_warning_pages"],
|
||||
"hmac_warning_samples": result["hmac_warning_samples"],
|
||||
"hmac_debug_samples": result["hmac_debug_samples"],
|
||||
"aes_debug_samples": result["aes_debug_samples"],
|
||||
"source_snapshot_before": result["source_snapshot_before"],
|
||||
"source_snapshot_after": result["source_snapshot_after"],
|
||||
"source_changed_during_read": result["source_changed_during_read"],
|
||||
"read_ms": result["read_ms"],
|
||||
"key_mode": result["key_mode"],
|
||||
"input_layout": result["input_layout"],
|
||||
"expected_output_size": result["expected_output_size"],
|
||||
"output_header_debug": result["output_header_debug"],
|
||||
"diagnostic_status": result["diagnostic_status"],
|
||||
"diagnostics": result["diagnostics"],
|
||||
"error": result["error"],
|
||||
@@ -370,6 +701,7 @@ class WeChatDatabaseDecryptor:
|
||||
if (
|
||||
(not result["success"])
|
||||
or int(result["failed_pages"] or 0) > 0
|
||||
or int(result.get("hmac_warning_pages") or 0) > 0
|
||||
or str(result["diagnostic_status"] or "") != "ok"
|
||||
):
|
||||
log_fn = logger.warning
|
||||
@@ -380,11 +712,81 @@ class WeChatDatabaseDecryptor:
|
||||
logger.info(f"开始解密数据库: {db_path}")
|
||||
|
||||
try:
|
||||
source_snapshot_before = _safe_file_snapshot(db_path)
|
||||
result["source_snapshot_before"] = source_snapshot_before
|
||||
logger.info(
|
||||
"[decrypt.pipeline] source_snapshot_before %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"snapshot": source_snapshot_before,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
|
||||
read_t0 = time.perf_counter()
|
||||
with open(db_path, 'rb') as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
result["read_ms"] = round((time.perf_counter() - read_t0) * 1000.0, 1)
|
||||
|
||||
source_snapshot_after = _safe_file_snapshot(db_path)
|
||||
result["source_snapshot_after"] = source_snapshot_after
|
||||
before_size = int(source_snapshot_before.get("size") or 0)
|
||||
after_size = int(source_snapshot_after.get("size") or 0)
|
||||
before_mtime = int(source_snapshot_before.get("mtime_ns") or 0)
|
||||
after_mtime = int(source_snapshot_after.get("mtime_ns") or 0)
|
||||
source_changed = bool(before_size != after_size or before_mtime != after_mtime)
|
||||
result["source_changed_during_read"] = source_changed
|
||||
logger.info(
|
||||
"[decrypt.pipeline] source_snapshot_after %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"snapshot": source_snapshot_after,
|
||||
"read_ms": result["read_ms"],
|
||||
"source_changed_during_read": source_changed,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
if source_changed:
|
||||
logger.warning(
|
||||
"[decrypt.pipeline] source_changed_during_read db=%s before_size=%s after_size=%s before_mtime_ns=%s after_mtime_ns=%s",
|
||||
result["db_name"],
|
||||
before_size,
|
||||
after_size,
|
||||
before_mtime,
|
||||
after_mtime,
|
||||
)
|
||||
|
||||
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
|
||||
result["input_size"] = int(len(encrypted_data))
|
||||
result["input_layout"] = {
|
||||
"page_size": PAGE_SIZE,
|
||||
"reserve_size": RESERVE_SIZE,
|
||||
"iv_size": IV_SIZE,
|
||||
"hmac_size": HMAC_SIZE,
|
||||
"input_size": int(len(encrypted_data)),
|
||||
"input_size_mod_page": int(len(encrypted_data) % PAGE_SIZE),
|
||||
"total_pages_floor": int(len(encrypted_data) // PAGE_SIZE),
|
||||
"total_pages_ceil": int((len(encrypted_data) + PAGE_SIZE - 1) // PAGE_SIZE),
|
||||
"starts_with_sqlite_header": bool(encrypted_data.startswith(SQLITE_HEADER)),
|
||||
"first16_hex": encrypted_data[:16].hex(),
|
||||
}
|
||||
logger.info(
|
||||
"[decrypt.pipeline] input_layout %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"input_layout": result["input_layout"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
|
||||
if len(encrypted_data) < 4096:
|
||||
logger.warning(f"文件太小,跳过解密: {db_path}")
|
||||
@@ -398,113 +800,134 @@ class WeChatDatabaseDecryptor:
|
||||
result["copied_as_sqlite"] = True
|
||||
return _finalize(True)
|
||||
|
||||
# 提取salt (前16字节)
|
||||
salt = encrypted_data[:16]
|
||||
|
||||
# 计算mac_salt (salt XOR 0x3a)
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
|
||||
# 使用PBKDF2-SHA512派生密钥
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA512(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=256000,
|
||||
backend=default_backend()
|
||||
page1 = encrypted_data[:PAGE_SIZE]
|
||||
resolved_key_material = _resolve_page1_key_material(self.key_bytes, page1)
|
||||
if resolved_key_material is None:
|
||||
_append_failed_page(1, "hmac")
|
||||
result["total_pages"] = int(len(encrypted_data) // PAGE_SIZE)
|
||||
result["failed_pages"] = 1
|
||||
logger.warning("Page 1 HMAC verification failed; key does not match database: %s", db_path)
|
||||
return _finalize(False, "key_mismatch")
|
||||
|
||||
enc_key, mac_key, key_mode = resolved_key_material
|
||||
result["key_mode"] = key_mode
|
||||
logger.info("Page 1 HMAC verification passed: mode=%s path=%s", key_mode, db_path)
|
||||
logger.info(
|
||||
"[decrypt.pipeline] key_material_resolved %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"key_mode": key_mode,
|
||||
"salt_sha256": _hash_prefix(page1[:SALT_SIZE], length=24),
|
||||
"page1_stored_hmac_prefix": _hex_prefix(page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE], length=16),
|
||||
"page1_expected_hmac_prefix": _hex_prefix(_compute_page_hmac(mac_key, page1, 1), length=16),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
derived_key = kdf.derive(self.key_bytes)
|
||||
|
||||
# 派生MAC密钥
|
||||
mac_kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA512(),
|
||||
length=32,
|
||||
salt=mac_salt,
|
||||
iterations=2,
|
||||
backend=default_backend()
|
||||
)
|
||||
mac_key = mac_kdf.derive(derived_key)
|
||||
|
||||
# 解密数据
|
||||
|
||||
decrypted_data = bytearray()
|
||||
decrypted_data.extend(SQLITE_HEADER)
|
||||
|
||||
page_size = 4096
|
||||
iv_size = 16
|
||||
hmac_size = 64 # SHA512的HMAC是64字节
|
||||
|
||||
# 计算保留区域大小 (对齐到AES块大小)
|
||||
reserve_size = iv_size + hmac_size
|
||||
if reserve_size % 16 != 0:
|
||||
reserve_size = ((reserve_size // 16) + 1) * 16
|
||||
|
||||
total_pages = len(encrypted_data) // page_size
|
||||
total_pages = (len(encrypted_data) + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
successful_pages = 0
|
||||
failed_pages = 0
|
||||
result["total_pages"] = int(total_pages)
|
||||
|
||||
# 逐页解密
|
||||
result["expected_output_size"] = int(total_pages * PAGE_SIZE)
|
||||
logger.info(
|
||||
"[decrypt.pipeline] page_loop_start db=%s total_pages=%s expected_output_size=%s",
|
||||
result["db_name"],
|
||||
int(total_pages),
|
||||
int(result["expected_output_size"]),
|
||||
)
|
||||
|
||||
for cur_page in range(total_pages):
|
||||
start = cur_page * page_size
|
||||
end = start + page_size
|
||||
page = encrypted_data[start:end]
|
||||
|
||||
page_num = cur_page + 1 # 页面编号从1开始
|
||||
|
||||
if len(page) < page_size:
|
||||
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes")
|
||||
page_num = cur_page + 1
|
||||
start = cur_page * PAGE_SIZE
|
||||
page = encrypted_data[start:start + PAGE_SIZE]
|
||||
if not page:
|
||||
break
|
||||
|
||||
# 确定偏移量:第一页(cur_page == 0)需要跳过salt
|
||||
offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16
|
||||
|
||||
# 提取存储的HMAC
|
||||
hmac_start = page_size - reserve_size + iv_size
|
||||
hmac_end = hmac_start + hmac_size
|
||||
stored_hmac = page[hmac_start:hmac_end]
|
||||
|
||||
# 按照wechat-dump-rs的方式验证HMAC
|
||||
data_end = page_size - reserve_size + iv_size
|
||||
hmac_data = page[offset:data_end]
|
||||
|
||||
# 分步计算HMAC:先更新数据,再更新页面编号
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(hmac_data) # 包含加密数据+IV
|
||||
mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序)
|
||||
expected_hmac = mac.digest()
|
||||
|
||||
if stored_hmac != expected_hmac:
|
||||
logger.warning(f"页面 {page_num} HMAC验证失败")
|
||||
failed_pages += 1
|
||||
_append_failed_page(page_num, "hmac")
|
||||
continue
|
||||
|
||||
# 提取IV和加密数据用于AES解密
|
||||
iv = page[page_size - reserve_size:page_size - reserve_size + iv_size]
|
||||
encrypted_page = page[offset:page_size - reserve_size]
|
||||
|
||||
# AES-CBC解密
|
||||
try:
|
||||
cipher = Cipher(
|
||||
algorithms.AES(derived_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
if len(page) < PAGE_SIZE:
|
||||
logger.warning(
|
||||
"Page %s is short: %s bytes; padding to %s bytes",
|
||||
page_num,
|
||||
len(page),
|
||||
PAGE_SIZE,
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
|
||||
|
||||
# 按照wechat-dump-rs的方式重组页面数据
|
||||
decrypted_data.extend(decrypted_page)
|
||||
decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域
|
||||
|
||||
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
|
||||
|
||||
stored_hmac = page[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
|
||||
expected_hmac = _compute_page_hmac(mac_key, page, page_num)
|
||||
if not hmac.compare_digest(stored_hmac, expected_hmac):
|
||||
logger.warning("Page %s HMAC verification failed; decrypting page anyway", page_num)
|
||||
_append_hmac_warning_page(page_num)
|
||||
anomaly_debug = _build_page_anomaly_debug(
|
||||
enc_key,
|
||||
mac_key,
|
||||
page,
|
||||
page_num,
|
||||
stored_hmac=stored_hmac,
|
||||
expected_hmac=expected_hmac,
|
||||
reason="hmac",
|
||||
)
|
||||
if len(result["hmac_debug_samples"]) < 8:
|
||||
result["hmac_debug_samples"].append(anomaly_debug)
|
||||
logger.warning(
|
||||
"[decrypt.page_anomaly] %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"anomaly": anomaly_debug,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
decrypted_data.extend(_decrypt_page(enc_key, page, page_num))
|
||||
successful_pages += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"页面 {page_num} AES解密失败: {e}")
|
||||
logger.error("Page %s AES decryption failed: %s", page_num, e)
|
||||
failed_pages += 1
|
||||
_append_failed_page(page_num, "aes", str(e))
|
||||
aes_debug = _build_page_anomaly_debug(
|
||||
enc_key,
|
||||
mac_key,
|
||||
page,
|
||||
page_num,
|
||||
stored_hmac=stored_hmac,
|
||||
expected_hmac=expected_hmac,
|
||||
reason="aes",
|
||||
)
|
||||
if len(result["aes_debug_samples"]) < 8:
|
||||
result["aes_debug_samples"].append(aes_debug)
|
||||
logger.error(
|
||||
"[decrypt.page_anomaly] %s",
|
||||
json.dumps(
|
||||
{
|
||||
"db_name": result["db_name"],
|
||||
"anomaly": aes_debug,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
# 保留页占位,避免后续页整体错位导致 SQLite 必然损坏。
|
||||
decrypted_data.extend(b"\x00" * PAGE_SIZE)
|
||||
continue
|
||||
|
||||
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages} 页")
|
||||
if total_pages >= 100000 and page_num % 50000 == 0:
|
||||
logger.info(
|
||||
"[decrypt.pipeline] page_loop_progress db=%s page=%s/%s successful_pages=%s failed_pages=%s hmac_warning_pages=%s output_bytes=%s",
|
||||
result["db_name"],
|
||||
int(page_num),
|
||||
int(total_pages),
|
||||
int(successful_pages),
|
||||
int(failed_pages),
|
||||
int(result.get("hmac_warning_pages") or 0),
|
||||
int(len(decrypted_data)),
|
||||
)
|
||||
|
||||
result["successful_pages"] = int(successful_pages)
|
||||
result["failed_pages"] = int(failed_pages)
|
||||
|
||||
@@ -513,6 +936,14 @@ class WeChatDatabaseDecryptor:
|
||||
f.write(decrypted_data)
|
||||
|
||||
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
|
||||
if int(len(decrypted_data)) != int(result["expected_output_size"]):
|
||||
logger.warning(
|
||||
"[decrypt.pipeline] output_size_mismatch db=%s output_size=%s expected_output_size=%s delta=%s",
|
||||
result["db_name"],
|
||||
int(len(decrypted_data)),
|
||||
int(result["expected_output_size"]),
|
||||
int(len(decrypted_data)) - int(result["expected_output_size"]),
|
||||
)
|
||||
if failed_pages > 0:
|
||||
logger.warning(
|
||||
"解密输出包含页失败: db=%s total_pages=%s failed_pages=%s failure_reasons=%s samples=%s",
|
||||
@@ -522,6 +953,14 @@ class WeChatDatabaseDecryptor:
|
||||
json.dumps(result["failure_reasons"], ensure_ascii=False, sort_keys=True),
|
||||
json.dumps(result["failed_page_samples"], ensure_ascii=False),
|
||||
)
|
||||
if int(result.get("hmac_warning_pages") or 0) > 0:
|
||||
logger.warning(
|
||||
"解密输出包含HMAC告警页但已保留页内容: db=%s total_pages=%s hmac_warning_pages=%s samples=%s",
|
||||
result["db_name"],
|
||||
int(total_pages),
|
||||
int(result.get("hmac_warning_pages") or 0),
|
||||
json.dumps(result["hmac_warning_samples"], ensure_ascii=False),
|
||||
)
|
||||
return _finalize(True)
|
||||
|
||||
except Exception as e:
|
||||
@@ -710,6 +1149,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
if (
|
||||
(not bool(db_diagnostic.get("success", ok)))
|
||||
or int(db_diagnostic.get("failed_pages") or 0) > 0
|
||||
or int(db_diagnostic.get("hmac_warning_pages") or 0) > 0
|
||||
or str(db_diagnostic.get("diagnostic_status") or "") != "ok"
|
||||
):
|
||||
account_diagnostic_warning_count += 1
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
import wechat_decrypt_tool.wechat_decrypt as wechat_decrypt
|
||||
from wechat_decrypt_tool.wechat_decrypt import (
|
||||
HMAC_SIZE,
|
||||
PAGE_SIZE,
|
||||
RESERVE_SIZE,
|
||||
SALT_SIZE,
|
||||
SQLITE_HEADER,
|
||||
WeChatDatabaseDecryptor,
|
||||
_derive_mac_key,
|
||||
_derive_sqlcipher_enc_key,
|
||||
)
|
||||
|
||||
|
||||
def _build_plain_page(fill: int, *, first_page: bool) -> bytes:
|
||||
body = bytes([fill]) * (PAGE_SIZE - RESERVE_SIZE)
|
||||
if first_page:
|
||||
body = SQLITE_HEADER + body[len(SQLITE_HEADER):]
|
||||
return body + (b"\x00" * RESERVE_SIZE)
|
||||
|
||||
|
||||
def _encrypt_page(key_material: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes, *, passphrase: bool = False) -> bytes:
|
||||
enc_key = _derive_sqlcipher_enc_key(key_material, salt) if passphrase else key_material
|
||||
if page_num == 1:
|
||||
encrypted_input = plain_page[SALT_SIZE: PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = salt
|
||||
else:
|
||||
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = b""
|
||||
|
||||
cipher = Cipher(algorithms.AES(enc_key), modes.CBC(iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
|
||||
|
||||
page_without_hmac = prefix + encrypted + iv
|
||||
mac = hmac.new(_derive_mac_key(enc_key, salt), digestmod=hashlib.sha512)
|
||||
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0:])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return page_without_hmac + mac.digest()
|
||||
|
||||
|
||||
def _decrypt_sample(key_hex: str, encrypted_db: bytes, monkeypatch) -> bytes:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src = Path(tmpdir) / "source.db"
|
||||
dst = Path(tmpdir) / "out.db"
|
||||
src.write_bytes(encrypted_db)
|
||||
monkeypatch.setattr(wechat_decrypt, "collect_sqlite_diagnostics", lambda *args, **kwargs: {"quick_check_ok": True})
|
||||
monkeypatch.setattr(wechat_decrypt, "sqlite_diagnostics_status", lambda diagnostics: "ok")
|
||||
decryptor = WeChatDatabaseDecryptor(key_hex)
|
||||
assert decryptor.decrypt_database(str(src), str(dst))
|
||||
return dst.read_bytes()
|
||||
|
||||
|
||||
def test_decrypt_database_accepts_raw_enc_key_like_weflow(monkeypatch):
|
||||
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
salt = bytes.fromhex("50f4090ef6897e146f94109f13743e34")
|
||||
page1 = _build_plain_page(0x41, first_page=True)
|
||||
page2 = _build_plain_page(0x42, first_page=False)
|
||||
|
||||
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, bytes.fromhex("0102030405060708090a0b0c0d0e0f10"))
|
||||
encrypted_db += _encrypt_page(raw_key, page2, 2, salt, bytes.fromhex("1112131415161718191a1b1c1d1e1f20"))
|
||||
|
||||
assert _decrypt_sample(raw_key.hex(), encrypted_db, monkeypatch) == page1 + page2
|
||||
|
||||
|
||||
def test_decrypt_database_keeps_sqlcipher_passphrase_compatibility(monkeypatch):
|
||||
passphrase_key = bytes.fromhex("9f5dd0d3b6d0477ea5045c9e380ee272e53927993eb548dd98a022e842d5f7bd")
|
||||
salt = bytes.fromhex("40f4090ef6897e146f94109f13743e34")
|
||||
page1 = _build_plain_page(0x51, first_page=True)
|
||||
page2 = _build_plain_page(0x52, first_page=False)
|
||||
|
||||
encrypted_db = _encrypt_page(passphrase_key, page1, 1, salt, bytes.fromhex("2122232425262728292a2b2c2d2e2f30"), passphrase=True)
|
||||
encrypted_db += _encrypt_page(passphrase_key, page2, 2, salt, bytes.fromhex("3132333435363738393a3b3c3d3e3f40"), passphrase=True)
|
||||
|
||||
assert _decrypt_sample(passphrase_key.hex(), encrypted_db, monkeypatch) == page1 + page2
|
||||
|
||||
|
||||
def test_decrypt_database_keeps_page_when_non_first_hmac_mismatch(monkeypatch):
|
||||
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
salt = bytes.fromhex("60f4090ef6897e146f94109f13743e34")
|
||||
page1 = _build_plain_page(0x61, first_page=True)
|
||||
page2 = _build_plain_page(0x62, first_page=False)
|
||||
|
||||
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, bytes.fromhex("4142434445464748494a4b4c4d4e4f50"))
|
||||
encrypted_page2 = bytearray(_encrypt_page(raw_key, page2, 2, salt, bytes.fromhex("5152535455565758595a5b5c5d5e5f60")))
|
||||
encrypted_page2[-1] ^= 0x01 # 只破坏 HMAC,不破坏密文和 IV。
|
||||
encrypted_db += bytes(encrypted_page2)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src = Path(tmpdir) / "source.db"
|
||||
dst = Path(tmpdir) / "out.db"
|
||||
src.write_bytes(encrypted_db)
|
||||
monkeypatch.setattr(wechat_decrypt, "collect_sqlite_diagnostics", lambda *args, **kwargs: {"quick_check_ok": True})
|
||||
monkeypatch.setattr(wechat_decrypt, "sqlite_diagnostics_status", lambda diagnostics: "ok")
|
||||
|
||||
decryptor = WeChatDatabaseDecryptor(raw_key.hex())
|
||||
assert decryptor.decrypt_database(str(src), str(dst))
|
||||
assert dst.read_bytes() == page1 + page2
|
||||
assert decryptor.last_result["failed_pages"] == 0
|
||||
assert decryptor.last_result["hmac_warning_pages"] == 1
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -872,7 +872,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.7.12"
|
||||
version = "1.8.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -919,7 +919,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key", specifier = ">=2.0.0" },
|
||||
{ name = "wx-key", specifier = ">=2.0.1" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -935,13 +935,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-2.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp314-cp314-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.1-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.1-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.1-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.1-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user