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:
dekun
2026-10-17 18:07:23 +08:00
parent 1d4d5b06df
commit a4474c28ea
13 changed files with 517 additions and 145 deletions
+189 -56
View File
@@ -106,6 +106,17 @@ def init_db() -> None:
key TEXT PRIMARY KEY,
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()]
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)
conn.execute(
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
(code, account_id, machine_id, "pending", now()),
)
# created_by 存在 activations 可选列:兼容旧库无该列时忽略
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
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
@@ -145,6 +171,10 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
raise ValueError("激活码无效")
if row["status"] == "revoked":
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)
machine_id = row["machine_id"]
@@ -156,7 +186,7 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
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_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"]))
@@ -183,18 +213,36 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
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,),
)
def list_trades(
conn: sqlite3.Connection,
limit: int = 200,
account_id: Optional[int] = None,
) -> list[dict]:
if account_id is None:
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,),
)
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 = []
for r in cur.fetchall():
d = row_to_dict(r)
@@ -226,6 +274,28 @@ def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
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:
n = 0
for b in bars:
@@ -277,61 +347,124 @@ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
def seed_if_empty() -> None:
"""仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。"""
from werkzeug.security import generate_password_hash
from app.config import Config
with get_db() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
if n == 0:
# 兼容旧库:补 activations.created_by
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(
"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()),
)
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),
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
VALUES(?,?,?,?,?,?)""",
(username, pwd_hash, "admin", None, 1, now()),
)
def verify_admin(username: str, password: str) -> bool:
def verify_login(username: str, password: str) -> Optional[dict]:
from werkzeug.security import check_password_hash
with get_db() as conn:
user = get_setting(conn, "admin_user", "admin")
pwd_hash = get_setting(conn, "admin_password_hash")
if username != user:
return False
if not pwd_hash:
from app.config import Config
return password == Config.ADMIN_PASSWORD
return check_password_hash(pwd_hash, password)
user = get_user_by_username(conn, username)
if not user:
return None
if not check_password_hash(user["password_hash"], password):
return None
return user
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
if len(new_password) < 6:
raise ValueError("新密码至少 6 位")
with get_db() as conn:
pwd_hash = get_setting(conn, "admin_password_hash")
if pwd_hash:
if not check_password_hash(pwd_hash, old_password):
raise ValueError("当前密码不正确")
else:
from app.config import Config
user = get_user(conn, user_id)
if not user:
raise ValueError("用户不存在")
if not check_password_hash(user["password_hash"], old_password):
raise ValueError("当前密码不正确")
conn.execute(
"UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(new_password), user_id),
)
if old_password != Config.ADMIN_PASSWORD:
raise ValueError("当前密码不正确")
set_setting(conn, "admin_password_hash", generate_password_hash(new_password))
def create_user(
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
View File
@@ -4,11 +4,11 @@ from __future__ import annotations
import json
import secrets
from functools import wraps
from pathlib import Path
from flask import (
Flask,
flash,
g,
jsonify,
redirect,
render_template,
@@ -25,24 +25,48 @@ from app import db as database
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("user"):
if not session.get("user_id"):
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
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:
@app.context_processor
def inject_globals():
return {
"app_name": "Gold Monitor",
"public_base": Config.PUBLIC_BASE_URL,
"current_user": current_user(),
"is_admin": session.get("role") == "admin",
}
@app.get("/login")
def login():
if session.get("user"):
if session.get("user_id"):
return redirect(url_for("dashboard"))
return render_template("login.html")
@@ -50,11 +74,16 @@ def register_routes(app: Flask) -> None:
def login_post():
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
if database.verify_admin(user, pwd):
session["user"] = user
return redirect(request.args.get("next") or url_for("dashboard"))
flash("用户名或密码错误", "error")
return render_template("login.html"), 401
u = database.verify_login(user, pwd)
if not u:
flash("用户名或密码错误", "error")
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")
def logout():
@@ -76,19 +105,62 @@ def register_routes(app: Flask) -> None:
flash("两次输入的新密码不一致", "error")
return redirect(url_for("settings_page"))
try:
database.change_admin_password(old_pwd, new_pwd)
database.change_user_password(int(session["user_id"]), old_pwd, new_pwd)
flash("密码已更新,请牢记新密码", "ok")
except ValueError as exc:
flash(str(exc), "error")
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("/")
@login_required
def dashboard():
u = current_user()
with database.get_db() as conn:
accounts = database.list_accounts(conn)
machines = database.list_machines(conn)
trades = database.list_trades(conn, 20)
if u["role"] == "admin":
accounts = database.list_accounts(conn)
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(
"dashboard.html",
accounts=accounts,
@@ -96,16 +168,19 @@ def register_routes(app: Flask) -> None:
trades=trades,
)
# ---------- 账户 / 机器 ----------
# ---------- 账户(仅管理员创建)----------
@app.get("/accounts")
@login_required
def accounts_page():
u = current_user()
with database.get_db() as 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")
@login_required
@admin_required
def accounts_create():
name = (request.form.get("name") or "").strip()
if not name:
@@ -125,12 +200,16 @@ def register_routes(app: Flask) -> None:
database.now(),
),
)
flash("账户已添加", "ok")
flash("账户已创建(仅管理员可创建)", "ok")
return redirect(url_for("accounts_page"))
@app.post("/accounts/<int:aid>/note")
@login_required
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()
with database.get_db() as conn:
conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid))
@@ -140,12 +219,26 @@ def register_routes(app: Flask) -> None:
@app.get("/machines")
@login_required
def machines_page():
# 机器列表:管理员全看;普通用户只看与自己账户激活相关的机器
u = current_user()
with database.get_db() as conn:
items = database.list_machines(conn)
return render_template("machines.html", items=items)
if u["role"] == "admin":
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")
@login_required
@admin_required
def machines_create():
name = (request.form.get("name") or "").strip() or "新机器"
with database.get_db() as conn:
@@ -170,22 +263,40 @@ def register_routes(app: Flask) -> None:
flash("机器标注已保存", "ok")
return redirect(url_for("machines_page"))
# ---------- 下载 / 激活 ----------
# ---------- 下载 / 激活(管理员与用户都可发码;用户仅自己账户)----------
@app.get("/download")
@login_required
def download_page():
u = current_user()
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)
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()
if u["role"] == "admin":
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()
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]
exe_name = "黄金市价辅助.exe"
exe_path = RELEASES_DIR / exe_name
@@ -201,15 +312,19 @@ def register_routes(app: Flask) -> None:
@app.post("/download/activation")
@login_required
def download_create_activation():
u = current_user()
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")
if not database.user_can_access_account(u, account_id):
flash("只能为自己的账户发放激活码", "error")
return redirect(url_for("download_page"))
try:
with database.get_db() as conn:
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"))
@app.get("/download/file")
@@ -218,12 +333,10 @@ def register_routes(app: Flask) -> None:
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",
"请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n",
encoding="utf-8",
)
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()})
# ---------- 成交上报 / 复盘 ----------
@app.post("/api/v1/trades")
def api_trades_upsert():
token = request.headers.get("X-Device-Token") or ""
@@ -267,6 +379,8 @@ def register_routes(app: Flask) -> None:
act = database.auth_token(conn, token)
if not act:
return jsonify({"ok": False, "error": "未激活"}), 401
if not act.get("account_id"):
return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400
snapshot = data.get("snapshot") or {}
conn.execute(
"""
@@ -326,19 +440,23 @@ def register_routes(app: Flask) -> None:
@app.get("/records")
@login_required
def records_page():
u = current_user()
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)
@app.get("/records/<int:tid>")
@login_required
def record_detail(tid: int):
u = current_user()
with database.get_db() as conn:
trade = database.get_trade(conn, tid)
if not trade:
flash("记录不存在", "error")
if not trade or not database.user_can_access_account(u, trade.get("account_id")):
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 = {}
@@ -351,9 +469,14 @@ def register_routes(app: Flask) -> None:
@app.post("/records/<int:tid>/annotate")
@login_required
def record_annotate(tid: int):
u = current_user()
entry_style = (request.form.get("entry_style") or "").strip()
note = (request.form.get("review_note") or "").strip()
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(
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
(entry_style, note, tid),
+5 -2
View File
@@ -1,10 +1,12 @@
{% extends "base.html" %}
{% block title %}账户 · {{ app_name }}{% endblock %}
{% 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">
{% if can_create %}
<div class="card">
<h3>新增账户</h3>
<p class="muted">账户禁止自助注册,创建后在「用户」中绑定给唯一操作员。</p>
<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>
@@ -21,8 +23,9 @@
<button class="btn" type="submit">添加</button>
</form>
</div>
{% endif %}
<div class="card">
<h3>已有账户</h3>
<h3>账户列表</h3>
<table>
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
<tbody>
+6 -1
View File
@@ -14,11 +14,16 @@
<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>
{% 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('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>
{% 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>
</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>
{% endif %}
<main class="wrap">
+1
View File
@@ -17,6 +17,7 @@
</div>
<div class="card">
<h3>生成激活码</h3>
<p class="muted">管理员与普通用户均可发放;普通用户只能发给自己的账户。</p>
<form method="post" action="{{ url_for('download_create_activation') }}">
<div class="form-row">
<label>绑定账户</label>
+9 -1
View File
@@ -1,8 +1,9 @@
{% extends "base.html" %}
{% block title %}机器 · {{ app_name }}{% endblock %}
{% 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">
{% if is_admin %}
<div class="card">
<h3>预登记机器</h3>
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
@@ -12,6 +13,7 @@
<button class="btn" type="submit">添加</button>
</form>
</div>
{% endif %}
<div class="card">
<h3>已有机器</h3>
<table>
@@ -20,12 +22,18 @@
{% for m in items %}
<tr>
<td>
{% if is_admin %}
<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>
{% 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 class="muted">{{ m.last_seen_at or '—' }}</td>
<td>#{{ m.id }}</td>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="grid cols-2">
<div class="card">
<h3>修改登录密码</h3>
<p class="muted">修改后立即生效,无需重启服务。请使用足够强度的密码。</p>
<p class="muted">修改当前登录用户密码,立即生效。管理员请在「用户」页创建普通用户。</p>
<form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px">
<div class="form-row">
<label>当前密码</label>
+56
View File
@@ -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 %}