Skip to content

Commit 7089ac2

Browse files
committed
improvement(desktop-output): 补充 output 目录迁移进度反馈并完善切换体验
- 将 output 目录迁移改为 worker 异步执行,并补充扫描、复制、校验、回滚阶段进度 - 设置页增加迁移进度展示,支持恢复进行中的状态并展示当前文件/传输体积 - 迁移完成后自动清理旧备份目录,并同步源码/桌面端版本号到 v1.7.10
1 parent d1a131c commit 7089ac2

11 files changed

Lines changed: 707 additions & 44 deletions

File tree

desktop/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "wechat-data-analysis-desktop",
33
"private": true,
4-
"version": "1.3.0",
4+
"version": "1.7.10",
55
"main": "src/main.cjs",
66
"scripts": {
77
"dev": "node scripts/dev.cjs",

desktop/src/main.cjs

Lines changed: 201 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ const fs = require("fs");
2222
const http = require("http");
2323
const net = require("net");
2424
const path = require("path");
25+
const { Worker } = require("worker_threads");
2526
const {
27+
cleanupOutputDirectoryBackup,
2628
getDefaultOutputDirPath,
2729
getEffectiveOutputDirPath,
28-
migrateOutputDirectory,
2930
normalizeDirectoryPath,
30-
rollbackOutputDirectoryChange,
3131
} = require("./output-dir.cjs");
3232

3333
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;
4545
let desktopSettings = null;
4646
let backendPortChangeInProgress = false;
4747
let outputDirChangeInProgress = false;
48+
let outputDirChangeProgressState = null;
4849

4950
const gotSingleInstanceLock = app.requestSingleInstanceLock();
5051
if (!gotSingleInstanceLock) {
@@ -279,7 +280,9 @@ function resolveOutputDir() {
279280
if (!dataDir) return null;
280281

281282
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
282-
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
283+
// Allow dev-mode desktop runs to persist the chosen output directory too.
284+
// An explicit environment variable still wins so local launch overrides keep working.
285+
const settingsOutputDir = safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "");
283286

284287
let chosen = null;
285288
try {
@@ -705,6 +708,7 @@ function getOutputDirInfo() {
705708
const defaultPath = getDefaultOutputDir() || "";
706709
const currentPath = resolveOutputDir() || defaultPath;
707710
const hasPending = desktopSettings.pendingOutputDir !== null;
711+
const canChange = !!defaultPath && !!currentPath;
708712
const pendingPath =
709713
desktopSettings.pendingOutputDir === null
710714
? ""
@@ -718,8 +722,8 @@ function getOutputDirInfo() {
718722
pendingPath,
719723
hasPending,
720724
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
721-
canChange: !!app.isPackaged,
722-
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
725+
canChange,
726+
changeUnavailableReason: canChange ? "" : "无法定位 output 目录",
723727
};
724728
}
725729

@@ -749,10 +753,6 @@ function setIgnoredUpdateVersion(version) {
749753
}
750754

751755
async function applyOutputDirChange(nextValue) {
752-
if (!app.isPackaged) {
753-
throw new Error("开发模式不支持界面修改 output 目录");
754-
}
755-
756756
const defaultPath = getDefaultOutputDir();
757757
const currentPath = resolveOutputDir();
758758
if (!defaultPath || !currentPath) {
@@ -785,15 +785,41 @@ async function applyOutputDirChange(nextValue) {
785785
const wasBackendRunning = !!backendProc;
786786
let migration = null;
787787
let settingsSwitched = false;
788+
let retainedBackupPath = "";
789+
let backupCleanupWarning = "";
788790

789791
try {
792+
setOutputDirChangeProgressState({
793+
active: true,
794+
stage: "preparing",
795+
message: wasBackendRunning ? "正在暂停后端并准备迁移 output 目录" : "正在准备迁移 output 目录",
796+
percent: 1,
797+
});
798+
790799
if (wasBackendRunning) {
791800
await stopBackendAndWait({ timeoutMs: 10_000 });
792801
}
793802

794-
migration = migrateOutputDirectory({
795-
currentDir: currentPath,
796-
nextDir: nextPath,
803+
migration = await runOutputDirWorker(
804+
"migrate",
805+
{
806+
currentDir: currentPath,
807+
nextDir: nextPath,
808+
},
809+
(progress) => {
810+
setOutputDirChangeProgressState({
811+
active: true,
812+
...progress,
813+
});
814+
}
815+
);
816+
817+
setOutputDirChangeProgressState({
818+
active: true,
819+
stage: "switching",
820+
message: "正在应用新的 output 目录设置",
821+
percent: 99,
822+
currentFile: "",
797823
});
798824

799825
setOutputDirSetting(nextPath);
@@ -803,28 +829,61 @@ async function applyOutputDirChange(nextValue) {
803829
ensureOutputLink();
804830

805831
if (wasBackendRunning) {
832+
setOutputDirChangeProgressState({
833+
active: true,
834+
stage: "restarting",
835+
message: "正在重启后端并应用新的 output 目录",
836+
percent: 99,
837+
});
806838
startBackend();
807839
await waitForBackend({ timeoutMs: 30_000 });
808840
}
809841

842+
retainedBackupPath = migration?.backupDir || "";
843+
if (retainedBackupPath) {
844+
try {
845+
cleanupOutputDirectoryBackup(retainedBackupPath);
846+
retainedBackupPath = "";
847+
} catch (cleanupErr) {
848+
backupCleanupWarning = `;旧 output 目录未能自动删除:${cleanupErr?.message || cleanupErr}`;
849+
logMain(
850+
`[main] failed to clean output dir backup ${retainedBackupPath}: ${cleanupErr?.message || cleanupErr}`
851+
);
852+
}
853+
}
854+
855+
setOutputDirChangeProgressState({
856+
active: true,
857+
stage: "complete",
858+
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
859+
percent: 100,
860+
});
810861
const info = getOutputDirInfo();
862+
const successMessage =
863+
(migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换") + backupCleanupWarning;
811864
return {
812865
success: true,
813866
changed: true,
814867
path: info.path,
815868
defaultPath: info.defaultPath,
816869
isDefault: info.isDefault,
817870
pendingPath: info.pendingPath,
818-
backupPath: migration?.backupDir || "",
871+
backupPath: retainedBackupPath,
819872
sourceWasEmpty: !!migration?.sourceWasEmpty,
820-
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
873+
message: successMessage,
821874
};
822875
} catch (err) {
823876
const message = err?.message || String(err);
824877
let rollbackMessage = "";
825878
if (migration?.changed) {
826879
try {
827-
rollbackOutputDirectoryChange({
880+
setOutputDirChangeProgressState({
881+
active: true,
882+
stage: "rolling-back",
883+
message: "迁移失败,正在回滚 output 目录",
884+
percent: 99,
885+
});
886+
await runOutputDirWorker("rollback", {
828887
previousDir: currentPath,
829888
currentDir: nextPath,
830889
backupDir: migration.backupDir,
@@ -969,6 +1028,119 @@ function setWindowProgressBar(value) {
9691028
} catch {}
9701029
}
9711030

1031+
function makeIdleOutputDirChangeProgressState() {
1032+
return {
1033+
active: false,
1034+
stage: "idle",
1035+
message: "",
1036+
percent: 0,
1037+
bytesTransferred: 0,
1038+
bytesTotal: 0,
1039+
itemsTransferred: 0,
1040+
itemsTotal: 0,
1041+
currentFile: "",
1042+
error: "",
1043+
};
1044+
}
1045+
1046+
function clampOutputDirProgressNumber(value) {
1047+
const n = Number(value);
1048+
if (!Number.isFinite(n) || n < 0) return 0;
1049+
return n;
1050+
}
1051+
1052+
function normalizeOutputDirChangeProgressState(next = {}) {
1053+
const active = next?.active !== false;
1054+
const percent = Math.max(0, Math.min(100, Math.round(Number(next?.percent || 0))));
1055+
return {
1056+
active,
1057+
stage: String(next?.stage || (active ? "running" : "idle")),
1058+
message: String(next?.message || ""),
1059+
percent,
1060+
bytesTransferred: clampOutputDirProgressNumber(next?.bytesTransferred),
1061+
bytesTotal: clampOutputDirProgressNumber(next?.bytesTotal),
1062+
itemsTransferred: clampOutputDirProgressNumber(next?.itemsTransferred),
1063+
itemsTotal: clampOutputDirProgressNumber(next?.itemsTotal),
1064+
currentFile: String(next?.currentFile || ""),
1065+
error: String(next?.error || ""),
1066+
};
1067+
}
1068+
1069+
function getOutputDirChangeProgressState() {
1070+
if (!outputDirChangeProgressState) {
1071+
outputDirChangeProgressState = makeIdleOutputDirChangeProgressState();
1072+
}
1073+
return outputDirChangeProgressState;
1074+
}
1075+
1076+
function setOutputDirChangeProgressState(next = {}) {
1077+
outputDirChangeProgressState = normalizeOutputDirChangeProgressState(next);
1078+
sendToRenderer("app:outputDirChangeProgress", outputDirChangeProgressState);
1079+
1080+
if (!outputDirChangeProgressState.active) {
1081+
setWindowProgressBar(-1);
1082+
return outputDirChangeProgressState;
1083+
}
1084+
1085+
const ratio =
1086+
outputDirChangeProgressState.percent > 0
1087+
? Math.max(0.02, Math.min(1, outputDirChangeProgressState.percent / 100))
1088+
: 2;
1089+
setWindowProgressBar(ratio);
1090+
return outputDirChangeProgressState;
1091+
}
1092+
1093+
function clearOutputDirChangeProgressState() {
1094+
return setOutputDirChangeProgressState({ active: false });
1095+
}
1096+
1097+
function getOutputDirWorkerScriptPath() {
1098+
return path.join(__dirname, "output-dir-worker.cjs");
1099+
}
1100+
1101+
function runOutputDirWorker(action, payload, onProgress) {
1102+
return new Promise((resolve, reject) => {
1103+
const worker = new Worker(getOutputDirWorkerScriptPath(), {
1104+
workerData: {
1105+
action: String(action || "migrate"),
1106+
payload,
1107+
},
1108+
});
1109+
1110+
let settled = false;
1111+
const finish = (err, result) => {
1112+
if (settled) return;
1113+
settled = true;
1114+
if (err) reject(err);
1115+
else resolve(result);
1116+
};
1117+
1118+
worker.on("message", (message) => {
1119+
if (!message || typeof message !== "object") return;
1120+
if (message.type === "progress") {
1121+
if (typeof onProgress === "function") onProgress(message.progress || {});
1122+
return;
1123+
}
1124+
if (message.type === "result") {
1125+
finish(null, message.result);
1126+
return;
1127+
}
1128+
if (message.type === "error") {
1129+
finish(new Error(message.error?.message || "output 目录迁移失败"));
1130+
}
1131+
});
1132+
1133+
worker.once("error", (err) => {
1134+
finish(err);
1135+
});
1136+
1137+
worker.once("exit", (code) => {
1138+
if (settled || code === 0) return;
1139+
finish(new Error(`output 目录任务异常退出(code=${code})`));
1140+
});
1141+
});
1142+
}
1143+
9721144
function looksLikeHtml(input) {
9731145
if (!input) return false;
9741146
const s = String(input);
@@ -1611,23 +1783,28 @@ function startBackend() {
16111783
if (backendProc) return backendProc;
16121784
startWcdbSidecar();
16131785

1786+
const resolvedDataPath = resolveDataDir() || getUserDataDir() || repoRoot();
1787+
const resolvedOutputPath = resolveOutputDir() || getDefaultOutputDir() || path.join(resolvedDataPath, "output");
16141788
const env = {
16151789
...process.env,
16161790
WECHAT_TOOL_HOST: getBackendBindHost(),
16171791
WECHAT_TOOL_PORT: String(getBackendPort()),
1792+
WECHAT_TOOL_DATA_DIR: resolvedDataPath,
1793+
WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath,
16181794
// Make sure Python prints UTF-8 to stdout/stderr.
16191795
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
16201796
};
16211797
ensureWcdbSidecarEnv(env);
1798+
logMain(
1799+
`[main] startBackend packaged=${app.isPackaged} port=${env.WECHAT_TOOL_PORT} dataDir=${env.WECHAT_TOOL_DATA_DIR} outputDir=${env.WECHAT_TOOL_OUTPUT_DIR}`
1800+
);
16221801

16231802
// In packaged mode we expect to provide the generated Nuxt output dir via env.
16241803
if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) {
16251804
env.WECHAT_TOOL_UI_DIR = path.join(process.resourcesPath, "ui");
16261805
}
16271806

16281807
if (app.isPackaged) {
1629-
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
1630-
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
16311808
try {
16321809
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
16331810
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
@@ -2156,8 +2333,8 @@ function registerWindowIpc() {
21562333
pendingPath: "",
21572334
hasPending: false,
21582335
lastError: err?.message || String(err),
2159-
canChange: !!app.isPackaged,
2160-
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
2336+
canChange: false,
2337+
changeUnavailableReason: "无法读取 output 目录信息",
21612338
};
21622339
}
21632340
});
@@ -2166,6 +2343,10 @@ function registerWindowIpc() {
21662343
return resolveOutputDir() || "";
21672344
});
21682345

2346+
ipcMain.handle("app:getOutputDirChangeProgress", () => {
2347+
return getOutputDirChangeProgressState();
2348+
});
2349+
21692350
ipcMain.handle("app:openOutputDir", async () => {
21702351
const outDir = resolveOutputDir();
21712352
if (!outDir) throw new Error("无法定位 output 目录");
@@ -2202,6 +2383,7 @@ function registerWindowIpc() {
22022383
};
22032384
} finally {
22042385
outputDirChangeInProgress = false;
2386+
clearOutputDirChangeProgressState();
22052387
}
22062388
});
22072389

0 commit comments

Comments
 (0)