From 2b3d122e56a211597fd91a6a9a4fbb9a6137e399 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 19 Sep 2026 04:14:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=AB=99=E7=82=B9=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1UIUX=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/handler/setting.go | 222 +++++++- backend/handler/upload.go | 79 +++ backend/router/router.go | 2 + backend/service/brand.go | 479 +++++++++++++++++ backend/service/brand_store.go | 187 +++++++ backend/service/brand_test.go | 121 +++++ backend/service/setting.go | 66 ++- backend/service/upload.go | 5 +- .../app/admin/settings/BasicIdentityPanel.tsx | 135 +++++ .../app/admin/settings/BrandMediaPicker.tsx | 104 ++++ frontend/app/admin/settings/BrandSeoPanel.tsx | 408 +++++++++++++++ .../app/admin/settings/FooterLinksPanel.tsx | 173 +++++++ frontend/app/admin/settings/SettingsAdmin.tsx | 487 ++++++++++-------- frontend/app/globals.css | 119 +++++ frontend/app/layout.tsx | 15 +- frontend/app/page.tsx | 12 +- frontend/components/BrandLockup.tsx | 104 ++++ frontend/components/Footer.tsx | 27 +- frontend/components/Header.tsx | 44 +- frontend/components/SiteBrandProvider.tsx | 183 +++++++ frontend/components/SiteChrome.tsx | 16 +- frontend/lib/api.ts | 91 ++++ frontend/lib/brand.ts | 169 ++++++ frontend/lib/realtime.ts | 10 + 24 files changed, 2952 insertions(+), 306 deletions(-) create mode 100644 backend/service/brand.go create mode 100644 backend/service/brand_store.go create mode 100644 backend/service/brand_test.go create mode 100644 frontend/app/admin/settings/BasicIdentityPanel.tsx create mode 100644 frontend/app/admin/settings/BrandMediaPicker.tsx create mode 100644 frontend/app/admin/settings/BrandSeoPanel.tsx create mode 100644 frontend/app/admin/settings/FooterLinksPanel.tsx create mode 100644 frontend/components/BrandLockup.tsx create mode 100644 frontend/components/SiteBrandProvider.tsx create mode 100644 frontend/lib/brand.ts diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 0482d0a..6638fcf 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -21,31 +21,41 @@ func (h *Handlers) PublicSettings(c *gin.Context) { // updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置 type updateSettingsRequest struct { - Accent *string `json:"accent"` - TrustReviewedPublish *bool `json:"trust_reviewed_publish"` - SiteName *string `json:"site_name"` - SiteDescription *string `json:"site_description"` - AllowRegister *bool `json:"allow_register"` - AllowComments *bool `json:"allow_comments"` - AllowMessages *bool `json:"allow_messages"` - PostCooldownHours *int `json:"post_cooldown_hours"` - CodeBlockAutoFold *bool `json:"code_block_auto_fold"` - CodeBlockFoldLines *int `json:"code_block_fold_lines"` - UIAnimations *bool `json:"ui_animations"` - AnimCodeFold *bool `json:"anim_code_fold"` - AnimSmoothScroll *bool `json:"anim_smooth_scroll"` - AnimChrome *bool `json:"anim_chrome"` - PostLinkNewTab *bool `json:"post_link_new_tab"` - AttachmentExtLimit *bool `json:"attachment_ext_limit"` - AttachmentExts *[]string `json:"attachment_exts"` - AttachmentMaxMB *int `json:"attachment_max_mb"` - AttachmentMaxCount *int `json:"attachment_max_count"` - ImageMaxMB *int `json:"image_max_mb"` - BgSiteURL *string `json:"bg_site_url"` - BgSiteMode *string `json:"bg_site_mode"` - BgAdminURL *string `json:"bg_admin_url"` - BgAdminMode *string `json:"bg_admin_mode"` - TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播 + Accent *string `json:"accent"` + TrustReviewedPublish *bool `json:"trust_reviewed_publish"` + SiteName *string `json:"site_name"` + SiteDescription *string `json:"site_description"` + AllowRegister *bool `json:"allow_register"` + AllowComments *bool `json:"allow_comments"` + AllowMessages *bool `json:"allow_messages"` + PostCooldownHours *int `json:"post_cooldown_hours"` + CodeBlockAutoFold *bool `json:"code_block_auto_fold"` + CodeBlockFoldLines *int `json:"code_block_fold_lines"` + UIAnimations *bool `json:"ui_animations"` + AnimCodeFold *bool `json:"anim_code_fold"` + AnimSmoothScroll *bool `json:"anim_smooth_scroll"` + AnimChrome *bool `json:"anim_chrome"` + PostLinkNewTab *bool `json:"post_link_new_tab"` + AttachmentExtLimit *bool `json:"attachment_ext_limit"` + AttachmentExts *[]string `json:"attachment_exts"` + AttachmentMaxMB *int `json:"attachment_max_mb"` + AttachmentMaxCount *int `json:"attachment_max_count"` + ImageMaxMB *int `json:"image_max_mb"` + BgSiteURL *string `json:"bg_site_url"` + BgSiteMode *string `json:"bg_site_mode"` + BgAdminURL *string `json:"bg_admin_url"` + BgAdminMode *string `json:"bg_admin_mode"` + SiteWordmark *string `json:"site_wordmark"` + SiteSlogan *string `json:"site_slogan"` + SiteKeywords *[]string `json:"site_keywords"` + LogoLightURL *string `json:"logo_light_url"` + LogoDarkURL *string `json:"logo_dark_url"` + FaviconURL *string `json:"favicon_url"` + BrandMark *string `json:"brand_mark"` + BrandLogoSize *string `json:"brand_logo_size"` + BrandLogoFit *string `json:"brand_logo_fit"` + FooterLinks *[]service.FooterLink `json:"footer_links"` + TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播 } func settingsPayload(saved service.PublicSiteSettings) gin.H { @@ -74,6 +84,16 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "bg_site_mode": saved.BgSiteMode, "bg_admin_url": saved.BgAdminURL, "bg_admin_mode": saved.BgAdminMode, + "site_wordmark": saved.SiteWordmark, + "site_slogan": saved.SiteSlogan, + "site_keywords": saved.SiteKeywords, + "logo_light_url": saved.LogoLightURL, + "logo_dark_url": saved.LogoDarkURL, + "favicon_url": saved.FaviconURL, + "brand_mark": saved.BrandMark, + "brand_logo_size": saved.BrandLogoSize, + "brand_logo_fit": saved.BrandLogoFit, + "footer_links": saved.FooterLinks, } } @@ -86,7 +106,11 @@ func (req *updateSettingsRequest) hasAny() bool { req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.BgSiteURL != nil || req.BgSiteMode != nil || - req.BgAdminURL != nil || req.BgAdminMode != nil || req.TimelineGitImport != nil + req.BgAdminURL != nil || req.BgAdminMode != nil || + req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil || + req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil || + req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil || + req.FooterLinks != nil || req.TimelineGitImport != nil } // AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings) @@ -306,6 +330,94 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.SiteWordmark != nil { + if err := h.Setting.SetSiteWordmark(*req.SiteWordmark); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "字标过长(最多 16 字)"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.SiteSlogan != nil { + if err := h.Setting.SetSiteSlogan(*req.SiteSlogan); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "标语过长(最多 32 字)"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.SiteKeywords != nil { + if err := h.Setting.SetSiteKeywords(*req.SiteKeywords); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "关键词过多或单个过长(最多 16 个,每个 20 字)"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.LogoLightURL != nil { + if err := h.applyBrandURL(service.BrandSlotLight, *req.LogoLightURL); err != nil { + writeBrandSettingError(c, err) + return + } + } + if req.LogoDarkURL != nil { + if err := h.applyBrandURL(service.BrandSlotDark, *req.LogoDarkURL); err != nil { + writeBrandSettingError(c, err) + return + } + } + if req.FaviconURL != nil { + if err := h.applyBrandURL(service.BrandSlotFavicon, *req.FaviconURL); err != nil { + writeBrandSettingError(c, err) + return + } + } + if req.BrandMark != nil { + if err := h.Setting.SetBrandMark(*req.BrandMark); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "页眉形式无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.BrandLogoFit != nil { + if err := h.Setting.SetBrandLogoFit(*req.BrandLogoFit); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "铺法无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.BrandLogoSize != nil { + if err := h.Setting.SetBrandLogoSize(*req.BrandLogoSize); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "画幅尺寸无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.FooterLinks != nil { + if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "页脚链接无效(最多 8 条,文案 32 字,地址须为站内路径或 http(s))"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } var savedAdapter string if req.TimelineGitImport != nil { @@ -378,3 +490,61 @@ func (h *Handlers) applyBgURL(surface, url string) error { h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin) return nil } + +func writeBrandSettingError(c *gin.Context, err error) { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "品牌图地址无效"}) + return + } + if errors.Is(err, service.ErrBrandFileMissing) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) +} + +// applyBrandURL 写入对应槽位,并删除不再被引用的旧文件 +func (h *Handlers) applyBrandURL(slot, url string) error { + u, ok := service.NormalizeBrandURL(slot, url) + if !ok { + return service.ErrInvalidSiteSetting + } + if u != "" && !h.Upload.BrandFileExists(u) { + return service.ErrBrandFileMissing + } + oldLight, err := h.Setting.LogoLightURL() + if err != nil { + return err + } + oldDark, err := h.Setting.LogoDarkURL() + if err != nil { + return err + } + oldFav, err := h.Setting.FaviconURL() + if err != nil { + return err + } + var old string + switch slot { + case service.BrandSlotDark: + old = oldDark + if err := h.Setting.SetLogoDarkURL(u); err != nil { + return err + } + oldDark = u + case service.BrandSlotFavicon: + old = oldFav + if err := h.Setting.SetFaviconURL(u); err != nil { + return err + } + oldFav = u + default: + old = oldLight + if err := h.Setting.SetLogoLightURL(u); err != nil { + return err + } + oldLight = u + } + h.Upload.RemoveBrandIfUnused(old, oldLight, oldDark, oldFav) + return nil +} diff --git a/backend/handler/upload.go b/backend/handler/upload.go index f13ab9f..613f642 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -230,3 +230,82 @@ func (h *Handlers) UploadBackground(c *gin.Context) { out["url"] = url c.JSON(http.StatusOK, out) } + +// UploadBrand 超管上传 Logo / Favicon,只落盘返回 URL,不写站点设置 +func (h *Handlers) UploadBrand(c *gin.Context) { + slot := c.PostForm("slot") + if _, ok := service.NormalizeBrandSlot(slot); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "请指定 logo_light、logo_dark 或 favicon"}) + return + } + maxB := int64(service.BrandLogoMaxBytes) + if slot == service.BrandSlotFavicon { + maxB = service.BrandFaviconMaxBytes + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxB+4096) + + fh, err := c.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"}) + return + } + if fh.Size > maxB { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)}) + return + } + f, err := fh.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) + return + } + defer f.Close() + + url, err := h.Upload.SaveBrand(slot, f) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} + +type brandFromMediaRequest struct { + AttachmentID uint `json:"attachment_id"` + Slot string `json:"slot"` +} + +// UploadBrandFromMedia 把本人媒体库图片复制进品牌目录,不写站点设置 +func (h *Handlers) UploadBrandFromMedia(c *gin.Context) { + var req brandFromMediaRequest + if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"}) + return + } + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + url, err := h.Upload.CopyBrandFromMedia(claims.ID, req.AttachmentID, req.Slot) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} + +func brandTooLarge(slot string) string { + if slot == service.BrandSlotFavicon { + return "Favicon 不能超过 512KB" + } + return "Logo 不能超过 2MB" +} diff --git a/backend/router/router.go b/backend/router/router.go index 4906109..099647c 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -267,6 +267,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings) staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground) + staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand) + staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia) // 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史 usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers)) diff --git a/backend/service/brand.go b/backend/service/brand.go new file mode 100644 index 0000000..a2c98a6 --- /dev/null +++ b/backend/service/brand.go @@ -0,0 +1,479 @@ +package service + +import ( + "encoding/json" + "regexp" + "strings" + "unicode/utf8" +) + +// 站点品牌与页脚(公开设置,缺省为空) +const ( + SettingKeySiteWordmark = "site_wordmark" + SettingKeySiteSlogan = "site_slogan" + SettingKeySiteKeywords = "site_keywords" + SettingKeyLogoLightURL = "logo_light_url" + SettingKeyLogoDarkURL = "logo_dark_url" + SettingKeyFaviconURL = "favicon_url" + SettingKeyFooterLinks = "footer_links" + SettingKeyBrandMark = "brand_mark" + SettingKeyBrandLogoSize = "brand_logo_size" + SettingKeyBrandLogoFit = "brand_logo_fit" + + BrandSlotLight = "logo_light" + BrandSlotDark = "logo_dark" + BrandSlotFavicon = "favicon" + + BrandMarkImageText = "image_text" + BrandMarkImage = "image" + BrandMarkText = "text" + + BrandLogoSizeSQ = "sq" + BrandLogoSize2x1 = "2x1" + BrandLogoSize3x1 = "3x1" + BrandLogoSize4x1 = "4x1" + BrandLogoSize169 = "16x9" + + BrandLogoFitCover = "cover" + BrandLogoFitContain = "contain" + BrandLogoFitRepeat = "repeat" + BrandLogoFitStretch = "stretch" + + MaxWordmarkRunes = 16 + MaxSloganRunes = 32 + MaxKeywordCount = 16 + MaxKeywordRunes = 20 + MaxFooterLinks = 8 + MaxFooterLabelRunes = 32 + MaxFooterURLRunes = 200 +) + +// FooterLink 页脚外链 / 站内链(备案、协议等) +type FooterLink struct { + Label string `json:"label"` + URL string `json:"url"` + NewTab bool `json:"new_tab"` +} + +// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico +var ( + brandFileURLRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg|ico)$`) + brandLogoURLRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg)$`) + brandFaviconRe = regexp.MustCompile(`^/uploads/brand/[0-9a-f]{32}\.(png|webp|svg|ico)$`) + footerHTTPURLRe = regexp.MustCompile(`(?i)^https?://`) +) + +// NormalizeBrandSlot 浅色 Logo / 暗色 Logo / Favicon +func NormalizeBrandSlot(raw string) (string, bool) { + s := strings.ToLower(strings.TrimSpace(raw)) + switch s { + case BrandSlotLight, BrandSlotDark, BrandSlotFavicon: + return s, true + default: + return "", false + } +} + +// NormalizeBrandMark 页眉品牌形式:图加文字 / 纯图 / 纯文字。空串视为图加文字。 +func NormalizeBrandMark(raw string) (string, bool) { + switch strings.TrimSpace(raw) { + case "", BrandMarkImageText: + return BrandMarkImageText, true + case BrandMarkImage, BrandMarkText: + return strings.TrimSpace(raw), true + default: + return "", false + } +} + +// NormalizeBrandLogoFit 图槽铺法:填充 / 适应 / 平铺 / 拉伸。空串视为适应。 +func NormalizeBrandLogoFit(raw string) (string, bool) { + switch strings.TrimSpace(raw) { + case "", BrandLogoFitContain: + return BrandLogoFitContain, true + case BrandLogoFitCover, BrandLogoFitRepeat, BrandLogoFitStretch: + return strings.TrimSpace(raw), true + default: + return "", false + } +} + +// NormalizeBrandLogoSize 图槽画幅。空串视为方标。图加文字和纯图共用。 +func NormalizeBrandLogoSize(raw string) (string, bool) { + switch strings.TrimSpace(raw) { + case "", BrandLogoSizeSQ: + return BrandLogoSizeSQ, true + case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169: + return strings.TrimSpace(raw), true + default: + return "", false + } +} + +// NormalizeBrandURL 校验品牌图 URL;空串表示清除。slot 决定是否允许 ico。 +func NormalizeBrandURL(slot, raw string) (string, bool) { + u := strings.ToLower(strings.TrimSpace(raw)) + if u == "" { + return "", true + } + switch slot { + case BrandSlotLight, BrandSlotDark: + if !brandLogoURLRe.MatchString(u) { + return "", false + } + case BrandSlotFavicon: + if !brandFaviconRe.MatchString(u) { + return "", false + } + default: + return "", false + } + return u, true +} + +// BrandRelPath 把合法品牌 URL 转成 uploads 根下的相对路径;非法返回空 +func BrandRelPath(url string) string { + u := strings.ToLower(strings.TrimSpace(url)) + if u == "" || !brandFileURLRe.MatchString(u) { + return "" + } + return strings.TrimPrefix(u, "/uploads/") +} + +// NormalizeKeywords 去空白、按逗号拆开、去重;超限返回 ErrInvalidSiteSetting +func NormalizeKeywords(raw []string) ([]string, error) { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, item := range raw { + parts := strings.FieldsFunc(item, func(r rune) bool { + return r == ',' || r == ',' || r == ';' || r == ';' + }) + if len(parts) == 0 { + parts = []string{item} + } + for _, p := range parts { + k := strings.TrimSpace(p) + if k == "" { + continue + } + if utf8.RuneCountInString(k) > MaxKeywordRunes { + return nil, ErrInvalidSiteSetting + } + key := strings.ToLower(k) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, k) + if len(out) > MaxKeywordCount { + return nil, ErrInvalidSiteSetting + } + } + } + return out, nil +} + +func keywordsToStore(list []string) string { + return strings.Join(list, ",") +} + +func keywordsFromStore(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + list, err := NormalizeKeywords([]string{raw}) + if err != nil || list == nil { + return []string{} + } + return list +} + +func isExternalFooterURL(u string) bool { + return footerHTTPURLRe.MatchString(u) +} + +// NormalizeFooterURL 站内路径或以 http(s) 开头;拒绝协议相对与脚本地址 +func NormalizeFooterURL(raw string) (string, bool) { + u := strings.TrimSpace(raw) + if u == "" || utf8.RuneCountInString(u) > MaxFooterURLRunes { + return "", false + } + if strings.ContainsAny(u, " \t\r\n\\") { + return "", false + } + lower := strings.ToLower(u) + if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") { + return "", false + } + if strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//") { + return u, true + } + if footerHTTPURLRe.MatchString(u) { + return u, true + } + return "", false +} + +type footerLinkIn struct { + Label string `json:"label"` + URL string `json:"url"` + NewTab *bool `json:"new_tab"` +} + +// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签 +func NormalizeFooterLinks(raw []FooterLink) ([]FooterLink, error) { + if len(raw) > MaxFooterLinks { + return nil, ErrInvalidSiteSetting + } + out := make([]FooterLink, 0, len(raw)) + for _, item := range raw { + label := strings.TrimSpace(item.Label) + if label == "" || utf8.RuneCountInString(label) > MaxFooterLabelRunes { + return nil, ErrInvalidSiteSetting + } + u, ok := NormalizeFooterURL(item.URL) + if !ok { + return nil, ErrInvalidSiteSetting + } + out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab}) + } + return out, nil +} + +func parseFooterLinksJSON(raw string) ([]FooterLink, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return []FooterLink{}, nil + } + var in []footerLinkIn + if err := json.Unmarshal([]byte(raw), &in); err != nil { + return []FooterLink{}, nil + } + if len(in) > MaxFooterLinks { + return []FooterLink{}, nil + } + out := make([]FooterLink, 0, len(in)) + for _, item := range in { + label := strings.TrimSpace(item.Label) + u, ok := NormalizeFooterURL(item.URL) + if label == "" || utf8.RuneCountInString(label) > MaxFooterLabelRunes || !ok { + continue + } + newTab := false + if item.NewTab != nil { + newTab = *item.NewTab + } else if isExternalFooterURL(u) { + newTab = true + } + out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab}) + } + return out, nil +} + +func (s *SettingService) fillBrand(out *PublicSiteSettings) error { + wm, found, err := s.getValue(SettingKeySiteWordmark) + if err != nil { + return err + } + if found { + out.SiteWordmark = strings.TrimSpace(wm) + } + slogan, found, err := s.getValue(SettingKeySiteSlogan) + if err != nil { + return err + } + if found { + out.SiteSlogan = strings.TrimSpace(slogan) + } + kw, found, err := s.getValue(SettingKeySiteKeywords) + if err != nil { + return err + } + if found { + out.SiteKeywords = keywordsFromStore(kw) + } + light, err := s.brandURL(SettingKeyLogoLightURL, BrandSlotLight) + if err != nil { + return err + } + out.LogoLightURL = light + dark, err := s.brandURL(SettingKeyLogoDarkURL, BrandSlotDark) + if err != nil { + return err + } + out.LogoDarkURL = dark + fav, err := s.brandURL(SettingKeyFaviconURL, BrandSlotFavicon) + if err != nil { + return err + } + out.FaviconURL = fav + mark, found, err := s.getValue(SettingKeyBrandMark) + if err != nil { + return err + } + if found { + if m, ok := NormalizeBrandMark(mark); ok { + out.BrandMark = m + } + } + size, found, err := s.getValue(SettingKeyBrandLogoSize) + if err != nil { + return err + } + if found { + if sz, ok := NormalizeBrandLogoSize(size); ok { + out.BrandLogoSize = sz + } + } + fit, found, err := s.getValue(SettingKeyBrandLogoFit) + if err != nil { + return err + } + if found { + if f, ok := NormalizeBrandLogoFit(fit); ok { + out.BrandLogoFit = f + } + } + rawLinks, found, err := s.getValue(SettingKeyFooterLinks) + if err != nil { + return err + } + if found { + links, perr := parseFooterLinksJSON(rawLinks) + if perr != nil { + return perr + } + out.FooterLinks = links + } + return nil +} + +func (s *SettingService) brandURL(key, slot string) (string, error) { + v, found, err := s.getValue(key) + if err != nil || !found { + return "", err + } + u, ok := NormalizeBrandURL(slot, v) + if !ok { + return "", nil + } + return u, nil +} + +func (s *SettingService) LogoLightURL() (string, error) { + return s.brandURL(SettingKeyLogoLightURL, BrandSlotLight) +} + +func (s *SettingService) LogoDarkURL() (string, error) { + return s.brandURL(SettingKeyLogoDarkURL, BrandSlotDark) +} + +func (s *SettingService) FaviconURL() (string, error) { + return s.brandURL(SettingKeyFaviconURL, BrandSlotFavicon) +} + +func (s *SettingService) SetSiteWordmark(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return s.deleteKey(SettingKeySiteWordmark) + } + if utf8.RuneCountInString(name) > MaxWordmarkRunes { + return ErrInvalidSiteSetting + } + return s.putValue(SettingKeySiteWordmark, name) +} + +func (s *SettingService) SetSiteSlogan(slogan string) error { + slogan = strings.TrimSpace(slogan) + if slogan == "" { + return s.deleteKey(SettingKeySiteSlogan) + } + if utf8.RuneCountInString(slogan) > MaxSloganRunes { + return ErrInvalidSiteSetting + } + return s.putValue(SettingKeySiteSlogan, slogan) +} + +func (s *SettingService) SetSiteKeywords(raw []string) error { + list, err := NormalizeKeywords(raw) + if err != nil { + return err + } + if len(list) == 0 { + return s.deleteKey(SettingKeySiteKeywords) + } + return s.putValue(SettingKeySiteKeywords, keywordsToStore(list)) +} + +func (s *SettingService) SetLogoLightURL(url string) error { + return s.setBrandURL(SettingKeyLogoLightURL, BrandSlotLight, url) +} + +func (s *SettingService) SetLogoDarkURL(url string) error { + return s.setBrandURL(SettingKeyLogoDarkURL, BrandSlotDark, url) +} + +func (s *SettingService) SetFaviconURL(url string) error { + return s.setBrandURL(SettingKeyFaviconURL, BrandSlotFavicon, url) +} + +func (s *SettingService) SetBrandMark(raw string) error { + v, ok := NormalizeBrandMark(raw) + if !ok { + return ErrInvalidSiteSetting + } + if v == BrandMarkImageText { + return s.deleteKey(SettingKeyBrandMark) + } + return s.putValue(SettingKeyBrandMark, v) +} + +func (s *SettingService) SetBrandLogoSize(raw string) error { + v, ok := NormalizeBrandLogoSize(raw) + if !ok { + return ErrInvalidSiteSetting + } + if v == BrandLogoSizeSQ { + return s.deleteKey(SettingKeyBrandLogoSize) + } + return s.putValue(SettingKeyBrandLogoSize, v) +} + +func (s *SettingService) SetBrandLogoFit(raw string) error { + v, ok := NormalizeBrandLogoFit(raw) + if !ok { + return ErrInvalidSiteSetting + } + if v == BrandLogoFitContain { + return s.deleteKey(SettingKeyBrandLogoFit) + } + return s.putValue(SettingKeyBrandLogoFit, v) +} + +func (s *SettingService) setBrandURL(key, slot, url string) error { + u, ok := NormalizeBrandURL(slot, url) + if !ok { + return ErrInvalidSiteSetting + } + if u == "" { + return s.deleteKey(key) + } + return s.putValue(key, u) +} + +func (s *SettingService) SetFooterLinks(raw []FooterLink) error { + list, err := NormalizeFooterLinks(raw) + if err != nil { + return err + } + if len(list) == 0 { + return s.deleteKey(SettingKeyFooterLinks) + } + // 外链未勾选时仍尊重调用方;新增行的默认新开由前端给出 + b, err := json.Marshal(list) + if err != nil { + return err + } + return s.putValue(SettingKeyFooterLinks, string(b)) +} diff --git a/backend/service/brand_store.go b/backend/service/brand_store.go new file mode 100644 index 0000000..217e2e9 --- /dev/null +++ b/backend/service/brand_store.go @@ -0,0 +1,187 @@ +package service + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/freefire/jiang13-bbs/model" +) + +const ( + BrandLogoMaxBytes = 2 << 20 // 2 MiB + BrandFaviconMaxBytes = 512 << 10 // 512 KiB + brandMaxDim = 2048 +) + +// ErrBrandFileMissing 设置了合法 URL 但磁盘上没有对应文件 +var ErrBrandFileMissing = errors.New("品牌图文件不存在") + +type brandFormat struct { + ext string + mime string + svg bool + ico bool +} + +func brandMaxBytes(slot string) int64 { + if slot == BrandSlotFavicon { + return BrandFaviconMaxBytes + } + return BrandLogoMaxBytes +} + +func detectBrandFormat(data []byte, slot string) (brandFormat, error) { + logo := slot != BrandSlotFavicon + too := "仅支持 PNG / WebP / SVG" + if !logo { + too = "仅支持 PNG / WebP / SVG / ICO" + } + fail := errors.New(too) + if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" { + return brandFormat{ext: ".png", mime: "image/png"}, nil + } + if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return brandFormat{ext: ".webp", mime: "image/webp"}, nil + } + if !logo && len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 { + return brandFormat{ext: ".ico", mime: "image/x-icon", ico: true}, nil + } + if looksLikeSVG(data) { + return brandFormat{ext: ".svg", mime: "image/svg+xml", svg: true}, nil + } + return brandFormat{}, fail +} + +func looksLikeSVG(data []byte) bool { + s := strings.ToLower(string(data)) + if !strings.Contains(s, " maxB { + if slot == BrandSlotFavicon { + return "", errors.New("Favicon 不能超过 512KB") + } + return "", errors.New("Logo 不能超过 2MB") + } + format, err := detectBrandFormat(data, slot) + if err != nil { + return "", err + } + if !format.svg && !format.ico { + w, h, derr := decodeImageSizeReader(bytes.NewReader(data), format.mime) + if derr != nil { + return "", derr + } + if w < 1 || h < 1 || w > brandMaxDim || h > brandMaxDim { + return "", errors.New("图片尺寸超出限制") + } + } + + if err := os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755); err != nil { + return "", err + } + nameBytes := make([]byte, 16) + if _, err := rand.Read(nameBytes); err != nil { + return "", err + } + filename := hex.EncodeToString(nameBytes) + format.ext + fullPath := filepath.Join(s.dir, "brand", filename) + if err := os.WriteFile(fullPath, data, 0o644); err != nil { + return "", err + } + return "/uploads/brand/" + filename, nil +} + +// CopyBrandFromMedia 把当前用户媒体库里的一张图复制进品牌目录,不写站点设置 +func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot string) (string, error) { + if _, ok := NormalizeBrandSlot(slot); !ok { + return "", errors.New("无效的品牌图位置") + } + var att model.Attachment + if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil { + return "", errors.New("图片不存在或不属于你") + } + abs, ok := s.safeUploadPath(att.URL) + if !ok { + return "", errors.New("无效的图片地址") + } + f, err := os.Open(abs) + if err != nil { + return "", errors.New("读取图片失败") + } + defer f.Close() + return s.SaveBrand(slot, f) +} + +func (s *UploadService) safeUploadPath(url string) (string, bool) { + rel := strings.TrimPrefix(strings.TrimSpace(url), "/uploads/") + if rel == "" || rel == url || strings.Contains(rel, "..") { + return "", false + } + abs := filepath.Join(s.dir, filepath.FromSlash(rel)) + root := filepath.Clean(s.dir) + clean := filepath.Clean(abs) + if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return "", false + } + return clean, true +} + +// BrandFileExists 确认 URL 对应文件在 brand 目录内 +func (s *UploadService) BrandFileExists(url string) bool { + rel := BrandRelPath(url) + if rel == "" { + return false + } + abs := filepath.Join(s.dir, filepath.FromSlash(rel)) + info, err := os.Stat(abs) + return err == nil && !info.IsDir() +} + +// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除 +func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) { + if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon { + return + } + rel := BrandRelPath(oldURL) + if rel == "" { + return + } + abs := filepath.Join(s.dir, filepath.FromSlash(rel)) + if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { + log.Printf("[upload] 删除品牌图失败 path=%s: %v", abs, err) + } +} diff --git a/backend/service/brand_test.go b/backend/service/brand_test.go new file mode 100644 index 0000000..4011f67 --- /dev/null +++ b/backend/service/brand_test.go @@ -0,0 +1,121 @@ +package service + +import "testing" + +func TestNormalizeBrandURL(t *testing.T) { + png := "/uploads/brand/0123456789abcdef0123456789abcdef.png" + got, ok := NormalizeBrandURL(BrandSlotLight, png) + if !ok || got != png { + t.Fatalf("logo png want %s got %q ok=%v", png, got, ok) + } + ico := "/uploads/brand/0123456789abcdef0123456789abcdef.ico" + if _, ok := NormalizeBrandURL(BrandSlotLight, ico); ok { + t.Fatal("logo should reject ico") + } + got, ok = NormalizeBrandURL(BrandSlotFavicon, ico) + if !ok || got != ico { + t.Fatalf("favicon ico want %s got %q ok=%v", ico, got, ok) + } + empty, ok := NormalizeBrandURL(BrandSlotDark, " ") + if !ok || empty != "" { + t.Fatalf("empty should clear, got %q ok=%v", empty, ok) + } + rejects := []string{ + "http://evil.test/x.png", + "/uploads/brand/../avatars/x.png", + "/uploads/backgrounds/0123456789abcdef0123456789abcdef.png", + "/uploads/brand/short.png", + "javascript:alert(1)", + } + for _, u := range rejects { + if _, ok := NormalizeBrandURL(BrandSlotFavicon, u); ok { + t.Fatalf("should reject %q", u) + } + } +} + +func TestNormalizeKeywords(t *testing.T) { + got, err := NormalizeKeywords([]string{" Go , gin ", "论坛,Go"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0] != "Go" || got[1] != "gin" || got[2] != "论坛" { + t.Fatalf("unexpected %v", got) + } + long := "" + for i := 0; i < MaxKeywordRunes+1; i++ { + long += "字" + } + if _, err := NormalizeKeywords([]string{long}); err == nil { + t.Fatal("overlong keyword should fail") + } + many := make([]string, MaxKeywordCount+1) + for i := range many { + many[i] = string(rune('a' + i)) + } + if _, err := NormalizeKeywords(many); err == nil { + t.Fatal("too many keywords should fail") + } +} + +func TestNormalizeFooterLinks(t *testing.T) { + got, err := NormalizeFooterLinks([]FooterLink{ + {Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true}, + {Label: "关于", URL: "/about", NewTab: false}, + }) + if err != nil || len(got) != 2 || got[1].URL != "/about" { + t.Fatalf("got %+v err=%v", got, err) + } + bad := []FooterLink{ + {Label: "x", URL: "javascript:alert(1)"}, + {Label: "x", URL: "//evil.test"}, + {Label: "", URL: "/a"}, + {Label: "x", URL: "ftp://files.test/a"}, + } + for _, item := range bad { + if _, err := NormalizeFooterLinks([]FooterLink{item}); err == nil { + t.Fatalf("should reject %+v", item) + } + } + raw := `[{"label":"ICP","url":"https://beian.miit.gov.cn/"}]` + parsed, err := parseFooterLinksJSON(raw) + if err != nil || len(parsed) != 1 || !parsed[0].NewTab { + t.Fatalf("external default new tab, got %+v err=%v", parsed, err) + } +} + +func TestNormalizeBrandMarkAndSize(t *testing.T) { + mark, ok := NormalizeBrandMark(" image ") + if !ok || mark != BrandMarkImage { + t.Fatalf("mark got %q ok=%v", mark, ok) + } + mark, ok = NormalizeBrandMark("") + if !ok || mark != BrandMarkImageText { + t.Fatalf("empty mark should default, got %q", mark) + } + if _, ok := NormalizeBrandMark("banner"); ok { + t.Fatal("unknown mark should fail") + } + size, ok := NormalizeBrandLogoSize("4x1") + if !ok || size != BrandLogoSize4x1 { + t.Fatalf("size got %q", size) + } + size, ok = NormalizeBrandLogoSize("") + if !ok || size != BrandLogoSizeSQ { + t.Fatalf("empty size should default, got %q", size) + } + if _, ok := NormalizeBrandLogoSize("xl"); ok { + t.Fatal("old height preset should fail") + } + fit, ok := NormalizeBrandLogoFit("repeat") + if !ok || fit != BrandLogoFitRepeat { + t.Fatalf("fit got %q", fit) + } + fit, ok = NormalizeBrandLogoFit("") + if !ok || fit != BrandLogoFitContain { + t.Fatalf("empty fit should default, got %q", fit) + } + if _, ok := NormalizeBrandLogoFit("zoom"); ok { + t.Fatal("unknown fit should fail") + } +} diff --git a/backend/service/setting.go b/backend/service/setting.go index d85dccc..5e6e2ba 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -95,30 +95,40 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示) type PublicSiteSettings struct { - Accent string `json:"accent"` - TrustReviewedPublish bool `json:"trust_reviewed_publish"` - SiteName string `json:"site_name"` - SiteDescription string `json:"site_description"` - AllowRegister bool `json:"allow_register"` - AllowComments bool `json:"allow_comments"` - AllowMessages bool `json:"allow_messages"` - PostCooldownHours int `json:"post_cooldown_hours"` - CodeBlockAutoFold bool `json:"code_block_auto_fold"` - CodeBlockFoldLines int `json:"code_block_fold_lines"` - UIAnimations bool `json:"ui_animations"` - AnimCodeFold bool `json:"anim_code_fold"` - AnimSmoothScroll bool `json:"anim_smooth_scroll"` - AnimChrome bool `json:"anim_chrome"` - PostLinkNewTab bool `json:"post_link_new_tab"` - AttachmentExtLimit bool `json:"attachment_ext_limit"` - AttachmentExts []string `json:"attachment_exts"` - AttachmentMaxMB int `json:"attachment_max_mb"` - AttachmentMaxCount int `json:"attachment_max_count"` - ImageMaxMB int `json:"image_max_mb"` - BgSiteURL string `json:"bg_site_url"` - BgSiteMode string `json:"bg_site_mode"` - BgAdminURL string `json:"bg_admin_url"` - BgAdminMode string `json:"bg_admin_mode"` + Accent string `json:"accent"` + TrustReviewedPublish bool `json:"trust_reviewed_publish"` + SiteName string `json:"site_name"` + SiteDescription string `json:"site_description"` + AllowRegister bool `json:"allow_register"` + AllowComments bool `json:"allow_comments"` + AllowMessages bool `json:"allow_messages"` + PostCooldownHours int `json:"post_cooldown_hours"` + CodeBlockAutoFold bool `json:"code_block_auto_fold"` + CodeBlockFoldLines int `json:"code_block_fold_lines"` + UIAnimations bool `json:"ui_animations"` + AnimCodeFold bool `json:"anim_code_fold"` + AnimSmoothScroll bool `json:"anim_smooth_scroll"` + AnimChrome bool `json:"anim_chrome"` + PostLinkNewTab bool `json:"post_link_new_tab"` + AttachmentExtLimit bool `json:"attachment_ext_limit"` + AttachmentExts []string `json:"attachment_exts"` + AttachmentMaxMB int `json:"attachment_max_mb"` + AttachmentMaxCount int `json:"attachment_max_count"` + ImageMaxMB int `json:"image_max_mb"` + BgSiteURL string `json:"bg_site_url"` + BgSiteMode string `json:"bg_site_mode"` + BgAdminURL string `json:"bg_admin_url"` + BgAdminMode string `json:"bg_admin_mode"` + SiteWordmark string `json:"site_wordmark"` + SiteSlogan string `json:"site_slogan"` + SiteKeywords []string `json:"site_keywords"` + LogoLightURL string `json:"logo_light_url"` + LogoDarkURL string `json:"logo_dark_url"` + FaviconURL string `json:"favicon_url"` + BrandMark string `json:"brand_mark"` + BrandLogoSize string `json:"brand_logo_size"` + BrandLogoFit string `json:"brand_logo_fit"` + FooterLinks []FooterLink `json:"footer_links"` } // SettingService 站点级键值设置 @@ -195,6 +205,11 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { ImageMaxMB: DefaultImageMaxMB, BgSiteMode: DefaultBgMode, BgAdminMode: DefaultBgMode, + SiteKeywords: []string{}, + BrandMark: BrandMarkImageText, + BrandLogoSize: BrandLogoSizeSQ, + BrandLogoFit: BrandLogoFitContain, + FooterLinks: []FooterLink{}, } accent, err := s.AccentColor() if err != nil { @@ -343,6 +358,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.BgAdminMode = adminMode + if err := s.fillBrand(&out); err != nil { + return out, err + } return out, nil } diff --git a/backend/service/upload.go b/backend/service/upload.go index 2f8018b..84ec79a 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -71,7 +71,10 @@ func (s *UploadService) EnsureDir() error { if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil { return err } - return os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755) + if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil { + return err + } + return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755) } // SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像 diff --git a/frontend/app/admin/settings/BasicIdentityPanel.tsx b/frontend/app/admin/settings/BasicIdentityPanel.tsx new file mode 100644 index 0000000..a764a2e --- /dev/null +++ b/frontend/app/admin/settings/BasicIdentityPanel.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Loader2, Save } from "lucide-react"; +import { useState } from "react"; +import { AdminField, AdminSection } from "@/components/admin"; +import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; +import { BRAND_LIMITS, siteDocumentTitle } from "@/lib/brand"; +import { toast } from "@/lib/toast"; + +export default function BasicIdentityPanel({ + initial, + onSaved, +}: { + initial: PublicSettings; + onSaved?: (next: PublicSettings) => void; +}) { + const [name, setName] = useState(initial.site_name); + const [wordmark, setWordmark] = useState(initial.site_wordmark); + const [slogan, setSlogan] = useState(initial.site_slogan); + const [desc, setDesc] = useState(initial.site_description); + const [saved, setSaved] = useState({ + name: initial.site_name, + wordmark: initial.site_wordmark, + slogan: initial.site_slogan, + desc: initial.site_description, + }); + const [saving, setSaving] = useState(false); + + const dirty = + name.trim() !== saved.name || + wordmark.trim() !== saved.wordmark || + slogan.trim() !== saved.slogan || + desc.trim() !== saved.desc; + + const preview = siteDocumentTitle(name, slogan); + + const save = async () => { + if (saving) return; + const nextName = name.trim(); + if (!nextName) { + toast("显示名称不能为空"); + return; + } + setSaving(true); + try { + const res = await apiUpdateSiteSettings({ + site_name: nextName, + site_wordmark: wordmark.trim(), + site_slogan: slogan.trim(), + site_description: desc.trim(), + }); + setName(res.site_name); + setWordmark(res.site_wordmark); + setSlogan(res.site_slogan); + setDesc(res.site_description); + setSaved({ + name: res.site_name, + wordmark: res.site_wordmark, + slogan: res.site_slogan, + desc: res.site_description, + }); + onSaved?.(res); + toast("基本信息已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + return ( +
+ +
+ + +
+
+ void save()} + > + {saving ? : } + 保存 + + } + > +
+ +

