Compare commits

...

5 Commits

  • fix(key): 修复数据库密钥模式识别逻辑
    - 新增原始加密密钥与 SQLCipher passphrase 两种密钥模式自动识别
    - 通过首页 HMAC 校验确认实际密钥模式,避免错误派生导致解密失败
    - 解密输出时清理加密页尾部的 IV/HMAC 保留区
    - 增加解密失败时的 key_mode、HMAC 与 SQLite 诊断信息
    - 新增单测覆盖两种密钥模式的解密路径
    - 更新 README 补充数据库解密兼容性说明
  • improvement(entry-ui): 优化首页检测与导入流程界面
    - 重构首页布局,突出检测、导入、导出归档和聊天回看等常用入口
    - 优化检测结果页的信息层级、加载态、手动路径选择和微信安装目录设置区域
    - 优化导入页面布局,补充目录选择说明、导入流程提示、导入进度和完成态展示
    - 统一入口页面的卡片、按钮、背景和响应式样式,提升操作引导清晰度
  • feat(export): 支持账号归档 ZIP 导出
    - 新增账号归档导出后端接口,支持创建、查询、取消导出任务和下载 ZIP 文件
    - 支持按账号打包数据库、元信息文件和资源文件,并使用 ZIP 存储模式提升大文件归档速度
    - 前端接入账号归档导出 API,支持导出目录选择、导出内容勾选、进度轮询和取消导出
    - 简化全局导出弹窗,将入口聚焦为数据库与资源文件归档导出
    - 开启侧边栏导出入口,并补充 README 中的导出说明
  • Simplify acknowledgments section in README
    Removed detailed descriptions of referenced projects in the acknowledgments section.
