mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(settings): 设置弹窗支持后端端口切换(桌面/网页)
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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(/\/$/, '')
|
||||
}
|
||||
|
||||
@@ -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 停止服务")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user