+ 标题预览:{preview} +

+ +
+
+
+ ); +} diff --git a/frontend/app/admin/settings/BrandMediaPicker.tsx b/frontend/app/admin/settings/BrandMediaPicker.tsx new file mode 100644 index 0000000..97fb0b9 --- /dev/null +++ b/frontend/app/admin/settings/BrandMediaPicker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Images, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import Modal from "@/components/Modal"; +import { apiMyMedia, type MediaAttachment } from "@/lib/api"; +import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand"; + +const SLOT_LABEL: Record = { + logo_light: "浅色 Logo", + logo_dark: "暗色 Logo", + favicon: "Favicon", +}; + +export default function BrandMediaPicker({ + open, + slot, + onClose, + onPick, +}: { + open: boolean; + slot: BrandSlot; + onClose: () => void; + onPick: (item: MediaAttachment) => void; +}) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setError(""); + void apiMyMedia() + .then((res) => { + if (cancelled) return; + if (res.error) { + setError(res.error); + setItems([]); + return; + } + const all = res.attachments || []; + setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url))); + }) + .catch(() => { + if (!cancelled) setError("加载媒体库失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, slot]); + + return ( + + {loading ? ( +

+ 加载中 +

+ ) : error ? ( +

+ {error} +

+ ) : items.length === 0 ? ( +

没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/admin/settings/BrandSeoPanel.tsx b/frontend/app/admin/settings/BrandSeoPanel.tsx new file mode 100644 index 0000000..711b043 --- /dev/null +++ b/frontend/app/admin/settings/BrandSeoPanel.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { ImagePlus, Images, Loader2, Save, X } from "lucide-react"; +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { AdminSection, AdminSegmented } from "@/components/admin"; +import BrandLockup from "@/components/BrandLockup"; +import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; +import { + BRAND_LIMITS, + BRAND_LOGO_FITS, + BRAND_LOGO_SIZES, + brandAccept, + brandFileOk, + runeCount, + type BrandLogoFit, + type BrandLogoSize, + type BrandMark, + type BrandSlot, +} from "@/lib/brand"; +import { toast } from "@/lib/toast"; +import BrandMediaPicker from "./BrandMediaPicker"; + +type SlotDraft = { + committed: string; + file: File | null; + blob: string; + mediaId: number | null; + mediaUrl: string; +}; + +function emptyDraft(committed: string): SlotDraft { + return { committed, file: null, blob: "", mediaId: null, mediaUrl: "" }; +} + +function previewOf(d: SlotDraft): string { + return d.blob || d.mediaUrl || d.committed; +} + +function slotDirty(d: SlotDraft, saved: string): boolean { + if (d.file || d.mediaId != null) return true; + return d.committed !== saved; +} + +const SLOTS: { id: BrandSlot; label: string; hint: string }[] = [ + { id: "logo_light", label: "浅色 Logo", hint: "浅色主题。只传一套时,暗色共用这张。" }, + { id: "logo_dark", label: "暗色 Logo", hint: "暗色主题。PNG / WebP / SVG,不超过 2MB。" }, + { id: "favicon", label: "Favicon", hint: "标签图标。另支持 ICO,不超过 512KB。" }, +]; + +export default function BrandSeoPanel({ + initial, + onSaved, +}: { + initial: PublicSettings; + onSaved?: (next: PublicSettings) => void; +}) { + const [light, setLight] = useState(() => emptyDraft(initial.logo_light_url)); + const [dark, setDark] = useState(() => emptyDraft(initial.logo_dark_url)); + const [favicon, setFavicon] = useState(() => emptyDraft(initial.favicon_url)); + const [saved, setSaved] = useState({ + light: initial.logo_light_url, + dark: initial.logo_dark_url, + favicon: initial.favicon_url, + keywords: initial.site_keywords, + mark: initial.brand_mark, + size: initial.brand_logo_size, + fit: initial.brand_logo_fit, + }); + const [keywords, setKeywords] = useState(initial.site_keywords); + const [mark, setMark] = useState(initial.brand_mark); + const [logoSize, setLogoSize] = useState(initial.brand_logo_size); + const [logoFit, setLogoFit] = useState(initial.brand_logo_fit); + const [kwInput, setKwInput] = useState(""); + const [saving, setSaving] = useState(false); + const [picker, setPicker] = useState(null); + const blobs = useRef([]); + + useEffect(() => { + const held = blobs; + return () => { + held.current.forEach((u) => URL.revokeObjectURL(u)); + }; + }, []); + + const drafts: Record>]> = { + logo_light: [light, setLight], + logo_dark: [dark, setDark], + favicon: [favicon, setFavicon], + }; + + const replaceFile = (slot: BrandSlot, file: File | null) => { + const [, set] = drafts[slot]; + set((prev) => { + if (prev.blob) URL.revokeObjectURL(prev.blob); + const blob = file ? URL.createObjectURL(file) : ""; + if (blob) blobs.current.push(blob); + return { ...prev, file, blob, mediaId: null, mediaUrl: "" }; + }); + }; + + const keywordsDirty = JSON.stringify(keywords) !== JSON.stringify(saved.keywords); + const dirty = + slotDirty(light, saved.light) || + slotDirty(dark, saved.dark) || + slotDirty(favicon, saved.favicon) || + keywordsDirty || + mark !== saved.mark || + logoSize !== saved.size || + logoFit !== saved.fit; + + const addKeyword = (raw: string) => { + const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean); + if (parts.length === 0) return; + setKeywords((prev) => { + const next = [...prev]; + const seen = new Set(next.map((k) => k.toLowerCase())); + for (const k of parts) { + if (runeCount(k) > BRAND_LIMITS.keywordRunes) { + toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`); + continue; + } + if (seen.has(k.toLowerCase())) continue; + if (next.length >= BRAND_LIMITS.keywordCount) { + toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`); + break; + } + seen.add(k.toLowerCase()); + next.push(k); + } + return next; + }); + setKwInput(""); + }; + + const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise => { + if (draft.file) { + const url = await apiUploadBrand(slot, draft.file); + const [, set] = drafts[slot]; + set((prev) => { + if (prev.blob) URL.revokeObjectURL(prev.blob); + return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" }; + }); + return url; + } + if (draft.mediaId != null) { + const url = await apiBrandFromMedia(slot, draft.mediaId); + const [, set] = drafts[slot]; + set({ committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" }); + return url; + } + return draft.committed; + }; + + const save = async () => { + if (saving || !dirty) return; + setSaving(true); + try { + const logoLight = await resolveSlot("logo_light", light); + const logoDark = await resolveSlot("logo_dark", dark); + const fav = await resolveSlot("favicon", favicon); + const res = await apiUpdateSiteSettings({ + logo_light_url: logoLight, + logo_dark_url: logoDark, + favicon_url: fav, + site_keywords: keywords, + brand_mark: mark, + brand_logo_size: logoSize, + brand_logo_fit: logoFit, + }); + setLight(emptyDraft(res.logo_light_url)); + setDark(emptyDraft(res.logo_dark_url)); + setFavicon(emptyDraft(res.favicon_url)); + setKeywords(res.site_keywords); + setMark(res.brand_mark); + setLogoSize(res.brand_logo_size); + setLogoFit(res.brand_logo_fit); + setSaved({ + light: res.logo_light_url, + dark: res.logo_dark_url, + favicon: res.favicon_url, + keywords: res.site_keywords, + mark: res.brand_mark, + size: res.brand_logo_size, + fit: res.brand_logo_fit, + }); + onSaved?.(res); + toast("品牌已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + const renderSlot = (slot: BrandSlot, label: string, hint: string, draft: SlotDraft) => { + const preview = previewOf(draft); + return ( +
+

+ {label} +

+

{hint}

+
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + 未设置 + )} +
+
+ + + +
+
+ ); + }; + + return ( + void save()}> + {saving ? : } + 保存品牌 + + } + > +
+ +
+
+ +
+
+ {mark !== "text" ? ( + <> +
+ {BRAND_LOGO_SIZES.map((item) => { + const on = logoSize === item.id; + const thumbH = 16; + const thumbW = Math.round((item.w / item.h) * thumbH); + return ( + + ); + })} +
+ ({ key: item.id, label: item.label }))} + /> + + ) : ( +

纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。

+ )} +
+
+ {SLOTS.map((s) => + renderSlot( + s.id, + s.label, + s.hint, + s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light + ) + )} +
+