16 changed files with 1726 additions and 1883 deletions
+8 -25
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wechat-data-analysis-desktop",
"version": "1.7.12",
"version": "1.7.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "1.7.12",
"version": "1.7.20",
"dependencies": {
"electron-updater": "^6.7.3"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "1.7.12",
"version": "1.7.20",
"main": "src/main.cjs",
"scripts": {
"dev": "node scripts/dev.cjs",
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -148,7 +148,7 @@
<div
v-if="showGlobalExportEntry"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="批量导出"
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">
@@ -387,7 +387,7 @@ const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realti
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
const { getChatAccountInfo, deleteChatAccount } = useApi()
const showGlobalExportEntry = false
const showGlobalExportEntry = true
const accountDialogOpen = ref(false)
const exportDialogOpen = ref(false)
const accountInfoLoading = ref(false)
@@ -648,3 +648,4 @@ const toggleRealtime = async () => {
color: var(--sidebar-rail-icon-active-color);
}
</style>
+27
View File
@@ -510,6 +510,30 @@ export const useApi = () => {
})
}
// Account archive export (databases + resource files)
const createAccountArchiveExport = async (payload = {}) => {
return await request('/account/archive_export', {
method: 'POST',
body: {
account: payload.account || null,
output_dir: payload.output_dir == null ? null : String(payload.output_dir || '').trim(),
include_databases: payload.include_databases == null ? true : !!payload.include_databases,
include_resources: payload.include_resources == null ? true : !!payload.include_resources,
file_name: payload.file_name || null
}
})
}
const getAccountArchiveExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`)
}
const cancelAccountArchiveExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// WeChat Wrapped(年度总结)
const getWrappedAnnual = async (params = {}) => {
const query = new URLSearchParams()
@@ -662,6 +686,9 @@ export const useApi = () => {
cancelSnsExport,
listChatContacts,
exportChatContacts,
createAccountArchiveExport,
getAccountArchiveExport,
cancelAccountArchiveExport,
getWrappedAnnual,
getWrappedAnnualMeta,
getWrappedAnnualCard,
+226 -234
View File
@@ -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'
+199 -144
View File
@@ -1,102 +1,143 @@
<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-4xl 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]">导入备份</p>
<h1 class="mt-1 text-[24px] font-semibold leading-none text-[#000000e6]">数据导入</h1>
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录支持本项目导出和 wxdump output/wxid_xxx 结构</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 sm:px-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white text-[#07C160] ring-1 ring-[#E7F1E8]">
<svg class="h-4 w-4" 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>
</div>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[#000000d9]">目录要求</div>
<p class="mt-1 text-sm leading-6 text-[#6F6F6F]">支持本项目导出和 wxdump 导出优先选择账号目录 output 下只有一个账号也可直接选 output</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="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">databases/</span>
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">database/</span>
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">media/</span>
<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="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` 层级wxdump `output` 根目录在单账号时也支持</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>
<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>
</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-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>
<button
class="mt-5 inline-flex w-full items-center justify-center rounded-2xl border border-[#F0D7D7] bg-white px-4 py-3 text-sm font-medium text-[#D64A4A] transition-colors hover:bg-[#FFF7F7]"
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"
>
取消导入
@@ -104,34 +145,41 @@
</div>
</div>
<div v-if="importComplete && !importing" class="animate-fade-in space-y-4">
<div class="rounded-[24px] border border-[#DCEFE2] bg-[#F7FCF8] px-5 py-7 text-center sm:px-6">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
<svg class="h-7 w-7" 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>
<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>
<h2 class="mt-4 text-xl font-semibold text-[#000000e6]">导入完成</h2>
<p class="mt-2 text-sm leading-6 text-[#6F6F6F]">{{ importComplete.message || '账号数据已成功导入。' }}</p>
<div class="mt-4 rounded-2xl border border-[#E2EFE5] bg-white px-4 py-3 text-left text-sm text-[#4A4A4A]">
<div class="flex items-center justify-between gap-3">
<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">{{ importComplete.account }}</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-3">
<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">{{ importComplete.backup_dir }}</span>
<span class="min-w-0 break-all text-right text-xs text-[#000000d9]">{{ importComplete.backup_dir }}</span>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<div class="grid gap-3 sm:grid-cols-2">
<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="retryPickDirectory"
>
继续导入其他目录
继续导入
</button>
<button
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]"
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')"
>
进入聊天页面
@@ -140,55 +188,62 @@
</div>
</div>
<div v-if="importPreview && !importing && !importComplete" 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="头像" />
</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]">检测到的账号</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]">导入路径</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
type="button"
:disabled="importing || importPreview?.source_overlaps_target"
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]"
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"
>
确认导入此账号
@@ -196,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]">当前路径</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>
+140 -96
View File
@@ -1,77 +1,146 @@
<template>
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
<!-- 网格背景 -->
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
<!-- 装饰元素 -->
<div class="absolute top-20 left-20 w-72 h-72 bg-[#07C160] opacity-5 rounded-full blur-3xl"></div>
<div class="absolute top-40 right-20 w-96 h-96 bg-[#10AEEF] opacity-5 rounded-full blur-3xl"></div>
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl"></div>
<!-- 主要内容区域 -->
<div class="relative z-10 text-center">
<!-- Logo和标题部分 -->
<div class="mb-12 animate-fade-in">
<!-- Logo -->
<div class="flex justify-center mb-8">
<img src="/logo.png" alt="微信解密助手Logo" class="w-48 h-48 object-contain">
<div class="landing-page relative h-full min-h-0 overflow-hidden px-4 py-6 text-[#000000e6] sm:px-6 sm:py-8">
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
<main class="relative z-10 mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col justify-center">
<section class="space-y-5">
<div class="flex flex-col gap-5 rounded-lg border border-[#EDEDED] bg-white/78 p-6 backdrop-blur sm:p-8 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-start gap-4 text-left">
<img src="/logo.png" alt="微信解密助手Logo" class="h-16 w-16 shrink-0 object-contain" />
<div>
<p class="text-[13px] font-medium tracking-[0.16em] text-[#07C160]">本地整理·安心备份</p>
<h1 class="mt-3 text-[34px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[46px]">
把微信记录留在本地
</h1>
<p class="mt-3 max-w-2xl text-[15px] leading-7 text-[#6B7280]">
从检测开始再查看聊天导入备份或导出归档
</p>
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-[#07C160] px-6 py-3 text-[14px] font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
@click="startDetection"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>开始检测</span>
</button>
</div>
<h1 class="text-5xl font-bold text-[#000000e6] mb-4">
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">微信</span>
<span class="text-[#000000e6]">解密助手</span>
</h1>
<p class="text-xl text-[#7F7F7F] font-normal">轻松解锁你的聊天记录</p>
</div>
<!-- 主要按钮 -->
<div class="flex flex-col sm:flex-row gap-4 justify-center animate-slide-up">
<button @click="startDetection"
class="group inline-flex items-center px-12 py-4 bg-[#07C160] text-white rounded-lg text-lg font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 group-hover:rotate-12 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<span>开始检测</span>
</button>
<NuxtLink to="/import"
class="group inline-flex items-center px-12 py-4 bg-white text-[#91D300] border border-[#91D300] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>数据导入</span>
</NuxtLink>
<NuxtLink to="/chat"
class="group inline-flex items-center px-12 py-4 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/>
</svg>
<span>聊天预览</span>
</NuxtLink>
<div class="grid gap-4 lg:grid-cols-[1.25fr_0.75fr]">
<button
type="button"
class="group min-h-[300px] rounded-lg border border-[#CFEEDB] bg-[#F3FFF8] p-6 text-left transition hover:border-[#AEE6C4] hover:bg-[#EFFAF3] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20 sm:p-8"
@click="startDetection"
>
<div class="flex h-full flex-col justify-between gap-8">
<div>
<div class="flex items-center gap-3 text-[#07C160]">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="text-[13px] font-medium">推荐第一步</span>
</div>
<h2 class="mt-5 text-[28px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[36px]">检测本机微信数据</h2>
<p class="mt-3 max-w-xl text-[14px] leading-7 text-[#6B7280]">
找到可用账号数据库文件和本地路径后续查看解密和归档都从这里开始
</p>
</div>
<NuxtLink to="/wrapped"
class="group inline-flex items-center px-12 py-4 bg-white text-[#B37800] border border-[#F2AA00] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2.5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 16v-5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 16v-8" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M16 16v-3" />
</svg>
<span>年度总结</span>
</NuxtLink>
</div>
</div>
<div class="flex items-center justify-between border-t border-[#DDF4E7] pt-5">
<span class="text-[13px] font-medium text-[#07C160]">开始检测</span>
<svg class="h-5 w-5 text-[#07C160] transition group-hover:translate-x-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
<div class="grid gap-4">
<NuxtLink
to="/import"
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
>
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>导入备份</span>
</div>
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">接入已准备好的本地备份目录</p>
</div>
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</NuxtLink>
<button
type="button"
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
@click="openExportDialog"
>
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.5 10.5L12 15l4.5-4.5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 19h16" />
</svg>
<span>导出归档</span>
</div>
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">打包当前账号的数据库和资源文件</p>
</div>
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
<NuxtLink
to="/chat"
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
>
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
<span>回看聊天</span>
</div>
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">查看会话搜索片段找到需要的内容</p>
</div>
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</NuxtLink>
</div>
</div>
</section>
</main>
<GlobalExportDialog :open="exportDialogOpen" @close="closeExportDialog" />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
const { listChatAccounts } = useApi()
const exportDialogOpen = ref(false)
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
@@ -88,47 +157,22 @@ onMounted(async () => {
} catch {}
})
// 开始检测并跳转到结果页面
const openExportDialog = () => {
exportDialogOpen.value = true
}
const closeExportDialog = () => {
exportDialogOpen.value = false
}
const startDetection = async () => {
// 直接跳转到检测结果页面,让该页面处理检测
await navigateTo('/detection-result')
}
</script>
<style scoped>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
.animate-slide-up {
animation: slide-up 0.8s ease-out 0.3s both;
}
/* 网格背景 */
.bg-grid-pattern {
background-image:
background-image:
linear-gradient(rgba(7, 193, 96, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "1.7.12"
version = "1.7.20"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "1.7.12"
__version__ = "1.7.20"
__author__ = "WeChat Decrypt Tool"
+2
View File
@@ -28,6 +28,7 @@ from .routers.decrypt import router as _decrypt_router
from .routers.import_decrypted import router as _import_decrypted_router
from .routers.health import router as _health_router
from .routers.admin import router as _admin_router
from .routers.account_archive_export import router as _account_archive_export_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
@@ -65,6 +66,7 @@ async def _log_server_errors(request: Request, call_next):
app.include_router(_health_router)
app.include_router(_admin_router)
app.include_router(_account_archive_export_router)
app.include_router(_wechat_detection_router)
app.include_router(_import_decrypted_router)
app.include_router(_decrypt_router)
@@ -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()}
+104 -93
View File
@@ -16,9 +16,7 @@ import json
from pathlib import Path
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 +26,75 @@ 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 _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:
@@ -398,113 +465,57 @@ 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()
)
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)
# 解密数据
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)
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)
# 逐页解密
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验证失败")
if len(page) < PAGE_SIZE:
logger.warning(
"Page %s is short: %s bytes; padding to %s bytes",
page_num,
len(page),
PAGE_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", page_num)
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()
)
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:]) # 保留区域
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))
continue
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages}")
result["successful_pages"] = int(successful_pages)
result["failed_pages"] = int(failed_pages)
+82
View File
@@ -0,0 +1,82 @@
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
Generated
+1 -1
View File
@@ -872,7 +872,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "1.7.12"
version = "1.7.20"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },