feat: 公告系统 — Phase 2 完成
- Model/Repo/Service/Controller 全栈实现 - CRUD + ListActive(生效时间范围过滤) - 管理后台独立页面 /admin/announcements(列表+弹窗CRUD) - 前台首页 Hero 下方公告栏(info/success/warning/error 四色) - Moderator+ 权限可管理公告
This commit is contained in:
13
docs/migrations/002_announcements.sql
Normal file
13
docs/migrations/002_announcements.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-- 公告表
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
type VARCHAR(20) DEFAULT 'info',
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
start_at TIMESTAMP,
|
||||||
|
end_at TIMESTAMP,
|
||||||
|
sort INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
102
internal/controller/admin/announcement_controller.go
Normal file
102
internal/controller/admin/announcement_controller.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/common"
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnnouncementController 公告管理控制器
|
||||||
|
type AnnouncementController struct {
|
||||||
|
svc announcementUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type announcementUseCase interface {
|
||||||
|
Create(a *model.Announcement) error
|
||||||
|
Update(a *model.Announcement) error
|
||||||
|
Delete(id uint) error
|
||||||
|
List(offset, limit int) ([]model.Announcement, int64, error)
|
||||||
|
ListActive() ([]model.Announcement, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnnouncementController 构造函数
|
||||||
|
func NewAnnouncementController(svc announcementUseCase) *AnnouncementController {
|
||||||
|
return &AnnouncementController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 公告管理页面
|
||||||
|
func (ac *AnnouncementController) Page(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/announcements/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "公告管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/announcements.css",
|
||||||
|
"ExtraJS": "/admin/static/js/announcements.js",
|
||||||
|
"Types": service.AnnouncementTypes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 公告列表 API
|
||||||
|
func (ac *AnnouncementController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
list, total, err := ac.svc.List(offset, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{"list": list, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建公告 API
|
||||||
|
func (ac *AnnouncementController) Create(c *gin.Context) {
|
||||||
|
var req model.Announcement
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ac.svc.Create(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新公告 API
|
||||||
|
func (ac *AnnouncementController) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.Announcement
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = uint(id)
|
||||||
|
if err := ac.svc.Update(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除公告 API
|
||||||
|
func (ac *AnnouncementController) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ac.svc.Delete(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已删除")
|
||||||
|
}
|
||||||
17
internal/model/announcement.go
Normal file
17
internal/model/announcement.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Announcement 公告
|
||||||
|
type Announcement struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
|
||||||
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
StartAt *time.Time `json:"start_at,omitempty"`
|
||||||
|
EndAt *time.Time `json:"end_at,omitempty"`
|
||||||
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
73
internal/repository/announcement_repo.go
Normal file
73
internal/repository/announcement_repo.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnnouncementRepo 公告数据访问
|
||||||
|
type AnnouncementRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnnouncementRepo 构造函数
|
||||||
|
func NewAnnouncementRepo(db *gorm.DB) *AnnouncementRepo {
|
||||||
|
return &AnnouncementRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于事务创建新实例
|
||||||
|
func (r *AnnouncementRepo) WithTx(tx *gorm.DB) service.AnnouncementStore {
|
||||||
|
return &AnnouncementRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建公告
|
||||||
|
func (r *AnnouncementRepo) Create(a *model.Announcement) error {
|
||||||
|
return r.db.Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新公告
|
||||||
|
func (r *AnnouncementRepo) Update(a *model.Announcement) error {
|
||||||
|
return r.db.Save(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除公告
|
||||||
|
func (r *AnnouncementRepo) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&model.Announcement{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 ID 查询
|
||||||
|
func (r *AnnouncementRepo) FindByID(id uint) (*model.Announcement, error) {
|
||||||
|
var a model.Announcement
|
||||||
|
err := r.db.First(&a, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询全部公告(管理后台用)
|
||||||
|
func (r *AnnouncementRepo) List(offset, limit int) ([]model.Announcement, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.Announcement{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var list []model.Announcement
|
||||||
|
err := r.db.Order("sort ASC, created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
|
||||||
|
return list, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActive 查询当前生效中的公告(前台用,按 sort 排序)
|
||||||
|
func (r *AnnouncementRepo) ListActive() ([]model.Announcement, error) {
|
||||||
|
now := time.Now()
|
||||||
|
var list []model.Announcement
|
||||||
|
err := r.db.Where("is_active = true").
|
||||||
|
Where("start_at IS NULL OR start_at <= ?", now).
|
||||||
|
Where("end_at IS NULL OR end_at >= ?", now).
|
||||||
|
Order("sort ASC, created_at DESC").
|
||||||
|
Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
||||||
|
adminPages.GET("/announcements", d.announcementCtrl.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 API ---
|
// --- 管理后台 API ---
|
||||||
@ -99,4 +100,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
|
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
|
||||||
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
|
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 公告管理 API(moderator+) ---
|
||||||
|
announcementAPI := r.Group("/api/admin/announcements")
|
||||||
|
announcementAPI.Use(d.authMdw.Required())
|
||||||
|
announcementAPI.Use(middleware.RequireMinRole(model.RoleModerator))
|
||||||
|
announcementAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
announcementAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
announcementAPI.GET("", d.announcementCtrl.List)
|
||||||
|
announcementAPI.POST("", d.announcementCtrl.Create)
|
||||||
|
announcementAPI.PUT("/:id", d.announcementCtrl.Update)
|
||||||
|
announcementAPI.DELETE("/:id", d.announcementCtrl.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,8 @@ type dependencies struct {
|
|||||||
favoriteService *service.FavoriteService
|
favoriteService *service.FavoriteService
|
||||||
trendsService *service.TrendsService
|
trendsService *service.TrendsService
|
||||||
homeCache *cache.HomePageCache
|
homeCache *cache.HomePageCache
|
||||||
|
announcementService *service.AnnouncementService
|
||||||
|
announcementCtrl *adminCtrl.AnnouncementController
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
|
|||||||
@ -103,6 +103,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
|
|
||||||
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
||||||
|
|
||||||
|
// 公告系统
|
||||||
|
announcementRepo := repository.NewAnnouncementRepo(db)
|
||||||
|
announcementService := service.NewAnnouncementService(announcementRepo)
|
||||||
|
announcementCtrl := adminCtrl.NewAnnouncementController(announcementService)
|
||||||
|
|
||||||
// 首页内存缓存(30s TTL),文章变更时失效
|
// 首页内存缓存(30s TTL),文章变更时失效
|
||||||
homeCache := cache.NewHomePageCache(30 * time.Second)
|
homeCache := cache.NewHomePageCache(30 * time.Second)
|
||||||
postService.WithHomePageInvalidator(func() { homeCache.Invalidate() })
|
postService.WithHomePageInvalidator(func() { homeCache.Invalidate() })
|
||||||
@ -128,5 +133,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
favoriteService: favoriteService,
|
favoriteService: favoriteService,
|
||||||
trendsService: trendsService,
|
trendsService: trendsService,
|
||||||
homeCache: homeCache,
|
homeCache: homeCache,
|
||||||
|
announcementService: announcementService,
|
||||||
|
announcementCtrl: announcementCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,11 +33,13 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
d.homeCache.Set(posts, total)
|
d.homeCache.Set(posts, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
announcements, _ := d.announcementService.ListActive()
|
||||||
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "首页",
|
"Title": "首页",
|
||||||
"ExtraCSS": "/static/css/home.css",
|
"ExtraCSS": "/static/css/home.css",
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
"PostCount": total,
|
"PostCount": total,
|
||||||
|
"Announcements": announcements,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
59
internal/service/announcement_service.go
Normal file
59
internal/service/announcement_service.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
|
// AnnouncementTypes 公告类型及其显示名称和颜色
|
||||||
|
var AnnouncementTypes = map[string]struct {
|
||||||
|
Label string
|
||||||
|
Color string
|
||||||
|
}{
|
||||||
|
"info": {"信息", "#3b82f6"},
|
||||||
|
"success": {"成功", "#22c55e"},
|
||||||
|
"warning": {"警告", "#f59e0b"},
|
||||||
|
"error": {"错误", "#ef4444"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementStore 公告仓库接口(ISP)
|
||||||
|
type AnnouncementStore interface {
|
||||||
|
Create(a *model.Announcement) error
|
||||||
|
Update(a *model.Announcement) error
|
||||||
|
Delete(id uint) error
|
||||||
|
FindByID(id uint) (*model.Announcement, error)
|
||||||
|
List(offset, limit int) ([]model.Announcement, int64, error)
|
||||||
|
ListActive() ([]model.Announcement, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementService 公告业务逻辑
|
||||||
|
type AnnouncementService struct {
|
||||||
|
repo AnnouncementStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnnouncementService 构造函数
|
||||||
|
func NewAnnouncementService(repo AnnouncementStore) *AnnouncementService {
|
||||||
|
return &AnnouncementService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建公告
|
||||||
|
func (s *AnnouncementService) Create(a *model.Announcement) error {
|
||||||
|
return s.repo.Create(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新公告
|
||||||
|
func (s *AnnouncementService) Update(a *model.Announcement) error {
|
||||||
|
return s.repo.Update(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除公告
|
||||||
|
func (s *AnnouncementService) Delete(id uint) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页列表
|
||||||
|
func (s *AnnouncementService) List(offset, limit int) ([]model.Announcement, int64, error) {
|
||||||
|
return s.repo.List(offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActive 生效中的公告
|
||||||
|
func (s *AnnouncementService) ListActive() ([]model.Announcement, error) {
|
||||||
|
return s.repo.ListActive()
|
||||||
|
}
|
||||||
@ -17,6 +17,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Main Content -->
|
||||||
<div class="container home-main">
|
<div class="container home-main">
|
||||||
<div class="home-grid">
|
<div class="home-grid">
|
||||||
|
|||||||
@ -368,6 +368,61 @@
|
|||||||
flex-shrink: 0;
|
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 ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.home-grid {
|
.home-grid {
|
||||||
|
|||||||
89
templates/admin/html/announcements/index.html
Normal file
89
templates/admin/html/announcements/index.html
Normal 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">×</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" .}}
|
||||||
@ -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>
|
<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>
|
||||||
|
<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}}">
|
<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>
|
<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>
|
||||||
评论管理
|
评论管理
|
||||||
|
|||||||
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