+ SEO 关键词 +

+

最多 16 个,每个 20 字。点芯片可删除。

+
+ {keywords.map((k) => ( + + ))} + {keywords.length === 0 ? 尚未添加 : null} +
+
+ setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "," || e.key === ",") { + e.preventDefault(); + addKeyword(kwInput); + } + }} + placeholder="输入后回车添加" + className="flex-1 min-w-0 px-3 py-2 text-sm rounded-lg" + style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }} + aria-label="添加关键词" + /> + +
+

{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}

+
+
+ setPicker(null)} + onPick={(item) => { + if (!picker) return; + const [prev, set] = drafts[picker]; + if (prev.blob) URL.revokeObjectURL(prev.blob); + set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url }); + }} + /> +
+ ); +} diff --git a/frontend/app/admin/settings/FooterLinksPanel.tsx b/frontend/app/admin/settings/FooterLinksPanel.tsx new file mode 100644 index 0000000..f70f52e --- /dev/null +++ b/frontend/app/admin/settings/FooterLinksPanel.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { AdminField, AdminSection } from "@/components/admin"; +import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; +import { + BRAND_LIMITS, + footerURLAllowed, + isExternalFooterURL, + type FooterLink, +} from "@/lib/brand"; +import { toast } from "@/lib/toast"; + +function clone(links: FooterLink[]): FooterLink[] { + return links.map((l) => ({ ...l })); +} + +export default function FooterLinksPanel({ + initial, + onSaved, +}: { + initial: PublicSettings; + onSaved?: (next: PublicSettings) => void; +}) { + const [links, setLinks] = useState(() => clone(initial.footer_links)); + const [saved, setSaved] = useState(() => clone(initial.footer_links)); + const [saving, setSaving] = useState(false); + + const dirty = JSON.stringify(links) !== JSON.stringify(saved); + + const update = (i: number, patch: Partial) => { + setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item))); + }; + + const save = async () => { + if (saving || !dirty) return; + for (const item of links) { + if (!item.label.trim()) { + toast("链接文案不能为空"); + return; + } + if (!footerURLAllowed(item.url)) { + toast(`地址无效:${item.url || "(空)"}`); + return; + } + } + setSaving(true); + try { + const payload = links.map((item) => ({ + label: item.label.trim(), + url: item.url.trim(), + new_tab: item.new_tab, + })); + const res = await apiUpdateSiteSettings({ footer_links: payload }); + setLinks(clone(res.footer_links)); + setSaved(clone(res.footer_links)); + onSaved?.(res); + toast("页脚链接已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + const move = (i: number, dir: -1 | 1) => { + setLinks((prev) => { + const j = i + dir; + if (j < 0 || j >= prev.length) return prev; + const next = [...prev]; + const [row] = next.splice(i, 1); + next.splice(j, 0, row); + return next; + }); + }; + + return ( +
+ void save()}> + {saving ? : } + 保存 + + } + > +
+ {links.map((item, i) => ( +
+
+ 第 {i + 1} 条 +
+ + + +
+
+ update(i, { label: v })} + placeholder="京 ICP 备xxxxxxxx号" + /> + { + const wasExt = isExternalFooterURL(item.url); + const nowExt = isExternalFooterURL(v); + update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab }); + }} + placeholder="https://beian.miit.gov.cn/ 或 /about" + /> + +
+ ))} + +
+
+ +
+ © {new Date().getFullYear()} {initial.site_name} + {links.filter((l) => l.label.trim() && l.url.trim()).map((item) => ( + + {item.label.trim()} + {item.new_tab ? " ↗" : ""} + + ))} + 性能指标 +
+

