fix(dev-runtime): 统一开发端口并优化页面初始化

- 新增 dev 启动脚本,自动分配前后端端口并等待 Nuxt 就绪后再启动 Electron
- 开发模式忽略持久化 API 覆盖,统一 Nuxt 与桌面端端口配置
- API 健康检查改为挂载后执行,聊天页预取改为 lazy,并隐藏搜索区账号切换
This commit is contained in:
2977094657
2026-03-15 19:33:58 +08:00
Unverified
parent d2db3b1bab
commit b8d0912907
7 changed files with 218 additions and 13 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "1.3.0",
"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": "node scripts/dev.cjs",
"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",
+179
View File
@@ -0,0 +1,179 @@
const http = require("http");
const net = require("net");
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const repoRoot = path.resolve(__dirname, "..", "..");
const frontendDir = path.join(repoRoot, "frontend");
const desktopDir = path.join(repoRoot, "desktop");
function parsePort(value) {
const n = Number.parseInt(String(value || "").trim(), 10);
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
}
function log(message) {
process.stdout.write(`[dev] ${message}\n`);
}
function prefixPipe(stream, prefix) {
if (!stream) return;
let pending = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() || "";
for (const line of lines) {
process.stdout.write(`${prefix} ${line}\n`);
}
});
stream.on("end", () => {
const tail = pending.trim();
if (tail) process.stdout.write(`${prefix} ${tail}\n`);
});
}
function isPortAvailable(port, host) {
return new Promise((resolve) => {
const server = net.createServer();
const done = (ok) => {
try {
server.close();
} catch {}
resolve(ok);
};
server.once("error", () => done(false));
server.once("listening", () => done(true));
server.listen(port, host);
});
}
async function choosePort({ label, envName, preferredPort, host, searchLimit = 20 }) {
if (preferredPort != null) {
const ok = await isPortAvailable(preferredPort, host);
if (!ok) throw new Error(`${label}端口 ${preferredPort} 已被占用,请修改环境变量 ${envName}`);
return preferredPort;
}
const startPort = envName === "NUXT_PORT" ? 3000 : 10392;
for (let port = startPort; port <= startPort + searchLimit; port += 1) {
if (await isPortAvailable(port, host)) return port;
}
throw new Error(`未找到可用的${label}端口(起始 ${startPort}`);
}
function httpReady(url) {
return new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(true);
});
req.on("error", () => resolve(false));
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
});
}
async function waitForUrl(url, child, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (child.exitCode != null) {
throw new Error(`前端进程提前退出,exitCode=${child.exitCode}`);
}
if (await httpReady(url)) return;
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(`等待前端启动超时:${url}`);
}
function killChild(child) {
if (!child || child.killed || child.exitCode != null) return;
if (process.platform === "win32") {
spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
return;
}
try {
child.kill("SIGTERM");
} catch {}
}
function spawnLogged(command, args, options, prefix) {
const child = spawn(command, args, {
...options,
shell: process.platform === "win32",
stdio: ["inherit", "pipe", "pipe"],
});
prefixPipe(child.stdout, `${prefix}`);
prefixPipe(child.stderr, `${prefix}`);
return child;
}
async function main() {
const frontendHost = String(process.env.NUXT_HOST || "127.0.0.1").trim() || "127.0.0.1";
const requestedFrontendPort = parsePort(process.env.NUXT_PORT);
const requestedBackendPort = parsePort(process.env.WECHAT_TOOL_PORT);
const frontendPort = await choosePort({
label: "前端",
envName: "NUXT_PORT",
preferredPort: requestedFrontendPort,
host: frontendHost,
});
const backendPort = await choosePort({
label: "后端",
envName: "WECHAT_TOOL_PORT",
preferredPort: requestedBackendPort,
host: "127.0.0.1",
});
const startUrl = `http://${frontendHost}:${frontendPort}`;
log(`frontend=${startUrl}`);
log(`backend=http://127.0.0.1:${backendPort}/api`);
const sharedEnv = {
...process.env,
NUXT_HOST: frontendHost,
NUXT_PORT: String(frontendPort),
WECHAT_TOOL_PORT: String(backendPort),
ELECTRON_START_URL: startUrl,
};
const npmCommand = "npm";
const electronCommand = "electron";
const children = new Set();
let shuttingDown = false;
const shutdown = (exitCode) => {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) killChild(child);
process.exitCode = exitCode;
};
process.on("SIGINT", () => shutdown(130));
process.on("SIGTERM", () => shutdown(143));
const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]");
children.add(frontend);
frontend.once("exit", (code, signal) => {
log(`frontend exited code=${code} signal=${signal}`);
shutdown(code == null ? 1 : code);
});
await waitForUrl(startUrl, frontend, 60_000);
log("frontend is ready, starting Electron");
const electron = spawnLogged(electronCommand, ["."], { cwd: desktopDir, env: sharedEnv }, "[electron]");
children.add(electron);
electron.once("exit", (code, signal) => {
log(`electron exited code=${code} signal=${signal}`);
shutdown(code == null ? 0 : code);
});
}
main().catch((err) => {
process.stderr.write(`[dev] ${err?.stack || err}\n`);
process.exit(1);
});
+5
View File
@@ -83,6 +83,11 @@ function getBackendAccessHost() {
}
function getBackendPort() {
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
if (envPort != null) return envPort;
// In dev we intentionally ignore persisted packaged-app settings so the
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
return settingsPort ?? DEFAULT_BACKEND_PORT;
}
+6 -1
View File
@@ -4,6 +4,11 @@ import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
let _clientCache = ''
const shouldIgnoreStoredOverride = () => {
if (!process.client || !import.meta.dev) return false
return typeof window !== 'undefined' && !!window.wechatDesktop?.__brand
}
export const useApiBase = () => {
if (process.client && _clientCache) return _clientCache
@@ -23,7 +28,7 @@ export const useApiBase = () => {
// 1) Local UI setting (web + desktop)
// 2) NUXT_PUBLIC_API_BASE env/runtime config
// 3) `/api`
const override = process.client ? readApiBaseOverride() : ''
const override = process.client && !shouldIgnoreStoredOverride() ? readApiBaseOverride() : ''
const runtime = String(config?.public?.apiBase || '').trim()
const result = normalizeApiBase(override || runtime || '/api')
+10 -1
View File
@@ -1,10 +1,18 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
const frontendHost = String(process.env.NUXT_HOST || '').trim()
const frontendPort = Number.parseInt(String(process.env.NUXT_PORT || process.env.PORT || '3000').trim(), 10)
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 },
experimental: {
// This app does not use Nuxt route rules on the client, so disabling
// the app manifest avoids an unnecessary `/_nuxt/builds/meta/dev.json`
// preload request and the related Chrome warning in dev mode.
appManifest: false,
},
runtimeConfig: {
public: {
@@ -16,7 +24,8 @@ export default defineNuxtConfig({
// 配置前端开发服务器端口
devServer: {
port: 3000
...(frontendHost ? { host: frontendHost } : {}),
port: Number.isInteger(frontendPort) && frontendPort >= 1 && frontendPort <= 65535 ? frontendPort : 3000
},
// 配置API代理,解决跨域问题
+4 -2
View File
@@ -206,6 +206,7 @@
</div>
<select
v-if="showSearchAccountSwitcher"
v-model="selectedAccount"
@change="onAccountChange"
class="account-select"
@@ -2537,6 +2538,7 @@ useHead({
})
const route = useRoute()
const showSearchAccountSwitcher = false
// Capture the API helper once in the synchronous setup scope.
// In Nuxt 4, useApi() useApiBase() useRuntimeConfig() requires the Nuxt
@@ -2721,7 +2723,7 @@ const { data: _prefetchedAccounts } = await useAsyncData('chat-accounts', () =>
return $fetch('/api/chat/accounts', { baseURL: `http://127.0.0.1:${port}` })
}
return $fetch('/chat/accounts', { baseURL: _apiBase })
}, { watch: false })
}, { watch: false, lazy: true })
if (_prefetchedAccounts.value?.accounts?.length && !chatAccounts.loaded) {
const resp = _prefetchedAccounts.value
chatAccounts.accounts = resp.accounts
@@ -2749,7 +2751,7 @@ const { data: _prefetchedSessions } = await useAsyncData(
}
return $fetch(`/chat/sessions?${params}`, { baseURL: _apiBase })
},
{ watch: false },
{ watch: false, lazy: true },
)
// Populate contacts from SSR-prefetched sessions so the list renders immediately.
// Deliberately omit avatar URLs during SSR to prevent the browser from flooding
+13 -8
View File
@@ -1,7 +1,8 @@
// 客户端插件:检查API连接状态
export default defineNuxtPlugin(async (nuxtApp) => {
export default defineNuxtPlugin((nuxtApp) => {
const { healthCheck } = useApi()
const appStore = useAppStore()
let intervalId = 0
// 检查API连接
const checkApiConnection = async () => {
@@ -17,10 +18,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
console.error('API连接失败:', error)
}
}
// 初始检查
await checkApiConnection()
// 定期检查(每30秒)
setInterval(checkApiConnection, 30000)
})
nuxtApp.hook('app:mounted', () => {
void checkApiConnection()
if (!intervalId) {
intervalId = window.setInterval(() => {
void checkApiConnection()
}, 30000)
}
})
})