-
![Avatar]()
+
-
Detected account
+
检测到的账号
{{ importPreview.nick || '未命名账号' }}
{{ importPreview.username }}
@@ -121,7 +163,7 @@
-
Import path
+
导入路径
{{ selectedImportPath }}
@@ -145,7 +187,7 @@
重新选择目录
-
Current path
+
当前路径
{{ selectedImportPath }}
@@ -197,7 +239,9 @@ const importProgress = ref(0)
const importMessage = ref('正在准备...')
const importPreview = ref(null)
const importError = ref('')
+const importComplete = ref(null)
const selectedImportPath = ref('')
+const importJobId = ref('')
let eventSource = null
@@ -220,23 +264,24 @@ const isDesktopShell = () => {
const resetImport = () => {
closeEventSource()
importPreview.value = null
+ importComplete.value = null
importError.value = ''
selectedImportPath.value = ''
importing.value = false
importProgress.value = 0
importMessage.value = '正在准备...'
+ importJobId.value = ''
}
const { importDecryptedPreview, pickSystemDirectory } = useApi()
const apiBase = useApiBase()
-
const handlePickDirectory = async () => {
let path = ''
if (isDesktopShell()) {
try {
const res = await window.wechatDesktop.chooseDirectory({
- title: '请选择解密输出目录 (如: output/wxid_xxxxx)'
+ title: '请选择解密输出目录 (如: output/wxid_xxxxx 或单账号 output)'
})
if (!res || res.canceled || !res.filePaths?.length) return
path = res.filePaths[0]
@@ -246,7 +291,7 @@ const handlePickDirectory = async () => {
}
} else {
try {
- const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
+ const res = await pickSystemDirectory({ title: '请选择解密输出目录 (建议选到 wxid_xxx 层级)' })
if (!res || !res.path) return
path = res.path
} catch (e) {
@@ -260,7 +305,7 @@ const handlePickDirectory = async () => {
const isOk = window.confirm(`你选择的目录为:
${path}
-该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
+该目录似乎不是 "wxid_xxxxx" 账号目录。如果这是 wxdump 的单账号 output 根目录,可以继续。确定要继续吗?`)
if (!isOk) return
}
@@ -271,7 +316,7 @@ ${path}
try {
importPreview.value = await importDecryptedPreview({ import_path: path })
} catch (e) {
- importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
+ importError.value = e.message || '目录格式不正确,请确保包含 databases/database 目录;wxdump 格式可不含 account.json'
}
}
@@ -280,16 +325,65 @@ const retryPickDirectory = async () => {
await handlePickDirectory()
}
+const makeImportJobId = () => {
+ const randomPart = typeof crypto !== 'undefined' && crypto.randomUUID
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
+ return `import-${randomPart}`
+}
+
+const cancelImport = async () => {
+ const jobId = importJobId.value
+ closeEventSource()
+ importing.value = false
+ importProgress.value = 0
+ importMessage.value = '正在准备...'
+ importComplete.value = null
+ importError.value = ''
+
+ if (!jobId) return
+ try {
+ const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted/cancel`, window.location.origin)
+ url.searchParams.set('job_id', jobId)
+ await fetch(url.toString(), { method: 'POST' })
+ } catch (e) {
+ console.error('取消导入失败:', e)
+ } finally {
+ importJobId.value = ''
+ }
+}
+
+
const confirmImport = async () => {
if (!selectedImportPath.value) return
+ if (importPreview.value?.source_overlaps_target) {
+ importError.value = '导入源目录与目标数据目录相同或相互包含,请重新选择外部备份目录。'
+ return
+ }
+
+ if (importPreview.value?.target_exists) {
+ const ok = window.confirm(`当前账号已存在:${importPreview.value.username}
+
+继续导入会先自动备份旧目录,然后导入新数据。
+旧数据库数量:${importPreview.value.existing_db_count || 0}
+新数据库数量:${importPreview.value.incoming_db_count || 0}
+
+确定继续吗?`)
+ if (!ok) return
+ }
+
importing.value = true
+
+ importComplete.value = null
importError.value = ''
importProgress.value = 0
importMessage.value = '启动导入程序...'
+ importJobId.value = makeImportJobId()
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
url.searchParams.set('import_path', selectedImportPath.value)
+ url.searchParams.set('job_id', importJobId.value)
closeEventSource()
eventSource = new EventSource(url.toString())
@@ -304,15 +398,17 @@ const confirmImport = async () => {
} else if (data.type === 'complete') {
importProgress.value = 100
importMessage.value = '导入完成!'
- closeEventSource()
-
- setTimeout(async () => {
- await navigateTo('/chat')
- }, 1000)
- } else if (data.type === 'error') {
- importError.value = data.message || '导入失败'
+ importComplete.value = data
+ importError.value = ''
importing.value = false
closeEventSource()
+ importJobId.value = ''
+ } else if (data.type === 'error') {
+ importError.value = data.message || '导入失败'
+ importComplete.value = null
+ importing.value = false
+ closeEventSource()
+ importJobId.value = ''
}
} catch (e) {
console.error('解析 SSE 数据失败:', e)
@@ -321,9 +417,15 @@ const confirmImport = async () => {
eventSource.onerror = (e) => {
console.error('EventSource 错误:', e)
+ if (!importing.value) {
+ closeEventSource()
+ return
+ }
+ importComplete.value = null
importError.value = '与服务器连接断开或发生错误'
importing.value = false
closeEventSource()
+ importJobId.value = ''
}
}
diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py
index 60740bc..83a1fbe 100644
--- a/src/wechat_decrypt_tool/media_helpers.py
+++ b/src/wechat_decrypt_tool/media_helpers.py
@@ -3051,14 +3051,26 @@ def _try_find_decrypted_resource(account_dir: Path, md5: str) -> Optional[Path]:
if not resource_dir.exists():
return None
sub_dir = md5[:2] if len(md5) >= 2 else "00"
+
+ # Prefer the standard layout: resource/{md5-prefix}/{md5}.{ext}
target_dir = resource_dir / sub_dir
- if not target_dir.exists():
- return None
- # 查找匹配MD5的文件(可能有不同扩展名)
- for ext in ["jpg", "png", "gif", "webp", "mp4", "dat"]:
- p = target_dir / f"{md5}.{ext}"
- if p.exists():
- return p
+ search_dirs = [target_dir]
+
+ # Support wxdump flat media layout after it is imported as resource.
+ # Typical files: resource/{md5}.jpg, resource/{md5}_t.jpg, or resource/{md5}.wxgf.
+ if resource_dir not in search_dirs:
+ search_dirs.append(resource_dir)
+
+ exts = ["jpg", "png", "gif", "webp", "mp4", "dat", "wxgf", "wxgf.jpg"]
+ suffixes = ["", "_t", "_b", "_h"]
+ for directory in search_dirs:
+ if not directory.exists():
+ continue
+ for suffix in suffixes:
+ for ext in exts:
+ candidate = directory / f"{md5}{suffix}.{ext}"
+ if candidate.exists():
+ return candidate
return None
diff --git a/src/wechat_decrypt_tool/routers/import_decrypted.py b/src/wechat_decrypt_tool/routers/import_decrypted.py
index fec4884..c0fc3cc 100644
--- a/src/wechat_decrypt_tool/routers/import_decrypted.py
+++ b/src/wechat_decrypt_tool/routers/import_decrypted.py
@@ -3,7 +3,9 @@ from __future__ import annotations
import os
import shutil
import json
+import sqlite3
import asyncio
+from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
@@ -20,6 +22,12 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
+_IMPORT_CANCEL_EVENTS: dict[str, asyncio.Event] = {}
+
+
+class ImportCancelled(Exception):
+ pass
+
class ImportRequest(BaseModel):
import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径")
@@ -33,46 +41,180 @@ def _is_valid_sqlite(path: Path) -> bool:
except Exception:
return False
-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
+def _clean_profile_text(value: object) -> str:
+ text = str(value or "").replace("\u3164", "").strip()
+ return text
+
+
+def _pick_import_account_dir(import_path: Path) -> Path:
+ """Resolve the actual account directory; supports selecting output root or wxid_xxx."""
+ if (import_path / "databases").is_dir() or (import_path / "database").is_dir():
+ return import_path
+ account_dirs: list[Path] = []
try:
- account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
+ for child in import_path.iterdir():
+ if child.is_dir() and ((child / "databases").is_dir() or (child / "database").is_dir()):
+ account_dirs.append(child)
+ except Exception:
+ account_dirs = []
+ if len(account_dirs) == 1:
+ return account_dirs[0]
+ if len(account_dirs) > 1:
+ names = ", ".join(p.name for p in account_dirs[:5])
+ raise HTTPException(status_code=400, detail=f"Multiple account directories found. Please select one account directory: {names}")
+ return import_path
+
+
+def _pick_database_dir(account_dir: Path) -> Path:
+ """Support both this app's databases/ and wxdump's database/ directory names."""
+ for name in ("databases", "database"):
+ db_dir = account_dir / name
+ if db_dir.exists() and db_dir.is_dir():
+ return db_dir
+ raise HTTPException(status_code=400, detail="Missing databases or database directory")
+
+
+def _pick_resource_dir(account_dir: Path) -> Optional[Path]:
+ """Support both this app's resource/ and wxdump's media/ directory names."""
+ for name in ("resource", "media"):
+ resource_dir = account_dir / name
+ if resource_dir.exists() and resource_dir.is_dir():
+ return resource_dir
+ return None
+
+
+def _read_contact_profile(db_dir: Path, username: str) -> dict:
+ """Best-effort account profile inference from contact.db."""
+ contact_db = db_dir / "contact.db"
+ if not _is_valid_sqlite(contact_db):
+ return {}
+ try:
+ conn = sqlite3.connect(str(contact_db))
+ conn.row_factory = sqlite3.Row
+ try:
+ row = conn.execute("""
+ SELECT username, remark, nick_name, alias, big_head_url, small_head_url
+ FROM contact
+ WHERE username = ?
+ LIMIT 1
+ """, (username,)).fetchone()
+ finally:
+ conn.close()
+ if not row:
+ return {}
+ nick = _clean_profile_text(row["nick_name"]) or _clean_profile_text(row["remark"]) or _clean_profile_text(row["alias"]) or username
+ return {"username": _clean_profile_text(row["username"]) or username, "nick": nick, "avatar_url": str(row["big_head_url"] or row["small_head_url"] or "").strip(), "alias": _clean_profile_text(row["alias"])}
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()
- }
+ logger.warning(f"Failed to read account profile from contact.db: {contact_db}, {e}")
+ return {}
+
+
+def _load_or_infer_account_info(account_dir: Path, db_dir: Path) -> tuple[dict, Optional[Path], bool]:
+ """Read account.json; if missing in wxdump output, infer from folder name and contact.db."""
+ account_json_path = account_dir / "account.json"
+ if account_json_path.exists():
+ try:
+ account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Failed to parse account.json: {e}")
+ username = _clean_profile_text(account_info.get("username"))
+ nick = _clean_profile_text(account_info.get("nick") or account_info.get("nickname"))
+ if not username or not nick:
+ raise HTTPException(status_code=400, detail="account.json is missing username or nick")
+ account_info["username"] = username
+ account_info["nick"] = nick
+ account_info.setdefault("avatar_url", "")
+ return account_info, account_json_path, False
+ inferred_username = _clean_profile_text(account_dir.name)
+ if not inferred_username:
+ raise HTTPException(status_code=400, detail="Missing account.json and cannot infer account from directory name")
+ profile = _read_contact_profile(db_dir, inferred_username)
+ username = _clean_profile_text(profile.get("username")) or inferred_username
+ nick = _clean_profile_text(profile.get("nick")) or _clean_profile_text(profile.get("alias")) or username
+ return {"username": username, "nick": nick, "avatar_url": str(profile.get("avatar_url") or ""), "alias": str(profile.get("alias") or "")}, None, True
+
+
+def _validate_import_structure(import_path: Path) -> dict:
+ account_dir = _pick_import_account_dir(import_path)
+ db_dir = _pick_database_dir(account_dir)
+ resource_dir = _pick_resource_dir(account_dir)
+ for db_name in ["contact.db", "session.db"]:
+ if not _is_valid_sqlite(db_dir / db_name):
+ raise HTTPException(status_code=400, detail=f"Missing valid {db_name} in {db_dir.name}")
+ account_info, account_json_path, inferred_account = _load_or_infer_account_info(account_dir, db_dir)
+ return {"username": account_info["username"], "nick": account_info["nick"], "avatar_url": account_info.get("avatar_url", ""), "alias": account_info.get("alias", ""), "has_resource": resource_dir is not None, "source_format": "wxdump" if db_dir.name == "database" or inferred_account else "wechat_data_analysis", "inferred_account": inferred_account, "account_dir": str(account_dir), "db_dir": str(db_dir), "resource_dir": str(resource_dir) if resource_dir else "", "account_json_path": str(account_json_path) if account_json_path else ""}
+
+
+def _count_db_files(db_dir: Path) -> int:
+ try:
+ return sum(1 for f in db_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
+ except Exception:
+ return 0
+
+
+def _is_dir_nonempty(path: Path) -> bool:
+ try:
+ return path.exists() and path.is_dir() and any(path.iterdir())
+ except Exception:
+ return False
+
+
+def _paths_overlap(a: Path, b: Path) -> bool:
+ try:
+ ar = a.resolve()
+ br = b.resolve()
+ except Exception:
+ ar = a.absolute()
+ br = b.absolute()
+ return ar == br or ar in br.parents or br in ar.parents
+
+
+def _build_target_state(info: dict) -> dict:
+ output_base = get_output_databases_dir()
+ account_name = str(info.get("username") or "").strip()
+ target_dir = output_base / account_name if account_name else output_base
+ resource_dir = target_dir / "resource"
+ db_files: list[str] = []
+ try:
+ if target_dir.exists() and target_dir.is_dir():
+ db_files = sorted(f.name for f in target_dir.iterdir() if f.is_file() and f.suffix.lower() == ".db")
+ except Exception:
+ db_files = []
+ paths = [Path(str(info.get("account_dir") or "")), Path(str(info.get("db_dir") or ""))]
+ if info.get("resource_dir"):
+ paths.append(Path(str(info.get("resource_dir"))))
+ return {"target_dir": str(target_dir), "target_exists": target_dir.exists(), "target_nonempty": _is_dir_nonempty(target_dir), "existing_db_count": len(db_files), "existing_db_files": db_files[:50], "incoming_db_count": _count_db_files(Path(str(info.get("db_dir") or ""))), "target_has_resource": resource_dir.exists(), "will_replace_resource": bool(resource_dir.exists() and info.get("resource_dir")), "source_overlaps_target": any(_paths_overlap(x, target_dir) for x in paths if str(x))}
+
+
+def _next_backup_dir(account_output_dir: Path) -> Path:
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ base = account_output_dir.with_name(f"{account_output_dir.name}.backup-{stamp}")
+ candidate = base
+ i = 1
+ while candidate.exists():
+ candidate = account_output_dir.with_name(f"{base.name}-{i}")
+ i += 1
+ return candidate
+
+
+def _backup_existing_account_dir(account_output_dir: Path) -> Optional[Path]:
+ if not account_output_dir.exists():
+ return None
+ backup_dir = _next_backup_dir(account_output_dir)
+ shutil.move(str(account_output_dir), str(backup_dir))
+ return backup_dir
+
+
+def _rollback_account_backup(account_output_dir: Path, backup_dir: Optional[Path]) -> None:
+ if not backup_dir or not backup_dir.exists():
+ return
+ if account_output_dir.exists():
+ if account_output_dir.is_symlink() or account_output_dir.is_file():
+ account_output_dir.unlink()
+ else:
+ shutil.rmtree(account_output_dir)
+ shutil.move(str(backup_dir), str(account_output_dir))
+
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
async def preview_import(request: ImportRequest):
@@ -80,18 +222,42 @@ async def preview_import(request: ImportRequest):
if not import_path.exists() or not import_path.is_dir():
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
- return _validate_import_structure(import_path)
+ info = _validate_import_structure(import_path)
+ info.update(_build_target_state(info))
+ return info
+
+@router.post("/api/import_decrypted/cancel", summary="取消正在执行的导入任务")
+async def cancel_import_decrypted(job_id: str = Query(..., description="导入任务 ID")):
+ cancel_event = _IMPORT_CANCEL_EVENTS.get(str(job_id or ""))
+ if cancel_event:
+ cancel_event.set()
+ return {"status": "cancel_requested"}
+ return {"status": "not_found"}
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
async def import_decrypted_directory(
- import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
+ import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径"),
+ job_id: str = Query("", description="导入任务 ID,用于取消导入")
):
import_path_obj = Path(import_path.strip())
+ account_output_dir: Optional[Path] = None
+ backup_dir: Optional[Path] = None
+ backup_restored = False
+ job_key = str(job_id or "").strip()
+ cancel_event: Optional[asyncio.Event] = None
+ if job_key:
+ cancel_event = _IMPORT_CANCEL_EVENTS.setdefault(job_key, asyncio.Event())
+ cancel_event.clear()
def _sse(data: dict):
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
+ def _check_cancel():
+ if cancel_event is not None and cancel_event.is_set():
+ raise ImportCancelled("用户已取消导入")
+
async def generate_progress():
+ nonlocal account_output_dir, backup_dir, backup_restored
try:
if not import_path_obj.exists() or not import_path_obj.is_dir():
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
@@ -108,22 +274,34 @@ async def import_decrypted_directory(
yield _sse({"type": "error", "message": f"验证失败: {e}"})
return
+ _check_cancel()
+ info.update(_build_target_state(info))
+ if info.get("source_overlaps_target"):
+ yield _sse({"type": "error", "message": "导入源目录与目标数据目录相同或相互包含,请选择外部备份目录。"})
+ return
+
account_name = info["username"]
- yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
+ yield _sse({"type": "progress", "percent": 10, "message": f"验证成功:{account_name}"})
- # 2. 准备输出目录
+ # 2. 准备目标目录;如果已有账号数据,先整体备份再替换。
output_base = get_output_databases_dir()
account_output_dir = output_base / account_name
+ if account_output_dir.exists():
+ yield _sse({"type": "progress", "percent": 12, "message": "检测到已有账号数据,正在创建备份..."})
+ backup_dir = await asyncio.to_thread(_backup_existing_account_dir, account_output_dir)
+ if backup_dir:
+ yield _sse({"type": "progress", "percent": 14, "message": f"已创建备份:{backup_dir.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_src_dir = Path(info["db_dir"])
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):
+ _check_cancel()
target = account_output_dir / item.name
def _do_import_db(src, dst):
if dst.exists():
@@ -143,24 +321,79 @@ async def import_decrypted_directory(
yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"})
# 4. 导入 resource 目录
- resource_src = import_path_obj / "resource"
- if resource_src.exists() and resource_src.is_dir():
+ resource_src = Path(info["resource_dir"]) if info.get("resource_dir") else None
+ if resource_src and 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):
+ def _reset_resource_dst(dst: Path) -> None:
if dst.exists():
if dst.is_symlink() or dst.is_file():
dst.unlink()
else:
shutil.rmtree(dst)
+
+ def _try_link_resource(src: Path, dst: Path) -> bool:
try:
os.symlink(src, dst, target_is_directory=True)
+ return True
except Exception:
- shutil.copytree(src, dst, dirs_exist_ok=True)
-
+ return False
+
+ def _collect_resource_files(src: Path) -> list[tuple[Path, Path]]:
+ files: list[tuple[Path, Path]] = []
+ for root, _, names in os.walk(src):
+ root_path = Path(root)
+ for name in names:
+ file_path = root_path / name
+ try:
+ if file_path.is_file():
+ files.append((file_path, file_path.relative_to(src)))
+ except Exception:
+ continue
+ return files
+
+ def _copy_resource_batch(batch: list[tuple[Path, Path]], dst_root: Path) -> int:
+ copied = 0
+ for src_file, rel_path in batch:
+ dst_file = dst_root / rel_path
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(src_file, dst_file)
+ copied += 1
+ return copied
+
try:
- await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
+ prefer_copy_resource = info.get("source_format") == "wxdump"
+ await asyncio.to_thread(_reset_resource_dst, resource_dst)
+
+ if not prefer_copy_resource:
+ linked = await asyncio.to_thread(_try_link_resource, resource_src, resource_dst)
+ if linked:
+ yield _sse({"type": "progress", "percent": 48, "message": "资源目录已通过快捷链接导入。"})
+ else:
+ prefer_copy_resource = True
+
+ if prefer_copy_resource:
+ yield _sse({"type": "progress", "percent": 31, "message": "正在扫描资源文件数量..."})
+ resource_files = await asyncio.to_thread(_collect_resource_files, resource_src)
+ total_resources = len(resource_files)
+ if total_resources <= 0:
+ await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
+ yield _sse({"type": "progress", "percent": 48, "message": "资源目录为空,已跳过资源复制。"})
+ else:
+ await asyncio.to_thread(resource_dst.mkdir, parents=True, exist_ok=True)
+ batch_size = 300
+ copied_resources = 0
+ for batch_start in range(0, total_resources, batch_size):
+ _check_cancel()
+ batch = resource_files[batch_start:batch_start + batch_size]
+ copied_resources += await asyncio.to_thread(_copy_resource_batch, batch, resource_dst)
+ percent = 31 + int(min(copied_resources, total_resources) / total_resources * 17)
+ yield _sse({
+ "type": "progress",
+ "percent": min(percent, 48),
+ "message": f"正在复制资源文件:{copied_resources}/{total_resources}"
+ })
except Exception as e:
logger.error(f"导入 resource 目录失败: {e}")
@@ -183,6 +416,7 @@ async def import_decrypted_directory(
total_wxgf = len(wxgf_files)
converted_count = 0
for i, wxgf_path in enumerate(wxgf_files):
+ _check_cancel()
def _convert_one(p):
jpg_p = p.with_suffix(".wxgf.jpg")
if not jpg_p.exists():
@@ -209,10 +443,26 @@ async def import_decrypted_directory(
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
- # 6. 复制 account.json
+ # 6. Copy or generate account.json
+ def _write_imported_account_json(dst: Path, info: dict) -> None:
+ src = Path(str(info.get("account_json_path") or ""))
+ target = dst / "account.json"
+ if src.exists() and src.is_file():
+ shutil.copy2(src, target)
+ return
+ payload = {
+ "username": info.get("username") or dst.name,
+ "nick": info.get("nick") or info.get("username") or dst.name,
+ "avatar_url": info.get("avatar_url") or "",
+ "alias": info.get("alias") or "",
+ "generated_by": "manual_import",
+ "source_format": info.get("source_format") or "unknown",
+ }
+ target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
try:
- await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
+ await asyncio.to_thread(_write_imported_account_json, account_output_dir, info)
except Exception:
pass
@@ -233,7 +483,7 @@ async def import_decrypted_directory(
)
try:
- await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
+ await asyncio.to_thread(_save_source_info, account_output_dir, Path(info.get("account_dir") or import_path_obj), info)
except Exception:
pass
@@ -255,12 +505,32 @@ async def import_decrypted_directory(
"status": "success",
"account": account_name,
"nick": info["nick"],
- "message": f"成功导入账号 {info['nick']} ({account_name})"
+ "message": f"成功导入账号 {info['nick']} ({account_name})",
+ "backup_dir": str(backup_dir) if backup_dir else ""
})
+ except ImportCancelled:
+ try:
+ if account_output_dir is not None and backup_dir is not None:
+ await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
+ backup_restored = True
+ except Exception as rollback_error:
+ logger.error(f"取消导入后恢复备份失败: {rollback_error}", exc_info=True)
+ suffix = ",已恢复导入前备份" if backup_restored else ""
+ yield _sse({"type": "error", "message": f"导入已取消{suffix}"})
except Exception as e:
- logger.error(f"导入过程中发生异常: {e}", exc_info=True)
- yield _sse({"type": "error", "message": f"导入失败: {str(e)}"})
+ logger.error(f"导入失败: {e}", exc_info=True)
+ try:
+ if account_output_dir is not None and backup_dir is not None:
+ await asyncio.to_thread(_rollback_account_backup, account_output_dir, backup_dir)
+ backup_restored = True
+ except Exception as rollback_error:
+ logger.error(f"导入失败后恢复备份失败: {rollback_error}", exc_info=True)
+ suffix = ",已恢复导入前备份" if backup_restored else ""
+ yield _sse({"type": "error", "message": f"导入失败: {str(e)}{suffix}"})
+ finally:
+ if job_key:
+ _IMPORT_CANCEL_EVENTS.pop(job_key, None)
headers = {
"Content-Type": "text/event-stream",