feat: Support electron desktop mode 新增Electron Windows桌面端打包配置 (#264)

* feat: Support `electron` desktop mode

* fix: remove package-lock.json
This commit is contained in:
Krane
2026-02-06 20:25:17 +08:00
committed by GitHub
parent 981fdd7310
commit 2ac96674fe
10 changed files with 741 additions and 2 deletions

279
apps/dsa-desktop/main.js Normal file
View File

@@ -0,0 +1,279 @@
const { app, BrowserWindow, shell } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const net = require('net');
const http = require('http');
let mainWindow = null;
let backendProcess = null;
let logFilePath = null;
const isWindows = process.platform === 'win32';
const appRootDev = path.resolve(__dirname, '..', '..');
function resolveEnvExamplePath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, '.env.example');
}
return path.join(appRootDev, '.env.example');
}
function resolveAppDir() {
if (app.isPackaged) {
// exe 所在目录
return path.dirname(app.getPath('exe'));
}
return app.getPath('userData');
}
function resolveBackendPath() {
if (process.env.DSA_BACKEND_PATH) {
return process.env.DSA_BACKEND_PATH;
}
if (app.isPackaged) {
const backendDir = path.join(process.resourcesPath, 'backend');
const exeName = isWindows ? 'stock_analysis.exe' : 'stock_analysis';
return path.join(backendDir, exeName);
}
return null;
}
function initLogging() {
const appDir = app.isPackaged ? path.dirname(app.getPath('exe')) : app.getPath('userData');
logFilePath = path.join(appDir, 'logs', 'desktop.log');
// 确保日志目录存在
const logDir = path.dirname(logFilePath);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
logLine('Desktop app starting');
}
function logLine(message) {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${message}\n`;
try {
if (logFilePath) {
fs.appendFileSync(logFilePath, line, 'utf-8');
}
} catch (error) {
console.error(error);
}
console.log(line.trim());
}
function resolvePythonPath() {
return process.env.DSA_PYTHON || 'python';
}
function ensureEnvFile(envPath) {
if (fs.existsSync(envPath)) {
return;
}
const envExample = resolveEnvExamplePath();
if (fs.existsSync(envExample)) {
fs.copyFileSync(envExample, envPath);
return;
}
fs.writeFileSync(envPath, '# Configure your API keys and stock list here.\n', 'utf-8');
}
function findAvailablePort(startPort = 8000, endPort = 8100) {
return new Promise((resolve, reject) => {
const tryPort = (port) => {
if (port > endPort) {
reject(new Error('No available port'));
return;
}
const server = net.createServer();
server.once('error', () => {
tryPort(port + 1);
});
server.once('listening', () => {
server.close(() => resolve(port));
});
server.listen(port, '127.0.0.1');
};
tryPort(startPort);
});
}
function waitForHealth(url, timeoutMs = 60000, intervalMs = 800) {
const start = Date.now();
return new Promise((resolve, reject) => {
const attempt = () => {
const req = http.get(url, (res) => {
res.resume();
if (res.statusCode === 200) {
resolve();
return;
}
if (Date.now() - start > timeoutMs) {
reject(new Error(`Health check failed: ${res.statusCode}`));
return;
}
setTimeout(attempt, intervalMs);
});
req.on('error', () => {
if (Date.now() - start > timeoutMs) {
reject(new Error('Health check timeout'));
return;
}
setTimeout(attempt, intervalMs);
});
};
attempt();
});
}
function startBackend({ port, envFile, dbPath, logDir }) {
const backendPath = resolveBackendPath();
const env = {
...process.env,
ENV_FILE: envFile,
DATABASE_PATH: dbPath,
LOG_DIR: logDir,
PYTHONUTF8: '1',
SCHEDULE_ENABLED: 'false',
WEBUI_ENABLED: 'false',
};
const args = ['--serve-only', '--host', '127.0.0.1', '--port', String(port)];
if (backendPath) {
if (!fs.existsSync(backendPath)) {
throw new Error(`Backend executable not found: ${backendPath}`);
}
backendProcess = spawn(backendPath, args, {
env,
cwd: path.dirname(backendPath),
stdio: 'pipe',
windowsHide: true,
});
} else {
const pythonPath = resolvePythonPath();
const scriptPath = path.join(appRootDev, 'main.py');
backendProcess = spawn(pythonPath, [scriptPath, ...args], {
env,
cwd: appRootDev,
stdio: 'pipe',
windowsHide: true,
});
}
if (backendProcess) {
backendProcess.stdout.on('data', (data) => {
logLine(`[backend] ${String(data).trim()}`);
});
backendProcess.stderr.on('data', (data) => {
logLine(`[backend] ${String(data).trim()}`);
});
backendProcess.on('exit', (code) => {
logLine(`[backend] exited with code ${code}`);
});
}
}
function stopBackend() {
if (!backendProcess || backendProcess.killed) {
return;
}
if (isWindows) {
spawn('taskkill', ['/PID', String(backendProcess.pid), '/T', '/F']);
return;
}
backendProcess.kill('SIGTERM');
setTimeout(() => {
if (!backendProcess.killed) {
backendProcess.kill('SIGKILL');
}
}, 3000);
}
async function createWindow() {
initLogging();
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 960,
minHeight: 640,
backgroundColor: '#0f172a',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
});
const loadingPath = path.join(__dirname, 'renderer', 'loading.html');
await mainWindow.loadFile(loadingPath);
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
const appDir = resolveAppDir();
const envPath = path.join(appDir, '.env');
ensureEnvFile(envPath);
const port = await findAvailablePort(8000, 8100);
logLine(`Using port ${port}`);
logLine(`ENV_FILE=${envPath}`);
logLine(`App directory=${appDir}`);
const dbPath = path.join(appDir, 'data', 'stock_analysis.db');
const logDir = path.join(appDir, 'logs');
try {
startBackend({ port, envFile: envPath, dbPath, logDir });
} catch (error) {
logLine(String(error));
const errorUrl = `file://${loadingPath}?error=${encodeURIComponent(String(error))}`;
await mainWindow.loadURL(errorUrl);
return;
}
const healthUrl = `http://127.0.0.1:${port}/api/health`;
try {
await waitForHealth(healthUrl);
await mainWindow.loadURL(`http://127.0.0.1:${port}/`);
} catch (error) {
logLine(String(error));
const errorUrl = `file://${loadingPath}?error=${encodeURIComponent(String(error))}`;
await mainWindow.loadURL(errorUrl);
}
}
app.whenReady().then(createWindow);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.on('window-all-closed', () => {
stopBackend();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
stopBackend();
});

