Skip to content

Commit 348d327

Browse files
committed
fix(web+tray+trade): restore Tailwind v3 via Play CDN + tray log + non-blocking trades fetch
1 parent a2544f2 commit 348d327

10 files changed

Lines changed: 251 additions & 97 deletions

File tree

installer/fetch-static-assets.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
44
CI 构建 NSIS 安装包前会运行本脚本,把 Tailwind / DaisyUI / htmx 打包进
55
应用目录,避免运行时从 unpkg/jsdelivr 拉取(Tracking Prevention 会拦截
6-
跨域 localStorage,导致 htmx 历史缓存失效;同时 Tailwind CDN 会输出
7-
"should not be used in production" 警告)。
6+
跨域 localStorage,导致 htmx 历史缓存失效)。
87
9-
为什么用 Tailwind v2.2.19 而不是 v3?v3 之后 Tailwind 切到 JIT 模式,不再
10-
发布预编译的 CSS——必须跑 Tailwind CLI 在构建期扫源码才能产出 CSS。
11-
v2.2.19 是最后支持"开箱即用预编译 CSS"的版本,CSS 体积约 3 MB(包含
12-
全部原子类),对桌面应用完全够用。生产 Tailwind(CLI + 按需生成 ~50 KB)
13-
是另一个 issue 的内容。
8+
为什么用 Tailwind Play CDN 而不是 v2 预编译 CSS 或 v3 CLI?
9+
- Play CDN(cdn.tailwindcss.com)是 Tailwind 官方提供的运行时 JIT 编译器,
10+
客户端首次加载后扫一遍 DOM、按需生成 CSS。400 KB(含编译器本体),
11+
比 v2 预编译的 ~3 MB CSS 小一档,跟原来 CDN 时代的渲染行为完全一致。
12+
- v2 预编译 CSS 缺少 v3 新增的若干原子类(.shrink-0 .gap-x-* .focus:ring-*
13+
等),会导致"原本正常的 UI"在 vendoring 后变样,需要写一堆 polyfill
14+
维护成本高。
15+
- v3 CLI 必须构建期扫源码,对 fasthtml 生成的 HTML(运行时才有内容)
16+
不友好;要在构建期处理就得把模板里所有 class 枚举出来,可行性差。
17+
18+
Play CDN 本地化是速度(比外网 CDN 快)+ 稳定性(不依赖网络)+ 设计保真
19+
(与原 CDN 渲染完全一致)的折中点。
1420
1521
本脚本也可在本地手动执行:
1622
@@ -31,8 +37,10 @@
3137

