Files
ewoooc/templates/user_management.html
OoO 5690d79a40
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
fix: 收斂系統管理頁新版樣式
2026-05-17 21:41:05 +08:00

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 %}