feat: 增加 manager 角色,admin+manager 共享管理权限(用户管理除外),所有用户可自行修改密码

- auth.py: 新增 privileged_required 装饰器 (admin+manager),admin_required 仅限用户管理
- 路由权限: fixture/logs/device_logs/test_data 的 admin 检查改为 admin+manager
- 前端: 导航栏/删除按钮/配置按钮扩展为 admin+manager 可见
- 用户管理: 角色下拉增加 manager 选项,仍仅 admin 可访问
- 新增 /change-password 路由+模板,所有登录用户可自行修改密码
- edc_server models.py: role COMMENT 更新 + ALTER TABLE 迁移
This commit is contained in:
wangfq
2026-06-11 09:11:54 +08:00
parent 50451de2df
commit 000e4f8d3a
12 changed files with 119 additions and 14 deletions

View File

@@ -46,7 +46,7 @@ def init_auth(app):
# ─── 装饰器 ──────────────────────────────────────────────────────── # ─── 装饰器 ────────────────────────────────────────────────────────
def admin_required(f): def admin_required(f):
"""要求 admin 角色""" """要求 admin 角色(仅 adminmanager 不可通过)"""
from functools import wraps from functools import wraps
@wraps(f) @wraps(f)
@login_required @login_required
@@ -57,6 +57,18 @@ def admin_required(f):
return wrapper return wrapper
def privileged_required(f):
"""要求 admin 或 manager 角色"""
from functools import wraps
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
if current_user.role not in ("admin", "manager"):
return "权限不足", 403
return f(*args, **kwargs)
return wrapper
# ─── 登录 / 登出 ──────────────────────────────────────────────────── # ─── 登录 / 登出 ────────────────────────────────────────────────────
@auth_bp.route("/login", methods=["GET", "POST"]) @auth_bp.route("/login", methods=["GET", "POST"])
@@ -86,3 +98,53 @@ def logout():
ip=request.remote_addr or "", result="ok") ip=request.remote_addr or "", result="ok")
logout_user() logout_user()
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@auth_bp.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
"""所有用户自行修改密码"""
if request.method == "POST":
old_password = request.form.get("old_password", "")
new_password = request.form.get("new_password", "").strip()
confirm_password = request.form.get("confirm_password", "")
if not old_password or not new_password:
flash("所有字段都不能为空")
return render_template("change_password.html")
if len(new_password) < 6:
flash("新密码至少6位")
return render_template("change_password.html")
if new_password != confirm_password:
flash("两次输入的新密码不一致")
return render_template("change_password.html")
# 验证旧密码
from app.models import get_conn, get_user_by_username
from werkzeug.security import generate_password_hash
user_dict = get_user_by_username(current_user.username)
if not user_dict or not check_password_hash(user_dict["password_hash"], old_password):
flash("原密码错误")
return render_template("change_password.html")
# 更新密码
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE tb_user SET password_hash=%s WHERE id=%s",
(generate_password_hash(new_password), current_user.id),
)
conn.commit()
finally:
conn.close()
insert_log(current_user.id, current_user.username, "update",
target="self", detail="修改个人密码",
result="ok", ip=request.remote_addr or "")
flash("密码修改成功")
return redirect(url_for("devices.index"))
return render_template("change_password.html")

View File

@@ -72,7 +72,7 @@ def api_export():
@login_required @login_required
def api_device_logs_delete(): def api_device_logs_delete():
"""删除设备日志admin 权限)""" """删除设备日志admin 权限)"""
if current_user.role != "admin": if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限"}), 403 return jsonify({"ok": False, "error": "无权限"}), 403
data = request.get_json() data = request.get_json()

View File

