feat(desktop): 新增 Electron 桌面端瘦客户端(封装 ui/ + 可配置后端反代)

This commit is contained in:
2026-10-14 09:44:50 +08:00
parent ac07cb2008
commit 2408def7c1
9 changed files with 439 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 桌面端本地产物(不入库)
node_modules/
renderer-dist/
dist/
+40
View File
@@ -0,0 +1,40 @@
# PlantAI 桌面端(Electron 瘦客户端)
把 `ui/` 的 Vue 单页应用封装成 Electron 桌面 App。**后端(Python/BFF/向量库)不打包进 App**,桌面端只是一个指向后端的瘦客户端。
## 原理
- Electron 主进程内起一个**只监听 `127.0.0.1`** 的本地 HTTP 服务(`server.js`):
- 静态托管 `renderer-dist`(即 `ui/dist` 的副本);
- 把 `/api` 反代到**可配置的后端地址**。
- 前端源码 `ui/` **零改动**、后端 **CORS 零改动**:相对路径 `/api` 与 SSE 流靠同源天然可用。
- 后端地址配置(`config.js`)优先级:`PLANTAI_BFF_URL` 环境变量 > `userData/config.json` > 默认 `http://127.0.0.1:8090`。可在应用内菜单「设置 → 后端地址…」修改(保存后重载)。
## 环境要求
- Node.js ≥ 18、npm。
## 开发运行
```bash
cd desktop
npm install
npm run build:ui # 先构建前端(ui/)并复制产物到 renderer-dist/
npm start # 启动 Electron 窗口
```
## 打包
```bash
cd desktop
npm run dist:linux # AppImage + deb
npm run dist:win # NSIS 安装包 + portable
npm run dist:mac # dmg + zip(必须在 macOS 上构建)
```
## 跨平台注意事项
- **macOS 安装包必须在 macOS 机器上构建**(或 macOS CI);Linux 无法产出 dmg。
- **Windows 可从 Linux 交叉打包**,个别步骤(图标/rcedit)可能依赖 wine;更稳的方式是用 CI 三平台矩阵产出。
- 应用图标:`build/icon.png`(512×512,PNG)。electron-builder 会据此自动派生 `.ico`/`.icns`;未提供时使用默认 Electron 图标。
- **后端可达性**:桌面端要能访问配置的后端地址(公网 HTTPS / VPN / SSH 隧道均可),属网络环境问题,非代码范围。
+39
View File
@@ -0,0 +1,39 @@
// config.js —— 后端地址配置的读写(主进程)。
// 优先级:环境变量 PLANTAI_BFF_URL > userData/config.json > 默认 http://127.0.0.1:8090。
const fs = require('fs');
const path = require('path');
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8090';
function configPath(userDataDir) {
return path.join(userDataDir, 'config.json');
}
function normalizeBackendUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function loadConfig(userDataDir) {
const envUrl = normalizeBackendUrl(process.env.PLANTAI_BFF_URL);
if (envUrl) return { backendUrl: envUrl, source: 'env' };
try {
const raw = fs.readFileSync(configPath(userDataDir), 'utf8');
const cfg = JSON.parse(raw);
const fileUrl = normalizeBackendUrl(cfg && cfg.backendUrl);
if (fileUrl) return { backendUrl: fileUrl, source: 'file' };
} catch (_e) {
// 无配置文件或解析失败 → 落到默认
}
return { backendUrl: DEFAULT_BACKEND_URL, source: 'default' };
}
function saveConfig(userDataDir, backendUrl) {
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(
configPath(userDataDir),
JSON.stringify({ backendUrl: normalizeBackendUrl(backendUrl) }, null, 2),
'utf8'
);
}
module.exports = { loadConfig, saveConfig, DEFAULT_BACKEND_URL };
+155
View File
@@ -0,0 +1,155 @@
// main.js —— Electron 主进程(瘦客户端壳)。
// 启动流程:读后端地址配置 → 起本地静态 + /api 反代服务器(仅监听 127.0.0.1)→ 建窗口加载。
// 前端 ui/ 源码零改动:相对路径 /api 与 SSE 流靠「同源」天然可用,后端 CORS 也无需改。
const { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const { createServer } = require('./server');
const { loadConfig, saveConfig } = require('./config');
let mainWindow = null;
let settingsWindow = null;
let server = null;
let currentOrigin = null;
/** 前端构建产物目录:开发态在 desktop/renderer-dist,打包态在 resources/renderer-dist(extraResources)。 */
function staticDir() {
return app.isPackaged
? path.join(process.resourcesPath, 'renderer-dist')
: path.join(__dirname, 'renderer-dist');
}
function startServer(backendUrl) {
return new Promise((resolve, reject) => {
const srv = createServer({ staticDir: staticDir(), backendUrl });
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
server = srv;
resolve(`http://127.0.0.1:${srv.address().port}`);
});
});
}
function stopServer() {
if (server) {
server.close();
server = null;
}
}
async function restartServer(backendUrl) {
stopServer();
currentOrigin = await startServer(backendUrl);
return currentOrigin;
}
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 960,
minHeight: 640,
title: '海古纪 PlantAI',
backgroundColor: '#0f1115',
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
mainWindow.loadURL(currentOrigin);
mainWindow.once('ready-to-show', () => mainWindow.show());
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function openSettingsWindow() {
if (settingsWindow) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({
width: 460,
height: 300,
resizable: false,
minimizable: false,
maximizable: false,
title: '后端地址设置',
parent: mainWindow || undefined,
modal: !!mainWindow,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
settingsWindow.loadFile(path.join(__dirname, 'settings.html'));
settingsWindow.on('closed', () => {
settingsWindow = null;
});
}
function buildMenu() {
const template = [
{
label: '设置',
submenu: [
{ label: '后端地址…', click: openSettingsWindow },
{ type: 'separator' },
{ role: 'reload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'quit', label: '退出' },
],
},
];
if (process.platform === 'darwin') {
template.unshift({
label: app.name,
submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }],
});
}
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
app.whenReady().then(async () => {
buildMenu();
const dir = staticDir();
if (!fs.existsSync(path.join(dir, 'index.html'))) {
dialog.showErrorBox(
'缺少前端构建产物',
`未找到 ${dir}\n\n请先构建前端:\n cd ui && npm run build\n cd desktop && npm run build:ui`
);
app.quit();
return;
}
ipcMain.handle('desktop:get-backend-url', () => loadConfig(app.getPath('userData')).backendUrl);
ipcMain.handle('desktop:set-backend-url', async (_event, url) => {
// 用户显式保存后,文件配置优先于环境变量(否则 env 会一直盖住用户设置)
delete process.env.PLANTAI_BFF_URL;
saveConfig(app.getPath('userData'), url);
await restartServer(loadConfig(app.getPath('userData')).backendUrl);
if (mainWindow) await mainWindow.loadURL(currentOrigin);
return loadConfig(app.getPath('userData')).backendUrl;
});
const { backendUrl } = loadConfig(app.getPath('userData'));
currentOrigin = await startServer(backendUrl);
createMainWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('will-quit', stopServer);
+53
View File
@@ -0,0 +1,53 @@
{
"name": "plantai-desktop",
"version": "0.1.0",
"description": "海古纪 PlantAI 桌面端(Electron 瘦客户端,前端 ui/ + 可配置后端反代)",
"private": true,
"main": "main.js",
"scripts": {
"build:ui": "npm --prefix ../ui run build && node scripts/copy-ui.js",
"start": "electron .",
"dist": "npm run build:ui && electron-builder",
"dist:linux": "npm run build:ui && electron-builder --linux",
"dist:win": "npm run build:ui && electron-builder --win",
"dist:mac": "npm run build:ui && electron-builder --mac"
},
"dependencies": {
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "com.haiguji.plantai",
"productName": "PlantAI",
"files": [
"**/*",
"!renderer-dist/**"
],
"extraResources": [
{ "from": "renderer-dist", "to": "renderer-dist" }
],
"directories": {
"output": "dist"
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Science",
"maintainer": "haiguji"
},
"win": {
"target": ["nsis", "portable"]
},
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.productivity"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// preload.js —— 设置窗口的桥接:仅暴露最小 API,不暴露任何 Node 能力。
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('desktopApi', {
getBackendUrl: () => ipcRenderer.invoke('desktop:get-backend-url'),
setBackendUrl: (url) => ipcRenderer.invoke('desktop:set-backend-url', url),
});
+15
View File
@@ -0,0 +1,15 @@
// scripts/copy-ui.js —— 把 ui/dist 复制到 renderer-dist(打包/运行前准备)。
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, '..', '..', 'ui', 'dist');
const dest = path.join(__dirname, '..', 'renderer-dist');
if (!fs.existsSync(path.join(src, 'index.html'))) {
console.error(`未找到前端构建产物:${src}\n请先运行: cd ui && npm run build`);
process.exit(1);
}
fs.rmSync(dest, { recursive: true, force: true });
fs.cpSync(src, dest, { recursive: true });
console.log(`已复制 ${src} -> ${dest}`);
+50
View File
@@ -0,0 +1,50 @@
// server.js —— 主进程内嵌的本地 HTTP 服务:
// 静态托管 renderer-dist(前端构建产物)+ 把 /api 反代到可配置后端。
// 仅监听 127.0.0.1,不做公网暴露。反代参数对齐 deploy/nginx/plantai.conf:
// SSE 流(问答/生信)不缓冲、长超时由后端自控,大文件上传不受限。
const path = require('path');
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
function createServer({ staticDir, backendUrl }) {
const app = express();
app.use(
'/api',
createProxyMiddleware({
target: backendUrl,
changeOrigin: true,
ws: false,
xfwd: true,
// http-proxy 侧不设响应/套接字超时(SSE 看门狗 1200s / 上传 600s 由后端自断)
proxyTimeout: 0,
timeout: 0,
on: {
error: (err, req, res) => {
// 后端不可达时返回结构化 502 信封(对齐前端 http.js 的错误契约),避免进程崩溃。
// 注意:http-proxy 的 error 事件里 res 可能是 net.Socket(连接失败),需防御式判断。
if (!res || typeof res.writeHead !== 'function') return;
if (res.headersSent) return res.end();
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(
JSON.stringify({
ok: false,
error: { code: 'NETWORK', message: '后端不可达', degraded: false },
})
);
},
},
})
);
// 静态托管 + SPA fallback(前端无 vue-router,单页;仍兜底到 index.html)
app.use(express.static(staticDir));
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
res.sendFile(path.join(staticDir, 'index.html'));
});
return app;
}
module.exports = { createServer };
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>后端地址设置</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1115;
color: #e6e8eb;
font-size: 14px;
user-select: none;
}
h1 { font-size: 15px; margin: 0 0 8px; font-weight: 600; }
p.desc { color: #9aa0a6; margin: 0 0 14px; font-size: 12px; line-height: 1.5; }
input {
width: 100%;
box-sizing: border-box;
padding: 9px 10px;
font-size: 13px;
color: #e6e8eb;
background: #1a1d23;
border: 1px solid #2a2f38;
border-radius: 6px;
outline: none;
}
input:focus { border-color: #3b82f6; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
button {
padding: 7px 16px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #2a2f38;
cursor: pointer;
background: #1a1d23;
color: #e6e8eb;
}
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
button:hover { filter: brightness(1.1); }
p.hint { color: #9aa0a6; font-size: 12px; margin: 10px 0 0; min-height: 16px; }
</style>
</head>
<body>
<h1>后端地址</h1>
<p class="desc">PlantAI 后端(BFF)地址,例如 http://127.0.0.1:8090。保存后立即重载页面。</p>
<input id="url" type="text" placeholder="http://127.0.0.1:8090" spellcheck="false">
<div class="actions">
<button id="cancel">取消</button>
<button id="save" class="primary">保存</button>
</div>
<p id="hint" class="hint"></p>
<script>
const input = document.getElementById('url');
const hint = document.getElementById('hint');
window.desktopApi.getBackendUrl().then((url) => { input.value = url || ''; });
document.getElementById('save').addEventListener('click', async () => {
const url = input.value.trim();
if (!url) { hint.textContent = '地址不能为空'; return; }
try {
await window.desktopApi.setBackendUrl(url);
window.close();
} catch (e) {
hint.textContent = '保存失败:' + (e && e.message ? e.message : e);
}
});
document.getElementById('cancel').addEventListener('click', () => window.close());
</script>
</body>
</html>