mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
feat: Support electron desktop mode 新增Electron Windows桌面端打包配置 (#264)
* feat: Support `electron` desktop mode * fix: remove package-lock.json
This commit is contained in:
279
apps/dsa-desktop/main.js
Normal file
279
apps/dsa-desktop/main.js
Normal 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();
|
||||
});
|
||||
39
apps/dsa-desktop/package.json
Normal file
39
apps/dsa-desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
apps/dsa-desktop/preload.js
Normal file
5
apps/dsa-desktop/preload.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { contextBridge } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('dsaDesktop', {
|
||||
version: '0.1.0',
|
||||
});
|
||||
122
apps/dsa-desktop/renderer/loading.html
Normal file
122
apps/dsa-desktop/renderer/loading.html
Normal 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
144
docs/desktop-package.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 桌面端打包说明 (Electron + React UI)
|
||||
|
||||
本项目可打包为桌面应用,使用 Electron 作为桌面壳,`apps/dsa-web` 的 React UI 作为界面。
|
||||
|
||||
## 架构说明
|
||||
|
||||
- React UI(Vite 构建)由本地 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
8
scripts/build-all.ps1
Normal 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
66
scripts/build-backend.ps1
Normal 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
54
scripts/build-desktop.ps1
Normal 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
17
scripts/run-desktop.ps1
Normal 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
|
||||
@@ -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 文件读取最新的配置
|
||||
|
||||
Reference in New Issue
Block a user