mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
10 Commits
Generated
+2
-2
@@ -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,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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">当前导出任务</div>
|
||||
<div class="mt-1 text-xs text-gray-500 break-all">ID:{{ 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>动态:{{ 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">
|
||||
联系人:{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }}
|
||||
</div>
|
||||
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
|
||||
格式:{{ exportActiveFormatLabel }}
|
||||
</div>
|
||||
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
|
||||
已复制媒体:{{ exportJob.progress?.mediaCopied || 0 }}
|
||||
</div>
|
||||
<div class="rounded-md border border-gray-200 bg-white px-3 py-2">
|
||||
缺失媒体:{{ 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">
|
||||
当前联系人:{{ exportCurrentTargetLabel }}
|
||||
({{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }})
|
||||
</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">
|
||||
已发送取消请求,正在等待当前步骤结束…
|
||||
</div>
|
||||
<div v-else-if="exportJob.status === 'cancelled'" class="text-xs text-amber-700">
|
||||
导出已取消。
|
||||
</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">
|
||||
已导出到:{{ 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">导出格式</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">导出文件名(可选)</label>
|
||||
<input
|
||||
v-model="exportFileName"
|
||||
type="text"
|
||||
placeholder="可选,不填则自动生成 .zip 文件名"
|
||||
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">导出目录</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"
|
||||
>
|
||||
选择文件夹
|
||||
</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"
|
||||
>
|
||||
清除
|
||||
</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">选择联系人</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-xs text-gray-500">已选 {{ exportSelectedCount }} 人</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"
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
v-model="exportSearchQuery"
|
||||
type="text"
|
||||
placeholder="搜索联系人(名称 / username)"
|
||||
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">
|
||||
未找到可导出的联系人
|
||||
</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 }} · {{ u.postCount || 0 }} 条</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">已选 {{ exportSelectedCount }} 人</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"
|
||||
>
|
||||
取消
|
||||
</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
@@ -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,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.7.12"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user