mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat: parse wxgf(wxam) for import data
This commit is contained in:
+81
-19
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 初始状态:选择目录 -->
|
||||
<div v-if="!importPreview && !importError" class="flex flex-col items-center justify-center py-12 border-2 border-dashed border-[#EDEDED] rounded-3xl hover:border-[#91D300] transition-colors cursor-pointer group" @click="handlePickDirectory">
|
||||
<div v-if="!importPreview && !importError && !importing" class="flex flex-col items-center justify-center py-12 border-2 border-dashed border-[#EDEDED] rounded-3xl hover:border-[#91D300] transition-colors cursor-pointer group" @click="handlePickDirectory">
|
||||
<div class="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-10 h-10 text-gray-400 group-hover:text-[#91D300]" 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"/>
|
||||
@@ -42,8 +42,30 @@
|
||||
<p class="text-[#7F7F7F] text-sm mt-2">支持原生目录选择器</p>
|
||||
</div>
|
||||
|
||||
<!-- 导入进度状态 -->
|
||||
<div v-if="importing" class="animate-fade-in py-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative w-32 h-32 mb-8">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100">
|
||||
<circle class="text-gray-100" stroke-width="8" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50"/>
|
||||
<circle class="text-[#91D300] transition-all duration-500" stroke-width="8" :stroke-dasharray="263.89" :stroke-dashoffset="263.89 * (1 - importProgress / 100)" stroke-linecap="round" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50" transform="rotate(-90 50 50)"/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-gray-900">{{ importProgress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ importMessage }}</h3>
|
||||
<p class="text-sm text-gray-500">正在为您处理数据,请稍候...</p>
|
||||
|
||||
<div class="w-full max-w-xs bg-gray-100 h-1.5 rounded-full mt-8 overflow-hidden">
|
||||
<div class="bg-[#91D300] h-full transition-all duration-500" :style="{ width: importProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览状态:显示账号信息 -->
|
||||
<div v-if="importPreview" class="animate-fade-in">
|
||||
<div v-if="importPreview && !importing" class="animate-fade-in">
|
||||
<div class="flex flex-col items-center py-8 bg-[#FBFBFB] rounded-3xl border border-[#EDEDED] mb-8">
|
||||
<div class="w-28 h-28 rounded-full overflow-hidden border-4 border-white shadow-md mb-5">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="w-full h-full object-cover" alt="头像">
|
||||
@@ -72,10 +94,6 @@
|
||||
</button>
|
||||
<button @click="confirmImport" :disabled="importing"
|
||||
class="flex-[2] px-8 py-4 bg-[#91D300] text-white rounded-2xl font-bold hover:bg-[#82BD00] shadow-lg shadow-[#91D300]/20 disabled:opacity-50 transition-all flex items-center justify-center transform hover:scale-[1.02] active:scale-[0.98]">
|
||||
<svg v-if="importing" class="animate-spin -ml-1 mr-3 h-5 w-5 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>
|
||||
<span v-if="!importing">确认导入此账号</span>
|
||||
<span v-else>正在导入数据...</span>
|
||||
</button>
|
||||
@@ -83,13 +101,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="importError" class="animate-fade-in">
|
||||
<div v-if="importError && !importing" class="animate-fade-in">
|
||||
<div class="p-6 bg-red-50 border border-red-100 rounded-2xl flex items-start mb-8">
|
||||
<svg class="w-6 h-6 text-red-500 mr-3 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>
|
||||
<div>
|
||||
<p class="font-bold text-red-800 mb-1">目录校验失败</p>
|
||||
<p class="font-bold text-red-800 mb-1">导入失败</p>
|
||||
<p class="text-sm text-red-600">{{ importError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,14 +136,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue'
|
||||
import {ref, onUnmounted} from 'vue'
|
||||
import {useApi} from '~/composables/useApi'
|
||||
import {useApiBase} from '~/composables/useApiBase'
|
||||
|
||||
const importing = ref(false)
|
||||
const importProgress = ref(0)
|
||||
const importMessage = ref('正在准备...')
|
||||
const importPreview = ref(null)
|
||||
const importError = ref('')
|
||||
const selectedImportPath = ref('')
|
||||
|
||||
let eventSource = null
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
@@ -135,9 +164,13 @@ const resetImport = () => {
|
||||
importPreview.value = null
|
||||
importError.value = ''
|
||||
selectedImportPath.value = ''
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
}
|
||||
|
||||
const { importDecryptedPreview, importDecrypted, pickSystemDirectory } = useApi()
|
||||
const { importDecryptedPreview, pickSystemDirectory } = useApi()
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
let path = ''
|
||||
@@ -186,18 +219,47 @@ const confirmImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
importProgress.value = 0
|
||||
importMessage.value = '启动导入程序...'
|
||||
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/api/import_decrypted`, window.location.origin)
|
||||
url.searchParams.set('import_path', selectedImportPath.value)
|
||||
|
||||
if (eventSource) eventSource.close()
|
||||
|
||||
try {
|
||||
const res = await importDecrypted({ import_path: selectedImportPath.value })
|
||||
if (res.status === 'success') {
|
||||
await navigateTo('/chat')
|
||||
} else {
|
||||
importError.value = res.message || '导入失败'
|
||||
eventSource = new EventSource(url.toString())
|
||||
|
||||
eventSource.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'progress') {
|
||||
importProgress.value = data.percent || 0
|
||||
importMessage.value = data.message || '正在处理...'
|
||||
} else if (data.type === 'complete') {
|
||||
importProgress.value = 100
|
||||
importMessage.value = '导入完成!'
|
||||
eventSource.close()
|
||||
|
||||
// 延迟跳转,让用户看到 100%
|
||||
setTimeout(async () => {
|
||||
await navigateTo('/chat')
|
||||
}, 1000)
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importing.value = false
|
||||
eventSource.close()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e)
|
||||
}
|
||||
} catch (e) {
|
||||
importError.value = e.message || '导入过程中发生错误'
|
||||
} finally {
|
||||
}
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('EventSource 错误:', e)
|
||||
importError.value = '与服务器连接断开或发生错误'
|
||||
importing.value = false
|
||||
eventSource.close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,14 +3,18 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..session_last_message import build_session_last_message_table
|
||||
from ..media_helpers import _wxgf_to_image_bytes
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -78,99 +82,190 @@ async def preview_import(request: ImportRequest):
|
||||
|
||||
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. 验证并获取账号信息
|
||||
info = _validate_import_structure(import_path)
|
||||
account_name = info["username"]
|
||||
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
|
||||
async def import_decrypted_directory(
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
):
|
||||
import_path_obj = Path(import_path.strip())
|
||||
|
||||
# 2. 准备输出目录
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
account_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
def _sse(data: dict):
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
logger.info(f"正在从 {import_path} 导入账号 {account_name} ...")
|
||||
async def generate_progress():
|
||||
try:
|
||||
if not import_path_obj.exists() or not import_path_obj.is_dir():
|
||||
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
|
||||
return
|
||||
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path / "databases"
|
||||
imported_files = []
|
||||
for item in db_src_dir.iterdir():
|
||||
if item.is_file() and item.suffix == ".db":
|
||||
target = account_output_dir / item.name
|
||||
yield _sse({"type": "progress", "percent": 5, "message": "正在验证目录结构..."})
|
||||
# 1. 验证并获取账号信息
|
||||
try:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
os.link(item, target)
|
||||
imported_files.append(item.name)
|
||||
except Exception:
|
||||
info = await asyncio.to_thread(_validate_import_structure, import_path_obj)
|
||||
except HTTPException as e:
|
||||
yield _sse({"type": "error", "message": e.detail})
|
||||
return
|
||||
except Exception as e:
|
||||
yield _sse({"type": "error", "message": f"验证失败: {e}"})
|
||||
return
|
||||
|
||||
account_name = info["username"]
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
|
||||
|
||||
# 2. 准备输出目录
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
await asyncio.to_thread(account_output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
yield _sse({"type": "progress", "percent": 15, "message": "正在准备目标目录..."})
|
||||
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path_obj / "databases"
|
||||
db_files = [f for f in db_src_dir.iterdir() if f.is_file() and f.suffix == ".db"]
|
||||
imported_files = []
|
||||
|
||||
for i, item in enumerate(db_files):
|
||||
target = account_output_dir / item.name
|
||||
def _do_import_db(src, dst):
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except Exception:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
try:
|
||||
shutil.copy2(item, target)
|
||||
await asyncio.to_thread(_do_import_db, item, target)
|
||||
imported_files.append(item.name)
|
||||
except Exception as e:
|
||||
logger.error(f"导入数据库失败: {item.name}, error: {e}")
|
||||
|
||||
percent = 15 + int((i + 1) / (len(db_files) or 1) * 15)
|
||||
yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"})
|
||||
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
resource_dst = account_output_dir / "resource"
|
||||
try:
|
||||
if resource_dst.exists():
|
||||
if resource_dst.is_symlink() or resource_dst.is_file():
|
||||
resource_dst.unlink()
|
||||
else:
|
||||
shutil.rmtree(resource_dst)
|
||||
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path_obj / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."})
|
||||
resource_dst = account_output_dir / "resource"
|
||||
|
||||
def _do_import_resource(src, dst):
|
||||
if dst.exists():
|
||||
if dst.is_symlink() or dst.is_file():
|
||||
dst.unlink()
|
||||
else:
|
||||
shutil.rmtree(dst)
|
||||
try:
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
except Exception:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
|
||||
# 5. 转换 .wxgf 资源 (新增加的流程)
|
||||
yield _sse({"type": "progress", "percent": 50, "message": "正在搜索并转换 .wxgf 图片..."})
|
||||
|
||||
if resource_dst.exists():
|
||||
# 搜索 wxgf 文件
|
||||
def _find_wxgf(root_dir):
|
||||
found = []
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.lower().endswith(".wxgf"):
|
||||
found.append(Path(root) / f)
|
||||
return found
|
||||
|
||||
wxgf_files = await asyncio.to_thread(_find_wxgf, resource_dst)
|
||||
|
||||
if wxgf_files:
|
||||
total_wxgf = len(wxgf_files)
|
||||
converted_count = 0
|
||||
for i, wxgf_path in enumerate(wxgf_files):
|
||||
def _convert_one(p):
|
||||
jpg_p = p.with_suffix(".wxgf.jpg")
|
||||
if not jpg_p.exists():
|
||||
data = p.read_bytes()
|
||||
if data.startswith(b"wxgf"):
|
||||
converted = _wxgf_to_image_bytes(data)
|
||||
if converted:
|
||||
jpg_p.write_bytes(converted)
|
||||
return True
|
||||
else:
|
||||
return True # 已经存在视为成功
|
||||
return False
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(_convert_one, wxgf_path)
|
||||
if success:
|
||||
converted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"转换 wxgf 失败: {wxgf_path}, {e}")
|
||||
|
||||
if i % max(1, total_wxgf // 20) == 0 or i == total_wxgf - 1:
|
||||
progress_val = 50 + int((i + 1) / total_wxgf * 30)
|
||||
yield _sse({"type": "progress", "percent": progress_val, "message": f"转换 wxgf 图片: {i+1}/{total_wxgf}"})
|
||||
|
||||
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
|
||||
|
||||
# 6. 复制 account.json
|
||||
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
|
||||
try:
|
||||
os.symlink(resource_src, resource_dst, target_is_directory=True)
|
||||
await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
|
||||
except Exception:
|
||||
shutil.copytree(resource_src, resource_dst, dirs_exist_ok=True)
|
||||
pass
|
||||
|
||||
# 7. 保存来源信息
|
||||
def _save_source_info(dst, path, info):
|
||||
(dst / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"db_storage_path": str(path),
|
||||
"import_mode": "manual_import",
|
||||
"imported_at": __import__('datetime').datetime.now().isoformat(),
|
||||
"original_info": info
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 8. 构建缓存
|
||||
yield _sse({"type": "progress", "percent": 90, "message": "正在构建会话缓存 (这可能需要较长时间)..."})
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
build_session_last_message_table,
|
||||
account_output_dir,
|
||||
rebuild=True,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"构建会话缓存失败: {e}")
|
||||
|
||||
yield _sse({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
logger.error(f"导入过程中发生异常: {e}", exc_info=True)
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(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(),
|
||||
"original_info": info
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 7. 构建缓存
|
||||
logger.info(f"正在为 {account_name} 构建会话缓存...")
|
||||
try:
|
||||
build_session_last_message_table(
|
||||
account_output_dir,
|
||||
rebuild=True,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"构建会话缓存失败: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"imported_files": imported_files,
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
return StreamingResponse(generate_progress(), headers=headers)
|
||||
|
||||
Reference in New Issue
Block a user