Compare commits

...

10 Commits

34 changed files with 5040 additions and 710 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wechat-data-analysis-desktop",
"version": "1.3.0",
"version": "1.7.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "1.3.0",
"version": "1.7.12",
"dependencies": {
"electron-updater": "^6.7.3"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "1.3.0",
"version": "1.7.12",
"main": "src/main.cjs",
"scripts": {
"dev": "node scripts/dev.cjs",
+201 -19
View File
@@ -22,12 +22,12 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const { Worker } = require("worker_threads");
const {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
@@ -45,6 +45,7 @@ let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
let outputDirChangeProgressState = null;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -279,7 +280,9 @@ function resolveOutputDir() {
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
// Allow dev-mode desktop runs to persist the chosen output directory too.
// An explicit environment variable still wins so local launch overrides keep working.
const settingsOutputDir = safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "");
let chosen = null;
try {
@@ -705,6 +708,7 @@ function getOutputDirInfo() {
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const canChange = !!defaultPath && !!currentPath;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
@@ -718,8 +722,8 @@ function getOutputDirInfo() {
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
canChange,
changeUnavailableReason: canChange ? "" : "无法定位 output 目录",
};
}
@@ -749,10 +753,6 @@ function setIgnoredUpdateVersion(version) {
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
@@ -785,15 +785,41 @@ async function applyOutputDirChange(nextValue) {
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
let retainedBackupPath = "";
let backupCleanupWarning = "";
try {
setOutputDirChangeProgressState({
active: true,
stage: "preparing",
message: wasBackendRunning ? "正在暂停后端并准备迁移 output 目录" : "正在准备迁移 output 目录",
percent: 1,
});
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
migration = await runOutputDirWorker(
"migrate",
{
currentDir: currentPath,
nextDir: nextPath,
},
(progress) => {
setOutputDirChangeProgressState({
active: true,
...progress,
});
}
);
setOutputDirChangeProgressState({
active: true,
stage: "switching",
message: "正在应用新的 output 目录设置",
percent: 99,
currentFile: "",
});
setOutputDirSetting(nextPath);
@@ -803,11 +829,38 @@ async function applyOutputDirChange(nextValue) {
ensureOutputLink();
if (wasBackendRunning) {
setOutputDirChangeProgressState({
active: true,
stage: "restarting",
message: "正在重启后端并应用新的 output 目录",
percent: 99,
});
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
retainedBackupPath = migration?.backupDir || "";
if (retainedBackupPath) {
try {
cleanupOutputDirectoryBackup(retainedBackupPath);
retainedBackupPath = "";
} catch (cleanupErr) {
backupCleanupWarning = `;旧 output 目录未能自动删除:${cleanupErr?.message || cleanupErr}`;
logMain(
`[main] failed to clean output dir backup ${retainedBackupPath}: ${cleanupErr?.message || cleanupErr}`
);
}
}
setOutputDirChangeProgressState({
active: true,
stage: "complete",
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
percent: 100,
});
const info = getOutputDirInfo();
const successMessage =
(migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换") + backupCleanupWarning;
return {
success: true,
changed: true,
@@ -815,16 +868,22 @@ async function applyOutputDirChange(nextValue) {
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
backupPath: retainedBackupPath,
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
message: successMessage,
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
setOutputDirChangeProgressState({
active: true,
stage: "rolling-back",
message: "迁移失败,正在回滚 output 目录",
percent: 99,
});
await runOutputDirWorker("rollback", {
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
@@ -969,6 +1028,119 @@ function setWindowProgressBar(value) {
} catch {}
}
function makeIdleOutputDirChangeProgressState() {
return {
active: false,
stage: "idle",
message: "",
percent: 0,
bytesTransferred: 0,
bytesTotal: 0,
itemsTransferred: 0,
itemsTotal: 0,
currentFile: "",
error: "",
};
}
function clampOutputDirProgressNumber(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function normalizeOutputDirChangeProgressState(next = {}) {
const active = next?.active !== false;
const percent = Math.max(0, Math.min(100, Math.round(Number(next?.percent || 0))));
return {
active,
stage: String(next?.stage || (active ? "running" : "idle")),
message: String(next?.message || ""),
percent,
bytesTransferred: clampOutputDirProgressNumber(next?.bytesTransferred),
bytesTotal: clampOutputDirProgressNumber(next?.bytesTotal),
itemsTransferred: clampOutputDirProgressNumber(next?.itemsTransferred),
itemsTotal: clampOutputDirProgressNumber(next?.itemsTotal),
currentFile: String(next?.currentFile || ""),
error: String(next?.error || ""),
};
}
function getOutputDirChangeProgressState() {
if (!outputDirChangeProgressState) {
outputDirChangeProgressState = makeIdleOutputDirChangeProgressState();
}
return outputDirChangeProgressState;
}
function setOutputDirChangeProgressState(next = {}) {
outputDirChangeProgressState = normalizeOutputDirChangeProgressState(next);
sendToRenderer("app:outputDirChangeProgress", outputDirChangeProgressState);
if (!outputDirChangeProgressState.active) {
setWindowProgressBar(-1);
return outputDirChangeProgressState;
}
const ratio =
outputDirChangeProgressState.percent > 0
? Math.max(0.02, Math.min(1, outputDirChangeProgressState.percent / 100))
: 2;
setWindowProgressBar(ratio);
return outputDirChangeProgressState;
}
function clearOutputDirChangeProgressState() {
return setOutputDirChangeProgressState({ active: false });
}
function getOutputDirWorkerScriptPath() {
return path.join(__dirname, "output-dir-worker.cjs");
}
function runOutputDirWorker(action, payload, onProgress) {
return new Promise((resolve, reject) => {
const worker = new Worker(getOutputDirWorkerScriptPath(), {
workerData: {
action: String(action || "migrate"),
payload,
},
});
let settled = false;
const finish = (err, result) => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve(result);
};
worker.on("message", (message) => {
if (!message || typeof message !== "object") return;
if (message.type === "progress") {
if (typeof onProgress === "function") onProgress(message.progress || {});
return;
}
if (message.type === "result") {
finish(null, message.result);
return;
}
if (message.type === "error") {
finish(new Error(message.error?.message || "output 目录迁移失败"));
}
});
worker.once("error", (err) => {
finish(err);
});
worker.once("exit", (code) => {
if (settled || code === 0) return;
finish(new Error(`output 目录任务异常退出(code=${code}`));
});
});
}
function looksLikeHtml(input) {
if (!input) return false;
const s = String(input);
@@ -1611,14 +1783,21 @@ function startBackend() {
if (backendProc) return backendProc;
startWcdbSidecar();
const resolvedDataPath = resolveDataDir() || getUserDataDir() || repoRoot();
const resolvedOutputPath = resolveOutputDir() || getDefaultOutputDir() || path.join(resolvedDataPath, "output");
const env = {
...process.env,
WECHAT_TOOL_HOST: getBackendBindHost(),
WECHAT_TOOL_PORT: String(getBackendPort()),
WECHAT_TOOL_DATA_DIR: resolvedDataPath,
WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath,
// Make sure Python prints UTF-8 to stdout/stderr.
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
};
ensureWcdbSidecarEnv(env);
logMain(
`[main] startBackend packaged=${app.isPackaged} port=${env.WECHAT_TOOL_PORT} dataDir=${env.WECHAT_TOOL_DATA_DIR} outputDir=${env.WECHAT_TOOL_OUTPUT_DIR}`
);
// In packaged mode we expect to provide the generated Nuxt output dir via env.
if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) {
@@ -1626,8 +1805,6 @@ function startBackend() {
}
if (app.isPackaged) {
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
try {
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
@@ -2156,8 +2333,8 @@ function registerWindowIpc() {
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
canChange: false,
changeUnavailableReason: "无法读取 output 目录信息",
};
}
});
@@ -2166,6 +2343,10 @@ function registerWindowIpc() {
return resolveOutputDir() || "";
});
ipcMain.handle("app:getOutputDirChangeProgress", () => {
return getOutputDirChangeProgressState();
});
ipcMain.handle("app:openOutputDir", async () => {
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
@@ -2202,6 +2383,7 @@ function registerWindowIpc() {
};
} finally {
outputDirChangeInProgress = false;
clearOutputDirChangeProgressState();
}
});
+74
View File
@@ -0,0 +1,74 @@
const { parentPort, workerData } = require("worker_threads");
const { migrateOutputDirectory, rollbackOutputDirectoryChange } = require("./output-dir.cjs");
function serializeError(err) {
return {
message: err?.message || String(err),
stack: err?.stack ? String(err.stack) : "",
};
}
function createProgressPoster() {
let lastStage = "";
let lastPercent = -1;
let lastSentAt = 0;
return (progress) => {
const stage = String(progress?.stage || "");
const percent = Number(progress?.percent || 0);
const now = Date.now();
const shouldSend =
stage !== lastStage ||
percent >= 100 ||
percent <= 0 ||
percent >= lastPercent + 1 ||
now - lastSentAt >= 120;
if (!shouldSend) return;
lastStage = stage;
lastPercent = percent;
lastSentAt = now;
parentPort?.postMessage({ type: "progress", progress });
};
}
async function main() {
const action = String(workerData?.action || "migrate");
const payload = workerData?.payload && typeof workerData.payload === "object" ? workerData.payload : {};
if (action === "migrate") {
const result = await migrateOutputDirectory({
...payload,
onProgress: createProgressPoster(),
});
parentPort?.postMessage({ type: "result", result });
return;
}
if (action === "rollback") {
parentPort?.postMessage({
type: "progress",
progress: {
stage: "rolling-back",
message: "迁移失败,正在回滚 output 目录",
percent: 99,
bytesTransferred: 0,
bytesTotal: 0,
itemsTransferred: 0,
itemsTotal: 0,
currentFile: "",
},
});
rollbackOutputDirectoryChange(payload);
parentPort?.postMessage({ type: "result", result: { success: true } });
return;
}
throw new Error(`不支持的 output 目录 worker 动作:${action}`);
}
main().catch((err) => {
parentPort?.postMessage({ type: "error", error: serializeError(err) });
process.exitCode = 1;
});
+281 -8
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { pipeline } = require("stream/promises");
const SENTINEL_NAMES = [
"account_keys.json",
@@ -10,6 +11,17 @@ const SENTINEL_NAMES = [
"logs",
];
const PROGRESS_STAGE_MESSAGES = {
preparing: "正在准备迁移 output 目录",
scanning: "正在扫描 output 目录",
copying: "正在复制 output 数据",
verifying: "正在校验已复制的数据",
switching: "正在切换 output 目录",
"rolling-back": "迁移失败,正在回滚 output 目录",
restarting: "正在重启后端并应用新的 output 目录",
complete: "output 目录迁移完成",
};
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
@@ -136,7 +148,233 @@ function ensureTargetIsUsable(targetDir) {
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
function clampNonNegativeNumber(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function computeProgressPercent(stage, bytesTransferred, bytesTotal, itemsTransferred, itemsTotal) {
if (stage === "preparing") return 1;
if (stage === "scanning") return 2;
if (stage === "verifying") return 96;
if (stage === "switching") return 99;
if (stage === "complete") return 100;
if (stage === "copying") {
const ratio =
bytesTotal > 0
? Math.min(1, bytesTransferred / bytesTotal)
: itemsTotal > 0
? Math.min(1, itemsTransferred / itemsTotal)
: 1;
return Math.max(5, Math.min(94, Math.round(5 + ratio * 89)));
}
return 0;
}
function buildProgressSnapshot({
stage = "preparing",
bytesTransferred = 0,
bytesTotal = 0,
itemsTransferred = 0,
itemsTotal = 0,
currentFile = "",
}) {
const normalizedStage = String(stage || "preparing");
const safeBytesTransferred = clampNonNegativeNumber(bytesTransferred);
const safeBytesTotal = clampNonNegativeNumber(bytesTotal);
const safeItemsTransferred = clampNonNegativeNumber(itemsTransferred);
const safeItemsTotal = clampNonNegativeNumber(itemsTotal);
return {
stage: normalizedStage,
message: PROGRESS_STAGE_MESSAGES[normalizedStage] || "正在迁移 output 目录",
percent: computeProgressPercent(
normalizedStage,
safeBytesTransferred,
safeBytesTotal,
safeItemsTransferred,
safeItemsTotal
),
bytesTransferred: safeBytesTransferred,
bytesTotal: safeBytesTotal,
itemsTransferred: safeItemsTransferred,
itemsTotal: safeItemsTotal,
currentFile: String(currentFile || ""),
};
}
function emitProgress(onProgress, payload) {
if (typeof onProgress !== "function") return;
onProgress(buildProgressSnapshot(payload));
}
function sortDirectoryEntries(entries) {
return entries.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
}
function depthOfRelativePath(relativePath) {
const text = String(relativePath || "").trim();
if (!text) return 0;
return text.split(path.sep).length;
}
function collectCopyManifest(sourceDir) {
const directories = [];
const files = [];
let totalBytes = 0;
const stack = [""];
while (stack.length > 0) {
const relativeDir = stack.pop();
const absoluteDir = relativeDir ? path.join(sourceDir, relativeDir) : sourceDir;
const dirEntries = sortDirectoryEntries(fs.readdirSync(absoluteDir, { withFileTypes: true }));
for (const dirent of dirEntries) {
const relativePath = relativeDir ? path.join(relativeDir, dirent.name) : dirent.name;
const absolutePath = path.join(sourceDir, relativePath);
const stat = fs.lstatSync(absolutePath);
if (dirent.isDirectory()) {
directories.push({
relativePath,
mode: stat.mode,
atime: stat.atime,
mtime: stat.mtime,
});
stack.push(relativePath);
continue;
}
if (dirent.isFile()) {
files.push({
relativePath,
size: stat.size,
mode: stat.mode,
atime: stat.atime,
mtime: stat.mtime,
});
totalBytes += stat.size;
continue;
}
if (dirent.isSymbolicLink()) {
throw new Error(`output 目录包含不支持的符号链接:${relativePath}`);
}
throw new Error(`output 目录包含不支持的文件类型:${relativePath}`);
}
}
directories.sort((a, b) => depthOfRelativePath(a.relativePath) - depthOfRelativePath(b.relativePath));
return {
directories,
files,
totalBytes,
totalItems: directories.length + files.length,
};
}
function applyStatMetadata(targetPath, statLike) {
try {
if (Number.isInteger(statLike?.mode)) {
fs.chmodSync(targetPath, statLike.mode);
}
} catch {}
try {
if (statLike?.atime && statLike?.mtime) {
fs.utimesSync(targetPath, statLike.atime, statLike.mtime);
}
} catch {}
}
async function copyFileWithProgress({ sourcePath, targetPath, mode, onChunk }) {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const readStream = fs.createReadStream(sourcePath);
readStream.on("data", (chunk) => {
if (typeof onChunk === "function") onChunk(chunk.length);
});
const writeStream = fs.createWriteStream(targetPath, {
flags: "w",
mode: Number.isInteger(mode) ? mode : undefined,
});
await pipeline(readStream, writeStream);
}
async function copyOutputTree({ sourceDir, targetDir, manifest, onProgress }) {
fs.mkdirSync(targetDir, { recursive: true });
let bytesTransferred = 0;
let itemsTransferred = 0;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
});
for (const dirEntry of manifest.directories) {
const targetPath = path.join(targetDir, dirEntry.relativePath);
fs.mkdirSync(targetPath, { recursive: true });
itemsTransferred += 1;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: dirEntry.relativePath,
});
}
for (const fileEntry of manifest.files) {
const sourcePath = path.join(sourceDir, fileEntry.relativePath);
const targetPath = path.join(targetDir, fileEntry.relativePath);
await copyFileWithProgress({
sourcePath,
targetPath,
mode: fileEntry.mode,
onChunk: (delta) => {
bytesTransferred += clampNonNegativeNumber(delta);
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: fileEntry.relativePath,
});
},
});
applyStatMetadata(targetPath, fileEntry);
itemsTransferred += 1;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: fileEntry.relativePath,
});
}
for (let i = manifest.directories.length - 1; i >= 0; i -= 1) {
const dirEntry = manifest.directories[i];
applyStatMetadata(path.join(targetDir, dirEntry.relativePath), dirEntry);
}
}
async function migrateOutputDirectory({ currentDir, nextDir, now = new Date(), onProgress } = {}) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
@@ -155,15 +393,19 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
throw new Error("新旧 output 路径不能互相包含");
}
emitProgress(onProgress, { stage: "scanning" });
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
emitProgress(onProgress, { stage: "switching" });
fs.mkdirSync(targetPath, { recursive: true });
emitProgress(onProgress, { stage: "complete", itemsTransferred: 1, itemsTotal: 1 });
return {
changed: true,
currentDir: currentPath,
@@ -173,18 +415,34 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
};
}
const manifest = collectCopyManifest(currentPath);
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
fs.cpSync(currentPath, tempTarget, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
try {
await copyOutputTree({
sourceDir: currentPath,
targetDir: tempTarget,
manifest,
onProgress,
});
emitProgress(onProgress, {
stage: "verifying",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
verifyCopiedOutputTree(currentPath, tempTarget);
emitProgress(onProgress, {
stage: "switching",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
@@ -209,6 +467,13 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
throw err;
}
emitProgress(onProgress, {
stage: "complete",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
return {
changed: true,
currentDir: currentPath,
@@ -242,7 +507,15 @@ function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sou
} catch {}
}
function cleanupOutputDirectoryBackup(backupDir) {
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return false;
fs.rmSync(backupPath, { recursive: true, force: true });
return !pathExists(backupPath);
}
module.exports = {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
+6
View File
@@ -84,10 +84,16 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
getOutputDirChangeProgress: () => ipcRenderer.invoke("app:getOutputDirChangeProgress"),
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
onOutputDirChangeProgress: (callback) => {
const handler = (_event, progress) => callback(progress);
ipcRenderer.on("app:outputDirChangeProgress", handler);
return () => ipcRenderer.removeListener("app:outputDirChangeProgress", handler);
},
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
+35 -10
View File
@@ -5,6 +5,7 @@ const os = require("os");
const path = require("path");
const {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
@@ -55,14 +56,14 @@ test("getEffectiveOutputDirPath prefers env, then settings, then default", () =>
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
test("migrateOutputDirectory switches empty source to a new directory", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(currentDir, { recursive: true });
const result = migrateOutputDirectory({ currentDir, nextDir });
const result = await migrateOutputDirectory({ currentDir, nextDir });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
@@ -73,7 +74,7 @@ test("migrateOutputDirectory switches empty source to a new directory", () => {
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
test("migrateOutputDirectory blocks non-empty targets", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
@@ -84,8 +85,8 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
await assert.rejects(
migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
@@ -93,15 +94,15 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
test("migrateOutputDirectory blocks invalid current paths", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.writeFileSync(currentDir, "not-a-directory");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
await assert.rejects(
migrateOutputDirectory({ currentDir, nextDir }),
/不是目录/
);
} finally {
@@ -109,7 +110,7 @@ test("migrateOutputDirectory blocks invalid current paths", () => {
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
test("migrateOutputDirectory copies data and leaves the old directory as a backup", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
@@ -120,7 +121,13 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
const progressEvents = [];
const result = await migrateOutputDirectory({
currentDir,
nextDir,
now: new Date("2026-03-30T08:00:00Z"),
onProgress: (progress) => progressEvents.push(progress),
});
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
@@ -129,6 +136,9 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
assert.ok(progressEvents.some((event) => event.stage === "scanning"));
assert.ok(progressEvents.some((event) => event.stage === "copying" && event.percent > 0));
assert.ok(progressEvents.some((event) => event.stage === "complete" && event.percent === 100));
} finally {
cleanupDir(root);
}
@@ -160,3 +170,18 @@ test("rollbackOutputDirectoryChange restores the previous directory", () => {
cleanupDir(root);
}
});
test("cleanupOutputDirectoryBackup removes a completed migration backup directory", () => {
const root = makeTempDir();
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
assert.equal(cleanupOutputDirectoryBackup(backupDir), true);
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+1 -1
View File
@@ -101,7 +101,7 @@ const showDesktopTitleBar = computed(() => isDesktop.value)
const showSidebar = computed(() => {
const path = String(route.path || '')
if (path === '/') return false
if (path === '/' || path === '/import') return false
if (path === '/decrypt' || path === '/detection-result' || path === '/decrypt-result') return false
return !(path === '/wrapped' || path.startsWith('/wrapped/'))
})
File diff suppressed because it is too large Load Diff
+104 -1
View File
@@ -214,6 +214,22 @@
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</div>
<div v-if="desktopOutputDirProgress" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-2">
<div class="flex items-center justify-between gap-3 text-[11px] text-[#1b6b43]">
<div class="min-w-0 truncate">{{ desktopOutputDirProgressText }}</div>
<div class="shrink-0 tabular-nums">{{ desktopOutputDirProgressPercentText }}</div>
</div>
<div class="mt-1.5 h-2 overflow-hidden rounded-full bg-[#dceee3]">
<div
class="h-full rounded-full bg-[#07b75b] transition-[width] duration-200 ease-out"
:class="desktopOutputDirProgressIndeterminate ? 'animate-pulse' : ''"
:style="{ width: desktopOutputDirProgressBarWidth }"
/>
</div>
<div v-if="desktopOutputDirProgressDetail" class="mt-1 text-[10px] text-[#5d7a68] break-all">
{{ desktopOutputDirProgressDetail }}
</div>
</div>
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
{{ desktopOutputDirMessage }}
</div>
@@ -410,6 +426,8 @@ const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirProgress = ref(null)
let removeDesktopOutputDirProgressListener = null
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
@@ -424,6 +442,48 @@ const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirProgressPercent = computed(() => {
const n = Number(desktopOutputDirProgress.value?.percent || 0)
if (!Number.isFinite(n) || n < 0) return 0
return Math.max(0, Math.min(100, Math.round(n)))
})
const desktopOutputDirProgressPercentText = computed(() => `${desktopOutputDirProgressPercent.value}%`)
const desktopOutputDirProgressText = computed(() => {
const text = String(desktopOutputDirProgress.value?.message || '').trim()
return text || '正在迁移 output 目录'
})
const desktopOutputDirProgressIndeterminate = computed(() => {
const stage = String(desktopOutputDirProgress.value?.stage || '').trim()
return stage === 'preparing' || stage === 'scanning' || stage === 'rolling-back' || stage === 'restarting'
})
const desktopOutputDirProgressBarWidth = computed(() => {
if (!desktopOutputDirProgress.value) return '0%'
if (desktopOutputDirProgressIndeterminate.value) return '28%'
return `${Math.max(6, desktopOutputDirProgressPercent.value)}%`
})
const desktopOutputDirProgressDetail = computed(() => {
const progress = desktopOutputDirProgress.value
if (!progress) return ''
const parts = []
const bytesTotal = Number(progress.bytesTotal || 0)
const bytesTransferred = Number(progress.bytesTransferred || 0)
const itemsTotal = Number(progress.itemsTotal || 0)
const itemsTransferred = Number(progress.itemsTransferred || 0)
if (bytesTotal > 0) {
parts.push(`${formatBytes(bytesTransferred)} / ${formatBytes(bytesTotal)}`)
} else if (itemsTotal > 0) {
parts.push(`${Math.min(itemsTransferred, itemsTotal)} / ${itemsTotal}`)
}
const currentFile = String(progress.currentFile || '').trim()
if (currentFile) {
parts.push(currentFile)
}
return parts.join(' · ')
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
@@ -442,6 +502,37 @@ const switchTrackClass = (enabled, disabled = false) => {
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
}
const formatBytes = (value) => {
const n = Number(value || 0)
if (!Number.isFinite(n) || n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let next = n
let unitIndex = 0
while (next >= 1024 && unitIndex < units.length - 1) {
next /= 1024
unitIndex += 1
}
const digits = next >= 100 || unitIndex === 0 ? 0 : next >= 10 ? 1 : 2
return `${next.toFixed(digits)} ${units[unitIndex]}`
}
const applyDesktopOutputDirProgress = (progress) => {
if (!progress || progress.active === false) {
desktopOutputDirProgress.value = null
return
}
desktopOutputDirProgress.value = { ...progress }
}
const refreshDesktopOutputDirProgress = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDirChangeProgress) return
try {
const progress = await window.wechatDesktop.getOutputDirChangeProgress()
applyDesktopOutputDirProgress(progress)
} catch {}
}
const sectionElements = computed(() => [
{ key: 'desktop', el: desktopSectionRef.value },
{ key: 'startup', el: startupSectionRef.value },
@@ -676,12 +767,13 @@ const applyDesktopOutputDir = async (nextDir) => {
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '当前环境不支持修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
desktopOutputDirProgress.value = null
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
@@ -856,6 +948,7 @@ watch(() => props.open, async (isOpen) => {
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
await refreshDesktopOutputDirProgress()
}
}, { immediate: true })
@@ -864,6 +957,11 @@ onMounted(async () => {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
isDesktopEnv.value = isElectron && !!window.wechatDesktop
window.addEventListener('keydown', onEscKeydown)
if (window.wechatDesktop?.onOutputDirChangeProgress) {
removeDesktopOutputDirProgressListener = window.wechatDesktop.onOutputDirChangeProgress((progress) => {
applyDesktopOutputDirProgress(progress)
})
}
}
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
@@ -876,6 +974,7 @@ onMounted(async () => {
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
await refreshDesktopOutputDir()
await refreshDesktopOutputDirProgress()
}
await nextTick()
@@ -885,6 +984,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onEscKeydown)
if (typeof removeDesktopOutputDirProgressListener === 'function') {
removeDesktopOutputDirProgressListener()
removeDesktopOutputDirProgressListener = null
}
})
</script>
+45 -2
View File
@@ -144,6 +144,33 @@
</div>
</div>
<!-- Export -->
<div
v-if="showGlobalExportEntry"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="批量导出"
@click="openExportDialog"
>
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': exportDialogOpen }">
<svg
class="w-full h-full"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3v11" />
<path d="M7.5 10.5L12 15l4.5-4.5" />
<path d="M4 19h16" />
</svg>
</div>
</div>
</div>
<!-- Realtime -->
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
@@ -333,6 +360,8 @@
</div>
</div>
</div>
<GlobalExportDialog v-if="showGlobalExportEntry" :open="exportDialogOpen" @close="closeExportDialog" />
</template>
<script setup>
@@ -358,7 +387,9 @@ const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realti
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
const { getChatAccountInfo, deleteChatAccount } = useApi()
const showGlobalExportEntry = false
const accountDialogOpen = ref(false)
const exportDialogOpen = ref(false)
const accountInfoLoading = ref(false)
const accountInfoError = ref('')
const accountInfo = ref(null)
@@ -460,11 +491,19 @@ const openAccountDialog = async () => {
await loadAccountInfo()
}
const openExportDialog = () => {
exportDialogOpen.value = true
}
const closeAccountDialog = () => {
if (accountDeleteLoading.value) return
accountDialogOpen.value = false
}
const closeExportDialog = () => {
exportDialogOpen.value = false
}
watch(selectedAccount, () => {
if (!accountDialogOpen.value) return
void loadAccountInfo()
@@ -508,9 +547,13 @@ const goSettings = () => { openSettingsDialog() }
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
if (!accountDialogOpen.value) return
if (exportDialogOpen.value) {
return
}
event.preventDefault()
closeAccountDialog()
if (accountDialogOpen.value) {
closeAccountDialog()
}
}
const deleteCurrentAccountData = async () => {
+8 -3
View File
@@ -397,6 +397,7 @@ export const useApi = () => {
const getSavedKeys = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.db_storage_path) query.set('db_storage_path', params.db_storage_path)
const url = '/keys' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
@@ -453,7 +454,7 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
// 朋友圈导出(离线 ZIP,支持 HTML / JSON / TXT
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
@@ -461,6 +462,7 @@ export const useApi = () => {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
format: data.format || 'html',
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
@@ -546,8 +548,11 @@ export const useApi = () => {
}
// 获取数据库密钥
const getKeys = async () => {
return await request('/get_keys')
const getKeys = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.wechat_install_path) query.set('wechat_install_path', params.wechat_install_path)
const url = '/get_keys' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取图片密钥
+24
View File
@@ -0,0 +1,24 @@
export const WECHAT_INSTALL_PATH_STORAGE_KEY = 'decrypt.wechatInstallPath'
export const normalizeWechatInstallPath = (value) => String(value || '').trim()
export const readStoredWechatInstallPath = () => {
if (!process.client || typeof window === 'undefined') return ''
try {
return normalizeWechatInstallPath(window.localStorage.getItem(WECHAT_INSTALL_PATH_STORAGE_KEY) || '')
} catch {
return ''
}
}
export const writeStoredWechatInstallPath = (value) => {
if (!process.client || typeof window === 'undefined') return
try {
const normalized = normalizeWechatInstallPath(value)
if (normalized) {
window.localStorage.setItem(WECHAT_INSTALL_PATH_STORAGE_KEY, normalized)
} else {
window.localStorage.removeItem(WECHAT_INSTALL_PATH_STORAGE_KEY)
}
} catch {}
}
+22 -3
View File
@@ -73,6 +73,12 @@
</svg>
点击按钮将自动获取数据库解密密钥您也可以手动输入已知的64位密钥
</p>
<p v-if="formData.wechat_install_path" class="mt-2 text-xs text-[#7F7F7F] flex items-start">
<svg class="w-4 h-4 mr-1 mt-0.5 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>
<span>当前将使用第一步检测时保存的微信安装目录<span class="font-mono break-all">{{ formData.wechat_install_path }}</span></span>
</p>
</div>
<!-- 数据库路径输入 -->
@@ -655,6 +661,7 @@
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useApi } from '~/composables/useApi'
import { normalizeWechatInstallPath, readStoredWechatInstallPath } from '~/lib/wechat-install-path'
const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi()
@@ -677,7 +684,8 @@ const steps = [
// 表单数据
const formData = reactive({
key: '',
db_storage_path: ''
db_storage_path: '',
wechat_install_path: ''
})
// 表单错误
@@ -764,7 +772,10 @@ const prefillKeysForAccount = async (account) => {
if (!acc) return
logDecryptDebug('prefill:start', { account: acc })
try {
const resp = await getSavedKeys({ account: acc })
const resp = await getSavedKeys({
account: acc,
db_storage_path: String(formData.db_storage_path || '').trim()
})
if (!resp || resp.status !== 'success') return
const keys = resp.keys || {}
@@ -786,6 +797,9 @@ const prefillKeysForAccount = async (account) => {
request_account: acc,
response_account: String(resp.account || '').trim(),
db_key_present: !!dbKey,
db_key_store_account: String(keys.db_key_store_account || '').trim(),
db_key_source_wxid_dir: String(keys.db_key_source_wxid_dir || '').trim(),
db_key_blocked_reason: String(keys.db_key_blocked_reason || '').trim(),
...summarizeKeyStateForLog(
String(keys.image_xor_key || '').trim(),
String(keys.image_aes_key || '').trim()
@@ -873,6 +887,8 @@ const handleGetDbKey = async () => {
formErrors.key = ''
try {
const wechatInstallPath = normalizeWechatInstallPath(formData.wechat_install_path || readStoredWechatInstallPath())
formData.wechat_install_path = wechatInstallPath
const statusRes = await getWxStatus()
const wxStatus = statusRes?.wx_status
@@ -883,7 +899,9 @@ const handleGetDbKey = async () => {
warning.value = '正在启动微信,请确保微信未开启“自动登录”,并在弹窗中正常登录。'
const res = await getKeys()
const res = await getKeys({
wechat_install_path: wechatInstallPath
})
if (res && res.status === 0) {
if (res.data?.db_key) {
@@ -1617,6 +1635,7 @@ const skipToChat = async () => {
// 页面加载时检查是否有选中的账户
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
formData.wechat_install_path = readStoredWechatInstallPath()
const selectedAccount = sessionStorage.getItem('selectedAccount')
logDecryptDebug('mounted:selected-account-raw', { raw: selectedAccount || '' })
if (selectedAccount) {
+233 -125
View File
@@ -1,5 +1,5 @@
<template>
<div class="detection-result-page min-h-screen relative overflow-hidden flex items-center">
<div class="detection-result-page min-h-screen relative overflow-hidden">
<!-- 网格背景 -->
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
@@ -9,14 +9,16 @@
<div class="absolute -bottom-8 left-40 w-80 h-80 bg-[#91D300] opacity-5 rounded-full blur-3xl pointer-events-none"></div>
<!-- 主要内容 -->
<div class="relative z-10 w-full max-w-6xl mx-auto px-4 py-8 animate-fade-in">
<div class="relative z-10 w-full max-w-5xl mx-auto px-4 sm:px-5 py-6 sm:py-8 animate-fade-in">
<!-- 顶部操作栏 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">检测结果</span>
</h2>
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-[22px] font-bold leading-none">
<span class="bg-gradient-to-r from-[#07C160] to-[#10AEEF] bg-clip-text text-transparent">检测结果</span>
</h2>
</div>
<NuxtLink to="/"
class="inline-flex items-center px-3 py-1.5 text-sm text-[#07C160] hover:text-[#06AD56] font-medium transition-colors">
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs text-[#07C160] hover:text-[#06AD56] hover:bg-white/80 font-medium transition-colors">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
@@ -24,66 +26,167 @@
</NuxtLink>
</div>
<!-- 兜底唤起原生目录选择器再次检测 -->
<div class="bg-white rounded-xl p-4 md:p-5 border border-[#EDEDED] mb-6 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-sm hover:shadow transition-shadow duration-200">
<div>
<h3 class="text-sm font-bold text-[#000000e6] flex items-center">
未找到想要的账号
<div
v-if="detectionResult && !loading && !detectionResult.error"
class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mb-4"
>
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">微信版本</p>
<p class="mt-1 text-lg font-semibold text-[#000000e6] truncate">{{ detectionResult.data?.wechat_version || '未知' }}</p>
</div>
<div class="w-9 h-9 shrink-0 bg-[#07C160]/10 rounded-lg flex items-center justify-center">
<svg class="w-[18px] h-[18px] text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">检测账号</p>
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_accounts || 0 }} </p>
</div>
<div class="w-9 h-9 shrink-0 bg-[#10AEEF]/10 rounded-lg flex items-center justify-center">
<svg class="w-[18px] h-[18px] text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283-.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white/90 backdrop-blur rounded-xl px-4 py-3 border border-[#EDEDED]">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] tracking-[0.08em] uppercase text-[#7F7F7F]">数据库文件</p>
<p class="mt-1 text-lg font-semibold text-[#000000e6]">{{ detectionResult.data?.total_databases || 0 }} </p>
</div>
<div class="w-9 h-9 shrink-0 bg-[#91D300]/10 rounded-lg flex items-center justify-center">
<svg class="w-[18px] h-[18px] text-[#91D300]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
</svg>
</div>
</div>
</div>
</div>
<!-- 第一步检测数据目录与微信安装目录都在这里设置 -->
<div v-if="!loading" class="bg-white/90 backdrop-blur rounded-xl p-3.5 md:p-4 border border-[#EDEDED] mb-4 space-y-3">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div>
<h3 class="text-[13px] font-semibold text-[#000000e6] flex items-center">
未找到想要的账号
<!-- <span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-normal">深度检测兜底</span>-->
</h3>
<p class="text-xs text-[#7F7F7F] mt-1.5">
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
<span v-else>如果自动检测漏了您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描</span>
</h3>
<p class="text-[11px] text-[#7F7F7F] mt-1">
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
<span v-else>如果自动检测漏了您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描</span>
</p>
</div>
<button @click="handlePickDirectory" :disabled="loading"
class="shrink-0 px-4 py-2.5 bg-[#07C160] text-white rounded-xl text-xs font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
<svg v-if="!loading" class="w-4 h-4 mr-2" 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>
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
<circle
cx="24"
cy="24"
r="18"
stroke="currentColor"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray="28 72"
pathLength="100"
transform="rotate(-90 24 24)"
></circle>
</svg>
{{ loading ? '检测中...' : '手动选择目录检测' }}
</button>
</div>
<div class="pt-3 border-t border-[#F3F3F3]">
<label for="wechatInstallPath" class="block text-[13px] font-medium text-[#000000e6] mb-2">
微信安装目录第一步先填这里
</label>
<div class="flex flex-col lg:flex-row gap-3">
<input
id="wechatInstallPath"
v-model="wechatInstallPath"
type="text"
placeholder="例如: D:\Program Files\Tencent\WeChat 或 D:\Program Files\Tencent\WeChat\Weixin.exe"
class="flex-1 px-4 py-2.5 bg-white border border-[#EDEDED] rounded-lg font-mono text-[13px] focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
@blur="persistWechatInstallPath"
/>
<button
type="button"
@click="pickWechatInstallDirectory"
:disabled="isPickingWechatInstallPath"
class="shrink-0 px-4 py-2.5 bg-white border border-[#EDEDED] text-[#000000e6] rounded-xl text-xs font-medium hover:bg-gray-50 disabled:opacity-50 disabled:cursor-wait transition-all duration-200"
>
{{ isPickingWechatInstallPath ? '选择中...' : '选择微信目录' }}
</button>
</div>
<p class="text-[11px] text-[#7F7F7F] mt-2">
一键获取数据库密钥会优先使用这里填写的路径支持填写安装目录也支持直接填写 <span class="font-mono">Weixin.exe</span> / <span class="font-mono">WeChat.exe</span> 路径
</p>
</div>
<button @click="handlePickDirectory" :disabled="loading"
class="shrink-0 px-5 py-2.5 bg-[#07C160] text-white rounded-xl text-sm font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
<svg v-if="!loading" class="w-4 h-4 mr-2" 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>
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
<circle
cx="24"
cy="24"
r="18"
stroke="currentColor"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray="28 72"
pathLength="100"
transform="rotate(-90 24 24)"
></circle>
</svg>
{{ loading ? '检测中...' : '手动选择目录检测' }}
</button>
</div>
<!-- 主内容区域 -->
<div>
<div :class="loading ? 'flex min-h-[52vh] items-center justify-center' : ''">
<!-- 检测中状态 -->
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
<circle
cx="24"
cy="24"
r="18"
stroke="currentColor"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray="28 72"
pathLength="100"
transform="rotate(-90 24 24)"
></circle>
</svg>
<p class="mt-4 text-lg text-[#7F7F7F]">正在检测微信数据...</p>
<div v-if="loading" class="w-full max-w-3xl rounded-[24px] border border-[#EDEDED] bg-white/92 px-5 py-6 sm:px-8 sm:py-7">
<div class="flex flex-col items-center text-center">
<div class="relative flex h-12 w-12 items-center justify-center rounded-2xl bg-[#07C160]/10">
<span class="absolute inset-0 rounded-2xl border border-[#07C160]/10"></span>
<svg class="h-5 w-5 animate-spin text-[#07C160]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" class="opacity-20"></circle>
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
</svg>
</div>
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
<span class="inline-flex items-center rounded-full bg-[#07C160]/10 px-2.5 py-1 text-[11px] font-medium text-[#07C160]">
检测中
</span>
<span class="text-[11px] text-[#7F7F7F]">正在自动读取本机微信环境</span>
</div>
<h3 class="mt-3 text-[20px] font-semibold text-[#000000e6] leading-tight">正在检查账号与数据库文件</h3>
<p class="mt-2 max-w-[560px] text-[13px] leading-6 text-[#7F7F7F]">
会依次确认微信安装信息最近登录账号以及可用数据库通常几秒内完成
</p>
<div class="mt-5 h-1.5 w-full max-w-[620px] overflow-hidden rounded-full bg-[#F3F4F6]">
<div class="h-full w-2/5 rounded-full bg-gradient-to-r from-[#07C160] via-[#34D17A] to-[#8CE0AF] animate-pulse"></div>
</div>
<div class="mt-5 flex flex-wrap items-center justify-center gap-2.5">
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
<span>安装信息</span>
</div>
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
<span>账号匹配</span>
</div>
<div class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-[#FAFAFA] px-3 py-2 text-[12px] text-[#000000d9]">
<span class="h-2 w-2 rounded-full bg-[#07C160] animate-pulse"></span>
<span>数据库汇总</span>
</div>
</div>
</div>
</div>
<!-- 检测结果内容 -->
<!-- detection result content -->
<div v-if="detectionResult && !loading">
<!-- 错误信息 -->
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-8">
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-6">
<div class="flex items-center">
<svg class="w-8 h-8 text-red-500 mr-3 shrink-0" 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"/>
@@ -96,95 +199,49 @@
</div>
<!-- 成功结果 -->
<div v-else class="space-y-4">
<!-- 概览卡片 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="bg-white rounded-xl p-4 border border-[#EDEDED]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[#7F7F7F]">微信版本</p>
<p class="text-xl font-bold text-[#000000e6] mt-1">{{ detectionResult.data?.wechat_version || '未知' }}</p>
</div>
<div class="w-12 h-12 bg-[#07C160]/10 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-4 border border-[#EDEDED]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[#7F7F7F]">检测到的账户</p>
<p class="text-xl font-bold text-[#000000e6] mt-1">{{ detectionResult.data?.total_accounts || 0 }} </p>
</div>
<div class="w-12 h-12 bg-[#10AEEF]/10 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283-.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-4 border border-[#EDEDED]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[#7F7F7F]">数据库文件</p>
<p class="text-xl font-bold text-[#000000e6] mt-1">{{ detectionResult.data?.total_databases || 0 }} </p>
</div>
<div class="w-12 h-12 bg-[#91D300]/10 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-[#91D300]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
</svg>
</div>
</div>
</div>
</div>
<div v-else class="space-y-3">
<!-- 账户列表 -->
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden shadow-sm">
<div class="p-4 border-b border-[#EDEDED] bg-gray-50 flex items-center justify-between">
<h3 class="text-base font-bold text-[#000000e6]">可操作的微信账户</h3>
<span class="text-xs text-gray-500">点击解密即可提取数据</span>
class="bg-white/92 backdrop-blur rounded-2xl border border-[#EDEDED] overflow-hidden">
<div class="px-4 py-3 border-b border-[#EDEDED] bg-[#fafafa] flex items-center justify-between">
<h3 class="text-[15px] font-semibold text-[#000000e6]">可操作的微信账户</h3>
<span class="text-[11px] text-gray-500">点击解密即可提取数据</span>
</div>
<div class="divide-y divide-[#EDEDED] max-h-96 overflow-y-auto">
<div class="divide-y divide-[#EDEDED] max-h-[420px] overflow-y-auto">
<div v-for="(account, index) in sortedAccounts" :key="index"
:class="['p-5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
:class="['px-4 py-3.5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-4 py-1 rounded-bl-xl flex items-center">
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-3 py-1 rounded-bl-xl flex items-center">
<span class="text-xs text-[#07C160] font-bold flex items-center">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
最近登录账户
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex items-center justify-between gap-3 mt-1">
<div class="flex-1">
<div class="flex items-center">
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
<img :src="currentAccountInfo.avatar" class="w-14 h-14 rounded-xl border-2 border-[#07C160]/30 mr-4 shadow-sm object-cover bg-white" alt=""/>
<img :src="currentAccountInfo.avatar" class="w-12 h-12 rounded-xl border-2 border-[#07C160]/30 mr-3 object-cover bg-white" alt=""/>
</template>
<template v-else>
<div class="w-14 h-14 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-4 shadow-inner">
<span class="text-[#07C160] font-bold text-xl">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
<div class="w-12 h-12 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-3">
<span class="text-[#07C160] font-bold text-lg">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
</div>
</template>
<div>
<div class="flex flex-col">
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
<p class="text-xl font-extrabold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
<p class="text-xs text-[#7F7F7F] mt-1 font-mono">wxid: {{ account.account_name }}</p>
<p class="text-lg font-bold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
<p class="text-[11px] text-[#7F7F7F] mt-0.5 font-mono">wxid: {{ account.account_name }}</p>
</template>
<template v-else>
<p class="text-lg font-bold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
<p class="text-[15px] font-semibold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
</template>
</div>
<div class="flex items-center mt-2 space-x-4 text-sm text-[#7F7F7F]">
<div class="flex items-center mt-1.5 space-x-3 text-[12px] text-[#7F7F7F]">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
@@ -203,7 +260,7 @@
</div>
<button @click="goToDecrypt(account)"
class="inline-flex items-center px-6 py-2.5 bg-[#07C160] text-white rounded-xl font-bold hover:bg-[#06AD56] hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 text-sm shrink-0 z-10">
class="inline-flex items-center px-4 py-2 bg-[#07C160] text-white rounded-lg font-semibold hover:bg-[#06AD56] hover:-translate-y-0.5 transition-all duration-200 text-xs shrink-0 z-10">
解密提取
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
@@ -211,8 +268,8 @@
</button>
</div>
<div class="mt-4 pt-3 border-t border-dashed border-gray-200 text-sm text-gray-400">
<p v-if="account.data_dir" class="font-mono text-xs truncate" title="复制路径">
<div class="mt-3 pt-2.5 border-t border-dashed border-gray-200 text-sm text-gray-400">
<p v-if="account.data_dir" class="font-mono text-[11px] truncate" title="复制路径">
📂 {{ account.data_dir }}
</p>
</div>
@@ -221,11 +278,11 @@
</div>
<!-- 无账户提示 -->
<div v-else class="bg-white rounded-2xl p-12 text-center border border-[#EDEDED]">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div v-else class="bg-white rounded-2xl p-8 text-center border border-[#EDEDED]">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-lg text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
<p class="text-base text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
<p class="text-sm text-gray-500 mt-2">您可以尝试通过上方的按钮手动指定 "xwechat_files" 文件夹路径</p>
</div>
</div>
@@ -238,6 +295,7 @@
<script setup>
import {computed, onMounted, ref} from 'vue'
import {useApi} from '~/composables/useApi'
import {normalizeWechatInstallPath, readStoredWechatInstallPath, writeStoredWechatInstallPath} from '~/lib/wechat-install-path'
import {useAppStore} from '~/stores/app'
const { detectWechat, pickSystemDirectory } = useApi()
@@ -245,6 +303,8 @@ const appStore = useAppStore()
const loading = ref(false)
const detectionResult = ref(null)
const customPath = ref('')
const wechatInstallPath = ref('')
const isPickingWechatInstallPath = ref(false)
const STORAGE_KEY = 'wechat_data_root_path'
const isDesktopShell = () => {
@@ -288,6 +348,51 @@ const handlePickDirectory = async () => {
}
}
const persistWechatInstallPath = () => {
const normalized = normalizeWechatInstallPath(wechatInstallPath.value)
wechatInstallPath.value = normalized
writeStoredWechatInstallPath(normalized)
}
const pickWechatInstallDirectory = async () => {
if (isPickingWechatInstallPath.value) return
isPickingWechatInstallPath.value = true
try {
let path = ''
if (isDesktopShell()) {
const res = await window.wechatDesktop.chooseDirectory({
title: '请选择微信安装目录'
})
if (!res || res.canceled || !res.filePaths?.length) return
path = res.filePaths[0]
} else {
try {
const res = await pickSystemDirectory({
title: '请选择微信安装目录',
initial_dir: normalizeWechatInstallPath(wechatInstallPath.value)
})
if (!res || !res.path) return
path = res.path
} catch (e) {
console.error('通过API唤起微信安装目录选择器失败:', e)
path = window.prompt('无法直接唤起窗口,请输入微信安装目录或 Weixin.exe / WeChat.exe 的绝对路径:')
if (!path) return
}
}
const normalized = normalizeWechatInstallPath(path)
if (!normalized) return
wechatInstallPath.value = normalized
persistWechatInstallPath()
} catch (e) {
console.error('选择微信安装目录失败:', e)
} finally {
isPickingWechatInstallPath.value = false
}
}
// 计算属性:将当前登录账号排在第一位
const sortedAccounts = computed(() => {
if (!detectionResult.value?.data?.accounts) return []
@@ -384,6 +489,8 @@ const startDetection = async () => {
// 跳转到解密页面并传递账户信息
const goToDecrypt = (account) => {
persistWechatInstallPath()
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('selectedAccount', JSON.stringify({
account_name: account.account_name,
@@ -412,6 +519,7 @@ onMounted(() => {
const saved = String(localStorage.getItem(STORAGE_KEY) || '').trim()
if (saved) customPath.value = saved
} catch {}
wechatInstallPath.value = readStoredWechatInstallPath()
}
startDetection()
})
+326 -146
View File
@@ -1,134 +1,228 @@
<template>
<div class="import-page min-h-screen flex items-center justify-center py-8">
<div class="max-w-2xl mx-auto px-6 w-full">
<div class="bg-white rounded-3xl border border-[#EDEDED] shadow-sm overflow-hidden">
<div class="p-8 md:p-12">
<!-- 标题部分 -->
<div class="flex items-center mb-8">
<div class="w-14 h-14 bg-[#91D300] rounded-2xl flex items-center justify-center mr-5 shadow-sm">
<svg class="w-8 h-8 text-white" 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>
</div>
<div>
<h2 class="text-2xl font-bold text-[#000000e6]">数据导入</h2>
<p class="text-[#7F7F7F] mt-1">导入已解密的数据库备份目录</p>
</div>
</div>
<div class="import-page min-h-screen relative overflow-hidden">
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-6 mb-8">
<h3 class="text-blue-800 font-bold mb-3 flex items-center">
<svg 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
标准目录结构要求
</h3>
<ul class="text-sm text-blue-700 space-y-2 list-disc list-inside opacity-90">
<li><strong>预期目标</strong>请选择形如 <strong>/output/wxid_xxxxx/</strong> 这一级目录</li>
<li><strong>databases/</strong> 目录存放扁平化的 .db 文件</li>
<li><strong>account.json</strong> 文件系统会自动生成</li>
</ul>
</div>
<!-- 初始状态选择目录 -->
<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"/>
</svg>
</div>
<div class="text-[#000000e6] font-medium text-lg">点击选择备份目录</div>
<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)"/>
<div class="relative z-10 mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
<div class="w-full rounded-[28px] border border-[#EDEDED] bg-white/92 backdrop-blur-sm">
<div class="px-5 py-5 sm:px-7 sm:py-7">
<div class="mb-5 flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
<svg class="h-6 w-6" 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>
<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 && !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="头像">
</div>
<div class="text-center">
<div class="text-xl font-bold text-gray-900">{{ importPreview.nick }}</div>
<div class="text-sm text-gray-500 font-mono mt-1 bg-white px-3 py-1 rounded-full border border-[#EDEDED] inline-block">{{ importPreview.username }}</div>
</div>
<div class="mt-8 flex gap-6">
<div class="flex items-center text-sm text-gray-600">
<div class="w-2 h-2 rounded-full bg-[#07C160] mr-2"></div>
数据库已就绪
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-2 h-2 rounded-full mr-2" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-gray-300'"></div>
资源文件{{ importPreview.has_resource ? '已发现' : '未发现' }}
</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入备份</p>
<h1 class="mt-1 text-[24px] font-semibold leading-none text-[#000000e6]">数据导入</h1>
<p class="mt-2 text-sm text-[#7F7F7F]">导入已解密的微信备份目录支持本项目导出和 wxdump output/wxid_xxx 结构</p>
</div>
</div>
<div class="flex gap-4">
<button @click="resetImport"
class="flex-1 px-8 py-4 border border-[#EDEDED] text-gray-600 rounded-2xl font-bold hover:bg-gray-50 transition-all">
重新选择
</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]">
<span v-if="!importing">确认导入此账号</span>
<span v-else>正在导入数据...</span>
</button>
</div>
</div>
<!-- 错误状态 -->
<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="text-sm text-red-600">{{ importError }}</p>
</div>
</div>
<button @click="resetImport"
class="w-full px-8 py-4 bg-white border border-[#EDEDED] text-[#07C160] rounded-2xl font-bold hover:bg-gray-50 transition-all flex items-center justify-center">
<svg 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
重新选择目录
</button>
</div>
<!-- 返回首页 -->
<div class="mt-12 text-center">
<NuxtLink to="/" class="text-[#7F7F7F] hover:text-[#07C160] text-sm transition-colors inline-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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
<NuxtLink
to="/"
class="inline-flex shrink-0 items-center rounded-lg px-3 py-1.5 text-xs font-medium text-[#07C160] transition-colors hover:bg-[#F3FBF6] hover:text-[#06AD56]"
>
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回首页
</NuxtLink>
</div>
<div class="mb-5 rounded-[22px] border border-[#E8EFE8] bg-[#F8FBF8] px-4 py-4 sm:px-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white text-[#07C160] ring-1 ring-[#E7F1E8]">
<svg class="h-4 w-4" 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>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[#000000d9]">目录要求</div>
<p class="mt-1 text-sm leading-6 text-[#6F6F6F]">支持本项目导出和 wxdump 导出优先选择账号目录 output 下只有一个账号也可直接选 output</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">databases/</span>
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">database/</span>
<span class="inline-flex items-center rounded-full border border-[#DDEBE0] bg-white px-3 py-1 text-xs font-medium text-[#4A4A4A]">media/</span>
</div>
</div>
</div>
<div v-if="!importPreview && !importError && !importing" class="animate-fade-in">
<div
class="group cursor-pointer rounded-[24px] border border-dashed border-[#D8E5DA] bg-[#FCFDFC] px-6 py-10 text-center transition-colors duration-200 hover:border-[#07C160] hover:bg-white"
@click="handlePickDirectory"
>
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-white text-[#07C160] ring-1 ring-[#EDEDED]">
<svg class="h-7 w-7" 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>
</div>
<h3 class="mt-4 text-lg font-semibold text-[#000000e6]">选择解密备份目录</h3>
<p class="mt-2 text-sm text-[#7F7F7F]">建议选择 `wxid_xxxxx` 层级wxdump `output` 根目录在单账号时也支持</p>
<div class="mt-5 inline-flex items-center rounded-full bg-[#07C160] px-4 py-2 text-sm font-medium text-white transition-colors duration-200 group-hover:bg-[#06AD56]">
点击开始选择
</div>
<p class="mt-4 text-xs text-[#A3A3A3]">桌面端优先使用系统目录选择器异常时会自动回退到手动输入</p>
</div>
</div>
<div v-if="importing" class="animate-fade-in">
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-8 sm:px-6">
<div class="mx-auto flex w-fit items-center gap-2 rounded-full bg-[#07C160]/10 px-3 py-1 text-xs font-medium text-[#07C160]">
<span class="inline-flex h-2 w-2 rounded-full bg-current animate-pulse"></span>
正在导入
</div>
<div class="mt-5 text-center">
<p class="text-xl font-semibold text-[#000000e6]">{{ importMessage }}</p>
<p class="mt-2 text-sm text-[#7F7F7F]">请保持程序运行导入完成后可手动进入聊天页面</p>
</div>
<div class="mt-6 overflow-hidden rounded-full bg-[#EDF3EE]">
<div
class="h-2 rounded-full bg-[#07C160] transition-all duration-500"
:style="{ width: `${Math.min(Math.max(importProgress, 0), 100)}%` }"
></div>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-[#7F7F7F]">
<span>已连接导入任务</span>
<span>{{ importProgress }}%</span>
</div>
<button
class="mt-5 inline-flex w-full items-center justify-center rounded-2xl border border-[#F0D7D7] bg-white px-4 py-3 text-sm font-medium text-[#D64A4A] transition-colors hover:bg-[#FFF7F7]"
@click="cancelImport"
>
取消导入
</button>
</div>
</div>
<div v-if="importComplete && !importing" class="animate-fade-in space-y-4">
<div class="rounded-[24px] border border-[#DCEFE2] bg-[#F7FCF8] px-5 py-7 text-center sm:px-6">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#07C160]/10 text-[#07C160]">
<svg class="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="mt-4 text-xl font-semibold text-[#000000e6]">导入完成</h2>
<p class="mt-2 text-sm leading-6 text-[#6F6F6F]">{{ importComplete.message || '账号数据已成功导入。' }}</p>
<div class="mt-4 rounded-2xl border border-[#E2EFE5] bg-white px-4 py-3 text-left text-sm text-[#4A4A4A]">
<div class="flex items-center justify-between gap-3">
<span class="text-[#7F7F7F]">账号</span>
<span class="min-w-0 truncate font-mono text-xs">{{ importComplete.account }}</span>
</div>
<div v-if="importComplete.backup_dir" class="mt-2 flex items-start justify-between gap-3">
<span class="shrink-0 text-[#7F7F7F]">旧数据备份</span>
<span class="min-w-0 break-all text-right text-xs">{{ importComplete.backup_dir }}</span>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<button
class="inline-flex items-center justify-center rounded-2xl border border-[#E2E2E2] bg-white px-4 py-3 text-sm font-medium text-[#4A4A4A] transition-colors hover:bg-[#F8F8F8]"
@click="retryPickDirectory"
>
继续导入其他目录
</button>
<button
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56]"
@click="navigateTo('/chat')"
>
进入聊天页面
</button>
</div>
</div>
</div>
<div v-if="importPreview && !importing && !importComplete" class="animate-fade-in space-y-4">
<div class="rounded-[24px] border border-[#EDEDED] bg-[#FCFDFC] px-5 py-5 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-[#EDEDED] bg-white">
<img :src="importPreview.avatar_url || '/Contact.png'" class="h-full w-full object-cover" alt="头像" />
</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">检测到的账号</p>
<div class="mt-1 truncate text-xl font-semibold text-[#000000e6]">{{ importPreview.nick || '未命名账号' }}</div>
<div class="mt-2 inline-flex max-w-full items-center rounded-full border border-[#EDEDED] bg-white px-3 py-1 text-xs font-mono text-[#7F7F7F]">
<span class="truncate">{{ importPreview.username }}</span>
</div>
</div>
</div>
<div class="inline-flex w-fit items-center rounded-full bg-[#07C160]/10 px-3 py-1 text-xs font-medium text-[#07C160]">
可导入
</div>
</div>
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#EDEDED] bg-white px-3 py-3">
<p class="text-[11px] uppercase tracking-[0.12em] text-[#7F7F7F]">导入路径</p>
<p class="mt-1 break-all text-sm text-[#000000d9]">{{ selectedImportPath }}</p>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-white px-3 py-1.5 text-xs text-[#4A4A4A]">
<span class="h-2 w-2 rounded-full bg-[#07C160]"></span>
数据库已就绪
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[#EDEDED] bg-white px-3 py-1.5 text-xs text-[#4A4A4A]">
<span class="h-2 w-2 rounded-full" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-[#C9D2CB]'"></span>
资源文件{{ importPreview.has_resource ? '已发现' : '未发现' }}
</span>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)]">
<button
class="inline-flex items-center justify-center rounded-2xl border border-[#E2E2E2] bg-white px-4 py-3 text-sm font-medium text-[#4A4A4A] transition-colors hover:bg-[#F8F8F8]"
@click="handlePickDirectory"
>
重新选择目录
</button>
<button
:disabled="importing || importPreview?.source_overlaps_target"
class="inline-flex items-center justify-center rounded-2xl bg-[#07C160] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[#06AD56] disabled:cursor-not-allowed disabled:bg-[#8FD9AE]"
@click="confirmImport"
>
确认导入此账号
</button>
</div>
</div>
<div v-if="importError && !importing" class="animate-fade-in space-y-4">
<div class="rounded-[22px] border border-[#F4D6D6] bg-[#FFF7F7] px-5 py-5">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-[#E05A5A] ring-1 ring-[#F0D7D7]">
<svg class="h-4 w-4" 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>
<div class="min-w-0">
<p class="text-base font-semibold text-[#B64545]">导入失败</p>
<p class="mt-1 text-sm leading-6 text-[#9C5F5F]">{{ importError }}</p>
</div>
</div>
<div v-if="selectedImportPath" class="mt-4 rounded-[18px] border border-[#F1E3E3] bg-white/80 px-3 py-3">
<p class="text-[11px] uppercase tracking-[0.12em] text-[#B26B6B]">当前路径</p>
<p class="mt-1 break-all text-sm text-[#7A4B4B]">{{ selectedImportPath }}</p>
</div>
</div>
<button
class="inline-flex w-full items-center justify-center rounded-2xl border border-[#E2E2E2] bg-white px-4 py-3 text-sm font-medium text-[#4A4A4A] transition-colors hover:bg-[#F8F8F8]"
@click="retryPickDirectory"
>
重新选择目录
</button>
</div>
</div>
</div>
</div>
@@ -136,23 +230,30 @@
</template>
<script setup>
import {ref, onUnmounted} from 'vue'
import {useApi} from '~/composables/useApi'
import {useApiBase} from '~/composables/useApiBase'
import { onUnmounted, ref } 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 importComplete = ref(null)
const selectedImportPath = ref('')
const importJobId = ref('')
let eventSource = null
onUnmounted(() => {
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
onUnmounted(() => {
closeEventSource()
})
const isDesktopShell = () => {
@@ -161,24 +262,26 @@ 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]
@@ -188,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) {
@@ -199,7 +302,10 @@ const handlePickDirectory = async () => {
}
if (path && !path.includes('wxid_')) {
const isOk = window.confirm(`你选择的目录为:\n${path}\n\n该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
const isOk = window.confirm(`你选择的目录为:
${path}
该目录似乎不是 "wxid_xxxxx" 账号目录。如果这是 wxdump 的单账号 output 根目录,可以继续。确定要继续吗?`)
if (!isOk) return
}
@@ -208,47 +314,101 @@ const handlePickDirectory = async () => {
importPreview.value = null
try {
importPreview.value = await importDecryptedPreview({import_path: path})
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'
}
}
const retryPickDirectory = async () => {
resetImport()
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)
if (eventSource) eventSource.close()
closeEventSource()
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)
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
eventSource.close()
closeEventSource()
importJobId.value = ''
}
} catch (e) {
console.error('解析 SSE 数据失败:', e)
@@ -257,20 +417,40 @@ const confirmImport = async () => {
eventSource.onerror = (e) => {
console.error('EventSource 错误:', e)
if (!importing.value) {
closeEventSource()
return
}
importComplete.value = null
importError.value = '与服务器连接断开或发生错误'
importing.value = false
eventSource.close()
closeEventSource()
importJobId.value = ''
}
}
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
animation: fadeIn 0.35s ease-out;
}
.bg-grid-pattern {
background-image:
linear-gradient(rgba(7, 193, 96, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(7, 193, 96, 0.08) 1px, transparent 1px);
background-size: 32px 32px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
-7
View File
@@ -34,13 +34,6 @@
<span>开始检测</span>
</button>
<NuxtLink to="/decrypt"
class="group inline-flex items-center px-12 py-4 bg-white text-[#07C160] border border-[#07C160] 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 group-hover:-rotate-12 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="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
</svg>
<span>直接解密</span>
</NuxtLink>
<NuxtLink to="/import"
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">
+802 -62
View File
@@ -5,7 +5,7 @@
<div class="p-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>
<div class="text-xs text-gray-500">{{ snsUsers.length }}</div>
<div class="text-xs text-gray-500">{{ visibleSnsUsers.length }}</div>
</div>
<input
v-model="snsUserQuery"
@@ -14,36 +14,14 @@
class="mt-2 w-full px-3 py-2 rounded-md border border-gray-200 bg-white text-sm outline-none focus:ring-2 focus:ring-[#576b95]/30 focus:border-[#576b95]"
/>
<div class="mt-2 flex gap-2">
<div class="mt-3">
<button
type="button"
class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="onExportAllClick"
:disabled="!selectedAccount || exportJob?.status === 'running'"
title="导出全部朋友圈(HTML 离线 ZIP"
class="w-full px-3 py-2.5 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!selectedAccount"
@click="openExportModal"
>
导出全部
</button>
<button
type="button"
class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="onExportCurrentClick"
:disabled="!selectedAccount || !selectedSnsUser || exportJob?.status === 'running'"
title="导出当前选中联系人(HTML 离线 ZIP)"
>
导出此人
</button>
</div>
<div v-if="exportError" class="mt-2 text-xs text-red-600 whitespace-pre-wrap">{{ exportError }}</div>
<div v-else-if="exportJob" class="mt-2 text-xs text-gray-500">
<span>导出状态{{ exportJob.status }}</span>
<button
v-if="exportJob.status === 'done' && exportJob.exportId"
type="button"
class="ml-2 text-xs text-[#576b95] hover:underline bg-transparent border-0 p-0"
@click="downloadSnsExport(exportJob.exportId)"
>
下载 ZIP
导出朋友圈
</button>
</div>
</div>
@@ -67,11 +45,12 @@
>
<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
v-if="postAvatarUrl(u.username)"
v-if="postAvatarUrl(u.username) && !hasSnsAvatarError(u.username)"
:src="postAvatarUrl(u.username)"
:alt="u.displayName || u.username"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onSnsAvatarError(u.username)"
/>
<div
v-else
@@ -587,7 +566,260 @@
</button>
</div>
<!-- 图片预览弹窗 -->
<!-- SNS export modal -->
<div v-if="exportModalOpen" class="fixed inset-0 z-[12000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeExportModal"></div>
<div class="relative w-[880px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-start gap-3">
<div class="min-w-0">
<div class="text-base font-medium text-gray-900">导出朋友圈离线 ZIP</div>
<div class="mt-1 text-xs text-gray-500 leading-5">
直接勾选要导出的联系人支持搜索批量勾选以及自定义 ZIP 文件名和导出目录
</div>
</div>
<button class="ml-auto text-gray-400 hover:text-gray-700" type="button" @click="closeExportModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 max-h-[75vh] overflow-y-auto space-y-5">
<div v-if="exportError" class="text-sm text-red-600 whitespace-pre-wrap">{{ exportError }}</div>
<div v-if="exportJob" class="border border-gray-200 rounded-lg bg-gray-50 p-4 space-y-3">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">&#24403;&#21069;&#23548;&#20986;&#20219;&#21153;</div>
<div class="mt-1 text-xs text-gray-500 break-all">ID&#65306;{{ exportJob.exportId || '-' }}</div>
</div>
<button
v-if="exportJob.status === 'done' && exportJob.exportId && hasWebExportFolder"
type="button"
class="w-fit px-3 py-1.5 rounded-md text-xs border border-[#03C160]/20 bg-[#03C160]/10 text-[#027a44] hover:bg-[#03C160]/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="exportSaveBusy"
@click="saveSnsExportToSelectedFolder()"
>
{{ exportSaveBusy ? '保存中…' : exportSaveState === 'success' ? '重新保存到文件夹' : '保存到已选文件夹' }}
</button>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm text-gray-700">
<div>&#21160;&#24577;&#65306;{{ exportJob.progress?.postsExported || 0 }}/{{ exportJob.progress?.postsTotal || 0 }}</div>
<div class="text-gray-500">{{ exportOverallPercent }}%</div>
</div>
<div class="h-2.5 rounded-full bg-white border border-gray-200 overflow-hidden">
<div class="h-full bg-[#03C160] transition-all duration-300" :style="{ width: exportOverallPercent + '%' }"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs text-gray-600">
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#32852;&#31995;&#20154;&#65306;{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#26684;&#24335;&#65306;{{ exportActiveFormatLabel }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#24050;&#22797;&#21046;&#23186;&#20307;&#65306;{{ exportJob.progress?.mediaCopied || 0 }}
</div>
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
&#32570;&#22833;&#23186;&#20307;&#65306;{{ exportJob.progress?.mediaMissing || 0 }}
</div>
</div>
<div v-if="exportCurrentTargetLabel" class="space-y-2">
<div class="flex items-center justify-between gap-2 text-sm text-gray-700">
<div class="truncate">
&#24403;&#21069;&#32852;&#31995;&#20154;&#65306;{{ exportCurrentTargetLabel }}
&#65288;{{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }}&#65289;
</div>
<div class="text-gray-500">
<span v-if="exportCurrentPercent != null">{{ exportCurrentPercent }}%</span>
<span v-else></span>
</div>
</div>
<div class="h-2.5 rounded-full bg-white border border-gray-200 overflow-hidden">
<div
v-if="exportCurrentPercent != null"
class="h-full bg-sky-500 transition-all duration-300"
:style="{ width: exportCurrentPercent + '%' }"
></div>
<div v-else class="h-full bg-sky-500/60 animate-pulse" style="width: 30%"></div>
</div>
</div>
<div v-if="isExportCancelling && canCancelSnsExport" class="text-xs text-amber-700">
&#24050;&#21457;&#36865;&#21462;&#28040;&#35831;&#27714;&#65292;&#27491;&#22312;&#31561;&#24453;&#24403;&#21069;&#27493;&#39588;&#32467;&#26463;&#8230;
</div>
<div v-else-if="exportJob.status === 'cancelled'" class="text-xs text-amber-700">
&#23548;&#20986;&#24050;&#21462;&#28040;&#12290;
</div>
<div v-else-if="exportJob.status === 'error' && exportJob.error" class="text-xs text-red-600 whitespace-pre-wrap break-words">
{{ exportJob.error }}
</div>
<div v-if="exportOutputPathText" class="text-xs text-green-600 break-all">
&#24050;&#23548;&#20986;&#21040;&#65306;{{ exportOutputPathText }}
</div>
</div>
<div class="flex flex-wrap items-end gap-4 xl:flex-nowrap">
<div class="min-w-[180px]">
<div class="text-sm font-medium text-gray-900 mb-2">&#23548;&#20986;&#26684;&#24335;</div>
<div class="flex flex-wrap gap-2">
<label
v-for="item in exportFormatOptions"
:key="item.value"
class="px-3 py-2 text-sm rounded-md border cursor-pointer transition-colors"
:class="exportFormat === item.value ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
>
<input v-model="exportFormat" type="radio" :value="item.value" class="hidden" />
<span>{{ item.label }}</span>
</label>
</div>
</div>
<div class="flex-1 min-w-[220px]">
<label class="block text-sm font-medium text-gray-900 mb-2">&#23548;&#20986;&#25991;&#20214;&#21517;&#65288;&#21487;&#36873;&#65289;</label>
<input
v-model="exportFileName"
type="text"
placeholder="&#21487;&#36873;&#65292;&#19981;&#22635;&#21017;&#33258;&#21160;&#29983;&#25104; .zip &#25991;&#20214;&#21517;"
class="w-full px-3 py-2 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
/>
</div>
<div class="flex-[1.4] min-w-[300px]">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="text-sm font-medium text-gray-900">&#23548;&#20986;&#30446;&#24405;</div>
<div class="text-[11px] text-gray-400">{{ exportFolderModeText }}</div>
</div>
<div class="flex items-center gap-2">
<div class="px-3 py-2 rounded-md border border-gray-200 bg-gray-50 text-sm text-gray-600 break-all min-h-[42px] flex items-center min-w-0 flex-1">
{{ exportFolder || '未选择' }}
</div>
<button
type="button"
class="px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="exportSaveBusy"
@click="chooseExportFolder"
>
&#36873;&#25321;&#25991;&#20214;&#22841;
</button>
<button
v-if="hasSelectedExportFolder"
type="button"
class="px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="exportSaveBusy"
@click="clearExportFolderSelection"
>
&#28165;&#38500;
</button>
</div>
</div>
</div>
<div class="text-[11px] text-gray-500 whitespace-pre-wrap">{{ exportFolderHint }}</div>
<div class="space-y-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-medium text-gray-900">&#36873;&#25321;&#32852;&#31995;&#20154;</div>
<div class="flex flex-wrap items-center gap-2">
<div class="text-xs text-gray-500">&#24050;&#36873; {{ exportSelectedCount }} &#20154;</div>
<button
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="!exportFilteredSnsUsers.length"
@click="toggleSelectAllFilteredExportUsers"
>
{{ areAllFilteredExportUsersSelected ? '取消全选当前结果' : '全选当前结果' }}
</button>
<button
type="button"
class="px-3 py-1.5 rounded-md text-xs border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
:disabled="!exportSelectedCount"
@click="clearExportSelectedUsers"
>
&#28165;&#31354;&#24050;&#36873;
</button>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="exportSearchQuery"
type="text"
placeholder="&#25628;&#32034;&#32852;&#31995;&#20154;&#65288;&#21517;&#31216; / username&#65289;"
class="flex-1 px-3 py-2 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
:class="{ 'privacy-blur': privacyMode }"
/>
</div>
<div class="border border-gray-200 rounded-md max-h-72 overflow-y-auto">
<div v-if="!exportFilteredSnsUsers.length" class="px-3 py-8 text-sm text-gray-500 text-center">
&#26410;&#25214;&#21040;&#21487;&#23548;&#20986;&#30340;&#32852;&#31995;&#20154;
</div>
<label
v-for="u in exportFilteredSnsUsers"
:key="u.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 cursor-pointer transition-colors"
:class="exportSelectedUsernameSet.has(u.username) ? 'bg-[#03C160]/5 hover:bg-[#03C160]/10' : 'hover:bg-gray-50'"
>
<input v-model="exportSelectedUsernames" type="checkbox" :value="u.username" class="cursor-pointer" />
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
v-if="postAvatarUrl(u.username) && !hasSnsAvatarError(u.username)"
:src="postAvatarUrl(u.username)"
:alt="u.displayName || u.username"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onSnsAvatarError(u.username)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
style="background-color: #4B5563"
>
{{ (u.displayName || u.username || '友').charAt(0) }}
</div>
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-800 truncate">{{ u.displayName || u.username }}</div>
<div class="text-[11px] text-gray-400 truncate">{{ u.username }} &#183; {{ u.postCount || 0 }} &#26465;</div>
</div>
</label>
</div>
<div class="text-[11px] text-gray-500">
默认按勾选联系人导出如需全部导出直接点全选当前结果即可
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 flex items-center justify-between gap-3">
<div class="text-xs text-gray-500">&#24050;&#36873; {{ exportSelectedCount }} &#20154;</div>
<div class="flex gap-2">
<button
type="button"
class="px-4 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors"
@click="closeExportModal"
>
&#21462;&#28040;
</button>
<button
type="button"
class="px-4 py-2 rounded-md text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="canCancelSnsExport ? 'border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100' : 'bg-[#03C160] text-white hover:bg-[#02ad56]'"
:disabled="exportPrimaryActionDisabled"
@click="handleExportPrimaryAction"
>
{{ exportPrimaryActionLabel }}
</button>
</div>
</div>
</div>
</div>
<!-- Image preview modal -->
<div
v-if="previewCtx"
class="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
@@ -664,7 +896,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
import { reportServerErrorFromError, reportServerErrorFromResponse } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
@@ -731,11 +963,42 @@ const snsUsers = ref([])
const snsUserQuery = ref('')
// 空字符串表示“全部”
const selectedSnsUser = ref('')
const snsAvatarErrors = ref({})
const shouldHideSnsUser = (item) => {
const username = String(item?.username || '').trim()
const displayName = String(item?.displayName || '').trim()
const postCount = Number(item?.postCount || 0)
if (!username) return true
if (!Number.isFinite(postCount) || postCount <= 0) return true
return /^v3_/i.test(username) && /@stranger$/i.test(username) && (!displayName || displayName === username)
}
const visibleSnsUsers = computed(() => {
const list = Array.isArray(snsUsers.value) ? snsUsers.value : []
return list.filter((item) => !shouldHideSnsUser(item))
})
const snsAvatarErrorKey = (username) => String(username || '').trim()
const hasSnsAvatarError = (username) => {
const key = snsAvatarErrorKey(username)
return key ? !!snsAvatarErrors.value[key] : false
}
const onSnsAvatarError = (username) => {
const key = snsAvatarErrorKey(username)
if (!key || snsAvatarErrors.value[key]) return
snsAvatarErrors.value = {
...snsAvatarErrors.value,
[key]: true
}
}
const selectedSnsUserInfo = computed(() => {
const uname = String(selectedSnsUser.value || '').trim()
if (!uname) return null
const list = Array.isArray(snsUsers.value) ? snsUsers.value : []
const list = visibleSnsUsers.value
return list.find((u) => String(u?.username || '').trim() === uname) || null
})
@@ -749,7 +1012,7 @@ const showSnsCountMismatchHint = computed(() => {
const filteredSnsUsers = computed(() => {
const q = String(snsUserQuery.value || '').trim().toLowerCase()
const list = Array.isArray(snsUsers.value) ? snsUsers.value : []
const list = visibleSnsUsers.value
if (!q) return list
return list.filter((u) => {
const uname = String(u?.username || '').toLowerCase()
@@ -762,12 +1025,413 @@ const pageSize = 20
const apiBase = useApiBase()
// 朋友圈导出(HTML 离线 ZIP
// 朋友圈导出(离线 ZIP
const exportFormat = ref('html')
const exportFormatOptions = [
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON' },
{ value: 'txt', label: 'TXT' }
]
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportSaveError = ref('')
const exportSaveState = ref('idle')
const exportSaveBytesWritten = ref(0)
const exportSaveBytesTotal = ref(0)
const exportAutoSavedFor = ref('')
const exportJob = ref(null)
const exportError = ref('')
const exportModalOpen = ref(false)
const exportFileName = ref('')
const exportSearchQuery = ref('')
const exportSelectedUsernames = ref([])
const isExportCancelling = ref(false)
let exportEventSource = null
let exportPollTimer = null
const asNumber = (v) => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
const formatBytes = (value) => {
const bytes = Number(value)
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${units[index]}`
}
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
exportSaveMsg.value = ''
exportSaveError.value = ''
exportSaveState.value = 'idle'
exportSaveBytesWritten.value = 0
exportSaveBytesTotal.value = 0
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const hasDesktopExportFolder = computed(() => {
return !!(isDesktopExportRuntime() && String(exportFolder.value || '').trim())
})
const hasWebExportFolder = computed(() => {
return !!(!isDesktopExportRuntime() && isWebDirectoryPickerSupported() && exportFolderHandle.value)
})
const hasSelectedExportFolder = computed(() => {
return !!(hasDesktopExportFolder.value || hasWebExportFolder.value)
})
const exportFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportFormat.value)?.label || 'HTML'
})
const exportActiveFormat = computed(() => {
const raw = String(exportJob.value?.options?.format || exportFormat.value || 'html').trim().toLowerCase()
return exportFormatOptions.some((item) => item.value === raw) ? raw : 'html'
})
const exportActiveFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportActiveFormat.value)?.label || 'HTML'
})
const exportOverallPercent = computed(() => {
const status = String(exportJob.value?.status || '').trim()
if (status === 'done') return 100
const progress = exportJob.value?.progress || {}
const postsTotal = asNumber(progress.postsTotal)
const postsDone = asNumber(progress.postsExported)
if (postsTotal > 0) return Math.round(clamp01(postsDone / postsTotal) * 100)
const usersTotal = asNumber(progress.usersTotal)
const usersDone = asNumber(progress.usersDone)
if (usersTotal > 0) return Math.round(clamp01(usersDone / usersTotal) * 100)
return 0
})
const exportCurrentPercent = computed(() => {
const progress = exportJob.value?.progress || {}
const total = asNumber(progress.currentUserPostsTotal)
const done = asNumber(progress.currentUserPostsDone)
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const exportCurrentTargetLabel = computed(() => {
const progress = exportJob.value?.progress || {}
return String(progress.currentDisplayName || progress.currentUsername || '').trim()
})
const isSnsExportBusy = computed(() => {
const status = String(exportJob.value?.status || '').trim()
return status === 'queued' || status === 'running'
})
const canCancelSnsExport = computed(() => {
if (!exportJob.value?.exportId) return false
const status = String(exportJob.value?.status || '').trim()
return status === 'queued' || status === 'running'
})
const exportPrimaryActionLabel = computed(() => {
if (canCancelSnsExport.value) return isExportCancelling.value ? '取消中…' : '取消导出'
return isSnsExportBusy.value ? '导出中…' : '开始导出'
})
const exportPrimaryActionDisabled = computed(() => {
if (canCancelSnsExport.value) return isExportCancelling.value
return !selectedAccount.value || !exportSelectedCount.value || isSnsExportBusy.value
})
const handleExportPrimaryAction = async () => {
if (canCancelSnsExport.value) {
await cancelSnsExportJob()
return
}
await startSnsExportFromModal()
}
const normalizeExportSelectedUsernames = (list) => {
const validUsernames = new Set(
visibleSnsUsers.value
.map((item) => String(item?.username || '').trim())
.filter(Boolean)
)
const seen = new Set()
return (Array.isArray(list) ? list : []).reduce((acc, item) => {
const username = String(item || '').trim()
if (!username || seen.has(username)) return acc
if (validUsernames.size > 0 && !validUsernames.has(username)) return acc
seen.add(username)
acc.push(username)
return acc
}, [])
}
const exportSelectedUsernameSet = computed(() => {
return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value))
})
const exportSelectedCount = computed(() => {
return exportSelectedUsernameSet.value.size
})
const exportFilteredSnsUsers = computed(() => {
const q = String(exportSearchQuery.value || '').trim().toLowerCase()
const list = visibleSnsUsers.value
if (!q) return list
return list.filter((item) => {
const username = String(item?.username || '').toLowerCase()
const displayName = String(item?.displayName || '').toLowerCase()
return username.includes(q) || displayName.includes(q)
})
})
const exportFilteredSnsUsernames = computed(() => {
return exportFilteredSnsUsers.value
.map((item) => String(item?.username || '').trim())
.filter(Boolean)
})
const areAllFilteredExportUsersSelected = computed(() => {
const usernames = exportFilteredSnsUsernames.value
if (!usernames.length) return false
return usernames.every((username) => exportSelectedUsernameSet.value.has(username))
})
const clearExportSelectedUsers = () => {
exportSelectedUsernames.value = []
}
const toggleSelectAllFilteredExportUsers = () => {
const usernames = exportFilteredSnsUsernames.value
if (!usernames.length) return
if (areAllFilteredExportUsersSelected.value) {
const removeSet = new Set(usernames)
exportSelectedUsernames.value = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
.filter((username) => !removeSet.has(username))
return
}
exportSelectedUsernames.value = normalizeExportSelectedUsernames([
...exportSelectedUsernames.value,
...usernames
])
}
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportSearchQuery.value = ''
exportFileName.value = ''
exportSelectedUsernames.value = selectedSnsUser.value
? normalizeExportSelectedUsernames([selectedSnsUser.value])
: []
}
const closeExportModal = () => {
exportModalOpen.value = false
exportError.value = ''
exportSearchQuery.value = ''
}
const exportBackendZipPath = computed(() => {
return String(exportJob.value?.zipPath || '').trim()
})
const exportFolderModeText = computed(() => {
if (isDesktopExportRuntime()) return '\u684c\u9762\u7aef\u76ee\u5f55'
if (isWebDirectoryPickerSupported()) return '\u6d4f\u89c8\u5668\u76ee\u5f55'
return '\u9700\u9009\u62e9\u6587\u4ef6\u5939'
})
const exportFolderHint = computed(() => {
if (isDesktopExportRuntime()) {
return hasDesktopExportFolder.value
? '\u4f1a\u50cf\u666e\u901a\u804a\u5929\u8bb0\u5f55\u5bfc\u51fa\u4e00\u6837\uff0c\u5b8c\u6210\u540e\u76f4\u63a5\u5199\u5165\u4e0a\u9762\u7684\u6587\u4ef6\u5939\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6587\u4ef6\u5939\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u76f4\u63a5\u5199\u5165\u8be5\u76ee\u5f55\u3002'
}
if (isWebDirectoryPickerSupported()) {
return hasWebExportFolder.value
? '\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u5230\u6240\u9009\u6d4f\u89c8\u5668\u76ee\u5f55\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u76ee\u5f55\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u3002'
}
return '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668\u3002'
})
const guessSnsExportZipName = (job) => {
const raw = String(job?.zipPath || '').trim()
if (raw) {
const name = raw.replace(/\\/g, '/').split('/').pop()
if (name && name.toLowerCase().endsWith('.zip')) return name
}
const format = String(job?.options?.format || exportFormat.value || 'html').trim().toLowerCase() || 'html'
const exportId = String(job?.exportId || '').trim() || 'export'
return `wechat_sns_export_${format}_${exportId}.zip`
}
const exportSaveProgressText = computed(() => {
if (exportSaveState.value !== 'saving') return ''
const fileName = guessSnsExportZipName(exportJob.value)
if (exportSaveBytesTotal.value > 0) {
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)}\uff09`
}
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)}\uff09`
})
const exportOutputPathText = computed(() => {
if (String(exportJob.value?.status || '') !== 'done') return ''
if (hasWebExportFolder.value) return ''
const raw = exportBackendZipPath.value
if (!raw) return ''
if (isDesktopExportRuntime()) return raw
const requestedOutputDir = String(exportJob.value?.options?.outputDir || '').trim()
return requestedOutputDir ? raw : ''
})
const chooseExportFolder = async () => {
exportError.value = ''
resetExportSaveFeedback()
try {
if (!process.client) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '').trim()
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${String(handle.name || '\u5df2\u9009\u62e9')}`
}
return
}
exportError.value = '\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
} catch (error) {
const message = String(error?.message || '').trim()
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
return
}
exportError.value = error?.message || '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55\u5931\u8d25'
}
}
const clearExportFolderSelection = () => {
exportFolder.value = ''
exportFolderHandle.value = null
resetExportSaveFeedback({ resetAutoSavedFor: true })
}
const getSnsExportDownloadUrl = (exportId) => {
return `${apiBase}/sns/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
const saveSnsExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
resetExportSaveFeedback()
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55'
return
}
const handle = exportFolderHandle.value
if (!handle || typeof handle.getFileHandle !== 'function') {
exportError.value = '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u5bfc\u51fa\u76ee\u5f55'
return
}
const exportId = exportJob.value?.exportId
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
exportError.value = '\u5bfc\u51fa\u4efb\u52a1\u5c1a\u672a\u5b8c\u6210'
return
}
exportSaveBusy.value = true
exportSaveState.value = 'saving'
try {
const response = await fetch(getSnsExportDownloadUrl(exportId))
if (!response.ok) {
await reportServerErrorFromResponse(response, {
method: 'GET',
requestUrl: getSnsExportDownloadUrl(exportId),
message: `\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`,
source: 'sns.exportDownload'
})
throw new Error(`\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`)
}
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
const fileName = guessSnsExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
if (response.body && typeof response.body.getReader === 'function') {
const reader = response.body.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || !value.byteLength) continue
await writable.write(value)
exportSaveBytesWritten.value += value.byteLength
}
await writable.close()
} catch (error) {
try {
await reader.cancel()
} catch {}
try {
await writable.abort()
} catch {}
throw error
}
} else {
const blob = await response.blob()
exportSaveBytesWritten.value = asNumber(blob.size)
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
await writable.write(blob)
await writable.close()
}
exportAutoSavedFor.value = String(exportId)
exportSaveState.value = 'success'
const folderLabel = String(exportFolder.value || '').trim() || '\u5df2\u9009\u76ee\u5f55'
exportSaveMsg.value = autoSave
? `\u6d4f\u89c8\u5668\u76ee\u5f55\u81ea\u52a8\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
: `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
} catch (error) {
exportSaveState.value = 'error'
exportSaveError.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u5931\u8d25\uff1a${error?.message || '\u672a\u77e5\u9519\u8bef'}`
} finally {
exportSaveBusy.value = false
}
}
const stopSnsExportPolling = () => {
if (exportEventSource) {
try {
@@ -828,50 +1492,80 @@ const startSnsExportPolling = (exportId) => {
startSnsExportHttpPolling(exportId)
}
const downloadSnsExport = (exportId) => {
if (!process.client) return
const id = String(exportId || '').trim()
if (!id) return
const url = `${apiBase}/sns/exports/${encodeURIComponent(id)}/download`
window.open(url, '_blank', 'noopener,noreferrer')
const ensureSnsExportFolderReady = () => {
if (hasSelectedExportFolder.value) return true
exportError.value = isDesktopExportRuntime() || isWebDirectoryPickerSupported()
? '\u8bf7\u5148\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
: '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
return false
}
const onExportAllClick = async () => {
if (!selectedAccount.value) return
const cancelSnsExportJob = async () => {
const exportId = String(exportJob.value?.exportId || '').trim()
if (!exportId || !canCancelSnsExport.value || isExportCancelling.value) return
exportError.value = ''
isExportCancelling.value = true
try {
await api.cancelSnsExport(exportId)
try {
const resp = await api.getSnsExport(exportId)
exportJob.value = resp?.job || exportJob.value
} catch {
// ignore refresh errors, polling/SSE will continue updating the job
}
} catch (e) {
exportError.value = e?.message || '取消导出任务失败'
isExportCancelling.value = false
}
}
const startSnsExport = async ({ scope, usernames, fileName } = {}) => {
if (!selectedAccount.value) return false
exportError.value = ''
isExportCancelling.value = false
resetExportSaveFeedback({ resetAutoSavedFor: true })
if (!ensureSnsExportFolderReady()) return false
const normalizedScope = String(scope || '').trim() === 'all' ? 'all' : 'selected'
const normalizedUsernames = normalizeExportSelectedUsernames(usernames)
if (normalizedScope === 'selected' && normalizedUsernames.length === 0) {
exportError.value = '请选择至少一个联系人'
return false
}
try {
const resp = await api.createSnsExport({
account: selectedAccount.value,
scope: 'all',
usernames: [],
use_cache: snsUseCache.value ? 1 : 0
scope: normalizedScope,
usernames: normalizedUsernames,
format: exportFormat.value,
use_cache: snsUseCache.value ? 1 : 0,
output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null,
file_name: String(fileName || '').trim() || null
})
exportJob.value = resp?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startSnsExportPolling(exportId)
return true
} catch (e) {
exportError.value = e?.message || '创建导出任务失败'
return false
}
}
const onExportCurrentClick = async () => {
if (!selectedAccount.value) return
const uname = String(selectedSnsUser.value || '').trim()
if (!uname) return
exportError.value = ''
try {
const resp = await api.createSnsExport({
account: selectedAccount.value,
scope: 'selected',
usernames: [uname],
use_cache: snsUseCache.value ? 1 : 0
})
exportJob.value = resp?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startSnsExportPolling(exportId)
} catch (e) {
exportError.value = e?.message || '创建导出任务失败'
const startSnsExportFromModal = async () => {
const usernames = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
if (!usernames.length) {
exportError.value = '请选择至少一个联系人'
return
}
const created = await startSnsExport({
scope: 'selected',
usernames,
fileName: exportFileName.value
})
if (created) exportError.value = ''
}
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
@@ -1809,9 +2503,16 @@ watch(
stopSnsExportPolling()
exportJob.value = null
exportError.value = ''
isExportCancelling.value = false
exportModalOpen.value = false
exportFileName.value = ''
exportSearchQuery.value = ''
exportSelectedUsernames.value = []
resetExportSaveFeedback({ resetAutoSavedFor: true })
snsUserQuery.value = ''
selectedSnsUser.value = ''
snsUsers.value = []
snsAvatarErrors.value = {}
activeLivePhotoKey.value = ''
livePhotoVideoErrors.value = {}
if (previewCtx.value) closeImagePreview()
@@ -1823,6 +2524,41 @@ watch(
{ immediate: true }
)
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
if (exportSaveBusy.value) return
await saveSnsExportToSelectedFolder({ auto: true })
}
)
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
({ exportId, status }, prev) => {
if (!exportId) {
isExportCancelling.value = false
return
}
if (exportId !== String(prev?.exportId || '')) {
isExportCancelling.value = false
return
}
if (status !== 'queued' && status !== 'running') {
isExportCancelling.value = false
}
}
)
onMounted(async () => {
privacyStore.init()
@@ -1837,6 +2573,10 @@ const onGlobalClick = () => {
const onGlobalKeyDown = (e) => {
if (!process.client) return
if (String(e?.key || '') === 'Escape') {
if (exportModalOpen.value) {
closeExportModal()
return
}
if (previewCtx.value) closeImagePreview()
if (contextMenu.value.visible) closeContextMenu()
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "1.3.0"
version = "1.7.12"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "1.3.0"
__version__ = "1.7.12"
__author__ = "WeChat Decrypt Tool"
+84 -13
View File
@@ -29,6 +29,8 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
logger = logging.getLogger(__name__)
WECHAT_EXECUTABLE_NAMES = ("Weixin.exe", "WeChat.exe")
def _summarize_aes_key(value: Any) -> str:
raw = str(value or "").strip()
@@ -109,19 +111,72 @@ def _resolve_wxid_dir_for_image_key(
raise FileNotFoundError("无法定位该账号的 wxid_dir,请传入有效的 db_storage_path 或先完成数据库解密")
def _normalize_user_path(value: Any) -> str:
raw = str(value or "").strip().strip('"').strip("'")
if not raw:
return ""
try:
return os.path.normpath(os.path.expandvars(raw))
except Exception:
return raw
def _read_wechat_version_from_exe(exe_path: str) -> str:
normalized = _normalize_user_path(exe_path)
if not normalized:
return ""
try:
import win32api
version_info = win32api.GetFileVersionInfo(normalized, "\\")
return (
f"{version_info['FileVersionMS'] >> 16}."
f"{version_info['FileVersionMS'] & 0xFFFF}."
f"{version_info['FileVersionLS'] >> 16}."
f"{version_info['FileVersionLS'] & 0xFFFF}"
)
except Exception:
return ""
def _resolve_manual_wechat_exe_path(wechat_install_path: Optional[str] = None) -> str:
normalized = _normalize_user_path(wechat_install_path)
if not normalized:
return ""
candidate = Path(normalized).expanduser()
executable_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
if candidate.is_file():
if candidate.name.lower() not in executable_names:
raise RuntimeError("手动路径必须指向微信安装目录,或直接指向 Weixin.exe / WeChat.exe")
return str(candidate)
if candidate.is_dir():
for exe_name in WECHAT_EXECUTABLE_NAMES:
exe_path = candidate / exe_name
if exe_path.is_file():
return str(exe_path)
raise RuntimeError("手动指定的微信安装目录中未找到 Weixin.exe 或 WeChat.exe")
raise RuntimeError(f"手动指定的微信安装目录不存在: {candidate}")
# ====================== 以下是hook逻辑 ======================================
class WeChatKeyFetcher:
def __init__(self):
self.process_name = "Weixin.exe"
self.process_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
self.timeout_seconds = 60
def _is_wechat_process(self, name: Any) -> bool:
return str(name or "").strip().lower() in self.process_names
def kill_wechat(self):
"""检测并查杀微信进程"""
killed = False
for proc in psutil.process_iter(['pid', 'name']):
try:
if proc.info['name'] == self.process_name:
if self._is_wechat_process(proc.info['name']):
logger.info(f"Killing WeChat process: {proc.info['pid']}")
proc.terminate()
killed = True
@@ -134,11 +189,14 @@ class WeChatKeyFetcher:
def launch_wechat(self, exe_path: str) -> int:
"""启动微信并返回 PID"""
try:
process = subprocess.Popen(exe_path)
normalized_exe_path = _normalize_user_path(exe_path)
process = subprocess.Popen(normalized_exe_path)
time.sleep(2)
candidates = []
target_process_name = Path(normalized_exe_path).name.lower()
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
if proc.info['name'] == self.process_name:
proc_name = str(proc.info.get('name') or "").strip().lower()
if proc_name == target_process_name or self._is_wechat_process(proc_name):
candidates.append(proc)
if candidates:
@@ -152,19 +210,32 @@ class WeChatKeyFetcher:
logger.error(f"启动微信失败: {e}")
raise RuntimeError(f"无法启动微信: {e}")
def fetch_db_key(self) -> dict:
def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
"""调用 wx_key 仅获取数据库密钥 (Hook 模式)"""
if wx_key is None:
raise RuntimeError("wx_key 模块未安装或加载失败")
install_info = detect_wechat_installation()
exe_path = install_info.get('wechat_exe_path')
version = install_info.get('wechat_version')
manual_path = _normalize_user_path(wechat_install_path)
if manual_path:
exe_path = _resolve_manual_wechat_exe_path(manual_path)
version = _read_wechat_version_from_exe(exe_path)
logger.info(
"[db_key] 使用手动指定的微信安装路径: input=%s exe_path=%s version=%s",
manual_path,
exe_path,
version or "unknown",
)
else:
install_info = detect_wechat_installation()
exe_path = _normalize_user_path(install_info.get('wechat_exe_path'))
version = str(install_info.get('wechat_version') or "").strip()
if not exe_path or not version:
raise RuntimeError("无法自动定位微信安装路径或版本")
if not exe_path:
raise RuntimeError("无法自动定位微信安装路径,请手动填写微信安装目录")
if not Path(exe_path).is_file():
raise RuntimeError(f"微信可执行文件不存在: {exe_path}")
logger.info(f"Detect WeChat: {version} at {exe_path}")
logger.info(f"Detect WeChat: {version or 'unknown'} at {exe_path}")
self.kill_wechat()
pid = self.launch_wechat(exe_path)
@@ -204,9 +275,9 @@ class WeChatKeyFetcher:
"db_key": found_db_key
}
def get_db_key_workflow():
def get_db_key_workflow(wechat_install_path: Optional[str] = None):
fetcher = WeChatKeyFetcher()
return fetcher.fetch_db_key()
return fetcher.fetch_db_key(wechat_install_path=wechat_install_path)
# ============================== 以下是图片密钥逻辑 =====================================
+44 -5
View File
@@ -1,13 +1,41 @@
import datetime
import json
from pathlib import Path
from typing import Any, Optional
from typing import Any, Iterable, Optional
from .app_paths import get_account_keys_path
_KEY_STORE_PATH = get_account_keys_path()
def normalize_key_store_path(path_value: Optional[str]) -> str:
raw = str(path_value or "").strip()
if not raw:
return ""
try:
return str(Path(raw).expanduser().resolve())
except Exception:
try:
return str(Path(raw).expanduser())
except Exception:
return raw
def _normalize_account_aliases(*values: Optional[str], aliases: Optional[Iterable[str]] = None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in [*values, *(list(aliases or []))]:
key = str(value or "").strip()
if (not key) or (key in seen):
continue
seen.add(key)
out.append(key)
return out
def _atomic_write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
@@ -40,25 +68,36 @@ def upsert_account_keys_in_store(
db_key: Optional[str] = None,
image_xor_key: Optional[str] = None,
image_aes_key: Optional[str] = None,
aliases: Optional[Iterable[str]] = None,
db_key_source_wxid_dir: Optional[str] = None,
db_key_source_db_storage_path: Optional[str] = None,
) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {}
store = load_account_keys_store()
item = store.get(account, {})
if not isinstance(item, dict):
item = {}
target_accounts = _normalize_account_aliases(account, aliases=aliases)
item: dict[str, Any] = {}
for target_account in target_accounts:
existing = store.get(target_account, {})
if isinstance(existing, dict) and existing:
item = dict(existing)
break
if db_key is not None:
item["db_key"] = str(db_key)
item["db_key_source_wxid_dir"] = normalize_key_store_path(db_key_source_wxid_dir)
item["db_key_source_db_storage_path"] = normalize_key_store_path(db_key_source_db_storage_path)
if image_xor_key is not None:
item["image_xor_key"] = str(image_xor_key)
if image_aes_key is not None:
item["image_aes_key"] = str(image_aes_key)
item["updated_at"] = datetime.datetime.now().isoformat(timespec="seconds")
store[account] = item
for target_account in target_accounts:
store[target_account] = dict(item)
try:
_atomic_write_json(_KEY_STORE_PATH, store)
+19 -7
View File
@@ -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
+38 -4
View File
@@ -118,6 +118,38 @@ def _acquire_decrypt_account_guards(accounts: Any, *, reason: str) -> list[tuple
return guards
def _save_db_key_for_account(account: str, key: str, account_result: dict[str, Any] | None) -> None:
payload = dict(account_result or {})
success_count = int(payload.get("success") or 0)
if success_count <= 0:
logger.info("[decrypt] skip saving db key for failed account=%s success=%s", account, success_count)
return
source_wxid_dir = str(payload.get("source_wxid_dir") or "").strip()
source_db_storage_path = str(payload.get("source_db_storage_path") or "").strip()
aliases: list[str] = []
if source_wxid_dir:
wxid_dir_name = str(Path(source_wxid_dir).name or "").strip()
if wxid_dir_name and wxid_dir_name != str(account or "").strip():
aliases.append(wxid_dir_name)
upsert_account_keys_in_store(
str(account),
db_key=key,
aliases=aliases,
db_key_source_wxid_dir=source_wxid_dir or None,
db_key_source_db_storage_path=source_db_storage_path or None,
)
logger.info(
"[decrypt] saved db key account=%s aliases=%s source_wxid_dir=%s source_db_storage_path=%s",
str(account),
aliases,
source_wxid_dir,
source_db_storage_path,
)
class DecryptRequest(BaseModel):
"""解密请求模型"""
@@ -170,8 +202,8 @@ async def decrypt_databases(request: DecryptRequest):
# 成功解密后,按账号保存数据库密钥(用于前端自动回填)
try:
for account_name in (results.get("account_results") or {}).keys():
upsert_account_keys_in_store(str(account_name), db_key=request.key)
for account_name, account_result in (results.get("account_results") or {}).items():
_save_db_key_for_account(str(account_name), request.key, account_result)
except Exception:
pass
@@ -417,6 +449,8 @@ async def decrypt_databases_stream(
"success": account_success,
"failed": len(dbs) - account_success,
"output_dir": str(account_output_dir),
"source_db_storage_path": str(source_db_storage_path),
"source_wxid_dir": str(wxid_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,
@@ -481,8 +515,8 @@ async def decrypt_databases_stream(
# Save db key for frontend autofill.
try:
for account in (account_results or {}).keys():
upsert_account_keys_in_store(str(account), db_key=k)
for account, account_result in (account_results or {}).items():
_save_db_key_for_account(str(account), k, account_result)
except Exception:
pass
@@ -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",
+158 -8
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter
from ..logging_config import get_logger
from ..key_store import get_account_keys_from_store
from ..key_store import get_account_keys_from_store, normalize_key_store_path
from ..key_service import get_db_key_workflow, get_image_key_integrated_workflow
from ..media_helpers import _load_media_keys, _resolve_account_dir
from ..path_fix import PathFixRoute
@@ -21,8 +22,106 @@ def _summarize_aes_key(value: str) -> str:
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
def _resolve_requested_wxid_dir(*, db_storage_path: Optional[str] = None, wxid_dir: Optional[str] = None) -> str:
explicit_wxid_dir = str(wxid_dir or "").strip()
if explicit_wxid_dir:
return normalize_key_store_path(explicit_wxid_dir)
raw_db_storage_path = str(db_storage_path or "").strip()
if not raw_db_storage_path:
return ""
candidate = Path(raw_db_storage_path).expanduser()
try:
if str(candidate.name or "").lower() == "db_storage":
return normalize_key_store_path(str(candidate.parent))
except Exception:
pass
try:
if str((candidate / "db_storage").name or "").lower() == "db_storage":
return normalize_key_store_path(str(candidate))
except Exception:
pass
return ""
def _build_saved_key_candidates(account_name: Optional[str], request_account: Optional[str], request_wxid_dir: str) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in [
Path(request_wxid_dir).name if request_wxid_dir else "",
str(account_name or "").strip(),
str(request_account or "").strip(),
]:
key = str(value or "").strip()
if (not key) or (key in seen):
continue
seen.add(key)
out.append(key)
return out
def _evaluate_db_key_candidate(
*,
store_account: str,
keys: dict,
account_name: Optional[str],
request_wxid_dir: str,
request_db_storage_path: str,
) -> tuple[bool, int, str]:
db_key = str(keys.get("db_key") or "").strip()
if not db_key:
return False, -1, ""
source_wxid_dir = normalize_key_store_path(keys.get("db_key_source_wxid_dir"))
source_db_storage_path = normalize_key_store_path(keys.get("db_key_source_db_storage_path"))
request_wxid_dir_name = Path(request_wxid_dir).name if request_wxid_dir else ""
source_wxid_dir_name = Path(source_wxid_dir).name if source_wxid_dir else ""
if request_db_storage_path and source_db_storage_path:
if source_db_storage_path == request_db_storage_path:
return True, 400, ""
return (
False,
0,
f"Saved db key source does not match current db_storage_path. request={request_db_storage_path} stored={source_db_storage_path}",
)
if request_wxid_dir and source_wxid_dir:
if (source_wxid_dir == request_wxid_dir) or (
source_wxid_dir_name and source_wxid_dir_name == request_wxid_dir_name
):
return True, 300, ""
return (
False,
0,
f"Saved db key source does not match current wxid_dir. request={request_wxid_dir_name} stored={source_wxid_dir_name or source_wxid_dir}",
)
if request_wxid_dir_name:
if store_account == request_wxid_dir_name:
return True, 200, ""
if account_name and request_wxid_dir_name == str(account_name or "").strip():
return True, 100, ""
return (
False,
0,
f"Legacy saved db key is ambiguous for current wxid_dir={request_wxid_dir_name}. Please fetch a fresh db key.",
)
return True, 50, ""
@router.get("/api/keys", summary="获取账号已保存的密钥")
async def get_saved_keys(account: Optional[str] = None):
async def get_saved_keys(
account: Optional[str] = None,
db_storage_path: Optional[str] = None,
wxid_dir: Optional[str] = None,
):
"""获取账号的数据库密钥与图片密钥(用于前端自动回填)"""
account_name: Optional[str] = None
account_dir = None
@@ -34,16 +133,56 @@ async def get_saved_keys(account: Optional[str] = None):
# 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account
account_name = str(account or "").strip() or None
request_db_storage_path = normalize_key_store_path(db_storage_path)
request_wxid_dir = _resolve_requested_wxid_dir(db_storage_path=db_storage_path, wxid_dir=wxid_dir)
candidate_accounts = _build_saved_key_candidates(account_name, account, request_wxid_dir)
logger.info(
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s",
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s db_storage_path=%s wxid_dir=%s candidates=%s",
str(account or "").strip(),
str(account_name or ""),
str(account_dir) if account_dir else "",
request_db_storage_path,
request_wxid_dir,
candidate_accounts,
)
keys: dict = {}
if account_name:
keys = get_account_keys_from_store(account_name)
selected_db_key_account = ""
selected_db_key_score = -1
db_key_blocked_reason = ""
db_key_source_wxid_dir = ""
db_key_source_db_storage_path = ""
for candidate_account in candidate_accounts:
candidate_keys = get_account_keys_from_store(candidate_account)
if not isinstance(candidate_keys, dict) or not candidate_keys:
continue
if not str(keys.get("image_xor_key") or "").strip():
keys["image_xor_key"] = str(candidate_keys.get("image_xor_key") or "").strip()
if not str(keys.get("image_aes_key") or "").strip():
keys["image_aes_key"] = str(candidate_keys.get("image_aes_key") or "").strip()
if not str(keys.get("updated_at") or "").strip():
keys["updated_at"] = str(candidate_keys.get("updated_at") or "").strip()
ok, score, blocked_reason = _evaluate_db_key_candidate(
store_account=candidate_account,
keys=candidate_keys,
account_name=account_name,
request_wxid_dir=request_wxid_dir,
request_db_storage_path=request_db_storage_path,
)
if ok and score > selected_db_key_score:
selected_db_key_score = score
selected_db_key_account = candidate_account
keys["db_key"] = str(candidate_keys.get("db_key") or "").strip()
db_key_source_wxid_dir = normalize_key_store_path(candidate_keys.get("db_key_source_wxid_dir"))
db_key_source_db_storage_path = normalize_key_store_path(candidate_keys.get("db_key_source_db_storage_path"))
if str(candidate_keys.get("updated_at") or "").strip():
keys["updated_at"] = str(candidate_keys.get("updated_at") or "").strip()
elif (not ok) and blocked_reason and (not db_key_blocked_reason):
db_key_blocked_reason = blocked_reason
# 兼容:如果 store 里没有图片密钥,尝试从账号目录的 _media_keys.json 读取
if account_dir and isinstance(keys, dict):
@@ -62,11 +201,18 @@ async def get_saved_keys(account: Optional[str] = None):
"image_xor_key": str(keys.get("image_xor_key") or "").strip(),
"image_aes_key": str(keys.get("image_aes_key") or "").strip(),
"updated_at": str(keys.get("updated_at") or "").strip(),
"db_key_source_wxid_dir": db_key_source_wxid_dir,
"db_key_source_db_storage_path": db_key_source_db_storage_path,
"db_key_store_account": selected_db_key_account,
"db_key_blocked_reason": db_key_blocked_reason,
}
logger.info(
"[keys] get_saved_keys done: account=%s db_key_present=%s xor_key=%s aes_key=%s updated_at=%s",
"[keys] get_saved_keys done: account=%s db_key_present=%s db_key_store_account=%s db_key_source_wxid_dir=%s blocked_reason=%s xor_key=%s aes_key=%s updated_at=%s",
str(account_name or ""),
bool(result["db_key"]),
result["db_key_store_account"],
result["db_key_source_wxid_dir"],
result["db_key_blocked_reason"],
result["image_xor_key"],
_summarize_aes_key(result["image_aes_key"]),
result["updated_at"],
@@ -80,7 +226,7 @@ async def get_saved_keys(account: Optional[str] = None):
@router.get("/api/get_keys", summary="自动获取微信数据库与图片密钥")
async def get_wechat_db_key():
async def get_wechat_db_key(wechat_install_path: Optional[str] = None):
"""
自动流程
1. 结束微信进程
@@ -89,7 +235,11 @@ async def get_wechat_db_key():
4. 抓取 DB 图片密钥(AES + XOR)并返回
"""
try:
keys_data = get_db_key_workflow()
logger.info(
"[keys] get_wechat_db_key start: wechat_install_path=%s",
str(wechat_install_path or "").strip(),
)
keys_data = get_db_key_workflow(wechat_install_path=wechat_install_path)
return {
"status": 0,
+2
View File
@@ -2067,6 +2067,8 @@ def list_sns_users(
post_count = int(r["postCount"] or 0)
except Exception:
post_count = 0
if post_count <= 0:
continue
row = contact_rows.get(uname)
display = _clean_name(_pick_display_name(row, uname)) or uname
@@ -13,23 +13,26 @@ from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
ExportFormat = Literal["html", "json", "txt"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
format: ExportFormat = Field("html", description="导出格式:html/json/txt")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 ZIP,支持 HTML/JSON/TXT")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
export_format=req.format,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
@@ -111,4 +114,3 @@ async def cancel_sns_export(export_id: str):
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
+439 -181
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
"""SNS (Moments) HTML export service (offline ZIP)."""
"""SNS (Moments) export service (offline ZIP)."""
import asyncio
from bisect import bisect_left, bisect_right
@@ -50,6 +50,7 @@ logger = get_logger(__name__)
ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
ExportScope = Literal["selected", "all"]
ExportFormat = Literal["html", "json", "txt"]
_INVALID_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_HEX_ONLY_RE = re.compile(r"[^0-9a-fA-F]+")
@@ -408,9 +409,71 @@ def _esc_attr(v: Any) -> str:
return html.escape(str(v or ""), quote=True)
def _json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return str(value)
def _guess_official_name_from_title(title: str) -> str:
t0 = str(title or "").strip()
if not t0:
return ""
m = re.search(r"[《「【](.+?)[》」】]", t0)
return str(m.group(1) or "").strip() if m and m.group(1) else ""
def _format_moment_type_label(post: dict[str, Any]) -> str:
try:
t = int(post.get("type") or 0)
except Exception:
t = 0
if t == 3:
off = post.get("official") if isinstance(post.get("official"), dict) else {}
st0 = off.get("serviceType") if isinstance(off, dict) else None
try:
st = int(st0) if st0 not in (None, "") else None
except Exception:
st = None
prefix = "服务号" if st == 1 else "公众号"
name = str(off.get("displayName") or "").strip() if isinstance(off, dict) else ""
if not name:
name = _guess_official_name_from_title(str(post.get("title") or ""))
return f"{prefix}·{name}" if name else prefix
if t == 28:
ff = post.get("finderFeed") if isinstance(post.get("finderFeed"), dict) else {}
name = str(ff.get("nickname") or "").strip() if isinstance(ff, dict) else ""
return f"视频号·{name}" if name else "视频号"
if t in (5, 42):
name0 = str(post.get("sourceName") or "").strip()
if name0:
return name0
url0 = str(post.get("contentUrl") or "").strip()
if not url0:
ml0 = post.get("media") if isinstance(post.get("media"), list) else []
m0 = ml0[0] if (ml0 and isinstance(ml0[0], dict)) else {}
url0 = str(m0.get("url") or "").strip()
if url0:
s = re.sub(r"^https?://", "", url0.strip(), flags=re.I)
s = s.split("#", 1)[0].split("?", 1)[0].rstrip("/")
return s or ("音乐" if t == 42 else "外部分享")
return "音乐" if t == 42 else "外部分享"
return ""
_SNS_EXPORT_CSS_PATCH = """
/* Moments export tweaks (keep consistent with frontend `sns.vue`). */
body { background-color: #EDEDED; }
.wse-sns-post-list > .wse-sns-post:first-child {
padding-top: 0;
}
.wse-sns-post-list > .wse-sns-post:first-child > .wse-sns-post-row {
padding-top: 12px;
}
.wse-live-photo video { display: none; }
.wse-live-photo:hover video { display: block; }
.wse-live-photo:hover img { display: none; }
@@ -477,7 +540,10 @@ class ExportProgress:
users_done: int = 0
current_username: str = ""
current_display_name: str = ""
posts_total: int = 0
posts_exported: int = 0
current_user_posts_total: int = 0
current_user_posts_done: int = 0
media_copied: int = 0
media_missing: int = 0
@@ -513,7 +579,10 @@ class ExportJob:
"usersDone": self.progress.users_done,
"currentUsername": self.progress.current_username,
"currentDisplayName": self.progress.current_display_name,
"postsTotal": self.progress.posts_total,
"postsExported": self.progress.posts_exported,
"currentUserPostsTotal": self.progress.current_user_posts_total,
"currentUserPostsDone": self.progress.current_user_posts_done,
"mediaCopied": self.progress.media_copied,
"mediaMissing": self.progress.media_missing,
},
@@ -554,6 +623,7 @@ class SnsExportManager:
account: Optional[str],
scope: ExportScope,
usernames: list[str],
export_format: ExportFormat,
use_cache: bool,
output_dir: Optional[str],
file_name: Optional[str],
@@ -568,6 +638,7 @@ class SnsExportManager:
options={
"scope": str(scope or "selected"),
"usernames": [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()],
"format": str(export_format or "html"),
"useCache": bool(use_cache),
"outputDir": str(output_dir or "").strip(),
"fileName": str(file_name or "").strip(),
@@ -627,6 +698,10 @@ class SnsExportManager:
opts = dict(job.options or {})
scope_raw = str(opts.get("scope") or "selected").strip() or "selected"
scope: ExportScope = "all" if scope_raw == "all" else "selected" # type: ignore[assignment]
export_format_raw = str(opts.get("format") or "html").strip().lower() or "html"
if export_format_raw not in {"html", "json", "txt"}:
raise ValueError(f"Unsupported export format: {export_format_raw}")
export_format: ExportFormat = export_format_raw # type: ignore[assignment]
target_usernames = [str(u or "").strip() for u in (opts.get("usernames") or []) if str(u or "").strip()]
if scope == "selected" and not target_usernames:
raise ValueError("No target usernames to export.")
@@ -638,13 +713,13 @@ class SnsExportManager:
base_name = str(opts.get("fileName") or "").strip()
if not base_name:
if scope == "all":
base_name = f"wechat_sns_export_{account_dir.name}_{ts}_{job.export_id}.zip"
base_name = f"wechat_sns_export_{account_dir.name}_{export_format}_{ts}_{job.export_id}.zip"
else:
hint = _safe_name(target_usernames[0], max_len=40) or "selected"
base_name = f"wechat_sns_export_{account_dir.name}_{hint}_{ts}_{job.export_id}.zip"
base_name = f"wechat_sns_export_{account_dir.name}_{hint}_{export_format}_{ts}_{job.export_id}.zip"
if not base_name.lower().endswith(".zip"):
base_name += ".zip"
base_name = _safe_name(base_name, max_len=120) or f"wechat_sns_export_{account_dir.name}_{ts}_{job.export_id}.zip"
base_name = _safe_name(base_name, max_len=120) or f"wechat_sns_export_{account_dir.name}_{export_format}_{ts}_{job.export_id}.zip"
final_zip = (exports_root / base_name).resolve()
tmp_zip = (exports_root / f".{base_name}.{job.export_id}.part").resolve()
@@ -1120,6 +1195,92 @@ class SnsExportManager:
media_written[cache_key] = arc
return arc
def _build_post_json_record(post: dict[str, Any]) -> dict[str, Any]:
item = _json_safe(post)
if isinstance(item, dict):
item["momentTypeLabel"] = _format_moment_type_label(post)
item["createTimeText"] = _format_dt(post.get("createTime"))
return item if isinstance(item, dict) else {"value": item}
def _render_post_text(post: dict[str, Any], index: int) -> str:
ts = _format_dt(post.get("createTime")) or "未知时间"
post_id = str(post.get("id") or post.get("tid") or "").strip()
content_desc = str(post.get("contentDesc") or "").strip()
location = str(post.get("location") or "").strip()
title0 = str(post.get("title") or "").strip()
content_url = str(post.get("contentUrl") or "").strip()
moment_label = _format_moment_type_label(post)
media_list = post.get("media") if isinstance(post.get("media"), list) else []
likes = post.get("likes") if isinstance(post.get("likes"), list) else []
comments = post.get("comments") if isinstance(post.get("comments"), list) else []
lines = [f"#{index}", f"时间: {ts}"]
if post_id:
lines.append(f"ID: {post_id}")
if moment_label:
lines.append(f"类型: {moment_label}")
if content_desc:
lines.append("内容:")
lines.append(content_desc)
if title0:
lines.append(f"标题: {title0}")
if content_url:
lines.append(f"链接: {content_url}")
if location:
lines.append(f"位置: {location}")
if media_list:
lines.append("媒体:")
for idx0, media0 in enumerate(media_list, start=1):
m = media0 if isinstance(media0, dict) else {}
mtype = str(m.get("type") or "").strip() or "-"
mid = str(m.get("id") or "").strip()
murl = str(m.get("url") or "").strip()
mthumb = str(m.get("thumb") or "").strip()
media_parts = [f"- [{idx0}] type={mtype}"]
if mid:
media_parts.append(f"id={mid}")
if murl:
media_parts.append(f"url={murl}")
if mthumb and mthumb != murl:
media_parts.append(f"thumb={mthumb}")
lines.append(" ".join(media_parts))
if likes:
like_names = [str(x or "").strip() for x in likes if str(x or "").strip()]
if like_names:
lines.append("点赞: " + "".join(like_names))
if comments:
lines.append("评论:")
for idx0, comment0 in enumerate(comments, start=1):
comment = comment0 if isinstance(comment0, dict) else {}
cn = _clean_name(comment.get("nickname") or comment.get("displayName") or comment.get("username") or "") or "未知"
refn = _clean_name(comment.get("refNickname") or comment.get("refUsername") or comment.get("refUserName") or "")
text = str(comment.get("content") or "").strip()
prefix = f"- [{idx0}] {cn}"
if refn:
prefix += f" 回复 {refn}"
if text:
prefix += f": {text}"
lines.append(prefix)
return "\n".join(lines)
def _render_user_text(*, username: str, display_name: str, post_count: int, posts: list[dict[str, Any]]) -> str:
header = [
"朋友圈导出",
f"联系人: {display_name or username}",
f"用户名: {username}",
f"条目数: {post_count}",
"",
]
body: list[str] = []
for idx0, post0 in enumerate(posts, start=1):
body.append(_render_post_text(post0, idx0))
body.append("")
return "\n".join(header + body).rstrip() + "\n"
def render_media_block(*, zf: zipfile.ZipFile, post: dict[str, Any]) -> str:
media = post.get("media") if isinstance(post.get("media"), list) else []
if not media:
@@ -1331,51 +1492,6 @@ class SnsExportManager:
likes = post.get("likes") if isinstance(post.get("likes"), list) else []
comments = post.get("comments") if isinstance(post.get("comments"), list) else []
def guess_official_name_from_title(title: str) -> str:
t0 = str(title or "").strip()
if not t0:
return ""
m = re.search(r"[《「【](.+?)[》」】]", t0)
return str(m.group(1) or "").strip() if m and m.group(1) else ""
def format_moment_type_label(p: dict[str, Any]) -> str:
try:
t = int(p.get("type") or 0)
except Exception:
t = 0
if t == 3:
off = p.get("official") if isinstance(p.get("official"), dict) else {}
st0 = off.get("serviceType") if isinstance(off, dict) else None
try:
st = int(st0) if st0 not in (None, "") else None
except Exception:
st = None
prefix = "服务号" if st == 1 else "公众号"
name = str(off.get("displayName") or "").strip() if isinstance(off, dict) else ""
if not name:
name = guess_official_name_from_title(str(p.get("title") or ""))
return f"{prefix}·{name}" if name else prefix
if t == 28:
ff = p.get("finderFeed") if isinstance(p.get("finderFeed"), dict) else {}
name = str(ff.get("nickname") or "").strip() if isinstance(ff, dict) else ""
return f"视频号·{name}" if name else "视频号"
if t in (5, 42):
name0 = str(p.get("sourceName") or "").strip()
if name0:
return name0
url0 = str(p.get("contentUrl") or "").strip()
if not url0:
ml0 = p.get("media") if isinstance(p.get("media"), list) else []
m0 = ml0[0] if (ml0 and isinstance(ml0[0], dict)) else {}
url0 = str(m0.get("url") or "").strip()
if url0:
# host+path (no query) as a readable fallback label.
s = re.sub(r"^https?://", "", url0.strip(), flags=re.I)
s = s.split("#", 1)[0].split("?", 1)[0].rstrip("/")
return s or ("音乐" if t == 42 else "外部分享")
return "音乐" if t == 42 else "外部分享"
return ""
def format_finder_feed_card_text(p: dict[str, Any]) -> str:
title0 = str(p.get("title") or "").strip()
if title0:
@@ -1426,15 +1542,15 @@ class SnsExportManager:
f'style="background-color:#4B5563">{fallback}</div></div>'
)
moment_label = format_moment_type_label(post)
moment_label = _format_moment_type_label(post)
try:
post_type = int(post.get("type") or 1)
except Exception:
post_type = 1
out: list[str] = []
out.append(f'<div class="bg-white rounded-sm px-4 py-4 mb-3" id="{_esc_attr(pid)}">')
out.append('<div class="flex items-start gap-3">')
out.append(f'<div class="wse-sns-post bg-white rounded-sm px-4 py-4 mb-3" id="{_esc_attr(pid)}">')
out.append('<div class="wse-sns-post-row flex items-start gap-3">')
out.append(avatar_html)
out.append('<div class="flex-1 min-w-0">')
out.append(f'<div class="text-sm font-medium leading-5 text-[#576b95]">{_esc_text(display)}</div>')
@@ -1558,6 +1674,7 @@ class SnsExportManager:
'<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>'
)
out.append("</div></div></div>")
out.append("</div>")
else:
out.append(render_media_block(zf=zf, post=post))
@@ -1635,7 +1752,7 @@ class SnsExportManager:
avatar_arc = export_avatar_to_zip(zf=zf, username=username, display_name=display_name)
out: list[str] = []
out.append('<div class="relative w-full mb-12 -mt-4 bg-white">')
out.append('<div class="wse-sns-cover relative w-full -mt-4">')
out.append('<div class="h-64 w-full bg-[#333333] relative overflow-hidden">')
if cover_arc:
out.append(
@@ -1644,7 +1761,7 @@ class SnsExportManager:
)
out.append("</div>")
out.append('<div class="absolute right-4 -bottom-6 flex items-end gap-4">')
out.append('<div class="absolute right-4 flex items-end gap-4" style="bottom:-12px; z-index:2;">')
out.append(
f'<div class="text-white font-bold text-xl mb-7 drop-shadow-md">{_esc_text(display_name or username)}</div>'
)
@@ -1667,22 +1784,24 @@ class SnsExportManager:
try:
with zipfile.ZipFile(str(tmp_zip), mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report) + "\n\n" + _SNS_EXPORT_CSS_PATCH
zf.writestr("assets/wechat-sns-export.css", css_payload)
written.add("assets/wechat-sns-export.css")
css_href = "assets/wechat-sns-export.css"
if export_format == "html":
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report) + "\n\n" + _SNS_EXPORT_CSS_PATCH
zf.writestr(css_href, css_payload)
written.add(css_href)
repo_root = Path(__file__).resolve().parents[2]
wxemoji_src: Optional[Path] = None
if ui_public_dir is not None:
cand = Path(ui_public_dir) / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is None:
cand = repo_root / "frontend" / "public" / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is not None:
_zip_write_tree(zf=zf, src_dir=wxemoji_src, dest_prefix="wxemoji", written=written)
repo_root = Path(__file__).resolve().parents[2]
wxemoji_src: Optional[Path] = None
if ui_public_dir is not None:
cand = Path(ui_public_dir) / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is None:
cand = repo_root / "frontend" / "public" / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is not None:
_zip_write_tree(zf=zf, src_dir=wxemoji_src, dest_prefix="wxemoji", written=written)
if scope == "all":
users = _load_sns_users(account_dir)
@@ -1691,19 +1810,37 @@ class SnsExportManager:
order = {u: i for i, u in enumerate(target_usernames)}
users.sort(key=lambda x: order.get(str(x.get("username") or ""), 10**9))
total_posts_est = 0
for user_item in users:
try:
total_posts_est += max(0, int(user_item.get("postCount") or 0))
except Exception:
continue
with self._lock:
job.progress.users_total = len(users)
job.progress.posts_total = total_posts_est
job.progress.posts_exported = 0
job.progress.current_user_posts_total = 0
job.progress.current_user_posts_done = 0
user_pages: list[dict[str, Any]] = []
css_href = "assets/wechat-sns-export.css"
user_outputs: list[dict[str, Any]] = []
exported_at = datetime.now().isoformat(timespec="seconds")
for i, u in enumerate(users):
should_cancel()
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
try:
post_count_est = max(0, int(u.get("postCount") or 0))
except Exception:
post_count_est = 0
safe_uname = _safe_name(uname, max_len=80) or hashlib.md5(uname.encode("utf-8", errors="ignore")).hexdigest()[:12]
with self._lock:
job.progress.current_username = uname
job.progress.current_display_name = display
job.progress.current_user_posts_total = post_count_est
job.progress.current_user_posts_done = 0
posts_all: list[dict[str, Any]] = []
cover_data: Optional[dict[str, Any]] = None
@@ -1728,132 +1865,252 @@ class SnsExportManager:
if not bool(resp.get("hasMore")):
break
post_parts: list[str] = []
for p in posts_all:
should_cancel()
post_parts.append(render_post_html(zf=zf, post=p))
actual_post_count = len(posts_all)
if actual_post_count != post_count_est:
with self._lock:
job.progress.posts_exported += 1
job.progress.posts_total = max(
job.progress.posts_exported,
max(0, job.progress.posts_total + (actual_post_count - post_count_est)),
)
job.progress.current_user_posts_total = actual_post_count
else:
with self._lock:
job.progress.current_user_posts_total = actual_post_count
safe_uname = _safe_name(uname, max_len=80) or hashlib.md5(uname.encode("utf-8", errors="ignore")).hexdigest()[:12]
page_name = f"sns_{safe_uname}.html"
title = f"朋友圈导出 - {display}"
back_link = (
'<a href="index.html" class="text-sm text-[#576b95] hover:underline">← 返回</a>'
if scope == "all"
else ""
)
cover_html = render_cover_header_html(zf=zf, username=uname, display_name=display, cover_data=cover_data)
page_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
f"<title>{_esc_text(title)}</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
cover_html,
('<div class="flex items-center justify-between mb-4">' + back_link + (f'<div class="text-xs text-gray-500 truncate">{_esc_text(uname)}</div>' if uname else "") + "</div>") if back_link else "",
"".join(post_parts),
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr(page_name, page_html)
written.add(page_name)
output_name = ""
if export_format == "html":
post_parts: list[str] = []
for p in posts_all:
should_cancel()
post_parts.append(render_post_html(zf=zf, post=p))
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
user_pages.append(
output_name = f"sns_{safe_uname}.html"
title = f"朋友圈导出 - {display}"
back_link = (
'<a href="index.html" class="text-sm text-[#576b95] hover:underline">← 返回</a>'
if scope == "all"
else ""
)
cover_html = render_cover_header_html(zf=zf, username=uname, display_name=display, cover_data=cover_data)
page_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
f"<title>{_esc_text(title)}</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="wse-sns-page max-w-2xl mx-auto px-4 py-4">',
cover_html,
('<div class="flex items-center justify-between mb-4">' + back_link + (f'<div class="text-xs text-gray-500 truncate">{_esc_text(uname)}</div>' if uname else "") + "</div>") if back_link else "",
'<div class="wse-sns-post-list">' + "".join(post_parts) + "</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr(output_name, page_html)
written.add(output_name)
elif export_format == "json":
exported_posts: list[dict[str, Any]] = []
for p in posts_all:
should_cancel()
exported_posts.append(_build_post_json_record(p))
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
output_name = f"sns_{safe_uname}.json"
json_payload: dict[str, Any] = {
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"username": uname,
"displayName": display,
"postCount": actual_post_count,
"posts": exported_posts,
}
if isinstance(cover_data, dict) and cover_data:
json_payload["cover"] = _json_safe(cover_data)
zf.writestr(output_name, json.dumps(json_payload, ensure_ascii=False, indent=2))
written.add(output_name)
else:
for _idx0, _post0 in enumerate(posts_all, start=1):
should_cancel()
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
output_name = f"sns_{safe_uname}.txt"
zf.writestr(
output_name,
_render_user_text(
username=uname,
display_name=display,
post_count=actual_post_count,
posts=posts_all,
),
)
written.add(output_name)
user_outputs.append(
{
"username": uname,
"displayName": display,
"postCount": int(u.get("postCount") or 0),
"page": page_name,
"postCount": actual_post_count,
"entry": output_name,
}
)
with self._lock:
job.progress.users_done = i + 1
job.progress.current_user_posts_done = actual_post_count
if scope == "all":
rows: list[str] = []
for u in user_pages:
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
pc = int(u.get("postCount") or 0)
href = str(u.get("page") or "").strip()
avatar_arc = export_avatar_to_zip(zf=zf, username=uname, display_name=display)
if avatar_arc:
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<img src="{_esc_attr(avatar_arc)}" class="w-full h-full object-cover" '
f'alt="{_esc_attr(display or uname)}" loading="lazy" referrerpolicy="no-referrer" />'
"</div>"
if export_format == "html":
if scope == "all":
rows: list[str] = []
for u in user_outputs:
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
pc = int(u.get("postCount") or 0)
href = str(u.get("entry") or "").strip()
avatar_arc = export_avatar_to_zip(zf=zf, username=uname, display_name=display)
if avatar_arc:
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<img src="{_esc_attr(avatar_arc)}" class="w-full h-full object-cover" '
f'alt="{_esc_attr(display or uname)}" loading="lazy" referrerpolicy="no-referrer" />'
"</div>"
)
else:
fallback = _esc_text((display or uname or "")[:1] or "")
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
'<div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" '
f'style="background-color:#4B5563">{fallback}</div></div>'
)
rows.append(
'<a class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50" '
f'href="{_esc_attr(href)}">'
f"{avatar_html}"
'<div class="flex-1 min-w-0">'
f'<div class="truncate">{_esc_text(display)}</div>'
f'<div class="text-[11px] text-gray-400 truncate">{_esc_text(uname)} · {pc} 条</div>'
"</div></a>"
)
else:
fallback = _esc_text((display or uname or "")[:1] or "")
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
'<div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" '
f'style="background-color:#4B5563">{fallback}</div></div>'
)
rows.append(
'<a class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50" '
f'href="{_esc_attr(href)}">'
f"{avatar_html}"
'<div class="flex-1 min-w-0">'
f'<div class="truncate">{_esc_text(display)}</div>'
f'<div class="text-[11px] text-gray-400 truncate">{_esc_text(uname)} · {pc} 条</div>'
"</div></a>"
)
index_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
"<title>朋友圈导出</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
'<div class="mb-4 flex items-center justify-between">',
'<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>',
f'<div class="text-xs text-gray-500">{len(user_pages)} 人</div>',
"</div>",
'<div class="bg-white rounded-sm overflow-hidden border border-gray-200">',
"".join(rows),
"</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr("index.html", index_html)
written.add("index.html")
else:
only_page = user_pages[0]["page"] if user_pages else ""
if only_page:
index_html = (
"<!doctype html><html><head>"
'<meta charset="utf-8" />'
f'<meta http-equiv="refresh" content="0; url={_esc_attr(only_page)}" />'
"</head><body></body></html>"
index_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
"<title>朋友圈导出</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
'<div class="mb-4 flex items-center justify-between">',
'<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>',
f'<div class="text-xs text-gray-500">{len(user_outputs)} 人</div>',
"</div>",
'<div class="bg-white rounded-sm overflow-hidden border border-gray-200">',
"".join(rows),
"</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr("index.html", index_html)
written.add("index.html")
else:
only_page = user_outputs[0]["entry"] if user_outputs else ""
if only_page:
index_html = (
"<!doctype html><html><head>"
'<meta charset="utf-8" />'
f'<meta http-equiv="refresh" content="0; url={_esc_attr(only_page)}" />'
"</head><body></body></html>"
)
zf.writestr("index.html", index_html)
written.add("index.html")
elif export_format == "json":
zf.writestr(
"index.json",
json.dumps(
{
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"users": user_outputs,
},
ensure_ascii=False,
indent=2,
),
)
written.add("index.json")
else:
lines = [
"朋友圈导出",
f"导出时间: {exported_at}",
f"账号: {account_dir.name}",
f"范围: {'全部联系人' if scope == 'all' else '指定联系人'}",
f"格式: {export_format}",
"",
]
for item in user_outputs:
lines.append(
f"- {item.get('displayName') or item.get('username') or ''} "
f"({item.get('username') or ''}) · {int(item.get('postCount') or 0)} 条 -> {item.get('entry') or ''}"
)
zf.writestr("index.txt", "\n".join(lines).rstrip() + "\n")
written.add("index.txt")
zf.writestr(
"manifest.json",
json.dumps(
{
"schemaVersion": 1,
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"options": {
"useCache": use_cache,
},
"stats": {
"users": len(user_outputs),
"postsExported": job.progress.posts_exported,
"postsTotal": job.progress.posts_total,
"mediaCopied": job.progress.media_copied,
"mediaMissing": job.progress.media_missing,
},
"entries": user_outputs,
},
ensure_ascii=False,
indent=2,
),
)
written.add("manifest.json")
try:
zf.writestr("export_report.json", json.dumps(report, ensure_ascii=False, indent=2))
@@ -1881,8 +2138,9 @@ class SnsExportManager:
if job.status != "cancelled":
job.status = "done"
job.finished_at = time.time()
job.progress.current_user_posts_done = job.progress.current_user_posts_total
return tmp_zip
return final_out
SNS_EXPORT_MANAGER = SnsExportManager()
@@ -731,6 +731,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"success": account_success,
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"source_db_storage_path": str(source_db_storage_path),
"source_wxid_dir": str(wxid_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,
+53 -42
View File
@@ -7,6 +7,7 @@ import unittest
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
@@ -64,35 +65,43 @@ class TestDecryptStreamSSE(unittest.TestCase):
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
with mock.patch.object(decrypt_router, "upsert_account_keys_in_store") as upsert_mock:
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":"):
continue
if not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
if line.startswith(":"):
continue
if not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
types = {e.get("type") for e in events}
self.assertIn("start", types)
self.assertIn("progress", types)
self.assertEqual(events[-1].get("type"), "complete")
self.assertEqual(events[-1].get("status"), "completed")
upsert_mock.assert_called_once_with(
"wxid_foo",
db_key="00" * 32,
aliases=["wxid_foo_bar"],
db_key_source_wxid_dir=str(db_storage.parent),
db_key_source_db_storage_path=str(db_storage),
)
out = root / "output" / "databases" / "wxid_foo" / "MSG0.db"
self.assertTrue(out.exists())
@@ -135,35 +144,37 @@ class TestDecryptStreamSSE(unittest.TestCase):
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
with mock.patch.object(decrypt_router, "upsert_account_keys_in_store") as upsert_mock:
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":"):
continue
if not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
if line.startswith(":"):
continue
if not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
self.assertEqual(events[-1].get("type"), "complete")
self.assertEqual(events[-1].get("status"), "failed")
self.assertEqual(events[-1].get("success_count"), 0)
self.assertEqual(events[-1].get("failure_count"), 1)
self.assertIn("密钥可能不匹配", str(events[-1].get("message") or ""))
upsert_mock.assert_not_called()
out = root / "output" / "databases" / "wxid_bad" / "MSG0.db"
self.assertFalse(out.exists())
@@ -0,0 +1,115 @@
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import wechat_decrypt_tool.key_service as key_service
class _FakeWxKey:
def __init__(self, key: str) -> None:
self.key = key
self.initialize_calls: list[int] = []
self.cleanup_calls = 0
def initialize_hook(self, pid: int) -> bool:
self.initialize_calls.append(pid)
return True
def get_last_error_msg(self) -> str:
return ""
def poll_key_data(self):
return {"key": self.key}
def get_status_message(self):
return None, None
def cleanup_hook(self) -> None:
self.cleanup_calls += 1
class TestKeyServiceManualWechatInstallPath(unittest.TestCase):
def test_get_db_key_workflow_can_use_manual_install_directory(self) -> None:
fake_wx_key = _FakeWxKey("a" * 64)
with TemporaryDirectory() as temp_dir:
install_dir = Path(temp_dir)
exe_path = install_dir / "WeChat.exe"
exe_path.write_bytes(b"")
with mock.patch.object(
key_service,
"wx_key",
fake_wx_key,
), mock.patch.object(
key_service,
"detect_wechat_installation",
side_effect=AssertionError("should not auto-detect when manual path is provided"),
), mock.patch.object(
key_service,
"_read_wechat_version_from_exe",
return_value="",
), mock.patch.object(
key_service.WeChatKeyFetcher,
"kill_wechat",
autospec=True,
) as kill_mock, mock.patch.object(
key_service.WeChatKeyFetcher,
"launch_wechat",
autospec=True,
return_value=4321,
) as launch_mock:
result = key_service.get_db_key_workflow(wechat_install_path=str(install_dir))
self.assertEqual(result["db_key"], "a" * 64)
kill_mock.assert_called_once()
launch_mock.assert_called_once()
_, used_exe_path = launch_mock.call_args.args
self.assertEqual(used_exe_path, str(exe_path))
self.assertEqual(fake_wx_key.initialize_calls, [4321])
self.assertEqual(fake_wx_key.cleanup_calls, 1)
def test_get_db_key_workflow_does_not_require_detected_version(self) -> None:
fake_wx_key = _FakeWxKey("b" * 64)
with TemporaryDirectory() as temp_dir:
exe_path = Path(temp_dir) / "Weixin.exe"
exe_path.write_bytes(b"")
with mock.patch.object(
key_service,
"wx_key",
fake_wx_key,
), mock.patch.object(
key_service,
"detect_wechat_installation",
return_value={
"wechat_exe_path": str(exe_path),
"wechat_version": "",
},
), mock.patch.object(
key_service.WeChatKeyFetcher,
"kill_wechat",
autospec=True,
), mock.patch.object(
key_service.WeChatKeyFetcher,
"launch_wechat",
autospec=True,
return_value=2468,
):
result = key_service.get_db_key_workflow()
self.assertEqual(result["db_key"], "b" * 64)
self.assertEqual(fake_wx_key.initialize_calls, [2468])
self.assertEqual(fake_wx_key.cleanup_calls, 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,107 @@
import asyncio
import importlib
import logging
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for handler in lg.handlers[:]:
try:
handler.close()
except Exception:
pass
try:
lg.removeHandler(handler)
except Exception:
pass
class TestSavedDbKeySourceValidation(unittest.TestCase):
def test_get_saved_keys_blocks_legacy_db_key_for_suffixed_wxid_dir(self) -> None:
with TemporaryDirectory() as td:
root = Path(td)
db_storage = root / "xwechat_files" / "wxid_demo_abcd" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.routers.keys as keys_router
importlib.reload(app_paths)
importlib.reload(key_store)
importlib.reload(keys_router)
key_store.upsert_account_keys_in_store("wxid_demo", db_key="A" * 64)
result = asyncio.run(
keys_router.get_saved_keys(account="wxid_demo", db_storage_path=str(db_storage))
)
self.assertEqual(result["status"], "success")
self.assertEqual(result["keys"]["db_key"], "")
self.assertIn("Legacy saved db key is ambiguous", result["keys"]["db_key_blocked_reason"])
finally:
_close_logging_handlers()
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
def test_get_saved_keys_accepts_source_matched_db_key(self) -> None:
with TemporaryDirectory() as td:
root = Path(td)
db_storage = root / "xwechat_files" / "wxid_demo_abcd" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.routers.keys as keys_router
importlib.reload(app_paths)
importlib.reload(key_store)
importlib.reload(keys_router)
key_store.upsert_account_keys_in_store(
"wxid_demo",
db_key="B" * 64,
aliases=["wxid_demo_abcd"],
db_key_source_wxid_dir=str(db_storage.parent),
db_key_source_db_storage_path=str(db_storage),
)
result = asyncio.run(
keys_router.get_saved_keys(account="wxid_demo", db_storage_path=str(db_storage))
)
self.assertEqual(result["status"], "success")
self.assertEqual(result["keys"]["db_key"], "B" * 64)
self.assertEqual(result["keys"]["db_key_store_account"], "wxid_demo_abcd")
self.assertEqual(result["keys"]["db_key_source_wxid_dir"], str(db_storage.parent))
self.assertEqual(result["keys"]["db_key_source_db_storage_path"], str(db_storage))
self.assertEqual(result["keys"]["db_key_blocked_reason"], "")
finally:
_close_logging_handlers()
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
if __name__ == "__main__":
unittest.main()
Generated
+1 -1
View File
@@ -872,7 +872,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "1.3.0"
version = "1.7.12"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },