From 322563dab0e9bb69c4ef24980bca89b0c7b97fa5 Mon Sep 17 00:00:00 2001 From: wangfq Date: Thu, 28 May 2026 13:58:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95/?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20+=20=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tb_user 用户表、tb_log 日志表 - Flask-Login 认证(login/logout/权限装饰器) - 用户管理页(admin 专有):增删改查、改密、角色设置 - 操作日志页:分页查询、按用户/类型筛选 - 测试操作区指令自动记录日志 - 所有页面加 @login_required 保护 - 默认管理员 admin/admin123(首次启动自动创建) --- edc-web/app/__init__.py | 30 +++++ .../app/__pycache__/__init__.cpython-311.pyc | Bin 1014 -> 2713 bytes .../app/__pycache__/models.cpython-311.pyc | Bin 13320 -> 19843 bytes edc-web/app/auth.py | 88 ++++++++++++++ edc-web/app/models.py | 105 ++++++++++++++++ .../__pycache__/devices.cpython-311.pyc | Bin 1649 -> 1741 bytes .../__pycache__/test_data.cpython-311.pyc | Bin 3634 -> 3700 bytes .../__pycache__/test_op.cpython-311.pyc | Bin 3497 -> 5208 bytes edc-web/app/routes/devices.py | 2 + edc-web/app/routes/logs.py | 32 +++++ edc-web/app/routes/test_data.py | 1 + edc-web/app/routes/test_op.py | 52 +++++++- edc-web/app/routes/users.py | 54 +++++++++ edc-web/app/static/css/style.css | 3 + edc-web/app/templates/base.html | 10 ++ edc-web/app/templates/login.html | 36 ++++++ edc-web/app/templates/logs.html | 92 ++++++++++++++ edc-web/app/templates/users.html | 112 ++++++++++++++++++ edc_server | 2 +- 19 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 edc-web/app/auth.py create mode 100644 edc-web/app/routes/logs.py create mode 100644 edc-web/app/routes/users.py create mode 100644 edc-web/app/templates/login.html create mode 100644 edc-web/app/templates/logs.html create mode 100644 edc-web/app/templates/users.html 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 2c7a4d0298f11422545cc03e03d4b52626c0ea59..adfd98ff5e8fdf1a3e6740353a3a3b123513db05 100644 GIT binary patch literal 2713 zcma)8|7#RS6rbIl`5@y+oJ|v~R)g4}rV*m{0wxxW$a1-zB&YW!%H(Bq21mC_V9 zy?Jlm%)Gg;+-@g=CjGwc!VeaN{$!m(<$kquoV3los$Zt=0!Sr|}s9Y46AKd)w=H2D{UoL-m{Rx}d&lwhT2!ORK z9vcb|8&*ox1dWB*c%caZ*!S5A$HGv77n)iK5d;e$zgHYfp(MIc9Gz$ORZX>*M3vU& z;`oRqYE5xbTM8D-qa>GHf7@h$z%yDwhqwpen^jjk>imIj9DW&1KSr zLa1VR$|RBauwu9+5>pbCNI^Loj`{6|02*bHIrFYzsfIJ}jbWFGnHScxo3k@yW3vKK z*t*1gd%@hcOOlyBscrPK;S5m{RKY7a+Xb2|6-pc2m~r%n({sx{)jrcc-lb#P#EIYV z&PRCXrw7uwF@qa5+?cf@ORI+8s3sk|$9pD2*8&rP@c@!u`h#t8ul^M{Tkj^ zPI_e*hID*Tt0Wz_Yn7zqH?>MyD*8K=#?2Yrtl{QT4C~1br*TULw`jPfq}P$gO&Q#z z;ii(T6#u^vzxP`2MDKX-Qq89E6VD)RD8yKyv9y~7>PJW>)Jaw&QIt!e{59sILW(m> z2&z2nDc*sS{5(6NRjpFLyoTZeiY9tp_C%gr%z{TXqzcWbUF_Ea`!l zdVz}0ilA(-yv(v~NLT>dd&Oc4SD2q?hpVbp`iJZ|*k`SEtj34-A_b4}mxQxu3^I2X zGHK4bZn3SNwGa7N3>(rOLpd*gXW{?^yu5II<+D30-+#VxXX^3d;^Uuh!K1Y@dHIQy6)VoL4--{_DinhfwRM=p2xS*;G^i+JaVkqA zL1pBMudn-5cUQmI)q5t;-?GOq1{E!LjcK@?w&wj_vwDIC(z$p z7!h08I4gLqV#U0zB8^9gUwr@QsWaVuV#{kvt4V&tVn%7$3&95b4#UZ6QZz1;h(cK{ zHfqNRy?BKrhT9YpO3<)+nTkwqg^m)ZhK*bTxk?QC5K%)T@fcy{HX049h9wkt$ot5b7z>K^ZGaymJ6b>`|E$#|OX za*sTI&EwZMZ@FFbagAPgPOsmkHS7oaZXn%oB-3!@m)Etr6HhHdt@k+smNl6j)z&kE zkK8xiH{4kd_%ofF>?w2qoUxK@J+!*Ix}G@eMGp6@bJ{uOnsH5ya}4ic_*Y?T*A5}zw;47`lH(zG87)KXFIeuq zG`BwYQksvW`4*TP^EINo+5WJddSO2d$0HFTopdjB+5akq6}_y$ah#5}YiRot+NM>K dj&^I6l(p{QJU6eaD delta 333 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 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 7ffece7869fafa70557d06f8dc81463e40f620be..b2822418a2e1b0c643801567c46e9f3d6a378bf4 100644 GIT binary patch delta 578 zcmZWkyG{Z@6rEXi-S;l2@e#$~0~APz8XFU%wJ|X!cH5ARGa7Uka8@j=ENHYN(^y(z zferBo`~ee4ER?Un!kB2|%z_1YGxyBg$GLOvhw);X52mRBZts()Uo~?YCTh#s90Uj= z(NlcYQGLzPAP@i+kc9QR06>Fz&m03A_hzj*wh7Qk9kLSho?#iuRZwR^<1aN7IaZ&X z21pqsBXyYS*!|`;fHb6^z(-?H3NrsuqR2V~X`8OXy%_9RUg@Mz%oBWGDG-e46>QB% znKZtBFv{Q>d@8`0x!Z2*nO8m25;Mf2XTnWRVMQV`NJA2K^Cc6+X4ui)av_iKW#I@1 zgindlg-8loPrH#t2vzEfvnvxU9Yi=M(P!D5RVgF)0_Tgak4cQIzLPvAU<(ywa*b7J~OQk!~^g7=o{5>2*Odj$DVObiUGffxd! zxKg=2{1(QAQZEsDdxaX%nMa)$dJOB!j;ar zh8xxD6dnvMd?~!a44QnCeHgtbuVg&0$Pcud6Nrn2fJ6hsS2hM7=^1txxwWovYh7T` zD&n0ylc`gW9Vl7E0wgpUi@>TuUQj6F1CqD66ALoqQ&P(^lT(X}1c4&_lLMI57fgc5Z$#BCs}))Ptt}22&FVKNz+P2^a4Sgk+^`6$i<4f20|R1t{n++>Y;z2 z+CR`!I8@@$2#Ev#fmGsx5BveHR0vMY*vbLQ$n&&*^L9M@)~Agh4gQXEMj)zRS6=-* z;x&5psCBmLa8B|V| zbXG9iO9zyUeeiCJIN+h<1Si_ux6I?@9Et+h3n4Ap14GM{UQnqC4q!om zDF8bo6B>zCgv$tZga*PDfFJ|VOhV>i9dT&vgl-TzVh|4G4d6=ZWBM1h=BN6(b}8;u z16EcLlAh{QZoAI$9l_Qjt-Br3QGl*Dn=-F5Y>V!wU3Q&X>KS`kxQ5y$!WsfoXYA41 ze};^7e;Q7vq3ojUmU>`Rs_~c%$8_IJd>P6+P`f!F8MkQ_&9N&{#&Lwd=Qwf`x3$#= zbA8=-?hS&G6I(8`z(>$y(jEK4^#U2&#?*JSwu3hipF3_GpC~px_UCu@F6@|kZcp|= Yhkr((Q?<(<7B&{-uSXs5_bD9c4~LSBga7~l delta 626 zcmZuvJ4*vW5Z*nKOLBLcm(Q4>Nl@f8KJd|r_9`|$idY=MJA9yA*t?*mg^gv{%1%&X zp^fG*_yZz}#eu)UMhmT+xr+$uz%g}Uvc%gWp zmQ_k5Vuj4Gr8Oi%uE7Hkl0|G35q@6nJ1}-5O?&Yn1D-FaHj*{n7qqG%eJzk4{GU2Eww@=&YHKq>z#aj5Jh`!P9yP(X-@S7l}S zpQ$|AuDR8k%g0cr19+4>xKw+}d@!bhV_HX5o4b4#vV+Z2b%u5`2=%lVF$~k*G7MgV zGanI;+F%+gs^bT?Y3)1Qf25emvdlC-vA?C?hWkrk7yp{i=p0nk_N{q72ONG1=Z%PM Lwj}P^O$c>gCboHM 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 ae26268c636761243f87fad354a572d90fe71f09..9d28dbe67a7b5ebb9188c755c3fc054c4ee08f88 100644 GIT binary patch literal 5208 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 delta 1607 zcmb_cO>7fK6rR~1d+qhVi81+g<+Q{NiHiyJM^yzC2dG4#YA&oQR<>tJ16kW0uS=0k z^-}fFOCtFYt%TYGjuMH(qN-BGsc$xMO9;n4dp^&6 z@4bEVzMb*v!0oQktzgiPz?u(spBq!IhmunzjL>(8APo^rgh{Mn_!TDwQ`AIL(j-&X zWYeR0F!xK7UQ^K&)2I1NzveepO*I2rzzk|Zj09weL^eVT0zykLHcbl?5AhO(_=umV zbFvmO!Z)LBFJhMifxUqR*u)ZyO(Vgt&;m@C+62;KJc&&OI)*|dJdW>1wihAM9U1X| zGh#2yi0{bQ)y_D&>$&}O?1=3k!Urgms6}9lYxl3L-@CEKu0DD&mpXFl_y*thD2oXD zv4<6eFWD1;ush_QKhZvlUuCXB4e! z_7kF8M%B{KPJC+BsF5{Dap;0k$mT1REG^f-mv#N+%5deP?Q`dq{J258;hA0h;W>Z? z`pbjZFVY#+11k~6pYLKeEVtPEo@7C^g1~H99oNO};$D(0we3ZSD0QhW65pJ-T_ljh zUyDIp>)Q2=#^0RJnX9Zj?7?tNF@=UM!ntzC`FgVETE`JOFQ* z{p~A_gm=%1GnX329!63(6N|}}NUj;lHI6@2W3&B>r++@S96tfy6?L?!jxI~1vnJu zs~jYNj6?_d+*NXz&s-6?Yg0q^IMf+E082iqUjuCE*2fNoyVz&pv7;Zd>@G5 z5g&w5{ue^|nP?YrpMQ`Ohd8_rP<^Jz;b>3v4d9M){uF>)q!c~T!NtxbY_G19a#7c9 z$(k??_FJ@j$`2=e*enxcvP#{H<97C3dET|PD`4!Ea+^7bg- z!foWfVE6PqRo;dIwMpY0dIVft=&A+ih!{ulDoQM)#2-j)xSv&aEtWPM!HWe4{eKWc zo^a#z<%_ceD{{IiryGLfXK%)P(}Fae{35yR?_I%tP2A@QGJeZJ08d0Dq+Ev0$2+?n zLB%JrgE(^6z9t^r;=0-I@x)0-2x6~;0J!^L6OU{%Dby~pe+B27IOhmH9tgk#4{T#x H+Ewl!@vUsG 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