feat: 公告系统 — Phase 2 完成
- Model/Repo/Service/Controller 全栈实现 - CRUD + ListActive(生效时间范围过滤) - 管理后台独立页面 /admin/announcements(列表+弹窗CRUD) - 前台首页 Hero 下方公告栏(info/success/warning/error 四色) - Moderator+ 权限可管理公告
This commit is contained in:
162
templates/admin/static/css/announcements.css
Normal file
162
templates/admin/static/css/announcements.css
Normal file
@ -0,0 +1,162 @@
|
||||
/* ============================================================
|
||||
Announcements Admin — 公告管理页面样式
|
||||
============================================================ */
|
||||
|
||||
/* Badge 类型色 */
|
||||
.badge-info { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-success { background: #dcfce7; color: #15803d; }
|
||||
.badge-warning { background: #fef3c7; color: #b45309; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.badge-active { background: #dcfce7; color: #15803d; }
|
||||
.badge-inactive { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Data table */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
color: #ef4444;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Modal 表单 */
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-item.active {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.toast-success { background: #22c55e; }
|
||||
.toast-error { background: #ef4444; }
|
||||
218
templates/admin/static/js/announcements.js
Normal file
218
templates/admin/static/js/announcements.js
Normal file
@ -0,0 +1,218 @@
|
||||
/* ============================================================
|
||||
Announcements JS — 公告管理页面脚本
|
||||
============================================================ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var pageSize = 20;
|
||||
var currentPage = 1;
|
||||
var editingId = null;
|
||||
|
||||
// DOM refs
|
||||
var tableBody = document.getElementById('announcementList');
|
||||
var pagination = document.getElementById('pagination');
|
||||
var btnNew = document.getElementById('btnNewAnnouncement');
|
||||
var modalOverlay = document.getElementById('modalOverlay');
|
||||
var modalTitle = document.getElementById('modalTitle');
|
||||
var modalClose = document.getElementById('modalClose');
|
||||
var modalCancel = document.getElementById('modalCancel');
|
||||
var modalSave = document.getElementById('modalSave');
|
||||
var form = document.getElementById('announcementForm');
|
||||
var toast = document.getElementById('toast');
|
||||
var announcementId = document.getElementById('announcementId');
|
||||
var announcementTitle = document.getElementById('announcementTitle');
|
||||
var announcementContent = document.getElementById('announcementContent');
|
||||
var announcementType = document.getElementById('announcementType');
|
||||
var announcementSort = document.getElementById('announcementSort');
|
||||
var announcementStartAt = document.getElementById('announcementStartAt');
|
||||
var announcementEndAt = document.getElementById('announcementEndAt');
|
||||
var announcementIsActive = document.getElementById('announcementIsActive');
|
||||
|
||||
if (!tableBody) return;
|
||||
|
||||
// ---- Toast ----
|
||||
function showToast(msg, isError) {
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast' + (isError ? ' toast-error' : ' toast-success');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function () { toast.style.display = 'none'; }, 2500);
|
||||
}
|
||||
|
||||
// ---- Load list ----
|
||||
function loadList(page) {
|
||||
currentPage = page || 1;
|
||||
fetch('/api/admin/announcements?page=' + currentPage + '&page_size=' + pageSize)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
if (!resp.success) { showToast(resp.message || '加载失败', true); return; }
|
||||
renderTable(resp.data.list);
|
||||
renderPagination(resp.data.total);
|
||||
})
|
||||
.catch(function () { showToast('网络异常', true); });
|
||||
}
|
||||
|
||||
function renderTable(list) {
|
||||
if (!list || list.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="table-empty">暂无公告</td></tr>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
list.forEach(function (a) {
|
||||
var typeClass = 'badge-' + a.type;
|
||||
var statusClass = a.is_active ? 'badge-active' : 'badge-inactive';
|
||||
var timeStr = '';
|
||||
if (a.start_at) timeStr += formatDate(a.start_at);
|
||||
timeStr += ' ~ ';
|
||||
if (a.end_at) timeStr += formatDate(a.end_at);
|
||||
else timeStr += '永久';
|
||||
html += '<tr>' +
|
||||
'<td>' + a.id + '</td>' +
|
||||
'<td>' + escapeHtml(a.title) + '</td>' +
|
||||
'<td><span class="badge ' + typeClass + '">' + (typeLabel(a.type)) + '</span></td>' +
|
||||
'<td><span class="badge ' + statusClass + '">' + (a.is_active ? '启用' : '禁用') + '</span></td>' +
|
||||
'<td>' + timeStr + '</td>' +
|
||||
'<td>' + a.sort + '</td>' +
|
||||
'<td>' + formatDate(a.created_at) + '</td>' +
|
||||
'<td><a href="#" class="btn-edit" data-id="' + a.id + '">编辑</a> <a href="#" class="btn-delete" data-id="' + a.id + '">删除</a></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tableBody.innerHTML = html;
|
||||
|
||||
// Bind edit/delete
|
||||
tableBody.querySelectorAll('.btn-edit').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var id = parseInt(this.getAttribute('data-id'));
|
||||
var item = list.find(function (x) { return x.id === id; });
|
||||
if (item) openModal(item);
|
||||
});
|
||||
});
|
||||
tableBody.querySelectorAll('.btn-delete').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var id = parseInt(this.getAttribute('data-id'));
|
||||
if (confirm('确定删除该公告?')) {
|
||||
fetch('/api/admin/announcements/' + id, { method: 'DELETE' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
showToast(resp.message || '已删除', !resp.success);
|
||||
if (resp.success) loadList(currentPage);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(total) {
|
||||
var totalPages = Math.ceil(total / pageSize) || 1;
|
||||
if (totalPages <= 1) { pagination.style.display = 'none'; return; }
|
||||
var html = '';
|
||||
for (var i = 1; i <= totalPages; i++) {
|
||||
html += '<a href="#" class="page-item' + (i === currentPage ? ' active' : '') + '" data-page="' + i + '">' + i + '</a>';
|
||||
}
|
||||
pagination.innerHTML = html;
|
||||
pagination.style.display = '';
|
||||
pagination.querySelectorAll('.page-item').forEach(function (item) {
|
||||
item.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
loadList(parseInt(this.getAttribute('data-page')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Modal ----
|
||||
function openModal(item) {
|
||||
if (item) {
|
||||
editingId = item.id;
|
||||
modalTitle.textContent = '编辑公告';
|
||||
announcementId.value = item.id;
|
||||
announcementTitle.value = item.title;
|
||||
announcementContent.value = item.content;
|
||||
announcementType.value = item.type;
|
||||
announcementSort.value = item.sort;
|
||||
announcementStartAt.value = item.start_at ? toDatetimeLocal(item.start_at) : '';
|
||||
announcementEndAt.value = item.end_at ? toDatetimeLocal(item.end_at) : '';
|
||||
announcementIsActive.checked = item.is_active;
|
||||
} else {
|
||||
editingId = null;
|
||||
modalTitle.textContent = '新建公告';
|
||||
form.reset();
|
||||
announcementId.value = '';
|
||||
announcementSort.value = '0';
|
||||
announcementType.value = 'info';
|
||||
announcementIsActive.checked = true;
|
||||
}
|
||||
modalOverlay.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOverlay.classList.remove('active');
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
btnNew.addEventListener('click', function () { openModal(null); });
|
||||
modalClose.addEventListener('click', closeModal);
|
||||
modalCancel.addEventListener('click', closeModal);
|
||||
modalOverlay.addEventListener('click', function (e) { if (e.target === modalOverlay) closeModal(); });
|
||||
|
||||
modalSave.addEventListener('click', function () {
|
||||
var data = {
|
||||
title: announcementTitle.value.trim(),
|
||||
content: announcementContent.value.trim(),
|
||||
type: announcementType.value,
|
||||
sort: parseInt(announcementSort.value) || 0,
|
||||
start_at: announcementStartAt.value ? new Date(announcementStartAt.value).toISOString() : null,
|
||||
end_at: announcementEndAt.value ? new Date(announcementEndAt.value).toISOString() : null,
|
||||
is_active: announcementIsActive.checked,
|
||||
};
|
||||
if (!data.title) { showToast('请输入标题', true); return; }
|
||||
if (!data.content) { showToast('请输入内容', true); return; }
|
||||
|
||||
var url = '/api/admin/announcements';
|
||||
var method = 'POST';
|
||||
if (editingId) {
|
||||
url += '/' + editingId;
|
||||
method = 'PUT';
|
||||
}
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
showToast(resp.message || '保存成功', !resp.success);
|
||||
if (resp.success) { closeModal(); loadList(currentPage); }
|
||||
})
|
||||
.catch(function () { showToast('网络异常', true); });
|
||||
});
|
||||
|
||||
// ---- Helpers ----
|
||||
function formatDate(s) {
|
||||
if (!s) return '';
|
||||
var d = new Date(s);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function toDatetimeLocal(s) {
|
||||
if (!s) return '';
|
||||
var d = new Date(s);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function escapeHtml(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
var map = { info: '信息', success: '成功', warning: '警告', error: '错误' };
|
||||
return map[t] || t;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
loadList(1);
|
||||
})();
|
||||
Reference in New Issue
Block a user