feat: 公告系统 — Phase 2 完成

- Model/Repo/Service/Controller 全栈实现
- CRUD + ListActive(生效时间范围过滤)
- 管理后台独立页面 /admin/announcements(列表+弹窗CRUD)
- 前台首页 Hero 下方公告栏(info/success/warning/error 四色)
- Moderator+ 权限可管理公告
This commit is contained in:
2026-06-22 00:00:21 +08:00
parent 2d1dd5d401
commit fc57ed17d4
15 changed files with 837 additions and 8 deletions

View File

@ -17,6 +17,18 @@
</div>
</section>
{{if .Announcements}}
<!-- Announcement Bar -->
<div class="announcement-bar">
{{range .Announcements}}
<div class="announcement-item announcement-{{.Type}}">
<span class="announcement-title">{{.Title}}</span>
<span class="announcement-content">{{.Content}}</span>
</div>
{{end}}
</div>
{{end}}
<!-- Main Content -->
<div class="container home-main">
<div class="home-grid">

View File

@ -368,6 +368,61 @@
flex-shrink: 0;
}
/* --- Announcement Bar --- */
.announcement-bar {
max-width: 900px;
margin: 0 auto;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 1.5rem;
}
.announcement-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
border-left: 4px solid;
}
.announcement-title {
font-weight: 650;
white-space: nowrap;
}
.announcement-content {
color: #374151;
flex: 1;
}
.announcement-info {
background: #dbeafe;
border-color: #3b82f6;
}
.announcement-info .announcement-title { color: #1d4ed8; }
.announcement-success {
background: #dcfce7;
border-color: #22c55e;
}
.announcement-success .announcement-title { color: #15803d; }
.announcement-warning {
background: #fef3c7;
border-color: #f59e0b;
}
.announcement-warning .announcement-title { color: #b45309; }
.announcement-error {
background: #fee2e2;
border-color: #ef4444;
}
.announcement-error .announcement-title { color: #dc2626; }
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.home-grid {

View File

@ -0,0 +1,89 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>公告管理</h1>
<p class="page-desc">管理前台首页公告栏展示内容</p>
</div>
<div class="toolbar">
<button class="btn btn-primary" id="btnNewAnnouncement">+ 新建公告</button>
</div>
<table class="data-table" id="announcementTable">
<thead>
<tr>
<th style="width:60px">ID</th>
<th>标题</th>
<th style="width:80px">类型</th>
<th style="width:80px">状态</th>
<th style="width:140px">生效时间</th>
<th style="width:60px">排序</th>
<th style="width:160px">创建时间</th>
<th style="width:120px">操作</th>
</tr>
</thead>
<tbody id="announcementList">
<tr><td colspan="8" class="table-empty">加载中...</td></tr>
</tbody>
</table>
<div class="pagination" id="pagination" style="display:none"></div>
<!-- 编辑/新建弹窗 -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">新建公告</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<form id="announcementForm">
<input type="hidden" id="announcementId">
<div class="form-group">
<label for="announcementTitle">标题</label>
<input type="text" id="announcementTitle" class="form-input" maxlength="200" required>
</div>
<div class="form-group">
<label for="announcementContent">内容</label>
<textarea id="announcementContent" class="form-textarea" rows="3" required></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="announcementType">类型</label>
<select id="announcementType" class="form-select">
{{range $k, $v := .Types}}
<option value="{{$k}}">{{$v.Label}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label for="announcementSort">排序(越小越前)</label>
<input type="number" id="announcementSort" class="form-input" value="0" min="0" style="width:100px">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="announcementStartAt">生效开始(可选)</label>
<input type="datetime-local" id="announcementStartAt" class="form-input">
</div>
<div class="form-group">
<label for="announcementEndAt">生效结束(可选)</label>
<input type="datetime-local" id="announcementEndAt" class="form-input">
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="announcementIsActive" checked>
启用
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalSave">保存</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
{{template "admin/layout/footer.html" .}}

View File

@ -55,6 +55,10 @@
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></span>
公户流水
</a>
<a href="/admin/announcements" class="sidebar-item {{if eq .CurrentPath "/admin/announcements"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
公告管理
</a>
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
评论管理

View 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; }

View 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);
})();