feat: scaffold gold_monitor web hub, API, PM2 deploy and docs

This commit is contained in:
dekun
2026-10-17 17:43:17 +08:00
commit fb35968a1f
35 changed files with 2080 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from app.factory import create_app
__all__ = ["create_app"]
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT / ".env")
DATA_DIR = ROOT / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
RELEASES_DIR = ROOT / "releases"
RELEASES_DIR.mkdir(parents=True, exist_ok=True)
class Config:
SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "gold-monitor-dev-secret-change-me")
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "5800"))
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:5800").rstrip("/")
_db = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'gold_monitor.db'}")
if _db.startswith("sqlite:///"):
# 相对路径转到仓库根
rel = _db.replace("sqlite:///", "", 1)
p = Path(rel)
if not p.is_absolute():
p = ROOT / p
DATABASE_PATH = p
else:
DATABASE_PATH = DATA_DIR / "gold_monitor.db"
+275
View File
@@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import secrets
import sqlite3
import time
from contextlib import contextmanager
from typing import Any, Iterator, Optional
from app.config import Config
def _connect() -> sqlite3.Connection:
Config.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(Config.DATABASE_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@contextmanager
def get_db() -> Iterator[sqlite3.Connection]:
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db() -> None:
with get_db() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
broker TEXT DEFAULT '',
login_id TEXT DEFAULT '',
account_type TEXT DEFAULT 'demo',
note TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
device_fingerprint TEXT DEFAULT '',
note TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
last_seen_at REAL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS activations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
account_id INTEGER,
machine_id INTEGER,
token TEXT,
status TEXT DEFAULT 'pending',
bound_at REAL,
created_at REAL NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id),
FOREIGN KEY(machine_id) REFERENCES machines(id)
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket TEXT,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
volume REAL,
open_price REAL,
close_price REAL,
open_time REAL,
close_time REAL,
profit REAL,
sl REAL,
tp REAL,
account_id INTEGER,
machine_id INTEGER,
entry_style TEXT DEFAULT '',
review_note TEXT DEFAULT '',
snapshot_json TEXT DEFAULT '{}',
created_at REAL NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id),
FOREIGN KEY(machine_id) REFERENCES machines(id)
);
CREATE TABLE IF NOT EXISTS bars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
ts REAL NOT NULL,
open REAL, high REAL, low REAL, close REAL, volume REAL,
UNIQUE(symbol, timeframe, ts)
);
"""
)
def now() -> float:
return time.time()
def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
if row is None:
return None
return {k: row[k] for k in row.keys()}
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
cur = conn.execute("SELECT * FROM accounts ORDER BY id DESC")
return [row_to_dict(r) for r in cur.fetchall()]
def list_machines(conn: sqlite3.Connection) -> list[dict]:
cur = conn.execute("SELECT * FROM machines ORDER BY id DESC")
return [row_to_dict(r) for r in cur.fetchall()]
def create_activation(conn: sqlite3.Connection, account_id: int, machine_id: Optional[int]) -> str:
code = secrets.token_urlsafe(12)
conn.execute(
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
(code, account_id, machine_id, "pending", now()),
)
return code
def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machine_name: str) -> dict[str, Any]:
row = conn.execute("SELECT * FROM activations WHERE code=?", (code,)).fetchone()
if row is None:
raise ValueError("激活码无效")
if row["status"] == "revoked":
raise ValueError("激活码已作废")
token = secrets.token_urlsafe(32)
machine_id = row["machine_id"]
if machine_id:
conn.execute(
"UPDATE machines SET device_fingerprint=?, last_seen_at=?, name=COALESCE(NULLIF(name,''), ?) WHERE id=?",
(fingerprint, now(), machine_name or f"PC-{fingerprint[:6]}", machine_id),
)
else:
cur = conn.execute(
"INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)",
(machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活自动创建", 1, now(), now()),
)
machine_id = cur.lastrowid
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"]))
conn.execute(
"UPDATE activations SET token=?, status='active', bound_at=? WHERE id=?",
(token, now(), row["id"]),
)
return {
"token": token,
"account_id": row["account_id"],
"machine_id": machine_id,
"public_base": Config.PUBLIC_BASE_URL,
}
def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
if not token:
return None
row = conn.execute(
"SELECT * FROM activations WHERE token=? AND status='active'",
(token,),
).fetchone()
return row_to_dict(row)
def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
cur = conn.execute(
"""
SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
LIMIT ?
""",
(limit,),
)
rows = []
for r in cur.fetchall():
d = row_to_dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except json.JSONDecodeError:
d["snapshot"] = {}
rows.append(d)
return rows
def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
r = conn.execute(
"""
SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id
WHERE t.id=?
""",
(trade_id,),
).fetchone()
d = row_to_dict(r)
if d:
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except json.JSONDecodeError:
d["snapshot"] = {}
return d
def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
n = 0
for b in bars:
conn.execute(
"""
INSERT INTO bars(symbol, timeframe, ts, open, high, low, close, volume)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(symbol, timeframe, ts) DO UPDATE SET
open=excluded.open, high=excluded.high, low=excluded.low,
close=excluded.close, volume=excluded.volume
""",
(
symbol,
timeframe,
float(b["ts"]),
float(b["open"]),
float(b["high"]),
float(b["low"]),
float(b["close"]),
float(b.get("volume") or 0),
),
)
n += 1
return n
def get_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, start_ts: float, end_ts: float) -> list[dict]:
cur = conn.execute(
"""
SELECT ts, open, high, low, close, volume FROM bars
WHERE symbol=? AND timeframe=? AND ts>=? AND ts<=?
ORDER BY ts ASC
""",
(symbol, timeframe, start_ts, end_ts),
)
return [row_to_dict(r) for r in cur.fetchall()]
def seed_if_empty() -> None:
with get_db() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
if n == 0:
conn.execute(
"INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)",
("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()),
)
n2 = conn.execute("SELECT COUNT(*) AS c FROM machines").fetchone()["c"]
if n2 == 0:
conn.execute(
"INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)",
("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()),
)
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from flask import Flask
from app.config import Config
from app.db import init_db, seed_if_empty
from app.routes import register_routes
def create_app() -> Flask:
app = Flask(__name__, template_folder="templates", static_folder="static")
app.config.from_object(Config)
app.secret_key = Config.SECRET_KEY
init_db()
seed_if_empty()
register_routes(app)
return app
+345
View File
@@ -0,0 +1,345 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import secrets
from functools import wraps
from pathlib import Path
from flask import (
Flask,
flash,
jsonify,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from app.config import Config, RELEASES_DIR
from app import db as database
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("user"):
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
def register_routes(app: Flask) -> None:
@app.context_processor
def inject_globals():
return {
"app_name": "Gold Monitor",
"public_base": Config.PUBLIC_BASE_URL,
}
@app.get("/login")
def login():
if session.get("user"):
return redirect(url_for("dashboard"))
return render_template("login.html")
@app.post("/login")
def login_post():
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
if user == Config.ADMIN_USER and pwd == Config.ADMIN_PASSWORD:
session["user"] = user
return redirect(request.args.get("next") or url_for("dashboard"))
flash("用户名或密码错误", "error")
return render_template("login.html"), 401
@app.get("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.get("/")
@login_required
def dashboard():
with database.get_db() as conn:
accounts = database.list_accounts(conn)
machines = database.list_machines(conn)
trades = database.list_trades(conn, 20)
return render_template(
"dashboard.html",
accounts=accounts,
machines=machines,
trades=trades,
)
# ---------- 账户 / 机器 ----------
@app.get("/accounts")
@login_required
def accounts_page():
with database.get_db() as conn:
items = database.list_accounts(conn)
return render_template("accounts.html", items=items)
@app.post("/accounts")
@login_required
def accounts_create():
name = (request.form.get("name") or "").strip()
if not name:
flash("名称不能为空", "error")
return redirect(url_for("accounts_page"))
with database.get_db() as conn:
conn.execute(
"""INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at)
VALUES(?,?,?,?,?,?,?)""",
(
name,
(request.form.get("broker") or "").strip(),
(request.form.get("login_id") or "").strip(),
(request.form.get("account_type") or "demo").strip(),
(request.form.get("note") or "").strip(),
1,
database.now(),
),
)
flash("账户已添加", "ok")
return redirect(url_for("accounts_page"))
@app.post("/accounts/<int:aid>/note")
@login_required
def accounts_note(aid: int):
note = (request.form.get("note") or "").strip()
with database.get_db() as conn:
conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid))
flash("标注已保存", "ok")
return redirect(url_for("accounts_page"))
@app.get("/machines")
@login_required
def machines_page():
with database.get_db() as conn:
items = database.list_machines(conn)
return render_template("machines.html", items=items)
@app.post("/machines")
@login_required
def machines_create():
name = (request.form.get("name") or "").strip() or "新机器"
with database.get_db() as conn:
conn.execute(
"""INSERT INTO machines(name, device_fingerprint, note, enabled, created_at)
VALUES(?,?,?,?,?)""",
(name, "", (request.form.get("note") or "").strip(), 1, database.now()),
)
flash("机器已添加", "ok")
return redirect(url_for("machines_page"))
@app.post("/machines/<int:mid>/note")
@login_required
def machines_note(mid: int):
note = (request.form.get("note") or "").strip()
name = (request.form.get("name") or "").strip()
with database.get_db() as conn:
if name:
conn.execute("UPDATE machines SET note=?, name=? WHERE id=?", (note, name, mid))
else:
conn.execute("UPDATE machines SET note=? WHERE id=?", (note, mid))
flash("机器标注已保存", "ok")
return redirect(url_for("machines_page"))
# ---------- 下载 / 激活 ----------
@app.get("/download")
@login_required
def download_page():
with database.get_db() as conn:
accounts = database.list_accounts(conn)
machines = database.list_machines(conn)
acts = conn.execute(
"""
SELECT act.*, a.name AS account_name, m.name AS machine_name
FROM activations act
LEFT JOIN accounts a ON a.id = act.account_id
LEFT JOIN machines m ON m.id = act.machine_id
ORDER BY act.id DESC LIMIT 50
"""
).fetchall()
activations = [database.row_to_dict(r) for r in acts]
exe_name = "黄金市价辅助.exe"
exe_path = RELEASES_DIR / exe_name
return render_template(
"download.html",
accounts=accounts,
machines=machines,
activations=activations,
exe_ready=exe_path.is_file(),
exe_name=exe_name,
)
@app.post("/download/activation")
@login_required
def download_create_activation():
account_id = int(request.form.get("account_id") or 0)
machine_raw = request.form.get("machine_id") or ""
machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None
with database.get_db() as conn:
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
flash("账户不存在", "error")
return redirect(url_for("download_page"))
code = database.create_activation(conn, account_id, machine_id)
flash(f"已生成激活码:{code}", "ok")
return redirect(url_for("download_page"))
@app.get("/download/file")
@login_required
def download_file():
name = "黄金市价辅助.exe"
path = RELEASES_DIR / name
if not path.is_file():
# 提供占位说明文件,避免空目录无下载
readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt"
if not readme.is_file():
readme.write_text(
"请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n"
"客户端首次启动使用网页生成的激活码绑定机器与账户。\n",
encoding="utf-8",
)
return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True)
return send_from_directory(RELEASES_DIR, name, as_attachment=True)
@app.post("/api/v1/activate")
def api_activate():
data = request.get_json(silent=True) or {}
code = (data.get("code") or "").strip()
fingerprint = (data.get("fingerprint") or secrets.token_hex(8)).strip()
machine_name = (data.get("machine_name") or "").strip()
if not code:
return jsonify({"ok": False, "error": "缺少激活码"}), 400
try:
with database.get_db() as conn:
result = database.activate_device(conn, code, fingerprint, machine_name)
return jsonify({"ok": True, **result})
except ValueError as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@app.get("/api/v1/ping")
def api_ping():
token = request.headers.get("X-Device-Token") or request.args.get("token") or ""
with database.get_db() as conn:
act = database.auth_token(conn, token)
if not act:
return jsonify({"ok": False, "error": "未激活"}), 401
if act.get("machine_id"):
conn.execute(
"UPDATE machines SET last_seen_at=? WHERE id=?",
(database.now(), act["machine_id"]),
)
return jsonify({"ok": True, "server_time": database.now()})
# ---------- 成交上报 / 复盘 ----------
@app.post("/api/v1/trades")
def api_trades_upsert():
token = request.headers.get("X-Device-Token") or ""
data = request.get_json(silent=True) or {}
with database.get_db() as conn:
act = database.auth_token(conn, token)
if not act:
return jsonify({"ok": False, "error": "未激活"}), 401
snapshot = data.get("snapshot") or {}
conn.execute(
"""
INSERT INTO trades(
ticket, symbol, side, volume, open_price, close_price,
open_time, close_time, profit, sl, tp,
account_id, machine_id, entry_style, review_note, snapshot_json, created_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
str(data.get("ticket") or ""),
(data.get("symbol") or "XAUUSD").upper(),
(data.get("side") or "").lower(),
float(data.get("volume") or 0),
float(data.get("open_price") or 0),
float(data.get("close_price") or 0) if data.get("close_price") is not None else None,
data.get("open_time"),
data.get("close_time"),
float(data.get("profit") or 0) if data.get("profit") is not None else None,
data.get("sl"),
data.get("tp"),
act["account_id"],
act["machine_id"],
(data.get("entry_style") or "").strip(),
(data.get("review_note") or "").strip(),
json.dumps(snapshot, ensure_ascii=False),
database.now(),
),
)
tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
return jsonify({"ok": True, "id": tid})
@app.post("/api/v1/bars")
def api_bars_upload():
token = request.headers.get("X-Device-Token") or ""
data = request.get_json(silent=True) or {}
with database.get_db() as conn:
if not database.auth_token(conn, token):
return jsonify({"ok": False, "error": "未激活"}), 401
symbol = (data.get("symbol") or "XAUUSD").upper()
timeframe = (data.get("timeframe") or "M1").upper()
bars = data.get("bars") or []
n = database.upsert_bars(conn, symbol, timeframe, bars)
return jsonify({"ok": True, "count": n})
@app.get("/api/v1/bars")
@login_required
def api_bars_get():
symbol = (request.args.get("symbol") or "XAUUSD").upper()
timeframe = (request.args.get("timeframe") or "M15").upper()
start_ts = float(request.args.get("start") or 0)
end_ts = float(request.args.get("end") or database.now())
with database.get_db() as conn:
bars = database.get_bars(conn, symbol, timeframe, start_ts, end_ts)
return jsonify({"ok": True, "bars": bars})
@app.get("/records")
@login_required
def records_page():
with database.get_db() as conn:
trades = database.list_trades(conn, 300)
return render_template("records.html", trades=trades)
@app.get("/records/<int:tid>")
@login_required
def record_detail(tid: int):
with database.get_db() as conn:
trade = database.get_trade(conn, tid)
if not trade:
flash("记录不存在", "error")
return redirect(url_for("records_page"))
# 四周期:以开仓时间为中心取前后窗口
center = trade.get("open_time") or trade.get("created_at") or database.now()
window = 3600 * 6
charts = {}
for tf in ("M1", "M5", "M15", "H1"):
charts[tf] = database.get_bars(
conn, trade["symbol"], tf, float(center) - window, float(center) + window
)
return render_template("record_detail.html", trade=trade, charts=charts)
@app.post("/records/<int:tid>/annotate")
@login_required
def record_annotate(tid: int):
entry_style = (request.form.get("entry_style") or "").strip()
note = (request.form.get("review_note") or "").strip()
with database.get_db() as conn:
conn.execute(
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
(entry_style, note, tid),
)
flash("复盘标注已保存", "ok")
return redirect(url_for("record_detail", tid=tid))
@app.get("/health")
def health():
return jsonify({"ok": True, "service": "gold_monitor", "port": Config.PORT})
+142
View File
@@ -0,0 +1,142 @@
:root {
--bg: #0a0a10;
--panel: #12121a;
--panel2: #1a1a29;
--border: #242435;
--text: #f0f0f5;
--muted: #9a9ab0;
--accent: #4cc2ff;
--accent2: #7b42ff;
--green: #0ecb81;
--red: #f6465d;
--warn: #e6a23c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: #0d0d16;
position: sticky;
top: 0;
z-index: 20;
}
.brand {
font-weight: 700;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-right: 8px;
}
.nav a {
color: var(--muted);
margin-right: 14px;
font-size: 14px;
}
.nav a.active, .nav a:hover { color: var(--text); text-decoration: none; }
.topbar .right { margin-left: auto; color: var(--muted); font-size: 13px; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.grid { display: grid; gap: 14px; }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 900px) {
.grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; }
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px 18px;
box-shadow: 0 8px 24px rgba(0,0,0,.25);
}
.card h2, .card h3 { margin: 0 0 12px; font-size: 16px; }
.muted { color: var(--muted); font-size: 13px; }
.stat { font-size: 28px; font-weight: 700; }
.btn {
display: inline-block;
border: 0;
border-radius: 10px;
padding: 10px 14px;
background: linear-gradient(90deg, #3aa8e0, #6a3ad8);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.secondary { background: #2a2a3d; }
.btn.danger { background: #8b2e3c; }
.btn:hover { filter: brightness(1.08); }
input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2e2e45;
background: var(--panel2);
color: var(--text);
outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
label { display: block; margin: 0 0 6px; color: #a9a9ff; font-size: 13px; }
.form-row { margin-bottom: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; }
th { color: var(--muted); font-weight: 600; }
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: #222235;
}
.tag.green { color: var(--green); background: rgba(14,203,129,.12); }
.tag.red { color: var(--red); background: rgba(246,70,93,.12); }
.tag.gray { color: var(--muted); }
.flash {
padding: 10px 12px;
border-radius: 10px;
margin-bottom: 12px;
font-size: 14px;
}
.flash.ok { background: rgba(14,203,129,.12); color: var(--green); }
.flash.error { background: rgba(246,70,93,.12); color: var(--red); }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-box {
background: #0e0e18;
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
min-height: 220px;
}
.chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); }
.chart-canvas { width: 100%; height: 180px; display: block; }
.empty-chart {
height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 13px;
}
+58
View File
@@ -0,0 +1,58 @@
/** 轻量蜡烛图(无外部依赖),用于复盘四周期预览 */
(function () {
function drawCandleChart(canvas, bars) {
if (!canvas || !bars || !bars.length) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 400;
const cssH = canvas.clientHeight || 180;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
const pad = 8;
let min = Infinity, max = -Infinity;
bars.forEach((b) => {
min = Math.min(min, b.low);
max = Math.max(max, b.high);
});
if (!(max > min)) {
max = min + 1;
}
const w = cssW - pad * 2;
const h = cssH - pad * 2;
const n = bars.length;
const slot = w / n;
ctx.clearRect(0, 0, cssW, cssH);
ctx.fillStyle = "#0e0e18";
ctx.fillRect(0, 0, cssW, cssH);
const yOf = (p) => pad + ((max - p) / (max - min)) * h;
bars.forEach((b, i) => {
const x = pad + i * slot + slot * 0.5;
const up = b.close >= b.open;
ctx.strokeStyle = up ? "#0ecb81" : "#f6465d";
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath();
ctx.moveTo(x, yOf(b.high));
ctx.lineTo(x, yOf(b.low));
ctx.stroke();
const y1 = yOf(b.open);
const y2 = yOf(b.close);
const top = Math.min(y1, y2);
const bh = Math.max(1, Math.abs(y2 - y1));
ctx.fillRect(x - Math.max(1, slot * 0.3), top, Math.max(2, slot * 0.6), bh);
});
}
document.querySelectorAll("canvas.chart-canvas").forEach((el) => {
try {
const bars = JSON.parse(el.dataset.bars || "[]");
drawCandleChart(el, bars);
} catch (e) {
console.warn(e);
}
});
})();
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}账户 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">账户标注</h2>
<div class="grid cols-2">
<div class="card">
<h3>新增账户</h3>
<form method="post" action="{{ url_for('accounts_create') }}">
<div class="form-row"><label>名称</label><input name="name" required placeholder="如:模拟盘-A"></div>
<div class="form-row"><label>券商</label><input name="broker" placeholder="MT5 券商名"></div>
<div class="form-row"><label>登录号</label><input name="login_id" placeholder="可选"></div>
<div class="form-row">
<label>类型</label>
<select name="account_type">
<option value="demo">模拟</option>
<option value="cent">美分</option>
<option value="real">实盘</option>
</select>
</div>
<div class="form-row"><label>标注</label><textarea name="note" rows="2" placeholder="用途、纪律备注"></textarea></div>
<button class="btn" type="submit">添加</button>
</form>
</div>
<div class="card">
<h3>已有账户</h3>
<table>
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
<tbody>
{% for a in items %}
<tr>
<td>{{ a.name }}<div class="muted">{{ a.broker }} {{ a.login_id }}</div></td>
<td><span class="tag gray">{{ a.account_type }}</span></td>
<td style="min-width:180px">
<form method="post" action="{{ url_for('accounts_note', aid=a.id) }}">
<input name="note" value="{{ a.note or '' }}" placeholder="标注">
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
</form>
</td>
<td>#{{ a.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
{% if session.get('user') %}
<header class="topbar">
<div class="brand">{{ app_name }}</div>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('records_page') }}" class="{% if request.endpoint and request.endpoint.startswith('record') %}active{% endif %}">交易复盘</a>
<a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a>
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">账户</a>
<a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a>
</nav>
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
</header>
{% endif %}
<main class="wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}总览 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">总览</h2>
<div class="grid cols-3">
<div class="card"><div class="muted">账户</div><div class="stat">{{ accounts|length }}</div></div>
<div class="card"><div class="muted">机器</div><div class="stat">{{ machines|length }}</div></div>
<div class="card"><div class="muted">最近成交(展示)</div><div class="stat">{{ trades|length }}</div></div>
</div>
<div class="grid cols-2" style="margin-top:14px">
<div class="card">
<h3>快速入口</h3>
<p class="muted">登录后下载通用辅助包,用激活码绑定机器与账户;成交自动上报,网页复盘四周期 K 线。</p>
<p style="margin-top:12px">
<a class="btn" href="{{ url_for('download_page') }}">下载中心</a>
<a class="btn secondary" href="{{ url_for('records_page') }}">交易复盘</a>
</p>
</div>
<div class="card">
<h3>服务信息</h3>
<p class="muted">对外地址:{{ public_base }}</p>
<p class="muted">端口:5800 · 健康检查:<a href="/health">/health</a></p>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>最近记录</h3>
<table>
<thead>
<tr><th>时间</th><th>品种</th><th>方向</th><th>盈亏</th><th>账户</th><th>入场形式</th><th></th></tr>
</thead>
<tbody>
{% for t in trades %}
<tr>
<td class="muted">{{ t.close_time or t.open_time or t.created_at }}</td>
<td>{{ t.symbol }}</td>
<td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td>
<td>{{ t.profit if t.profit is not none else '—' }}</td>
<td>{{ t.account_name or '—' }}</td>
<td>{{ t.entry_style or '—' }}</td>
<td><a href="{{ url_for('record_detail', tid=t.id) }}">复盘</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">暂无成交。激活客户端并上报后显示。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}下载中心 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">下载中心</h2>
<div class="grid cols-2">
<div class="card">
<h3>通用辅助包</h3>
<p class="muted">所有机器下载同一 exe。首次启动输入激活码,绑定账户与机器标识。</p>
{% if exe_ready %}
<p><span class="tag green">已上传 {{ exe_name }}</span></p>
<a class="btn" href="{{ url_for('download_file') }}">下载 exe</a>
{% else %}
<p><span class="tag gray">尚未放置 exe</span></p>
<p class="muted">请把打包文件放到服务器 <code>releases/黄金市价辅助.exe</code>。当前可下载说明文件。</p>
<a class="btn secondary" href="{{ url_for('download_file') }}">下载说明</a>
{% endif %}
</div>
<div class="card">
<h3>生成激活码</h3>
<form method="post" action="{{ url_for('download_create_activation') }}">
<div class="form-row">
<label>绑定账户</label>
<select name="account_id" required>
{% for a in accounts %}
<option value="{{ a.id }}">#{{ a.id }} {{ a.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label>预绑定机器(可选)</label>
<select name="machine_id">
<option value="">激活时自动创建</option>
{% for m in machines %}
<option value="{{ m.id }}">#{{ m.id }} {{ m.name }}</option>
{% endfor %}
</select>
</div>
<button class="btn" type="submit">生成激活码</button>
</form>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>激活码列表</h3>
<table>
<thead><tr><th>激活码</th><th>账户</th><th>机器</th><th>状态</th><th>创建时间</th></tr></thead>
<tbody>
{% for a in activations %}
<tr>
<td><code>{{ a.code }}</code></td>
<td>{{ a.account_name or a.account_id }}</td>
<td>{{ a.machine_name or a.machine_id or '—' }}</td>
<td><span class="tag {% if a.status=='active' %}green{% else %}gray{% endif %}">{{ a.status }}</span></td>
<td class="muted">{{ a.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">暂无激活码</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · {{ app_name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
<style>
body { display:flex; align-items:center; justify-content:center; }
.login-box { width:100%; max-width:400px; }
.login-box h2 {
text-align:center; margin:0 0 8px;
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
-webkit-background-clip:text; -webkit-text-fill-color:transparent;
}
</style>
</head>
<body>
<div class="card login-box">
<h2>{{ app_name }}</h2>
<p class="muted" style="text-align:center;margin-bottom:18px">MT5 黄金 · 下单辅助 · 网页复盘</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post">
<div class="form-row">
<label>用户名</label>
<input name="username" autocomplete="username" value="admin" required>
</div>
<div class="form-row">
<label>密码</label>
<input name="password" type="password" autocomplete="current-password" required>
</div>
<button class="btn" style="width:100%" type="submit">登录</button>
</form>
<p class="muted" style="margin-top:14px;text-align:center">默认 admin / admin123(请部署后修改 .env)</p>
</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}机器 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">机器标注</h2>
<div class="grid cols-2">
<div class="card">
<h3>预登记机器</h3>
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
<form method="post" action="{{ url_for('machines_create') }}">
<div class="form-row"><label>名称</label><input name="name" placeholder="如:书房台式机"></div>
<div class="form-row"><label>标注</label><textarea name="note" rows="2"></textarea></div>
<button class="btn" type="submit">添加</button>
</form>
</div>
<div class="card">
<h3>已有机器</h3>
<table>
<thead><tr><th>名称 / 指纹</th><th>最近在线</th><th>标注</th></tr></thead>
<tbody>
{% for m in items %}
<tr>
<td>
<form method="post" action="{{ url_for('machines_note', mid=m.id) }}">
<input name="name" value="{{ m.name }}" style="margin-bottom:6px">
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
<textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea>
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
</form>
</td>
<td class="muted">{{ m.last_seen_at or '—' }}</td>
<td>#{{ m.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}复盘 #{{ trade.id }} · {{ app_name }}{% endblock %}
{% block content %}
<p><a href="{{ url_for('records_page') }}">← 返回列表</a></p>
<h2 style="margin:8px 0 14px">复盘 #{{ trade.id }} · {{ trade.symbol }} · {{ trade.side }}</h2>
<div class="grid cols-2">
<div class="card">
<h3>成交信息</h3>
<table>
<tr><th>账户</th><td>{{ trade.account_name or '—' }}</td></tr>
<tr><th>机器</th><td>{{ trade.machine_name or '—' }}</td></tr>
<tr><th>手数</th><td>{{ trade.volume }}</td></tr>
<tr><th>开仓</th><td>{{ trade.open_price }} / {{ trade.open_time }}</td></tr>
<tr><th>平仓</th><td>{{ trade.close_price }} / {{ trade.close_time }}</td></tr>
<tr><th>盈亏</th><td>{{ trade.profit }}</td></tr>
<tr><th>SL / TP</th><td>{{ trade.sl }} / {{ trade.tp }}</td></tr>
<tr><th>单号</th><td>{{ trade.ticket or '—' }}</td></tr>
</table>
</div>
<div class="card">
<h3>入场形式标注</h3>
<form method="post" action="{{ url_for('record_annotate', tid=trade.id) }}">
<div class="form-row">
<label>入场形式</label>
<select name="entry_style">
{% set styles = ['', 'M15关键位+M1强吞没', '回撤接力', '突破延续', '逆势试错', '其它'] %}
{% for s in styles %}
<option value="{{ s }}" {% if trade.entry_style==s %}selected{% endif %}>{{ s or '(未选)' }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label>复盘备注</label>
<textarea name="review_note" rows="5" placeholder="位不对 / 形不对 / 出场违纪 …">{{ trade.review_note or '' }}</textarea>
</div>
<button class="btn" type="submit">保存标注</button>
</form>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>四周期 K 线(M1 / M5 / M15 / H1)</h3>
<p class="muted">数据来自服务器缓存;由已激活客户端通过 API 上报 bars。无数据时显示占位。</p>
<div class="chart-grid" style="margin-top:10px">
{% for tf in ['M1','M5','M15','H1'] %}
<div class="chart-box">
<h4>{{ tf }} · {{ charts[tf]|length }} 根</h4>
{% if charts[tf] %}
<canvas class="chart-canvas" id="chart-{{ tf }}"
data-bars='{{ charts[tf]|tojson }}'></canvas>
{% else %}
<div class="empty-chart">暂无 {{ tf }} K 线缓存</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}交易复盘 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 6px">交易记录与复盘</h2>
<p class="muted" style="margin:0 0 14px">参考 crypto_monitor 复盘流程:列表筛选 → 点开详情 → 四周期 K 线 + 入场形式标注。</p>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th><th>品种</th><th>方向</th><th>手数</th><th>开仓价</th><th>平仓价</th>
<th>盈亏</th><th>账户</th><th>机器</th><th>入场形式</th><th></th>
</tr>
</thead>
<tbody>
{% for t in trades %}
<tr>
<td>{{ t.id }}</td>
<td>{{ t.symbol }}</td>
<td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td>
<td>{{ t.volume }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.close_price if t.close_price is not none else '—' }}</td>
<td style="color:{% if t.profit and t.profit>0 %}var(--green){% elif t.profit and t.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ t.profit if t.profit is not none else '—' }}
</td>
<td>{{ t.account_name or '—' }}</td>
<td>{{ t.machine_name or '—' }}</td>
<td>{{ t.entry_style or '—' }}</td>
<td><a href="{{ url_for('record_detail', tid=t.id) }}">打开复盘</a></td>
</tr>
{% else %}
<tr><td colspan="11" class="muted">暂无数据。客户端上报成交后出现在此。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}