feat(admin): 系统公告 - 支持置顶排序与管理端标识
Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com>
This commit is contained in:
@@ -374,6 +374,7 @@ type Announcement struct {
|
||||
Title string `json:"title" gorm:"size:120"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Level AnnouncementLevel `json:"level" gorm:"index;size:24"`
|
||||
Pinned bool `json:"pinned" gorm:"index"`
|
||||
Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"`
|
||||
CreatedBy string `json:"createdBy" gorm:"index;size:36"`
|
||||
PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"`
|
||||
|
||||
@@ -24,7 +24,7 @@ func (r *Repository) AdminAnnouncements(keyword string, status model.Announcemen
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := query.Order("published_at desc").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil {
|
||||
if err := query.Order("pinned desc, published_at desc").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return announcements, total, nil
|
||||
@@ -34,7 +34,7 @@ func (r *Repository) AnnouncementFeed(userID string) ([]model.Announcement, int6
|
||||
var announcements []model.Announcement
|
||||
var unreadCount int64
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("status = ?", model.AnnouncementStatusActive).Order("published_at desc").Find(&announcements).Error; err != nil {
|
||||
if err := tx.Where("status = ?", model.AnnouncementStatusActive).Order("pinned desc, published_at desc").Find(&announcements).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Announcement{}).
|
||||
@@ -66,6 +66,7 @@ func (r *Repository) UpdateAnnouncement(announcement *model.Announcement) error
|
||||
"title": announcement.Title,
|
||||
"content": announcement.Content,
|
||||
"level": announcement.Level,
|
||||
"pinned": announcement.Pinned,
|
||||
"status": announcement.Status,
|
||||
"published_at": announcement.PublishedAt,
|
||||
"closed_at": announcement.ClosedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ type CreateAnnouncementRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Level model.AnnouncementLevel `json:"level"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type UpdateAnnouncementRequest = CreateAnnouncementRequest
|
||||
@@ -57,6 +58,7 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe
|
||||
Title: title,
|
||||
Content: content,
|
||||
Level: level,
|
||||
Pinned: req.Pinned,
|
||||
Status: model.AnnouncementStatusActive,
|
||||
CreatedBy: actor.ID,
|
||||
PublishedAt: now,
|
||||
@@ -88,6 +90,7 @@ func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnn
|
||||
announcement.Title = title
|
||||
announcement.Content = content
|
||||
announcement.Level = level
|
||||
announcement.Pinned = req.Pinned
|
||||
announcement.Status = model.AnnouncementStatusActive
|
||||
announcement.ClosedAt = nil
|
||||
announcement.PublishedAt = now
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -97,11 +98,11 @@ func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning})
|
||||
updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning, Pinned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning {
|
||||
if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning || !updated.Pinned {
|
||||
t.Fatalf("updated announcement = %+v", updated)
|
||||
}
|
||||
feed, err := svc.UserAnnouncements(user)
|
||||
@@ -113,6 +114,51 @@ func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedAnnouncementsAreReturnedBeforeNewerRegularAnnouncements(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
|
||||
pinned, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "置顶", Content: "置顶公告", Level: model.AnnouncementLevelWarning, Pinned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "普通", Content: "较新的普通公告", Level: model.AnnouncementLevelInfo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.Announcement{}).Where("id = ?", pinned.ID).Update("published_at", regular.PublishedAt.Add(-time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feed, err := svc.UserAnnouncements(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Announcements) != 2 || feed.Announcements[0].ID != pinned.ID || !feed.Announcements[0].Pinned {
|
||||
t.Fatalf("feed = %+v, want pinned announcement first", feed.Announcements)
|
||||
}
|
||||
page, err := svc.AdminAnnouncementPage(admin, AdminListQuery{Page: 1, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Announcements) != 2 || page.Announcements[0].ID != pinned.ID {
|
||||
t.Fatalf("admin page = %+v, want pinned announcement first", page.Announcements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) {
|
||||
svc := &Service{}
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
|
||||
@@ -49,6 +49,15 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
请求表保存工作流执行 payload 和结果,但不保存 Bridge Token。任务取消时,未领取请求可以确定取消;已经领取的本机工作流无法可靠中止,迟到结果会被丢弃并进入费用核对流程。
|
||||
|
||||
## 系统公告
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
| --- | --- | --- |
|
||||
| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变活动公告和管理列表的排序;公告正文、状态和重新发布时间仍由 service 校验 |
|
||||
| `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 |
|
||||
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。
|
||||
|
||||
## 资源存储
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Modal } from "antd";
|
||||
import { Bell, BellOff, CircleAlert, Info, ShieldAlert, Wrench } from "lucide-react";
|
||||
import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { AnnouncementContent } from "@/components/ui/announcement-content";
|
||||
@@ -108,6 +108,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="text-[var(--fs-body-lg)] font-semibold leading-6 tracking-normal text-foreground sm:text-base">{announcement.title}</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-foreground/45"><Icon className="size-3" />{meta.label}</span>
|
||||
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
|
||||
</div>
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
|
||||
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { App, Button, Form, Input, Modal, Select } from "antd";
|
||||
import { App, Button, Form, Input, Modal, Select, Switch, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Pin, Plus, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
@@ -20,6 +20,7 @@ type AnnouncementFormValues = {
|
||||
title: string;
|
||||
content: string;
|
||||
level: AnnouncementLevel;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const levelOptions: Array<{ value: AnnouncementLevel; label: string }> = [
|
||||
@@ -71,13 +72,13 @@ export default function AdminAnnouncementsPanel() {
|
||||
|
||||
const openPublishModal = () => {
|
||||
setEditingAnnouncement(null);
|
||||
form.setFieldsValue({ title: "", content: "", level: "info" });
|
||||
form.setFieldsValue({ title: "", content: "", level: "info", pinned: false });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (announcement: SystemAnnouncement) => {
|
||||
setEditingAnnouncement(announcement);
|
||||
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level });
|
||||
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level, pinned: announcement.pinned });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -85,7 +86,7 @@ export default function AdminAnnouncementsPanel() {
|
||||
const values = await form.validateFields();
|
||||
setPublishing(true);
|
||||
try {
|
||||
const input = { title: values.title.trim(), content: values.content.trim(), level: values.level };
|
||||
const input = { title: values.title.trim(), content: values.content.trim(), level: values.level, pinned: Boolean(values.pinned) };
|
||||
if (editingAnnouncement) await updateAdminAnnouncement(editingAnnouncement.id, input);
|
||||
else await createAdminAnnouncement(input);
|
||||
setModalOpen(false);
|
||||
@@ -125,6 +126,12 @@ export default function AdminAnnouncementsPanel() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "置顶",
|
||||
dataIndex: "pinned",
|
||||
width: 90,
|
||||
render: (pinned: boolean) => pinned ? <Tag color="gold" icon={<Pin className="size-3" />}>置顶</Tag> : <span className="text-foreground/35">--</span>,
|
||||
},
|
||||
{
|
||||
title: "级别",
|
||||
dataIndex: "level",
|
||||
@@ -182,6 +189,9 @@ export default function AdminAnnouncementsPanel() {
|
||||
<Form.Item name="level" label="公告级别" rules={[{ required: true, message: "请选择公告级别" }]}>
|
||||
<Select options={levelOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pinned" label="展示方式" valuePropName="checked" extra="置顶公告会优先展示。">
|
||||
<Switch checkedChildren="置顶" unCheckedChildren="普通" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="公告正文" rules={[{ required: true, whitespace: true, message: "请填写公告正文" }, { max: 4000, message: "正文不能超过 4000 个字符" }]}>
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 6, maxRows: 12 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SystemAnnouncement = {
|
||||
title: string;
|
||||
content: string;
|
||||
level: AnnouncementLevel;
|
||||
pinned: boolean;
|
||||
status: AnnouncementStatus;
|
||||
createdBy: string;
|
||||
publishedAt: string;
|
||||
@@ -42,11 +43,11 @@ export function listAdminAnnouncements(params: AdminAnnouncementListParams = {})
|
||||
return request<{ announcements: SystemAnnouncement[]; total: number; page: number; limit: number }>(api.get("/admin/announcements", { params }));
|
||||
}
|
||||
|
||||
export function createAdminAnnouncement(input: { title: string; content: string; level: AnnouncementLevel }) {
|
||||
export function createAdminAnnouncement(input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
return request<{ announcement: SystemAnnouncement }>(api.post("/admin/announcements", input));
|
||||
}
|
||||
|
||||
export function updateAdminAnnouncement(id: string, input: { title: string; content: string; level: AnnouncementLevel }) {
|
||||
export function updateAdminAnnouncement(id: string, input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
return request<{ announcement: SystemAnnouncement }>(api.patch(`/admin/announcements/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user