diff --git a/docs/migrations/002_announcements.sql b/docs/migrations/002_announcements.sql new file mode 100644 index 0000000..52fcfed --- /dev/null +++ b/docs/migrations/002_announcements.sql @@ -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() +); diff --git a/internal/controller/admin/announcement_controller.go b/internal/controller/admin/announcement_controller.go new file mode 100644 index 0000000..d526278 --- /dev/null +++ b/internal/controller/admin/announcement_controller.go @@ -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, "公告已删除") +} diff --git a/internal/model/announcement.go b/internal/model/announcement.go new file mode 100644 index 0000000..5a6a114 --- /dev/null +++ b/internal/model/announcement.go @@ -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"` +} diff --git a/internal/repository/announcement_repo.go b/internal/repository/announcement_repo.go new file mode 100644 index 0000000..674cc60 --- /dev/null +++ b/internal/repository/announcement_repo.go @@ -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 +} diff --git a/internal/router/admin.go b/internal/router/admin.go index 647b090..c5c093a 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -32,6 +32,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage) adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage) adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage) + adminPages.GET("/announcements", d.announcementCtrl.Page) } // --- 管理后台 API --- @@ -99,4 +100,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminCommentAPI.GET("", d.adminCommentCtrl.ListComments) 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) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 43a51bd..c067533 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -43,8 +43,10 @@ type dependencies struct { followCtrl *controller.FollowController favoriteCtrl *controller.FavoriteController favoriteService *service.FavoriteService - trendsService *service.TrendsService - homeCache *cache.HomePageCache + trendsService *service.TrendsService + homeCache *cache.HomePageCache + announcementService *service.AnnouncementService + announcementCtrl *adminCtrl.AnnouncementController } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 0141408..73f5287 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -103,6 +103,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr) + // 公告系统 + announcementRepo := repository.NewAnnouncementRepo(db) + announcementService := service.NewAnnouncementService(announcementRepo) + announcementCtrl := adminCtrl.NewAnnouncementController(announcementService) + // 首页内存缓存(30s TTL),文章变更时失效 homeCache := cache.NewHomePageCache(30 * time.Second) postService.WithHomePageInvalidator(func() { homeCache.Invalidate() }) @@ -126,7 +131,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting followCtrl: followCtrl, favoriteCtrl: favoriteCtrl, favoriteService: favoriteService, - trendsService: trendsService, - homeCache: homeCache, + trendsService: trendsService, + homeCache: homeCache, + announcementService: announcementService, + announcementCtrl: announcementCtrl, } } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index fc0a7fa..aaf7079 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -33,11 +33,13 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { d.homeCache.Set(posts, total) } } + announcements, _ := d.announcementService.ListActive() c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{ - "Title": "首页", - "ExtraCSS": "/static/css/home.css", - "Posts": posts, - "PostCount": total, + "Title": "首页", + "ExtraCSS": "/static/css/home.css", + "Posts": posts, + "PostCount": total, + "Announcements": announcements, })) }) diff --git a/internal/service/announcement_service.go b/internal/service/announcement_service.go new file mode 100644 index 0000000..981e9e3 --- /dev/null +++ b/internal/service/announcement_service.go @@ -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() +} diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html index a248344..05826a9 100644 --- a/templates/MetaLab-2026/html/home/index.html +++ b/templates/MetaLab-2026/html/home/index.html @@ -17,6 +17,18 @@ +{{if .Announcements}} + +
+ {{range .Announcements}} +
+ {{.Title}} + {{.Content}} +
+ {{end}} +
+{{end}} +
diff --git a/templates/MetaLab-2026/static/css/home.css b/templates/MetaLab-2026/static/css/home.css index 42b7b7b..01f3436 100644 --- a/templates/MetaLab-2026/static/css/home.css +++ b/templates/MetaLab-2026/static/css/home.css @@ -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 { diff --git a/templates/admin/html/announcements/index.html b/templates/admin/html/announcements/index.html new file mode 100644 index 0000000..48271a2 --- /dev/null +++ b/templates/admin/html/announcements/index.html @@ -0,0 +1,89 @@ +{{template "admin/layout/base.html" .}} + + +
+ +
+ + + + + + + + + + + + + + + + + +
ID标题类型状态生效时间排序创建时间操作
加载中...
+ + + + + + +
+{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html index 32232ec..1289455 100644 --- a/templates/admin/html/layout/base.html +++ b/templates/admin/html/layout/base.html @@ -55,6 +55,10 @@ 公户流水 + + + 公告管理 + 评论管理 diff --git a/templates/admin/static/css/announcements.css b/templates/admin/static/css/announcements.css new file mode 100644 index 0000000..089cf47 --- /dev/null +++ b/templates/admin/static/css/announcements.css @@ -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; } diff --git a/templates/admin/static/js/announcements.js b/templates/admin/static/js/announcements.js new file mode 100644 index 0000000..d7e7e26 --- /dev/null +++ b/templates/admin/static/js/announcements.js @@ -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 = '暂无公告'; + 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 += '' + + '' + a.id + '' + + '' + escapeHtml(a.title) + '' + + '' + (typeLabel(a.type)) + '' + + '' + (a.is_active ? '启用' : '禁用') + '' + + '' + timeStr + '' + + '' + a.sort + '' + + '' + formatDate(a.created_at) + '' + + '编辑 删除' + + ''; + }); + 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 += '' + i + ''; + } + 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); +})();