View File

@@ -0,0 +1,39 @@
{
"name": "daily-stock-analysis-desktop",
"version": "0.1.0",
"private": true,
"main": "main.js",
"scripts": {
"dev": "electron .",
"build": "electron-builder"
},
"devDependencies": {
"electron": "^31.4.0",
"electron-builder": "^24.13.3"
},
"build": {
"appId": "com.daily-stock-analysis.desktop",
"productName": "Daily Stock Analysis",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"renderer/**/*"
],
"extraResources": [
{
"from": "../../.env.example",
"to": ".env.example"
},
{
"from": "../../dist/backend/stock_analysis.exe",
"to": "backend/stock_analysis.exe"
}
],
"win": {
"target": "portable"
}
}
}

View File

@@ -0,0 +1,5 @@
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('dsaDesktop', {
version: '0.1.0',
});

View File

@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Daily Stock Analysis</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Fira Sans", "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, #1e293b, #0f172a 55%);
color: var(--text);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.panel {
width: min(640px, 90vw);
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 16px;
padding: 32px 36px;
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.35);
}
.title {
font-size: 24px;
margin: 0 0 8px;
letter-spacing: 0.4px;
}
.subtitle {
margin: 0 0 24px;
color: var(--muted);
font-size: 14px;
}
.status {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--muted);
}
.error {
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fecaca;
font-size: 12px;
line-height: 1.5;
display: none;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(56, 189, 248, 0.8);
animation: pulse 1.5s ease-in-out infinite;
}
.tips {
margin-top: 20px;
font-size: 12px;
color: rgba(148, 163, 184, 0.8);
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.4);
opacity: 0.6;
}
}
</style>
</head>
<body>
<div class="panel">
<h1 class="title">Daily Stock Analysis</h1>
<p class="subtitle">Launching local analysis service</p>
<div class="status">
<span class="dot"></span>
<span>Preparing backend and loading UI...</span>
</div>
<p class="tips">If this takes long, check your API keys and network connectivity.</p>
<div class="error" id="errorBox"></div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (!error) return;
const box = document.getElementById('errorBox');
if (!box) return;
box.textContent = `Error: ${decodeURIComponent(error)}`;
box.style.display = 'block';
})();
</script>
</body>
</html>

144
docs/desktop-package.md Normal file
View File

