From 974812e34a77e9863c63da961705c14d4dd4d632 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:04:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E6=B3=A8=E5=86=8C=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E4=B8=8E=E5=85=AC=E5=91=8A=20-=20=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E6=AD=A3=E6=96=87=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/migrations.go | 26 +++ backend/internal/database/migrations_test.go | 42 +++++ backend/internal/service/announcement.go | 4 +- backend/internal/service/announcement_test.go | 13 ++ backend/internal/service/auth.go | 3 + backend/internal/service/email.go | 166 ++++++++++++++---- backend/internal/service/email_domain_test.go | 61 +++++++ .../internal/service/password_reset_test.go | 2 +- .../content/docs/backend/backend-database.mdx | 4 +- .../layout/system-announcement-center.tsx | 65 ++++++- .../components/layout/workspace-top-bar.tsx | 2 +- .../announcement-timeline-modal.tsx | 16 +- .../components/admin-announcements-panel.tsx | 13 +- .../admin/components/email-settings-panel.tsx | 133 +++++++++++--- web/src/services/api/wallet.ts | 1 + web/src/styles/admin-ui.css | 6 + 16 files changed, 480 insertions(+), 77 deletions(-) create mode 100644 backend/internal/service/email_domain_test.go diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 4ca74de76..5e12943fc 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -54,6 +54,26 @@ var schemaMigrations = []migration{ {version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7}, } +// reconcileLegacyMigrationV6 repairs databases created while asset folders were +// temporarily assigned to migration 6 before resource playback became migration 6. +// The schema changes are additive and the transaction keeps the record update atomic. +func reconcileLegacyMigrationV6(tx *gorm.DB, applied schemaMigration, expected migration) error { + if expected.version != 6 || applied.Name != "asset_library_folders" || applied.Checksum != assetLibraryFoldersChecksum { + return nil + } + if err := migrateSchemaV6(tx); err != nil { + return fmt.Errorf("补齐资源播放副本结构:%w", err) + } + if err := tx.Model(&schemaMigration{}).Where("version = ?", applied.Version).Updates(map[string]any{ + "name": expected.name, + "checksum": expected.checksum, + "applied_at": applied.AppliedAt, + }).Error; err != nil { + return fmt.Errorf("修复数据库迁移 %d 记录:%w", applied.Version, err) + } + return nil +} + func migrateSchemaV2(tx *gorm.DB) error { return tx.Exec("CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at ON schema_migrations (applied_at)").Error } @@ -169,6 +189,12 @@ func MigrateSchema(db *gorm.DB) error { var applied schemaMigration err := tx.First(&applied, "version = ?", item.version).Error if err == nil { + if err := reconcileLegacyMigrationV6(tx, applied, item); err != nil { + return err + } + if applied.Version == item.version && applied.Name == "asset_library_folders" && item.version == 6 { + continue + } if err := validateMigrationRecord(applied, item); err != nil { return err } diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index e6dc7ec2d..b73c430d0 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -133,6 +133,48 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { } } +func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-legacy-v6-order?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&schemaMigration{}); err != nil { + t.Fatal(err) + } + for _, item := range schemaMigrations[:5] { + if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + + if err := MigrateSchema(db); err != nil { + t.Fatalf("repair legacy migration order: %v", err) + } + if !db.Migrator().HasColumn(&model.Resource{}, "playback_status") || !db.Migrator().HasColumn(&model.Resource{}, "playback_object_key") || !db.Migrator().HasColumn(&model.Resource{}, "playback_error") { + t.Fatal("legacy database did not receive resource playback columns") + } + status, err := ReadSchemaStatus(db) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("unexpected repaired schema status: %#v", status) + } + var applied schemaMigration + if err := db.First(&applied, "version = ?", 6).Error; err != nil { + t.Fatal(err) + } + if applied.Name != "resource_playback_variant" || applied.Checksum != resourcePlaybackChecksum { + t.Fatalf("migration 6 after repair = %#v", applied) + } +} + func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index eb74ddbce..f90a89821 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -459,8 +459,8 @@ func validAnnouncementLevel(level model.AnnouncementLevel) bool { func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, model.AnnouncementLevel, error) { title := strings.TrimSpace(req.Title) content := strings.TrimSpace(req.Content) - if title == "" || content == "" { - return "", "", "", BadAuthRequest("请填写公告标题和正文") + if title == "" { + return "", "", "", BadAuthRequest("请填写公告标题") } if utf8.RuneCountInString(title) > 120 { return "", "", "", BadAuthRequest("公告标题不能超过 120 个字符") diff --git a/backend/internal/service/announcement_test.go b/backend/internal/service/announcement_test.go index bf76bca51..79dab8000 100644 --- a/backend/internal/service/announcement_test.go +++ b/backend/internal/service/announcement_test.go @@ -262,6 +262,19 @@ func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { } } +func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) { + svc, _ := newAnnouncementImageTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + + announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "仅标题公告", Content: " ", Level: model.AnnouncementLevelInfo}) + if err != nil { + t.Fatal(err) + } + if announcement.Title != "仅标题公告" || announcement.Content != "" { + t.Fatalf("announcement = %+v", announcement) + } +} + func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index f37b09bc3..74fa9829f 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -129,6 +129,9 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) { if email == "" { return nil, BadAuthRequest("请输入邮箱") } + if err := s.validateRegistrationEmailDomain(email); err != nil { + return nil, err + } verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode) if err != nil { return nil, err diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index d7afd2b3b..b5461a10b 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -28,41 +28,56 @@ const emailSettingKey = "email" const registrationEmailPurpose = "registration" const registrationCodeTTL = 10 * time.Minute +var defaultRegistrationEmailDomains = []string{ + "gmail.com", + "163.com", + "126.com", + "qq.com", + "outlook.com", + "hotmail.com", + "icloud.com", + "yahoo.com", + "foxmail.com", +} + type EmailSettingRequest struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } type PublicEmailSetting struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` - FromNameInherited bool `json:"fromNameInherited"` - HasPassword bool `json:"hasPassword"` - UpdatedBy string `json:"updatedBy"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + FromNameInherited bool `json:"fromNameInherited"` + HasPassword bool `json:"hasPassword"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` + UpdatedBy string `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type emailSettingValue struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) { @@ -84,7 +99,15 @@ func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest) if err != nil { return nil, err } - next := normalizeEmailSetting(emailSettingValue{Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName}) + allowedDomains := req.RegistrationAllowedDomains + if allowedDomains == nil { + allowedDomains = current.RegistrationAllowedDomains + } + next := normalizeEmailSetting(emailSettingValue{ + Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, + Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName, + RegistrationAllowedDomains: allowedDomains, + }) if next.Password == "" { next.Password = current.Password } @@ -130,6 +153,9 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { if count == 0 { return BadAuthRequest("首个管理员账号不需要邮箱验证码") } + if err := s.validateRegistrationEmailDomain(email); err != nil { + return err + } registrationEnabled, err := s.RegistrationEnabled() if err != nil { return err @@ -249,6 +275,9 @@ func (s *Service) readEmailSetting() (*model.SystemSetting, emailSettingValue, e } func validateEmailSetting(value emailSettingValue) error { + if err := validateRegistrationEmailDomains(value.RegistrationAllowedDomains); err != nil { + return err + } if !value.Enabled { return nil } @@ -273,6 +302,10 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue { value.Password = strings.TrimSpace(value.Password) value.FromEmail = normalizeEmail(value.FromEmail) value.FromName = strings.TrimSpace(value.FromName) + if value.RegistrationAllowedDomains == nil { + value.RegistrationAllowedDomains = append([]string(nil), defaultRegistrationEmailDomains...) + } + value.RegistrationAllowedDomains = normalizeEmailDomains(value.RegistrationAllowedDomains) if value.Port == 0 { value.Port = 587 } @@ -287,7 +320,11 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue { func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSettingValue) *PublicEmailSetting { inherited := value.FromName == "" || value.FromName == defaultAppearanceBrandName value = resolveEmailSender(value, s.appearanceBrandName()) - result := &PublicEmailSetting{Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != ""} + result := &PublicEmailSetting{ + Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, + FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != "", + RegistrationAllowedDomains: value.RegistrationAllowedDomains, + } if setting != nil { result.UpdatedBy = setting.UpdatedBy result.CreatedAt = setting.CreatedAt @@ -296,6 +333,75 @@ func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSe return result } +func normalizeEmailDomains(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, raw := range values { + value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@"))) + value = strings.TrimSuffix(value, ".") + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func validateRegistrationEmailDomains(allowed []string) error { + if len(allowed) > 100 { + return BadAuthRequest("电子邮件域名白名单最多填写 100 项") + } + for _, domain := range allowed { + if !validEmailDomain(domain) { + return BadAuthRequest("电子邮件域名格式不正确:" + domain) + } + } + return nil +} + +func validEmailDomain(value string) bool { + if len(value) > 253 || !strings.Contains(value, ".") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { + return false + } + for _, label := range strings.Split(value, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return false + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { + return false + } + } + } + return true +} + +func (s *Service) validateRegistrationEmailDomain(email string) error { + _, setting, err := s.readEmailSetting() + if err != nil { + return err + } + return validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains) +} + +func validateRegistrationEmailDomain(email string, allowedDomains []string) error { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return BadAuthRequest("邮箱格式不正确") + } + domain := strings.ToLower(parts[1]) + if len(allowedDomains) == 0 { + return nil + } + for _, allowed := range allowedDomains { + if domain == allowed { + return nil + } + } + return BadAuthRequest("该邮箱域名不在管理员设置的白名单内") +} + func resolveEmailSender(value emailSettingValue, brandName string) emailSettingValue { if value.FromName == "" || value.FromName == defaultAppearanceBrandName { value.FromName = brandName diff --git a/backend/internal/service/email_domain_test.go b/backend/internal/service/email_domain_test.go new file mode 100644 index 000000000..0bb6ebf71 --- /dev/null +++ b/backend/internal/service/email_domain_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "strings" + "testing" +) + +func TestNormalizeEmailSettingDefaultsToMainstreamRegistrationDomains(t *testing.T) { + setting := normalizeEmailSetting(emailSettingValue{}) + for _, domain := range []string{"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com", "icloud.com", "yahoo.com", "foxmail.com"} { + if !containsEmailDomain(setting.RegistrationAllowedDomains, domain) { + t.Fatalf("default allowed domains = %#v, missing %q", setting.RegistrationAllowedDomains, domain) + } + } + + setting = normalizeEmailSetting(emailSettingValue{RegistrationAllowedDomains: []string{}}) + if len(setting.RegistrationAllowedDomains) != 0 { + t.Fatalf("explicit empty allowed domains = %#v, want unrestricted", setting.RegistrationAllowedDomains) + } +} + +func TestValidateRegistrationEmailDomainUsesExactWhitelistMatch(t *testing.T) { + allowed := []string{"gmail.com", "example.org"} + for _, email := range []string{"member@gmail.com", "member@example.org"} { + if err := validateRegistrationEmailDomain(email, allowed); err != nil { + t.Fatalf("%s should be allowed: %v", email, err) + } + } + for _, test := range []struct { + email string + want string + }{ + {email: "member@fakegmail.com", want: "不在管理员设置的白名单"}, + {email: "member@blocked.example", want: "不在管理员设置的白名单"}, + } { + if err := validateRegistrationEmailDomain(test.email, allowed); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validateRegistrationEmailDomain(%q) error = %v", test.email, err) + } + } +} + +func TestValidateRegistrationEmailDomainsRejectsInvalidDomain(t *testing.T) { + if err := validateRegistrationEmailDomains([]string{"invalid_domain"}); err == nil { + t.Fatal("invalid domain should be rejected") + } +} + +func TestValidateRegistrationEmailDomainAllowsAnyDomainWhenWhitelistIsEmpty(t *testing.T) { + if err := validateRegistrationEmailDomain("member@example.org", nil); err != nil { + t.Fatalf("empty whitelist should not restrict registration: %v", err) + } +} + +func containsEmailDomain(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/service/password_reset_test.go b/backend/internal/service/password_reset_test.go index f8db217bd..bcccb10e5 100644 --- a/backend/internal/service/password_reset_test.go +++ b/backend/internal/service/password_reset_test.go @@ -255,7 +255,7 @@ func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) { if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil { t.Fatal(err) } - settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策"}) + settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}}) if err != nil { t.Fatal(err) } diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 435a3e4df..2a2e84938 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -35,6 +35,8 @@ description: 影策后端核心持久化表与状态边界 密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。 +邮件系统设置还保存电子邮件域名白名单。新配置默认填入 Gmail、163、126、QQ、Outlook、Hotmail、iCloud、Yahoo 和 Foxmail;管理员可按行编辑完整域名。白名单为空时不限制邮箱域名,非空时只有精确匹配的域名可注册。该规则只作用于新用户注册和注册验证码,不限制 SMTP 发件邮箱,也不影响已有账号找回密码。 + ## 技能包与版本 | 表 | 用途 | 关键边界 | @@ -140,7 +142,7 @@ description: 影策后端核心持久化表与状态边界 | `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 | | `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 | -活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。 +活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。 ## 资源存储 diff --git a/web/src/components/layout/system-announcement-center.tsx b/web/src/components/layout/system-announcement-center.tsx index 741ddd2ea..76441746f 100644 --- a/web/src/components/layout/system-announcement-center.tsx +++ b/web/src/components/layout/system-announcement-center.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Tag } from "antd"; import { Bell } from "lucide-react"; -import { useState, type CSSProperties } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal"; @@ -10,6 +10,7 @@ import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/annou const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000; const ANNOUNCEMENT_CACHE_TTL_MS = 60_000; +const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today"; type AnnouncementFeed = Awaited>; @@ -20,12 +21,15 @@ type SystemAnnouncementCenterProps = { showLabel?: boolean; labelClassName?: string; staticMotion?: boolean; + autoOpen?: boolean; }; -export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) { +export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false, autoOpen = false }: SystemAnnouncementCenterProps) { const reducedMotion = useReducedMotion(); const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const [automaticPrompt, setAutomaticPrompt] = useState(false); + const [dismissedFingerprint, setDismissedFingerprint] = useState(""); const queryKey = ["system-announcements", userId] as const; const feedQuery = useQuery({ queryKey, @@ -40,7 +44,16 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0); const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : ""; + useEffect(() => { + if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return; + const fingerprint = announcementFeedFingerprint(announcements); + if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return; + setAutomaticPrompt(true); + setOpen(true); + }, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]); + const openAnnouncements = async () => { + setAutomaticPrompt(false); setOpen(true); const feed = (await feedQuery.refetch()).data; if (!feed?.unreadCount) return; @@ -54,6 +67,14 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = } }; + const dismissAutomaticPrompt = (duration: "once" | "today") => { + const fingerprint = announcementFeedFingerprint(announcements); + if (fingerprint) setDismissedFingerprint(fingerprint); + if (duration === "today") rememberAnnouncementDismissalToday(userId); + setAutomaticPrompt(false); + setOpen(false); + }; + return ( <> ) : null} - setOpen(false)} onRetry={() => void feedQuery.refetch()} /> + automaticPrompt ? dismissAutomaticPrompt("once") : setOpen(false)} + onDismissOnce={() => dismissAutomaticPrompt("once")} + onDismissToday={() => dismissAutomaticPrompt("today")} + onRetry={() => void feedQuery.refetch()} + /> ); } + +function announcementFeedFingerprint(announcements: AnnouncementFeed["announcements"]) { + return announcements + .map((announcement) => `${announcement.id}:${announcement.publishedAt}`) + .sort() + .join("|"); +} + +function announcementAutoPromptSuppressed(userId: string) { + try { + return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey(); + } catch { + return false; + } +} + +function rememberAnnouncementDismissalToday(userId: string) { + try { + localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey()); + } catch { + // 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。 + } +} + +function localDateKey() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} diff --git a/web/src/components/layout/workspace-top-bar.tsx b/web/src/components/layout/workspace-top-bar.tsx index 870188e1f..4c56ccd2f 100644 --- a/web/src/components/layout/workspace-top-bar.tsx +++ b/web/src/components/layout/workspace-top-bar.tsx @@ -47,7 +47,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
- {user ? : null} + {user ? : null}
diff --git a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx index b7b830f16..82e7c1700 100644 --- a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx +++ b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Image as AntImage, Modal } from "antd"; +import { Button, Image as AntImage, Modal } from "antd"; import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -11,7 +11,10 @@ type AnnouncementTimelineModalProps = { announcements: SystemAnnouncement[]; loading?: boolean; error?: string; + automaticPrompt?: boolean; onClose: () => void; + onDismissOnce?: () => void; + onDismissToday?: () => void; onRetry?: () => void; }; @@ -22,7 +25,7 @@ const levelMeta: Record + + + + ) : null} onCancel={onClose} title={
@@ -111,7 +119,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou {announcement.pinned ? 置顶 : null}
{announcement.imageUrl ? : null} - + {announcement.content ? : null} diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index 4f9173137..308703bc7 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -810,13 +810,10 @@ function AnnouncementEditor({ - + @@ -835,7 +832,7 @@ function AnnouncementEditor({

{watchedTitle?.trim() || "公告标题将在这里显示"}

{imagePreviewUrl ? 公告配图预览 : null} - {watchedContent?.trim() ? :

公告正文将在这里显示。

} + {watchedContent?.trim() ? :

此公告仅展示标题。

} @@ -889,7 +886,7 @@ function AnnouncementEditor({
公告正文
- + {pending.content ? : "无正文"}
diff --git a/web/src/pages/admin/components/email-settings-panel.tsx b/web/src/pages/admin/components/email-settings-panel.tsx index 27f1194a4..1f3d892a7 100644 --- a/web/src/pages/admin/components/email-settings-panel.tsx +++ b/web/src/pages/admin/components/email-settings-panel.tsx @@ -8,7 +8,13 @@ import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from import { useAppearanceStore } from "@/stores/use-appearance-store"; import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui"; -type EmailFormValues = Pick; +type EmailFormValues = Pick & { + registrationAllowedDomains: string; +}; + +type NormalizedEmailFormValues = Omit & { + registrationAllowedDomains: string[]; +}; export default function EmailSettingsPanel() { const { message, modal } = App.useApp(); @@ -289,8 +295,25 @@ export default function EmailSettingsPanel() { - {draftEnabled ? ( -
+
{ + const values = form.getFieldsValue(true); + setDraftEnabled(Boolean(values.enabled)); + setDirty(hasEmailChanges(values, setting)); + setSaveError(""); + }} + > + + + {draftEnabled ? ( +
} > - { - const values = form.getFieldsValue(true); - setDraftEnabled(Boolean(values.enabled)); - setDirty(hasEmailChanges(values, setting)); - setSaveError(""); - }} - > - -
} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
@@ -402,10 +409,53 @@ export default function EmailSettingsPanel() {
-
+ +
+ ) : null} + +
+
- ) : null} + ); } @@ -432,10 +482,11 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues { encryption: setting.encryption, fromEmail: setting.fromEmail, fromName: setting.fromNameInherited ? "" : setting.fromName, + registrationAllowedDomains: setting.registrationAllowedDomains.join("\n"), }; } -function normalizeEmailFormValues(values: Partial): EmailFormValues { +function normalizeEmailFormValues(values: Partial): NormalizedEmailFormValues { return { enabled: Boolean(values.enabled), host: values.host?.trim() || "", @@ -445,6 +496,7 @@ function normalizeEmailFormValues(values: Partial): EmailFormVa encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls", fromEmail: values.fromEmail?.trim().toLowerCase() || "", fromName: values.fromName?.trim() || "", + registrationAllowedDomains: normalizeDomains(values.registrationAllowedDomains), }; } @@ -453,7 +505,8 @@ function hasEmailChanges(values: Partial, setting: EmailSetting const draft = normalizeEmailFormValues(values); const saved = normalizeEmailFormValues(toEmailFormValues(setting)); if (draft.password) return true; - return (Object.keys(saved) as Array).some((key) => key !== "password" && draft[key] !== saved[key]); + const scalarFields: Array = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"]; + return scalarFields.some((key) => draft[key] !== saved[key]) || !arraysEqual(draft.registrationAllowedDomains, saved.registrationAllowedDomains); } function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) { @@ -466,11 +519,11 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul return ""; } -function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) { +function emailResponseMatches(setting: EmailSetting, expected: NormalizedEmailFormValues) { const actual = normalizeEmailFormValues(toEmailFormValues(setting)); const fields: Array = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"]; if (expected.password && !setting.hasPassword) return false; - return fields.every((key) => actual[key] === expected[key]); + return fields.every((key) => actual[key] === expected[key]) && arraysEqual(actual.registrationAllowedDomains, expected.registrationAllowedDomains); } function isEmailSetting(value: unknown): value is EmailSetting { @@ -485,10 +538,36 @@ function isEmailSetting(value: unknown): value is EmailSetting { typeof setting.fromEmail === "string" && typeof setting.fromName === "string" && typeof setting.fromNameInherited === "boolean" && - typeof setting.hasPassword === "boolean" + typeof setting.hasPassword === "boolean" && + Array.isArray(setting.registrationAllowedDomains) && + setting.registrationAllowedDomains.every((value) => typeof value === "string") ); } +function normalizeDomains(value?: string) { + return Array.from( + new Set( + (value || "") + .split(/[\s,,;;]+/) + .map((domain) => domain.trim().toLowerCase().replace(/^@/, "").replace(/\.$/, "")) + .filter(Boolean), + ), + ); +} + +function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function validateDomainList(value?: string) { + const invalid = invalidDomain(value); + return invalid ? Promise.reject(new Error(`邮箱域名格式不正确:“${invalid}”`)) : Promise.resolve(); +} + +function invalidDomain(value?: string) { + return normalizeDomains(value).find((domain) => !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain)); +} + function isValidEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index d52c46c9e..fd92a75f3 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -147,6 +147,7 @@ export type EmailSetting = { fromName: string; fromNameInherited: boolean; hasPassword: boolean; + registrationAllowedDomains: string[]; updatedBy?: string; createdAt?: string; updatedAt?: string; diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index ee939dd67..e24744f88 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -3337,6 +3337,12 @@ padding-bottom: 24px; } +.admin-email-form-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + .admin-email-settings .admin-settings-section { border: 1px solid var(--admin-card-border); }