外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。

+
+
+ ); +} diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index 654a421..0a9932b 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -13,7 +13,6 @@ import { HardDrive, Ban, Wrench, - Palette, FileText, Paperclip, Image as ImageIcon, @@ -21,7 +20,7 @@ import { RotateCcw, CheckCircle2, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { AdminExtChipEditor, AdminField, @@ -32,6 +31,9 @@ import { } from "@/components/admin"; import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; +import BasicIdentityPanel from "./BasicIdentityPanel"; +import BrandSeoPanel from "./BrandSeoPanel"; +import FooterLinksPanel from "./FooterLinksPanel"; import ConfirmDialog from "@/components/ConfirmDialog"; function extsEqual(a: string[], b: string[]) { @@ -85,8 +87,6 @@ type SectionId = | "basic" | "content" | "gitimport" - | "brand" - | "footer" | "security" | "mail" | "storage" @@ -97,8 +97,6 @@ const NAV: { id: SectionId; label: string; ready?: boolean }[] = [ { id: "basic", label: "基本信息", ready: true }, { id: "content", label: "内容与互动", ready: true }, { id: "gitimport", label: "提交导入", ready: true }, - { id: "brand", label: "品牌与 SEO" }, - { id: "footer", label: "页脚与合规" }, { id: "security", label: "安全与限流" }, { id: "mail", label: "邮件" }, { id: "storage", label: "对象存储" }, @@ -119,14 +117,14 @@ function SwitchRow({ }) { return (
-

+

{title}

-

{description}

+

{description}

{control}
@@ -145,7 +143,7 @@ function ComingSoonCard({ return (
@@ -160,21 +158,33 @@ function ComingSoonCard({ ); } +function ComingSoonPair({ + left, + right, +}: { + left: { title: string; description: string; icon: ReactNode }; + right: { title: string; description: string; icon: ReactNode }; +}) { + return ( +
+ + +
+ ); +} + export default function SettingsAdmin({ initial }: { initial: PublicSettings }) { - const [siteName, setSiteName] = useState(initial.site_name); - const [siteDescription, setSiteDescription] = useState(initial.site_description); + const [live, setLive] = useState(initial); const [allowRegister, setAllowRegister] = useState(initial.allow_register); const [allowComments, setAllowComments] = useState(initial.allow_comments !== false); const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false); const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false); - const [savedName, setSavedName] = useState(initial.site_name); - const [savedDesc, setSavedDesc] = useState(initial.site_description); - const [saving, setSaving] = useState(false); const [togglingReg, setTogglingReg] = useState(false); const [togglingComments, setTogglingComments] = useState(false); const [togglingMessages, setTogglingMessages] = useState(false); const [togglingLink, setTogglingLink] = useState(false); const [active, setActive] = useState("basic"); + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false); const [exts, setExts] = useState(() => [...(initial.attachment_exts || [])]); @@ -194,9 +204,31 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) const [restoreGitOpen, setRestoreGitOpen] = useState(false); const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null); - const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]); const gitImportDirty = gitImportJSON !== savedGitImport; + const gitPreview = useMemo(() => { + try { + const parsed = JSON.parse(gitImportJSON) as { + max_pages?: unknown; + max_commits?: unknown; + sources?: unknown; + }; + if (!Array.isArray(parsed.sources)) return null; + const sources = parsed.sources + .filter((s): s is Record => !!s && typeof s === "object") + .map((s) => ({ + id: typeof s.id === "string" ? s.id : "—", + host: typeof s.host === "string" ? s.host : "*", + })); + return { + maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null, + maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null, + sources, + }; + } catch { + return null; + } + }, [gitImportJSON]); const loadGitImport = async () => { if (gitImportLoading) return; @@ -227,35 +259,28 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - const ids = NAV.map((n) => n.id); - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((e) => e.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - if (visible[0]?.target?.id) { - const id = visible[0].target.id.replace(/^settings-/, "") as SectionId; - if (ids.includes(id)) setActive(id); - } - }, - { rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] } - ); - ids.forEach((id) => { - const el = document.getElementById(`settings-${id}`); - if (el) observer.observe(el); - }); - return () => observer.disconnect(); - }, []); - - const scrollTo = (id: SectionId) => { - setActive(id); - document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); + const selectTab = (id: SectionId) => { + if (id !== active) { + setActive(id); + document.getElementById("main")?.scrollTo({ top: 0 }); + } if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) { void loadGitImport(); } }; + const onTabKeyDown = (e: KeyboardEvent, i: number) => { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return; + e.preventDefault(); + let next = i; + if (e.key === "ArrowRight") next = (i + 1) % NAV.length; + else if (e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length; + else if (e.key === "Home") next = 0; + else next = NAV.length - 1; + selectTab(NAV[next].id); + tabRefs.current[next]?.focus(); + }; + const validateGitImportClient = (): boolean => { try { JSON.parse(gitImportJSON); @@ -320,31 +345,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; - const saveIdentity = async () => { - if (saving) return; - const name = siteName.trim(); - if (!name) { - toast("站点名称不能为空"); - return; - } - setSaving(true); - try { - const res = await apiUpdateSiteSettings({ - site_name: name, - site_description: siteDescription.trim(), - }); - setSiteName(res.site_name); - setSiteDescription(res.site_description); - setSavedName(res.site_name); - setSavedDesc(res.site_description); - toast("站点信息已保存", "ok"); - } catch (e) { - toast(e instanceof Error ? e.message : "保存失败"); - } finally { - setSaving(false); - } - }; - const toggleRegister = async () => { if (togglingReg) return; const next = !allowRegister; @@ -479,80 +479,55 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) -
- +
+ {NAV.map((item, i) => { + const isActive = active === item.id; + return ( + + ); + })} +
-
-
- - - - - -
- -
-
+
+ {active === "basic" && ( +
+ + +
+ )} -
+ {active === "content" && ( +
+ )} -
+ {active === "gitimport" && ( +
-

- 通过 JSON 描述 host、路径正则与 API 字段映射;服务端只按规则拉取,不会执行任意代码。 -

- {gitImportLoading && !gitImportLoaded ? (
加载适配器…
) : ( <> -