mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat: 优化导入流程,将导入按钮移至首页并支持目录选择和账号预览
This commit is contained in:
@@ -62,6 +62,14 @@ export const useApi = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 导入预览API
|
||||
const importDecryptedPreview = async (data) => {
|
||||
return await request('/import_decrypted/preview', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
// 导入已解密目录API
|
||||
const importDecrypted = async (data) => {
|
||||
return await request('/import_decrypted', {
|
||||
@@ -613,6 +621,8 @@ export const useApi = () => {
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
decryptDatabase,
|
||||
importDecryptedPreview,
|
||||
importDecrypted,
|
||||
healthCheck,
|
||||
listChatAccounts,
|
||||
getChatAccountInfo,
|
||||
|
||||
+12
-149
@@ -16,31 +16,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">数据获取</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">选择解密新数据或导入已解密目录</p>
|
||||
</div>
|
||||
<!-- 模式切换 -->
|
||||
<div class="bg-gray-100 p-1 rounded-lg flex">
|
||||
<button
|
||||
@click="decryptMode = 'standard'"
|
||||
:class="decryptMode === 'standard' ? 'bg-white shadow-sm text-[#07C160]' : 'text-[#7F7F7F]'"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
>
|
||||
标准解密
|
||||
</button>
|
||||
<button
|
||||
@click="decryptMode = 'import'"
|
||||
:class="decryptMode === 'import' ? 'bg-white shadow-sm text-[#07C160]' : 'text-[#7F7F7F]'"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
>
|
||||
直接导入
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">数据库解密</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">输入密钥和路径开始解密</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标准解密模式 -->
|
||||
<form v-if="decryptMode === 'standard'" @submit.prevent="handleDecrypt" class="space-y-6">
|
||||
<form @submit.prevent="handleDecrypt" class="space-y-6">
|
||||
<!-- 密钥输入 -->
|
||||
<div>
|
||||
<label for="key" class="block text-sm font-medium text-[#000000e6] mb-2">
|
||||
@@ -178,72 +160,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 直接导入模式 -->
|
||||
<form v-else @submit.prevent="handleImport" class="space-y-6">
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 mr-3" 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 class="text-sm text-blue-700">
|
||||
<p class="font-bold mb-1">什么是直接导入?</p>
|
||||
<p>如果您已经有了已解密的数据库文件(扁平化目录结构,含 contact.db, session.db 等)以及 resource 资源目录,可以直接导入。此过程<strong>不校验密钥</strong>,也不进行实时同步。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入路径输入 -->
|
||||
<div>
|
||||
<label for="importPath" class="block text-sm font-medium text-[#000000e6] mb-2">
|
||||
<svg class="w-4 h-4 inline mr-1" 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>
|
||||
已解密目录路径 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="importPath"
|
||||
v-model="importData.path"
|
||||
type="text"
|
||||
placeholder="例如: D:\MyBackups\wxid_xxxx"
|
||||
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
:class="{ 'border-red-500': formErrors.import_path }"
|
||||
required
|
||||
/>
|
||||
<p v-if="formErrors.import_path" class="mt-1 text-sm text-red-600 flex items-center">
|
||||
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ formErrors.import_path }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-[#7F7F7F] flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" 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>
|
||||
该目录应包含已解密的 .db 文件,若有 resource 文件夹也会一并导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="pt-4 border-t border-[#EDEDED]">
|
||||
<div class="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center px-8 py-3 bg-[#07C160] text-white rounded-lg text-base font-medium hover:bg-[#06AD56] transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="!loading" class="w-5 h-5 mr-2" 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>
|
||||
<svg v-if="loading" class="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ loading ? '导入中...' : '开始导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,7 +434,7 @@
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const { decryptDatabase, importDecrypted, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi()
|
||||
const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -527,8 +443,12 @@ const currentStep = ref(0)
|
||||
const mediaAccount = ref('')
|
||||
const isGettingDbKey = ref(false)
|
||||
|
||||
// 解密模式切换
|
||||
const decryptMode = ref('standard') // 'standard' or 'import'
|
||||
// 步骤定义
|
||||
const steps = [
|
||||
{ title: '数据库解密' },
|
||||
{ title: '填写图片密钥' },
|
||||
{ title: '图片解密' }
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
@@ -536,16 +456,10 @@ const formData = reactive({
|
||||
db_storage_path: ''
|
||||
})
|
||||
|
||||
// 导入数据
|
||||
const importData = reactive({
|
||||
path: ''
|
||||
})
|
||||
|
||||
// 表单错误
|
||||
const formErrors = reactive({
|
||||
key: '',
|
||||
db_storage_path: '',
|
||||
import_path: ''
|
||||
db_storage_path: ''
|
||||
})
|
||||
|
||||
// 图片密钥相关
|
||||
@@ -784,57 +698,6 @@ const resetDbDecryptProgress = () => {
|
||||
dbDecryptProgress.message = ''
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importData.path) {
|
||||
formErrors.import_path = '请输入已解密目录路径'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
formErrors.import_path = ''
|
||||
|
||||
try {
|
||||
const res = await importDecrypted({
|
||||
import_path: importData.path
|
||||
})
|
||||
|
||||
if (res.status === 'success') {
|
||||
mediaAccount.value = res.account
|
||||
// 模拟一个成功的结果
|
||||
decryptResult.value = {
|
||||
status: 'completed',
|
||||
success_count: res.imported_files.length,
|
||||
total_databases: res.imported_files.length,
|
||||
account_results: {
|
||||
[res.account]: {
|
||||
success: res.imported_files.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('decryptResult', JSON.stringify(decryptResult.value))
|
||||
}
|
||||
|
||||
// 如果有 resource 目录,则提示用户可以跳过图片解密
|
||||
if (res.has_resource) {
|
||||
warning.value = '检测到已包含 resource 资源目录,您可以直接跳转到聊天记录。'
|
||||
}
|
||||
|
||||
currentStep.value = 1
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
} else {
|
||||
error.value = res.message || '导入失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '导入过程中发生错误'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理解密
|
||||
const handleDecrypt = async () => {
|
||||
if (!validateForm()) {
|
||||
|
||||
+141
-14
@@ -41,6 +41,14 @@
|
||||
</svg>
|
||||
<span>直接解密</span>
|
||||
</NuxtLink>
|
||||
|
||||
<button @click="handleImportClick"
|
||||
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>
|
||||
</button>
|
||||
|
||||
<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">
|
||||
@@ -49,27 +57,86 @@
|
||||
</svg>
|
||||
<span>聊天预览</span>
|
||||
</NuxtLink>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 导入预览对话框 -->
|
||||
<transition name="fade">
|
||||
<div v-if="showImportModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-slide-up">
|
||||
<div class="p-8">
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-6 flex items-center">
|
||||
<svg class="w-6 h-6 mr-2 text-[#91D300]" 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>
|
||||
确认导入账号
|
||||
</h3>
|
||||
|
||||
<div v-if="importPreview" class="flex flex-col items-center mb-8">
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden border-4 border-[#F7F7F7] shadow-sm mb-4">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="w-full h-full object-cover" alt="头像">
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-bold text-gray-900">{{ importPreview.nick }}</div>
|
||||
<div class="text-sm text-gray-500 font-mono mt-1">{{ importPreview.username }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 w-full bg-gray-50 rounded-xl p-4 text-sm text-gray-600 space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span>包含数据库</span>
|
||||
<span class="text-[#07C160] font-medium">是</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>包含资源文件</span>
|
||||
<span :class="importPreview.has_resource ? 'text-[#07C160]' : 'text-gray-400'" class="font-medium">
|
||||
{{ importPreview.has_resource ? '是' : '否' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importError" class="mb-6 p-4 bg-red-50 border border-red-100 rounded-xl flex items-start">
|
||||
<svg class="w-5 h-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" 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>
|
||||
<span class="text-sm text-red-600">{{ importError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button @click="showImportModal = false"
|
||||
class="flex-1 px-6 py-3 border border-gray-200 text-gray-600 rounded-xl font-medium hover:bg-gray-50 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button @click="confirmImport" :disabled="importing"
|
||||
class="flex-1 px-6 py-3 bg-[#91D300] text-white rounded-xl font-medium hover:bg-[#82BD00] disabled:opacity-50 transition-colors flex items-center justify-center">
|
||||
<svg v-if="importing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
确认导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
const { listChatAccounts, importDecryptedPreview, importDecrypted } = useApi()
|
||||
|
||||
// 导入相关
|
||||
const showImportModal = ref(false)
|
||||
const importing = ref(false)
|
||||
const importPreview = ref(null)
|
||||
const importError = ref('')
|
||||
const selectedImportPath = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
|
||||
@@ -77,8 +144,7 @@ onMounted(async () => {
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const resp = await listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
if (accounts.length) {
|
||||
await navigateTo('/chat', { replace: true })
|
||||
@@ -86,6 +152,67 @@ onMounted(async () => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
// 导入点击
|
||||
const handleImportClick = async () => {
|
||||
let path = ''
|
||||
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
const res = await window.wechatDesktop.chooseDirectory({
|
||||
title: '选择解密数据所在目录'
|
||||
})
|
||||
if (!res || res.canceled || !res.filePaths?.length) return
|
||||
path = res.filePaths[0]
|
||||
} catch (e) {
|
||||
console.error('选择目录失败:', e)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 网页版演示,弹出提示让输入(通常在本地开发使用)
|
||||
path = window.prompt('请输入已解密目录的绝对路径:')
|
||||
if (!path) return
|
||||
}
|
||||
|
||||
selectedImportPath.value = path
|
||||
importError.value = ''
|
||||
importPreview.value = null
|
||||
showImportModal.value = true
|
||||
|
||||
try {
|
||||
const res = await importDecryptedPreview({ import_path: path })
|
||||
importPreview.value = res
|
||||
} catch (e) {
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
|
||||
}
|
||||
}
|
||||
|
||||
// 确认导入
|
||||
const confirmImport = async () => {
|
||||
if (!selectedImportPath.value) return
|
||||
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
|
||||
try {
|
||||
const res = await importDecrypted({ import_path: selectedImportPath.value })
|
||||
if (res.status === 'success') {
|
||||
// 导入成功后,可以跳转到聊天界面
|
||||
await navigateTo('/chat')
|
||||
} else {
|
||||
importError.value = res.message || '导入失败'
|
||||
}
|
||||
} catch (e) {
|
||||
importError.value = e.message || '导入过程中发生错误'
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始检测并跳转到结果页面
|
||||
const startDetection = async () => {
|
||||
// 直接跳转到检测结果页面,让该页面处理检测
|
||||
|
||||
@@ -29,57 +29,91 @@ def _is_valid_sqlite(path: Path) -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@router.post("/api/import_decrypted", summary="导入已解密的数据库和资源目录")
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
"""
|
||||
验证导入目录结构:
|
||||
- databases/ (必须包含 contact.db, session.db)
|
||||
- resource/ (可选)
|
||||
- account.json (必须包含 username, nick)
|
||||
"""
|
||||
db_dir = import_path / "databases"
|
||||
account_json_path = import_path / "account.json"
|
||||
|
||||
if not db_dir.exists() or not db_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="未找到 databases 目录")
|
||||
|
||||
if not account_json_path.exists():
|
||||
raise HTTPException(status_code=400, detail="未找到 account.json 文件")
|
||||
|
||||
# 验证关键数据库
|
||||
required_dbs = ["contact.db", "session.db"]
|
||||
for db_name in required_dbs:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"databases 目录中未找到有效的 {db_name}")
|
||||
|
||||
# 解析 account.json
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"解析 account.json 失败: {e}")
|
||||
|
||||
username = account_info.get("username")
|
||||
nick = account_info.get("nick")
|
||||
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json 中缺少 username 或 nick")
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"nick": nick,
|
||||
"avatar_url": account_info.get("avatar_url", ""),
|
||||
"has_resource": (import_path / "resource").exists()
|
||||
}
|
||||
|
||||
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
|
||||
async def preview_import(request: ImportRequest):
|
||||
import_path = Path(request.import_path.strip())
|
||||
if not import_path.exists() or not import_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
|
||||
|
||||
return _validate_import_structure(import_path)
|
||||
|
||||
@router.post("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录")
|
||||
async def import_decrypted_directory(request: ImportRequest):
|
||||
"""
|
||||
导入已解密的微信数据库和资源目录。
|
||||
该功能不需要密钥,直接将现有的已解密文件链接或复制到输出目录。
|
||||
"""
|
||||
import_path = Path(request.import_path.strip())
|
||||
if not import_path.exists() or not import_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
|
||||
|
||||
# 1. 尝试识别账号名
|
||||
# 优先从路径名识别 (例如 .../wxid_xxxx)
|
||||
from ..wechat_decrypt import _derive_account_name_from_path
|
||||
account_name = _derive_account_name_from_path(import_path)
|
||||
# 1. 验证并获取账号信息
|
||||
info = _validate_import_structure(import_path)
|
||||
account_name = info["username"]
|
||||
|
||||
# 2. 验证关键数据库文件
|
||||
# 必须包含 contact.db 和 session.db 才能在列表中正常显示
|
||||
required_dbs = ["contact.db", "session.db"]
|
||||
for db_name in required_dbs:
|
||||
if not _is_valid_sqlite(import_path / db_name):
|
||||
# 兼容性检查:如果不在根目录,可能在 db_storage 子目录?
|
||||
# 但用户说“和现在完全保存的目录一致”,所以应该在根目录。
|
||||
raise HTTPException(status_code=400, detail=f"导入目录中未找到有效的 {db_name},请确保是已解密的扁平化目录")
|
||||
|
||||
# 3. 准备输出目录
|
||||
# 2. 准备输出目录
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
account_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"正在从 {import_path} 导入账号 {account_name} ...")
|
||||
|
||||
# 4. 导入 .db 文件
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path / "databases"
|
||||
imported_files = []
|
||||
for item in import_path.iterdir():
|
||||
for item in db_src_dir.iterdir():
|
||||
if item.is_file() and item.suffix == ".db":
|
||||
target = account_output_dir / item.name
|
||||
try:
|
||||
# 优先尝试硬链接以节省空间
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
os.link(item, target)
|
||||
imported_files.append(item.name)
|
||||
except Exception as e:
|
||||
logger.warning(f"硬链接失败,尝试复制: {item.name}, error: {e}")
|
||||
except Exception:
|
||||
try:
|
||||
shutil.copy2(item, target)
|
||||
imported_files.append(item.name)
|
||||
except Exception as e2:
|
||||
logger.error(f"复制失败: {item.name}, error: {e2}")
|
||||
except Exception as e:
|
||||
logger.error(f"导入数据库失败: {item.name}, error: {e}")
|
||||
|
||||
# 5. 导入 resource 目录
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
resource_dst = account_output_dir / "resource"
|
||||
@@ -90,22 +124,29 @@ async def import_decrypted_directory(request: ImportRequest):
|
||||
else:
|
||||
shutil.rmtree(resource_dst)
|
||||
|
||||
# 对目录尝试符号链接(Windows 下可能需要权限)
|
||||
try:
|
||||
os.symlink(resource_src, resource_dst, target_is_directory=True)
|
||||
logger.info("已创建 resource 目录的符号链接")
|
||||
except Exception:
|
||||
# 符号链接失败则尝试硬链接或复制(对于资源目录,复制比较慢,建议用户手动移动)
|
||||
logger.warning("符号链接失败,尝试复制 resource 目录(这可能需要较长时间)")
|
||||
shutil.copytree(resource_src, resource_dst, dirs_exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
|
||||
# 5. 复制 account.json
|
||||
try:
|
||||
shutil.copy2(import_path / "account.json", account_output_dir / "account.json")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 6. 保存来源信息
|
||||
try:
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{"db_storage_path": str(import_path), "import_mode": "manual_import", "imported_at": __import__('datetime').datetime.now().isoformat()},
|
||||
{
|
||||
"db_storage_path": str(import_path),
|
||||
"import_mode": "manual_import",
|
||||
"imported_at": __import__('datetime').datetime.now().isoformat(),
|
||||
"original_info": info
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
@@ -129,7 +170,7 @@ async def import_decrypted_directory(request: ImportRequest):
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"imported_files": imported_files,
|
||||
"has_resource": resource_src.exists(),
|
||||
"message": f"成功导入账号 {account_name},共 {len(imported_files)} 个数据库"
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user