feat(settings): 设置弹窗支持后端端口切换(桌面/网页)

This commit is contained in:
2977094657
2026-02-28 18:36:22 +08:00
Unverified
parent e62b2377da
commit 8a101b4c5e
16 changed files with 1286 additions and 317 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/main.cjs",
"scripts": {
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
"dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:8000 electron .",
"dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .",
"build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs",
"build:backend": "uv sync --extra build && node scripts/build-backend.cjs",
"build:icon": "node scripts/build-icon.cjs",
+189 -20
View File
@@ -12,19 +12,19 @@ const { autoUpdater } = require("electron-updater");
const { spawn, spawnSync } = require("child_process");
const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const BACKEND_HOST = process.env.WECHAT_TOOL_HOST || "127.0.0.1";
const BACKEND_PORT = Number(process.env.WECHAT_TOOL_PORT || "8000");
const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
let backendProc = null;
let backendStdioStream = null;
let resolvedDataDir = null;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -46,6 +46,77 @@ function nowIso() {
return new Date().toISOString();
}
function parsePort(value) {
if (value == null) return null;
const raw = String(value).trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1 || n > 65535) return null;
return n;
}
function formatHostForUrl(host) {
const h = String(host || "").trim();
if (!h) return "127.0.0.1";
// IPv6 literals must be wrapped in brackets in URLs.
if (h.includes(":") && !(h.startsWith("[") && h.endsWith("]"))) return `[${h}]`;
return h;
}
function getBackendBindHost() {
return DEFAULT_BACKEND_HOST;
}
function getBackendAccessHost() {
// 0.0.0.0 / :: are fine bind hosts, but not a reachable client destination.
const host = String(getBackendBindHost() || "").trim();
if (host === "0.0.0.0" || host === "::") return "127.0.0.1";
return host || "127.0.0.1";
}
function getBackendPort() {
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
return settingsPort ?? DEFAULT_BACKEND_PORT;
}
function setBackendPortSetting(nextPort) {
const p = parsePort(nextPort);
if (p == null) throw new Error("端口无效,请输入 1-65535 的整数");
loadDesktopSettings();
desktopSettings.backendPort = p;
persistDesktopSettings();
process.env.WECHAT_TOOL_PORT = String(p);
return p;
}
function getBackendHealthUrl() {
const host = formatHostForUrl(getBackendAccessHost());
const port = getBackendPort();
return `http://${host}:${port}/api/health`;
}
function getBackendUiUrl() {
const host = formatHostForUrl(getBackendAccessHost());
const port = getBackendPort();
return `http://${host}:${port}/`;
}
function isPortAvailable(port, host) {
return new Promise((resolve) => {
try {
const srv = net.createServer();
srv.unref();
srv.once("error", () => resolve(false));
srv.listen({ port, host }, () => {
srv.close(() => resolve(true));
});
} catch {
resolve(false);
}
});
}
function resolveDataDir() {
if (resolvedDataDir) return resolvedDataDir;
@@ -146,6 +217,8 @@ function loadDesktopSettings() {
closeBehavior: "tray",
// When set, suppress the auto-update prompt for this exact version.
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
};
const p = getDesktopSettingsPath();
@@ -162,6 +235,7 @@ function loadDesktopSettings() {
const raw = fs.readFileSync(p, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
@@ -710,20 +784,20 @@ function attachBackendStdio(proc, logPath) {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
} catch {}
let stream = null;
try {
backendStdioStream = fs.createWriteStream(logPath, { flags: "a" });
backendStdioStream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`);
stream = fs.createWriteStream(logPath, { flags: "a" });
stream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`);
} catch {
backendStdioStream = null;
return;
}
const write = (prefix, chunk) => {
if (!backendStdioStream) return;
if (!stream) return;
try {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
backendStdioStream.write(`[${nowIso()}] ${prefix} ${text}`);
if (!text.endsWith("\n")) backendStdioStream.write("\n");
stream.write(`[${nowIso()}] ${prefix} ${text}`);
if (!text.endsWith("\n")) stream.write("\n");
} catch {}
};
@@ -733,9 +807,9 @@ function attachBackendStdio(proc, logPath) {
proc.on("close", (code, signal) => {
write("[backend:close]", `code=${code} signal=${signal}`);
try {
backendStdioStream?.end();
stream?.end();
} catch {}
backendStdioStream = null;
stream = null;
});
}
@@ -754,8 +828,8 @@ function startBackend() {
const env = {
...process.env,
WECHAT_TOOL_HOST: BACKEND_HOST,
WECHAT_TOOL_PORT: String(BACKEND_PORT),
WECHAT_TOOL_HOST: getBackendBindHost(),
WECHAT_TOOL_PORT: String(getBackendPort()),
// Make sure Python prints UTF-8 to stdout/stderr.
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
};
@@ -795,8 +869,9 @@ function startBackend() {
});
}
backendProc.on("exit", (code, signal) => {
backendProc = null;
const proc = backendProc;
proc.on("exit", (code, signal) => {
if (backendProc === proc) backendProc = null;
// eslint-disable-next-line no-console
console.log(`[backend] exited code=${code} signal=${signal}`);
logMain(`[backend] exited code=${code} signal=${signal}`);
@@ -835,6 +910,42 @@ function stopBackend() {
} catch {}
}
async function stopBackendAndWait({ timeoutMs = 10_000 } = {}) {
if (!backendProc) return;
const proc = backendProc;
await new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
resolve();
};
const timer = setTimeout(finish, timeoutMs);
try {
proc.once("exit", () => {
clearTimeout(timer);
finish();
});
} catch {}
try {
stopBackend();
} catch {
clearTimeout(timer);
finish();
}
});
}
async function restartBackend({ timeoutMs = 30_000 } = {}) {
await stopBackendAndWait({ timeoutMs: 10_000 });
startBackend();
await waitForBackend({ timeoutMs });
}
function httpGet(url) {
return new Promise((resolve, reject) => {
const req = http.get(url, (res) => {
@@ -849,17 +960,18 @@ function httpGet(url) {
});
}
async function waitForBackend({ timeoutMs }) {
async function waitForBackend({ timeoutMs, healthUrl } = {}) {
const url = String(healthUrl || getBackendHealthUrl()).trim();
const startedAt = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const code = await httpGet(BACKEND_HEALTH_URL);
const code = await httpGet(url);
if (code >= 200 && code < 500) return;
} catch {}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${BACKEND_HEALTH_URL}`);
throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${url}`);
}
await new Promise((r) => setTimeout(r, 300));
@@ -1051,6 +1163,63 @@ function registerWindowIpc() {
}
});
ipcMain.handle("backend:getPort", () => {
try {
return getBackendPort();
} catch (err) {
logMain(`[main] backend:getPort failed: ${err?.message || err}`);
return DEFAULT_BACKEND_PORT;
}
});
ipcMain.handle("backend:setPort", async (_event, port) => {
if (backendPortChangeInProgress) throw new Error("端口切换中,请稍后重试");
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改端口;请设置 WECHAT_TOOL_PORT 环境变量后重启");
}
const nextPort = parsePort(port);
if (nextPort == null) throw new Error("端口无效,请输入 1-65535 的整数");
const prevPort = getBackendPort();
if (nextPort === prevPort) {
return { success: true, changed: false, port: prevPort, uiUrl: getBackendUiUrl() };
}
const bindHost = getBackendBindHost();
const ok = await isPortAvailable(nextPort, bindHost);
if (!ok) throw new Error(`端口 ${nextPort} 已被占用,请换一个端口`);
backendPortChangeInProgress = true;
try {
setBackendPortSetting(nextPort);
try {
await restartBackend({ timeoutMs: 30_000 });
} catch (err) {
// Roll back to the previous port so the UI can keep working.
setBackendPortSetting(prevPort);
try {
await restartBackend({ timeoutMs: 30_000 });
} catch {}
throw err;
}
const uiUrl = getBackendUiUrl();
setTimeout(() => {
try {
if (!mainWindow || mainWindow.isDestroyed()) return;
void loadWithRetry(mainWindow, uiUrl);
} catch (err) {
logMain(`[main] failed to reload UI after backend port change: ${err?.message || err}`);
}
}, 50);
return { success: true, changed: true, port: nextPort, uiUrl };
} finally {
backendPortChangeInProgress = false;
}
});
ipcMain.handle("app:getVersion", () => {
try {
return app.getVersion();
@@ -1134,7 +1303,7 @@ async function main() {
const startUrl =
process.env.ELECTRON_START_URL ||
(app.isPackaged ? `http://${BACKEND_HOST}:${BACKEND_PORT}/` : "http://localhost:3000");
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
await loadWithRetry(win, startUrl);
+3
View File
@@ -14,6 +14,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
getBackendPort: () => ipcRenderer.invoke("backend:getPort"),
setBackendPort: (port) => ipcRenderer.invoke("backend:setPort", Number(port)),
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Auto update
+6 -1
View File
@@ -3,12 +3,14 @@
<SidebarRail v-if="showSidebar" />
<div class="flex-1 flex flex-col min-h-0">
<!-- Desktop titlebar lives above the page content (right column) -->
<DesktopTitleBar />
<DesktopTitleBar v-if="showDesktopTitleBar" />
<div :class="contentClass">
<NuxtPage />
</div>
</div>
<SettingsDialog :open="settingsDialogOpen" @close="closeSettingsDialog" />
<ClientOnly v-if="isDesktopUpdater">
<DesktopUpdateDialog
:open="desktopUpdate.open.value"
@@ -33,6 +35,7 @@ import { usePrivacyStore } from '~/stores/privacy'
const route = useRoute()
const desktopUpdate = useDesktopUpdate()
const { open: settingsDialogOpen, closeDialog: closeSettingsDialog } = useSettingsDialog()
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
// If we render different DOM on server vs client, Vue hydration will keep the
@@ -87,6 +90,8 @@ const contentClass = computed(() =>
: 'flex-1 overflow-auto min-h-0'
)
const showDesktopTitleBar = computed(() => isDesktop.value)
const showSidebar = computed(() => {
const path = String(route.path || '')
if (path === '/') return false
+616
View File
@@ -0,0 +1,616 @@
<template>
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<span class="text-[14px] font-bold text-[#1f1f1f]">设置</span>
</div>
<div class="flex-1 space-y-0.5 px-3 py-2 overflow-y-auto scrollbar-custom">
<button
v-for="item in settingNavItems"
:key="item.key"
type="button"
class="group flex w-full flex-col items-start rounded-[6px] px-3 py-1.5 text-left transition select-none"
:class="activeSection === item.key ? 'bg-white shadow-sm ring-1 ring-[#e5e5e5]' : 'hover:bg-[#f0f0f0]/60'"
@click="scrollToSection(item.key)"
>
<div class="text-[12px] font-medium" :class="activeSection === item.key ? 'text-[#111]' : 'text-[#777] group-hover:text-[#333]'">
{{ item.label }}
</div>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="relative flex min-w-0 flex-1 flex-col bg-white">
<button
type="button"
class="absolute right-3 top-3 z-10 flex h-6 w-6 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
title="关闭设置"
@click="handleClose"
>
<svg class="h-[14px] w-[14px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<header class="flex h-12 shrink-0 items-center px-6">
<div class="flex items-center gap-1.5 text-[#111]">
<svg class="h-[15px] w-[15px] text-[#666]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h2 class="text-[13px] font-bold">{{ settingNavItems.find(i => i.key === activeSection)?.label || '设置' }}</h2>
</div>
</header>
<div ref="contentScrollRef" class="scrollbar-custom flex-1 overflow-y-auto px-6 pb-8 pt-1 space-y-8" @scroll="onContentScroll">
<div v-if="!isDesktopEnv" class="rounded-[6px] border border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] leading-relaxed text-amber-900">
当前为浏览器环境开机自启动/关闭窗口/更新 不可用启动偏好可正常使用后端端口会尝试同步重启本机后端到新端口
</div>
<section ref="desktopSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
{{ desktopCloseBehaviorError }}
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端重启内置后端并刷新网页端尝试切换端口</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<input
v-model="desktopBackendPortInput"
type="number"
min="1"
max="65535"
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@keyup.enter="onDesktopBackendPortApply"
/>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortApply"
>
{{ desktopBackendPortApplying ? '...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopBackendPortError }}
</div>
</div>
</section>
<section ref="startupSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开实时开关</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoRealtime"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoRealtime)"
@click="toggleDesktopAutoRealtime"
>
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时打开应用跳转到 /chat</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopDefaultToChatWhenData"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopDefaultToChatWhenData)"
@click="toggleDesktopDefaultToChat"
>
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
</section>
<section ref="updatesSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</section>
<section ref="snsSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
<div class="mt-0.5 text-[11px] text-[#909090]">开启下载解密失败时回退本地缓存默认关闭始终重新下载</div>
</div>
<button
type="button"
role="switch"
:aria-checked="snsUseCache"
class="settings-switch shrink-0"
:class="switchTrackClass(snsUseCache)"
@click="toggleSnsUseCache"
>
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</section>
</div>
</main>
</div>
</div>
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
defineProps({
open: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const settingNavItems = [
{ key: 'desktop', label: '桌面行为', hint: '启动 / 关闭 / 端口' },
{ key: 'startup', label: '启动偏好', hint: '自动实时 / 默认页面' },
{ key: 'updates', label: '更新', hint: '版本信息 / 检查更新' },
{ key: 'sns', label: '朋友圈', hint: '图片缓存策略' },
]
const activeSection = ref(settingNavItems[0].key)
const contentScrollRef = ref(null)
const desktopSectionRef = ref(null)
const startupSectionRef = ref(null)
const updatesSectionRef = ref(null)
const snsSectionRef = ref(null)
const isDesktopEnv = ref(false)
const desktopUpdate = useDesktopUpdate()
const desktopVersionText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopUpdate.currentVersion.value || '').trim()
return v || '—'
})
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const snsUseCache = ref(true)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
const desktopAutoLaunchError = ref('')
const desktopCloseBehavior = ref('tray')
const desktopCloseBehaviorLoading = ref(false)
const desktopCloseBehaviorError = ref('')
const desktopBackendPortInput = ref('')
const desktopBackendPortLoading = ref(false)
const desktopBackendPortApplying = ref(false)
const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const switchTrackClass = (enabled, disabled = false) => {
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
}
const sectionElements = computed(() => [
{ key: 'desktop', el: desktopSectionRef.value },
{ key: 'startup', el: startupSectionRef.value },
{ key: 'updates', el: updatesSectionRef.value },
{ key: 'sns', el: snsSectionRef.value },
])
const scrollToSection = (key) => {
const scrollHost = contentScrollRef.value
const target = sectionElements.value.find((item) => item.key === key)?.el
activeSection.value = key
if (!scrollHost || !target) return
scrollHost.scrollTo({
top: Math.max(0, target.offsetTop - 10),
behavior: 'smooth',
})
}
const onContentScroll = () => {
const scrollHost = contentScrollRef.value
if (!scrollHost) return
const position = scrollHost.scrollTop + 120
let current = settingNavItems[0].key
for (const section of sectionElements.value) {
if (!section.el) continue
if (section.el.offsetTop <= position) current = section.key
}
activeSection.value = current
}
const handleClose = () => {
emit('close')
}
const onEscKeydown = (event) => {
if (event?.key !== 'Escape') return
event.preventDefault()
handleClose()
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
} catch (e) {
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
} finally {
desktopAutoLaunchLoading.value = false
}
}
const setDesktopAutoLaunch = async (enabled) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
} catch (e) {
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
await refreshDesktopAutoLaunch()
} finally {
desktopAutoLaunchLoading.value = false
}
}
const refreshDesktopCloseBehavior = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getCloseBehavior) return
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.getCloseBehavior()
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const setDesktopCloseBehavior = async (behavior) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setCloseBehavior) return
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.setCloseBehavior(desired)
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
await refreshDesktopCloseBehavior()
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const refreshDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
desktopBackendPortLoading.value = true
desktopBackendPortError.value = ''
try {
if (window.wechatDesktop?.getBackendPort) {
const v = await window.wechatDesktop.getBackendPort()
const n = Number(v)
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
desktopBackendPortInput.value = String(n)
return
}
}
try {
const apiBase = useApiBase()
const resp = await $fetch('/admin/port', { baseURL: apiBase })
const n = Number(resp?.port)
const d = Number(resp?.default_port)
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
desktopBackendPortInput.value = String(n)
return
}
} catch {}
let detectedPort = null
const override = readApiBaseOverride()
if (override && /^https?:\/\//i.test(override)) {
try {
const u = new URL(override)
const n = Number(u.port)
if (Number.isInteger(n) && n >= 1 && n <= 65535) detectedPort = n
} catch {}
}
if (!desktopBackendPortInput.value) desktopBackendPortInput.value = String(detectedPort ?? 10392)
} catch (e) {
desktopBackendPortError.value = e?.message || '读取后端端口失败'
} finally {
desktopBackendPortLoading.value = false
}
}
const applyDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
const raw = String(desktopBackendPortInput.value || '').trim()
const n = Number(raw)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
desktopBackendPortError.value = '端口无效:请输入 1-65535 的整数'
return
}
desktopBackendPortApplying.value = true
desktopBackendPortError.value = ''
try {
if (window.wechatDesktop?.setBackendPort) {
await window.wechatDesktop.setBackendPort(n)
return
}
const currentApiBase = useApiBase()
let currentBackendPort = null
try {
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
const p = Number(info?.port)
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
} catch {}
const uiPort = (() => {
const rawPort = String(window.location?.port || '').trim()
if (rawPort) return Number(rawPort)
return window.location?.protocol === 'https:' ? 443 : 80
})()
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
await $fetch('/admin/port', {
baseURL: currentApiBase,
method: 'POST',
body: { port: n },
})
let protocol = String(window.location?.protocol || 'http:')
if (protocol !== 'http:' && protocol !== 'https:') protocol = 'http:'
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
const nextOrigin = `${protocol}//${host}:${n}`
writeApiBaseOverride(`${nextOrigin}/api`)
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
const startedAt = Date.now()
while (true) {
try {
const r = await fetch(healthUrl, { method: 'GET' })
if (r && r.status < 500) return
} catch {}
if (Date.now() - startedAt > timeoutMs) throw new Error(`后端启动超时:${healthUrl}`)
await new Promise((r) => setTimeout(r, 300))
}
}
await waitForHealth(`${nextOrigin}/api/health`, 30_000)
if (isUiServedByBackend) {
const nextUrl = new URL(window.location.href)
nextUrl.port = String(n)
window.location.href = nextUrl.toString()
return
}
try {
window.location.reload()
} catch {}
} catch (e) {
desktopBackendPortError.value = e?.message || '设置后端端口失败(若为网页端,请确认后端为本机启动且允许重启)'
await refreshDesktopBackendPort()
} finally {
desktopBackendPortApplying.value = false
}
}
const toggleDesktopAutoLaunch = async () => {
if (!isDesktopEnv.value || desktopAutoLaunchLoading.value) return
await setDesktopAutoLaunch(!desktopAutoLaunch.value)
}
const onDesktopCloseBehaviorChange = async (ev) => {
const v = String(ev?.target?.value || '').trim()
await setDesktopCloseBehavior(v)
}
const onDesktopBackendPortApply = async () => {
await applyDesktopBackendPort()
}
const onDesktopBackendPortReset = async () => {
desktopBackendPortInput.value = String(desktopBackendPortDefault.value || 10392)
await applyDesktopBackendPort()
}
const toggleDesktopAutoRealtime = () => {
const next = !desktopAutoRealtime.value
desktopAutoRealtime.value = next
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, next)
}
const toggleDesktopDefaultToChat = () => {
const next = !desktopDefaultToChatWhenData.value
desktopDefaultToChatWhenData.value = next
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, next)
}
const toggleSnsUseCache = () => {
const next = !snsUseCache.value
snsUseCache.value = next
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, next)
}
const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
isDesktopEnv.value = isElectron && !!window.wechatDesktop
window.addEventListener('keydown', onEscKeydown)
}
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
await refreshDesktopBackendPort()
if (isDesktopEnv.value) {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
await nextTick()
onContentScroll()
})
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onEscKeydown)
})
</script>
<style scoped>
.settings-switch {
width: 44px;
height: 24px;
border-radius: 999px;
padding: 2px;
transition: background-color 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
}
.settings-switch-thumb {
display: block;
height: 20px;
width: 20px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
transition: transform 0.16s ease;
}
/* 自定义右侧滚动条 */
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 8px;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
</style>
+6 -7
View File
@@ -171,7 +171,7 @@
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSettingsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -201,17 +201,18 @@ const { privacyMode } = storeToRefs(privacyStore)
const realtimeStore = useChatRealtimeStore()
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
onMounted(async () => {
await chatAccounts.ensureLoaded()
})
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
const apiBase = useApiBase()
const selfAvatarUrl = computed(() => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return ''
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
})
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
@@ -219,8 +220,6 @@ const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const isSettingsRoute = computed(() => route.path?.startsWith('/settings'))
const goChat = async () => {
await navigateTo('/chat')
}
@@ -241,8 +240,8 @@ const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goSettings = async () => {
await navigateTo('/settings')
const goSettings = () => {
openSettingsDialog()
}
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
+14
View File
@@ -0,0 +1,14 @@
import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings'
export const useApiBase = () => {
const config = useRuntimeConfig()
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
// Override priority:
// 1) Local UI setting (web + desktop)
// 2) NUXT_PUBLIC_API_BASE env/runtime config
// 3) `/api`
const override = process.client ? readApiBaseOverride() : ''
const runtime = String(config?.public?.apiBase || '').trim()
return normalizeApiBase(override || runtime || '/api')
}
+17
View File
@@ -0,0 +1,17 @@
export const useSettingsDialog = () => {
const open = useState('settings-dialog-open', () => false)
const openDialog = () => {
open.value = true
}
const closeDialog = () => {
open.value = false
}
return {
open,
openDialog,
closeDialog,
}
}
+5 -2
View File
@@ -1,4 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392'
const devProxyTarget = `http://127.0.0.1:${backendPort}/api`
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
@@ -6,7 +9,7 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
// Full API base, including `/api` when needed.
// Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api`
// Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:10392/api`
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
},
},
@@ -22,7 +25,7 @@ export default defineNuxtConfig({
'/api': {
// `h3` strips the matched prefix (`/api`) before calling the middleware,
// so the proxy target must include `/api` to preserve backend routes.
target: 'http://127.0.0.1:8000/api',
target: devProxyTarget,
changeOrigin: true
}
}
-282
View File
@@ -1,282 +0,0 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="flex-1 min-h-0 overflow-auto p-6">
<div class="max-w-3xl mx-auto">
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 bg-[#F7F7F7]">
<div class="text-lg font-semibold text-gray-900">设置</div>
<div class="text-sm text-gray-500 mt-1">桌面端相关行为与启动偏好</div>
</div>
<div class="p-5 space-y-5">
<div v-if="!isDesktopEnv" class="rounded-md border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-xs leading-5">
当前为浏览器环境桌面行为分组仅桌面端可用启动偏好分组可正常使用
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">桌面行为</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">开机自启动</div>
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoLaunch"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@change="onDesktopAutoLaunchToggle"
/>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
<div class="text-xs text-gray-500">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="text-sm px-2 py-1 rounded-md border border-gray-200 bg-white"
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">启动偏好</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
<div class="text-xs text-gray-500">进入聊天页后自动打开实时开关默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoRealtime"
@change="onDesktopAutoRealtimeToggle"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
<div class="text-xs text-gray-500">有已解密账号时打开应用默认跳转到 /chat默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopDefaultToChatWhenData"
@change="onDesktopDefaultToChatToggle"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">更新</div>
</div>
<div class="px-4 py-3 space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">当前版本</div>
<div class="text-xs text-gray-500">
{{ desktopVersionText }}
</div>
</div>
<button
type="button"
class="text-sm px-3 py-1.5 rounded-md border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="text-xs text-gray-600 whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">朋友圈</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">朋友圈图片使用缓存</div>
<div class="text-xs text-gray-500">开启下载解密失败时回退本地缓存默认开启关闭每次都走下载+解密</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="snsUseCache"
@change="onSnsUseCacheToggle"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
useHead({ title: '设置 - 微信数据分析助手' })
const isDesktopEnv = ref(false)
const desktopUpdate = useDesktopUpdate()
const desktopVersionText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopUpdate.currentVersion.value || '').trim()
return v || '—'
})
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const snsUseCache = ref(true)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
const desktopAutoLaunchError = ref('')
const desktopCloseBehavior = ref('tray')
const desktopCloseBehaviorLoading = ref(false)
const desktopCloseBehaviorError = ref('')
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
} catch (e) {
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
} finally {
desktopAutoLaunchLoading.value = false
}
}
const setDesktopAutoLaunch = async (enabled) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
} catch (e) {
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
await refreshDesktopAutoLaunch()
} finally {
desktopAutoLaunchLoading.value = false
}
}
const refreshDesktopCloseBehavior = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getCloseBehavior) return
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.getCloseBehavior()
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const setDesktopCloseBehavior = async (behavior) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setCloseBehavior) return
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.setCloseBehavior(desired)
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
await refreshDesktopCloseBehavior()
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const onDesktopAutoLaunchToggle = async (ev) => {
const checked = !!ev?.target?.checked
await setDesktopAutoLaunch(checked)
}
const onDesktopCloseBehaviorChange = async (ev) => {
const v = String(ev?.target?.value || '').trim()
await setDesktopCloseBehavior(v)
}
const onDesktopAutoRealtimeToggle = (ev) => {
const checked = !!ev?.target?.checked
desktopAutoRealtime.value = checked
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
}
const onDesktopDefaultToChatToggle = (ev) => {
const checked = !!ev?.target?.checked
desktopDefaultToChatWhenData.value = checked
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
}
const onSnsUseCacheToggle = (ev) => {
const checked = !!ev?.target?.checked
snsUseCache.value = checked
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked)
}
const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
isDesktopEnv.value = isElectron && !!window.wechatDesktop
}
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
if (isDesktopEnv.value) {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
})
</script>
+35
View File
@@ -0,0 +1,35 @@
export const API_BASE_OVERRIDE_KEY = 'ui.apiBaseOverride'
export const readApiBaseOverride = () => {
if (!process.client) return ''
try {
const raw = localStorage.getItem(API_BASE_OVERRIDE_KEY)
return String(raw || '').trim()
} catch {
return ''
}
}
export const writeApiBaseOverride = (value) => {
if (!process.client) return
try {
const v = String(value || '').trim()
if (!v) localStorage.removeItem(API_BASE_OVERRIDE_KEY)
else localStorage.setItem(API_BASE_OVERRIDE_KEY, v)
} catch {}
}
export const normalizeApiBase = (value) => {
const raw = String(value || '').trim()
if (!raw) return '/api'
let v = raw.replace(/\/$/, '')
// If a full origin is provided, auto-append `/api` when missing.
if (/^https?:\/\//i.test(v) && !/\/api$/i.test(v)) {
v = `${v}/api`
}
return v.replace(/\/$/, '')
}
+9 -2
View File
@@ -5,23 +5,30 @@
使用方法:
uv run main.py
默认在8000端口启动API服务
默认在10392端口启动API服务
"""
import uvicorn
import os
from pathlib import Path
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
def main():
"""启动微信解密工具API服务"""
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
port, port_source = read_effective_backend_port(default=10392)
access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
print("=" * 60)
print("微信解密工具 API 服务")
print("=" * 60)
print("正在启动服务...")
if port_source == "env":
print("端口来源: 环境变量 WECHAT_TOOL_PORT")
elif port_source == "settings":
print("端口来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
else:
print("端口来源: 默认值")
print(f"API文档: http://{access_host}:{port}/docs")
print(f"健康检查: http://{access_host}:{port}/api/health")
print("按 Ctrl+C 停止服务")
+5 -1
View File
@@ -20,6 +20,7 @@ from .routers.chat_export import router as _chat_export_router
from .routers.chat_media import router as _chat_media_router
from .routers.decrypt import router as _decrypt_router
from .routers.health import router as _health_router
from .routers.admin import router as _admin_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
@@ -75,6 +76,7 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
app.include_router(_health_router)
app.include_router(_admin_router)
app.include_router(_wechat_detection_router)
app.include_router(_decrypt_router)
app.include_router(_keys_router)
@@ -192,6 +194,8 @@ async def _shutdown_wcdb_realtime() -> None:
if __name__ == "__main__":
import uvicorn
from .runtime_settings import read_effective_backend_port
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
port, _ = read_effective_backend_port(default=10392)
uvicorn.run(app, host=host, port=port)
+2 -1
View File
@@ -9,11 +9,12 @@ import os
import uvicorn
from wechat_decrypt_tool.api import app
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
def main() -> None:
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
port, _ = read_effective_backend_port(default=10392)
uvicorn.run(app, host=host, port=port, log_level="info")
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import asyncio
import ipaddress
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException
from starlette.requests import Request
from ..path_fix import PathFixRoute
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
router = APIRouter(route_class=PathFixRoute)
DEFAULT_BACKEND_PORT = 10392
_PORT_CHANGE_IN_PROGRESS = False
def _format_host_for_url(host: str) -> str:
h = str(host or "").strip() or "127.0.0.1"
if ":" in h and not (h.startswith("[") and h.endswith("]")):
return f"[{h}]"
return h
def _get_backend_bind_host() -> str:
return str(os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") or "").strip() or "127.0.0.1"
def _get_backend_access_host() -> str:
host = _get_backend_bind_host()
if host in {"0.0.0.0", "::"}:
return "127.0.0.1"
return host
def _is_loopback_client(request: Request) -> bool:
client = request.client
host = str(getattr(client, "host", "") or "").strip()
if not host:
return False
try:
ip = ipaddress.ip_address(host)
if ip.is_loopback:
return True
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped and ip.ipv4_mapped.is_loopback:
return True
except ValueError:
if host.lower() == "localhost":
return True
return False
def _is_port_available(port: int, host: str) -> bool:
try:
addr = (host, int(port))
family = socket.AF_INET6 if ":" in host else socket.AF_INET
with socket.socket(family, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
s.bind(addr)
return True
except Exception:
return False
async def _wait_for_backend_ready(health_url: str, timeout_s: float = 30.0) -> bool:
started = time.time()
async with httpx.AsyncClient(timeout=1.0) as client:
while time.time() - started < timeout_s:
try:
resp = await client.get(health_url)
if resp.status_code < 500:
return True
except Exception:
pass
await asyncio.sleep(0.3)
return False
def _spawn_backend_process(next_port: int) -> subprocess.Popen:
env = os.environ.copy()
env["WECHAT_TOOL_PORT"] = str(int(next_port))
env.setdefault("WECHAT_TOOL_HOST", _get_backend_bind_host())
# Keep the same working directory so output paths remain consistent.
# (When `WECHAT_TOOL_DATA_DIR` is not set, the app uses `Path.cwd()`.)
cwd = os.getcwd()
cwd_path = Path(cwd)
# Ensure local imports work when running from source (repo root + src layout).
src_dir = cwd_path / "src"
try:
existing_pp = str(env.get("PYTHONPATH", "") or "").strip()
if src_dir.is_dir():
env["PYTHONPATH"] = str(src_dir) if not existing_pp else f"{src_dir}{os.pathsep}{existing_pp}"
except Exception:
pass
if getattr(sys, "frozen", False):
cmd = [sys.executable]
spawn_cwd = cwd
else:
main_py = cwd_path / "main.py"
if main_py.is_file():
cmd = [sys.executable, str(main_py)]
spawn_cwd = cwd
else:
cmd = [sys.executable, "-m", "wechat_decrypt_tool.backend_entry"]
spawn_cwd = cwd
return subprocess.Popen(cmd, cwd=spawn_cwd, env=env)
async def _exit_process_after(delay_s: float) -> None:
try:
await asyncio.sleep(max(0.0, float(delay_s)))
except Exception:
pass
os._exit(0) # noqa: S404
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
async def get_backend_port() -> dict:
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
return {"port": port, "source": source, "default_port": DEFAULT_BACKEND_PORT}
@router.post("/api/admin/port", summary="修改后端端口并重启(仅允许本机访问)")
async def set_backend_port(payload: dict, request: Request, background_tasks: BackgroundTasks) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
global _PORT_CHANGE_IN_PROGRESS
if _PORT_CHANGE_IN_PROGRESS:
raise HTTPException(status_code=409, detail="端口切换中,请稍后重试")
raw = payload.get("port") if isinstance(payload, dict) else None
try:
next_port = int(raw)
except Exception:
raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数")
if next_port < 1 or next_port > 65535:
raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数")
current_port, _ = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
if next_port == int(current_port):
write_backend_port_setting(next_port)
env_file = write_backend_port_env_file(next_port)
host = _format_host_for_url(_get_backend_access_host())
return {
"success": True,
"changed": False,
"port": next_port,
"ui_url": f"http://{host}:{next_port}/",
"env_file": str(env_file) if env_file else None,
}
bind_host = _get_backend_bind_host()
if not _is_port_available(next_port, bind_host):
raise HTTPException(status_code=409, detail=f"端口 {next_port} 已被占用,请换一个端口")
proc = None
_PORT_CHANGE_IN_PROGRESS = True
try:
try:
proc = _spawn_backend_process(next_port)
except Exception as e:
raise HTTPException(status_code=500, detail=f"启动新后端进程失败:{e}")
access_host = _get_backend_access_host()
health_url = f"http://{_format_host_for_url(access_host)}:{next_port}/api/health"
ok = await _wait_for_backend_ready(health_url, timeout_s=30.0)
if not ok:
try:
if proc and proc.poll() is None:
proc.terminate()
except Exception:
pass
raise HTTPException(status_code=500, detail=f"新端口启动超时:{health_url}")
# Persist only after the new backend is confirmed ready.
write_backend_port_setting(next_port)
env_file = write_backend_port_env_file(next_port)
background_tasks.add_task(_exit_process_after, 0.2)
host = _format_host_for_url(access_host)
return {
"success": True,
"changed": True,
"port": next_port,
"ui_url": f"http://{host}:{next_port}/",
"env_file": str(env_file) if env_file else None,
}
finally:
_PORT_CHANGE_IN_PROGRESS = False
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
RUNTIME_SETTINGS_FILENAME = "runtime_settings.json"
BACKEND_PORT_KEY = "backend_port"
ENV_PORT_KEY = "WECHAT_TOOL_PORT"
ENV_FILE_KEY = "WECHAT_TOOL_ENV_FILE"
DEFAULT_ENV_FILENAME = ".env"
def _parse_port(value: object) -> int | None:
if value is None:
return None
try:
raw = str(value).strip()
except Exception:
return None
if not raw:
return None
try:
port = int(raw, 10)
except Exception:
return None
if port < 1 or port > 65535:
return None
return port
def get_runtime_settings_path() -> Path:
from .app_paths import get_output_dir
return get_output_dir() / RUNTIME_SETTINGS_FILENAME
def read_backend_port_setting() -> int | None:
path = get_runtime_settings_path()
try:
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8") or "{}")
if not isinstance(data, dict):
return None
return _parse_port(data.get(BACKEND_PORT_KEY))
except Exception:
return None
def write_backend_port_setting(port: int | None) -> None:
path = get_runtime_settings_path()
safe_port = _parse_port(port)
try:
path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
return
try:
data: dict = {}
if path.is_file():
try:
existing = json.loads(path.read_text(encoding="utf-8") or "{}")
if isinstance(existing, dict):
data = existing
except Exception:
data = {}
if safe_port is None:
data.pop(BACKEND_PORT_KEY, None)
else:
data[BACKEND_PORT_KEY] = safe_port
# Keep the file small and stable; remove if empty.
if not data:
try:
path.unlink(missing_ok=True)
except Exception:
pass
return
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
return
def read_effective_backend_port(default: int) -> tuple[int, str]:
"""Return (port, source) where source is one of: env | settings | default."""
env_raw = str(os.environ.get("WECHAT_TOOL_PORT", "") or "").strip()
env_port = _parse_port(env_raw)
if env_port is not None:
return env_port, "env"
settings_port = read_backend_port_setting()
if settings_port is not None:
return settings_port, "settings"
return int(default), "default"
def get_env_file_path() -> Path | None:
"""Best-effort env file path for `uv run` (defaults to repo root `.env`)."""
v = str(os.environ.get(ENV_FILE_KEY, "") or "").strip()
if v:
try:
return Path(v)
except Exception:
return None
cwd = Path.cwd()
# Heuristic: only write `.env` in a project root (avoid polluting random dirs).
try:
if (cwd / "pyproject.toml").is_file():
return cwd / DEFAULT_ENV_FILENAME
except Exception:
return None
return None
def _set_env_var_in_file(env_file: Path, key: str, value: str | None) -> bool:
try:
env_file.parent.mkdir(parents=True, exist_ok=True)
except Exception:
return False
pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=")
try:
raw = env_file.read_text(encoding="utf-8") if env_file.is_file() else ""
except Exception:
raw = ""
lines = raw.splitlines(keepends=True) if raw else []
out: list[str] = []
replaced = False
for line in lines:
if pattern.match(line):
if value is None:
continue
if not replaced:
out.append(f"{key}={value}\n")
replaced = True
continue
out.append(line)
if value is not None and not replaced:
if out and not out[-1].endswith("\n"):
out[-1] = out[-1] + "\n"
out.append(f"{key}={value}\n")
try:
env_file.write_text("".join(out), encoding="utf-8")
return True
except Exception:
return False
def write_backend_port_env_file(port: int | None) -> Path | None:
"""Write `WECHAT_TOOL_PORT` into a `.env` file so `uv run main.py` picks it up on restart.
Note: `uv` doesn't override already-set env vars; `.env` only applies when the variable is not
present in the current shell/session.
"""
env_file = get_env_file_path()
if not env_file:
return None
safe_port = _parse_port(port)
ok = _set_env_var_in_file(env_file, ENV_PORT_KEY, str(safe_port) if safe_port is not None else None)
return env_file if ok else None