diff --git a/edc-web/app/__init__.py b/edc-web/app/__init__.py index 135484a..3e99325 100644 --- a/edc-web/app/__init__.py +++ b/edc-web/app/__init__.py @@ -8,13 +8,43 @@ def create_app() -> Flask: app = Flask(__name__) app.config.from_object(Config) + # 初始化认证 + from app.auth import init_auth, auth_bp + init_auth(app) + app.register_blueprint(auth_bp) + # 注册蓝图 from app.routes.devices import bp as devices_bp from app.routes.test_op import bp as test_op_bp from app.routes.test_data import bp as test_data_bp + from app.routes.users import bp as users_bp + from app.routes.logs import bp as logs_bp app.register_blueprint(devices_bp) app.register_blueprint(test_op_bp) app.register_blueprint(test_data_bp) + app.register_blueprint(users_bp) + app.register_blueprint(logs_bp) + + # 初始化默认管理员 + _ensure_admin() return app + + +def _ensure_admin(): + """如果没有任何用户,创建默认 admin/admin123""" + from app.models import get_conn + from werkzeug.security import generate_password_hash + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) as cnt FROM tb_user") + if cur.fetchone()["cnt"] == 0: + cur.execute( + "INSERT INTO tb_user (username, password_hash, role) VALUES (%s,%s,%s)", + ("admin", generate_password_hash("admin123"), "admin"), + ) + conn.commit() + finally: + conn.close() diff --git a/edc-web/app/__pycache__/__init__.cpython-311.pyc b/edc-web/app/__pycache__/__init__.cpython-311.pyc index 2c7a4d0..adfd98f 100644 Binary files a/edc-web/app/__pycache__/__init__.cpython-311.pyc and b/edc-web/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/edc-web/app/__pycache__/models.cpython-311.pyc b/edc-web/app/__pycache__/models.cpython-311.pyc index 2aaf9c1..0a26320 100644 Binary files a/edc-web/app/__pycache__/models.cpython-311.pyc and b/edc-web/app/__pycache__/models.cpython-311.pyc differ diff --git a/edc-web/app/auth.py b/edc-web/app/auth.py new file mode 100644 index 0000000..65c310a --- /dev/null +++ b/edc-web/app/auth.py @@ -0,0 +1,88 @@ +"""认证模块 — Flask-Login 集成""" + +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import LoginManager, login_user, logout_user, login_required, current_user +from werkzeug.security import check_password_hash +from app.models import get_user_by_username, insert_log + +auth_bp = Blueprint("auth", __name__) +login_manager = LoginManager() +login_manager.login_view = "auth.login" +login_manager.login_message = "请先登录" + + +class User: + """Flask-Login 用户对象""" + def __init__(self, user_dict): + self.id = user_dict["id"] + self.username = user_dict["username"] + self.role = user_dict["role"] + self.is_active = bool(user_dict.get("is_active", 1)) + + @property + def is_authenticated(self): + return True + + def get_id(self): + return str(self.id) + + +@login_manager.user_loader +def load_user(user_id): + conn = __import__("app.models", fromlist=["get_conn"]).get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT * FROM tb_user WHERE id=%s", (int(user_id),)) + row = cur.fetchone() + finally: + conn.close() + return User(row) if row else None + + +def init_auth(app): + login_manager.init_app(app) + + +# ─── 装饰器 ──────────────────────────────────────────────────────── + +def admin_required(f): + """要求 admin 角色""" + from functools import wraps + @wraps(f) + @login_required + def wrapper(*args, **kwargs): + if current_user.role != "admin": + return "权限不足", 403 + return f(*args, **kwargs) + return wrapper + + +# ─── 登录 / 登出 ──────────────────────────────────────────────────── + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user_dict = get_user_by_username(username) + ip = request.remote_addr or "" + + if user_dict and user_dict.get("is_active") and check_password_hash(user_dict["password_hash"], password): + user = User(user_dict) + login_user(user) + insert_log(user.id, user.username, "login", ip=ip, result="ok") + next_page = request.args.get("next") + return redirect(next_page or url_for("devices.index")) + else: + insert_log(0, username, "login", detail="密码错误或账号禁用", ip=ip, result="error") + flash("用户名或密码错误") + return render_template("login.html") + + +@auth_bp.route("/logout") +@login_required +def logout(): + insert_log(current_user.id, current_user.username, "logout", + ip=request.remote_addr or "", result="ok") + logout_user() + return redirect(url_for("auth.login")) diff --git a/edc-web/app/models.py b/edc-web/app/models.py index cd008a4..f8663df 100644 --- a/edc-web/app/models.py +++ b/edc-web/app/models.py @@ -236,3 +236,108 @@ def get_automation_averages(dnt_id: int) -> dict: if row: return {k: round(v, 2) if v else 0 for k, v in row.items()} return {} + + +# ─── 用户管理 ────────────────────────────────────────────────────── + +def get_user_by_username(username: str) -> dict | None: + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT * FROM tb_user WHERE username=%s", (username,)) + return cur.fetchone() + finally: + conn.close() + + +def get_all_users() -> list[dict]: + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT id, username, role, is_active, create_time FROM tb_user ORDER BY id") + return cur.fetchall() + finally: + conn.close() + + +def create_user(username: str, password_hash: str, role: str = "operator"): + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO tb_user (username, password_hash, role) VALUES (%s,%s,%s)", + (username, password_hash, role), + ) + conn.commit() + finally: + conn.close() + + +def update_user(user_id: int, password_hash: str = None, role: str = None, is_active: bool = None): + conn = get_conn() + try: + with conn.cursor() as cur: + parts = [] + params = [] + if password_hash is not None: + parts.append("password_hash=%s") + params.append(password_hash) + if role is not None: + parts.append("role=%s") + params.append(role) + if is_active is not None: + parts.append("is_active=%s") + params.append(int(is_active)) + if parts: + params.append(user_id) + cur.execute(f"UPDATE tb_user SET {', '.join(parts)} WHERE id=%s", params) + conn.commit() + finally: + conn.close() + + +# ─── 日志管理 ────────────────────────────────────────────────────── + +def insert_log(user_id: int, username: str, action_type: str, + target: str = "", detail: str = "", result: str = "ok", + ip: str = ""): + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO tb_log (user_id, username, action_type, target, detail, result, ip) " + "VALUES (%s,%s,%s,%s,%s,%s,%s)", + (user_id, username, action_type, target, detail, result, ip), + ) + conn.commit() + finally: + conn.close() + + +def get_logs(page: int = 1, per_page: int = 30, + username: str = "", action_type: str = "") -> tuple[list[dict], int]: + conn = get_conn() + try: + with conn.cursor() as cur: + where = [] + params = [] + if username: + where.append("username LIKE %s") + params.append(f"%{username}%") + if action_type: + where.append("action_type=%s") + params.append(action_type) + where_clause = " AND ".join(where) if where else "1=1" + + cur.execute(f"SELECT COUNT(*) as total FROM tb_log WHERE {where_clause}", params) + total = cur.fetchone()["total"] + + offset = (page - 1) * per_page + cur.execute( + f"SELECT * FROM tb_log WHERE {where_clause} " + f"ORDER BY id DESC LIMIT %s OFFSET %s", + params + [per_page, offset], + ) + return cur.fetchall(), total + finally: + conn.close() diff --git a/edc-web/app/routes/__pycache__/devices.cpython-311.pyc b/edc-web/app/routes/__pycache__/devices.cpython-311.pyc index 7ffece7..b282241 100644 Binary files a/edc-web/app/routes/__pycache__/devices.cpython-311.pyc and b/edc-web/app/routes/__pycache__/devices.cpython-311.pyc differ diff --git a/edc-web/app/routes/__pycache__/test_data.cpython-311.pyc b/edc-web/app/routes/__pycache__/test_data.cpython-311.pyc index 2c9bc9f..82109d4 100644 Binary files a/edc-web/app/routes/__pycache__/test_data.cpython-311.pyc and b/edc-web/app/routes/__pycache__/test_data.cpython-311.pyc differ diff --git a/edc-web/app/routes/__pycache__/test_op.cpython-311.pyc b/edc-web/app/routes/__pycache__/test_op.cpython-311.pyc index ae26268..9d28dbe 100644 Binary files a/edc-web/app/routes/__pycache__/test_op.cpython-311.pyc and b/edc-web/app/routes/__pycache__/test_op.cpython-311.pyc differ diff --git a/edc-web/app/routes/devices.py b/edc-web/app/routes/devices.py index 013a1eb..1c3a611 100644 --- a/edc-web/app/routes/devices.py +++ b/edc-web/app/routes/devices.py @@ -1,12 +1,14 @@ """设备页面 API""" from flask import Blueprint, jsonify, render_template, request +from flask_login import login_required from app.models import get_all_devices, update_device_name bp = Blueprint("devices", __name__) @bp.route("/") +@login_required def index(): """设备列表页(默认首页)""" return render_template("devices.html") diff --git a/edc-web/app/routes/logs.py b/edc-web/app/routes/logs.py new file mode 100644 index 0000000..405dc57 --- /dev/null +++ b/edc-web/app/routes/logs.py @@ -0,0 +1,32 @@ +"""日志查询 API""" + +from flask import Blueprint, jsonify, render_template, request +from flask_login import login_required +from app.auth import admin_required +from app.models import get_logs + +bp = Blueprint("logs", __name__, url_prefix="/logs") + + +@bp.route("/") +@admin_required +def logs_page(): + return render_template("logs.html") + + +@bp.route("/api/logs") +@admin_required +def api_logs(): + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 30, type=int) + username = request.args.get("username", "", type=str) + action_type = request.args.get("action_type", "", type=str) + + records, total = get_logs(page, per_page, username, action_type) + return jsonify({ + "records": records, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if total > 0 else 1, + }) diff --git a/edc-web/app/routes/test_data.py b/edc-web/app/routes/test_data.py index 394f541..2540291 100644 --- a/edc-web/app/routes/test_data.py +++ b/edc-web/app/routes/test_data.py @@ -3,6 +3,7 @@ import csv import io from flask import Blueprint, jsonify, render_template, request, Response +from flask_login import login_required from app.models import get_test_data, get_all_test_data_for_export bp = Blueprint("test_data", __name__) diff --git a/edc-web/app/routes/test_op.py b/edc-web/app/routes/test_op.py index 91a29f2..9414821 100644 --- a/edc-web/app/routes/test_op.py +++ b/edc-web/app/routes/test_op.py @@ -1,7 +1,7 @@ """测试操作 API""" -import time from flask import Blueprint, jsonify, render_template, request +from flask_login import login_required, current_user from app.models import ( get_device_by_id, insert_serialnet, @@ -9,12 +9,12 @@ from app.models import ( get_latest_test_state, get_automation_averages, clear_serialnet_records, + insert_log, ) bp = Blueprint("test_op", __name__) # DG430 指令 (addr=0x01, ADDR=0x81) -# XOR/SUM 预计算值 COMMANDS = { "B0": "7F8101B03032", # 开始测试 "B1": "7F8101B13133", # 测试复原 @@ -23,8 +23,17 @@ COMMANDS = { "BC": "7F8101BC3C3E", # 电机停止 } +CMD_NAMES = { + "B0": "开始测试", + "B1": "测试复原", + "BA": "电机前进", + "BB": "电机后退", + "BC": "电机停止", +} + @bp.route("/test/") +@login_required def test_page(dnt_id): """测试操作页面""" device = get_device_by_id(dnt_id) @@ -34,6 +43,7 @@ def test_page(dnt_id): @bp.route("/api/command", methods=["POST"]) +@login_required def api_command(): """发送单次指令""" data = request.get_json() @@ -43,21 +53,54 @@ def api_command(): if cmd not in COMMANDS: return jsonify({"ok": False, "error": f"未知指令: {cmd}"}), 400 + device = get_device_by_id(dnt_id) + target = f"{device['serial']}" if device else f"dnt_id={dnt_id}" + send_pkg = COMMANDS[cmd] - record_id = insert_serialnet(dnt_id, send_pkg) - return jsonify({"ok": True, "record_id": record_id, "send_pkg": send_pkg}) + cmd_name = CMD_NAMES.get(cmd, cmd) + try: + record_id = insert_serialnet(dnt_id, send_pkg) + insert_log( + current_user.id, current_user.username, "command", + target=target, + detail=f"{cmd_name}({cmd}) → {send_pkg}", + result="ok", + ip=request.remote_addr or "", + ) + return jsonify({"ok": True, "record_id": record_id, "send_pkg": send_pkg}) + except Exception as e: + insert_log( + current_user.id, current_user.username, "command", + target=target, + detail=f"{cmd_name}({cmd}) 失败: {e}", + result="error", + ip=request.remote_addr or "", + ) + return jsonify({"ok": False, "error": str(e)}), 500 @bp.route("/api/automation/start", methods=["POST"]) +@login_required def api_automation_start(): """开始自动化测试""" data = request.get_json() dnt_id = data.get("dnt_id") count = int(data.get("count", 1)) + device = get_device_by_id(dnt_id) + target = f"{device['serial']}" if device else f"dnt_id={dnt_id}" + # 清除旧记录,然后插入第一条 0xB0 clear_serialnet_records(dnt_id) record_id = insert_serialnet(dnt_id, COMMANDS["B0"]) + + insert_log( + current_user.id, current_user.username, "command", + target=target, + detail=f"自动化测试开始 ×{count} 次", + result="ok", + ip=request.remote_addr or "", + ) return jsonify({ "ok": True, "total": count, @@ -66,6 +109,7 @@ def api_automation_start(): @bp.route("/api/automation//progress") +@login_required def api_automation_progress(dnt_id): """获取自动化进度""" stats = get_serialnet_stats(dnt_id) diff --git a/edc-web/app/routes/users.py b/edc-web/app/routes/users.py new file mode 100644 index 0000000..6106baa --- /dev/null +++ b/edc-web/app/routes/users.py @@ -0,0 +1,54 @@ +"""用户管理 API""" + +from flask import Blueprint, jsonify, render_template, request +from flask_login import login_required, current_user +from werkzeug.security import generate_password_hash +from app.auth import admin_required +from app.models import get_all_users, create_user, update_user, get_user_by_username + +bp = Blueprint("users", __name__, url_prefix="/users") + + +@bp.route("/") +@admin_required +def users_page(): + return render_template("users.html") + + +@bp.route("/api/users") +@admin_required +def api_users(): + return jsonify(get_all_users()) + + +@bp.route("/api/users", methods=["POST"]) +@admin_required +def api_create_user(): + data = request.get_json() + username = data.get("username", "").strip() + password = data.get("password", "").strip() + role = data.get("role", "operator") + + if not username or not password: + return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400 + if get_user_by_username(username): + return jsonify({"ok": False, "error": "用户名已存在"}), 400 + + create_user(username, generate_password_hash(password), role) + return jsonify({"ok": True}) + + +@bp.route("/api/users/", methods=["PUT"]) +@admin_required +def api_update_user(user_id): + data = request.get_json() + kwargs = {} + if "password" in data and data["password"]: + kwargs["password_hash"] = generate_password_hash(data["password"]) + if "role" in data: + kwargs["role"] = data["role"] + if "is_active" in data: + kwargs["is_active"] = data["is_active"] + if kwargs: + update_user(user_id, **kwargs) + return jsonify({"ok": True}) diff --git a/edc-web/app/static/css/style.css b/edc-web/app/static/css/style.css index cc9f3a4..a9081ff 100644 --- a/edc-web/app/static/css/style.css +++ b/edc-web/app/static/css/style.css @@ -7,6 +7,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; b .top-menu a { color: #bdc3c7; text-decoration: none; padding: 14px 24px; font-size: 15px; transition: .2s; } .top-menu a:hover { color: #fff; background: #34495e; } .top-menu a.active { color: #fff; background: #3498db; } +.top-menu .user-info { margin-left: auto; color: #bdc3c7; padding: 14px 0; font-size: 13px; display: flex; align-items: center; gap: 12px; } +.top-menu .user-info a { padding: 4px 12px; background: #e74c3c; border-radius: 4px; font-size: 12px; } +.top-menu .user-info a:hover { background: #c0392b; } /* === Container === */ .container { max-width: 1400px; margin: 24px auto; padding: 0 24px; } diff --git a/edc-web/app/templates/base.html b/edc-web/app/templates/base.html index f0e4cac..df2735a 100644 --- a/edc-web/app/templates/base.html +++ b/edc-web/app/templates/base.html @@ -10,6 +10,16 @@
{% block content %}{% endblock %} diff --git a/edc-web/app/templates/login.html b/edc-web/app/templates/login.html new file mode 100644 index 0000000..27d5a4a --- /dev/null +++ b/edc-web/app/templates/login.html @@ -0,0 +1,36 @@ + + + + + + 登录 - EDC 工装管理系统 + + + + + + diff --git a/edc-web/app/templates/logs.html b/edc-web/app/templates/logs.html new file mode 100644 index 0000000..8b17ee9 --- /dev/null +++ b/edc-web/app/templates/logs.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}操作日志 - EDC 工装管理系统{% endblock %} + +{% block content %} +

操作日志

+ + + + + + + + + + + + + + + + + +
ID用户操作类型对象详情结果IP时间
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/edc-web/app/templates/users.html b/edc-web/app/templates/users.html new file mode 100644 index 0000000..c608c57 --- /dev/null +++ b/edc-web/app/templates/users.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block title %}用户管理 - EDC 工装管理系统{% endblock %} + +{% block content %} +

用户管理

+ +
+ + +
+ + + + + + + + + + + + + +
ID用户名角色状态创建时间操作
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/edc_server b/edc_server index df46136..43fd3e7 160000 --- a/edc_server +++ b/edc_server @@ -1 +1 @@ -Subproject commit df461362f528edbe3e92aa4fa40c5b512b46e63d +Subproject commit 43fd3e7be9f5d66628f5d9bff510e9ef3eb532b8