@@ -114,7 +114,7 @@ def build_4b_packet(addr: int, dev_type: int, test_mode: int,
@login_required @login_required
def fixture_page(dnt_id): def fixture_page(dnt_id):
"""工装配置页面""" """工装配置页面"""
if current_user.role != "admin": if current_user.role not in ("admin", "manager"):
return "无权限:仅管理员可访问工装配置", 403 return "无权限:仅管理员可访问工装配置", 403
device = get_device_by_id(dnt_id) device = get_device_by_id(dnt_id)
if not device: if not device:
@@ -135,7 +135,7 @@ def vehicle_base_test_page():
@login_required @login_required
def api_fixture_command(): def api_fixture_command():
"""发送工装配置指令 (0x4A/0x4B/0x4C/0x4D/0x4E)""" """发送工装配置指令 (0x4A/0x4B/0x4C/0x4D/0x4E)"""
if current_user.role != "admin": if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限:仅管理员可执行工装指令"}), 403 return jsonify({"ok": False, "error": "无权限:仅管理员可执行工装指令"}), 403
data = request.get_json() data = request.get_json()
dnt_id = data.get("dnt_id") dnt_id = data.get("dnt_id")
@@ -226,7 +226,7 @@ def api_get_fixture_param(dnt_id):
@login_required @login_required
def api_save_fixture_param(dnt_id): def api_save_fixture_param(dnt_id):
"""保存工装测试参数(仅数据库,不下发设备)""" """保存工装测试参数(仅数据库,不下发设备)"""
if current_user.role != "admin": if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限:仅管理员可修改工装参数"}), 403 return jsonify({"ok": False, "error": "无权限:仅管理员可修改工装参数"}), 403
data = request.get_json() data = request.get_json()
if not data: if not data:

View File

@@ -2,20 +2,20 @@
from flask import Blueprint, jsonify, render_template, request from flask import Blueprint, jsonify, render_template, request
from flask_login import login_required from flask_login import login_required
from app.auth import admin_required from app.auth import privileged_required
from app.models import get_logs from app.models import get_logs
bp = Blueprint("logs", __name__, url_prefix="/logs") bp = Blueprint("logs", __name__, url_prefix="/logs")
@bp.route("/") @bp.route("/")
@admin_required @privileged_required
def logs_page(): def logs_page():
return render_template("logs.html") return render_template("logs.html")
@bp.route("/api/logs") @bp.route("/api/logs")
@admin_required @privileged_required
def api_logs(): def api_logs():
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 30, type=int) per_page = request.args.get("per_page", 30, type=int)

View File

@@ -84,7 +84,7 @@ def api_export():
@login_required @login_required
def api_delete(): def api_delete():
"""删除测试数据(仅 admin""" """删除测试数据(仅 admin"""
if current_user.role != "admin": if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限"}), 403 return jsonify({"ok": False, "error": "无权限"}), 403
data = request.get_json() or {} data = request.get_json() or {}

View File

@@ -25,7 +25,7 @@ function renderTable(devices) {
<td>${d.last_login || '-'}</td> <td>${d.last_login || '-'}</td>
<td> <td>
<button class="btn-test" onclick="location.href='/test/${d.id}'">测试</button> <button class="btn-test" onclick="location.href='/test/${d.id}'">测试</button>
${USER_ROLE === 'admin' ? `<button class="btn-config" onclick="location.href='/fixture/${d.id}'">配置</button>` : ''} ${USER_ROLE === 'admin' || USER_ROLE === 'manager' ? `<button class="btn-config" onclick="location.href='/fixture/${d.id}'">配置</button>` : ''}
</td> </td>
</tr>`; </tr>`;
}).join(""); }).join("");

View File

