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