Add admin-generated reset links, reset UI, timezone-aware expiry display, and registration captcha coverage.
239 lines
10 KiB
HTML
239 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_pagination.html" import render_pagination %}
|
|
|
|
{% set active_page = "admin" %}
|
|
{% block title %}管理台 | 供水管道健康评估系统{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管理台</h1>
|
|
<p class="mt-2 text-sm text-textSub">管理系统注册状态、密码重置链接,查看所有用户的上传文件和预测结果。</p>
|
|
</div>
|
|
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
|
|
<span class="material-symbols-outlined text-lg">arrow_back</span>
|
|
返回主页
|
|
</a>
|
|
</div>
|
|
|
|
<section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-extrabold tracking-tight">用户注册</h2>
|
|
<p class="mt-1 text-sm text-textSub">
|
|
当前状态:<span id="registrationStatus" class="font-bold {{ 'text-successText' if registration_allowed else 'text-slate-600' }}">{{ '已开放' if registration_allowed else '已关闭' }}</span>
|
|
</p>
|
|
</div>
|
|
<form id="registrationForm" method="post" action="{{ url_for('main.update_registration_setting') }}" class="flex items-center gap-3">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<label class="inline-flex items-center gap-2 text-sm font-semibold text-slate-600">
|
|
<input id="registrationToggle" type="checkbox" name="allow_registration" class="rounded border-slate-300 text-primary focus:ring-primary" {{ 'checked' if registration_allowed }}>
|
|
允许自助注册
|
|
</label>
|
|
<button id="registrationSubmit" type="submit" class="ui-btn ui-btn-sm ui-btn-primary">
|
|
<span class="material-symbols-outlined text-lg">save</span>
|
|
<span id="registrationSubmitText">保存</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel">
|
|
<div class="mb-4 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-extrabold tracking-tight">用户密码重置</h2>
|
|
<p class="mt-1 text-sm text-textSub">为普通用户生成一次性重置链接,旧链接会自动失效。</p>
|
|
</div>
|
|
<span class="text-sm text-textSub">共 {{ password_reset_users|length }} 位普通用户</span>
|
|
</div>
|
|
<div id="resetLinkPanel" class="mb-4 hidden rounded-lg border border-blue-200 bg-blue-50 p-4">
|
|
<div class="mb-2 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="text-sm font-extrabold text-textMain">已生成重置链接</div>
|
|
<div id="resetLinkExpires" class="text-xs font-semibold text-textSub"></div>
|
|
</div>
|
|
<div class="flex flex-col gap-3 sm:flex-row">
|
|
<input id="resetLinkValue" class="min-w-0 flex-1 rounded-md border border-blue-200 bg-white px-3 py-2 text-sm text-textMain" readonly>
|
|
<button id="resetLinkCopy" type="button" class="ui-btn ui-btn-sm ui-btn-secondary">
|
|
<span class="material-symbols-outlined text-lg">content_copy</span>
|
|
复制
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-x-auto rounded-lg border border-line">
|
|
<table class="min-w-full text-sm">
|
|
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left">用户</th>
|
|
<th class="px-4 py-3 text-left">创建时间</th>
|
|
<th class="px-4 py-3 text-left">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-line">
|
|
{% for user in password_reset_users %}
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-4 py-3 font-semibold">{{ user.username }}</td>
|
|
<td class="px-4 py-3 text-textSub">{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '-' }}</td>
|
|
<td class="px-4 py-3">
|
|
<form method="post" action="{{ url_for('main.create_password_reset_link', user_id=user.id) }}" data-reset-link-form class="inline-flex">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="ui-btn ui-btn-sm ui-btn-secondary">
|
|
<span class="material-symbols-outlined text-lg">link</span>
|
|
生成重置链接
|
|
</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="3" class="px-4 py-8 text-center text-textSub">暂无普通用户</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="flex h-[710px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel">
|
|
<div class="border-b border-line px-5 py-4">
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 class="text-lg font-extrabold">上传记录</h2>
|
|
{% if pagination.total %}
|
|
<span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-auto">
|
|
<table class="min-w-full text-sm">
|
|
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
|
|
<tr>
|
|
<th class="px-5 py-4 text-left">用户</th>
|
|
<th class="px-5 py-4 text-left">原始文件</th>
|
|
<th class="px-5 py-4 text-left">上传时间</th>
|
|
<th class="px-5 py-4 text-left">下载</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-line">
|
|
{% for record in records %}
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-5 py-4 font-semibold">{{ record.user.username }}</td>
|
|
<td class="max-w-[420px] truncate px-5 py-4">{{ record.original_filename }}</td>
|
|
<td class="px-5 py-4 text-textSub">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
|
<td class="px-5 py-4">
|
|
<div class="flex flex-wrap gap-3">
|
|
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">原始文件</a>
|
|
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="4" class="px-5 py-12 text-center text-textSub">暂无上传记录</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ render_pagination(pagination, 'main.admin_dashboard') }}
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(() => {
|
|
const form = document.getElementById('registrationForm');
|
|
const toggle = document.getElementById('registrationToggle');
|
|
const status = document.getElementById('registrationStatus');
|
|
const submit = document.getElementById('registrationSubmit');
|
|
const submitText = document.getElementById('registrationSubmitText');
|
|
|
|
if (!form || !toggle || !status || !submit || !submitText) return;
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
submit.disabled = true;
|
|
submitText.textContent = '保存中';
|
|
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: 'POST',
|
|
body: new FormData(form),
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
window.showAppNotification?.(data.error || '保存失败,请刷新页面后重试。', 'error', '保存失败');
|
|
return;
|
|
}
|
|
|
|
toggle.checked = Boolean(data.registration_allowed);
|
|
status.textContent = data.status_label;
|
|
status.classList.toggle('text-successText', data.registration_allowed);
|
|
status.classList.toggle('text-slate-600', !data.registration_allowed);
|
|
window.showAppNotification?.(data.message, 'info');
|
|
} catch (error) {
|
|
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '保存失败');
|
|
} finally {
|
|
submit.disabled = false;
|
|
submitText.textContent = '保存';
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const panel = document.getElementById('resetLinkPanel');
|
|
const value = document.getElementById('resetLinkValue');
|
|
const expires = document.getElementById('resetLinkExpires');
|
|
const copy = document.getElementById('resetLinkCopy');
|
|
|
|
if (!panel || !value || !expires || !copy) return;
|
|
|
|
document.querySelectorAll('[data-reset-link-form]').forEach((form) => {
|
|
const submit = form.querySelector('button[type="submit"]');
|
|
const submitText = submit?.lastChild;
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
if (submit) submit.disabled = true;
|
|
if (submitText) submitText.textContent = '生成中';
|
|
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: 'POST',
|
|
body: new FormData(form),
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
window.showAppNotification?.(data.error || '生成失败,请刷新页面后重试。', 'error', '生成失败');
|
|
return;
|
|
}
|
|
|
|
value.value = data.reset_url;
|
|
expires.textContent = `有效期至 ${data.expires_at}`;
|
|
panel.classList.remove('hidden');
|
|
window.showAppNotification?.(data.message, 'info');
|
|
} catch (error) {
|
|
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '生成失败');
|
|
} finally {
|
|
if (submit) submit.disabled = false;
|
|
if (submitText) submitText.textContent = '生成重置链接';
|
|
}
|
|
});
|
|
});
|
|
|
|
copy.addEventListener('click', async () => {
|
|
value.select();
|
|
try {
|
|
await navigator.clipboard.writeText(value.value);
|
|
} catch (error) {
|
|
document.execCommand('copy');
|
|
}
|
|
window.showAppNotification?.('重置链接已复制', 'info');
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|