Add roles/ACL so admin and users can issue activation codes; exe opens review login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,14 +39,15 @@ bash deploy/manage.sh
|
|||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **登录** | 网页账号(默认 admin) |
|
| **登录** | 管理员 / 普通用户(一人一账户,账户仅管理员创建) |
|
||||||
| **系统设置** | 前端修改登录密码 |
|
| **用户管理** | 管理员创建用户并绑定唯一交易账户 |
|
||||||
| **下载中心** | 通用辅助包 + 生成激活码 |
|
| **系统设置** | 修改当前登录密码 |
|
||||||
| **账户 / 机器** | 标注名称、备注、类型 |
|
| **下载中心** | 通用辅助包;**管理员与用户均可**为自己权限内账户发激活码 |
|
||||||
| **首次激活** | exe 调用 API 绑定指纹与 token |
|
| **账户 / 机器** | 账户仅管理员创建;机器可预登记或激活时自动创建 |
|
||||||
|
| **首次激活** | exe 调用 API 绑定指纹与 token(账户须已存在) |
|
||||||
| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
|
| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
|
||||||
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 |
|
| **交易复盘** | 按角色过滤;详情四周期 + 入场形式标注 |
|
||||||
| **desktop/** | 已迁入下单辅助,支持激活与上报 |
|
| **desktop/** | 下单辅助:激活、上报、**打开复盘**(浏览器登录页) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ def init_db() -> None:
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
account_id INTEGER UNIQUE,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY(account_id) REFERENCES accounts(id)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,12 +141,27 @@ def list_machines(conn: sqlite3.Connection) -> list[dict]:
|
|||||||
return [row_to_dict(r) for r in cur.fetchall()]
|
return [row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def create_activation(conn: sqlite3.Connection, account_id: int, machine_id: Optional[int]) -> str:
|
def create_activation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
account_id: int,
|
||||||
|
machine_id: Optional[int],
|
||||||
|
created_by_user_id: Optional[int] = None,
|
||||||
|
) -> str:
|
||||||
|
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
|
||||||
|
raise ValueError("账户不存在(须由管理员预先创建)")
|
||||||
code = secrets.token_urlsafe(12)
|
code = secrets.token_urlsafe(12)
|
||||||
conn.execute(
|
# created_by 存在 activations 可选列:兼容旧库无该列时忽略
|
||||||
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
|
||||||
(code, account_id, machine_id, "pending", now()),
|
if "created_by" in cols:
|
||||||
)
|
conn.execute(
|
||||||
|
"INSERT INTO activations(code, account_id, machine_id, status, created_at, created_by) VALUES(?,?,?,?,?,?)",
|
||||||
|
(code, account_id, machine_id, "pending", now(), created_by_user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
|
||||||
|
(code, account_id, machine_id, "pending", now()),
|
||||||
|
)
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +171,10 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
|
|||||||
raise ValueError("激活码无效")
|
raise ValueError("激活码无效")
|
||||||
if row["status"] == "revoked":
|
if row["status"] == "revoked":
|
||||||
raise ValueError("激活码已作废")
|
raise ValueError("激活码已作废")
|
||||||
|
if not row["account_id"]:
|
||||||
|
raise ValueError("激活码未绑定账户,请联系管理员重新发放")
|
||||||
|
if not conn.execute("SELECT id FROM accounts WHERE id=? AND enabled=1", (row["account_id"],)).fetchone():
|
||||||
|
raise ValueError("绑定账户不存在或已停用")
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
machine_id = row["machine_id"]
|
machine_id = row["machine_id"]
|
||||||
@@ -156,7 +186,7 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
|
|||||||
else:
|
else:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)",
|
"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_name or f"PC-{fingerprint[:6]}", fingerprint, "激活绑定", 1, now(), now()),
|
||||||
)
|
)
|
||||||
machine_id = cur.lastrowid
|
machine_id = cur.lastrowid
|
||||||
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"]))
|
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"]))
|
||||||
@@ -183,18 +213,36 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
|
|||||||
return row_to_dict(row)
|
return row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
|
def list_trades(
|
||||||
cur = conn.execute(
|
conn: sqlite3.Connection,
|
||||||
"""
|
limit: int = 200,
|
||||||
SELECT t.*, a.name AS account_name, m.name AS machine_name
|
account_id: Optional[int] = None,
|
||||||
FROM trades t
|
) -> list[dict]:
|
||||||
LEFT JOIN accounts a ON a.id = t.account_id
|
if account_id is None:
|
||||||
LEFT JOIN machines m ON m.id = t.machine_id
|
cur = conn.execute(
|
||||||
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
|
"""
|
||||||
LIMIT ?
|
SELECT t.*, a.name AS account_name, m.name AS machine_name
|
||||||
""",
|
FROM trades t
|
||||||
(limit,),
|
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,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
WHERE t.account_id=?
|
||||||
|
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(account_id, limit),
|
||||||
|
)
|
||||||
rows = []
|
rows = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
d = row_to_dict(r)
|
d = row_to_dict(r)
|
||||||
@@ -226,6 +274,28 @@ def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.*, a.name AS account_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN accounts a ON a.id = u.account_id
|
||||||
|
ORDER BY u.id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(conn: sqlite3.Connection, username: str) -> Optional[dict]:
|
||||||
|
return row_to_dict(
|
||||||
|
conn.execute("SELECT * FROM users WHERE username=? AND enabled=1", (username,)).fetchone()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(conn: sqlite3.Connection, user_id: int) -> Optional[dict]:
|
||||||
|
return row_to_dict(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
|
def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
|
||||||
n = 0
|
n = 0
|
||||||
for b in bars:
|
for b in bars:
|
||||||
@@ -277,61 +347,124 @@ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def seed_if_empty() -> None:
|
def seed_if_empty() -> None:
|
||||||
|
"""仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。"""
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
|
# 兼容旧库:补 activations.created_by
|
||||||
if n == 0:
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
|
||||||
|
if "created_by" not in cols:
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE activations ADD COLUMN created_by INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||||
|
if n_users == 0:
|
||||||
|
# 迁移旧 settings 密码,或使用 .env 默认
|
||||||
|
old_hash = get_setting(conn, "admin_password_hash")
|
||||||
|
pwd_hash = old_hash or generate_password_hash(Config.ADMIN_PASSWORD)
|
||||||
|
username = get_setting(conn, "admin_user") or Config.ADMIN_USER
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)",
|
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
|
||||||
("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()),
|
VALUES(?,?,?,?,?,?)""",
|
||||||
)
|
(username, pwd_hash, "admin", None, 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()),
|
|
||||||
)
|
|
||||||
if not get_setting(conn, "admin_user"):
|
|
||||||
set_setting(conn, "admin_user", Config.ADMIN_USER)
|
|
||||||
if not get_setting(conn, "admin_password_hash"):
|
|
||||||
set_setting(
|
|
||||||
conn,
|
|
||||||
"admin_password_hash",
|
|
||||||
generate_password_hash(Config.ADMIN_PASSWORD),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_admin(username: str, password: str) -> bool:
|
def verify_login(username: str, password: str) -> Optional[dict]:
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
user = get_setting(conn, "admin_user", "admin")
|
user = get_user_by_username(conn, username)
|
||||||
pwd_hash = get_setting(conn, "admin_password_hash")
|
if not user:
|
||||||
if username != user:
|
return None
|
||||||
return False
|
if not check_password_hash(user["password_hash"], password):
|
||||||
if not pwd_hash:
|
return None
|
||||||
from app.config import Config
|
return user
|
||||||
|
|
||||||
return password == Config.ADMIN_PASSWORD
|
|
||||||
return check_password_hash(pwd_hash, password)
|
|
||||||
|
|
||||||
|
|
||||||
def change_admin_password(old_password: str, new_password: str) -> None:
|
def change_user_password(user_id: int, old_password: str, new_password: str) -> None:
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
if len(new_password) < 6:
|
if len(new_password) < 6:
|
||||||
raise ValueError("新密码至少 6 位")
|
raise ValueError("新密码至少 6 位")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
pwd_hash = get_setting(conn, "admin_password_hash")
|
user = get_user(conn, user_id)
|
||||||
if pwd_hash:
|
if not user:
|
||||||
if not check_password_hash(pwd_hash, old_password):
|
raise ValueError("用户不存在")
|
||||||
raise ValueError("当前密码不正确")
|
if not check_password_hash(user["password_hash"], old_password):
|
||||||
else:
|
raise ValueError("当前密码不正确")
|
||||||
from app.config import Config
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash=? WHERE id=?",
|
||||||
|
(generate_password_hash(new_password), user_id),
|
||||||
|
)
|
||||||
|
|
||||||
if old_password != Config.ADMIN_PASSWORD:
|
|
||||||
raise ValueError("当前密码不正确")
|
def create_user(
|
||||||
set_setting(conn, "admin_password_hash", generate_password_hash(new_password))
|
conn: sqlite3.Connection,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
role: str,
|
||||||
|
account_id: Optional[int],
|
||||||
|
) -> int:
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError("用户名不能为空")
|
||||||
|
if len(password) < 6:
|
||||||
|
raise ValueError("密码至少 6 位")
|
||||||
|
if role not in ("admin", "user"):
|
||||||
|
raise ValueError("角色无效")
|
||||||
|
if role == "user":
|
||||||
|
if not account_id:
|
||||||
|
raise ValueError("普通用户必须绑定一个交易账户")
|
||||||
|
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
|
||||||
|
raise ValueError("交易账户不存在")
|
||||||
|
taken = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE account_id=? AND role='user'",
|
||||||
|
(account_id,),
|
||||||
|
).fetchone()
|
||||||
|
if taken:
|
||||||
|
raise ValueError("该交易账户已绑定其他用户(一人一账户)")
|
||||||
|
else:
|
||||||
|
account_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
|
||||||
|
VALUES(?,?,?,?,?,?)""",
|
||||||
|
(username, generate_password_hash(password), role, account_id, 1, now()),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ValueError("用户名已存在或账户已被占用") from exc
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_access_account(user: dict, account_id: Optional[int]) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if user.get("role") == "admin":
|
||||||
|
return True
|
||||||
|
return account_id is not None and int(user.get("account_id") or 0) == int(account_id)
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧调用名
|
||||||
|
def verify_admin(username: str, password: str) -> bool:
|
||||||
|
u = verify_login(username, password)
|
||||||
|
return bool(u and u.get("role") == "admin")
|
||||||
|
|
||||||
|
|
||||||
|
def change_admin_password(old_password: str, new_password: str) -> None:
|
||||||
|
"""兼容:修改名为 admin 的管理员密码。"""
|
||||||
|
with get_db() as conn:
|
||||||
|
u = get_user_by_username(conn, "admin") or conn.execute(
|
||||||
|
"SELECT * FROM users WHERE role='admin' ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not u:
|
||||||
|
raise ValueError("未找到管理员用户")
|
||||||
|
uid = u["id"] if isinstance(u, sqlite3.Row) else u["id"]
|
||||||
|
change_user_password(uid, old_password, new_password)
|
||||||
|
|||||||
+167
-44
@@ -4,11 +4,11 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
flash,
|
flash,
|
||||||
|
g,
|
||||||
jsonify,
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
@@ -25,24 +25,48 @@ from app import db as database
|
|||||||
def login_required(view):
|
def login_required(view):
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
if not session.get("user"):
|
if not session.get("user_id"):
|
||||||
return redirect(url_for("login", next=request.path))
|
return redirect(url_for("login", next=request.path))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not session.get("user_id"):
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
if session.get("role") != "admin":
|
||||||
|
flash("需要管理员权限", "error")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def current_user() -> dict:
|
||||||
|
return {
|
||||||
|
"id": session.get("user_id"),
|
||||||
|
"username": session.get("username"),
|
||||||
|
"role": session.get("role"),
|
||||||
|
"account_id": session.get("account_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app: Flask) -> None:
|
def register_routes(app: Flask) -> None:
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals():
|
def inject_globals():
|
||||||
return {
|
return {
|
||||||
"app_name": "Gold Monitor",
|
"app_name": "Gold Monitor",
|
||||||
"public_base": Config.PUBLIC_BASE_URL,
|
"public_base": Config.PUBLIC_BASE_URL,
|
||||||
|
"current_user": current_user(),
|
||||||
|
"is_admin": session.get("role") == "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
def login():
|
def login():
|
||||||
if session.get("user"):
|
if session.get("user_id"):
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
|
|
||||||
@@ -50,11 +74,16 @@ def register_routes(app: Flask) -> None:
|
|||||||
def login_post():
|
def login_post():
|
||||||
user = (request.form.get("username") or "").strip()
|
user = (request.form.get("username") or "").strip()
|
||||||
pwd = request.form.get("password") or ""
|
pwd = request.form.get("password") or ""
|
||||||
if database.verify_admin(user, pwd):
|
u = database.verify_login(user, pwd)
|
||||||
session["user"] = user
|
if not u:
|
||||||
return redirect(request.args.get("next") or url_for("dashboard"))
|
flash("用户名或密码错误", "error")
|
||||||
flash("用户名或密码错误", "error")
|
return render_template("login.html"), 401
|
||||||
return render_template("login.html"), 401
|
session["user_id"] = u["id"]
|
||||||
|
session["username"] = u["username"]
|
||||||
|
session["role"] = u["role"]
|
||||||
|
session["account_id"] = u.get("account_id")
|
||||||
|
session["user"] = u["username"] # 兼容旧模板
|
||||||
|
return redirect(request.args.get("next") or url_for("dashboard"))
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
@@ -76,19 +105,62 @@ def register_routes(app: Flask) -> None:
|
|||||||
flash("两次输入的新密码不一致", "error")
|
flash("两次输入的新密码不一致", "error")
|
||||||
return redirect(url_for("settings_page"))
|
return redirect(url_for("settings_page"))
|
||||||
try:
|
try:
|
||||||
database.change_admin_password(old_pwd, new_pwd)
|
database.change_user_password(int(session["user_id"]), old_pwd, new_pwd)
|
||||||
flash("密码已更新,请牢记新密码", "ok")
|
flash("密码已更新,请牢记新密码", "ok")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_for("settings_page"))
|
return redirect(url_for("settings_page"))
|
||||||
|
|
||||||
|
# ---------- 用户管理(仅管理员)----------
|
||||||
|
@app.get("/users")
|
||||||
|
@admin_required
|
||||||
|
def users_page():
|
||||||
|
with database.get_db() as conn:
|
||||||
|
users = database.list_users(conn)
|
||||||
|
accounts = database.list_accounts(conn)
|
||||||
|
# 未被占用的账户(用于新建普通用户)
|
||||||
|
used = {u["account_id"] for u in users if u.get("account_id")}
|
||||||
|
free_accounts = [a for a in accounts if a["id"] not in used]
|
||||||
|
return render_template(
|
||||||
|
"users.html",
|
||||||
|
users=users,
|
||||||
|
accounts=accounts,
|
||||||
|
free_accounts=free_accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/users")
|
||||||
|
@admin_required
|
||||||
|
def users_create():
|
||||||
|
username = (request.form.get("username") or "").strip()
|
||||||
|
password = request.form.get("password") or ""
|
||||||
|
role = (request.form.get("role") or "user").strip()
|
||||||
|
account_raw = request.form.get("account_id") or ""
|
||||||
|
account_id = int(account_raw) if account_raw.strip().isdigit() else None
|
||||||
|
try:
|
||||||
|
with database.get_db() as conn:
|
||||||
|
database.create_user(conn, username, password, role, account_id)
|
||||||
|
flash("用户已创建", "ok")
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
return redirect(url_for("users_page"))
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
accounts = database.list_accounts(conn)
|
if u["role"] == "admin":
|
||||||
machines = database.list_machines(conn)
|
accounts = database.list_accounts(conn)
|
||||||
trades = database.list_trades(conn, 20)
|
machines = database.list_machines(conn)
|
||||||
|
trades = database.list_trades(conn, 20)
|
||||||
|
else:
|
||||||
|
accounts = [
|
||||||
|
a
|
||||||
|
for a in database.list_accounts(conn)
|
||||||
|
if a["id"] == u.get("account_id")
|
||||||
|
]
|
||||||
|
machines = database.list_machines(conn)
|
||||||
|
trades = database.list_trades(conn, 20, account_id=u.get("account_id"))
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
accounts=accounts,
|
accounts=accounts,
|
||||||
@@ -96,16 +168,19 @@ def register_routes(app: Flask) -> None:
|
|||||||
trades=trades,
|
trades=trades,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------- 账户 / 机器 ----------
|
# ---------- 账户(仅管理员创建)----------
|
||||||
@app.get("/accounts")
|
@app.get("/accounts")
|
||||||
@login_required
|
@login_required
|
||||||
def accounts_page():
|
def accounts_page():
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
items = database.list_accounts(conn)
|
items = database.list_accounts(conn)
|
||||||
return render_template("accounts.html", items=items)
|
if u["role"] != "admin":
|
||||||
|
items = [a for a in items if a["id"] == u.get("account_id")]
|
||||||
|
return render_template("accounts.html", items=items, can_create=(u["role"] == "admin"))
|
||||||
|
|
||||||
@app.post("/accounts")
|
@app.post("/accounts")
|
||||||
@login_required
|
@admin_required
|
||||||
def accounts_create():
|
def accounts_create():
|
||||||
name = (request.form.get("name") or "").strip()
|
name = (request.form.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -125,12 +200,16 @@ def register_routes(app: Flask) -> None:
|
|||||||
database.now(),
|
database.now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
flash("账户已添加", "ok")
|
flash("账户已创建(仅管理员可创建)", "ok")
|
||||||
return redirect(url_for("accounts_page"))
|
return redirect(url_for("accounts_page"))
|
||||||
|
|
||||||
@app.post("/accounts/<int:aid>/note")
|
@app.post("/accounts/<int:aid>/note")
|
||||||
@login_required
|
@login_required
|
||||||
def accounts_note(aid: int):
|
def accounts_note(aid: int):
|
||||||
|
u = current_user()
|
||||||
|
if not database.user_can_access_account(u, aid):
|
||||||
|
flash("无权修改该账户", "error")
|
||||||
|
return redirect(url_for("accounts_page"))
|
||||||
note = (request.form.get("note") or "").strip()
|
note = (request.form.get("note") or "").strip()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid))
|
conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid))
|
||||||
@@ -140,12 +219,26 @@ def register_routes(app: Flask) -> None:
|
|||||||
@app.get("/machines")
|
@app.get("/machines")
|
||||||
@login_required
|
@login_required
|
||||||
def machines_page():
|
def machines_page():
|
||||||
|
# 机器列表:管理员全看;普通用户只看与自己账户激活相关的机器
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
items = database.list_machines(conn)
|
if u["role"] == "admin":
|
||||||
return render_template("machines.html", items=items)
|
items = database.list_machines(conn)
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT m.* FROM machines m
|
||||||
|
JOIN activations act ON act.machine_id = m.id
|
||||||
|
WHERE act.account_id=?
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
""",
|
||||||
|
(u.get("account_id"),),
|
||||||
|
)
|
||||||
|
items = [database.row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
return render_template("machines.html", items=items, is_admin=(u["role"] == "admin"))
|
||||||
|
|
||||||
@app.post("/machines")
|
@app.post("/machines")
|
||||||
@login_required
|
@admin_required
|
||||||
def machines_create():
|
def machines_create():
|
||||||
name = (request.form.get("name") or "").strip() or "新机器"
|
name = (request.form.get("name") or "").strip() or "新机器"
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
@@ -170,22 +263,40 @@ def register_routes(app: Flask) -> None:
|
|||||||
flash("机器标注已保存", "ok")
|
flash("机器标注已保存", "ok")
|
||||||
return redirect(url_for("machines_page"))
|
return redirect(url_for("machines_page"))
|
||||||
|
|
||||||
# ---------- 下载 / 激活 ----------
|
# ---------- 下载 / 激活(管理员与用户都可发码;用户仅自己账户)----------
|
||||||
@app.get("/download")
|
@app.get("/download")
|
||||||
@login_required
|
@login_required
|
||||||
def download_page():
|
def download_page():
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
accounts = database.list_accounts(conn)
|
all_accounts = database.list_accounts(conn)
|
||||||
|
if u["role"] == "admin":
|
||||||
|
accounts = all_accounts
|
||||||
|
else:
|
||||||
|
accounts = [a for a in all_accounts if a["id"] == u.get("account_id")]
|
||||||
machines = database.list_machines(conn)
|
machines = database.list_machines(conn)
|
||||||
acts = conn.execute(
|
if u["role"] == "admin":
|
||||||
"""
|
acts = conn.execute(
|
||||||
SELECT act.*, a.name AS account_name, m.name AS machine_name
|
"""
|
||||||
FROM activations act
|
SELECT act.*, a.name AS account_name, m.name AS machine_name
|
||||||
LEFT JOIN accounts a ON a.id = act.account_id
|
FROM activations act
|
||||||
LEFT JOIN machines m ON m.id = act.machine_id
|
LEFT JOIN accounts a ON a.id = act.account_id
|
||||||
ORDER BY act.id DESC LIMIT 50
|
LEFT JOIN machines m ON m.id = act.machine_id
|
||||||
"""
|
ORDER BY act.id DESC LIMIT 50
|
||||||
).fetchall()
|
"""
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
WHERE act.account_id=?
|
||||||
|
ORDER BY act.id DESC LIMIT 50
|
||||||
|
""",
|
||||||
|
(u.get("account_id"),),
|
||||||
|
).fetchall()
|
||||||
activations = [database.row_to_dict(r) for r in acts]
|
activations = [database.row_to_dict(r) for r in acts]
|
||||||
exe_name = "黄金市价辅助.exe"
|
exe_name = "黄金市价辅助.exe"
|
||||||
exe_path = RELEASES_DIR / exe_name
|
exe_path = RELEASES_DIR / exe_name
|
||||||
@@ -201,15 +312,19 @@ def register_routes(app: Flask) -> None:
|
|||||||
@app.post("/download/activation")
|
@app.post("/download/activation")
|
||||||
@login_required
|
@login_required
|
||||||
def download_create_activation():
|
def download_create_activation():
|
||||||
|
u = current_user()
|
||||||
account_id = int(request.form.get("account_id") or 0)
|
account_id = int(request.form.get("account_id") or 0)
|
||||||
machine_raw = request.form.get("machine_id") or ""
|
machine_raw = request.form.get("machine_id") or ""
|
||||||
machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None
|
machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None
|
||||||
with database.get_db() as conn:
|
if not database.user_can_access_account(u, account_id):
|
||||||
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
|
flash("只能为自己的账户发放激活码", "error")
|
||||||
flash("账户不存在", "error")
|
return redirect(url_for("download_page"))
|
||||||
return redirect(url_for("download_page"))
|
try:
|
||||||
code = database.create_activation(conn, account_id, machine_id)
|
with database.get_db() as conn:
|
||||||
flash(f"已生成激活码:{code}", "ok")
|
code = database.create_activation(conn, account_id, machine_id, u.get("id"))
|
||||||
|
flash(f"已生成激活码:{code}", "ok")
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
return redirect(url_for("download_page"))
|
return redirect(url_for("download_page"))
|
||||||
|
|
||||||
@app.get("/download/file")
|
@app.get("/download/file")
|
||||||
@@ -218,12 +333,10 @@ def register_routes(app: Flask) -> None:
|
|||||||
name = "黄金市价辅助.exe"
|
name = "黄金市价辅助.exe"
|
||||||
path = RELEASES_DIR / name
|
path = RELEASES_DIR / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
# 提供占位说明文件,避免空目录无下载
|
|
||||||
readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt"
|
readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt"
|
||||||
if not readme.is_file():
|
if not readme.is_file():
|
||||||
readme.write_text(
|
readme.write_text(
|
||||||
"请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n"
|
"请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n",
|
||||||
"客户端首次启动使用网页生成的激活码绑定机器与账户。\n",
|
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True)
|
return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True)
|
||||||
@@ -258,7 +371,6 @@ def register_routes(app: Flask) -> None:
|
|||||||
)
|
)
|
||||||
return jsonify({"ok": True, "server_time": database.now()})
|
return jsonify({"ok": True, "server_time": database.now()})
|
||||||
|
|
||||||
# ---------- 成交上报 / 复盘 ----------
|
|
||||||
@app.post("/api/v1/trades")
|
@app.post("/api/v1/trades")
|
||||||
def api_trades_upsert():
|
def api_trades_upsert():
|
||||||
token = request.headers.get("X-Device-Token") or ""
|
token = request.headers.get("X-Device-Token") or ""
|
||||||
@@ -267,6 +379,8 @@ def register_routes(app: Flask) -> None:
|
|||||||
act = database.auth_token(conn, token)
|
act = database.auth_token(conn, token)
|
||||||
if not act:
|
if not act:
|
||||||
return jsonify({"ok": False, "error": "未激活"}), 401
|
return jsonify({"ok": False, "error": "未激活"}), 401
|
||||||
|
if not act.get("account_id"):
|
||||||
|
return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400
|
||||||
snapshot = data.get("snapshot") or {}
|
snapshot = data.get("snapshot") or {}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -326,19 +440,23 @@ def register_routes(app: Flask) -> None:
|
|||||||
@app.get("/records")
|
@app.get("/records")
|
||||||
@login_required
|
@login_required
|
||||||
def records_page():
|
def records_page():
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
trades = database.list_trades(conn, 300)
|
if u["role"] == "admin":
|
||||||
|
trades = database.list_trades(conn, 300)
|
||||||
|
else:
|
||||||
|
trades = database.list_trades(conn, 300, account_id=u.get("account_id"))
|
||||||
return render_template("records.html", trades=trades)
|
return render_template("records.html", trades=trades)
|
||||||
|
|
||||||
@app.get("/records/<int:tid>")
|
@app.get("/records/<int:tid>")
|
||||||
@login_required
|
@login_required
|
||||||
def record_detail(tid: int):
|
def record_detail(tid: int):
|
||||||
|
u = current_user()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
trade = database.get_trade(conn, tid)
|
trade = database.get_trade(conn, tid)
|
||||||
if not trade:
|
if not trade or not database.user_can_access_account(u, trade.get("account_id")):
|
||||||
flash("记录不存在", "error")
|
flash("记录不存在或无权查看", "error")
|
||||||
return redirect(url_for("records_page"))
|
return redirect(url_for("records_page"))
|
||||||
# 四周期:以开仓时间为中心取前后窗口
|
|
||||||
center = trade.get("open_time") or trade.get("created_at") or database.now()
|
center = trade.get("open_time") or trade.get("created_at") or database.now()
|
||||||
window = 3600 * 6
|
window = 3600 * 6
|
||||||
charts = {}
|
charts = {}
|
||||||
@@ -351,9 +469,14 @@ def register_routes(app: Flask) -> None:
|
|||||||
@app.post("/records/<int:tid>/annotate")
|
@app.post("/records/<int:tid>/annotate")
|
||||||
@login_required
|
@login_required
|
||||||
def record_annotate(tid: int):
|
def record_annotate(tid: int):
|
||||||
|
u = current_user()
|
||||||
entry_style = (request.form.get("entry_style") or "").strip()
|
entry_style = (request.form.get("entry_style") or "").strip()
|
||||||
note = (request.form.get("review_note") or "").strip()
|
note = (request.form.get("review_note") or "").strip()
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
|
trade = database.get_trade(conn, tid)
|
||||||
|
if not trade or not database.user_can_access_account(u, trade.get("account_id")):
|
||||||
|
flash("无权标注该记录", "error")
|
||||||
|
return redirect(url_for("records_page"))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
|
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
|
||||||
(entry_style, note, tid),
|
(entry_style, note, tid),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}账户 · {{ app_name }}{% endblock %}
|
{% block title %}账户 · {{ app_name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 style="margin:0 0 14px">账户标注</h2>
|
<h2 style="margin:0 0 14px">账户{% if can_create %}(仅管理员可创建){% else %}(只读自己的账户){% endif %}</h2>
|
||||||
<div class="grid cols-2">
|
<div class="grid cols-2">
|
||||||
|
{% if can_create %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>新增账户</h3>
|
<h3>新增账户</h3>
|
||||||
|
<p class="muted">账户禁止自助注册,创建后在「用户」中绑定给唯一操作员。</p>
|
||||||
<form method="post" action="{{ url_for('accounts_create') }}">
|
<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="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="broker" placeholder="MT5 券商名"></div>
|
||||||
@@ -21,8 +23,9 @@
|
|||||||
<button class="btn" type="submit">添加</button>
|
<button class="btn" type="submit">添加</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>已有账户</h3>
|
<h3>账户列表</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
|
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -14,11 +14,16 @@
|
|||||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
|
<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('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('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a>
|
||||||
|
{% if is_admin %}
|
||||||
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' 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('users_page') }}" class="{% if request.endpoint and 'users' in request.endpoint %}active{% endif %}">用户</a>
|
||||||
<a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a>
|
<a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">我的账户</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('settings_page') }}" class="{% if request.endpoint and 'settings' in request.endpoint %}active{% endif %}">系统设置</a>
|
<a href="{{ url_for('settings_page') }}" class="{% if request.endpoint and 'settings' in request.endpoint %}active{% endif %}">系统设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
|
<div class="right">{{ current_user.username }}({{ current_user.role }}) · <a href="{{ url_for('logout') }}">退出</a></div>
|
||||||
</header>
|
</header>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>生成激活码</h3>
|
<h3>生成激活码</h3>
|
||||||
|
<p class="muted">管理员与普通用户均可发放;普通用户只能发给自己的账户。</p>
|
||||||
<form method="post" action="{{ url_for('download_create_activation') }}">
|
<form method="post" action="{{ url_for('download_create_activation') }}">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>绑定账户</label>
|
<label>绑定账户</label>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}机器 · {{ app_name }}{% endblock %}
|
{% block title %}机器 · {{ app_name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 style="margin:0 0 14px">机器标注</h2>
|
<h2 style="margin:0 0 14px">机器{% if is_admin %}(管理员可预登记){% else %}(与本账户相关){% endif %}</h2>
|
||||||
<div class="grid cols-2">
|
<div class="grid cols-2">
|
||||||
|
{% if is_admin %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>预登记机器</h3>
|
<h3>预登记机器</h3>
|
||||||
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
|
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
<button class="btn" type="submit">添加</button>
|
<button class="btn" type="submit">添加</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>已有机器</h3>
|
<h3>已有机器</h3>
|
||||||
<table>
|
<table>
|
||||||
@@ -20,12 +22,18 @@
|
|||||||
{% for m in items %}
|
{% for m in items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
{% if is_admin %}
|
||||||
<form method="post" action="{{ url_for('machines_note', mid=m.id) }}">
|
<form method="post" action="{{ url_for('machines_note', mid=m.id) }}">
|
||||||
<input name="name" value="{{ m.name }}" style="margin-bottom:6px">
|
<input name="name" value="{{ m.name }}" style="margin-bottom:6px">
|
||||||
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
|
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
|
||||||
<textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea>
|
<textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea>
|
||||||
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
|
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div>{{ m.name }}</div>
|
||||||
|
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
|
||||||
|
{% if m.note %}<div class="muted" style="margin-top:6px">{{ m.note }}</div>{% endif %}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{{ m.last_seen_at or '—' }}</td>
|
<td class="muted">{{ m.last_seen_at or '—' }}</td>
|
||||||
<td>#{{ m.id }}</td>
|
<td>#{{ m.id }}</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="grid cols-2">
|
<div class="grid cols-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>修改登录密码</h3>
|
<h3>修改登录密码</h3>
|
||||||
<p class="muted">修改后立即生效,无需重启服务。请使用足够强度的密码。</p>
|
<p class="muted">修改当前登录用户密码,立即生效。管理员请在「用户」页创建普通用户。</p>
|
||||||
<form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px">
|
<form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>当前密码</label>
|
<label>当前密码</label>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% 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">仅管理员可创建。普通用户必须绑定唯一交易账户(一人一账户,账户不可自助注册)。</p>
|
||||||
|
<div class="grid cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>创建用户</h3>
|
||||||
|
<form method="post" action="{{ url_for('users_create') }}">
|
||||||
|
<div class="form-row"><label>用户名</label><input name="username" required></div>
|
||||||
|
<div class="form-row"><label>初始密码</label><input name="password" type="password" required minlength="6"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>角色</label>
|
||||||
|
<select name="role" id="roleSel">
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="accountRow">
|
||||||
|
<label>绑定交易账户(普通用户必选,且未被占用)</label>
|
||||||
|
<select name="account_id">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for a in free_accounts %}
|
||||||
|
<option value="{{ a.id }}">#{{ a.id }} {{ a.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</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 u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td><span class="tag {% if u.role=='admin' %}green{% else %}gray{% endif %}">{{ u.role }}</span></td>
|
||||||
|
<td>{{ u.account_name or (u.account_id or '—') }}</td>
|
||||||
|
<td>{{ '启用' if u.enabled else '停用' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const sel = document.getElementById('roleSel');
|
||||||
|
const row = document.getElementById('accountRow');
|
||||||
|
function sync(){ row.style.display = sel.value === 'admin' ? 'none' : 'block'; }
|
||||||
|
sel.addEventListener('change', sync); sync();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
||||||
- **通用包 + 首次激活**:启动时填写中控地址与激活码
|
- **通用包 + 首次激活**:启动时填写中控地址与激活码
|
||||||
|
- **打开复盘**:主界面按钮用浏览器打开中控 `/login`,用网页账号直接登录复盘
|
||||||
- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
|
- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
|
||||||
|
|
||||||
## 本地配置
|
## 本地配置
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import math
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, simpledialog, ttk
|
||||||
from typing import Any, Optional, Tuple, Union
|
from typing import Any, Optional, Tuple, Union
|
||||||
|
|
||||||
# 顶层导入,避免打包 exe 漏依赖
|
# 顶层导入,避免打包 exe 漏依赖
|
||||||
@@ -66,6 +67,17 @@ BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本
|
|||||||
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
|
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
|
||||||
|
|
||||||
|
|
||||||
|
def simpledialog_ask_server(parent: tk.Misc) -> str:
|
||||||
|
"""未激活或本地无中控地址时,让用户输入复盘服务器 URL。"""
|
||||||
|
value = simpledialog.askstring(
|
||||||
|
"打开复盘",
|
||||||
|
"请输入中控地址(例如 http://192.168.1.10:5800)",
|
||||||
|
parent=parent,
|
||||||
|
initialvalue="http://127.0.0.1:5800",
|
||||||
|
)
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def enable_dpi_awareness() -> None:
|
def enable_dpi_awareness() -> None:
|
||||||
"""避免高分屏下 Tk 窗口被系统拉伸发虚。"""
|
"""避免高分屏下 Tk 窗口被系统拉伸发虚。"""
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
@@ -743,6 +755,9 @@ class OrderApp(tk.Tk):
|
|||||||
ttk.Label(pos_bar, text="持仓记录", font=("Microsoft YaHei UI", 13, "bold")).pack(side=tk.LEFT)
|
ttk.Label(pos_bar, text="持仓记录", font=("Microsoft YaHei UI", 13, "bold")).pack(side=tk.LEFT)
|
||||||
self.btn_break_even = self._mk_btn(pos_bar, "保本", COLOR_BE, self.on_break_even, h=34, w=90)
|
self.btn_break_even = self._mk_btn(pos_bar, "保本", COLOR_BE, self.on_break_even, h=34, w=90)
|
||||||
self.btn_break_even.pack(side=tk.RIGHT, padx=4)
|
self.btn_break_even.pack(side=tk.RIGHT, padx=4)
|
||||||
|
self._mk_btn(pos_bar, "打开复盘", "#3a5a80", self.on_open_review, h=34, w=100).pack(
|
||||||
|
side=tk.RIGHT, padx=4
|
||||||
|
)
|
||||||
self._set_be_button_enabled(False)
|
self._set_be_button_enabled(False)
|
||||||
|
|
||||||
# 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮
|
# 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮
|
||||||
@@ -1430,6 +1445,24 @@ class OrderApp(tk.Tk):
|
|||||||
|
|
||||||
self._run_async(work)
|
self._run_async(work)
|
||||||
|
|
||||||
|
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
|
||||||
|
url = server.rstrip("/") + "/login"
|
||||||
|
try:
|
||||||
|
webbrowser.open(url)
|
||||||
|
self.set_status(f"已打开复盘登录页:{url}", ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
self.set_status(f"无法打开浏览器:{exc}", ok=False)
|
||||||
|
|
||||||
def destroy(self) -> None:
|
def destroy(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.bridge.shutdown()
|
self.bridge.shutdown()
|
||||||
|
|||||||
+10
-7
@@ -27,12 +27,15 @@ Windows exe (通用包+激活) ──API───────────┘
|
|||||||
|
|
||||||
| 表 | 用途 |
|
| 表 | 用途 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| `accounts` | 交易账户与标注 |
|
| `users` | 登录用户:`role`=admin/user,普通用户必绑唯一 `account_id` |
|
||||||
|
| `accounts` | 交易账户与标注(仅管理员创建,禁止自助注册) |
|
||||||
| `machines` | 机器、指纹、在线时间 |
|
| `machines` | 机器、指纹、在线时间 |
|
||||||
| `activations` | 激活码、token、状态 |
|
| `activations` | 激活码、token、状态、`created_by` 发放人 |
|
||||||
| `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) |
|
| `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) |
|
||||||
| `bars` | K 线缓存(symbol+timeframe+ts 唯一) |
|
| `bars` | K 线缓存(symbol+timeframe+ts 唯一) |
|
||||||
|
|
||||||
|
**权限**:管理员看全部;普通用户复盘/激活码仅限自己的 `account_id`。激活码可由 admin 与 user 发放(user 仅自己账户)。
|
||||||
|
|
||||||
## 4. HTTP API
|
## 4. HTTP API
|
||||||
|
|
||||||
### 健康检查
|
### 健康检查
|
||||||
@@ -115,17 +118,17 @@ Header:`X-Device-Token`
|
|||||||
| `deploy/ecosystem.config.cjs` | PM2 |
|
| `deploy/ecosystem.config.cjs` | PM2 |
|
||||||
| `deploy/manage.sh` | 交互部署 |
|
| `deploy/manage.sh` | 交互部署 |
|
||||||
|
|
||||||
## 7. 管理员密码
|
## 7. 用户与密码
|
||||||
|
|
||||||
- 表 `settings`:`admin_user` / `admin_password_hash`(Werkzeug hash)
|
- 表 `users`:密码为 Werkzeug hash;首次启动用 `.env` 的 `ADMIN_USER` / `ADMIN_PASSWORD` 种子管理员
|
||||||
- 首次启动用 `.env` 的 `ADMIN_PASSWORD` 写入 hash
|
- 网页「系统设置」改当前用户密码,**不写回 .env**
|
||||||
- 网页「系统设置」可改密,**不写回 .env**,以数据库为准
|
- 普通用户由管理员在 `/users` 创建并绑定账户
|
||||||
|
|
||||||
## 8. 客户端(desktop/)
|
## 8. 客户端(desktop/)
|
||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `gold_order_app.py` | 下单主程序(已迁入) |
|
| `gold_order_app.py` | 下单主程序;含「打开复盘」→ 浏览器 `/login` |
|
||||||
| `activation_dialog.py` | 首次激活 UI |
|
| `activation_dialog.py` | 首次激活 UI |
|
||||||
| `hub_client.py` | HTTP 激活/上报 |
|
| `hub_client.py` | HTTP 激活/上报 |
|
||||||
| `device_store.py` | `~/.gold_monitor/device.json` |
|
| `device_store.py` | `~/.gold_monitor/device.json` |
|
||||||
|
|||||||
+30
-25
@@ -3,15 +3,24 @@
|
|||||||
## 1. 登录网页
|
## 1. 登录网页
|
||||||
|
|
||||||
浏览器打开:`http://<服务器IP>:5800`
|
浏览器打开:`http://<服务器IP>:5800`
|
||||||
默认:`admin` / `admin123`(部署后请修改)。
|
或在已激活的 exe 上点 **打开复盘**,会用浏览器打开登录页。
|
||||||
|
|
||||||
|
默认管理员:`admin` / `admin123`(部署后请立即修改)。
|
||||||
|
普通用户由管理员在 **用户** 页创建,**一人绑定一个交易账户**,不可自助注册账户。
|
||||||
|
|
||||||
|
### 角色说明
|
||||||
|
|
||||||
|
| 角色 | 权限 |
|
||||||
|
|------|------|
|
||||||
|
| **管理员** | 创建账户、创建用户、看全部复盘、给任意账户发激活码、管理机器 |
|
||||||
|
| **普通用户** | 只看自己账户的复盘与下载中心;可为**自己的账户**发放激活码;改自己的登录密码 |
|
||||||
|
|
||||||
顶栏:
|
顶栏:
|
||||||
|
|
||||||
- **总览**:账户/机器数量、最近成交
|
- **总览** / **交易复盘**:按角色过滤数据
|
||||||
- **交易复盘**:列表与详情
|
- **下载中心**:下载通用 exe + 生成激活码(用户和管理员都可发)
|
||||||
- **下载中心**:通用 exe + 激活码
|
- **账户 / 用户 / 机器**:管理员可见;普通用户仅「我的账户」
|
||||||
- **账户 / 机器**:标注管理
|
- **系统设置**:修改当前登录用户密码
|
||||||
- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启)
|
|
||||||
|
|
||||||
### 修改密码
|
### 修改密码
|
||||||
|
|
||||||
@@ -19,23 +28,18 @@
|
|||||||
2. 输入当前密码与新密码(至少 6 位)→ 保存
|
2. 输入当前密码与新密码(至少 6 位)→ 保存
|
||||||
3. 下次登录使用新密码
|
3. 下次登录使用新密码
|
||||||
|
|
||||||
> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。
|
> 初始管理员密码来自部署时的 `.env`(默认 admin123);修改后以系统设置为准。
|
||||||
|
|
||||||
## 2. 标注账户与机器
|
## 2. 管理员:建账户与用户
|
||||||
|
|
||||||
### 账户
|
1. **账户** 页新增交易账户(模拟/美分/实盘),禁止用户自助注册
|
||||||
|
2. **用户** 页创建普通用户,并绑定**唯一、尚未占用**的账户
|
||||||
「账户」页新增:名称、券商、登录号、类型(模拟/美分/实盘)、备注。
|
3. 把登录账号发给该操作员
|
||||||
用于激活码绑定与复盘归属。
|
|
||||||
|
|
||||||
### 机器
|
|
||||||
|
|
||||||
可预登记机器名称与备注;也可不登记,**激活时自动创建**并写入设备指纹。
|
|
||||||
|
|
||||||
## 3. 下载通用包并首次激活
|
## 3. 下载通用包并首次激活
|
||||||
|
|
||||||
1. 打开 **下载中心**
|
1. 打开 **下载中心**(管理员或普通用户均可)
|
||||||
2. 选择要绑定的 **账户**(可选预绑定机器)→ **生成激活码**
|
2. 选择账户 → **生成激活码**(普通用户只能选自己的账户)
|
||||||
3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`)
|
3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`)
|
||||||
4. 在 Windows 上启动 exe:
|
4. 在 Windows 上启动 exe:
|
||||||
- 填写服务器地址,例如 `http://192.168.1.10:5800`
|
- 填写服务器地址,例如 `http://192.168.1.10:5800`
|
||||||
@@ -48,20 +52,21 @@
|
|||||||
|
|
||||||
1. 先登录本机 MT5
|
1. 先登录本机 MT5
|
||||||
2. 打开已激活的辅助 exe 下单 / 保本 / 平仓
|
2. 打开已激活的辅助 exe 下单 / 保本 / 平仓
|
||||||
3. 客户端在成交或平仓时自动上报(接口见技术文档)
|
3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可
|
||||||
|
4. 客户端在成交或平仓时自动上报(接口见技术文档)
|
||||||
|
|
||||||
## 5. 网页复盘
|
## 5. 网页复盘
|
||||||
|
|
||||||
1. 打开 **交易复盘**
|
1. 打开 **交易复盘**(普通用户只看到自己账户的成交)
|
||||||
2. 点某笔 **打开复盘**
|
2. 点某笔 **打开复盘**
|
||||||
3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图
|
3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图
|
||||||
4. 选择 **入场形式**(如「M15关键位+M1强吞没」)并填写备注 → 保存
|
4. 选择 **入场形式** 并填写备注 → 保存
|
||||||
|
|
||||||
> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars,或稍后在客户端补传。
|
> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars。
|
||||||
|
|
||||||
## 6. 推荐练手流程
|
## 6. 推荐练手流程
|
||||||
|
|
||||||
1. 美分/模拟账户在「账户」中建好并标注
|
1. 管理员建美分/模拟账户,并创建对应用户
|
||||||
2. 每台电脑生成激活码并激活
|
2. 用户登录 → 下载中心为自己账户生成激活码 → 本机激活
|
||||||
3. 按纪律交易,自动上报
|
3. 按纪律交易,自动上报
|
||||||
4. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪)
|
4. 每日网页复盘(exe「打开复盘」或浏览器)
|
||||||
|
|||||||
Reference in New Issue
Block a user