mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
feat(packaging): v1.19.1 支持 Windows 单 EXE 打包 + 系统托盘 + 自动发版
面向零基础老年用户,easy-tdx 可打包成单一 Windows EXE,双击即用。 新增: - 后端同源托管前端 dist(app.py 三级探测:env → _MEIPASS → web-ui/dist) - easy-tdx serve 默认 --open-browser,启动后自动开浏览器 - PyInstaller 打包入口(__main__.py)+ spec 配置(easy_tdx.spec) - 系统托盘(tray.py):右下角图标,右键"打开浏览器/退出" 解决老人不会用任务管理器关闭的问题 - GitHub Actions release.yml:打 v* tag 自动构建并发布 EXE 到 Releases - docs/packaging.md 打包使用文档 修复: - K 线残缺尾记录导致 500(security_bars.py):通达信服务器偶发 ret_count 与 body 长度不匹配,改为 try/except 优雅降级丢弃残缺尾, 返回已解析的完整记录。GetIndexBarsCmd 同改。加 4 个回归测试。 - PyInstaller frozen 模式三个坑: 1. console=False 下 stdout/stderr 为 None → 重定向到日志文件 2. multiprocessing spawn 子进程重新 import __main__ → freeze_support + 子进程检测 3. 系统托盘需主线程消息泵 → uvicorn 挪到后台线程 文档: - README/手册改为三档分流:EXE(零基础)/ Python(一条命令)/ 源码(打包) - 删除 npm run dev / 5173 / 两个终端的过时说明 - 手册补虚拟环境配置 + EXE 打包附录 + EXE 排错 FAQ
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""``python -m easy_tdx`` 入口 + PyInstaller 打包入口(``easy-tdx.exe``)。
|
||||
|
||||
**两种启动形态**:
|
||||
|
||||
- **开发态**(``python -m easy_tdx``):等价于 ``easy-tdx`` CLI,无托盘。
|
||||
- **打包态**(双击 EXE):uvicorn 后台线程 + 右下角系统托盘图标,老人右键
|
||||
→ 退出即可关闭,无需任务管理器。
|
||||
|
||||
**三个关键坑(PyInstaller frozen 模式)**:
|
||||
|
||||
1. **``console=False`` 下标准流为 None**:Windows GUI 子系统下
|
||||
``sys.stdout`` / ``sys.stderr`` 都是 ``None``。uvicorn 的
|
||||
``DefaultFormatter.__init__`` 会调 ``sys.stderr.isatty()``,对 ``None`` 取
|
||||
属性直接报 ``AttributeError: 'NoneType' object has no attribute 'isatty'``,
|
||||
导致启动即崩。解决:把 None 流重定向到家目录下的日志文件。
|
||||
|
||||
2. **``multiprocessing`` spawn 子进程会重新 import ``__main__``**:Windows
|
||||
下 ``ProcessPoolExecutor`` 用 spawn 方式启动子进程,子进程会重新执行
|
||||
``__main__`` 模块来重建执行环境。如果不拦截,子进程会再次执行 ``cli()``
|
||||
→ 又启动一个 uvicorn server → 子进程死循环 + 抢占端口 + 各种莫名其妙的
|
||||
错误。解决:(a) 调 ``freeze_support()``;(b) 在 ``__main__`` 里判断如果是
|
||||
子进程(argv 含 ``--multiprocessing-fork`` 等标记)就直接 return,不跑 CLI。
|
||||
一键寻优、screen scanner 等所有用多进程的功能都依赖这个保护。
|
||||
|
||||
3. **托盘需要主线程消息泵**:pystray 在 Windows 上需要主线程跑消息循环,
|
||||
而 uvicorn 的 ``server.run()`` 是阻塞调用。解决:打包态把 uvicorn 挪到
|
||||
后台线程,主线程跑托盘(见 ``_run_tray_server`` / ``easy_tdx.tray``)。
|
||||
开发态走原 CLI 路径,不引入托盘。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _is_multiprocessing_child() -> bool:
|
||||
"""判断当前进程是否为 multiprocessing fork 出来的子进程。
|
||||
|
||||
Windows spawn 模式下,子进程的 argv[0] 是父 EXE 路径,但会带特殊 flag:
|
||||
``--multiprocessing-fork``(带或不带 ``=``)或新版 Python 的
|
||||
``--mp-main`` / ``-c`` 等。检测到这些就说明本进程是子进程,不应启动 CLI。
|
||||
"""
|
||||
if len(sys.argv) < 2:
|
||||
return False
|
||||
first_arg = sys.argv[1]
|
||||
# multiprocessing 标准标记(不同 Python 版本略有差异)
|
||||
return first_arg.startswith("--multiprocessing-fork") or first_arg == "--mp-main"
|
||||
|
||||
|
||||
def _redirect_std_streams_to_log() -> None:
|
||||
"""``console=False`` 下把 None 的 stdout/stderr 重定向到日志文件。
|
||||
|
||||
PyInstaller ``--windowed``(Windows GUI 子系统)下 Python 的 sys.stdout /
|
||||
sys.stderr 为 ``None``。许多库(uvicorn/click/logging)假设它们存在,
|
||||
调 ``.isatty()`` 或 ``.write()`` 即崩。本函数把它们重定向到家目录下的
|
||||
``easy_tdx_runtime.log``,并设 ``PYTHONUNBUFFERED=1`` 保证日志实时落盘。
|
||||
|
||||
只在 ``sys.stdout is None``(打包态)时启用;开发态(有真实终端)不动。
|
||||
"""
|
||||
if sys.stdout is not None and sys.stderr is not None:
|
||||
return # 开发态:有真实终端,不重定向
|
||||
|
||||
# 落在 ~/.easy_tdx/,与 strategy_store 的 SQLite 同目录,便于一键收集诊断
|
||||
config_dir = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = config_dir / "easy_tdx_runtime.log"
|
||||
|
||||
# 用 "a" 追加而非覆盖:老人多次启动的日志都保留,便于复盘
|
||||
try:
|
||||
f = open(log_path, "a", encoding="utf-8", buffering=1) # noqa: SIM115
|
||||
except OSError:
|
||||
# 家目录不可写(极罕见),退化到 os.devnull——至少不崩
|
||||
f = open(os.devnull, "w", encoding="utf-8") # noqa: SIM115
|
||||
|
||||
if sys.stdout is None:
|
||||
sys.stdout = f
|
||||
if sys.stderr is None:
|
||||
sys.stderr = f
|
||||
|
||||
|
||||
def _run_tray_server() -> None:
|
||||
"""打包态双击启动专用:uvicorn(后台线程)+ 系统托盘(主线程)。
|
||||
|
||||
提取为函数避免 ``__main__`` 顶层 import 拖累开发态启动——pystray/Pillow
|
||||
只在打包态才需要。
|
||||
"""
|
||||
from easy_tdx.tray import run_with_tray
|
||||
from easy_tdx.web import create_app
|
||||
|
||||
run_with_tray(
|
||||
app_factory=create_app,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
open_browser=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""进程入口:区分子进程 / 开发态 / 打包态三条路径。"""
|
||||
# 1. 必须最先:multiprocessing 子进程保护。
|
||||
# Windows spawn 子进程会重新 import __main__;如果不拦截,子进程会
|
||||
# 再次跑 cli() 启动 uvicorn,导致死循环 + 端口冲突 + 数据错乱。
|
||||
if _is_multiprocessing_child():
|
||||
# 子进程:只跑 freeze_support(处理 multiprocessing 协议),
|
||||
# 不启动 CLI / uvicorn / 浏览器。子进程的 mainloop 由
|
||||
# multiprocessing 内部接管,会执行被 pickle 过来的任务函数。
|
||||
import multiprocessing
|
||||
|
||||
multiprocessing.freeze_support()
|
||||
# freeze_support 在子进程里会阻塞到任务完成后 exit,理论不会走到这里;
|
||||
# 但防御性 return,避免任何情况下的 CLI 重复启动。
|
||||
sys.exit(0)
|
||||
|
||||
# 2. 标准流重定向(必须在 import click/uvicorn 之前)。
|
||||
_redirect_std_streams_to_log()
|
||||
|
||||
# 3. freeze_support 即便在主进程也建议调(无害,防御未来加的子进程触发点)。
|
||||
import multiprocessing
|
||||
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
from easy_tdx.cli import cli
|
||||
|
||||
is_frozen = getattr(sys, "frozen", False)
|
||||
|
||||
# 双击启动(无参 + 打包态)→ 走托盘路径:uvicorn 后台 + 右下角图标可退出。
|
||||
# 命令行带参(含 ``serve``)→ 走原 CLI,行为不变(开发/调试场景)。
|
||||
if is_frozen and len(sys.argv) <= 1:
|
||||
_run_tray_server()
|
||||
return
|
||||
|
||||
# 命令行显式调用:保持原行为。
|
||||
# 显式传 ``serve --no-open-browser`` 可关闭浏览器自动打开,
|
||||
# 传其他子命令(如 ``server-info``)走原 CLI 行为。
|
||||
if len(sys.argv) <= 1:
|
||||
# 开发态无参 python -m easy_tdx:默认走 serve(无托盘,开发态不需要)。
|
||||
sys.argv = [sys.argv[0], "serve"]
|
||||
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@@ -11,7 +14,19 @@ import click
|
||||
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
|
||||
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
|
||||
@click.option("--reload", is_flag=True, help="开发模式(自动重载)")
|
||||
def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, reload: bool) -> None:
|
||||
@click.option(
|
||||
"--open-browser/--no-open-browser",
|
||||
default=True,
|
||||
help="启动后自动打开浏览器(默认开启,PyInstaller 打包后老人双击即用)",
|
||||
)
|
||||
def serve(
|
||||
host: str,
|
||||
port: int,
|
||||
tdx_host: str | None,
|
||||
tdx_port: int | None,
|
||||
reload: bool,
|
||||
open_browser: bool,
|
||||
) -> None:
|
||||
"""启动 Web API 服务器(需要安装 easy-tdx[web])。"""
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -22,6 +37,15 @@ def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, relo
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
# 启动后延迟打开浏览器:uvicorn 需要约 1-2 秒绑定端口,过早打开会
|
||||
# 命中 connection refused。用后台 Timer 而非阻塞主线程。
|
||||
if open_browser and not reload:
|
||||
# 0.0.0.0 / 127.0.0.1 在浏览器里用 localhost 打开(更友好)。
|
||||
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
|
||||
url = f"http://{display_host}:{port}"
|
||||
# 1.5 秒通常足够本地端口就绪;uvicorn 启动慢的机器可适当延长。
|
||||
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
||||
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
"easy_tdx.web:app_factory",
|
||||
|
||||
@@ -63,17 +63,27 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
|
||||
pre_diff_base = 0
|
||||
cat = int(self.category)
|
||||
|
||||
# 服务器偶发返回的 ret_count 与 body 实际长度不匹配(pytdx/mootdx 均
|
||||
# 有类似报告):ret_count 撒谎或网络帧粘包/截断,循环到中途 pos 已
|
||||
# 读到底("剩余 0 字节")。改用"取 min(ret_count, body 可解析条数)"
|
||||
# 策略——TdxDecodeError 视为记录边界,提前结束循环并丢弃残缺尾记录,
|
||||
# 而不是让整批数据 500。调用方拿到的是完整记录(少几根 K 线比全崩好)。
|
||||
for _ in range(ret_count):
|
||||
record_start = pos
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
try:
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
|
||||
open_diff, pos = get_price(body, pos)
|
||||
close_diff, pos = get_price(body, pos)
|
||||
high_diff, pos = get_price(body, pos)
|
||||
low_diff, pos = get_price(body, pos)
|
||||
open_diff, pos = get_price(body, pos)
|
||||
close_diff, pos = get_price(body, pos)
|
||||
high_diff, pos = get_price(body, pos)
|
||||
low_diff, pos = get_price(body, pos)
|
||||
|
||||
vol, pos = get_volume(body, pos)
|
||||
amount, pos = get_volume(body, pos)
|
||||
vol, pos = get_volume(body, pos)
|
||||
amount, pos = get_volume(body, pos)
|
||||
except Exception:
|
||||
# 残缺尾记录:body 已读到底或字段不完整,丢弃本条并停止。
|
||||
# 不重新抛出—— degrade gracefully,返回已解析的完整记录。
|
||||
break
|
||||
|
||||
# 差分还原(与 pytdx 完全一致)
|
||||
open_abs = open_diff + pre_diff_base
|
||||
@@ -116,20 +126,25 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
|
||||
pre_diff_base = 0
|
||||
cat = int(self.category)
|
||||
|
||||
# 同 GetSecurityBarsCmd:ret_count 与 body 实际长度偶发不匹配,
|
||||
# 残缺尾记录提前 break,详见父类同名注释。
|
||||
for _ in range(ret_count):
|
||||
record_start = pos
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
try:
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
|
||||
open_diff, pos = get_price(body, pos)
|
||||
close_diff, pos = get_price(body, pos)
|
||||
high_diff, pos = get_price(body, pos)
|
||||
low_diff, pos = get_price(body, pos)
|
||||
open_diff, pos = get_price(body, pos)
|
||||
close_diff, pos = get_price(body, pos)
|
||||
high_diff, pos = get_price(body, pos)
|
||||
low_diff, pos = get_price(body, pos)
|
||||
|
||||
vol, pos = get_volume(body, pos)
|
||||
amount, pos = get_volume(body, pos)
|
||||
vol, pos = get_volume(body, pos)
|
||||
amount, pos = get_volume(body, pos)
|
||||
|
||||
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
|
||||
pos += 4
|
||||
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
|
||||
pos += 4
|
||||
except Exception:
|
||||
break
|
||||
|
||||
open_abs = open_diff + pre_diff_base
|
||||
close_abs = open_abs + close_diff
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""系统托盘(仅打包态使用)。
|
||||
|
||||
双击 ``easy-tdx.exe`` 后:uvicorn 跑在后台线程,主线程跑 pystray 托盘
|
||||
图标,右键菜单提供"打开浏览器 / 退出"。老人不用学任务管理器,右下角
|
||||
图标右键 → 退出即可干净关闭。
|
||||
|
||||
**为什么需要独立模块**:
|
||||
- uvicorn 的 ``server.run()`` 是阻塞调用。常规 CLI 路径(``cmd_web.py``)
|
||||
让 uvicorn 占主线程;但 pystray 在 Windows 上需要主线程的消息泵,所以
|
||||
打包态必须把 uvicorn 挪到后台线程。
|
||||
- 本模块仅在 PyInstaller frozen 模式下由 ``__main__.py`` 调用,开发态
|
||||
``easy-tdx serve`` 走原 CLI 路径,不引入托盘。
|
||||
|
||||
依赖:``pystray`` + ``Pillow``(纯 Python wheel,PyInstaller 打包无坑)。
|
||||
两者仅在打包态 import,开发态不强制安装。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import PIL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_icon_image() -> PIL.Image.Image:
|
||||
"""画一个简单的"K 线图"风格图标(红涨绿跌的简化样式)。
|
||||
|
||||
用 Pillow 代码生成,避免在仓库里维护二进制 .ico 文件。32×32 是
|
||||
Windows 系统托盘的标准尺寸。
|
||||
"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
size = 64 # 高分辨率,pystray 会自动缩放到托盘尺寸
|
||||
img = Image.new("RGBA", (size, size), (30, 30, 40, 255)) # 深色背景
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 三根简化 K 线:红涨两根 + 绿跌一根
|
||||
bars = [
|
||||
# (x, y_top, y_bottom, color) —— y 越大越往下
|
||||
(16, 18, 44, (231, 76, 60)), # 红
|
||||
(30, 12, 38, (231, 76, 60)), # 红(更高的高点)
|
||||
(44, 22, 50, (46, 204, 113)), # 绿
|
||||
]
|
||||
for x, top, bottom, color in bars:
|
||||
# 影线(细竖线)
|
||||
draw.line([(x + 3, top - 4), (x + 3, bottom + 4)], fill=color, width=1)
|
||||
# 实体(矩形)
|
||||
draw.rectangle([(x, top), (x + 6, bottom)], fill=color)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def run_with_tray(
|
||||
app_factory: Callable[[], Any],
|
||||
host: str,
|
||||
port: int,
|
||||
open_browser: bool = True,
|
||||
) -> None:
|
||||
"""启动 uvicorn(后台线程)+ 系统托盘(主线程阻塞)。
|
||||
|
||||
Args:
|
||||
app_factory: 返回配置好的 ASGI app 的零参 callable(惰性调用,
|
||||
避免本模块顶层 import fastapi/uvicorn)。
|
||||
host: 监听地址。
|
||||
port: 监听端口。
|
||||
open_browser: 启动后是否自动开浏览器。
|
||||
"""
|
||||
import signal
|
||||
|
||||
import uvicorn
|
||||
from pystray import Icon, Menu, MenuItem
|
||||
|
||||
app = app_factory()
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# uvicorn 跑在后台线程:server.run() 阻塞,由 server.should_exit 通知退出
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn")
|
||||
server_thread.start()
|
||||
|
||||
# 启动后延迟开浏览器(等端口就绪)
|
||||
if open_browser:
|
||||
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
|
||||
url = f"http://{display_host}:{port}"
|
||||
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
||||
|
||||
def _open_browser() -> None:
|
||||
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
|
||||
webbrowser.open(f"http://{display_host}:{port}")
|
||||
|
||||
def _quit(icon: Icon, item: MenuItem) -> None:
|
||||
logger.info("Tray quit clicked — shutting down uvicorn")
|
||||
server.should_exit = True
|
||||
icon.stop()
|
||||
|
||||
menu = Menu(
|
||||
MenuItem("打开浏览器", _open_browser, default=True), # default = 双击图标触发
|
||||
Menu.SEPARATOR,
|
||||
MenuItem("退出", _quit),
|
||||
)
|
||||
|
||||
icon = Icon("easy-tdx", _make_icon_image(), "easy-tdx 回测服务", menu)
|
||||
|
||||
# Ctrl+C 兜底(console=False 下其实收不到,但开发态调试时有用)
|
||||
def _signal_handler(signum: int, frame: object) -> None:
|
||||
server.should_exit = True
|
||||
icon.stop()
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
except (ValueError, OSError):
|
||||
# 非 main 线程或 Windows GUI 子系统下会失败,可忽略
|
||||
pass
|
||||
|
||||
logger.info("Starting tray icon (main thread blocks here)")
|
||||
icon.run() # 阻塞主线程,直到 icon.stop() 被调用
|
||||
|
||||
# 托盘退出后,等 uvicorn 线程收尾(最多 5 秒)
|
||||
server_thread.join(timeout=5.0)
|
||||
logger.info("uvicorn thread joined — process exiting")
|
||||
@@ -3,8 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -15,6 +18,41 @@ from easy_tdx.web.errors import register_exception_handlers
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_web_dist_dir() -> Path | None:
|
||||
"""定位前端构建产物目录(Vite build 输出的 ``web-ui/dist``)。
|
||||
|
||||
依次探测三处,命中即返回,全部缺失时返回 ``None``(开发期未构建前端
|
||||
时正常,路由层照常工作,仅前端页面 404):
|
||||
|
||||
1. ``EASY_TDX_WEB_DIST`` 环境变量——部署/调试时显式指定。
|
||||
2. PyInstaller 运行态:``sys._MEIPASS / "web_dist"``——单 EXE 解压
|
||||
后的临时目录(``--onefile`` 模式)。开发态无 ``_MEIPASS`` 属性,
|
||||
此分支自动跳过。
|
||||
3. 开发态:仓库根目录的 ``web-ui/dist``——支持 ``pip install -e .``
|
||||
后直接 ``easy-tdx serve`` 调试,无需打包。
|
||||
"""
|
||||
env_dir = os.environ.get("EASY_TDX_WEB_DIST")
|
||||
if env_dir:
|
||||
p = Path(env_dir)
|
||||
if p.is_dir():
|
||||
return p
|
||||
|
||||
# PyInstaller --onefile 解压目录(frozen 运行态)
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass is not None:
|
||||
p = Path(meipass) / "web_dist"
|
||||
if p.is_dir():
|
||||
return p
|
||||
|
||||
# 开发态:从 src/easy_tdx/web/app.py 回溯到仓库根的 web-ui/dist
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
p = repo_root / "web-ui" / "dist"
|
||||
if p.is_dir():
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
|
||||
@@ -191,4 +229,16 @@ def _create_app(
|
||||
# 策略库路由(SQLite 持久化,纯数据 CRUD)
|
||||
app.include_router(strategies_router, prefix="/api/v1")
|
||||
|
||||
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
|
||||
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
|
||||
# 未匹配路径,放最后保证 /api/v1/* 优先命中路由表。
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
dist_dir = _resolve_web_dist_dir()
|
||||
if dist_dir is not None:
|
||||
app.mount("/", StaticFiles(directory=str(dist_dir), html=True), name="web-ui")
|
||||
logger.info("Web UI mounted from %s", dist_dir)
|
||||
else:
|
||||
logger.info("Web UI dist not found — serving API only")
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user