Skip to content

Commit 512d5ef

Browse files
committed
fix(dev-runtime): 统一开发端口并优化页面初始化
- 新增 dev 启动脚本,自动分配前后端端口并等待 Nuxt 就绪后再启动 Electron - 开发模式忽略持久化 API 覆盖,统一 Nuxt 与桌面端端口配置 - API 健康检查改为挂载后执行,聊天页预取改为 lazy,并隐藏搜索区账号切换
1 parent 5d165ae commit 512d5ef

7 files changed

Lines changed: 218 additions & 13 deletions

File tree

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "1.3.0",
55
"main": "src/main.cjs",
66
"scripts": {
7-
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
7+
"dev": "node scripts/dev.cjs",
88
"dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .",
99
"build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs",
1010
"build:backend": "uv sync --extra build && node scripts/build-backend.cjs",

desktop/scripts/dev.cjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
const http = require("http");
2+
const net = require("net");
3+
const path = require("path");
4+
const { spawn, spawnSync } = require("child_process");
5+
6+
const repoRoot = path.resolve(__dirname, "..", "..");
7+
const frontendDir = path.join(repoRoot, "frontend");
8+
const desktopDir = path.join(repoRoot, "desktop");
9+
10+
function parsePort(value) {
11+
const n = Number.parseInt(String(value || "").trim(), 10);
12+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
13+
}
14+
15+
function log(message) {
16+
process.stdout.write(`[dev] ${message}\n`);
17+
}
18+
19+
function prefixPipe(stream, prefix) {
20+
if (!stream) return;
21+
let pending = "";
22+
stream.setEncoding("utf8");
23+
stream.on("data", (chunk) => {
24+
pending += chunk;
25+
const lines = pending.split(/\r?\n/);
26+
pending = lines.pop() || "";
27+
for (const line of lines) {
28+
process.stdout.write(`${prefix} ${line}\n`);
29+
}
30+
});
31+
stream.on("end", () => {
32+
const tail = pending.trim();
33+
if (tail) process.stdout.write(`${prefix} ${tail}\n`);
34+
});
35+
}
36+
37+
function isPortAvailable(port, host) {
38+
return new Promise((resolve) => {
39+
const server = net.createServer();
40+
const done = (ok) => {
41+
try {
42+
server.close();
43+
} catch {}
44+
resolve(ok);
45+
};
46+
server.once("error", () => done(false));
47+
server.once("listening", () => done(true));
48+
server.listen(port, host);
49+
});
50+
}
51+
52+
async function choosePort({ label, envName, preferredPort, host, searchLimit = 20 }) {
53+
if (preferredPort != null) {
54+
const ok = await isPortAvailable(preferredPort, host);
55+
if (!ok) throw new Error(`${label}端口 ${preferredPort} 已被占用,请修改环境变量 ${envName}`);
56+
return preferredPort;
57+
}
58+
59+
const startPort = envName === "NUXT_PORT" ? 3000 : 10392;
60+
for (let port = startPort; port <= startPort + searchLimit; port += 1) {
61+
if (await isPortAvailable(port, host)) return port;
62+
}
63+
throw new Error(`未找到可用的${label}端口(起始 ${startPort})`);
64+
}
65+
66+
function httpReady(url) {
67+
return new Promise((resolve) => {
68+
const req = http.get(url, (res) => {
69+
res.resume();
70+
resolve(true);
71+
});
72+
req.on("error", () => resolve(false));
73+
req.setTimeout(1000, () => {
74+
req.destroy();
75+
resolve(false);
76+
});
77+
});
78+
}
79+
80+
async function waitForUrl(url, child, timeoutMs) {
81+
const startedAt = Date.now();
82+
while (Date.now() - startedAt < timeoutMs) {
83+
if (child.exitCode != null) {
84+
throw new Error(`前端进程提前退出,exitCode=${child.exitCode}`);
85+
}
86+
if (await httpReady(url)) return;
87+
await new Promise((resolve) => setTimeout(resolve, 300));
88+
}
89+
throw new Error(`等待前端启动超时:${url}`);
90+
}
91+
92+
function killChild(child) {
93+
if (!child || child.killed || child.exitCode != null) return;
94+
if (process.platform === "win32") {
95+
spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
96+
return;
97+
}
98+
try {
99+
child.kill("SIGTERM");
100+
} catch {}
101+
}
102+
103+
function spawnLogged(command, args, options, prefix) {
104+
const child = spawn(command, args, {
105+
...options,
106+
shell: process.platform === "win32",
107+
stdio: ["inherit", "pipe", "pipe"],
108+
});
109+
prefixPipe(child.stdout, `${prefix}`);
110+
prefixPipe(child.stderr, `${prefix}`);
111+
return child;
112+
}
113+
114+
async function main() {
115+
const frontendHost = String(process.env.NUXT_HOST || "127.0.0.1").trim() || "127.0.0.1";
116+
const requestedFrontendPort = parsePort(process.env.NUXT_PORT);
117+
const requestedBackendPort = parsePort(process.env.WECHAT_TOOL_PORT);
118+
const frontendPort = await choosePort({
119+
label: "前端",
120+
envName: "NUXT_PORT",
121+
preferredPort: requestedFrontendPort,
122+
host: frontendHost,
123+
});
124+
const backendPort = await choosePort({
125+
label: "后端",
126+
envName: "WECHAT_TOOL_PORT",
127+
preferredPort: requestedBackendPort,
128+
host: "127.0.0.1",
129+
});
130+
const startUrl = `http://${frontendHost}:${frontendPort}`;
131+
132+
log(`frontend=${startUrl}`);
133+
log(`backend=http://127.0.0.1:${backendPort}/api`);
134+
135+
const sharedEnv = {
136+
...process.env,
137+
NUXT_HOST: frontendHost,
138+
NUXT_PORT: String(frontendPort),
139+
WECHAT_TOOL_PORT: String(backendPort),
140+
ELECTRON_START_URL: startUrl,
141+
};
142+
143+
const npmCommand = "npm";
144+
const electronCommand = "electron";
145+
const children = new Set();
146+
let shuttingDown = false;
147+
148+
const shutdown = (exitCode) => {
149+
if (shuttingDown) return;
150+
shuttingDown = true;
151+
for (const child of children) killChild(child);
152+
process.exitCode = exitCode;
153+
};
154+
155+
process.on("SIGINT", () => shutdown(130));
156+
process.on("SIGTERM", () => shutdown(143));
157+
158+
const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]");
159+
children.add(frontend);
160+
frontend.once("exit", (code, signal) => {
161+
log(`frontend exited code=${code} signal=${signal}`);
162+
shutdown(code == null ? 1 : code);
163+
});
164+
165+
await waitForUrl(startUrl, frontend, 60_000);
166+
log("frontend is ready, starting Electron");
167+
168+
const electron = spawnLogged(electronCommand, ["."], { cwd: desktopDir, env: sharedEnv }, "[electron]");
169+
children.add(electron);
170+
electron.once("exit", (code, signal) => {
171+
log(`electron exited code=${code} signal=${signal}`);
172+
shutdown(code == null ? 0 : code);
173+
});
174+
}
175+
176+
main().catch((err) => {
177+
process.stderr.write(`[dev] ${err?.stack || err}\n`);
178+
process.exit(1);
179+
});