@@ -0,0 +1,144 @@
# 桌面端打包说明 (Electron + React UI)
本项目可打包为桌面应用,使用 Electron 作为桌面壳,`apps/dsa-web` 的 React UI 作为界面。
## 架构说明
- React UIVite 构建)由本地 FastAPI 服务托管
- Electron 启动时自动拉起后端服务,等待 `/api/health` 就绪后加载 UI
- 用户配置文件 `.env` 和数据库放在 exe 同级目录(便携模式)
## 本地开发
一键启动(开发模式):
```bash
powershell -ExecutionPolicy Bypass -File scripts\run-desktop.ps1
```
或手动执行:
1) 构建 React UI输出到 `static/`
```bash
cd apps/dsa-web
npm install
npm run build
```
2) 启动 Electron 应用(自动拉起后端)
```bash
cd apps/dsa-desktop
npm install
npm run dev
```
首次运行时会自动从 `.env.example` 复制生成 `.env`
## 打包 (Windows)
### 前置条件
- Node.js 18+
- Python 3.10+
- 开启 Windows 开发者模式electron-builder 需要创建符号链接)
- 设置 -> 隐私和安全性 -> 开发者选项 -> 开发者模式
### 一键打包
```bash
powershell -ExecutionPolicy Bypass -File scripts\build-all.ps1
```
该脚本会依次执行:
1. 构建 React UI
2. 安装 Python 依赖
3. PyInstaller 打包后端
4. electron-builder 打包桌面应用
### 分步打包
1) 构建 React UI
```bash
cd apps/dsa-web
npm install
npm run build
```
2) 打包 Python 后端
```bash
pip install pyinstaller
pip install -r requirements.txt
pyinstaller --name stock_analysis --onefile --noconsole --add-data "static;static" main.py
```
将生成的 exe 复制到 `dist/backend/`
```bash
mkdir dist\backend
copy dist\stock_analysis.exe dist\backend\stock_analysis.exe
```
3) 打包 Electron 桌面应用
```bash
cd apps/dsa-desktop
npm install
npm run build
```
打包产物位于 `apps/dsa-desktop/dist/`
## 目录结构
打包后用户拿到的目录结构(便携模式):
```
win-unpacked/
Daily Stock Analysis.exe <- 双击启动
.env <- 用户配置文件(首次启动自动生成)
data/
stock_analysis.db <- 数据库
logs/
desktop.log <- 运行日志
resources/
.env.example <- 配置模板
backend/
stock_analysis.exe <- 后端服务
```
## 配置文件说明
- `.env` 放在 exe 同目录下
- 首次启动时自动从 `.env.example` 复制生成
- 用户需要编辑 `.env` 配置以下内容:
- `GEMINI_API_KEY``OPENAI_API_KEY`AI 分析必需
- `STOCK_LIST`:自选股列表(逗号分隔)
- 其他可选配置参考 `.env.example`
## 常见问题
### 启动后一直显示 "Preparing backend..."
1. 检查 `logs/desktop.log` 查看错误信息
2. 确认 `.env` 文件存在且配置正确
3. 确认端口 8000-8100 未被占用
### 后端启动报 ModuleNotFoundError
PyInstaller 打包时缺少模块,需要在 `scripts/build-backend.ps1` 中增加 `--hidden-import`
### UI 加载空白
确认 `static/index.html` 存在,如不存在需重新构建 React UI。
## 分发给用户
`apps/dsa-desktop/dist/win-unpacked/` 整个文件夹打包发给用户即可。用户只需:
1. 解压文件夹
2. 编辑 `.env` 配置 API Key 和股票列表
3. 双击 `Daily Stock Analysis.exe` 启动

8
scripts/build-all.ps1 Normal file
View File

@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
Write-Host '=== Daily Stock Analysis Desktop Build ==='
& "${PSScriptRoot}\build-backend.ps1"
& "${PSScriptRoot}\build-desktop.ps1"
Write-Host 'All builds completed.'

66
scripts/build-backend.ps1 Normal file
View File

