mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
5 Commits
@@ -195,31 +195,14 @@ npm run dist
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
1. **[echotrace](https://github.com/ycccccccy/echotrace)** - 微信数据解析/取证工具
|
||||
- 本项目大量功能参考并复用其实现思路,提供了重要技术支持
|
||||
|
||||
2. **[WeFlow](https://github.com/hicccc77/WeFlow)** - 微信数据分析工具
|
||||
- 提供了重要的功能参考和技术支持
|
||||
|
||||
3. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具
|
||||
- 支持获取微信 4.x 数据库密钥与缓存图片密钥
|
||||
- 本项目推荐使用此工具获取密钥
|
||||
|
||||
4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具
|
||||
- 提供了SQLCipher 4.0解密的正确实现参考
|
||||
- 本项目的HMAC验证和页面处理逻辑基于此项目的实现
|
||||
|
||||
5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具
|
||||
- 提供了优秀的聊天记录界面设计参考
|
||||
- 本项目的聊天界面风格参考了此项目的实现
|
||||
|
||||
6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3)
|
||||
- 提供了聊天记录展示与交互的实现参考
|
||||
|
||||
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)** - 微信图片密钥获取工具
|
||||
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存与点击朋友圈大图
|
||||
1. **[echotrace](https://github.com/ycccccccy/echotrace)**
|
||||
2. **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
3. **[wx_key](https://github.com/ycccccccy/wx_key)**
|
||||
4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)**
|
||||
5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)**
|
||||
6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)**
|
||||
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)**
|
||||
8. **[Ritsu](https://xhslink.com/m/7YJUsd1sgyF)**
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -510,6 +510,30 @@ export const useApi = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Account archive export (databases + resource files)
|
||||
const createAccountArchiveExport = async (payload = {}) => {
|
||||
return await request('/account/archive_export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: payload.account || null,
|
||||
output_dir: payload.output_dir == null ? null : String(payload.output_dir || '').trim(),
|
||||
include_databases: payload.include_databases == null ? true : !!payload.include_databases,
|
||||
include_resources: payload.include_resources == null ? true : !!payload.include_resources,
|
||||
file_name: payload.file_name || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getAccountArchiveExport = async (exportId) => {
|
||||
if (!exportId) throw new Error('Missing exportId')
|
||||
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`)
|
||||
}
|
||||
|
||||
const cancelAccountArchiveExport = async (exportId) => {
|
||||
if (!exportId) throw new Error('Missing exportId')
|
||||
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// WeChat Wrapped(年度总结)
|
||||
const getWrappedAnnual = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -662,6 +686,9 @@ export const useApi = () => {
|
||||
cancelSnsExport,
|
||||
listChatContacts,
|
||||
exportChatContacts,
|
||||
createAccountArchiveExport,
|
||||
getAccountArchiveExport,
|
||||
cancelAccountArchiveExport,
|
||||
getWrappedAnnual,
|
||||
getWrappedAnnualMeta,
|
||||
getWrappedAnnualCard,
|
||||
|
||||
+226
-234
@@ -1,297 +1,289 @@
|
||||
<template>
|
||||
<div class="detection-result-page min-h-screen relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute top-20 left-20 w-72 h-72 bg-[#07C160] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute top-40 right-20 w-96 h-96 bg-[#10AEEF] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div class="relative z-10 w-full max-w-5xl mx-auto px-4 sm:px-5 py-6 sm:py-8 animate-fade-in">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-[22px] font-bold leading-none">
|
||||
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">检测结果</span>
|
||||
</h2>
|
||||
<div class="detection-result-page relative min-h-screen overflow-hidden px-3 py-4 text-[#000000e6] sm:px-5 sm:py-5">
|
||||
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
|
||||
|
||||
<main class="relative z-10 mx-auto w-full max-w-6xl">
|
||||
<div class="mb-3 flex flex-col gap-3 rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12px] font-medium tracking-[0.16em] text-[#07C160]">本地检测</p>
|
||||
<h1 class="mt-1.5 text-[30px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[38px]">
|
||||
{{ loading ? '正在检测微信数据' : detectionResult?.error ? '需要手动指定目录' : '找到可操作的微信账号' }}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-3xl text-[14px] leading-6 text-[#6B7280]">
|
||||
{{ loading ? '正在检查微信安装信息、账号目录和数据库文件。' : detectionResult?.error ? '自动检测没有找到可用数据,可以在下方手动选择 xwechat_files 目录后重试。' : '选择要处理的账号进入解密提取。如果结果不完整,可以手动指定数据根目录后重新检测。' }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs text-[#07C160] hover:text-[#06AD56] hover:bg-white/80 font-medium transition-colors">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex shrink-0 items-center rounded-lg border border-[#CFEEDB] bg-[#F7FDF9]/82 px-2.5 py-1.5 text-xs font-medium text-[#07C160] transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回首页
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detectionResult && !loading && !detectionResult.error"
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mb-4"
|
||||
>
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">微信版本</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6] truncate">{{ detectionResult.data?.wechat_version || '未知' }}</p>
|
||||
<div v-if="loading" class="flex min-h-[48vh] items-center justify-center">
|
||||
<div class="w-full max-w-3xl rounded-lg border border-[#DDF4E7] bg-[#F7FDF9]/76 p-4 backdrop-blur sm:p-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#07C160]">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" class="opacity-20"></circle>
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
<span>正在检测</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-[22px] font-semibold leading-tight tracking-[-0.03em] text-[#000000e6] sm:text-[28px]">
|
||||
正在寻找可用的微信数据
|
||||
</h3>
|
||||
<p class="mt-1.5 max-w-2xl text-[14px] leading-6 text-[#6B7280]">
|
||||
这一步会自动检查本机环境、匹配账号并统计数据库文件。检测期间请保持当前页面打开。
|
||||
</p>
|
||||
</div>
|
||||
<span class="hidden shrink-0 rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-medium text-[#07C160] sm:inline-flex">
|
||||
自动进行
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#07C160]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">检测账号</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_accounts || 0 }} 个</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#10AEEF]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283-.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2.5 rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-3">
|
||||
<div class="h-2 overflow-hidden rounded-full border border-[#DDF4E7] bg-[#F7FCF8]/86">
|
||||
<div class="h-full w-1/2 rounded-full bg-[#07C160] animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">数据库文件</p>
|
||||
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_databases || 0 }} 个</p>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>检查环境</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">读取微信安装与数据目录</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>匹配账号</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">找到可操作的本地账号</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#000000e6]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[#07C160]"></span>
|
||||
<span>汇总数据</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">整理后续解密所需信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-9 h-9 shrink-0 bg-[#91D300]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-[18px] h-[18px] text-[#91D300]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
|
||||
</svg>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-[#DDF4E7] pt-3 text-[12px] leading-5 text-[#7F7F7F] sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>如果等待时间较长,通常是本地文件较多或磁盘读取较慢。</span>
|
||||
<span class="font-medium text-[#07C160]">请稍候</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第一步检测:数据目录与微信安装目录都在这里设置 -->
|
||||
<div v-if="!loading" class="bg-white/90 backdrop-blur rounded-xl p-3.5 md:p-4 border border-[#EDEDED] mb-4 space-y-3">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-semibold text-[#000000e6] flex items-center">
|
||||
未找到想要的账号?
|
||||
<!-- <span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-normal">深度检测兜底</span>-->
|
||||
</h3>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-1">
|
||||
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
|
||||
<span v-else>如果自动检测漏了,您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描。</span>
|
||||
</p>
|
||||
<section v-else class="grid gap-4 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<aside class="space-y-3">
|
||||
<div
|
||||
v-if="detectionResult && !detectionResult.error"
|
||||
class="grid grid-cols-1 gap-2.5 sm:grid-cols-3 lg:grid-cols-3"
|
||||
>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#EFFAF3]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">微信版本</p>
|
||||
<p class="mt-1.5 truncate text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.wechat_version || '未知' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">检测账号</p>
|
||||
<p class="mt-1.5 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.total_accounts || 0 }} 个</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[#CFEEDB] bg-[#F1FAF4]/82 p-3 backdrop-blur">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">数据库文件</p>
|
||||
<p class="mt-1.5 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ detectionResult.data?.total_databases || 0 }} 个</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handlePickDirectory" :disabled="loading"
|
||||
class="shrink-0 px-4 py-2.5 bg-[#07C160] text-white rounded-xl text-xs font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-[#F3F3F3]">
|
||||
<label for="wechatInstallPath" class="block text-[13px] font-medium text-[#000000e6] mb-2">
|
||||
微信安装目录(第一步先填这里)
|
||||
</label>
|
||||
<div class="flex flex-col lg:flex-row gap-3">
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div>
|
||||
<div class="text-[15px] font-medium text-[#000000e6]">手动补充路径</div>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">自动检测不完整时,可以指定微信数据根目录重新扫描。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customPath" class="mt-3 rounded-md border border-[#E1EFE5] bg-[#F7FCF8]/86 px-2.5 py-2.5">
|
||||
<p class="text-[12px] font-medium text-[#5F6F66]">当前检测路径</p>
|
||||
<p class="mt-1 break-all font-mono text-[12px] leading-5 text-[#000000d9]">{{ customPath }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-[#07C160] px-3 py-2.5 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25 disabled:opacity-50"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
<svg v-if="!loading" class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<svg v-else class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-dasharray="28 72" pathLength="100" transform="rotate(-90 24 24)"></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '选择 xwechat_files 目录' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<label for="wechatInstallPath" class="block text-[15px] font-medium text-[#000000e6]">
|
||||
微信安装目录
|
||||
</label>
|
||||
<p class="mt-1 text-[12px] leading-5 text-[#7F7F7F]">
|
||||
一键获取数据库密钥会优先使用这里的路径。
|
||||
</p>
|
||||
<input
|
||||
id="wechatInstallPath"
|
||||
v-model="wechatInstallPath"
|
||||
type="text"
|
||||
placeholder="例如: D:\Program Files\Tencent\WeChat 或 D:\Program Files\Tencent\WeChat\Weixin.exe"
|
||||
class="flex-1 px-4 py-2.5 bg-white border border-[#EDEDED] rounded-lg font-mono text-[13px] focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
class="mt-3 w-full rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-3 py-2 font-mono text-[13px] text-[#000000d9] transition focus:border-[#07C160] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
@blur="persistWechatInstallPath"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="pickWechatInstallDirectory"
|
||||
:disabled="isPickingWechatInstallPath"
|
||||
class="shrink-0 px-4 py-2.5 bg-white border border-[#EDEDED] text-[#000000e6] rounded-xl text-xs font-medium hover:bg-gray-50 disabled:opacity-50 disabled:cursor-wait transition-all duration-200"
|
||||
class="mt-2 inline-flex w-full items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 px-3 py-2 text-sm font-medium text-[#4A4A4A] transition hover:bg-[#F1FAF4] focus:outline-none focus:ring-2 focus:ring-[#07C160]/15 disabled:cursor-wait disabled:opacity-50"
|
||||
@click="pickWechatInstallDirectory"
|
||||
>
|
||||
{{ isPickingWechatInstallPath ? '选择中...' : '选择微信目录' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-2">
|
||||
一键获取数据库密钥会优先使用这里填写的路径。支持填写安装目录,也支持直接填写 <span class="font-mono">Weixin.exe</span> / <span class="font-mono">WeChat.exe</span> 路径。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div :class="loading ? 'flex min-h-[52vh] items-center justify-center' : ''">
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="w-full max-w-3xl rounded-[24px] border border-[#EDEDED] bg-white/92 px-5 py-6 sm:px-8 sm:py-7">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative flex h-12 w-12 items-center justify-center rounded-2xl bg-[#07C160]/10">
|
||||
<span class="absolute inset-0 rounded-2xl border border-[#07C160]/10"></span>
|
||||
<svg class="h-5 w-5 animate-spin text-[#07C160]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" class="opacity-20"></circle>
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-[#07C160]/10 px-2.5 py-1 text-[11px] font-medium text-[#07C160]">
|
||||
检测中
|
||||
</span>
|
||||
<span class="text-[11px] text-[#7F7F7F]">正在自动读取本机微信环境</span>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-3 text-[20px] font-semibold text-[#000000e6] leading-tight">正在检查账号与数据库文件</h3>
|
||||
<p class="mt-2 max-w-[560px] text-[13px] leading-6 text-[#7F7F7F]">
|
||||
会依次确认微信安装信息、最近登录账号以及可用数据库,通常几秒内完成。
|
||||
</p>
|
||||
|
||||
<div class="mt-5 h-1.5 w-full max-w-[620px] overflow-hidden rounded-full bg-[#F3F4F6]">
|
||||
<div class="h-full w-2/5 rounded-full bg-gradient-to-r from-[#07C160] via-[#34D17A] to-[#8CE0AF] animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center justify-center gap-2.5">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>安装信息</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>账号匹配</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
|
||||
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
|
||||
<span>数据库汇总</span>
|
||||
<div class="rounded-lg border border-[#DDEBE0] bg-[#F4FAF6]/82 p-4 backdrop-blur sm:p-5">
|
||||
<div v-if="detectionResult?.error" class="flex min-h-[340px] flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[13px] font-medium text-[#D64A4A]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>未找到微信数据</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[28px]">可以手动指定目录重试</h2>
|
||||
<p class="mt-2 text-[14px] leading-6 text-[#9C5F5F]">{{ detectionResult.error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#F4D6D6] bg-[#FFF7F7] p-3 text-[13px] leading-6 text-[#9C5F5F]">
|
||||
请尝试选择微信数据根目录,通常名为 <span class="font-mono">xwechat_files</span>。
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-[#07C160] px-3 py-2.5 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="handlePickDirectory"
|
||||
>
|
||||
重新选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- detection result content -->
|
||||
<div v-if="detectionResult && !loading">
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-6">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-8 h-8 text-red-500 mr-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div v-else-if="detectionResult?.data?.accounts && detectionResult.data.accounts.length > 0" class="space-y-3">
|
||||
<div class="flex flex-col gap-2 border-b border-[#DDEBE0] pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-lg font-bold text-red-800">未找到微信数据</p>
|
||||
<p class="text-red-600 mt-1 text-sm">{{ detectionResult.error }}</p>
|
||||
<h2 class="text-[22px] font-semibold tracking-[-0.03em] text-[#000000e6]">可操作的微信账号</h2>
|
||||
<p class="mt-1 text-[13px] leading-6 text-[#7F7F7F]">点击解密提取,会将该账号信息带入下一步。</p>
|
||||
</div>
|
||||
<span class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2.5 py-1 text-[12px] font-medium text-[#07C160]">
|
||||
{{ sortedAccounts.length }} 个账号
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功结果 -->
|
||||
<div v-else class="space-y-3">
|
||||
<!-- 账户列表 -->
|
||||
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
|
||||
class="bg-white/92 backdrop-blur rounded-2xl border border-[#EDEDED] overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-[#EDEDED] bg-[#fafafa] flex items-center justify-between">
|
||||
<h3 class="text-[15px] font-semibold text-[#000000e6]">可操作的微信账户</h3>
|
||||
<span class="text-[11px] text-gray-500">点击解密即可提取数据</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[#EDEDED] max-h-[420px] overflow-y-auto">
|
||||
<div v-for="(account, index) in sortedAccounts" :key="index"
|
||||
:class="['px-4 py-3.5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
|
||||
|
||||
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-3 py-1 rounded-bl-xl flex items-center">
|
||||
<span class="text-xs text-[#07C160] font-bold flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
最近登录账户
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-[460px] space-y-2.5 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(account, index) in sortedAccounts"
|
||||
:key="index"
|
||||
:class="[
|
||||
'rounded-lg border p-3 transition',
|
||||
isCurrentAccount(account.account_name)
|
||||
? 'border-[#AEE6C4] bg-[#EAF8EF]/86'
|
||||
: 'border-[#E1EFE5] bg-[#F7FCF8]/86 hover:border-[#CFEEDB] hover:bg-[#F1FAF4]'
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
|
||||
<img :src="currentAccountInfo.avatar" class="h-12 w-12 shrink-0 rounded-md border border-[#AEE6C4] bg-white object-cover" alt="" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86">
|
||||
<span class="text-[17px] font-semibold text-[#07C160]">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 mt-1">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
|
||||
<img :src="currentAccountInfo.avatar" class="w-12 h-12 rounded-xl border-2 border-[#07C160]/30 mr-3 object-cover bg-white" alt=""/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
|
||||
<p class="truncate text-[18px] font-semibold tracking-[-0.03em] text-[#000000e6]">{{ currentAccountInfo.nickname }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-3">
|
||||
<span class="text-[#07C160] font-bold text-lg">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
<p class="truncate text-[16px] font-semibold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
|
||||
<p class="text-lg font-bold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
|
||||
<p class="text-[11px] text-[#7F7F7F] mt-0.5 font-mono">wxid: {{ account.account_name }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-[15px] font-semibold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname" class="mt-1 truncate font-mono text-[12px] text-[#7F7F7F]">
|
||||
wxid: {{ account.account_name }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center mt-1.5 space-x-3 text-[12px] text-[#7F7F7F]">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
|
||||
</svg>
|
||||
{{ account.database_count }} 个库文件
|
||||
</span>
|
||||
<span v-if="account.data_dir" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
路径已确认
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-2 text-[12px] text-[#7F7F7F]">
|
||||
<span class="rounded-md border border-[#E1EFE5] bg-[#F4FAF6]/82 px-2 py-1">{{ account.database_count }} 个库文件</span>
|
||||
<span v-if="isCurrentAccount(account.account_name)" class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2 py-1 font-medium text-[#07C160]">最近登录</span>
|
||||
<span v-if="account.data_dir" class="rounded-md border border-[#CFEEDB] bg-[#F4FBF6]/86 px-2 py-1 text-[#07C160]">路径已确认</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToDecrypt(account)"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#07C160] text-white rounded-lg font-semibold hover:bg-[#06AD56] hover:-translate-y-0.5 transition-all duration-200 text-xs shrink-0 z-10">
|
||||
解密提取
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-2.5 border-t border-dashed border-gray-200 text-sm text-gray-400">
|
||||
<p v-if="account.data_dir" class="font-mono text-[11px] truncate" title="复制路径">
|
||||
📂 {{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-lg bg-[#07C160] px-3 py-2 text-sm font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="goToDecrypt(account)"
|
||||
>
|
||||
解密提取
|
||||
<svg class="ml-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="account.data_dir" class="mt-3 border-t border-dashed border-[#DDEBE0] pt-2.5">
|
||||
<p class="truncate font-mono text-[12px] text-[#7F7F7F]" :title="account.data_dir">
|
||||
{{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无账户提示 -->
|
||||
<div v-else class="bg-white rounded-2xl p-8 text-center border border-[#EDEDED]">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-base text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
|
||||
<p class="text-sm text-gray-500 mt-2">您可以尝试通过上方的按钮手动指定 "xwechat_files" 文件夹路径。</p>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[340px] flex-col items-center justify-center rounded-lg border border-[#DDEBE0] bg-[#F7FCF8]/86 p-5 text-center">
|
||||
<svg class="mb-3 h-10 w-10 text-[#9CA3AF]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-[18px] font-semibold tracking-[-0.03em] text-[#000000e6]">没有发现微信数据</p>
|
||||
<p class="mt-1.5 max-w-md text-[13px] leading-6 text-[#7F7F7F]">可以尝试手动指定 <span class="font-mono">xwechat_files</span> 文件夹后重新检测。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {useApi} from '~/composables/useApi'
|
||||
|
||||
+199
-144
@@ -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
@@ -1,77 +1,146 @@
|
||||
<template>
|
||||
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute top-20 left-20 w-72 h-72 bg-[#07C160] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-40 right-20 w-96 h-96 bg-[#10AEEF] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl"></div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="relative z-10 text-center">
|
||||
<!-- Logo和标题部分 -->
|
||||
<div class="mb-12 animate-fade-in">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<img src="/logo.png" alt="微信解密助手Logo" class="w-48 h-48 object-contain">
|
||||
<div class="landing-page relative h-full min-h-0 overflow-hidden px-4 py-6 text-[#000000e6] sm:px-6 sm:py-8">
|
||||
<div class="pointer-events-none absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="pointer-events-none absolute left-20 top-20 h-72 w-72 rounded-full bg-[#07C160] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute right-20 top-40 h-96 w-96 rounded-full bg-[#10AEEF] opacity-5 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-8 left-40 h-80 w-80 rounded-full bg-[#91D300] opacity-5 blur-3xl"></div>
|
||||
|
||||
<main class="relative z-10 mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col justify-center">
|
||||
<section class="space-y-5">
|
||||
<div class="flex flex-col gap-5 rounded-lg border border-[#EDEDED] bg-white/78 p-6 backdrop-blur sm:p-8 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-start gap-4 text-left">
|
||||
<img src="/logo.png" alt="微信解密助手Logo" class="h-16 w-16 shrink-0 object-contain" />
|
||||
<div>
|
||||
<p class="text-[13px] font-medium tracking-[0.16em] text-[#07C160]">本地整理·安心备份</p>
|
||||
<h1 class="mt-3 text-[34px] font-semibold leading-tight tracking-[-0.04em] text-[#000000e6] sm:text-[46px]">
|
||||
把微信记录留在本地
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-[15px] leading-7 text-[#6B7280]">
|
||||
从检测开始,再查看聊天、导入备份或导出归档。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-[#07C160] px-6 py-3 text-[14px] font-medium text-white transition hover:bg-[#06AD56] focus:outline-none focus:ring-2 focus:ring-[#07C160]/25"
|
||||
@click="startDetection"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>开始检测</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl font-bold text-[#000000e6] mb-4">
|
||||
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">微信</span>
|
||||
<span class="text-[#000000e6]">解密助手</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[#7F7F7F] font-normal">轻松解锁你的聊天记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 主要按钮 -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center animate-slide-up">
|
||||
<button @click="startDetection"
|
||||
class="group inline-flex items-center px-12 py-4 bg-[#07C160] text-white rounded-lg text-lg font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 group-hover:rotate-12 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<span>开始检测</span>
|
||||
</button>
|
||||
|
||||
|
||||
<NuxtLink to="/import"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#91D300] border border-[#91D300] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<span>数据导入</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/chat"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/>
|
||||
</svg>
|
||||
<span>聊天预览</span>
|
||||
</NuxtLink>
|
||||
<div class="grid gap-4 lg:grid-cols-[1.25fr_0.75fr]">
|
||||
<button
|
||||
type="button"
|
||||
class="group min-h-[300px] rounded-lg border border-[#CFEEDB] bg-[#F3FFF8] p-6 text-left transition hover:border-[#AEE6C4] hover:bg-[#EFFAF3] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20 sm:p-8"
|
||||
@click="startDetection"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-between gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 text-[#07C160]">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-medium">推荐第一步</span>
|
||||
</div>
|
||||
<h2 class="mt-5 text-[28px] font-semibold tracking-[-0.03em] text-[#000000e6] sm:text-[36px]">检测本机微信数据</h2>
|
||||
<p class="mt-3 max-w-xl text-[14px] leading-7 text-[#6B7280]">
|
||||
找到可用账号、数据库文件和本地路径,后续查看、解密和归档都从这里开始。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/wrapped"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#B37800] border border-[#F2AA00] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2.5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 16v-5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 16v-8" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M16 16v-3" />
|
||||
</svg>
|
||||
<span>年度总结</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-[#DDF4E7] pt-5">
|
||||
<span class="text-[13px] font-medium text-[#07C160]">开始检测</span>
|
||||
<svg class="h-5 w-5 text-[#07C160] transition group-hover:translate-x-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<NuxtLink
|
||||
to="/import"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>导入备份</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">接入已准备好的本地备份目录。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
@click="openExportDialog"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.5 10.5L12 15l4.5-4.5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 19h16" />
|
||||
</svg>
|
||||
<span>导出归档</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">打包当前账号的数据库和资源文件。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
to="/chat"
|
||||
class="group rounded-lg border border-[#EDEDED] bg-white/72 p-5 text-left transition hover:border-[#CFEEDB] hover:bg-[#F7FDF9] focus:outline-none focus:ring-2 focus:ring-[#07C160]/20"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-[15px] font-medium text-[#000000e6]">
|
||||
<svg class="h-5 w-5 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h8M8 14h5M4 6h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
<span>回看聊天</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] leading-6 text-[#7F7F7F]">查看会话、搜索片段,找到需要的内容。</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-4 w-4 shrink-0 text-[#A1A1AA] transition group-hover:translate-x-0.5 group-hover:text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<GlobalExportDialog :open="exportDialogOpen" @close="closeExportDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
const { listChatAccounts } = useApi()
|
||||
const exportDialogOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
@@ -88,47 +157,22 @@ onMounted(async () => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// 开始检测并跳转到结果页面
|
||||
const openExportDialog = () => {
|
||||
exportDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeExportDialog = () => {
|
||||
exportDialogOpen.value = false
|
||||
}
|
||||
|
||||
const startDetection = async () => {
|
||||
// 直接跳转到检测结果页面,让该页面处理检测
|
||||
await navigateTo('/detection-result')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
/* 网格背景 */
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
background-image:
|
||||
linear-gradient(rgba(7, 193, 96, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.7.12"
|
||||
version = "1.7.20"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.7.12"
|
||||
__version__ = "1.7.20"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -28,6 +28,7 @@ from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.import_decrypted import router as _import_decrypted_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.admin import router as _admin_router
|
||||
from .routers.account_archive_export import router as _account_archive_export_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
@@ -65,6 +66,7 @@ async def _log_server_errors(request: Request, call_next):
|
||||
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_account_archive_export_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
app.include_router(_import_decrypted_router)
|
||||
app.include_router(_decrypt_router)
|
||||
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user