@@ -10,14 +10,17 @@
<nav class="top-menu"> <nav class="top-menu">
<a href="/" class="{% if request.path == '/' %}active{% endif %}">设备</a> <a href="/" class="{% if request.path == '/' %}active{% endif %}">设备</a>
<a href="/test-data" class="{% if request.path == '/test-data' %}active{% endif %}">测试信息</a> <a href="/test-data" class="{% if request.path == '/test-data' %}active{% endif %}">测试信息</a>
{% if current_user.is_authenticated and current_user.role == 'admin' %} {% if current_user.is_authenticated and current_user.role in ('admin', 'manager') %}
<a href="/device-logs" class="{% if request.path == '/device-logs' %}active{% endif %}">设备日志</a> <a href="/device-logs" class="{% if request.path == '/device-logs' %}active{% endif %}">设备日志</a>
<a href="/logs/" class="{% if request.path == '/logs/' %}active{% endif %}">操作日志</a> <a href="/logs/" class="{% if request.path == '/logs/' %}active{% endif %}">操作日志</a>
{% endif %}
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<a href="/users/" class="{% if request.path == '/users/' %}active{% endif %}">用户管理</a> <a href="/users/" class="{% if request.path == '/users/' %}active{% endif %}">用户管理</a>
{% endif %} {% endif %}
<span class="user-info"> <span class="user-info">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{{ current_user.username }} ({{ current_user.role }}) {{ current_user.username }} ({{ current_user.role }})
<a href="/change-password">修改密码</a>
<a href="/logout">退出</a> <a href="/logout">退出</a>
{% endif %} {% endif %}
</span> </span>

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}修改密码 - EDC 工装管理系统{% endblock %}
{% block content %}
<div style="max-width:400px;margin:40px auto;">
<h2>修改密码</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div style="background:#fef3e2;color:#b45309;padding:10px;border-radius:6px;margin-bottom:16px;">
{% for msg in messages %}{{ msg }}{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" style="display:flex;flex-direction:column;gap:16px;">
<div>
<label>当前密码</label>
<input type="password" name="old_password" required
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div>
<label>新密码至少6位</label>
<input type="password" name="new_password" required minlength="6"
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div>
<label>确认新密码</label>
<input type="password" name="confirm_password" required minlength="6"
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div style="display:flex;justify-content:space-between;">
<a href="/" style="line-height:36px;">← 返回</a>
<button type="submit" class="btn-search" style="padding:8px 24px;">确认修改</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -27,7 +27,7 @@
</label> </label>
<button onclick="searchLogs(1)" class="btn-search">查询</button> <button onclick="searchLogs(1)" class="btn-search">查询</button>
<button onclick="exportCSV()" class="btn-export">导出 CSV</button> <button onclick="exportCSV()" class="btn-export">导出 CSV</button>
{% if current_user.role == 'admin' %} {% if current_user.role in ('admin', 'manager') %}
<button onclick="confirmDeleteLogs()" class="btn-delete">🗑 删除</button> <button onclick="confirmDeleteLogs()" class="btn-delete">🗑 删除</button>
{% endif %} {% endif %}
</div> </div>

View File

@@ -34,7 +34,7 @@
</select> </select>
</label> </label>
<button id="btn-chart" class="btn-chart" onclick="toggleChart()">📈 图表</button> <button id="btn-chart" class="btn-chart" onclick="toggleChart()">📈 图表</button>
{% if current_user.role == 'admin' %} {% if current_user.role in ('admin', 'manager') %}
<button id="btn-delete" class="btn-delete" onclick="confirmDelete()">🗑 删除</button> <button id="btn-delete" class="btn-delete" onclick="confirmDelete()">🗑 删除</button>
{% endif %} {% endif %}
</div> </div>

View File

@@ -12,6 +12,7 @@
<label>角色: <label>角色:
<select id="new-role" style="margin:0 8px;"> <select id="new-role" style="margin:0 8px;">
<option value="operator">operator</option> <option value="operator">operator</option>
<option value="manager">manager</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</label> </label>
@@ -51,6 +52,7 @@ async function loadUsers() {
<td> <td>
<select onchange="updateUser(${u.id}, this, 'role')" data-field="role"> <select onchange="updateUser(${u.id}, this, 'role')" data-field="role">
<option value="operator" ${u.role==='operator'?'selected':''}>operator</option> <option value="operator" ${u.role==='operator'?'selected':''}>operator</option>
<option value="manager" ${u.role==='manager'?'selected':''}>manager</option>
<option value="admin" ${u.role==='admin'?'selected':''}>admin</option> <option value="admin" ${u.role==='admin'?'selected':''}>admin</option>
</select> </select>
<select onchange="updateUser(${u.id}, this, 'is_active')" data-field="is_active"> <select onchange="updateUser(${u.id}, this, 'is_active')" data-field="is_active">