3238
ASSETS: list[tuple[str, str]] = [
3339
(
34-
"https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css",
35-
"tailwind.min.css",
40+
# Tailwind Play CDN(含 v3 JIT 编译器,~400 KB)。
41+
# 比 v2 预编译 CSS 小,渲染行为与原 CDN 时代一致,保留所有 v3 原子类。
42+
"https://cdn.tailwindcss.com",
43+
"tailwind.min.js",
3644
),
3745
(
3846
"https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js",

installer/installer.nsi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ Section "-Core" SEC_CORE
313313
File "start.bat"
314314
File "start-silent.bat"
315315
File "start-silent.vbs"
316+
File "wait-and-open-browser.ps1"
316317
File "task-template.xml"
317318
File "register-task.ps1"
318319
File "create-shortcuts.ps1"

installer/start.bat

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@echo off
2+
chcp 65001 >nul 2>&1
23
cd /d "%~dp0"
34
setlocal
45
set "QMT_GATEWAY_HOME=%~dp0data\home"
@@ -11,40 +12,21 @@ if exist "%QMT_GATEWAY_HOME%\.port" (
1112
set /p GATEWAY_PORT=<"%QMT_GATEWAY_HOME%\.port"
1213
)
1314

14-
:: 如果已有 gateway 在 .port 指向的端口上跑着,就只打开浏览器,不重复启动。
15-
:: 重复启动会让 mini-qmt 同时收到多个 session_id 的连接,互相挤掉,导致
16-
:: UI 一直显示"未连接"。脚本里用纯 PowerShell 探活,避免拉起额外的 exe。
15+
rem 如果已有 gateway 在 .port 指向的端口上跑着,就只打开浏览器,不重复启动。
1716
powershell.exe -NoProfile -Command "try{$c=New-Object Net.Sockets.TcpClient; $c.Connect('127.0.0.1', %GATEWAY_PORT%); $c.Close(); exit 0}catch{exit 1}" >nul 2>&1
1817
if not errorlevel 1 (
1918
start "" "http://localhost:%GATEWAY_PORT%"
2019
endlocal
2120
exit /b 0
2221
)
2322

24-
:: 端口没响应——可能是 gateway 没启动(用户刚从托盘"停止"过,.port 已被
25-
:: atexit 删掉),也可能是上一个实例崩了。先在后台启动一个"等待就绪并打开
26-
:: 浏览器"的任务,再在前台启动 gateway——这样 gateway 的日志留在当前控制
27-
:: 台窗口,浏览器在 gateway 监听端口后自动弹出。旧实现是在启动 gateway
28-
:: 之前就 start 浏览器,结果浏览器先于 gateway 就绪弹出,显示"无法连接"。
29-
::
30-
:: .port 可能因为 8130 被占用而指向 8131-8139,所以等待结束后重新读取,
31-
:: 而不是沿用启动前的值。
32-
start "" /B powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
33-
"$portFile='%QMT_GATEWAY_HOME%\.port';" ^
34-
"$w=0;" ^
35-
"while($w -lt 15){" ^
36-
"if(Test-Path -LiteralPath $portFile){$r=Get-Content -LiteralPath $portFile -ErrorAction SilentlyContinue; if($r -match '^\d+$'){break}}" ^
37-
"Start-Sleep -Seconds 1; $w++" ^
38-
"};" ^
39-
"$port=8130; if(Test-Path -LiteralPath $portFile){$r=Get-Content -LiteralPath $portFile -ErrorAction SilentlyContinue; if($r -match '^\d+$'){$port=[int]$r}};" ^
40-
"$w=0;" ^
41-
"while($w -lt 30){" ^
42-
"try{$c=New-Object Net.Sockets.TcpClient; $c.Connect('127.0.0.1',$port); $c.Close(); start \"\" \"http://localhost:$port\"; exit 0}catch{}; Start-Sleep -Seconds 1; $w++" ^
43-
"}"
23+
rem 端口没响应:gateway 没启动或已停止。先在后台启动等待就绪打开浏览器的
24+
rem 任务,再在前台启动 gateway。浏览器在 gateway 监听端口后自动弹出。
25+
start "" /B powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0wait-and-open-browser.ps1" -PortFile "%QMT_GATEWAY_HOME%\.port"
26+
4427
python\python.exe -m qmt_gateway
4528
set "RC=%ERRORLEVEL%"
46-
:: 退出码 2 = 另一个实例已在跑(被单实例锁拒绝)。浏览器由上面的等待逻辑
47-
:: 打开,不需要提示用户出错。
29+
rem 退出码 2 = 另一个实例已在跑(被单实例锁拒绝)。
4830
if not "%RC%"=="2" (
4931
if not "%RC%"=="0" (
5032
echo qmt-gateway 启动失败,退出码 %RC%
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# 等待 gateway 写入 .port 并真正监听,然后打开浏览器。
2+
#
3+
# 由 start.bat 在后台调用(start "" /B powershell ... -File 本文件)。
4+
# 单独成文件而不是内联在 start.bat 的 ^ 续行里,是因为中文系统下
5+
# cmd.exe 用 GBK 读取 .bat,多行 ^ 续行 + 中文注释会被 GBK 字节
6+
# 切断,导致注释内容被当作命令执行。
7+
#
8+
# 调用:powershell -NoProfile -ExecutionPolicy Bypass -File 本文件 -PortFile <path>
9+
param(
10+
[Parameter(Mandatory = $true)]
11+
[string]$PortFile
12+
)
13+
14+
$defaultPort = 8130
15+
16+
# 1. 等 gateway 写 .port(最多 15 秒)
17+
$w = 0
18+
while ($w -lt 15) {
19+
if (Test-Path -LiteralPath $PortFile) {
20+
$r = Get-Content -LiteralPath $PortFile -ErrorAction SilentlyContinue
21+
if ($r -match '^\d+$') { break }
22+
}
23+
Start-Sleep -Seconds 1
24+
$w++
25+
}
26+
27+
# 2. 读真实端口(8130 被占用时 gateway 会跳到 8131-8139)
28+
$port = $defaultPort
29+
if (Test-Path -LiteralPath $PortFile) {
30+
$r = Get-Content -LiteralPath $PortFile -ErrorAction SilentlyContinue
31+
if ($r -match '^\d+$') { $port = [int]$r }
32+
}
33+
34+
# 3. 探活端口就绪后打开浏览器(最多 30 秒)
35+
$w = 0
36+
while ($w -lt 30) {
37+
try {
38+
$c = New-Object Net.Sockets.TcpClient
39+
$c.Connect('127.0.0.1', $port)
40+
$c.Close()
41+
Start-Process "http://localhost:$port"
42+
exit 0
43+
} catch {}
44+
Start-Sleep -Seconds 1
45+
$w++
46+
}
47+
exit 1

qmt_gateway/app.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -318,18 +318,14 @@ def create_app():
318318
# 创建 FastHTML 应用
319319
app = FastHTML(
320320
hdrs=[
321-
# Tailwind v2(预编译,~3 MB;覆盖 .flex .text-sm .w-9 等原子类)
322-
# 必须先于 DaisyUI——DaisyUI 的 .btn .card 等组件依赖 Tailwind 变量
323-
Link(rel="stylesheet", href="/static/tailwind.min.css"),
321+
# Tailwind Play CDN(v3 JIT 编译器,本地化;与原 CDN 渲染行为完全一致,
322+
# 保留全部 v3 原子类,包括 .shrink-0 .gap-x-* .focus:ring-* 等)。
323+
# 体积 ~400 KB(含编译器),小于 v2 预编译的 ~3 MB CSS。
324+
Script(src="/static/tailwind.min.js"),
324325
# DaisyUI(主题层;.btn .loading .text-primary 等组件)
325326
Link(rel="stylesheet", href="/static/daisyui.min.css"),
326327
# HTMX
327328
Script(src="/static/htmx.min.js"),
328-
# v3-only 工具类 polyfill(tailwind v2 没有 .shrink-0 简写)
329-
Style(
330-
".shrink-0 { flex-shrink: 0; }\n"
331-
".flex-shrink-0 { flex-shrink: 0; }"
332-
),
333329
],
334330
session_cookie="qmt_gateway_session",
335331
)

qmt_gateway/services/trade_service.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,11 @@ def get_orders(self) -> list[dict]:
12541254
Returns:
12551255
委托列表(字典列表)
12561256
"""
1257-
if not self._ensure_connected():
1257+
# 不要在同步 HTTP 路径里触发 _ensure_connected() —— 那是阻塞路径,
1258+
# 会同步启动 QMT 客户端(10+ 秒),导致 / 和 /trading 页面首次
1259+
# 加载卡住。断线场景由 mark_disconnected → _schedule_auto_reconnect
1260+
# 异步处理;这里只读已连接状态。
1261+
if not self._connected or self._trader is None or self._account is None:
12581262
return []
12591263

12601264
try:
@@ -1300,7 +1304,8 @@ def get_trades(self) -> list[dict]:
13001304
Returns:
13011305
成交列表(字典列表)
13021306
"""
1303-
if not self._ensure_connected():
1307+
# 同 get_orders:避免在同步路径里同步 connect()。
1308+
if not self._connected or self._trader is None or self._account is None:
13041309
return []
13051310

13061311
try:

qmt_gateway/tray.py

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -228,43 +228,57 @@ def _load_icon() -> Image.Image:
228228

229229

230230
def _open_browser(icon: pystray.Icon, item: pystray.MenuItem) -> None:
231-
port = _read_port()
232-
url = f"http://localhost:{port}"
233-
logger.info(f"打开浏览器: {url}")
234-
webbrowser.open(url)
231+
try:
232+
port = _read_port()
233+
url = f"http://localhost:{port}"
234+
logger.info(f"打开浏览器: {url}")
235+
webbrowser.open(url)
236+
except Exception as exc:
237+
logger.exception(f"打开浏览器失败: {exc}")
235238

236239

237240
def _restart_gateway(icon: pystray.Icon, item: pystray.MenuItem) -> None:
238-
logger.info("用户触发重启")
239-
# 先清过期锁(gateway 已被外部 kill -9 / 任务管理器结束的场景)
240-
_clear_stale_lock()
241-
# "停止"之后 gateway 已死、.lock 已被 atexit 删掉,_read_pid() 返回 None。
242-
# 此时 _kill_gateway() 返回 False,旧实现会短路掉 _spawn_gateway()——
243-
# 结果用户点"重启"什么也没发生。这里改成:没有正在跑的进程就直接拉起。
244-
# 注意不调 _wait_for_port_free:停止后 .port 已被删,_read_port() 退回
245-
# 8130,但 8130 可能被别的应用占用——此时等它空闲会误判为"端口占用"。
246-
# gateway 内部的 find_available_port 会自己探测可用端口,托盘不需要干预。
247-
pid = _read_pid()
248-
if pid is None:
249-
logger.info("未发现正在运行的 gateway,直接启动新实例")
250-
_spawn_gateway()
251-
return
252-
port = _read_port()
253-
if _kill_gateway() and _wait_for_port_free(port, timeout=10.0):
254-
_spawn_gateway()
255-
else:
256-
logger.warning("重启失败:旧进程未退出或端口仍占用")
241+
try:
242+
logger.info("用户触发重启")
243+
# 先清过期锁(gateway 已被外部 kill -9 / 任务管理器结束的场景)
244+
_clear_stale_lock()
245+
# "停止"之后 gateway 已死、.lock 已被 atexit 删掉,_read_pid() 返回 None。
246+
# 此时 _kill_gateway() 返回 False,旧实现会短路掉 _spawn_gateway()——
247+
# 结果用户点"重启"什么也没发生。这里改成:没有正在跑的进程就直接拉起。
248+
# 注意不调 _wait_for_port_free:停止后 .port 已被删,_read_port() 退回
249+
# 8130,但 8130 可能被别的应用占用——此时等它空闲会误判为"端口占用"。
250+
# gateway 内部的 find_available_port 会自己探测可用端口,托盘不需要干预。
251+
pid = _read_pid()
252+
if pid is None:
253+
logger.info("未发现正在运行的 gateway,直接启动新实例")
254+
if not _spawn_gateway():
255+
logger.error("重启失败:spawn 返回 False")
256+
return
257+
port = _read_port()
258+
if _kill_gateway() and _wait_for_port_free(port, timeout=10.0):
259+
if not _spawn_gateway():
260+
logger.error("重启失败:spawn 返回 False")
261+
else:
262+
logger.warning("重启失败:旧进程未退出或端口仍占用")
263+
except Exception as exc:
264+
logger.exception(f"重启失败: {exc}")
257265

258266

259267
def _stop_gateway(icon: pystray.Icon, item: pystray.MenuItem) -> None:
260-
logger.info("用户触发停止")
261-
_kill_gateway()
268+
try:
269+
logger.info("用户触发停止")
270+
_kill_gateway()
271+
except Exception as exc:
272+
logger.exception(f"停止失败: {exc}")
262273

263274

264275
def _quit_tray(icon: pystray.Icon, item: pystray.MenuItem) -> None:
265276
"""退出托盘本身;gateway 继续运行(用户可从开始菜单重启托盘)。"""
266-
logger.info("退出托盘;gateway 保持运行")
267-
icon.stop()
277+
try:
278+
logger.info("退出托盘;gateway 保持运行")
279+
icon.stop()
280+
except Exception as exc:
281+
logger.exception(f"退出托盘失败: {exc}")
268282

269283

270284
def _start_gateway(icon: pystray.Icon, item: pystray.MenuItem) -> None:
@@ -275,15 +289,19 @@ def _start_gateway(icon: pystray.Icon, item: pystray.MenuItem) -> None:
275289
探测可用端口,托盘不需要预先等端口空闲(否则 8130 被别的应用占用时
276290
会误判为"无法启动")。
277291
"""
278-
logger.info("用户触发启动")
279-
_clear_stale_lock()
280-
pid = _read_pid()
281-
if pid is not None and _is_pid_alive_windows(pid):
282-
# 已经在跑——直接打开浏览器,不重复拉起
283-
logger.info("gateway 已在运行,打开浏览器")
284-
_open_browser(icon, item)
285-
return
286-
_spawn_gateway()
292+
try:
293+
logger.info("用户触发启动")
294+
_clear_stale_lock()
295+
pid = _read_pid()
296+
if pid is not None and _is_pid_alive_windows(pid):
297+
# 已经在跑——直接打开浏览器,不重复拉起
298+
logger.info("gateway 已在运行,打开浏览器")
299+
_open_browser(icon, item)
300+
return
301+
if not _spawn_gateway():
302+
logger.error("启动失败:spawn 返回 False")
303+
except Exception as exc:
304+
logger.exception(f"启动失败: {exc}")
287305

288306

289307
def _build_menu() -> pystray.Menu:
@@ -334,5 +352,24 @@ def _ready_signal():
334352
if __name__ == "__main__":
335353
logger.remove()
336354
logger.add(sys.stderr, level="INFO")
355+
# 托盘是 DETACHED_PROCESS 启动的,stderr 在父进程上下文里没有可见
356+
# 的控制台——所有诊断信息会消失。这里把 INFO 及以上级别的日志镜像
357+
# 到 $INSTDIR\logs\tray.log(开发态为仓库根的 logs\tray.log),
358+
# 出问题时有据可查。
359+
try:
360+
log_dir = Path(__file__).resolve().parent.parent / "logs"
361+
# 安装态:qmt_gateway/tray.py 在 $INSTDIR\app\qmt_gateway\,
362+
# parent.parent 是 $INSTDIR\app,所以这里指向 app\logs。
363+
# 退而求其次写到 logs\tray.log(cwd 视角)。
364+
log_dir.mkdir(parents=True, exist_ok=True)
365+
logger.add(
366+
log_dir / "tray.log",
367+
level="INFO",
368+
rotation="1 MB",
369+
retention=3,
370+
encoding="utf-8",
371+
)
372+
except OSError:
373+
pass
337374
logger.info("QMT Gateway 托盘启动")
338375
run()

qmt_gateway/web/theme.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ def headers():
2626
构建时下载到 ``qmt_gateway/web/static/``。
2727
"""
2828
return [
29-
# Tailwind v2(必须先于 DaisyUI
30-
Link(rel="stylesheet", href="/static/tailwind.min.css"),
29+
# Tailwind Play CDN(v3 JIT,本地化;与原 CDN 时代渲染完全一致
30+
Script(src="/static/tailwind.min.js"),
3131
# DaisyUI(Tailwind 主题层)
3232
Link(rel="stylesheet", href="/static/daisyui.min.css"),
33-
# 自定义主题 CSS + Tailwind v2→v3 兼容 polyfill
33+
# 自定义主题 CSS
3434
Style(f"""
3535
:root {{
3636
--p: 4 90% 58%; /* primary color in HSL: #D13527 approx */
@@ -50,9 +50,6 @@ def headers():
5050
.border-primary {{
5151
border-color: {PRIMARY_COLOR} !important;
5252
}}
53-
/* Tailwind v3-only utility polyfill for v2 compat */
54-
.shrink-0 {{ flex-shrink: 0; }}
55-
.flex-shrink-0 {{ flex-shrink: 0; }}
5653
"""),
5754
# HTMX
5855
Script(src="/static/htmx.min.js"),

0 commit comments

Comments
 (0)