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 ? + +
+ ); +} + +function bannerFeedFingerprint(banners: BannerAnnouncement[]) { + return banners + .map((banner) => `${banner.id}:${banner.updatedAt}`) + .sort() + .join("|"); +} + +function bannerDismissalKey(userId?: string) { + return `${BANNER_DISMISS_STORAGE_PREFIX}.${userId || "anonymous"}`; +} + +function hasBannerDismissal(userId: string | undefined, fingerprint: string) { + try { + return localStorage.getItem(bannerDismissalKey(userId)) === fingerprint; + } catch { + return false; + } +} + +function rememberBannerDismissal(userId: string | undefined, fingerprint: string) { + try { + localStorage.setItem(bannerDismissalKey(userId), fingerprint); + } catch { + // 浏览器禁用存储时仍允许关闭,本次组件生命周期内不会重复出现。 + } +} diff --git a/web/src/lib/announcements/banner-notice.ts b/web/src/lib/announcements/banner-notice.ts new file mode 100644 index 000000000..ea3323c22 --- /dev/null +++ b/web/src/lib/announcements/banner-notice.ts @@ -0,0 +1,181 @@ +/** + * 首页常驻通知条的「通知类型」与「标题图标素材」模型。 + * + * 通知类型决定通知条底色(后台与前台共用同一套 token)。 + * 图标素材是 emoji 字符:它就是普通文本,随 TitleRuns 的文本分段一起存储与渲染, + * 因此可以插到标题任意位置、跟随分段样式、参与关键字检索,后端不需要单独字段或白名单。 + * + * 本模块只放纯数据与纯函数,不 import 任何组件。 + */ + +export type BannerNoticeType = "notice" | "activity" | "update" | "warning"; + +/** 未指定类型时的兜底:平台公告。 */ +export const BANNER_NOTICE_DEFAULT_TYPE: BannerNoticeType = "notice"; + +export const BANNER_NOTICE_TYPES: { + key: BannerNoticeType; + label: string; + hint: string; + /** 该类型的语义底色 token;由 globals.css 按明暗主题分别取值。 */ + surfaceVar: string; +}[] = [ + { key: "notice", label: "公告", hint: "平台公告、规则与流程变更", surfaceVar: "--notice-banner-surface-notice" }, + { key: "activity", label: "活动", hint: "运营活动、限时促销与福利", surfaceVar: "--notice-banner-surface-activity" }, + { key: "update", label: "更新", hint: "版本发布、功能上新与改版", surfaceVar: "--notice-banner-surface-update" }, + { key: "warning", label: "警告", hint: "维护窗口、风险与服务异常", surfaceVar: "--notice-banner-surface-warning" }, +]; + +const BANNER_NOTICE_TYPE_KEYS = new Set(BANNER_NOTICE_TYPES.map((item) => item.key)); + +/** 未知 / 空类型一律回落到默认类型,通知条不会因为脏数据失去底色。 */ +export function normalizeBannerNoticeType(value?: string | null): BannerNoticeType { + const raw = (value || "").trim().toLowerCase(); + return BANNER_NOTICE_TYPE_KEYS.has(raw) ? (raw as BannerNoticeType) : BANNER_NOTICE_DEFAULT_TYPE; +} + +export function bannerNoticeTypeMeta(value?: string | null) { + const key = normalizeBannerNoticeType(value); + return BANNER_NOTICE_TYPES.find((item) => item.key === key) as (typeof BANNER_NOTICE_TYPES)[number]; +} + +export function bannerNoticeTypeLabel(value?: string | null) { + return bannerNoticeTypeMeta(value).label; +} + +export function bannerNoticeTypeHint(value?: string | null) { + return bannerNoticeTypeMeta(value).hint; +} + +/** 该类型底色的 CSS 引用(含 var());scope 内可被预览容器覆盖成明暗指定档。 */ +export function bannerNoticeSurfaceCSS(value?: string | null) { + return `var(${bannerNoticeTypeMeta(value).surfaceVar})`; +} + +/** 合法类型键,供后端白名单与前端一致性测试使用。 */ +export const BANNER_NOTICE_TYPE_VALUES = BANNER_NOTICE_TYPES.map((item) => item.key); + +/** + * 标题可插入的 emoji 图标素材,按用途分组。 + * 每项的 char 会作为普通文本插进标题(跟随光标处的字号 / 颜色样式),label 供搜索与 aria 标注。 + */ +export const BANNER_NOTICE_EMOJI_GROUPS: { key: string; label: string; emoji: { char: string; label: string }[] }[] = [ + { + key: "notice", + label: "通知", + emoji: [ + { char: "📢", label: "喇叭" }, + { char: "📣", label: "扩音" }, + { char: "🔔", label: "铃铛" }, + { char: "📮", label: "邮筒" }, + { char: "📌", label: "图钉" }, + { char: "📄", label: "文件" }, + { char: "📝", label: "备注" }, + { char: "📰", label: "快讯" }, + { char: "🗓️", label: "日历" }, + { char: "⏰", label: "闹钟" }, + { char: "🕐", label: "时钟" }, + { char: "🎯", label: "目标" }, + ], + }, + { + key: "celebrate", + label: "庆祝", + emoji: [ + { char: "🎉", label: "撒花" }, + { char: "🎊", label: "礼花" }, + { char: "🎈", label: "气球" }, + { char: "🎁", label: "礼物" }, + { char: "🎂", label: "蛋糕" }, + { char: "🥳", label: "狂欢" }, + { char: "🏆", label: "奖杯" }, + { char: "🥇", label: "金牌" }, + { char: "👑", label: "皇冠" }, + { char: "💎", label: "宝石" }, + { char: "✨", label: "闪光" }, + { char: "⭐", label: "星星" }, + ], + }, + { + key: "hot", + label: "热门", + emoji: [ + { char: "🔥", label: "热门" }, + { char: "🚀", label: "火箭" }, + { char: "⚡", label: "闪电" }, + { char: "💥", label: "爆炸" }, + { char: "🌟", label: "亮星" }, + { char: "💫", label: "星晕" }, + { char: "🌈", label: "彩虹" }, + { char: "☀️", label: "太阳" }, + { char: "🌙", label: "月亮" }, + { char: "❤️", label: "红心" }, + { char: "💯", label: "满分" }, + { char: "👍", label: "点赞" }, + ], + }, + { + key: "commerce", + label: "商业", + emoji: [ + { char: "💰", label: "钱袋" }, + { char: "💸", label: "飞钱" }, + { char: "💳", label: "银行卡" }, + { char: "🏦", label: "银行" }, + { char: "🛒", label: "购物车" }, + { char: "🛍️", label: "购物袋" }, + { char: "🏷️", label: "价签" }, + { char: "💼", label: "公文包" }, + { char: "📈", label: "上涨" }, + { char: "📉", label: "下跌" }, + { char: "🎫", label: "票券" }, + { char: "🎟️", label: "门票" }, + ], + }, + { + key: "status", + label: "状态", + emoji: [ + { char: "✅", label: "完成" }, + { char: "❌", label: "取消" }, + { char: "⚠️", label: "警告" }, + { char: "⛔", label: "禁止" }, + { char: "🚫", label: "禁用" }, + { char: "❓", label: "疑问" }, + { char: "❗", label: "感叹" }, + { char: "⏳", label: "等待" }, + { char: "🔄", label: "刷新" }, + { char: "🔧", label: "扳手" }, + { char: "🔒", label: "上锁" }, + { char: "🐞", label: "缺陷" }, + ], + }, +]; + +/** 扁平化的 emoji 清单,顺序与分组一致。 */ +export const BANNER_NOTICE_EMOJIS = BANNER_NOTICE_EMOJI_GROUPS.flatMap((group) => + group.emoji.map((item) => ({ ...item, group: group.key, groupLabel: group.label })), +); + +/** 按关键字过滤(匹配中文名、分组名或字符本身),空关键字返回全量。 */ +export function searchBannerNoticeEmojis(keyword: string) { + const query = keyword.trim().toLowerCase(); + if (!query) return BANNER_NOTICE_EMOJIS; + return BANNER_NOTICE_EMOJIS.filter((item) => item.char.includes(query) || item.label.includes(query) || item.groupLabel.includes(query)); +} + +/** + * 读取指定类型在当前主题下的通知条实底色,避免把设计 token 的色值复制到 TS 里。 + * scope 传后台预览容器可在该容器强制指定的明 / 暗档里取值。 + */ +export function readBannerNoticeSurface(noticeType?: string | null, scope?: HTMLElement | null) { + if (typeof window === "undefined") return ""; + const host = scope || window.document.body || window.document.documentElement; + if (!host) return ""; + const probe = window.document.createElement("span"); + probe.style.cssText = `position:absolute;left:-9999px;top:-9999px;background-color:${bannerNoticeSurfaceCSS(noticeType)}`; + host.appendChild(probe); + const resolved = window.getComputedStyle(probe).backgroundColor; + probe.remove(); + return resolved; +} diff --git a/web/src/lib/announcements/banner-title-rich-text.ts b/web/src/lib/announcements/banner-title-rich-text.ts new file mode 100644 index 000000000..399284354 --- /dev/null +++ b/web/src/lib/announcements/banner-title-rich-text.ts @@ -0,0 +1,217 @@ +/** + * 通知标题富文本编辑与通知条渲染之间的桥接层。 + * + * 编辑器用 Tiptap 提供选区能力,但存储与渲染都只认 `BannerTitleRun[]`: + * - 只保留一个 paragraph 与行内 `textStyle` 标记,标题永远是单段、单行语义。 + * - 字号 / 字体只接受白名单档位(其余值在转换时丢弃),因此不会把任意 CSS 写进通知条。 + */ + +import { Extension, type Editor, type JSONContent } from "@tiptap/core"; +import CharacterCount from "@tiptap/extension-character-count"; +import { Color } from "@tiptap/extension-color"; +import Placeholder from "@tiptap/extension-placeholder"; +import { FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"; +import { StarterKit } from "@tiptap/starter-kit"; + +import { + BANNER_TITLE_FONT_FAMILIES, + BANNER_TITLE_FONT_SIZES, + BANNER_TITLE_FONT_WEIGHTS, + bannerTitleFontFamilyCSS, + bannerTitleFontFamilyKey, + normalizeBannerTitleHex, + normalizeBannerTitleRuns, + type BannerTitleFontFamilyKey, + type BannerTitleRun, +} from "./banner-title"; + +export const BANNER_TITLE_TEXT_STYLE = "textStyle"; + +/** + * 字重没有官方扩展,按 FontSize 的写法把 fontWeight 挂到 textStyle 标记上。 + * 只声明属性,不下发命令:样式统一走 applyBannerTitleStyle,保证每个文本节点只留一个标记。 + */ +export const BannerTitleFontWeight = Extension.create<{ types: string[] }>({ + name: "bannerTitleFontWeight", + addOptions() { + return { types: [BANNER_TITLE_TEXT_STYLE] }; + }, + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + fontWeight: { + default: null, + parseHTML: (element) => element.style.fontWeight || null, + renderHTML: (attributes) => (attributes.fontWeight ? { style: `font-weight: ${attributes.fontWeight}` } : {}), + }, + }, + }, + ]; + }, +}); + +export function createBannerTitleExtensions(placeholder?: string) { + return [ + StarterKit.configure({ + bold: false, + blockquote: false, + bulletList: false, + code: false, + codeBlock: false, + dropcursor: false, + gapcursor: false, + hardBreak: false, + heading: false, + horizontalRule: false, + italic: false, + link: false, + listItem: false, + listKeymap: false, + orderedList: false, + strike: false, + trailingNode: false, + underline: false, + }), + TextStyle, + FontSize, + FontFamily, + Color, + BannerTitleFontWeight, + CharacterCount, + ...(placeholder ? [Placeholder.configure({ placeholder })] : []), + ]; +} + +export type BannerTitleStylePatch = { + fontSize?: number | null; + fontWeight?: number | null; + fontFamily?: BannerTitleFontFamilyKey | null; + color?: string | null; +}; + +export type BannerTitleSelectionStyle = { + fontSize?: number; + fontWeight?: number; + fontFamily?: BannerTitleFontFamilyKey; + color?: string; +}; + +export function bannerTitleRunsToDoc(runs: BannerTitleRun[]): JSONContent { + const content = []; + for (const run of normalizeBannerTitleRuns(runs)) { + const attrs = bannerTitleMarkAttrs(run); + const node: JSONContent = { type: "text", text: run.text }; + if (Object.values(attrs).some((value) => value)) { + node.marks = [{ type: BANNER_TITLE_TEXT_STYLE, attrs }]; + } + content.push(node); + } + return { type: "doc", content: [{ type: "paragraph", content }] }; +} + +export function bannerDocToRuns(doc?: JSONContent | null): BannerTitleRun[] { + const paragraph = (doc?.content || []).find((node) => node.type === "paragraph"); + const runs: BannerTitleRun[] = []; + for (const node of paragraph?.content || []) { + if (node.type !== "text" || !node.text) continue; + const attrs = textStyleAttributes(node); + const run: BannerTitleRun = { text: node.text }; + const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : ""); + if (BANNER_TITLE_FONT_SIZES.includes(size)) run.fontSize = size; + const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, "")); + if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) run.fontWeight = weight; + const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : ""); + if (family && family !== "sans") run.fontFamily = family; + const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : ""); + if (color) run.color = color; + runs.push(run); + } + return normalizeBannerTitleRuns(runs); +} + +export function bannerTitleSelectionStyle(editor: Editor): BannerTitleSelectionStyle { + const attrs = editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record; + const selection: BannerTitleSelectionStyle = {}; + const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : ""); + if (BANNER_TITLE_FONT_SIZES.includes(size)) selection.fontSize = size; + const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, "")); + if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) selection.fontWeight = weight; + const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : ""); + if (family && family !== "sans") selection.fontFamily = family; + const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : ""); + if (color) selection.color = color; + return selection; +} + +/** + * 把样式片段应用到当前选区。 + * + * 没有用 Tiptap 的 `setMark`:同一文本节点上它会叠加多个 textStyle 标记(属性不合并), + * 反复调整会留下旧值。这里按节点先移除旧标记再写入合并后的属性,保证每个节点只有一个标记。 + */ +export function applyBannerTitleStyle(editor: Editor, patch: BannerTitleStylePatch) { + const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE]; + if (!textStyle) return; + const markPatch: Record = {}; + if (patch.fontSize !== undefined) markPatch.fontSize = patch.fontSize ? `${patch.fontSize}px` : null; + if (patch.fontWeight !== undefined) markPatch.fontWeight = patch.fontWeight ? String(patch.fontWeight) : null; + if (patch.fontFamily !== undefined) markPatch.fontFamily = patch.fontFamily ? bannerTitleFontFamilyCSS(patch.fontFamily) || null : null; + if (patch.color !== undefined) markPatch.color = patch.color ? normalizeBannerTitleHex(patch.color) || null : null; + + const { from, to, empty } = editor.state.selection; + const transaction = editor.state.tr; + if (empty) { + transaction.addStoredMark(textStyle.create({ ...(editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record), ...markPatch })); + } else { + editor.state.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText) return; + const start = Math.max(pos, from); + const end = Math.min(pos + node.nodeSize, to); + if (start >= end) return; + const attrs = textStyleAttributes(node); + transaction.removeMark(start, end, textStyle); + transaction.addMark(start, end, textStyle.create({ ...attrs, ...markPatch })); + }); + } + editor.view.dispatch(transaction); +} + +export function clearBannerTitleStyle(editor: Editor) { + const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE]; + if (!textStyle) return; + const { from, to, empty } = editor.state.selection; + const transaction = editor.state.tr; + if (empty) { + transaction.removeStoredMark(textStyle); + } else { + transaction.removeMark(from, to, textStyle); + } + editor.view.dispatch(transaction); +} + +function bannerTitleMarkAttrs(run: BannerTitleRun) { + return { + fontSize: run.fontSize ? `${run.fontSize}px` : null, + fontWeight: run.fontWeight ? String(run.fontWeight) : null, + fontFamily: bannerTitleFontFamilyCSS(run.fontFamily) || null, + color: run.color || null, + }; +} + +/** + * 同一文本节点可能带有多个 textStyle 标记,按出现顺序合并,后写入的取最终值。 + * 标记来自两处:编辑器里的 ProseMirror 节点(`mark.type` 是 MarkType 对象)与 JSON 文档(`mark.type` 是字符串)。 + */ +function textStyleAttributes(node: { marks?: readonly { type?: string | { name?: string }; attrs?: Record }[] }) { + const attrs: Record = {}; + for (const mark of node.marks || []) { + const name = typeof mark.type === "string" ? mark.type : mark.type?.name; + if (name !== BANNER_TITLE_TEXT_STYLE) continue; + Object.assign(attrs, mark.attrs || {}); + } + return attrs; +} + +export { BANNER_TITLE_FONT_FAMILIES }; diff --git a/web/src/lib/announcements/banner-title.ts b/web/src/lib/announcements/banner-title.ts new file mode 100644 index 000000000..6c387e50c --- /dev/null +++ b/web/src/lib/announcements/banner-title.ts @@ -0,0 +1,167 @@ +/** + * 首页常驻通知条的标题样式模型。 + * + * 标题按「样式分段」存储:每段是一串连续文本加可选的字号 / 字重 / 字体 / 颜色覆盖。 + * 后端只接受这里的白名单取值(字号 10-20、字重 400/500/600/700、字体 sans|serif|mono、颜色 #RRGGBB), + * 所以通知条渲染时不会出现任意 CSS 值。 + */ + +export type BannerTitleFontFamilyKey = "sans" | "serif" | "mono"; + +export type BannerTitleRun = { + text: string; + fontSize?: number; + fontWeight?: number; + fontFamily?: BannerTitleFontFamilyKey; + color?: string; +}; + +export const BANNER_TITLE_MAX_CHARS = 120; + +export const BANNER_TITLE_MIN_FONT_SIZE = 10; +export const BANNER_TITLE_MAX_FONT_SIZE = 20; + +/** 通知条标题默认字号,与前台 `text-xs` 一致。 */ +export const BANNER_TITLE_DEFAULT_FONT_SIZE = 12; +/** 通知条标题默认字重,与前台 `font-semibold` 一致。 */ +export const BANNER_TITLE_DEFAULT_FONT_WEIGHT = 600; + +export const BANNER_TITLE_FONT_SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20]; + +export const BANNER_TITLE_FONT_WEIGHTS = [ + { value: 400, label: "常规" }, + { value: 500, label: "中等" }, + { value: 600, label: "半粗" }, + { value: 700, label: "粗体" }, +]; + +export const BANNER_TITLE_FONT_FAMILIES: { key: BannerTitleFontFamilyKey; label: string; css: string }[] = [ + { key: "sans", label: "默认", css: "" }, + { key: "serif", label: "衬线", css: 'Georgia, "Songti SC", "SimSun", serif' }, + { key: "mono", label: "等宽", css: "var(--font-mono)" }, +]; + +export function bannerTitleFontFamilyCSS(key?: BannerTitleFontFamilyKey) { + if (!key) return ""; + return BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === key)?.css || ""; +} + +/** 由 CSS 值反查字体档位;未知字体返回空,调用方按未设置处理。 */ +export function bannerTitleFontFamilyKey(css?: string) { + const value = (css || "").trim(); + if (!value) return "" as const; + return BANNER_TITLE_FONT_FAMILIES.find((item) => item.css === value)?.key || ("" as const); +} + +/** 清洗单个分段:非法取值一律丢弃,不调用方兜底。 */ +export function normalizeBannerTitleRun(run: BannerTitleRun): BannerTitleRun | null { + const text = typeof run.text === "string" ? run.text : ""; + if (!text) return null; + const normalized: BannerTitleRun = { text }; + const size = Number.isInteger(run.fontSize) ? (run.fontSize as number) : undefined; + if (size && size >= BANNER_TITLE_MIN_FONT_SIZE && size <= BANNER_TITLE_MAX_FONT_SIZE) normalized.fontSize = size; + const weight = BANNER_TITLE_FONT_WEIGHTS.find((item) => item.value === run.fontWeight)?.value; + if (weight) normalized.fontWeight = weight; + const family = BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === run.fontFamily)?.key; + if (family && family !== "sans") normalized.fontFamily = family; + const color = normalizeBannerTitleHex(run.color); + if (color) normalized.color = color; + return normalized; +} + +/** 清洗整段标题并合并相邻同样式分段,与后端 normalizeBannerTitleRuns 保持同一套规则。 */ +export function normalizeBannerTitleRuns(runs?: BannerTitleRun[] | null): BannerTitleRun[] { + const normalized: BannerTitleRun[] = []; + for (const run of runs || []) { + const next = normalizeBannerTitleRun(run); + if (!next) continue; + const previous = normalized[normalized.length - 1]; + if (previous && sameBannerTitleRunStyle(previous, next)) { + previous.text += next.text; + continue; + } + normalized.push(next); + } + return normalized; +} + +export function sameBannerTitleRunStyle(left: BannerTitleRun, right: BannerTitleRun) { + return left.fontSize === right.fontSize && left.fontWeight === right.fontWeight && left.fontFamily === right.fontFamily && left.color === right.color; +} + +export function bannerTitlePlainText(runs?: BannerTitleRun[] | null) { + return (runs || []).map((run) => run.text).join(""); +} + +/** 按码点计数,与后端 utf8.RuneCountInString 一致(emoji 等代理对按 1 个字符算)。 */ +export function bannerTitleCharCount(runs?: BannerTitleRun[] | null) { + return Array.from(bannerTitlePlainText(runs)).length; +} + +export function normalizeBannerTitleHex(value?: string) { + const raw = (value || "").trim().toUpperCase(); + if (!raw) return ""; + const hex = raw.startsWith("#") ? raw.slice(1) : raw; + if (/^[0-9A-F]{3}$/.test(hex)) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`; + } + return /^[0-9A-F]{6}$/.test(hex) ? `#${hex}` : ""; +} + +/** 分段对应的内联样式;未设置的维度交给外层类名(通知条默认白色文字、12px)。 */ +export function bannerTitleRunStyle(run: BannerTitleRun) { + const style: Record = {}; + if (run.fontSize) { + style.fontSize = `${run.fontSize}px`; + } + if (run.fontWeight) style.fontWeight = run.fontWeight; + const family = bannerTitleFontFamilyCSS(run.fontFamily); + if (family) style.fontFamily = family; + if (run.color) style.color = run.color; + return style; +} + +/** 解析 #RGB / #RRGGBB / rgb() 为 0-255 三元组;无法解析时返回 null。 */ +export function parseBannerTitleColor(value?: string): [number, number, number] | null { + const raw = (value || "").trim(); + if (!raw) return null; + const hex = normalizeBannerTitleHex(raw); + if (hex) { + return [Number.parseInt(hex.slice(1, 3), 16), Number.parseInt(hex.slice(3, 5), 16), Number.parseInt(hex.slice(5, 7), 16)]; + } + const rgb = raw.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i); + if (!rgb) return null; + return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]; +} + +/** WCAG 对比度;任一颜色无法解析时返回 null。 */ +export function bannerTitleContrastRatio(foreground?: string, background?: string) { + const front = parseBannerTitleColor(foreground); + const back = parseBannerTitleColor(background); + if (!front || !back) return null; + const frontLuminance = relativeLuminance(front); + const backLuminance = relativeLuminance(back); + const lighter = Math.max(frontLuminance, backLuminance); + const darker = Math.min(frontLuminance, backLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance([red, green, blue]: [number, number, number]) { + const channels = [red, green, blue].map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +/** 返回该主题下对比度不足 4.5:1 的文字颜色(去重),用于编辑时的可读性提示。 */ +export function lowContrastBannerTitleColors(runs: BannerTitleRun[] | null | undefined, background: string) { + if (!background) return []; + const failing = new Set(); + for (const run of runs || []) { + if (!run.color) continue; + const ratio = bannerTitleContrastRatio(run.color, background); + if (ratio !== null && ratio < 4.5) failing.add(run.color); + } + return Array.from(failing); +} diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx index 8e580f473..caaa1f445 100644 --- a/web/src/pages/admin/admin-route-pages.tsx +++ b/web/src/pages/admin/admin-route-pages.tsx @@ -8,6 +8,7 @@ import { readAnnouncementPendingReview } from "./components/admin-announcement-s const AnalyticsPanel = lazy(() => import("./components/analytics-panel")); const AdminAnnouncementsPanel = lazy(() => import("./components/admin-announcements-panel")); +const AdminBannerAnnouncementsPanel = lazy(() => import("./components/admin-banner-announcements-panel")); const CreditOperationsPanel = lazy(() => import("./components/credit-operations-panel")); const AccessSettingsPanel = lazy(() => import("./components/access-settings-panel")); const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel")); @@ -52,6 +53,27 @@ export function AnnouncementsPage() { ); } +export function BannerAnnouncementsPage() { + const [createOpen, setCreateOpen] = useState(false); + return ( + } + onClick={() => setCreateOpen(true)} + > + 新增常驻通知 + + } + > + + + ); +} + export function CreditOperationsPage() { const { references } = useAdminContext(); const [activeOperation, setActiveOperation] = useState<"policy" | "adjustment" | null>(null); diff --git a/web/src/pages/admin/components/admin-banner-announcements-panel.tsx b/web/src/pages/admin/components/admin-banner-announcements-panel.tsx new file mode 100644 index 000000000..ea93a153b --- /dev/null +++ b/web/src/pages/admin/components/admin-banner-announcements-panel.tsx @@ -0,0 +1,438 @@ +import { App, Button, DatePicker, Form, Input, Modal, Popconfirm, Select, Switch } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { Trash2, Edit3, Search } from "lucide-react"; +import dayjs, { type Dayjs } from "dayjs"; +import { useCallback, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + createAdminBanner, + deleteAdminBanner, + listAdminBanners, + updateAdminBanner, + type BannerAnnouncement, +} from "@/services/api/announcements"; +import { + BANNER_NOTICE_DEFAULT_TYPE, + bannerNoticeTypeLabel, + normalizeBannerNoticeType, + type BannerNoticeType, +} from "@/lib/announcements/banner-notice"; +import { bannerAnnouncementBarStyle } from "@/components/layout/banner-announcement-content"; +import { BANNER_TITLE_MAX_CHARS, bannerTitleCharCount, bannerTitlePlainText, normalizeBannerTitleRuns, type BannerTitleRun } from "@/lib/announcements/banner-title"; +import { AdminDataTable } from "./admin-ui"; +import { BannerNoticeTypeSelector } from "./banner-notice-type-selector"; +import { BannerNoticePreview, BannerTitleEditor } from "./banner-title-editor"; + +type FormValues = { + noticeType: BannerNoticeType; + link?: string; + status: boolean; + dateRange?: [Dayjs | null, Dayjs | null] | null; +}; + +type BannerDialog = { mode: "create" } | { mode: "edit"; banner: BannerAnnouncement }; + +export default function AdminBannerAnnouncementsPanel({ + createOpen, + onCreateOpenChange, +}: { + createOpen: boolean; + onCreateOpenChange: (open: boolean) => void; +}) { + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + const linkValue = Form.useWatch("link", form); + const [banners, setBanners] = useState([]); + const [keyword, setKeyword] = useState(""); + const [status, setStatus] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [dialog, setDialog] = useState(null); + const [titleRuns, setTitleRuns] = useState([]); + const [noticeType, setNoticeType] = useState(BANNER_NOTICE_DEFAULT_TYPE); + const [titleTouched, setTitleTouched] = useState(false); + const [dirty, setDirty] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const reload = async (targetPage = page, targetPageSize = pageSize) => { + setLoading(true); + try { + const data = await listAdminBanners({ + keyword: keyword.trim() || undefined, + status: status === "all" ? undefined : status, + page: targetPage, + pageSize: targetPageSize, + }); + setBanners(data.banners || []); + setTotal(data.total || 0); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取常驻通知列表失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void reload(1, pageSize); + }, [keyword, status]); + + const openCreate = useCallback(() => { + setTitleRuns([]); + setNoticeType(BANNER_NOTICE_DEFAULT_TYPE); + setTitleTouched(false); + setDirty(false); + form.resetFields(); + form.setFieldsValue({ + noticeType: BANNER_NOTICE_DEFAULT_TYPE, + status: true, + }); + setDialog({ mode: "create" }); + }, [form]); + + // 父级的 createOpen 只是「请求新建」信号:消费后立即复位,避免它再次触发时把编辑中的内容重置掉。 + useEffect(() => { + if (!createOpen) return; + onCreateOpenChange(false); + openCreate(); + }, [createOpen, onCreateOpenChange, openCreate]); + + const openEdit = (banner: BannerAnnouncement) => { + const initialRuns = banner.titleRuns?.length ? banner.titleRuns : banner.title ? [{ text: banner.title }] : []; + const type = normalizeBannerNoticeType(banner.noticeType); + setTitleRuns(initialRuns); + setNoticeType(type); + setTitleTouched(false); + setDirty(false); + form.setFieldsValue({ + noticeType: type, + link: banner.link || undefined, + status: banner.status === "active", + dateRange: banner.startsAt || banner.endsAt ? [banner.startsAt ? dayjs(banner.startsAt) : null, banner.endsAt ? dayjs(banner.endsAt) : null] : null, + }); + setDialog({ mode: "edit", banner }); + }; + + const requestClose = () => { + if (submitting) return; + if (!dirty) { + setDialog(null); + return; + } + modal.confirm({ + title: "放弃未保存的修改?", + content: "关闭后本次编辑的内容不会保留。", + okText: "放弃修改", + cancelText: "继续编辑", + okButtonProps: { danger: true }, + onOk: () => setDialog(null), + }); + }; + + const handleDelete = async (id: string) => { + try { + await deleteAdminBanner(id); + message.success("常驻通知已删除"); + void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] }); + void reload(page, pageSize); + } catch (error) { + message.error(error instanceof Error ? error.message : "删除失败"); + } + }; + + const handleToggleStatus = async (banner: BannerAnnouncement, nextStatus: boolean) => { + try { + await updateAdminBanner(banner.id, { + title: banner.title, + // 必须带上样式分段与通知类型:后端更新走显式字段表,漏传会把它们清空。 + titleRuns: banner.titleRuns, + noticeType: normalizeBannerNoticeType(banner.noticeType), + link: banner.link, + status: nextStatus ? "active" : "disabled", + startsAt: banner.startsAt, + endsAt: banner.endsAt, + }); + message.success(nextStatus ? "常驻通知已启用" : "常驻通知已禁用"); + void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] }); + void reload(page, pageSize); + } catch (error) { + message.error(error instanceof Error ? error.message : "更新状态失败"); + } + }; + + const handleSubmit = async (values: FormValues) => { + setTitleTouched(true); + const runs = normalizeBannerTitleRuns(titleRuns); + const plain = bannerTitlePlainText(runs).trim(); + if (!plain || !runs.length) { + message.error("请填写通知标题"); + return; + } + if (bannerTitleCharCount(runs) > BANNER_TITLE_MAX_CHARS) { + message.error(`通知标题不能超过 ${BANNER_TITLE_MAX_CHARS} 个字符`); + return; + } + setSubmitting(true); + try { + const startsAt = values.dateRange?.[0] ? values.dateRange[0].toISOString() : undefined; + const endsAt = values.dateRange?.[1] ? values.dateRange[1].toISOString() : undefined; + const selectedNoticeType = values.noticeType || noticeType; + const payload = { + title: plain, + titleRuns: runs, + noticeType: selectedNoticeType, + link: values.link?.trim() || undefined, + status: (values.status ? "active" : "disabled") as "active" | "disabled", + startsAt, + endsAt, + }; + + if (dialog?.mode === "edit") { + await updateAdminBanner(dialog.banner.id, payload); + message.success("常驻通知已更新"); + } else { + await createAdminBanner(payload); + message.success("常驻通知已新建"); + } + setDialog(null); + form.resetFields(); + void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] }); + void reload(1, pageSize); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存常驻通知失败"); + } finally { + setSubmitting(false); + } + }; + + const columns: ColumnsType = [ + { + title: "通知标题", + dataIndex: "title", + key: "title", + render: (text: string, record) => ( +
+ {text} + {record.link ? ( + + {record.link.length > 44 ? `${record.link.slice(0, 44)}…` : record.link} + + ) : null} +
+ ), + }, + { + title: "类型", + key: "noticeType", + width: 92, + // 用真实底色渲染类型,和前台/预览看到的是同一个 token。 + render: (_, record) => ( + + {bannerNoticeTypeLabel(record.noticeType)} + + ), + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 110, + render: (value: string, record) => ( + void handleToggleStatus(record, checked)} + /> + ), + }, + { + title: "有效期", + key: "validity", + width: 280, + render: (_, record) => { + if (!record.startsAt && !record.endsAt) { + return 长期有效; + } + const startStr = record.startsAt ? dayjs(record.startsAt).format("YYYY-MM-DD HH:mm") : "不限"; + const endStr = record.endsAt ? dayjs(record.endsAt).format("YYYY-MM-DD HH:mm") : "不限"; + return ( + + {startStr} 至 {endStr} + + ); + }, + }, + { + title: "创建时间", + dataIndex: "createdAt", + key: "createdAt", + width: 160, + render: (date: string) => {dayjs(date).format("YYYY-MM-DD HH:mm")}, + }, + { + title: "操作", + key: "action", + width: 120, + align: "right", + render: (_, record) => ( +
+ + void handleDelete(record.id)}> + + +
+ ), + }, + ]; + + const hasFilters = Boolean(keyword.trim() || status !== "all"); + const titleMissing = titleTouched && (!titleRuns.length || !bannerTitlePlainText(titleRuns).trim()); + const titleTooLong = bannerTitleCharCount(titleRuns) > BANNER_TITLE_MAX_CHARS; + + return ( +
+ } + value={keyword} + placeholder="搜索常驻通知标题" + onChange={(event) => { + setKeyword(event.target.value); + setPage(1); + }} + /> + } + toolbarActive={hasFilters} + toolbarFilters={ + + + + + + + + + + + + +
+ ); +} diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 7a7dc11cd..69ccdbcd9 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -20,6 +20,7 @@ import { KeyRound, Layers3, Mail, + Megaphone, MessageSquareText, Moon, Palette, @@ -77,6 +78,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = label: "运营", items: [ { path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: }, + { path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon: }, { path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon: }, { path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon: }, { path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon: }, diff --git a/web/src/pages/admin/components/banner-notice-emoji-picker.tsx b/web/src/pages/admin/components/banner-notice-emoji-picker.tsx new file mode 100644 index 000000000..fe89b9757 --- /dev/null +++ b/web/src/pages/admin/components/banner-notice-emoji-picker.tsx @@ -0,0 +1,90 @@ +import { Input, Popover, Button } from "antd"; +import { SmilePlus, Search } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { BANNER_NOTICE_EMOJI_GROUPS, searchBannerNoticeEmojis } from "@/lib/announcements/banner-notice"; + +/** + * emoji 图标素材面板:按分组铺开,支持按中文名搜索。 + * 选中即作为普通文本插进标题光标处,可插任意多个、任意位置,并跟随光标处的字号 / 颜色样式。 + * 面板不自动关闭,方便连续插入;点击面板外任意位置收起。 + */ +export function BannerNoticeEmojiPanel({ onPick }: { onPick: (emoji: string) => void }) { + const [keyword, setKeyword] = useState(""); + + const matched = useMemo(() => new Set(searchBannerNoticeEmojis(keyword).map((item) => item.char)), [keyword]); + const groups = useMemo( + () => + BANNER_NOTICE_EMOJI_GROUPS.map((group) => ({ + ...group, + emoji: group.emoji.filter((item) => matched.has(item.char)), + })).filter((group) => group.emoji.length > 0), + [matched], + ); + + return ( +
+
+ } + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + /> +
+ +
+ {groups.length ? ( + groups.map((group) => ( +
+ {group.label} +
+ {group.emoji.map((item) => ( + + ))} +
+
+ )) + ) : ( +

没有匹配「{keyword.trim()}」的素材

+ )} +
+
+ ); +} + +/** + * 标题编辑工具栏的「插入图标」入口:默认不占任何视觉位,点了才弹出素材面板。 + * onPick 由调用方提供(编辑器要把内容插到光标处)。 + */ +export function BannerNoticeEmojiPopover({ onPick }: { onPick: (emoji: string) => void }) { + return ( + } + // AntD 6 Popover 的语义样式键是 content / container / root / arrow,没有 body; + // container 对应 .ant-popover-inner(内边距所在元素)。 + styles={{ container: { padding: 0 } }} + > + + + ); +} diff --git a/web/src/pages/admin/components/banner-notice-type-selector.tsx b/web/src/pages/admin/components/banner-notice-type-selector.tsx new file mode 100644 index 000000000..a02035b06 --- /dev/null +++ b/web/src/pages/admin/components/banner-notice-type-selector.tsx @@ -0,0 +1,40 @@ +import { Check } from "lucide-react"; + +import { BANNER_NOTICE_TYPES, normalizeBannerNoticeType, type BannerNoticeType } from "@/lib/announcements/banner-notice"; + +/** + * 通知类型选择:每种类型决定通知条底色,卡片上直接铺真实底色条, + * 免得用「蓝色 / 橙色」这种文字去描述一个已经看得见的颜色。 + */ +export function BannerNoticeTypeSelector({ value, onChange }: { value: string; onChange: (type: BannerNoticeType) => void }) { + const active = normalizeBannerNoticeType(value); + + return ( +
+ {BANNER_NOTICE_TYPES.map((type) => { + const selected = active === type.key; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/pages/admin/components/banner-title-editor.tsx b/web/src/pages/admin/components/banner-title-editor.tsx new file mode 100644 index 000000000..02d610867 --- /dev/null +++ b/web/src/pages/admin/components/banner-title-editor.tsx @@ -0,0 +1,213 @@ +import type { Editor } from "@tiptap/core"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { Button, ColorPicker, Select, Tooltip } from "antd"; +import { Eraser, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + BannerAnnouncementLinkHint, + BannerAnnouncementTitle, + bannerAnnouncementBarStyle, +} from "@/components/layout/banner-announcement-content"; +import { readBannerNoticeSurface, bannerNoticeTypeLabel } from "@/lib/announcements/banner-notice"; +import { BannerNoticeEmojiPopover } from "./banner-notice-emoji-picker"; +import { + BANNER_TITLE_DEFAULT_FONT_SIZE, + BANNER_TITLE_DEFAULT_FONT_WEIGHT, + BANNER_TITLE_FONT_FAMILIES, + BANNER_TITLE_FONT_SIZES, + BANNER_TITLE_FONT_WEIGHTS, + BANNER_TITLE_MAX_CHARS, + bannerTitleCharCount, + lowContrastBannerTitleColors, + type BannerTitleRun, +} from "@/lib/announcements/banner-title"; +import { + applyBannerTitleStyle, + bannerDocToRuns, + bannerTitleRunsToDoc, + bannerTitleSelectionStyle, + clearBannerTitleStyle, + createBannerTitleExtensions, + type BannerTitleSelectionStyle, + type BannerTitleStylePatch, +} from "@/lib/announcements/banner-title-rich-text"; + +const DEFAULT_OPTION = "default"; + +/** 一次性读出编辑器当前的分段与光标处样式,供受控渲染和回调共用。 */ +function readEditorTitle(instance: Editor) { + const runs = bannerDocToRuns(instance.getJSON()); + return { runs, selection: bannerTitleSelectionStyle(instance) }; +} + +/** + * 通知标题编辑区:文字按选区设置字号 / 字重 / 字体 / 颜色。 + * 组件只把 title 分段当初始值,切换编辑对象时由调用方通过 key 重建实例。 + */ +export function BannerTitleEditor({ value, onChange }: { value: BannerTitleRun[]; onChange: (runs: BannerTitleRun[]) => void }) { + const [selectionStyle, setSelectionStyle] = useState({}); + const [charCount, setCharCount] = useState(() => bannerTitleCharCount(value)); + + const editor = useEditor({ + immediatelyRender: false, + extensions: createBannerTitleExtensions("输入首页顶部展示的通知标题"), + content: bannerTitleRunsToDoc(value), + editorProps: { + attributes: { class: "outline-none", "aria-label": "通知标题编辑区" }, + handleKeyDown: (_view, event) => { + // 通知条标题是单行语义,Enter 不产生新段落。 + return event.key === "Enter"; + }, + }, + onUpdate: ({ editor: instance }) => { + const { runs, selection } = readEditorTitle(instance); + setSelectionStyle(selection); + setCharCount(bannerTitleCharCount(runs)); + onChange(runs); + }, + }); + + const applyStyle = (patch: BannerTitleStylePatch) => { + if (!editor) return; + editor.view.focus(); + applyBannerTitleStyle(editor, patch); + const { runs, selection } = readEditorTitle(editor); + setSelectionStyle(selection); + setCharCount(bannerTitleCharCount(runs)); + onChange(runs); + }; + + const tooLong = charCount > BANNER_TITLE_MAX_CHARS; + + return ( +
+
+ applyStyle({ fontWeight: next === DEFAULT_OPTION ? null : Number(next) })} + options={[ + { value: DEFAULT_OPTION, label: "默认字重" }, + ...BANNER_TITLE_FONT_WEIGHTS.map((item) => ({ value: item.value, label: `${item.label} ${item.value}` })), + ]} + /> +