@@ -0,0 +1,66 @@
$ErrorActionPreference = 'Stop'
Write-Host 'Building React UI (static assets)...'
Push-Location 'apps\dsa-web'
if (!(Test-Path 'node_modules')) {
npm install
}
npm run build
Pop-Location
Write-Host 'Building backend executable...'
if (!(Get-Command pyinstaller -ErrorAction SilentlyContinue)) {
python -m pip install pyinstaller
}
Write-Host 'Installing backend dependencies...'
python -m pip install -r requirements.txt
if (Test-Path 'dist\backend') {
Remove-Item -Recurse -Force 'dist\backend'
}
New-Item -ItemType Directory -Path 'dist\backend' | Out-Null
$hiddenImports = @(
'json_repair',
'api',
'api.app',
'api.deps',
'api.v1',
'api.v1.router',
'api.v1.endpoints',
'api.v1.endpoints.analysis',
'api.v1.endpoints.history',
'api.v1.endpoints.stocks',
'api.v1.endpoints.health',
'api.v1.schemas',
'api.v1.schemas.analysis',
'api.v1.schemas.history',
'api.v1.schemas.stocks',
'api.v1.schemas.common',
'api.middlewares',
'api.middlewares.error_handler',
'src.services',
'src.services.task_queue',
'src.services.analysis_service',
'src.services.history_service',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on'
)
$hiddenImportArgs = ($hiddenImports | ForEach-Object { "--hidden-import=$_" }) -join ' '
$cmd = "pyinstaller --name stock_analysis --onefile --noconsole --add-data `"static;static`" $hiddenImportArgs main.py"
Write-Host "Running: $cmd"
Invoke-Expression $cmd
Copy-Item -Path 'dist\stock_analysis.exe' -Destination 'dist\backend\stock_analysis.exe' -Force
Write-Host 'Backend build completed.'

54
scripts/build-desktop.ps1 Normal file
View File

@@ -0,0 +1,54 @@
$ErrorActionPreference = 'Stop'
$devModeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
$allowDev = 0
$allowTrusted = 0
if (Test-Path $devModeKey) {
$props = Get-ItemProperty -Path $devModeKey -ErrorAction SilentlyContinue
if ($null -ne $props) {
$allowDev = $props.AllowDevelopmentWithoutDevLicense
$allowTrusted = $props.AllowAllTrustedApps
}
}
if (($allowDev -ne 1) -and ($allowTrusted -ne 1)) {
Write-Host 'Developer Mode is disabled. Enable it to allow symlink creation for electron-builder.'
Write-Host 'Windows Settings -> Privacy & security -> For developers -> Developer Mode'
throw 'Developer Mode required for electron-builder cache extraction.'
}
$env:CSC_IDENTITY_AUTO_DISCOVERY = 'false'
$env:ELECTRON_BUILDER_ALLOW_UNRESOLVED_SYMLINKS = 'true'
$env:ELECTRON_BUILDER_CACHE = "${PSScriptRoot}\..\.electron-builder-cache"
Write-Host 'Building Electron desktop app...'
Push-Location 'apps\dsa-desktop'
if (!(Test-Path 'node_modules')) {
npm install
}
Write-Host 'Stopping running app (if any)...'
Get-Process -Name "Daily Stock Analysis" -ErrorAction SilentlyContinue | Stop-Process -Force
Get-Process -Name "stock_analysis" -ErrorAction SilentlyContinue | Stop-Process -Force
if (Test-Path 'dist\win-unpacked') {
Write-Host 'Cleaning dist\win-unpacked...'
Remove-Item -Recurse -Force 'dist\win-unpacked'
}
$appBuilderPath = 'node_modules\app-builder-bin\win\x64\app-builder.exe'
if (!(Test-Path $appBuilderPath)) {
Write-Host 'app-builder.exe missing, reinstalling dependencies...'
if (Test-Path 'node_modules') {
Remove-Item -Recurse -Force 'node_modules'
}
npm install
}
npm run build
if ($LASTEXITCODE -ne 0) {
throw 'Electron build failed.'
}
Pop-Location
Write-Host 'Desktop build completed.'

17
scripts/run-desktop.ps1 Normal file
View File

@@ -0,0 +1,17 @@
$ErrorActionPreference = 'Stop'
Write-Host 'Building React UI (static assets)...'
Push-Location 'apps\dsa-web'
if (!(Test-Path 'node_modules')) {
npm install
}
npm run build
Pop-Location
Write-Host 'Starting Electron desktop (dev mode)...'
Push-Location 'apps\dsa-desktop'
if (!(Test-Path 'node_modules')) {
npm install
}
npm run dev
Pop-Location

View File

@@ -20,7 +20,11 @@ from dataclasses import dataclass, field
def setup_env():
"""初始化环境变量(支持从 .env 加载)"""
# src/config.py -> src/ -> root
env_path = Path(__file__).parent.parent / '.env'
env_file = os.getenv("ENV_FILE")
if env_file:
env_path = Path(env_file)
else:
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
@@ -431,7 +435,8 @@ class Config:
"""
# 优先从 .env 文件读取最新配置,这样即使在容器环境中修改了 .env 文件,
# 也能获取到最新的股票列表配置
env_path = Path(__file__).parent.parent / '.env'
env_file = os.getenv("ENV_FILE")
env_path = Path(env_file) if env_file else (Path(__file__).parent.parent / '.env')
stock_list_str = ''
if env_path.exists():
# 直接从 .env 文件读取最新的配置