feat: 用户登录/管理 + 操作日志模块

- tb_user 用户表、tb_log 日志表
- Flask-Login 认证(login/logout/权限装饰器)
- 用户管理页(admin 专有):增删改查、改密、角色设置
- 操作日志页:分页查询、按用户/类型筛选
- 测试操作区指令自动记录日志
- 所有页面加 @login_required 保护
- 默认管理员 admin/admin123(首次启动自动创建)
This commit is contained in:
wangfq
2026-05-28 13:58:19 +08:00
parent 56e3b03121
commit 322563dab0
19 changed files with 614 additions and 5 deletions

View File

@@ -10,6 +10,16 @@
<nav class="top-menu">
<a href="/" class="{% if request.path == '/' %}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' %}
<a href="/logs/" class="{% if request.path == '/logs/' %}active{% endif %}">操作日志</a>
<a href="/users/" class="{% if request.path == '/users/' %}active{% endif %}">用户管理</a>
{% endif %}
<span class="user-info">
{% if current_user.is_authenticated %}
{{ current_user.username }} ({{ current_user.role }})
<a href="/logout">退出</a>
{% endif %}
</span>
</nav>
<main class="container">
{% block content %}{% endblock %}

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - EDC 工装管理系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f6fa; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-box { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,.1); width: 360px; }
.login-box h2 { text-align: center; margin-bottom: 24px; color: #2c3e50; }
.login-box label { display: block; font-size: 13px; margin-bottom: 4px; color: #555; }
.login-box input { width: 100%; padding: 10px 12px; margin-bottom: 16px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
.login-box button { width: 100%; padding: 10px; background: #3498db; color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; }
.login-box button:hover { background: #2980b9; }
.flash-error { color: #e74c3c; font-size: 13px; text-align: center; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="login-box">
<h2>EDC 工装管理系统</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-error">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="post">
<label>用户名</label>
<input type="text" name="username" required autofocus>
<label>密码</label>
<input type="password" name="password" required>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}操作日志 - EDC 工装管理系统{% endblock %}
{% block content %}
<h2>操作日志</h2>
<div class="search-bar">
<label>用户名:<input type="text" id="search-username" placeholder="筛选用户..."></label>
<label>操作类型:
<select id="search-action">
<option value="">全部</option>
<option value="login">登录</option>
<option value="logout">登出</option>
<option value="command">指令操作</option>
</select>
</label>
<button onclick="searchLogs(1)" class="btn-search">查询</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>操作类型</th>
<th>对象</th>
<th>详情</th>
<th>结果</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody id="log-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1, totalPages = 1;
async function searchLogs(page = 1) {
currentPage = page;
const username = document.getElementById("search-username").value;
const action_type = document.getElementById("search-action").value;
const params = new URLSearchParams({page, per_page: 30});
if (username) params.set("username", username);
if (action_type) params.set("action_type", action_type);
const resp = await fetch(`/logs/api/logs?${params}`);
const data = await resp.json();
renderTable(data.records);
totalPages = data.pages;
renderPagination();
}
function renderTable(records) {
const tbody = document.getElementById("log-tbody");
if (!records.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;">暂无记录</td></tr>';
return;
}
tbody.innerHTML = records.map(r => `
<tr>
<td>${r.id}</td>
<td>${r.username || '-'}</td>
<td>${r.action_type}</td>
<td>${r.target || '-'}</td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(r.detail)}">${r.detail || '-'}</td>
<td style="color:${r.result==='ok'?'#27ae60':'#e74c3c'}">${r.result}</td>
<td>${r.ip || '-'}</td>
<td>${r.create_time || '-'}</td>
</tr>
`).join("");
}
function renderPagination() {
const div = document.getElementById("pagination");
let html = `<button onclick="searchLogs(${currentPage-1})" ${currentPage<=1?'disabled':''}>上一页</button>`;
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="searchLogs(${i})" class="${i===currentPage?'active':''}">${i}</button>`;
}
html += `<button onclick="searchLogs(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}>下一页</button>`;
div.innerHTML = html;
}
function esc(s) { return s.replace(/"/g, '&quot;'); }
searchLogs(1);
</script>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}用户管理 - EDC 工装管理系统{% endblock %}
{% block content %}
<h2>用户管理</h2>
<div style="margin-bottom:16px;">
<button onclick="showAddForm()" class="btn-search">新增用户</button>
<div id="add-form" style="display:none;margin-top:12px;background:#fff;padding:16px;border-radius:8px;">
<label>用户名:<input type="text" id="new-username" style="margin:0 8px;"></label>
<label>密码:<input type="password" id="new-password" style="margin:0 8px;"></label>
<label>角色:
<select id="new-role" style="margin:0 8px;">
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
</label>
<button onclick="addUser()" class="btn-search">确认</button>
<button onclick="document.getElementById('add-form').style.display='none'" class="btn-export">取消</button>
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="user-tbody"></tbody>
</table>
{% endblock %}
{% block scripts %}
<script>
async function loadUsers() {
const resp = await fetch("/users/api/users");
const users = await resp.json();
const tbody = document.getElementById("user-tbody");
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td>${u.role}</td>
<td>${u.is_active ? '启用' : '禁用'}</td>
<td>${u.create_time || '-'}</td>
<td>
<select onchange="updateUser(${u.id}, this, 'role')" data-field="role">
<option value="operator" ${u.role==='operator'?'selected':''}>operator</option>
<option value="admin" ${u.role==='admin'?'selected':''}>admin</option>
</select>
<select onchange="updateUser(${u.id}, this, 'is_active')" data-field="is_active">
<option value="1" ${u.is_active?'selected':''}>启用</option>
<option value="0" ${!u.is_active?'selected':''}>禁用</option>
</select>
<button onclick="resetPwd(${u.id})" class="btn-search" style="font-size:11px;">改密</button>
</td>
</tr>
`).join("");
}
function showAddForm() {
document.getElementById("add-form").style.display = "block";
document.getElementById("new-username").value = "";
document.getElementById("new-password").value = "";
}
async function addUser() {
const username = document.getElementById("new-username").value.trim();
const password = document.getElementById("new-password").value;
const role = document.getElementById("new-role").value;
if (!username || !password) { alert("用户名和密码不能为空"); return; }
const resp = await fetch("/users/api/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username, password, role}),
});
const data = await resp.json();
if (data.ok) { document.getElementById("add-form").style.display = "none"; loadUsers(); }
else { alert(data.error || "创建失败"); }
}
async function updateUser(id, el, field) {
const value = el.value;
const body = {};
body[field] = field === 'is_active' ? parseInt(value) : value;
await fetch(`/users/api/users/${id}`, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
loadUsers();
}
async function resetPwd(id) {
const pwd = prompt("输入新密码至少6位");
if (!pwd || pwd.length < 6) { alert("密码至少6位"); return; }
await fetch(`/users/api/users/${id}`, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({password: pwd}),
});
alert("密码已更新");
}
loadUsers();
</script>
{% endblock %}