1129 lines
44 KiB
HTML
1129 lines
44 KiB
HTML
{% extends "ewoooc_base.html" %}
|
|
|
|
{% block title %}用戶管理 - EwoooC{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.user-management-page {
|
|
color: var(--momo-text-primary);
|
|
}
|
|
|
|
.user-management-head {
|
|
padding: var(--momo-space-4) var(--momo-space-5);
|
|
background: var(--momo-dot-grid), var(--momo-bg-surface);
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-md);
|
|
}
|
|
|
|
.user-management-head h2 {
|
|
color: var(--momo-text-primary);
|
|
font-family: var(--momo-font-display);
|
|
font-size: var(--momo-text-headline);
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.user-management-page .text-muted {
|
|
color: var(--momo-text-secondary) !important;
|
|
}
|
|
|
|
.user-table-head th {
|
|
background: var(--momo-bg-paper) !important;
|
|
color: var(--momo-text-secondary) !important;
|
|
font-family: var(--momo-font-mono, monospace);
|
|
font-size: var(--momo-text-label);
|
|
font-weight: 800;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.user-role-badge,
|
|
.user-status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 22px;
|
|
padding: 3px 8px;
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-sm);
|
|
font-family: var(--momo-font-mono, monospace);
|
|
font-size: var(--momo-text-label);
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
}
|
|
|
|
.user-role-badge--danger {
|
|
background: var(--momo-danger-bg);
|
|
border-color: var(--momo-danger-border);
|
|
color: var(--momo-danger-text);
|
|
}
|
|
|
|
.user-role-badge--warning {
|
|
background: var(--momo-warning-bg);
|
|
border-color: var(--momo-warning-border);
|
|
color: var(--momo-warning-text);
|
|
}
|
|
|
|
.user-role-badge--muted,
|
|
.user-status-badge.is-muted {
|
|
background: var(--momo-tag-muted-bg);
|
|
border-color: var(--momo-tag-muted-border);
|
|
color: var(--momo-tag-muted-text);
|
|
}
|
|
|
|
.user-status-badge.is-active,
|
|
.user-status-badge.is-info {
|
|
background: var(--momo-success-bg);
|
|
border-color: var(--momo-success-border);
|
|
color: var(--momo-success-text);
|
|
}
|
|
|
|
.user-status-badge.is-inactive {
|
|
background: var(--momo-tag-muted-bg);
|
|
border-color: var(--momo-tag-muted-border);
|
|
color: var(--momo-tag-muted-text);
|
|
}
|
|
|
|
.user-permission-toolbar {
|
|
background: var(--momo-bg-paper);
|
|
border-color: var(--momo-border-light);
|
|
}
|
|
|
|
.user-modal-alert {
|
|
background: var(--momo-bg-paper);
|
|
border-bottom: 1px solid var(--momo-border-light);
|
|
color: var(--momo-text-primary);
|
|
}
|
|
|
|
.user-modal-alert--danger {
|
|
background: var(--momo-danger-bg);
|
|
color: var(--momo-danger-text);
|
|
}
|
|
|
|
.user-modal-alert--info {
|
|
background: var(--momo-info-bg);
|
|
color: var(--momo-info-text);
|
|
}
|
|
|
|
.user-toast {
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-md);
|
|
background: var(--momo-bg-elevated);
|
|
color: var(--momo-text-primary);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.user-toast--success {
|
|
background: var(--momo-success-bg);
|
|
border-color: var(--momo-success-border);
|
|
color: var(--momo-success-text);
|
|
}
|
|
|
|
.user-toast--danger {
|
|
background: var(--momo-danger-bg);
|
|
border-color: var(--momo-danger-border);
|
|
color: var(--momo-danger-text);
|
|
}
|
|
|
|
.user-toast--warning {
|
|
background: var(--momo-warning-bg);
|
|
border-color: var(--momo-warning-border);
|
|
color: var(--momo-warning-text);
|
|
}
|
|
|
|
.user-toast--info {
|
|
background: var(--momo-info-bg);
|
|
border-color: var(--momo-info-border);
|
|
color: var(--momo-info-text);
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.user-management-head {
|
|
padding: var(--momo-space-4);
|
|
}
|
|
|
|
.user-management-head .col-auto,
|
|
.user-management-head .btn {
|
|
width: 100%;
|
|
}
|
|
|
|
#usersTable {
|
|
min-width: 0;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
}
|
|
|
|
#usersTable,
|
|
#usersTable tbody,
|
|
#usersTable tr,
|
|
#usersTable td {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
#usersTable thead {
|
|
display: none;
|
|
}
|
|
|
|
#usersTableBody {
|
|
display: grid;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
}
|
|
|
|
#usersTableBody tr {
|
|
overflow: hidden;
|
|
border: 1px solid var(--momo-border-light, #e5dccd);
|
|
border-radius: 8px;
|
|
background: var(--momo-bg-surface, #fffaf1);
|
|
}
|
|
|
|
#usersTableBody td {
|
|
display: grid;
|
|
grid-template-columns: 5.6rem minmax(0, 1fr);
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--momo-border-light, #e5dccd);
|
|
text-align: left !important;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
#usersTableBody td:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
#usersTableBody td::before {
|
|
color: var(--momo-text-tertiary, #9a8f80);
|
|
font-family: var(--momo-font-mono, monospace);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
#usersTableBody td:nth-child(1)::before { content: "帳號"; }
|
|
#usersTableBody td:nth-child(2)::before { content: "名稱"; }
|
|
#usersTableBody td:nth-child(3)::before { content: "信箱"; }
|
|
#usersTableBody td:nth-child(4)::before { content: "角色"; }
|
|
#usersTableBody td:nth-child(5)::before { content: "狀態"; }
|
|
#usersTableBody td:nth-child(6)::before { content: "建立"; }
|
|
#usersTableBody td:nth-child(7)::before { content: "更新"; }
|
|
#usersTableBody td:nth-child(8)::before { content: "操作"; }
|
|
|
|
#usersTableBody td[colspan] {
|
|
display: block;
|
|
}
|
|
|
|
#usersTableBody td[colspan]::before {
|
|
content: none;
|
|
}
|
|
|
|
#usersTableBody .btn-group {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4 user-management-page">
|
|
<div class="row mb-4 user-management-head">
|
|
<div class="col">
|
|
<h2 class="mb-0">
|
|
<i class="fas fa-users-cog me-2"></i>用戶管理
|
|
</h2>
|
|
<p class="text-muted mb-0">管理系統用戶帳號與權限</p>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-primary" onclick="showAddUserModal()">
|
|
<i class="fas fa-user-plus me-2"></i>新增用戶
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 用戶列表 -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fas fa-list me-2"></i>用戶列表</span>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="showInactive" onchange="loadUsers()">
|
|
<label class="form-check-label" for="showInactive">顯示停用帳號</label>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" id="usersTable">
|
|
<thead class="user-table-head">
|
|
<tr>
|
|
<th>帳號</th>
|
|
<th>顯示名稱</th>
|
|
<th>電子郵件</th>
|
|
<th>角色</th>
|
|
<th>狀態</th>
|
|
<th>建立時間</th>
|
|
<th>最後更新</th>
|
|
<th class="text-end">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersTableBody">
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">載入中...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新增/編輯用戶 Modal -->
|
|
<div class="modal fade" id="userModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="userModalTitle">新增用戶</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="userForm">
|
|
<input type="hidden" id="userId">
|
|
|
|
<div class="mb-3">
|
|
<label for="username" class="form-label">帳號 <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="username" required>
|
|
<div class="form-text">帳號建立後無法修改</div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="passwordGroup">
|
|
<label for="password" class="form-label">密碼 <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="password">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('password')">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="generatePassword()">
|
|
<i class="fas fa-key"></i>
|
|
</button>
|
|
</div>
|
|
<div class="form-text" id="passwordRequirements"></div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="confirmPasswordGroup">
|
|
<label for="confirmPassword" class="form-label">確認密碼 <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="confirmPassword">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('confirmPassword')">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
</div>
|
|
<div class="invalid-feedback">兩次輸入的密碼不一致</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="displayName" class="form-label">顯示名稱</label>
|
|
<input type="text" class="form-control" id="displayName">
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="email" class="form-label">電子郵件</label>
|
|
<input type="email" class="form-control" id="email">
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="role" class="form-label">角色 <span class="text-danger">*</span></label>
|
|
<select class="form-select" id="role" required>
|
|
<option value="user">一般用戶</option>
|
|
<option value="manager">管理者</option>
|
|
<option value="admin">系統管理員</option>
|
|
</select>
|
|
<div class="form-text">
|
|
<strong>系統管理員</strong>:完整權限<br>
|
|
<strong>管理者</strong>:資料匯入、廠商管理、報表<br>
|
|
<strong>一般用戶</strong>:僅可查看報表
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="isActiveGroup" style="display: none;">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="isActive" checked>
|
|
<label class="form-check-label" for="isActive">啟用帳號</label>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveUser()">
|
|
<i class="fas fa-save me-1"></i>儲存
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 重設密碼 Modal -->
|
|
<div class="modal fade" id="resetPasswordModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">重設密碼</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>為用戶 <strong id="resetPasswordUsername"></strong> 設定新密碼:</p>
|
|
<input type="hidden" id="resetPasswordUserId">
|
|
<div class="mb-3">
|
|
<label for="newPassword" class="form-label">新密碼 <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="newPassword" required>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('newPassword')">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="generatePasswordForReset()">
|
|
<i class="fas fa-key"></i>
|
|
</button>
|
|
</div>
|
|
<div class="form-text" id="resetPasswordRequirements"></div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="confirmNewPassword" class="form-label">確認新密碼 <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="confirmNewPassword" required>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('confirmNewPassword')">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
</div>
|
|
<div class="invalid-feedback" id="confirmPasswordError">兩次輸入的密碼不一致</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-warning" onclick="resetPassword()">
|
|
<i class="fas fa-key me-1"></i>重設密碼
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 刪除確認 Modal -->
|
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header user-modal-alert user-modal-alert--danger">
|
|
<h5 class="modal-title"><i class="fas fa-exclamation-triangle me-2"></i>確認停用</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>確定要停用用戶 <strong id="deleteUsername"></strong> 嗎?</p>
|
|
<p class="text-muted mb-0">停用後,該用戶將無法登入系統。</p>
|
|
<input type="hidden" id="deleteUserId">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-danger" onclick="deleteUser()">
|
|
<i class="fas fa-user-slash me-1"></i>停用
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 權限編輯 Modal -->
|
|
<div class="modal fade" id="permissionsModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header user-modal-alert user-modal-alert--info">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-shield-alt me-2"></i>編輯權限: <span id="permissionsUsername"></span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="permissionsUserId">
|
|
|
|
<!-- 快速設定區 -->
|
|
<div class="card mb-3 user-permission-toolbar">
|
|
<div class="card-body py-2">
|
|
<div class="row align-items-center">
|
|
<div class="col-auto">
|
|
<label class="form-label mb-0 me-2">快速設定:</label>
|
|
</div>
|
|
<div class="col-auto">
|
|
<select class="form-select form-select-sm" id="roleTemplateSelect" style="width: 160px;">
|
|
<option value="">套用角色模板</option>
|
|
<option value="manager">管理者模板</option>
|
|
<option value="user">一般用戶模板</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="applyRoleTemplate()">
|
|
<i class="fas fa-magic me-1"></i>套用
|
|
</button>
|
|
</div>
|
|
<div class="col-auto ms-auto">
|
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="selectAllPermissions()">
|
|
<i class="fas fa-check-square me-1"></i>全選
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="clearAllPermissions()">
|
|
<i class="fas fa-square me-1"></i>清除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 權限列表區 -->
|
|
<div id="permissionsContainer">
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">載入中...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<div class="text-muted me-auto">
|
|
<small><i class="fas fa-info-circle me-1"></i>管理員 (admin) 自動擁有所有權限</small>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-info" onclick="savePermissions()">
|
|
<i class="fas fa-save me-1"></i>儲存權限
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let userModal, resetPasswordModal, deleteModal, permissionsModal;
|
|
let passwordRequirements = [];
|
|
let allPermissions = []; // 所有權限定義
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
userModal = new bootstrap.Modal(document.getElementById('userModal'));
|
|
resetPasswordModal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
|
|
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
permissionsModal = new bootstrap.Modal(document.getElementById('permissionsModal'));
|
|
|
|
loadPasswordRequirements();
|
|
loadUsers();
|
|
});
|
|
|
|
async function loadPasswordRequirements() {
|
|
try {
|
|
const response = await fetch('/api/password_requirements');
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
passwordRequirements = data.data;
|
|
const requirementsHtml = passwordRequirements.map(r => `<li>${r}</li>`).join('');
|
|
document.getElementById('passwordRequirements').innerHTML = `<ul class="mb-0 ps-3">${requirementsHtml}</ul>`;
|
|
document.getElementById('resetPasswordRequirements').innerHTML = `<ul class="mb-0 ps-3">${requirementsHtml}</ul>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('載入密碼要求失敗:', error);
|
|
}
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const includeInactive = document.getElementById('showInactive').checked;
|
|
const tbody = document.getElementById('usersTableBody');
|
|
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">載入中...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
try {
|
|
const response = await fetch(`/api/users?include_inactive=${includeInactive}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
if (data.data.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4 text-muted">
|
|
<i class="fas fa-users fa-2x mb-2"></i><br>
|
|
尚無用戶資料
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.data.map(user => `
|
|
<tr class="${!user.is_active ? 'table-secondary' : ''}">
|
|
<td>
|
|
<i class="fas fa-user me-1"></i>
|
|
${escapeHtml(user.username)}
|
|
</td>
|
|
<td>${escapeHtml(user.display_name || '-')}</td>
|
|
<td>${escapeHtml(user.email || '-')}</td>
|
|
<td>
|
|
<span class="user-role-badge user-role-badge--${getRoleBadgeTone(user.role)}">
|
|
${escapeHtml(user.role_label)}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${user.is_active
|
|
? '<span class="user-status-badge is-active">啟用</span>'
|
|
: '<span class="user-status-badge is-inactive">停用</span>'
|
|
}
|
|
</td>
|
|
<td>${formatDateTime(user.created_at)}</td>
|
|
<td>${formatDateTime(user.updated_at)}</td>
|
|
<td class="text-end">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary" onclick="showEditUserModal(${user.id})" title="編輯">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
${user.role !== 'admin' ? `
|
|
<button class="btn btn-outline-info" onclick="showPermissionsModal(${user.id}, '${escapeHtml(user.username)}', '${escapeHtml(user.display_name || user.username)}')" title="編輯權限">
|
|
<i class="fas fa-shield-alt"></i>
|
|
</button>
|
|
` : ''}
|
|
<button class="btn btn-outline-warning" onclick="showResetPasswordModal(${user.id}, '${escapeHtml(user.username)}')" title="重設密碼">
|
|
<i class="fas fa-key"></i>
|
|
</button>
|
|
${user.is_active ? `
|
|
<button class="btn btn-outline-danger" onclick="showDeleteModal(${user.id}, '${escapeHtml(user.username)}')" title="停用">
|
|
<i class="fas fa-user-slash"></i>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} else {
|
|
showToast('載入失敗:' + data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('載入用戶列表失敗:', error);
|
|
showToast('載入用戶列表失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
function showAddUserModal() {
|
|
document.getElementById('userModalTitle').textContent = '新增用戶';
|
|
document.getElementById('userId').value = '';
|
|
document.getElementById('username').value = '';
|
|
document.getElementById('username').disabled = false;
|
|
document.getElementById('password').value = '';
|
|
document.getElementById('confirmPassword').value = '';
|
|
document.getElementById('confirmPassword').classList.remove('is-invalid');
|
|
document.getElementById('passwordGroup').style.display = 'block';
|
|
document.getElementById('confirmPasswordGroup').style.display = 'block';
|
|
document.getElementById('displayName').value = '';
|
|
document.getElementById('email').value = '';
|
|
document.getElementById('role').value = 'user';
|
|
document.getElementById('isActive').checked = true;
|
|
document.getElementById('isActiveGroup').style.display = 'none';
|
|
userModal.show();
|
|
}
|
|
|
|
async function showEditUserModal(userId) {
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const user = data.data;
|
|
document.getElementById('userModalTitle').textContent = '編輯用戶';
|
|
document.getElementById('userId').value = user.id;
|
|
document.getElementById('username').value = user.username;
|
|
document.getElementById('username').disabled = true;
|
|
document.getElementById('password').value = '';
|
|
document.getElementById('confirmPassword').value = '';
|
|
document.getElementById('passwordGroup').style.display = 'none';
|
|
document.getElementById('confirmPasswordGroup').style.display = 'none';
|
|
document.getElementById('displayName').value = user.display_name || '';
|
|
document.getElementById('email').value = user.email || '';
|
|
document.getElementById('role').value = user.role;
|
|
document.getElementById('isActive').checked = user.is_active;
|
|
document.getElementById('isActiveGroup').style.display = 'block';
|
|
userModal.show();
|
|
} else {
|
|
showToast('載入用戶資料失敗:' + data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('載入用戶資料失敗:', error);
|
|
showToast('載入用戶資料失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
async function saveUser() {
|
|
const userId = document.getElementById('userId').value;
|
|
const isEdit = !!userId;
|
|
|
|
const userData = {
|
|
username: document.getElementById('username').value.trim(),
|
|
display_name: document.getElementById('displayName').value.trim(),
|
|
email: document.getElementById('email').value.trim(),
|
|
role: document.getElementById('role').value
|
|
};
|
|
|
|
if (!isEdit) {
|
|
userData.password = document.getElementById('password').value;
|
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
const confirmInput = document.getElementById('confirmPassword');
|
|
|
|
if (!userData.password) {
|
|
showToast('請輸入密碼', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!confirmPassword) {
|
|
showToast('請輸入確認密碼', 'warning');
|
|
confirmInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
|
|
if (userData.password !== confirmPassword) {
|
|
showToast('兩次輸入的密碼不一致', 'warning');
|
|
confirmInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
|
|
confirmInput.classList.remove('is-invalid');
|
|
} else {
|
|
userData.is_active = document.getElementById('isActive').checked;
|
|
}
|
|
|
|
if (!userData.username) {
|
|
showToast('請輸入帳號', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = isEdit ? `/api/users/${userId}` : '/api/users';
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify(userData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
userModal.hide();
|
|
showToast(data.message, 'success');
|
|
loadUsers();
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('儲存用戶失敗:', error);
|
|
showToast('儲存用戶失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
function showResetPasswordModal(userId, username) {
|
|
document.getElementById('resetPasswordUserId').value = userId;
|
|
document.getElementById('resetPasswordUsername').textContent = username;
|
|
document.getElementById('newPassword').value = '';
|
|
document.getElementById('confirmNewPassword').value = '';
|
|
document.getElementById('confirmNewPassword').classList.remove('is-invalid');
|
|
resetPasswordModal.show();
|
|
}
|
|
|
|
async function resetPassword() {
|
|
const userId = document.getElementById('resetPasswordUserId').value;
|
|
const newPassword = document.getElementById('newPassword').value;
|
|
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
|
const confirmInput = document.getElementById('confirmNewPassword');
|
|
|
|
if (!newPassword) {
|
|
showToast('請輸入新密碼', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!confirmPassword) {
|
|
showToast('請輸入確認密碼', 'warning');
|
|
confirmInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
showToast('兩次輸入的密碼不一致', 'warning');
|
|
confirmInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
|
|
confirmInput.classList.remove('is-invalid');
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/reset_password`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify({ new_password: newPassword })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
resetPasswordModal.hide();
|
|
showToast('密碼重設成功', 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('重設密碼失敗:', error);
|
|
showToast('重設密碼失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
function showDeleteModal(userId, username) {
|
|
document.getElementById('deleteUserId').value = userId;
|
|
document.getElementById('deleteUsername').textContent = username;
|
|
deleteModal.show();
|
|
}
|
|
|
|
async function deleteUser() {
|
|
const userId = document.getElementById('deleteUserId').value;
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRFToken': csrfToken
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
deleteModal.hide();
|
|
showToast('用戶已停用', 'success');
|
|
loadUsers();
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('停用用戶失敗:', error);
|
|
showToast('停用用戶失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
function generatePassword() {
|
|
const password = generateRandomPassword();
|
|
document.getElementById('password').value = password;
|
|
document.getElementById('password').type = 'text';
|
|
document.getElementById('confirmPassword').value = password;
|
|
document.getElementById('confirmPassword').type = 'text';
|
|
document.getElementById('confirmPassword').classList.remove('is-invalid');
|
|
}
|
|
|
|
function generatePasswordForReset() {
|
|
const password = generateRandomPassword();
|
|
document.getElementById('newPassword').value = password;
|
|
document.getElementById('newPassword').type = 'text';
|
|
document.getElementById('confirmNewPassword').value = password;
|
|
document.getElementById('confirmNewPassword').type = 'text';
|
|
document.getElementById('confirmNewPassword').classList.remove('is-invalid');
|
|
}
|
|
|
|
function generateRandomPassword() {
|
|
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
|
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
const digits = '0123456789';
|
|
const special = '!@#$%^&*';
|
|
|
|
let password = '';
|
|
password += uppercase[Math.floor(Math.random() * uppercase.length)];
|
|
password += lowercase[Math.floor(Math.random() * lowercase.length)];
|
|
password += digits[Math.floor(Math.random() * digits.length)];
|
|
password += special[Math.floor(Math.random() * special.length)];
|
|
|
|
const allChars = lowercase + uppercase + digits + special;
|
|
for (let i = 0; i < 8; i++) {
|
|
password += allChars[Math.floor(Math.random() * allChars.length)];
|
|
}
|
|
|
|
return password.split('').sort(() => Math.random() - 0.5).join('');
|
|
}
|
|
|
|
function togglePasswordVisibility(inputId) {
|
|
const input = document.getElementById(inputId);
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
}
|
|
|
|
function getRoleBadgeTone(role) {
|
|
switch (role) {
|
|
case 'admin': return 'danger';
|
|
case 'manager': return 'warning';
|
|
default: return 'muted';
|
|
}
|
|
}
|
|
|
|
function formatDateTime(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString('zh-TW', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
// 簡單的 toast 實現
|
|
const toastContainer = document.querySelector('.toast-container') || createToastContainer();
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast align-items-center user-toast user-toast--${type}`;
|
|
toast.setAttribute('role', 'alert');
|
|
toast.innerHTML = `
|
|
<div class="d-flex">
|
|
<div class="toast-body">${escapeHtml(message)}</div>
|
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
`;
|
|
toastContainer.appendChild(toast);
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
bsToast.show();
|
|
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
|
}
|
|
|
|
function createToastContainer() {
|
|
const container = document.createElement('div');
|
|
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
|
document.body.appendChild(container);
|
|
return container;
|
|
}
|
|
|
|
// ==========================================
|
|
// 權限管理相關函數
|
|
// ==========================================
|
|
|
|
async function showPermissionsModal(userId, username, displayName) {
|
|
document.getElementById('permissionsUserId').value = userId;
|
|
document.getElementById('permissionsUsername').textContent = displayName || username;
|
|
document.getElementById('roleTemplateSelect').value = '';
|
|
|
|
// 顯示載入中
|
|
document.getElementById('permissionsContainer').innerHTML = `
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">載入中...</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
permissionsModal.show();
|
|
|
|
try {
|
|
// 載入用戶權限詳細資訊
|
|
const response = await fetch(`/api/users/${userId}/permissions`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
allPermissions = data.data.permissions;
|
|
renderPermissions(allPermissions);
|
|
} else {
|
|
showToast('載入權限失敗:' + data.message, 'danger');
|
|
permissionsModal.hide();
|
|
}
|
|
} catch (error) {
|
|
console.error('載入權限失敗:', error);
|
|
showToast('載入權限失敗', 'danger');
|
|
permissionsModal.hide();
|
|
}
|
|
}
|
|
|
|
function renderPermissions(permissionsByCategory) {
|
|
const container = document.getElementById('permissionsContainer');
|
|
|
|
// 分類圖示對應
|
|
const categoryIcons = {
|
|
'首頁/看板': 'fas fa-chart-line',
|
|
'報表': 'fas fa-file-alt',
|
|
'活動看板': 'fas fa-bullhorn',
|
|
'廠商缺貨': 'fas fa-industry',
|
|
'匯入': 'fas fa-file-import',
|
|
'系統': 'fas fa-cogs',
|
|
'其他': 'fas fa-folder'
|
|
};
|
|
|
|
let html = '<div class="accordion" id="permissionsAccordion">';
|
|
|
|
permissionsByCategory.forEach((categoryData, index) => {
|
|
const category = categoryData.category;
|
|
const permissions = categoryData.permissions;
|
|
const icon = categoryIcons[category] || 'fas fa-folder';
|
|
const collapseId = `collapse-${index}`;
|
|
const grantedCount = permissions.filter(p => p.granted).length;
|
|
|
|
html += `
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header">
|
|
<button class="accordion-button ${index > 0 ? 'collapsed' : ''}" type="button"
|
|
data-bs-toggle="collapse" data-bs-target="#${collapseId}">
|
|
<i class="${icon} me-2"></i>
|
|
<span class="flex-grow-1">${escapeHtml(category)}</span>
|
|
<span class="user-status-badge ${grantedCount > 0 ? 'is-info' : 'is-muted'} me-2">
|
|
${grantedCount} / ${permissions.length}
|
|
</span>
|
|
</button>
|
|
</h2>
|
|
<div id="${collapseId}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
|
|
data-bs-parent="#permissionsAccordion">
|
|
<div class="accordion-body">
|
|
<div class="row">
|
|
`;
|
|
|
|
permissions.forEach(perm => {
|
|
html += `
|
|
<div class="col-md-6 mb-2">
|
|
<div class="form-check">
|
|
<input class="form-check-input permission-checkbox" type="checkbox"
|
|
id="perm-${perm.code}" data-code="${perm.code}"
|
|
${perm.granted ? 'checked' : ''}>
|
|
<label class="form-check-label" for="perm-${perm.code}">
|
|
<strong>${escapeHtml(perm.name)}</strong>
|
|
${perm.description ? `<br><small class="text-muted">${escapeHtml(perm.description)}</small>` : ''}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function getSelectedPermissions() {
|
|
const checkboxes = document.querySelectorAll('.permission-checkbox:checked');
|
|
return Array.from(checkboxes).map(cb => cb.dataset.code);
|
|
}
|
|
|
|
function selectAllPermissions() {
|
|
document.querySelectorAll('.permission-checkbox').forEach(cb => cb.checked = true);
|
|
updateCategoryBadges();
|
|
}
|
|
|
|
function clearAllPermissions() {
|
|
document.querySelectorAll('.permission-checkbox').forEach(cb => cb.checked = false);
|
|
updateCategoryBadges();
|
|
}
|
|
|
|
function updateCategoryBadges() {
|
|
// 更新每個分類的徽章顯示
|
|
const accordion = document.getElementById('permissionsAccordion');
|
|
if (!accordion) return;
|
|
|
|
accordion.querySelectorAll('.accordion-item').forEach(item => {
|
|
const checkboxes = item.querySelectorAll('.permission-checkbox');
|
|
const checkedCount = item.querySelectorAll('.permission-checkbox:checked').length;
|
|
const badge = item.querySelector('.user-status-badge');
|
|
if (badge) {
|
|
badge.textContent = `${checkedCount} / ${checkboxes.length}`;
|
|
badge.className = `user-status-badge ${checkedCount > 0 ? 'is-info' : 'is-muted'} me-2`;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function applyRoleTemplate() {
|
|
const role = document.getElementById('roleTemplateSelect').value;
|
|
if (!role) {
|
|
showToast('請選擇角色模板', 'warning');
|
|
return;
|
|
}
|
|
|
|
const userId = document.getElementById('permissionsUserId').value;
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/apply_role_template`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify({ role: role })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast(data.message, 'success');
|
|
// 重新載入權限
|
|
showPermissionsModal(userId, '', document.getElementById('permissionsUsername').textContent);
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('套用角色模板失敗:', error);
|
|
showToast('套用角色模板失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
async function savePermissions() {
|
|
const userId = document.getElementById('permissionsUserId').value;
|
|
const permissions = getSelectedPermissions();
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/permissions`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify({ permissions: permissions })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
permissionsModal.hide();
|
|
showToast(data.message, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('儲存權限失敗:', error);
|
|
showToast('儲存權限失敗', 'danger');
|
|
}
|
|
}
|
|
|
|
// 監聽權限勾選變化,更新徽章
|
|
document.addEventListener('change', function(e) {
|
|
if (e.target.classList.contains('permission-checkbox')) {
|
|
updateCategoryBadges();
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|