desktop/src/main.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ function getBackendAccessHost() {
8383
}
8484

8585
function getBackendPort() {
86+
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
87+
if (envPort != null) return envPort;
88+
// In dev we intentionally ignore persisted packaged-app settings so the
89+
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
90+
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
8691
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
8792
return settingsPort ?? DEFAULT_BACKEND_PORT;
8893
}

frontend/composables/useApiBase.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
44
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
55
let _clientCache = ''
66

7+
const shouldIgnoreStoredOverride = () => {
8+
if (!process.client || !import.meta.dev) return false
9+
return typeof window !== 'undefined' && !!window.wechatDesktop?.__brand
10+
}
11+
712
export const useApiBase = () => {
813
if (process.client && _clientCache) return _clientCache
914

@@ -23,7 +28,7 @@ export const useApiBase = () => {
2328
// 1) Local UI setting (web + desktop)
2429
// 2) NUXT_PUBLIC_API_BASE env/runtime config
2530
// 3) `/api`
26-
const override = process.client ? readApiBaseOverride() : ''
31+
const override = process.client && !shouldIgnoreStoredOverride() ? readApiBaseOverride() : ''
2732
const runtime = String(config?.public?.apiBase || '').trim()
2833
const result = normalizeApiBase(override || runtime || '/api')
2934

frontend/nuxt.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
// https://nuxt.com/docs/api/configuration/nuxt-config
2+
const frontendHost = String(process.env.NUXT_HOST || '').trim()
3+
const frontendPort = Number.parseInt(String(process.env.NUXT_PORT || process.env.PORT || '3000').trim(), 10)
24
const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392'
35
const devProxyTarget = `http://127.0.0.1:${backendPort}/api`
46

57
export default defineNuxtConfig({
68
compatibilityDate: '2025-07-15',
79
devtools: { enabled: false },
10+
experimental: {
11+
// This app does not use Nuxt route rules on the client, so disabling
12+
// the app manifest avoids an unnecessary `/_nuxt/builds/meta/dev.json`
13+
// preload request and the related Chrome warning in dev mode.
14+
appManifest: false,
15+
},
816

917
runtimeConfig: {
1018
public: {
@@ -16,7 +24,8 @@ export default defineNuxtConfig({
1624

1725
// 配置前端开发服务器端口
1826
devServer: {
19-
port: 3000
27+
...(frontendHost ? { host: frontendHost } : {}),
28+
port: Number.isInteger(frontendPort) && frontendPort >= 1 && frontendPort <= 65535 ? frontendPort : 3000
2029
},
2130

2231
// 配置API代理,解决跨域问题

frontend/pages/chat/[[username]].vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
</div>
207207

208208
<select
209+
v-if="showSearchAccountSwitcher"
209210
v-model="selectedAccount"
210211
@change="onAccountChange"
211212
class="account-select"
@@ -2537,6 +2538,7 @@ useHead({
25372538
})
25382539

25392540
const route = useRoute()
2541+
const showSearchAccountSwitcher = false
25402542

25412543
// Capture the API helper once in the synchronous setup scope.
25422544
// In Nuxt 4, useApi() → useApiBase() → useRuntimeConfig() requires the Nuxt
@@ -2721,7 +2723,7 @@ const { data: _prefetchedAccounts } = await useAsyncData('chat-accounts', () =>
27212723
return $fetch('/api/chat/accounts', { baseURL: `http://127.0.0.1:${port}` })
27222724
}
27232725
return $fetch('/chat/accounts', { baseURL: _apiBase })
2724-
}, { watch: false })
2726+
}, { watch: false, lazy: true })
27252727
if (_prefetchedAccounts.value?.accounts?.length && !chatAccounts.loaded) {
27262728
const resp = _prefetchedAccounts.value
27272729
chatAccounts.accounts = resp.accounts
@@ -2749,7 +2751,7 @@ const { data: _prefetchedSessions } = await useAsyncData(
27492751
}
27502752
return $fetch(`/chat/sessions?${params}`, { baseURL: _apiBase })
27512753
},
2752-
{ watch: false },
2754+
{ watch: false, lazy: true },
27532755
)
27542756
// Populate contacts from SSR-prefetched sessions so the list renders immediately.
27552757
// Deliberately omit avatar URLs during SSR to prevent the browser from flooding
Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// 客户端插件:检查API连接状态
2-
export default defineNuxtPlugin(async (nuxtApp) => {
2+
export default defineNuxtPlugin((nuxtApp) => {
33
const { healthCheck } = useApi()
44
const appStore = useAppStore()
5+
let intervalId = 0
56

67
// 检查API连接
78
const checkApiConnection = async () => {
@@ -17,10 +18,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
1718
console.error('API连接失败:', error)
1819
}
1920
}
20-
21-
// 初始检查
22-
await checkApiConnection()
23-
24-
// 定期检查(每30秒)
25-
setInterval(checkApiConnection, 30000)
26-
})
21+
22+
nuxtApp.hook('app:mounted', () => {
23+
void checkApiConnection()
24+
25+
if (!intervalId) {
26+
intervalId = window.setInterval(() => {
27+
void checkApiConnection()
28+
}, 30000)
29+
}
30+
})
31+
})

0 commit comments

Comments
 (0)