From a3b6761ba9d7dd8b9ea73fede94f0812886ac7a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=9C=A8await?= <1335360669@qq.com>
Date: Thu, 17 Sep 2026 23:17:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(banner):=20=E9=A6=96=E9=A1=B5=E5=B8=B8?=
=?UTF-8?q?=E9=A9=BB=E6=BB=9A=E5=8A=A8=E9=80=9A=E7=9F=A5=E4=B8=8E=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E9=85=8D=E7=BD=AE=E5=8F=8A=E7=B1=BB=E5=9E=8B=E6=A0=B7?=
=?UTF-8?q?=E5=BC=8F=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 2 +
backend/cmd/migrate-sqlite-postgres/main.go | 1 +
backend/internal/app/announcement.go | 323 +++++++++++++
backend/internal/app/announcement_test.go | 391 ++++++++++++++++
backend/internal/database/migrations.go | 11 +-
backend/internal/database/migrations_test.go | 115 ++++-
backend/internal/database/schema.go | 1 +
backend/internal/handler/announcement.go | 83 ++++
backend/internal/handler/auth.go | 2 +-
backend/internal/model/models_project.go | 27 ++
backend/internal/repository/announcement.go | 106 +++++
backend/internal/service/aliases_types.go | 2 +
.../content/docs/backend/backend-database.mdx | 9 +
web/src/components/layout/app-top-nav.tsx | 3 +
.../layout/banner-announcement-content.tsx | 53 +++
.../layout/banner-announcements-slider.tsx | 144 ++++++
web/src/lib/announcements/banner-notice.ts | 181 ++++++++
.../announcements/banner-title-rich-text.ts | 217 +++++++++
web/src/lib/announcements/banner-title.ts | 167 +++++++
web/src/pages/admin/admin-route-pages.tsx | 22 +
.../admin-banner-announcements-panel.tsx | 438 ++++++++++++++++++
.../pages/admin/components/admin-shell.tsx | 2 +
.../components/banner-notice-emoji-picker.tsx | 90 ++++
.../banner-notice-type-selector.tsx | 40 ++
.../admin/components/banner-title-editor.tsx | 213 +++++++++
web/src/router.tsx | 2 +
web/src/services/api/announcements.ts | 57 +++
web/src/styles/admin-ui.css | 215 +++++++++
web/src/styles/globals.css | 32 +-
web/src/styles/workspace-product.css | 12 +-
web/test/admin-ui-regressions.test.ts | 74 +++
web/test/banner-notice.test.ts | 147 ++++++
web/test/banner-title-rich-text.test.ts | 72 +++
web/test/banner-title.test.ts | 101 ++++
34 files changed, 3345 insertions(+), 10 deletions(-)
create mode 100644 web/src/components/layout/banner-announcement-content.tsx
create mode 100644 web/src/components/layout/banner-announcements-slider.tsx
create mode 100644 web/src/lib/announcements/banner-notice.ts
create mode 100644 web/src/lib/announcements/banner-title-rich-text.ts
create mode 100644 web/src/lib/announcements/banner-title.ts
create mode 100644 web/src/pages/admin/components/admin-banner-announcements-panel.tsx
create mode 100644 web/src/pages/admin/components/banner-notice-emoji-picker.tsx
create mode 100644 web/src/pages/admin/components/banner-notice-type-selector.tsx
create mode 100644 web/src/pages/admin/components/banner-title-editor.tsx
create mode 100644 web/test/banner-notice.test.ts
create mode 100644 web/test/banner-title-rich-text.test.ts
create mode 100644 web/test/banner-title.test.ts
diff --git a/.gitignore b/.gitignore
index b206b6e3d..25947b833 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,3 +68,5 @@ camera_language_guide.md
# Local backend executable; never commit build output.
backend/cmd/server/server
+# One-off local probe scripts and their compiled output.
+backend/tmp_*
diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go
index 90819f921..ec5336344 100644
--- a/backend/cmd/migrate-sqlite-postgres/main.go
+++ b/backend/cmd/migrate-sqlite-postgres/main.go
@@ -284,6 +284,7 @@ func migrations() []tableMigration {
migrateTable[model.UserPromptCustomization]("user_prompt_customizations"),
migrateTable[model.Announcement]("announcements"),
migrateTable[model.UserAnnouncementRead]("user_announcement_reads"),
+ migrateTable[model.BannerAnnouncement]("banner_announcements"),
migrateTable[model.CreationRun]("creation_runs"),
migrateTable[model.CreationSubmission]("creation_submissions"),
migrateTable[model.Task]("tasks"),
diff --git a/backend/internal/app/announcement.go b/backend/internal/app/announcement.go
index 151f3e97a..7d09df7cc 100644
--- a/backend/internal/app/announcement.go
+++ b/backend/internal/app/announcement.go
@@ -476,3 +476,326 @@ func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string,
}
return title, content, req.Level, nil
}
+
+type CreateBannerAnnouncementRequest struct {
+ Title string `json:"title"`
+ // TitleRuns 是标题的样式分段;非空时 Title 由分段文本拼接得出,请求里的 Title 只作兼容兜底。
+ TitleRuns []model.BannerTitleRun `json:"titleRuns"`
+ // NoticeType 通知类型,决定通知条底色;空值按默认类型(公告)处理。
+ // 图标素材(emoji)内嵌在 TitleRuns 的文本里,不单独存字段。
+ NoticeType string `json:"noticeType"`
+ Link string `json:"link"`
+ Status string `json:"status"` // "active" | "disabled"
+ StartsAt *time.Time `json:"startsAt"`
+ EndsAt *time.Time `json:"endsAt"`
+}
+
+type UpdateBannerAnnouncementRequest = CreateBannerAnnouncementRequest
+
+type BannerAnnouncementPage struct {
+ Banners []model.BannerAnnouncement `json:"banners"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"pageSize"`
+}
+
+func (s *Service) ActiveBannerAnnouncements() ([]model.BannerAnnouncement, error) {
+ banners, err := s.repo.ActiveBannerAnnouncements(time.Now())
+ if err != nil {
+ return nil, err
+ }
+ if banners == nil {
+ banners = []model.BannerAnnouncement{}
+ }
+ return banners, nil
+}
+
+func (s *Service) AdminBannerAnnouncementPage(actor *model.User, query AdminListQuery) (*BannerAnnouncementPage, error) {
+ if err := s.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ page, limit := normalizeAdminPage(query.Page, query.Limit)
+ banners, total, err := s.repo.AdminBannerAnnouncements(query.Keyword, query.Status, limit, (page-1)*limit)
+ if err != nil {
+ return nil, err
+ }
+ if banners == nil {
+ banners = []model.BannerAnnouncement{}
+ }
+ return &BannerAnnouncementPage{Banners: banners, Total: total, Page: page, Limit: limit}, nil
+}
+
+// 标题样式分段的边界:字号与字重只接受固定档位,字体与颜色进白名单,避免把任意 CSS 写进通知条。
+const (
+ bannerTitleMinFontSize = 10
+ bannerTitleMaxFontSize = 20
+ bannerTitleMaxRuns = 60
+ bannerTitleMaxTextRunes = 120
+)
+
+var bannerTitleFontWeights = map[int]struct{}{400: {}, 500: {}, 600: {}, 700: {}}
+var bannerTitleFontFamilies = map[string]struct{}{"sans": {}, "serif": {}, "mono": {}}
+
+// normalizeBannerTitle 统一处理标题与样式分段:分段非空时以分段文本为准,分段为空时回落到纯文本标题。
+func normalizeBannerTitle(title string, runs []model.BannerTitleRun) (string, []model.BannerTitleRun, error) {
+ normalized, err := normalizeBannerTitleRuns(runs)
+ if err != nil {
+ return "", nil, err
+ }
+ if len(normalized) == 0 {
+ plain := strings.TrimSpace(title)
+ if plain == "" {
+ return "", nil, BadAuthRequest("请填写通知标题")
+ }
+ if utf8.RuneCountInString(plain) > bannerTitleMaxTextRunes {
+ return "", nil, BadAuthRequest("通知标题不能超过 120 个字符")
+ }
+ return plain, nil, nil
+ }
+ plain := strings.TrimSpace(bannerTitleRunsText(normalized))
+ if plain == "" {
+ return "", nil, BadAuthRequest("请填写通知标题")
+ }
+ if utf8.RuneCountInString(plain) > bannerTitleMaxTextRunes {
+ return "", nil, BadAuthRequest("通知标题不能超过 120 个字符")
+ }
+ return plain, normalized, nil
+}
+
+func normalizeBannerTitleRuns(runs []model.BannerTitleRun) ([]model.BannerTitleRun, error) {
+ if len(runs) == 0 {
+ return nil, nil
+ }
+ if len(runs) > bannerTitleMaxRuns {
+ return nil, BadAuthRequest("通知标题样式片段过多")
+ }
+ normalized := make([]model.BannerTitleRun, 0, len(runs))
+ for _, run := range runs {
+ if run.FontSize != nil {
+ value := *run.FontSize
+ if value < bannerTitleMinFontSize || value > bannerTitleMaxFontSize {
+ return nil, BadAuthRequest("标题字号需在 10 到 20 之间")
+ }
+ run.FontSize = &value
+ }
+ if run.FontWeight != nil {
+ value := *run.FontWeight
+ if _, ok := bannerTitleFontWeights[value]; !ok {
+ return nil, BadAuthRequest("标题字重仅支持 400 / 500 / 600 / 700")
+ }
+ run.FontWeight = &value
+ }
+ family := strings.TrimSpace(run.FontFamily)
+ if family != "" {
+ if _, ok := bannerTitleFontFamilies[family]; !ok {
+ return nil, BadAuthRequest("标题字体仅支持默认、衬线或等宽")
+ }
+ }
+ run.FontFamily = family
+ color := strings.ToUpper(strings.TrimSpace(run.Color))
+ if color != "" && !isBannerTitleHexColor(color) {
+ return nil, BadAuthRequest("标题颜色需为 #RRGGBB 格式")
+ }
+ run.Color = color
+ if run.Text == "" {
+ continue
+ }
+ normalized = append(normalized, run)
+ }
+ return mergeBannerTitleRuns(normalized), nil
+}
+
+// mergeBannerTitleRuns 合并相邻的同样式分段,避免前端反复编辑后存下大量零碎片段。
+func mergeBannerTitleRuns(runs []model.BannerTitleRun) []model.BannerTitleRun {
+ merged := make([]model.BannerTitleRun, 0, len(runs))
+ for _, run := range runs {
+ if len(merged) > 0 && sameBannerTitleRunStyle(merged[len(merged)-1], run) {
+ merged[len(merged)-1].Text += run.Text
+ continue
+ }
+ merged = append(merged, run)
+ }
+ return merged
+}
+
+func sameBannerTitleRunStyle(left model.BannerTitleRun, right model.BannerTitleRun) bool {
+ return equalIntPointer(left.FontSize, right.FontSize) &&
+ equalIntPointer(left.FontWeight, right.FontWeight) &&
+ left.FontFamily == right.FontFamily &&
+ left.Color == right.Color
+}
+
+func equalIntPointer(left *int, right *int) bool {
+ if left == nil || right == nil {
+ return left == nil && right == nil
+ }
+ return *left == *right
+}
+
+func isBannerTitleHexColor(value string) bool {
+ if len(value) != 7 || value[0] != '#' {
+ return false
+ }
+ for _, char := range value[1:] {
+ if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func bannerTitleRunsText(runs []model.BannerTitleRun) string {
+ var text strings.Builder
+ for _, run := range runs {
+ text.WriteString(run.Text)
+ }
+ return text.String()
+}
+
+func normalizeBannerLink(link string) (string, error) {
+ link = strings.TrimSpace(link)
+ if link == "" {
+ return "", nil
+ }
+ lower := strings.ToLower(link)
+ if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
+ if utf8.RuneCountInString(link) > 500 {
+ return "", BadAuthRequest("跳转链接不能超过 500 个字符")
+ }
+ return link, nil
+ }
+ if strings.HasPrefix(link, "/") {
+ if utf8.RuneCountInString(link) > 500 {
+ return "", BadAuthRequest("站内路径不能超过 500 个字符")
+ }
+ return link, nil
+ }
+ return "", BadAuthRequest("跳转链接仅支持 http(s) 外链或以 / 开头的站内路径")
+}
+
+// 通知类型的取值白名单。
+//
+// 必须与前端 web/src/lib/announcements/banner-notice.ts 的 BANNER_NOTICE_TYPES 逐项一致:
+// 后端多一项会让前端的合法取值被拒,少一项会让前端选中的值被静默丢弃;
+// web/test/banner-notice.test.ts 会读本文件做双向校验。
+// 图标素材(emoji)是普通文本,随 TitleRuns 走,不需要单独白名单。
+var bannerNoticeTypes = map[string]struct{}{
+ "notice": {}, "activity": {}, "update": {}, "warning": {},
+}
+
+const bannerNoticeDefaultType = "notice"
+
+// normalizeBannerNoticeType 把空值补成默认类型,其余值必须在白名单内,避免存进无法渲染的类型。
+func normalizeBannerNoticeType(value string) (string, error) {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ if normalized == "" {
+ return bannerNoticeDefaultType, nil
+ }
+ if _, ok := bannerNoticeTypes[normalized]; !ok {
+ return "", BadAuthRequest("通知类型无效,仅支持公告、活动、更新或警告")
+ }
+ return normalized, nil
+}
+
+func (s *Service) CreateBannerAnnouncement(actor *model.User, req CreateBannerAnnouncementRequest) (*model.BannerAnnouncement, error) {
+ if err := s.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ title, titleRuns, err := normalizeBannerTitle(req.Title, req.TitleRuns)
+ if err != nil {
+ return nil, err
+ }
+ link, err := normalizeBannerLink(req.Link)
+ if err != nil {
+ return nil, err
+ }
+ noticeType, err := normalizeBannerNoticeType(req.NoticeType)
+ if err != nil {
+ return nil, err
+ }
+ status := strings.TrimSpace(req.Status)
+ if status != "active" && status != "disabled" {
+ return nil, BadAuthRequest("通知状态无效,仅支持 active 或 disabled")
+ }
+ if req.StartsAt != nil && req.EndsAt != nil && req.EndsAt.Before(*req.StartsAt) {
+ return nil, BadAuthRequest("有效期结束时间不能早于开始时间")
+ }
+ now := time.Now()
+ banner := &model.BannerAnnouncement{
+ ID: newID(),
+ Title: title,
+ TitleRuns: titleRuns,
+ NoticeType: noticeType,
+ Link: link,
+ Status: status,
+ StartsAt: req.StartsAt,
+ EndsAt: req.EndsAt,
+ CreatedBy: actor.ID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.repo.CreateBannerAnnouncement(banner); err != nil {
+ return nil, err
+ }
+ return banner, nil
+}
+
+func (s *Service) UpdateBannerAnnouncement(actor *model.User, id string, req UpdateBannerAnnouncementRequest) (*model.BannerAnnouncement, error) {
+ if err := s.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ banner, err := s.repo.BannerAnnouncement(strings.TrimSpace(id))
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, BadAuthRequest("常驻通知不存在")
+ }
+ return nil, err
+ }
+ title, titleRuns, err := normalizeBannerTitle(req.Title, req.TitleRuns)
+ if err != nil {
+ return nil, err
+ }
+ link, err := normalizeBannerLink(req.Link)
+ if err != nil {
+ return nil, err
+ }
+ noticeType, err := normalizeBannerNoticeType(req.NoticeType)
+ if err != nil {
+ return nil, err
+ }
+ status := strings.TrimSpace(req.Status)
+ if status != "active" && status != "disabled" {
+ return nil, BadAuthRequest("通知状态无效,仅支持 active 或 disabled")
+ }
+ if req.StartsAt != nil && req.EndsAt != nil && req.EndsAt.Before(*req.StartsAt) {
+ return nil, BadAuthRequest("有效期结束时间不能早于开始时间")
+ }
+ banner.Title = title
+ banner.TitleRuns = titleRuns
+ banner.NoticeType = noticeType
+ banner.Link = link
+ banner.Status = status
+ banner.StartsAt = req.StartsAt
+ banner.EndsAt = req.EndsAt
+ banner.UpdatedAt = time.Now()
+
+ if err := s.repo.UpdateBannerAnnouncement(banner); err != nil {
+ return nil, err
+ }
+ return banner, nil
+}
+
+func (s *Service) DeleteBannerAnnouncement(actor *model.User, id string) error {
+ if err := s.RequireAdmin(actor); err != nil {
+ return err
+ }
+ _, err := s.repo.BannerAnnouncement(strings.TrimSpace(id))
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("常驻通知不存在")
+ }
+ return err
+ }
+ return s.repo.DeleteBannerAnnouncement(strings.TrimSpace(id))
+}
diff --git a/backend/internal/app/announcement_test.go b/backend/internal/app/announcement_test.go
index 4e2a308cd..e05e156bd 100644
--- a/backend/internal/app/announcement_test.go
+++ b/backend/internal/app/announcement_test.go
@@ -275,6 +275,397 @@ func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) {
}
}
+func TestBannerAnnouncementLifecycle(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
+ Title: "常驻公告1",
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if banner.Title != "常驻公告1" || banner.Status != "active" {
+ t.Fatalf("banner = %+v", banner)
+ }
+
+ activeBanners, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(activeBanners) != 1 || activeBanners[0].ID != banner.ID {
+ t.Fatalf("activeBanners = %+v", activeBanners)
+ }
+
+ updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
+ Title: "更新的常驻公告",
+ Link: "/projects",
+ Status: "disabled",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Title != "更新的常驻公告" || updated.Link != "/projects" || updated.Status != "disabled" {
+ t.Fatalf("updated = %+v", updated)
+ }
+
+ activeAfterDisable, _ := svc.ActiveBannerAnnouncements()
+ if len(activeAfterDisable) != 0 {
+ t.Fatalf("expected 0 active banners, got %d", len(activeAfterDisable))
+ }
+}
+
+func TestBannerAnnouncementAcceptsFutureStartAndLinkKinds(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ future := time.Now().Add(24 * time.Hour)
+ banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
+ Title: "预约明天上线",
+ Link: "https://example.com/news/1",
+ Status: "active",
+ StartsAt: &future,
+ })
+ if err != nil {
+ t.Fatalf("future start should be accepted: %v", err)
+ }
+ if banner.Link != "https://example.com/news/1" || banner.StartsAt == nil {
+ t.Fatalf("banner = %+v", banner)
+ }
+
+ // 未来开始时间未到,不应出现在 active 列表
+ active, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(active) != 0 {
+ t.Fatalf("future banner should not be active yet, got %d", len(active))
+ }
+
+ // 内链合法
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "内链", Link: "/projects", Status: "active"}); err != nil {
+ t.Fatalf("internal link should be accepted: %v", err)
+ }
+ // 非法链接拒绝
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "坏链接", Link: "javascript:alert(1)", Status: "active"}); err == nil {
+ t.Fatal("javascript: link should be rejected")
+ }
+ // 结束早于开始拒绝
+ start := time.Now().Add(-time.Hour)
+ end := time.Now().Add(-2 * time.Hour)
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "倒挂时间", Status: "active", StartsAt: &start, EndsAt: &end}); err == nil {
+ t.Fatal("endsAt before startsAt should be rejected")
+ }
+}
+
+func TestBannerAnnouncementValidityWindowFiltering(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ now := time.Now()
+ past := now.Add(-48 * time.Hour)
+ future := now.Add(48 * time.Hour)
+
+ // 已结束:start 在过去、end 已过 → 不展示
+ middle := past.Add(24 * time.Hour)
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已过期", Status: "active", StartsAt: &past, EndsAt: &middle}); err != nil {
+ t.Fatal(err)
+ }
+ // 生效中:start 已过、end 在未来 → 展示
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "生效中", Status: "active", StartsAt: &past, EndsAt: &future}); err != nil {
+ t.Fatal(err)
+ }
+ // 未开始:start 在未来 → 不展示
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "未开始", Status: "active", StartsAt: &future}); err != nil {
+ t.Fatal(err)
+ }
+ // 禁用:即使时间窗口内也不展示
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "被禁用", Status: "disabled"}); err != nil {
+ t.Fatal(err)
+ }
+
+ active, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(active) != 1 || active[0].Title != "生效中" {
+ t.Fatalf("active = %+v, want only 生效中", active)
+ }
+}
+
+func TestBannerAnnouncementTitleRuns(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ size := 16
+ weight := 700
+ banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
+ TitleRuns: []model.BannerTitleRun{
+ {Text: "限时", FontSize: &size, FontWeight: &weight, Color: "#ff0000"},
+ {Text: "活动", FontSize: &size, FontWeight: &weight, Color: "#FF0000"},
+ {Text: "已上线", FontFamily: "mono"},
+ },
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 相邻同样式分段合并,纯文本标题由分段拼接得出,供列表与检索使用。
+ if banner.Title != "限时活动已上线" {
+ t.Fatalf("title = %q, want 限时活动已上线", banner.Title)
+ }
+ if len(banner.TitleRuns) != 2 {
+ t.Fatalf("titleRuns = %+v, want 2 merged runs", banner.TitleRuns)
+ }
+ if banner.TitleRuns[0].Text != "限时活动" || banner.TitleRuns[0].Color != "#FF0000" {
+ t.Fatalf("first run = %+v", banner.TitleRuns[0])
+ }
+
+ // 读回时样式分段必须完整保留(列存储 + 显式编解码的回归点)。
+ stored, err := svc.repo.BannerAnnouncement(banner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(stored.TitleRuns) != 2 || stored.TitleRuns[0].FontSize == nil || *stored.TitleRuns[0].FontSize != 16 {
+ t.Fatalf("stored = %+v", stored.TitleRuns)
+ }
+ active, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(active) != 1 || len(active[0].TitleRuns) != 2 {
+ t.Fatalf("active = %+v", active)
+ }
+
+ // 更新走显式 map,样式分段同样不能被丢掉。
+ updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
+ TitleRuns: []model.BannerTitleRun{{Text: "更新后的标题", Color: "#00FF00"}},
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Title != "更新后的标题" || len(updated.TitleRuns) != 1 || updated.TitleRuns[0].Color != "#00FF00" {
+ t.Fatalf("updated = %+v", updated)
+ }
+
+ // 只传纯文本时清空样式分段。
+ plain, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{Title: "纯文本标题", Status: "active"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(plain.TitleRuns) != 0 || plain.Title != "纯文本标题" {
+ t.Fatalf("plain = %+v", plain)
+ }
+}
+
+func TestBannerAnnouncementRejectsInvalidTitleRuns(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ size := func(value int) *int { return &value }
+ weight := func(value int) *int { return &value }
+
+ cases := []struct {
+ name string
+ req CreateBannerAnnouncementRequest
+ }{
+ {name: "字号过小", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(9)}}, Status: "active"}},
+ {name: "字号过大", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(21)}}, Status: "active"}},
+ {name: "字重非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(6000)}}, Status: "active"}},
+ {name: "字体非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: "comic-sans"}}, Status: "active"}},
+ {name: "颜色非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "red"}}, Status: "active"}},
+ {name: "颜色注入", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "#fff;background:url(x)"}}, Status: "active"}},
+ {name: "分段文本为空", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "", FontSize: size(14)}}, Status: "active"}},
+ }
+ for _, testCase := range cases {
+ if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
+ t.Fatalf("%s: expected rejection", testCase.name)
+ }
+ }
+
+ long := ""
+ for range 121 {
+ long += "字"
+ }
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: long}}, Status: "active"}); err == nil {
+ t.Fatal("over-long run text should be rejected")
+ }
+ // 边界值可接受:10 与 20、四种字重、三种字体。
+ for _, value := range []int{10, 20} {
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(value)}}, Status: "active"}); err != nil {
+ t.Fatalf("font size %d should be accepted: %v", value, err)
+ }
+ }
+ for _, value := range []int{400, 500, 600, 700} {
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(value)}}, Status: "active"}); err != nil {
+ t.Fatalf("font weight %d should be accepted: %v", value, err)
+ }
+ }
+ for _, value := range []string{"sans", "serif", "mono"} {
+ if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: value}}, Status: "active"}); err != nil {
+ t.Fatalf("font family %q should be accepted: %v", value, err)
+ }
+ }
+}
+
+func TestBannerAnnouncementNoticeType(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ // 未传类型时补成默认值,行数据始终是可直接渲染的具体取值。
+ def, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "默认外观", Status: "active"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if def.NoticeType != bannerNoticeDefaultType {
+ t.Fatalf("default noticeType = %q", def.NoticeType)
+ }
+
+ banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
+ Title: "限时活动通知",
+ NoticeType: "activity",
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if banner.NoticeType != "activity" {
+ t.Fatalf("banner = %+v", banner)
+ }
+
+ // 读回路径(仓储 + 公开接口)都要保留类型。
+ stored, err := svc.repo.BannerAnnouncement(banner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.NoticeType != "activity" {
+ t.Fatalf("stored = %+v", stored)
+ }
+ active, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, item := range active {
+ if item.ID == banner.ID && item.NoticeType == "activity" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("active = %+v, want activity", active)
+ }
+
+ // 更新走显式字段表,漏字段会被静默清空的回归点。
+ updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
+ Title: "改成警告",
+ NoticeType: "warning",
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.NoticeType != "warning" {
+ t.Fatalf("updated = %+v", updated)
+ }
+ reloaded, err := svc.repo.BannerAnnouncement(banner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reloaded.NoticeType != "warning" {
+ t.Fatalf("reloaded = %+v", reloaded)
+ }
+
+ // 边界:全部合法取值都必须被接受,否则后台选了却被静默丢弃。
+ for value := range bannerNoticeTypes {
+ created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "类型 " + value, NoticeType: value, Status: "active"})
+ if err != nil {
+ t.Fatalf("notice type %q should be accepted: %v", value, err)
+ }
+ if created.NoticeType != value {
+ t.Fatalf("notice type %q normalized to %q", value, created.NoticeType)
+ }
+ }
+
+ // 大小写与空格归一化。
+ normalized, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "归一化", NoticeType: " ACTIVITY ", Status: "active"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if normalized.NoticeType != "activity" {
+ t.Fatalf("normalized = %+v", normalized)
+ }
+}
+
+func TestBannerAnnouncementRejectsUnknownNoticeType(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ cases := []struct {
+ name string
+ req CreateBannerAnnouncementRequest
+ }{
+ {name: "类型不在白名单", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "danger", Status: "active"}},
+ {name: "类型注入", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "notice;--x:1", Status: "active"}},
+ }
+ for _, testCase := range cases {
+ if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
+ t.Fatalf("%s: expected rejection", testCase.name)
+ }
+ }
+
+ // 更新路径同样受白名单约束,不能绕过校验改坏已存在的行。
+ created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已有通知", Status: "active"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.UpdateBannerAnnouncement(admin, created.ID, UpdateBannerAnnouncementRequest{Title: "改坏", NoticeType: "rainbow", Status: "active"}); err == nil {
+ t.Fatal("update with unknown notice type should be rejected")
+ }
+}
+
+// emoji 图标素材就是普通文本分段:不需要独立字段或白名单,但必须能完整往返、并入纯文本标题参与检索。
+func TestBannerAnnouncementTitleRunsWithEmoji(t *testing.T) {
+ svc, _ := newAnnouncementImageTestService(t)
+ admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
+
+ banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
+ TitleRuns: []model.BannerTitleRun{
+ {Text: "💰"},
+ {Text: "双 11 预售开启,全场"},
+ {Text: "5 折", Color: "#FFE58F", FontWeight: func() *int { v := 700; return &v }()},
+ {Text: "🚀"},
+ },
+ NoticeType: "activity",
+ Status: "active",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if banner.Title != "💰双 11 预售开启,全场5 折🚀" {
+ t.Fatalf("title = %q", banner.Title)
+ }
+ if len(banner.TitleRuns) != 3 {
+ t.Fatalf("titleRuns = %+v, want 3(开头 emoji 与正文同为默认样式应合并)", banner.TitleRuns)
+ }
+
+ stored, err := svc.repo.BannerAnnouncement(banner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(stored.TitleRuns) != 3 {
+ t.Fatalf("stored = %+v", stored.TitleRuns)
+ }
+ active, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(active) != 1 || active[0].Title != banner.Title {
+ t.Fatalf("active = %+v", active)
+ }
+}
+
func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go
index e5af160b4..70fa095ad 100644
--- a/backend/internal/database/migrations.go
+++ b/backend/internal/database/migrations.go
@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
-const CurrentSchemaVersion int64 = 15
+const CurrentSchemaVersion int64 = 18
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -67,6 +67,15 @@ var schemaMigrations = []migration{
{version: 15, name: "agent_profiles", checksum: "sha256:agent-profiles-v15-20260914", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.AgentProfile{})
}},
+ {version: 16, name: "banner_announcements", checksum: "sha256:banner-announcements-v16-20260917", apply: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&model.BannerAnnouncement{})
+ }},
+ {version: 17, name: "banner_announcement_title_runs", checksum: "sha256:banner-announcement-title-runs-v17-20260917", apply: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&model.BannerAnnouncement{})
+ }},
+ {version: 18, name: "banner_announcement_notice_type", checksum: "sha256:banner-announcement-notice-type-v18-20260917", apply: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&model.BannerAnnouncement{})
+ }},
}
func migrateSchemaV14(tx *gorm.DB) error {
diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go
index 18d275eed..6b248ceb5 100644
--- a/backend/internal/database/migrations_test.go
+++ b/backend/internal/database/migrations_test.go
@@ -61,7 +61,120 @@ func TestMigrateSchemaV15UpgradesExistingDatabase(t *testing.T) {
t.Fatal("v15 upgrade did not install Agent profile table and scope index")
}
status, err := ReadSchemaStatus(db)
- if err != nil || !status.Ready || status.Current != 15 {
+ if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
+ t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
+ }
+}
+
+func TestMigrateSchemaV16UpgradesExistingDatabaseWithBannerAnnouncements(t *testing.T) {
+ db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-announcements-v16?mode=memory&cache=shared"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatal(err)
+ }
+ if !db.Migrator().HasTable(&model.BannerAnnouncement{}) {
+ t.Fatal("v16 migration did not create banner_announcements table")
+ }
+ // 模拟旧库升级:删表 + 删除 v16 记录,重跑迁移应能重建。
+ if err := db.Migrator().DropTable(&model.BannerAnnouncement{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Where("version = ?", 16).Delete(&schemaMigration{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatalf("upgrade from v15: %v", err)
+ }
+ if !db.Migrator().HasTable(&model.BannerAnnouncement{}) {
+ t.Fatal("v16 upgrade did not reinstall banner_announcements table")
+ }
+ status, err := ReadSchemaStatus(db)
+ if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
+ t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
+ }
+}
+
+func TestMigrateSchemaV17AddsBannerAnnouncementTitleRuns(t *testing.T) {
+ db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-title-runs-v17?mode=memory&cache=shared"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatal(err)
+ }
+ if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "title_runs") {
+ t.Fatal("v17 migration did not add banner_announcements.title_runs")
+ }
+ // 模拟 v16 旧库升级:先写入一条通知,删掉新列并移除 v17 记录,重跑迁移应补回列且保留原数据。
+ legacy := &model.BannerAnnouncement{ID: "legacy-banner", Title: "旧库通知", Status: "active"}
+ if err := db.Create(legacy).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Migrator().DropColumn(&model.BannerAnnouncement{}, "title_runs"); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Where("version = ?", 17).Delete(&schemaMigration{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatalf("upgrade from v16: %v", err)
+ }
+ if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "title_runs") {
+ t.Fatal("v17 upgrade did not restore banner_announcements.title_runs")
+ }
+ var stored model.BannerAnnouncement
+ if err := db.First(&stored, "id = ?", "legacy-banner").Error; err != nil {
+ t.Fatal(err)
+ }
+ if stored.Title != "旧库通知" {
+ t.Fatalf("legacy banner lost during upgrade: %+v", stored)
+ }
+ status, err := ReadSchemaStatus(db)
+ if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
+ t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
+ }
+}
+
+func TestMigrateSchemaV18AddsBannerAnnouncementNoticeType(t *testing.T) {
+ db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-notice-type-v18?mode=memory&cache=shared"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatal(err)
+ }
+ if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "notice_type") {
+ t.Fatal("v18 migration did not add banner_announcements.notice_type")
+ }
+ // 模拟 v17 旧库升级:先写入一条通知,删掉新列并移除 v18 记录,重跑迁移应补回列且保留原数据。
+ legacy := &model.BannerAnnouncement{ID: "legacy-banner-v17", Title: "旧库通知", TitleRunsJSON: `[{"text":"旧库通知"}]`, Status: "active"}
+ if err := db.Create(legacy).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Migrator().DropColumn(&model.BannerAnnouncement{}, "notice_type"); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Where("version = ?", 18).Delete(&schemaMigration{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateSchema(db); err != nil {
+ t.Fatalf("upgrade from v17: %v", err)
+ }
+ if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "notice_type") {
+ t.Fatal("v18 upgrade did not restore banner_announcements.notice_type")
+ }
+ var stored model.BannerAnnouncement
+ if err := db.First(&stored, "id = ?", "legacy-banner-v17").Error; err != nil {
+ t.Fatal(err)
+ }
+ // 新增列在旧行上为空,前端按默认类型与默认图标回落,因此只校验旧数据未丢失。
+ if stored.Title != "旧库通知" || stored.TitleRunsJSON != `[{"text":"旧库通知"}]` {
+ t.Fatalf("legacy banner lost during upgrade: %+v", stored)
+ }
+ status, err := ReadSchemaStatus(db)
+ if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
}
}
diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go
index d2a7e6586..7aaf35af3 100644
--- a/backend/internal/database/schema.go
+++ b/backend/internal/database/schema.go
@@ -89,6 +89,7 @@ func Models() []any {
&model.UserPromptCustomization{},
&model.Announcement{},
&model.UserAnnouncementRead{},
+ &model.BannerAnnouncement{},
&model.Task{},
&model.CreationRun{},
&model.CreationSubmission{},
diff --git a/backend/internal/handler/announcement.go b/backend/internal/handler/announcement.go
index bbe8c043a..7fdaed27a 100644
--- a/backend/internal/handler/announcement.go
+++ b/backend/internal/handler/announcement.go
@@ -10,6 +10,14 @@ import (
)
func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) {
+ r.GET("/banner-announcements", func(c *gin.Context) {
+ banners, err := svc.ActiveBannerAnnouncements()
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"banners": banners})
+ })
r.GET("/announcements", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -177,4 +185,79 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"announcement": announcement})
})
+
+ r.GET("/admin/banner-announcements", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ page, limit, err := parsePaginationQuery(c, 20)
+ if err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ banners, err := svc.AdminBannerAnnouncementPage(user, service.AdminListQuery{
+ Keyword: c.Query("keyword"),
+ Status: c.Query("status"),
+ Page: page,
+ Limit: limit,
+ })
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, banners)
+ })
+
+ r.POST("/admin/banner-announcements", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ var req service.CreateBannerAnnouncementRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ banner, err := svc.CreateBannerAnnouncement(user, req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"banner": banner})
+ })
+
+ r.PUT("/admin/banner-announcements/:id", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ var req service.UpdateBannerAnnouncementRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ banner, err := svc.UpdateBannerAnnouncement(user, c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"banner": banner})
+ })
+
+ r.DELETE("/admin/banner-announcements/:id", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.DeleteBannerAnnouncement(user, c.Param("id")); err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"ok": true})
+ })
}
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index 3459bc712..190c41c95 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -884,7 +884,7 @@ func shortSystemProxyPath(rawPath string) (string, string, bool) {
// as a channel request when a business route returns 404.
func isReservedAPIPathPrefix(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
- case "admin", "agent", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
+ case "admin", "agent", "ai", "announcements", "banner-announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
return true
default:
return false
diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go
index 608347281..45533a70c 100644
--- a/backend/internal/model/models_project.go
+++ b/backend/internal/model/models_project.go
@@ -437,3 +437,30 @@ type UserAnnouncementRead struct {
AnnouncementID string `json:"announcementId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:2"`
ReadAt time.Time `json:"readAt"`
}
+
+// BannerTitleRun 是通知标题中一段连续文本的样式覆盖;零值字段表示沿用默认样式。
+// 字重与字号用指针区分「未设置」和「显式设置为默认值」。
+type BannerTitleRun struct {
+ Text string `json:"text"`
+ FontSize *int `json:"fontSize,omitempty"` // px,仅接受 10-20
+ FontWeight *int `json:"fontWeight,omitempty"` // 仅接受 400 / 500 / 600 / 700
+ FontFamily string `json:"fontFamily,omitempty"` // "" | "sans" | "serif" | "mono"
+ Color string `json:"color,omitempty"` // #RRGGBB
+}
+
+type BannerAnnouncement struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ Title string `json:"title" gorm:"size:120"` // 纯文本标题,由 TitleRuns 拼接得出,供列表展示和关键字检索
+ // TitleRuns 是标题的样式分段,前端按段渲染;持久化在 title_runs 文本列,由仓储层显式编解码。
+ TitleRuns []BannerTitleRun `json:"titleRuns,omitempty" gorm:"-"`
+ TitleRunsJSON string `json:"-" gorm:"column:title_runs;type:text"`
+ // NoticeType 决定通知条底色("notice" | "activity" | "update" | "warning"),取值白名单见 app 层。
+ NoticeType string `json:"noticeType" gorm:"size:24"`
+ Link string `json:"link" gorm:"size:500"` // 点击跳转目标:http(s) 外链或 / 开头的站内路径;空表示不可点击
+ Status string `json:"status" gorm:"size:24;index"` // "active" | "disabled"
+ StartsAt *time.Time `json:"startsAt,omitempty" gorm:"index"`
+ EndsAt *time.Time `json:"endsAt,omitempty" gorm:"index"`
+ CreatedBy string `json:"createdBy" gorm:"size:36"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
diff --git a/backend/internal/repository/announcement.go b/backend/internal/repository/announcement.go
index 9bd9e1e6c..e065f5360 100644
--- a/backend/internal/repository/announcement.go
+++ b/backend/internal/repository/announcement.go
@@ -1,6 +1,7 @@
package repository
import (
+ "encoding/json"
"strings"
"time"
@@ -77,3 +78,108 @@ func (r *Repository) MarkAnnouncementsRead(userID string, announcementIDs []stri
}
return r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "announcement_id"}}, DoNothing: true}).Create(&reads).Error
}
+
+func (r *Repository) ActiveBannerAnnouncements(now time.Time) ([]model.BannerAnnouncement, error) {
+ var banners []model.BannerAnnouncement
+ err := r.db.Model(&model.BannerAnnouncement{}).
+ Where("status = ?", "active").
+ Where("starts_at IS NULL OR starts_at <= ?", now).
+ Where("ends_at IS NULL OR ends_at >= ?", now).
+ Order("created_at desc").
+ Find(&banners).Error
+ decodeBannerTitleRuns(banners)
+ return banners, err
+}
+
+func (r *Repository) AdminBannerAnnouncements(keyword string, status string, limit int, offset int) ([]model.BannerAnnouncement, int64, error) {
+ var banners []model.BannerAnnouncement
+ var total int64
+ query := r.db.Model(&model.BannerAnnouncement{})
+ if k := strings.TrimSpace(keyword); k != "" {
+ query = query.Where("title LIKE ?", "%"+k+"%")
+ }
+ if status == "active" || status == "disabled" {
+ query = query.Where("status = ?", status)
+ }
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&banners).Error; err != nil {
+ return nil, 0, err
+ }
+ decodeBannerTitleRuns(banners)
+ return banners, total, nil
+}
+
+func (r *Repository) BannerAnnouncement(id string) (*model.BannerAnnouncement, error) {
+ var banner model.BannerAnnouncement
+ if err := r.db.First(&banner, "id = ?", id).Error; err != nil {
+ return nil, err
+ }
+ banner.TitleRuns = bannerTitleRunsFromJSON(banner.TitleRunsJSON)
+ return &banner, nil
+}
+
+// encodeBannerTitleRuns 把样式分段写入 title_runs 文本列;无分段时写空串,与「纯文本标题」区分。
+func encodeBannerTitleRuns(banner *model.BannerAnnouncement) error {
+ if len(banner.TitleRuns) == 0 {
+ banner.TitleRunsJSON = ""
+ return nil
+ }
+ encoded, err := json.Marshal(banner.TitleRuns)
+ if err != nil {
+ return err
+ }
+ banner.TitleRunsJSON = string(encoded)
+ return nil
+}
+
+// decodeBannerTitleRuns 回填列表项的样式分段。
+func decodeBannerTitleRuns(banners []model.BannerAnnouncement) {
+ for index := range banners {
+ banners[index].TitleRuns = bannerTitleRunsFromJSON(banners[index].TitleRunsJSON)
+ }
+}
+
+// bannerTitleRunsFromJSON 解析 title_runs 列文本;空值或内容损坏时返回 nil,降级为纯文本标题,不阻断通知条展示。
+func bannerTitleRunsFromJSON(raw string) []model.BannerTitleRun {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ var runs []model.BannerTitleRun
+ if err := json.Unmarshal([]byte(raw), &runs); err != nil {
+ return nil
+ }
+ return runs
+}
+
+func (r *Repository) CreateBannerAnnouncement(banner *model.BannerAnnouncement) error {
+ if err := encodeBannerTitleRuns(banner); err != nil {
+ return err
+ }
+ return r.db.Create(banner).Error
+}
+
+func (r *Repository) UpdateBannerAnnouncement(banner *model.BannerAnnouncement) error {
+ if err := encodeBannerTitleRuns(banner); err != nil {
+ return err
+ }
+ updates := map[string]any{
+ "title": banner.Title,
+ "title_runs": banner.TitleRunsJSON,
+ "notice_type": banner.NoticeType,
+ "link": banner.Link,
+ "status": banner.Status,
+ "updated_at": banner.UpdatedAt,
+ "starts_at": banner.StartsAt,
+ "ends_at": banner.EndsAt,
+ }
+ return r.db.Model(&model.BannerAnnouncement{}).
+ Where("id = ?", banner.ID).
+ Updates(updates).Error
+}
+
+func (r *Repository) DeleteBannerAnnouncement(id string) error {
+ return r.db.Where("id = ?", id).Delete(&model.BannerAnnouncement{}).Error
+}
diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go
index 41ac86d3c..b2e085417 100644
--- a/backend/internal/service/aliases_types.go
+++ b/backend/internal/service/aliases_types.go
@@ -98,6 +98,7 @@ type (
ConfirmProjectAssetCandidateRequest = app.ConfirmProjectAssetCandidateRequest
CreateAdminUserRequest = app.CreateAdminUserRequest
CreateAnnouncementRequest = app.CreateAnnouncementRequest
+ CreateBannerAnnouncementRequest = app.CreateBannerAnnouncementRequest
CreateAssetCandidatesRequest = app.CreateAssetCandidatesRequest
CreateAssetFolderRequest = app.CreateAssetFolderRequest
CreateAssetVersionRequest = app.CreateAssetVersionRequest
@@ -293,6 +294,7 @@ type (
TimelineTranscriptionCreateRequest = app.TimelineTranscriptionCreateRequest
TopupProductRequest = app.TopupProductRequest
UpdateAnnouncementRequest = app.UpdateAnnouncementRequest
+ UpdateBannerAnnouncementRequest = app.UpdateBannerAnnouncementRequest
UpdateAssetFolderRequest = app.UpdateAssetFolderRequest
UpdateManager = app.UpdateManager
UpdatePaymentProviderConfigRequest = app.UpdatePaymentProviderConfigRequest
diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx
index 920961652..2471b964d 100644
--- a/docs/content/docs/backend/backend-database.mdx
+++ b/docs/content/docs/backend/backend-database.mdx
@@ -199,9 +199,18 @@ docker compose -f docker-compose.deploy.yml up -d backend web
| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变排序;`image_resource_id` 只能绑定由当前管理员上传并在发布事务中消费的公告配图草稿 |
| `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 |
| `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 |
+| `banner_announcements` | 保存首页顶部常驻展示的滚动通知条 | 与系统公告完全独立:只有标题、图标与跳转链接,无正文、无配图、无已读状态;`link` 仅接受 `http(s)` 外链或 `/` 开头的站内路径;`status` 为 `active` / `disabled`,`starts_at` / `ends_at` 构成生效窗口(可预约未来开始);`title_runs` 以 JSON 文本保存标题的样式分段;`notice_type` 决定通知条底色,`icon` 是标题前的图标键 |
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。
+常驻通知的标题支持按分段设置样式:`title_runs` 是可选的 JSON 数组,每项为 `{ text, fontSize?, fontWeight?, fontFamily?, color? }`,只在有分段时写入,历史数据与纯文本标题保持为空。后端在写入前统一收口取值——字号 10-20、字重 400/500/600/700、字体 `sans` / `serif` / `mono`、颜色 `#RRGGBB`,并合并相邻同样式分段;标题总字数上限 120 与纯文本标题一致。`title` 列始终保存由分段拼接出的纯文本,列表展示与关键字检索不受样式影响,通知条渲染分段时也不依赖该列。
+
+常驻通知的通知类型与标题图标同样是白名单枚举。`notice_type` 取 `notice`(公告)/ `activity`(活动)/ `update`(更新)/ `warning`(警告),决定通知条底色——四种类型在 `globals.css` 里各有一组明暗两档色值,浅色档按白字 4.5:1 反推,深色档统一压到 7:1 以上。`icon` 取前端 `lib/announcements/banner-notice` 图标库里的键(kebab-case,当前 42 个),前端按下标取 lucide 组件渲染。两个字段都允许为空,后端在写入时补齐默认值(`notice` / `megaphone`),保证行数据始终是可直接渲染的具体取值;白名单在 `backend/internal/app/announcement.go`,与前端注册表的一致性由 `web/test/banner-notice.test.ts` 双向校验,改任一侧都会让测试失败。
+
+常驻通知由管理后台「运营 → 常驻通知」维护,公开接口 `GET /api/banner-announcements` 按生效窗口返回 `active` 条目(`starts_at <= now <= ends_at`,空窗口视为长期有效),前台工作台顶部布局层轮播展示;点击按 `link` 跳转:外链新窗口打开,站内路径前端路由跳转,无链接则不可点击。
+
+后台编辑弹窗可选中通知类型(卡片上直接铺出该类型的真实底色)、选中标题文字直接调整样式,并通过工具栏「图标」按钮在光标处插入 emoji 素材(可插入多个、任意位置),全部实时联动到深浅两套主题的预览(含文字对比度提示)。预览与前台通知条共用 `components/layout/banner-announcement-content.tsx` 的展示单元,因此「预览即前台结果」;标题在通知条内固定单行展示,超长按可用宽度裁切。通知条默认不显示任何图标,是否带图标完全由标题内容决定。
+
## 资源存储
| 表 | 用途 | 关键边界 |
diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx
index 3c27ad0ba..9aa605d63 100644
--- a/web/src/components/layout/app-top-nav.tsx
+++ b/web/src/components/layout/app-top-nav.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
+import { BannerAnnouncementsSlider } from "@/components/layout/banner-announcements-slider";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
@@ -80,6 +81,8 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
{!hideChrome && mobileSidebarExpanded ?