feat: 优化导入流程,将导入按钮移至首页并支持目录选择和账号预览

This commit is contained in:
H3CoF6
2026-04-08 07:00:53 +08:00
Unverified
parent d64a2e46f7
commit ad6031651b
4 changed files with 238 additions and 197 deletions
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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})"
}