feat: parse wxgf(wxam) for import data

This commit is contained in:
H3CoF6
2026-04-09 01:13:48 +08:00
Unverified
parent 246d76812c
commit 73f69f6f14
2 changed files with 259 additions and 102 deletions
+81 -19
View File
@@ -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)