diff --git a/app/routes.py b/app/routes.py
index 25ce4f6..e71f5cc 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import secrets
+from datetime import datetime
from functools import wraps
from flask import (
@@ -323,7 +324,8 @@ def register_routes(app: Flask) -> None:
if u["role"] == "admin":
acts = conn.execute(
"""
- SELECT act.*, a.name AS account_name, m.name AS machine_name
+ SELECT act.*, a.name AS account_name, m.name AS machine_name,
+ m.last_seen_at AS machine_last_seen
FROM activations act
LEFT JOIN accounts a ON a.id = act.account_id
LEFT JOIN machines m ON m.id = act.machine_id
@@ -333,7 +335,8 @@ def register_routes(app: Flask) -> None:
else:
acts = conn.execute(
"""
- SELECT act.*, a.name AS account_name, m.name AS machine_name
+ SELECT act.*, a.name AS account_name, m.name AS machine_name,
+ m.last_seen_at AS machine_last_seen
FROM activations act
LEFT JOIN accounts a ON a.id = act.account_id
LEFT JOIN machines m ON m.id = act.machine_id
@@ -342,7 +345,25 @@ def register_routes(app: Flask) -> None:
""",
(u.get("account_id"),),
).fetchall()
- activations = [database.row_to_dict(r) for r in acts]
+ activations = []
+ now_ts = database.now()
+ online_sec = 180 # 3 分钟内有心跳视为在线
+ for r in acts:
+ item = database.row_to_dict(r)
+ last = item.get("machine_last_seen")
+ if item.get("status") == "active" and last and (now_ts - float(last)) <= online_sec:
+ item["online_label"] = "在线"
+ item["online"] = True
+ else:
+ item["online_label"] = "离线"
+ item["online"] = False
+ try:
+ item["created_at_fmt"] = datetime.fromtimestamp(float(item["created_at"])).strftime(
+ "%Y-%m-%d %H:%M"
+ )
+ except (TypeError, ValueError, OSError):
+ item["created_at_fmt"] = str(item.get("created_at") or "—")
+ activations.append(item)
exe_name = "黄金市价辅助.exe"
exe_path = RELEASES_DIR / exe_name
return render_template(
diff --git a/app/templates/download.html b/app/templates/download.html
index d44b19f..be84a00 100644
--- a/app/templates/download.html
+++ b/app/templates/download.html
@@ -5,7 +5,7 @@
通用辅助包
-
所有机器下载同一 exe。首次启动输入激活码,绑定账户与机器标识。
+
所有机器下载同一 exe。首次启动输入激活码,绑定账户与机器标识(须在线,不允许离线运行)。
{% if exe_ready %}
已上传 {{ exe_name }}
下载 exe
@@ -48,11 +48,21 @@
{% for a in activations %}
- {{ a.code }} |
+
+ {{ a.code }}
+
+ |
{{ a.account_name or a.account_id }} |
{{ a.machine_name or a.machine_id or '—' }} |
- {{ a.status }} |
- {{ a.created_at }} |
+
+ {% if a.online %}
+ 在线
+ {% else %}
+ 离线
+ {% endif %}
+ |
+ {{ a.created_at_fmt }} |
{% else %}
| 暂无激活码 |
@@ -61,3 +71,27 @@
{% endblock %}
+{% block scripts %}
+
+{% endblock %}
diff --git a/desktop/README.md b/desktop/README.md
index 8b94f1f..e4fcee1 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -3,14 +3,15 @@
## 功能
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
-- **通用包 + 首次激活**:仅第一次填写中控地址与激活码,写入本机后以后自动连接
-- **打开复盘**:用已保存的中控地址打开 `/login`,无需再输入
-- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
+- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
+- **打开复盘**:用已保存的中控地址打开 `/login`
+- **心跳**:运行中每分钟 ping 中控,网页下载中心显示在线/离线
+- **自动上报**:开仓/平仓成功后上报成交与四周期 K 线
## 本地配置
-激活信息保存在:`%USERPROFILE%\.gold_monitor\device.json`(含 `server`、`token` 等)。
-已激活后启动不再弹窗;删掉该文件才会再次要求激活。
+激活信息:`%USERPROFILE%\.gold_monitor\device.json`(`server`、`token` 等)。
+已激活且中控可达则直接进主界面;中控不可达会提示重试,不能跳过。
## 开发运行
@@ -27,8 +28,4 @@ py -3 gold_order_app.py
build_exe.bat
```
-产物:`dist\黄金市价辅助.exe`,复制到仓库 `releases\黄金市价辅助.exe` 后由网页下载中心分发。
-
-## 离线模式
-
-激活窗可点「离线使用」:正常下单,但不上报复盘中心。
+产物同步到仓库 `releases\黄金市价辅助.exe` 供下载中心分发。
diff --git a/desktop/activation_dialog.py b/desktop/activation_dialog.py
index 39aa18a..56c6369 100644
--- a/desktop/activation_dialog.py
+++ b/desktop/activation_dialog.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-"""首次激活对话框:仅第一次填写中控地址 + 激活码;之后自动读取本地配置。"""
+"""首次激活:必须填写中控地址 + 激活码,不允许离线运行。"""
from __future__ import annotations
import tkinter as tk
@@ -24,32 +24,51 @@ def _bring_front(win: tk.Misc) -> None:
def ensure_activated(parent: Optional[tk.Misc] = None) -> bool:
"""
- - 已激活(本地有 server + token):直接放行,不再弹窗;中控地址固定复用。
- - 未激活:弹出首次激活窗,成功后写入 device.json。
+ - 已激活:探测中控,成功则放行;失败则提示重试/退出(不允许离线)。
+ - 未激活:必须激活成功才能进入。
"""
if is_activated():
- # 后台轻量探测(失败也不打扰,主界面仍可用,上报会静默失败)
- try:
- hub_client.ping()
- except Exception:
- pass
- return True
+ if hub_client.ping():
+ return True
+ return _ask_retry_or_exit(parent)
- owns_root = False
- if parent is not None:
- root = parent
- else:
- root = tk.Tk()
- owns_root = True
+ return _show_activation_form(parent)
+
+
+def _ask_retry_or_exit(parent: Optional[tk.Misc]) -> bool:
+ owns = parent is None
+ root = parent or tk.Tk()
+ if owns:
+ root.withdraw()
+ try:
+ while True:
+ _bring_front(root)
+ retry = messagebox.askretrycancel(
+ "无法连接中控",
+ "客户端不允许离线运行。\n请确认中控已启动且本机网络正常后重试。\n\n点「重试」再次连接,点「取消」退出。",
+ parent=root,
+ )
+ if not retry:
+ return False
+ if hub_client.ping():
+ return True
+ finally:
+ if owns:
+ try:
+ root.destroy()
+ except Exception:
+ pass
+
+
+def _show_activation_form(parent: Optional[tk.Misc]) -> bool:
+ owns_root = parent is None
+ root = parent or tk.Tk()
try:
- if owns_root:
- win = root
- else:
- win = tk.Toplevel(root)
+ win = root if owns_root else tk.Toplevel(root)
win.title("首次激活 · 黄金辅助")
- win.minsize(520, 420)
- win.geometry("520x420")
+ win.minsize(520, 400)
+ win.geometry("520x400")
win.resizable(True, True)
result = {"ok": False}
device = load_device()
@@ -62,7 +81,7 @@ def ensure_activated(parent: Optional[tk.Misc] = None) -> bool:
ttk.Label(
body,
- text="首次使用:填写中控地址与激活码。激活成功后会保存在本机,以后打开无需再填。",
+ text="必须激活后才能使用。填写中控地址与激活码,成功后本机保存,以后自动连接。",
wraplength=470,
).pack(anchor="w")
@@ -83,9 +102,8 @@ def ensure_activated(parent: Optional[tk.Misc] = None) -> bool:
status = ttk.Label(body, text="", foreground="#888")
status.pack(anchor="w", pady=(14, 0))
- def do_skip():
- # 离线:不写 token,下次仍会提示首次激活
- result["ok"] = True
+ def do_exit():
+ result["ok"] = False
win.destroy()
def do_ok():
@@ -103,20 +121,17 @@ def ensure_activated(parent: Optional[tk.Misc] = None) -> bool:
result["ok"] = True
messagebox.showinfo(
"激活成功",
- f"已绑定中控:{server}\n以后打开将自动连接,无需再输入。",
+ f"已绑定中控:{server}\n以后打开将自动连接。",
parent=win,
)
win.destroy()
except hub_client.HubError as exc:
status.configure(text=str(exc), foreground="#f6465d")
- ttk.Button(btn_bar, text="离线使用(下次仍会提示激活)", command=do_skip).pack(
- side=tk.LEFT, ipadx=6, ipady=4
- )
+ ttk.Button(btn_bar, text="退出", command=do_exit).pack(side=tk.LEFT, ipadx=8, ipady=4)
ttk.Button(btn_bar, text="确认激活", command=do_ok).pack(side=tk.RIGHT, ipadx=12, ipady=4)
win.bind("", lambda _e: do_ok())
-
- win.protocol("WM_DELETE_WINDOW", do_skip)
+ win.protocol("WM_DELETE_WINDOW", do_exit)
_bring_front(win)
try:
win.grab_set()
diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py
index 2e9bce0..2390587 100644
--- a/desktop/gold_order_app.py
+++ b/desktop/gold_order_app.py
@@ -621,6 +621,7 @@ class OrderApp(tk.Tk):
self._pos_profit_labels: dict = {}
self._build_ui()
self.after(200, self._try_connect_and_refresh)
+ self.after(3000, self._hub_heartbeat)
def _build_ui(self) -> None:
style = ttk.Style(self)
@@ -1445,17 +1446,41 @@ class OrderApp(tk.Tk):
self._run_async(work)
+ def _hub_heartbeat(self) -> None:
+ """定期 ping 中控,供网页显示在线;断线则提示(不允许离线运行)。"""
+ if hub_client is None or not is_activated():
+ self.after(60000, self._hub_heartbeat)
+ return
+
+ def work():
+ ok = False
+ try:
+ ok = bool(hub_client.ping())
+ except Exception:
+ ok = False
+ return ok
+
+ def done(ok: bool):
+ if not ok:
+ self.set_status("中控离线:请检查服务器,客户端需保持在线", ok=False)
+ self.after(60000, self._hub_heartbeat)
+
+ def run():
+ ok = work()
+ self.after(0, lambda: done(ok))
+
+ threading.Thread(target=run, daemon=True).start()
+
def on_open_review(self) -> None:
- """打开中控复盘登录页(浏览器)。"""
+ """打开中控复盘登录页(浏览器),使用已保存地址。"""
server = ""
try:
server = (load_device().get("server") or "").rstrip("/")
except Exception:
server = ""
if not server:
- server = simpledialog_ask_server(self)
- if not server:
- return
+ self.set_status("未激活:请先完成中控激活", ok=False)
+ return
url = server.rstrip("/") + "/login"
try:
webbrowser.open(url)
diff --git a/releases/黄金市价辅助.exe b/releases/黄金市价辅助.exe
index 8d48a38..0e0615f 100644
Binary files a/releases/黄金市价辅助.exe and b/releases/黄金市价辅助.exe differ