后台站点设计UIUX优化
This commit is contained in:
@@ -21,31 +21,41 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
|||||||
|
|
||||||
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
|
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
|
||||||
type updateSettingsRequest struct {
|
type updateSettingsRequest struct {
|
||||||
Accent *string `json:"accent"`
|
Accent *string `json:"accent"`
|
||||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||||
SiteName *string `json:"site_name"`
|
SiteName *string `json:"site_name"`
|
||||||
SiteDescription *string `json:"site_description"`
|
SiteDescription *string `json:"site_description"`
|
||||||
AllowRegister *bool `json:"allow_register"`
|
AllowRegister *bool `json:"allow_register"`
|
||||||
AllowComments *bool `json:"allow_comments"`
|
AllowComments *bool `json:"allow_comments"`
|
||||||
AllowMessages *bool `json:"allow_messages"`
|
AllowMessages *bool `json:"allow_messages"`
|
||||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||||
UIAnimations *bool `json:"ui_animations"`
|
UIAnimations *bool `json:"ui_animations"`
|
||||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||||
AnimChrome *bool `json:"anim_chrome"`
|
AnimChrome *bool `json:"anim_chrome"`
|
||||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||||
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
|
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
|
||||||
AttachmentExts *[]string `json:"attachment_exts"`
|
AttachmentExts *[]string `json:"attachment_exts"`
|
||||||
AttachmentMaxMB *int `json:"attachment_max_mb"`
|
AttachmentMaxMB *int `json:"attachment_max_mb"`
|
||||||
AttachmentMaxCount *int `json:"attachment_max_count"`
|
AttachmentMaxCount *int `json:"attachment_max_count"`
|
||||||
ImageMaxMB *int `json:"image_max_mb"`
|
ImageMaxMB *int `json:"image_max_mb"`
|
||||||
BgSiteURL *string `json:"bg_site_url"`
|
BgSiteURL *string `json:"bg_site_url"`
|
||||||
BgSiteMode *string `json:"bg_site_mode"`
|
BgSiteMode *string `json:"bg_site_mode"`
|
||||||
BgAdminURL *string `json:"bg_admin_url"`
|
BgAdminURL *string `json:"bg_admin_url"`
|
||||||
BgAdminMode *string `json:"bg_admin_mode"`
|
BgAdminMode *string `json:"bg_admin_mode"`
|
||||||
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
|
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 {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -74,6 +84,16 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"bg_site_mode": saved.BgSiteMode,
|
"bg_site_mode": saved.BgSiteMode,
|
||||||
"bg_admin_url": saved.BgAdminURL,
|
"bg_admin_url": saved.BgAdminURL,
|
||||||
"bg_admin_mode": saved.BgAdminMode,
|
"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.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
||||||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
|
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
|
||||||
req.BgSiteURL != nil || req.BgSiteMode != 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)
|
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
|
||||||
@@ -306,6 +330,94 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
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
|
var savedAdapter string
|
||||||
if req.TimelineGitImport != nil {
|
if req.TimelineGitImport != nil {
|
||||||
@@ -378,3 +490,61 @@ func (h *Handlers) applyBgURL(surface, url string) error {
|
|||||||
h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin)
|
h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,3 +230,82 @@ func (h *Handlers) UploadBackground(c *gin.Context) {
|
|||||||
out["url"] = url
|
out["url"] = url
|
||||||
c.JSON(http.StatusOK, out)
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
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/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))
|
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
|
||||||
|
|||||||
479
backend/service/brand.go
Normal file
479
backend/service/brand.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
187
backend/service/brand_store.go
Normal file
187
backend/service/brand_store.go
Normal file
@@ -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, "<svg") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "<script") || strings.Contains(s, "javascript:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "onload=") || strings.Contains(s, "onerror=") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBrand 把品牌图落到 uploads/brand,不写站点设置
|
||||||
|
func (s *UploadService) SaveBrand(slot string, src io.Reader) (string, error) {
|
||||||
|
slot, ok := NormalizeBrandSlot(slot)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("无效的品牌图位置")
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return "", errors.New("文件为空")
|
||||||
|
}
|
||||||
|
maxB := brandMaxBytes(slot)
|
||||||
|
data, err := io.ReadAll(io.LimitReader(src, maxB+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("读取图片失败")
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", errors.New("文件为空")
|
||||||
|
}
|
||||||
|
if int64(len(data)) > 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend/service/brand_test.go
Normal file
121
backend/service/brand_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,30 +95,40 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|||||||
|
|
||||||
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
|
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
|
||||||
type PublicSiteSettings struct {
|
type PublicSiteSettings struct {
|
||||||
Accent string `json:"accent"`
|
Accent string `json:"accent"`
|
||||||
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteDescription string `json:"site_description"`
|
SiteDescription string `json:"site_description"`
|
||||||
AllowRegister bool `json:"allow_register"`
|
AllowRegister bool `json:"allow_register"`
|
||||||
AllowComments bool `json:"allow_comments"`
|
AllowComments bool `json:"allow_comments"`
|
||||||
AllowMessages bool `json:"allow_messages"`
|
AllowMessages bool `json:"allow_messages"`
|
||||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||||
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
||||||
UIAnimations bool `json:"ui_animations"`
|
UIAnimations bool `json:"ui_animations"`
|
||||||
AnimCodeFold bool `json:"anim_code_fold"`
|
AnimCodeFold bool `json:"anim_code_fold"`
|
||||||
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||||
AnimChrome bool `json:"anim_chrome"`
|
AnimChrome bool `json:"anim_chrome"`
|
||||||
PostLinkNewTab bool `json:"post_link_new_tab"`
|
PostLinkNewTab bool `json:"post_link_new_tab"`
|
||||||
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
||||||
AttachmentExts []string `json:"attachment_exts"`
|
AttachmentExts []string `json:"attachment_exts"`
|
||||||
AttachmentMaxMB int `json:"attachment_max_mb"`
|
AttachmentMaxMB int `json:"attachment_max_mb"`
|
||||||
AttachmentMaxCount int `json:"attachment_max_count"`
|
AttachmentMaxCount int `json:"attachment_max_count"`
|
||||||
ImageMaxMB int `json:"image_max_mb"`
|
ImageMaxMB int `json:"image_max_mb"`
|
||||||
BgSiteURL string `json:"bg_site_url"`
|
BgSiteURL string `json:"bg_site_url"`
|
||||||
BgSiteMode string `json:"bg_site_mode"`
|
BgSiteMode string `json:"bg_site_mode"`
|
||||||
BgAdminURL string `json:"bg_admin_url"`
|
BgAdminURL string `json:"bg_admin_url"`
|
||||||
BgAdminMode string `json:"bg_admin_mode"`
|
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 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -195,6 +205,11 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
ImageMaxMB: DefaultImageMaxMB,
|
ImageMaxMB: DefaultImageMaxMB,
|
||||||
BgSiteMode: DefaultBgMode,
|
BgSiteMode: DefaultBgMode,
|
||||||
BgAdminMode: DefaultBgMode,
|
BgAdminMode: DefaultBgMode,
|
||||||
|
SiteKeywords: []string{},
|
||||||
|
BrandMark: BrandMarkImageText,
|
||||||
|
BrandLogoSize: BrandLogoSizeSQ,
|
||||||
|
BrandLogoFit: BrandLogoFitContain,
|
||||||
|
FooterLinks: []FooterLink{},
|
||||||
}
|
}
|
||||||
accent, err := s.AccentColor()
|
accent, err := s.AccentColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -343,6 +358,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.BgAdminMode = adminMode
|
out.BgAdminMode = adminMode
|
||||||
|
if err := s.fillBrand(&out); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ func (s *UploadService) EnsureDir() error {
|
|||||||
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
|
||||||
return err
|
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 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
||||||
|
|||||||
135
frontend/app/admin/settings/BasicIdentityPanel.tsx
Normal file
135
frontend/app/admin/settings/BasicIdentityPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="admin-settings-grid">
|
||||||
|
<AdminSection title="名称与字标" description="显示名称用于版权与标题;无 Logo 时页眉改用字标">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<AdminField
|
||||||
|
id="site-name"
|
||||||
|
label="显示名称"
|
||||||
|
maxLength={32}
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="姜十三论坛"
|
||||||
|
/>
|
||||||
|
<AdminField
|
||||||
|
id="site-wordmark"
|
||||||
|
label="字标"
|
||||||
|
hint="留空则回退为显示名称,最多 16 字"
|
||||||
|
maxLength={BRAND_LIMITS.wordmark}
|
||||||
|
value={wordmark}
|
||||||
|
onChange={setWordmark}
|
||||||
|
placeholder="姜十三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminSection>
|
||||||
|
<AdminSection
|
||||||
|
title="标语与简介"
|
||||||
|
description="标语出现在浏览器标题和页眉副标题"
|
||||||
|
extra={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<AdminField
|
||||||
|
id="site-slogan"
|
||||||
|
label="标语"
|
||||||
|
maxLength={BRAND_LIMITS.slogan}
|
||||||
|
value={slogan}
|
||||||
|
onChange={setSlogan}
|
||||||
|
placeholder="技术分享与讨论"
|
||||||
|
/>
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
标题预览:<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||||
|
</p>
|
||||||
|
<AdminField
|
||||||
|
id="site-desc"
|
||||||
|
label="简介"
|
||||||
|
as="textarea"
|
||||||
|
minHeight="96px"
|
||||||
|
maxLength={160}
|
||||||
|
value={desc}
|
||||||
|
onChange={setDesc}
|
||||||
|
placeholder="一句话介绍社区定位…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/app/admin/settings/BrandMediaPicker.tsx
Normal file
104
frontend/app/admin/settings/BrandMediaPicker.tsx
Normal file
@@ -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<BrandSlot, string> = {
|
||||||
|
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<MediaAttachment[]>([]);
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||||
|
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidthClass="max-w-lg"
|
||||||
|
dismissOnOverlay
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||||||
|
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(item);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="relative group aspect-square rounded-xl overflow-hidden"
|
||||||
|
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||||||
|
data-tip="用作草稿预览,点保存后才生效"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={item.url} alt="" className="w-full h-full object-contain" loading="lazy" />
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<Images size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
408
frontend/app/admin/settings/BrandSeoPanel.tsx
Normal file
408
frontend/app/admin/settings/BrandSeoPanel.tsx
Normal file
@@ -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<SlotDraft>(() => emptyDraft(initial.logo_light_url));
|
||||||
|
const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url));
|
||||||
|
const [favicon, setFavicon] = useState<SlotDraft>(() => 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<string[]>(initial.site_keywords);
|
||||||
|
const [mark, setMark] = useState<BrandMark>(initial.brand_mark);
|
||||||
|
const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size);
|
||||||
|
const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit);
|
||||||
|
const [kwInput, setKwInput] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||||
|
const blobs = useRef<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const held = blobs;
|
||||||
|
return () => {
|
||||||
|
held.current.forEach((u) => URL.revokeObjectURL(u));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const drafts: Record<BrandSlot, [SlotDraft, Dispatch<SetStateAction<SlotDraft>>]> = {
|
||||||
|
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<string> => {
|
||||||
|
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 (
|
||||||
|
<div key={slot}>
|
||||||
|
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
||||||
|
<div
|
||||||
|
className="h-28 rounded-xl flex items-center justify-center px-3"
|
||||||
|
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={preview} alt="" className="max-h-20 max-w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="meta text-[12.5px]">未设置</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<label className="btn btn-line btn-sm cursor-pointer">
|
||||||
|
<ImagePlus size={14} /> 上传
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={brandAccept(slot)}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
const err = brandFileOk(slot, file);
|
||||||
|
if (err) {
|
||||||
|
toast(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceFile(slot, file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setPicker(slot)}>
|
||||||
|
<Images size={14} /> 媒体库
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={!preview}
|
||||||
|
onClick={() => {
|
||||||
|
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||||
|
drafts[slot][1](emptyDraft(""));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} /> 清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSection
|
||||||
|
title="品牌与 SEO"
|
||||||
|
description="图加文字和纯图共用画幅与铺法。预览条即时变化,保存后访客才看到。"
|
||||||
|
extra={
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
保存品牌
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 mb-5">
|
||||||
|
<AdminSegmented
|
||||||
|
role="group"
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="页眉形式"
|
||||||
|
value={mark}
|
||||||
|
onChange={setMark}
|
||||||
|
options={[
|
||||||
|
{ key: "image_text", label: "图加文字" },
|
||||||
|
{ key: "image", label: "纯图" },
|
||||||
|
{ key: "text", label: "纯文字" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="h-[68px] px-4 flex items-center">
|
||||||
|
<BrandLockup
|
||||||
|
mode={mark}
|
||||||
|
size={logoSize}
|
||||||
|
fit={logoFit}
|
||||||
|
frame
|
||||||
|
name={initial.site_name}
|
||||||
|
wordmark={initial.site_wordmark}
|
||||||
|
slogan={initial.site_slogan}
|
||||||
|
logoLight={previewOf(light)}
|
||||||
|
logoDark={previewOf(dark)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mark !== "text" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅">
|
||||||
|
{BRAND_LOGO_SIZES.map((item) => {
|
||||||
|
const on = logoSize === item.id;
|
||||||
|
const thumbH = 16;
|
||||||
|
const thumbW = Math.round((item.w / item.h) * thumbH);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||||
|
color: on ? "var(--accent)" : "var(--ink)",
|
||||||
|
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
|
||||||
|
}}
|
||||||
|
onClick={() => setLogoSize(item.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: thumbW,
|
||||||
|
height: thumbH,
|
||||||
|
boxShadow: "inset 0 0 0 1.5px currentColor",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-[13px] font-medium">{item.label}</span>
|
||||||
|
<span className="meta text-[12px]">{item.w}×{item.h}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<AdminSegmented
|
||||||
|
role="group"
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="图槽铺法"
|
||||||
|
value={logoFit}
|
||||||
|
onChange={setLogoFit}
|
||||||
|
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="meta text-[12.5px]">纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-brand-grid">
|
||||||
|
{SLOTS.map((s) =>
|
||||||
|
renderSlot(
|
||||||
|
s.id,
|
||||||
|
s.label,
|
||||||
|
s.hint,
|
||||||
|
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className="admin-brand-keywords">
|
||||||
|
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
SEO 关键词
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12px] mt-0.5 mb-2">最多 16 个,每个 20 字。点芯片可删除。</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{keywords.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px]"
|
||||||
|
style={{ background: "var(--panel-2)", color: "var(--ink)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||||
|
onClick={() => setKeywords((prev) => prev.filter((x) => x !== k))}
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 max-w-xl">
|
||||||
|
<input
|
||||||
|
value={kwInput}
|
||||||
|
onChange={(e) => 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="添加关键词"
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => addKeyword(kwInput)}>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="meta text-[12.5px] mt-3">{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BrandMediaPicker
|
||||||
|
open={picker != null}
|
||||||
|
slot={picker || "logo_light"}
|
||||||
|
onClose={() => 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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AdminSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
frontend/app/admin/settings/FooterLinksPanel.tsx
Normal file
173
frontend/app/admin/settings/FooterLinksPanel.tsx
Normal file
@@ -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<FooterLink[]>(() => clone(initial.footer_links));
|
||||||
|
const [saved, setSaved] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const dirty = JSON.stringify(links) !== JSON.stringify(saved);
|
||||||
|
|
||||||
|
const update = (i: number, patch: Partial<FooterLink>) => {
|
||||||
|
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 (
|
||||||
|
<div className="admin-settings-grid">
|
||||||
|
<AdminSection
|
||||||
|
title="页脚与合规"
|
||||||
|
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。"
|
||||||
|
extra={
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{links.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-xl p-3 flex flex-col gap-3" style={{ boxShadow: "inset 0 0 0 1px var(--line)" }}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" className="admin-icon-btn" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="下移"
|
||||||
|
disabled={i === links.length - 1}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="删除"
|
||||||
|
onClick={() => setLinks((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AdminField
|
||||||
|
id={`footer-label-${i}`}
|
||||||
|
label="文案"
|
||||||
|
maxLength={BRAND_LIMITS.footerLabel}
|
||||||
|
value={item.label}
|
||||||
|
onChange={(v) => update(i, { label: v })}
|
||||||
|
placeholder="京 ICP 备xxxxxxxx号"
|
||||||
|
/>
|
||||||
|
<AdminField
|
||||||
|
id={`footer-url-${i}`}
|
||||||
|
label="地址"
|
||||||
|
maxLength={BRAND_LIMITS.footerURL}
|
||||||
|
value={item.url}
|
||||||
|
onChange={(v) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.new_tab}
|
||||||
|
onChange={(e) => update(i, { new_tab: e.target.checked })}
|
||||||
|
/>
|
||||||
|
新标签打开
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm self-start"
|
||||||
|
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
||||||
|
onClick={() => setLinks((prev) => [...prev, { label: "", url: "", new_tab: false }])}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> 新增链接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminSection>
|
||||||
|
<AdminSection title="页脚预览" description="保存前仅在此处预览,前台页脚不会跟着变">
|
||||||
|
<div className="rounded-xl px-4 py-3 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span>
|
||||||
|
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => (
|
||||||
|
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{item.label.trim()}
|
||||||
|
{item.new_tab ? " ↗" : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="meta text-[12px] ml-auto">性能指标</span>
|
||||||
|
</div>
|
||||||
|
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
|
||||||
|
</AdminSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Ban,
|
Ban,
|
||||||
Wrench,
|
Wrench,
|
||||||
Palette,
|
|
||||||
FileText,
|
FileText,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
AdminExtChipEditor,
|
AdminExtChipEditor,
|
||||||
AdminField,
|
AdminField,
|
||||||
@@ -32,6 +31,9 @@ import {
|
|||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||||
|
import BrandSeoPanel from "./BrandSeoPanel";
|
||||||
|
import FooterLinksPanel from "./FooterLinksPanel";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
function extsEqual(a: string[], b: string[]) {
|
function extsEqual(a: string[], b: string[]) {
|
||||||
@@ -85,8 +87,6 @@ type SectionId =
|
|||||||
| "basic"
|
| "basic"
|
||||||
| "content"
|
| "content"
|
||||||
| "gitimport"
|
| "gitimport"
|
||||||
| "brand"
|
|
||||||
| "footer"
|
|
||||||
| "security"
|
| "security"
|
||||||
| "mail"
|
| "mail"
|
||||||
| "storage"
|
| "storage"
|
||||||
@@ -97,8 +97,6 @@ const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
|
|||||||
{ id: "basic", label: "基本信息", ready: true },
|
{ id: "basic", label: "基本信息", ready: true },
|
||||||
{ id: "content", label: "内容与互动", ready: true },
|
{ id: "content", label: "内容与互动", ready: true },
|
||||||
{ id: "gitimport", label: "提交导入", ready: true },
|
{ id: "gitimport", label: "提交导入", ready: true },
|
||||||
{ id: "brand", label: "品牌与 SEO" },
|
|
||||||
{ id: "footer", label: "页脚与合规" },
|
|
||||||
{ id: "security", label: "安全与限流" },
|
{ id: "security", label: "安全与限流" },
|
||||||
{ id: "mail", label: "邮件" },
|
{ id: "mail", label: "邮件" },
|
||||||
{ id: "storage", label: "对象存储" },
|
{ id: "storage", label: "对象存储" },
|
||||||
@@ -119,14 +117,14 @@ function SwitchRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between gap-4 flex-wrap py-4 first:pt-0 last:pb-0"
|
className="flex items-start justify-between gap-3 flex-wrap py-3 first:pt-0 last:pb-0"
|
||||||
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
|
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[14.5px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
<p className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
|
<p className="meta text-[12px] mt-0.5 leading-relaxed">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{control}</div>
|
<div className="shrink-0">{control}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +143,7 @@ function ComingSoonCard({
|
|||||||
return (
|
return (
|
||||||
<AdminSection title={title} description={description}>
|
<AdminSection title={title} description={description}>
|
||||||
<div
|
<div
|
||||||
className="rounded-[var(--r-sm)] px-4 py-10 text-center"
|
className="rounded-[var(--r-sm)] px-4 py-8 text-center"
|
||||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||||
>
|
>
|
||||||
<span className="inline-flex mb-2 opacity-80" aria-hidden>
|
<span className="inline-flex mb-2 opacity-80" aria-hidden>
|
||||||
@@ -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 (
|
||||||
|
<div className="admin-settings-grid">
|
||||||
|
<ComingSoonCard {...left} />
|
||||||
|
<ComingSoonCard {...right} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
||||||
const [siteName, setSiteName] = useState(initial.site_name);
|
const [live, setLive] = useState(initial);
|
||||||
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
|
||||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
||||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
||||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== 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 [togglingReg, setTogglingReg] = useState(false);
|
||||||
const [togglingComments, setTogglingComments] = useState(false);
|
const [togglingComments, setTogglingComments] = useState(false);
|
||||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||||
const [togglingLink, setTogglingLink] = useState(false);
|
const [togglingLink, setTogglingLink] = useState(false);
|
||||||
const [active, setActive] = useState<SectionId>("basic");
|
const [active, setActive] = useState<SectionId>("basic");
|
||||||
|
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
||||||
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
||||||
@@ -194,9 +204,31 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
|
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
|
||||||
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
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 extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
|
||||||
const gitImportDirty = gitImportJSON !== savedGitImport;
|
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<string, unknown> => !!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 () => {
|
const loadGitImport = async () => {
|
||||||
if (gitImportLoading) return;
|
if (gitImportLoading) return;
|
||||||
@@ -227,35 +259,28 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const selectTab = (id: SectionId) => {
|
||||||
const ids = NAV.map((n) => n.id);
|
if (id !== active) {
|
||||||
const observer = new IntersectionObserver(
|
setActive(id);
|
||||||
(entries) => {
|
document.getElementById("main")?.scrollTo({ top: 0 });
|
||||||
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" });
|
|
||||||
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
||||||
void loadGitImport();
|
void loadGitImport();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTabKeyDown = (e: KeyboardEvent<HTMLButtonElement>, 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 => {
|
const validateGitImportClient = (): boolean => {
|
||||||
try {
|
try {
|
||||||
JSON.parse(gitImportJSON);
|
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 () => {
|
const toggleRegister = async () => {
|
||||||
if (togglingReg) return;
|
if (togglingReg) return;
|
||||||
const next = !allowRegister;
|
const next = !allowRegister;
|
||||||
@@ -479,80 +479,55 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="站点设置"
|
title="站点设置"
|
||||||
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
|
description="名称、Logo、关键词与页脚都在「基本信息」;主题色请到「外观」页调整"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
<div
|
||||||
<nav
|
className="admin-settings-tabs"
|
||||||
className="w-full lg:w-52 shrink-0 lg:sticky lg:top-4 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0"
|
role="tablist"
|
||||||
aria-label="设置分区"
|
aria-label="设置分区"
|
||||||
>
|
>
|
||||||
{NAV.map((item) => {
|
{NAV.map((item, i) => {
|
||||||
const isActive = active === item.id;
|
const isActive = active === item.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
ref={(el) => {
|
||||||
onClick={() => scrollTo(item.id)}
|
tabRefs.current[i] = el;
|
||||||
className="shrink-0 text-left px-3 py-2 rounded-[var(--r-sm)] text-[13.5px] transition-colors whitespace-nowrap"
|
}}
|
||||||
style={{
|
type="button"
|
||||||
background: isActive ? "var(--accent-soft)" : "transparent",
|
role="tab"
|
||||||
color: isActive ? "var(--accent)" : "var(--ink-2)",
|
id={`settings-tab-${item.id}`}
|
||||||
fontWeight: isActive ? 600 : 400,
|
aria-selected={isActive}
|
||||||
}}
|
aria-controls={`settings-panel-${item.id}`}
|
||||||
aria-current={isActive ? "true" : undefined}
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
className="admin-settings-tab"
|
||||||
{item.label}
|
onClick={() => selectTab(item.id)}
|
||||||
{!item.ready && (
|
onKeyDown={(e) => onTabKeyDown(e, i)}
|
||||||
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--ink-3)" }}>
|
>
|
||||||
即将
|
{item.label}
|
||||||
</span>
|
{!item.ready && <span className="admin-settings-tab-soon">即将</span>}
|
||||||
)}
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-4 w-full">
|
<div
|
||||||
<div id="settings-basic" className="scroll-mt-4">
|
role="tabpanel"
|
||||||
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
id={`settings-panel-${active}`}
|
||||||
<AdminField
|
aria-labelledby={`settings-tab-${active}`}
|
||||||
id="site-name"
|
className="flex flex-col gap-4"
|
||||||
className="mb-4"
|
>
|
||||||
label="站点名称"
|
{active === "basic" && (
|
||||||
maxLength={32}
|
<div className="flex flex-col gap-4">
|
||||||
value={siteName}
|
<BasicIdentityPanel initial={live} onSaved={setLive} />
|
||||||
onChange={setSiteName}
|
<BrandSeoPanel initial={live} onSaved={setLive} />
|
||||||
placeholder="姜十三论坛"
|
<FooterLinksPanel initial={live} onSaved={setLive} />
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminField
|
|
||||||
id="site-desc"
|
|
||||||
className="mb-4"
|
|
||||||
label="站点简介"
|
|
||||||
as="textarea"
|
|
||||||
minHeight="96px"
|
|
||||||
maxLength={160}
|
|
||||||
value={siteDescription}
|
|
||||||
onChange={setSiteDescription}
|
|
||||||
placeholder="一句话介绍社区定位…"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
onClick={() => void saveIdentity()}
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
||||||
保存基本信息
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</AdminSection>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div id="settings-content" className="scroll-mt-4 flex flex-col gap-4">
|
{active === "content" && (
|
||||||
|
<div className="admin-settings-grid">
|
||||||
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
|
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
bordered
|
bordered
|
||||||
@@ -754,29 +729,27 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
</div>
|
</div>
|
||||||
</AdminSection>
|
</AdminSection>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div id="settings-gitimport" className="scroll-mt-4">
|
{active === "gitimport" && (
|
||||||
|
<div className="admin-settings-grid">
|
||||||
<AdminSection
|
<AdminSection
|
||||||
title="提交导入"
|
title="适配器 JSON"
|
||||||
description="声明式匹配规则,非可执行脚本。用于将 GitHub / Gitea 等提交历史导入时间线。"
|
description="声明式匹配规则,非可执行脚本。服务端只按规则拉取。"
|
||||||
>
|
>
|
||||||
<p className="meta text-[12.5px] mb-3 leading-relaxed">
|
|
||||||
通过 JSON 描述 host、路径正则与 API 字段映射;服务端只按规则拉取,不会执行任意代码。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{gitImportLoading && !gitImportLoaded ? (
|
{gitImportLoading && !gitImportLoaded ? (
|
||||||
<div className="flex items-center gap-2 meta py-8 justify-center">
|
<div className="flex items-center gap-2 meta py-8 justify-center">
|
||||||
<Loader2 size={16} className="animate-spin" /> 加载适配器…
|
<Loader2 size={16} className="animate-spin" /> 加载适配器…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="field-label" htmlFor="git-import-json">
|
<label className="field-label sr-only" htmlFor="git-import-json">
|
||||||
适配器 JSON
|
适配器 JSON
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="git-import-json"
|
id="git-import-json"
|
||||||
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
|
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
|
||||||
style={{ minHeight: 320 }}
|
style={{ minHeight: 360 }}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={gitImportJSON}
|
value={gitImportJSON}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -785,7 +758,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
}}
|
}}
|
||||||
placeholder="粘贴或编辑适配器 JSON…"
|
placeholder="粘贴或编辑适配器 JSON…"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{gitValidateMsg ? (
|
{gitValidateMsg ? (
|
||||||
<p
|
<p
|
||||||
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
||||||
@@ -798,91 +770,158 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
{gitValidateMsg.text}
|
{gitValidateMsg.text}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={gitImportLoading || savingGitImport}
|
|
||||||
onClick={() => {
|
|
||||||
if (validateGitImportClient()) toast("语法校验通过", "ok");
|
|
||||||
else toast("JSON 语法无效");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={14} /> 校验
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
disabled={!gitImportDirty || savingGitImport}
|
|
||||||
onClick={() => void saveGitImport()}
|
|
||||||
>
|
|
||||||
{savingGitImport ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save size={14} />
|
|
||||||
)}
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={savingGitImport}
|
|
||||||
onClick={() => setRestoreGitOpen(true)}
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} /> 恢复默认
|
|
||||||
</button>
|
|
||||||
<span className="meta text-[11.5px] inline-flex items-center gap-1 ml-auto">
|
|
||||||
<GitBranch size={12} /> GitHub + Gitea 模板
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AdminSection>
|
</AdminSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settings-brand" className="scroll-mt-4">
|
<AdminSection
|
||||||
<ComingSoonCard
|
title="说明与操作"
|
||||||
title="品牌与 SEO"
|
description="用于将 GitHub / Gitea 等提交历史导入时间线"
|
||||||
description="站点图标、OG 图与结构化元数据"
|
extra={
|
||||||
icon={<Palette size={22} />}
|
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||||
/>
|
<GitBranch size={12} /> 内置模板
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="meta text-[12.5px] leading-relaxed mb-4">
|
||||||
|
通过 JSON 描述 host、路径正则与 API 字段映射。改动不会执行任意代码,保存时服务端会做结构校验。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{gitPreview ? (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--r-sm)] px-3.5 py-3 mb-4"
|
||||||
|
style={{ background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
<p className="text-[12.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
|
||||||
|
当前规则摘要
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12px] mb-2">
|
||||||
|
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{gitPreview.sources.map((s) => (
|
||||||
|
<li
|
||||||
|
key={`${s.id}-${s.host}`}
|
||||||
|
className="text-[12.5px] flex items-center gap-2"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
>
|
||||||
|
<GitBranch size={12} className="shrink-0" style={{ color: "var(--ink-3)" }} />
|
||||||
|
<span className="font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{s.id}
|
||||||
|
</span>
|
||||||
|
<span className="meta">{s.host}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : gitImportLoaded ? (
|
||||||
|
<p className="meta text-[12.5px] mb-4">JSON 无法解析时不显示摘要,保存前请先校验语法。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={gitImportLoading || savingGitImport}
|
||||||
|
onClick={() => {
|
||||||
|
if (validateGitImportClient()) toast("语法校验通过", "ok");
|
||||||
|
else toast("JSON 语法无效");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> 校验
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!gitImportDirty || savingGitImport}
|
||||||
|
onClick={() => void saveGitImport()}
|
||||||
|
>
|
||||||
|
{savingGitImport ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={savingGitImport}
|
||||||
|
onClick={() => setRestoreGitOpen(true)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} /> 恢复默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminSection>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-footer" className="scroll-mt-4">
|
)}
|
||||||
<ComingSoonCard
|
|
||||||
title="页脚与合规"
|
{active === "security" && (
|
||||||
description="备案号、版权声明与合规链接"
|
<ComingSoonPair
|
||||||
icon={<FileText size={22} />}
|
left={{
|
||||||
/>
|
title: "登录与注册限流",
|
||||||
</div>
|
description: "按 IP / 账号的速率与锁定策略",
|
||||||
<div id="settings-security" className="scroll-mt-4">
|
icon: <Shield size={22} />,
|
||||||
<ComingSoonCard
|
}}
|
||||||
title="安全与限流"
|
right={{
|
||||||
description="登录/注册速率与发帖冷静期等"
|
title: "发帖冷静期",
|
||||||
icon={<Shield size={22} />}
|
description: "新用户发帖等待时长与例外规则",
|
||||||
/>
|
icon: <UserPlus size={22} />,
|
||||||
</div>
|
}}
|
||||||
<div id="settings-mail" className="scroll-mt-4">
|
/>
|
||||||
<ComingSoonCard title="邮件" description="SMTP 与通知邮件模板" icon={<Mail size={22} />} />
|
)}
|
||||||
</div>
|
{active === "mail" && (
|
||||||
<div id="settings-storage" className="scroll-mt-4">
|
<ComingSoonPair
|
||||||
<ComingSoonCard
|
left={{
|
||||||
title="对象存储"
|
title: "SMTP 发信",
|
||||||
description="附件与头像的云端存储配置"
|
description: "服务器、端口、加密与发件人身份",
|
||||||
icon={<HardDrive size={22} />}
|
icon: <Mail size={22} />,
|
||||||
/>
|
}}
|
||||||
</div>
|
right={{
|
||||||
<div id="settings-words" className="scroll-mt-4">
|
title: "通知模板",
|
||||||
<ComingSoonCard title="敏感词" description="内容过滤词库与命中策略" icon={<Ban size={22} />} />
|
description: "注册验证、重置密码与系统通知",
|
||||||
</div>
|
icon: <FileText size={22} />,
|
||||||
<div id="settings-maintenance" className="scroll-mt-4">
|
}}
|
||||||
<ComingSoonCard
|
/>
|
||||||
title="系统维护"
|
)}
|
||||||
description="维护模式、缓存与诊断工具"
|
{active === "storage" && (
|
||||||
icon={<Wrench size={22} />}
|
<ComingSoonPair
|
||||||
/>
|
left={{
|
||||||
</div>
|
title: "附件存储",
|
||||||
</div>
|
description: "对象存储桶、路径前缀与访问策略",
|
||||||
|
icon: <HardDrive size={22} />,
|
||||||
|
}}
|
||||||
|
right={{
|
||||||
|
title: "头像存储",
|
||||||
|
description: "头像目录、体积上限与 CDN 域名",
|
||||||
|
icon: <ImageIcon size={22} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{active === "words" && (
|
||||||
|
<ComingSoonPair
|
||||||
|
left={{
|
||||||
|
title: "过滤词库",
|
||||||
|
description: "敏感词列表、导入导出与分组",
|
||||||
|
icon: <Ban size={22} />,
|
||||||
|
}}
|
||||||
|
right={{
|
||||||
|
title: "命中策略",
|
||||||
|
description: "拦截、替换或送审的处理方式",
|
||||||
|
icon: <Shield size={22} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{active === "maintenance" && (
|
||||||
|
<ComingSoonPair
|
||||||
|
left={{
|
||||||
|
title: "维护模式",
|
||||||
|
description: "全站只读、公告文案与白名单",
|
||||||
|
icon: <Wrench size={22} />,
|
||||||
|
}}
|
||||||
|
right={{
|
||||||
|
title: "缓存与诊断",
|
||||||
|
description: "缓存清理、健康检查与日志下载",
|
||||||
|
icon: <HardDrive size={22} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -3590,6 +3590,125 @@ a.j13-mention-token:hover {
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 站点设置:顶栏横向 tab,窄屏横向滚动 */
|
||||||
|
.admin-settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.125rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.admin-settings-tab {
|
||||||
|
appearance: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.admin-settings-tab:hover:not([aria-selected="true"]) {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.admin-settings-tab[aria-selected="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.admin-settings-tab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.admin-settings-tab-soon {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.admin-settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.admin-settings-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基本信息里的品牌槽:窄屏单列,宽屏浅色 / 暗色 / Favicon 并排 */
|
||||||
|
.admin-brand-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.admin-brand-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.admin-brand-keywords {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.j13-brand-slot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.j13-brand-slot[data-frame="1"] {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.j13-brand-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.j13-brand-slot[data-fit="cover"] > .j13-brand-logo:not(.j13-brand-logo-tile) {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.j13-brand-slot[data-fit="stretch"] > .j13-brand-logo:not(.j13-brand-logo-tile) {
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
.j13-brand-logo-tile {
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: auto;
|
||||||
|
background-position: left top;
|
||||||
|
}
|
||||||
|
.j13-brand-bubble {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.j13-brand-logo-dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html.dark .j13-brand-logo-light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html.dark .j13-brand-logo-dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-icon-btn {
|
.admin-icon-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import SiteBrandProvider from "@/components/SiteBrandProvider";
|
||||||
import SiteChrome from "@/components/SiteChrome";
|
import SiteChrome from "@/components/SiteChrome";
|
||||||
import Toaster from "@/components/Toaster";
|
import Toaster from "@/components/Toaster";
|
||||||
import TipHost from "@/components/TipHost";
|
import TipHost from "@/components/TipHost";
|
||||||
@@ -19,6 +20,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
|||||||
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
import { accentStyleCSS } from "@/lib/theme";
|
import { accentStyleCSS } from "@/lib/theme";
|
||||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||||
|
import { siteDocumentTitle } from "@/lib/brand";
|
||||||
import { motionConfigFromSettings } from "@/lib/motion";
|
import { motionConfigFromSettings } from "@/lib/motion";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
@@ -47,15 +49,21 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
const name = settings.site_name || "姜十三论坛";
|
const name = settings.site_name || "姜十三论坛";
|
||||||
|
const title = siteDocumentTitle(name, settings.site_slogan || "");
|
||||||
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
||||||
|
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
|
||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
default: `${name} · J13 FORUM`,
|
default: title,
|
||||||
template: `%s · ${name}`,
|
template: `%s · ${name}`,
|
||||||
},
|
},
|
||||||
description: desc,
|
description: desc,
|
||||||
|
keywords,
|
||||||
|
...(settings.favicon_url
|
||||||
|
? { icons: { icon: settings.favicon_url } }
|
||||||
|
: {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${name} · J13 FORUM`,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
@@ -133,6 +141,7 @@ export default async function RootLayout({
|
|||||||
<a href="#main" className="skip-link">
|
<a href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
|
<SiteBrandProvider initial={settings}>
|
||||||
<MotionProvider initial={motionCfg}>
|
<MotionProvider initial={motionCfg}>
|
||||||
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
||||||
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
|
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
|
||||||
@@ -147,7 +156,6 @@ export default async function RootLayout({
|
|||||||
unread={unread}
|
unread={unread}
|
||||||
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
|
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
siteName={settings.site_name}
|
|
||||||
allowRegister={settings.allow_register}
|
allowRegister={settings.allow_register}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -157,6 +165,7 @@ export default async function RootLayout({
|
|||||||
</MessagesPolicyProvider>
|
</MessagesPolicyProvider>
|
||||||
</CommentsPolicyProvider>
|
</CommentsPolicyProvider>
|
||||||
</MotionProvider>
|
</MotionProvider>
|
||||||
|
</SiteBrandProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TipHost />
|
<TipHost />
|
||||||
<AccountGuard />
|
<AccountGuard />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import { siteDocumentTitle } from "@/lib/brand";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { isAdminOrAbove } from "@/lib/roles";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import PostRow from "@/components/PostRow";
|
import PostRow from "@/components/PostRow";
|
||||||
@@ -25,10 +26,13 @@ import {
|
|||||||
BoardMissingPanel,
|
BoardMissingPanel,
|
||||||
} from "@/components/home/BoardPanels";
|
} from "@/components/home/BoardPanels";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "姜十三论坛 · 技术讨论社区",
|
const settings = await getPublicSettingsCached();
|
||||||
description: "姜十三论坛,分享技术、讨论问题、交流心得的社区",
|
return {
|
||||||
};
|
title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
|
||||||
|
description: settings.site_description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
|
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
|
||||||
|
|||||||
104
frontend/components/BrandLockup.tsx
Normal file
104
frontend/components/BrandLockup.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
|
||||||
|
|
||||||
|
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
|
||||||
|
export default function BrandLockup({
|
||||||
|
mode,
|
||||||
|
size = "sq",
|
||||||
|
fit = "contain",
|
||||||
|
name,
|
||||||
|
wordmark = "",
|
||||||
|
slogan = "",
|
||||||
|
logoLight = "",
|
||||||
|
logoDark = "",
|
||||||
|
frame = false,
|
||||||
|
}: {
|
||||||
|
mode: BrandMark;
|
||||||
|
size?: BrandLogoSize;
|
||||||
|
fit?: BrandLogoFit;
|
||||||
|
name: string;
|
||||||
|
wordmark?: string;
|
||||||
|
slogan?: string;
|
||||||
|
logoLight?: string;
|
||||||
|
logoDark?: string;
|
||||||
|
/** 后台预览画出画幅框,方便看非正方形占位 */
|
||||||
|
frame?: boolean;
|
||||||
|
}) {
|
||||||
|
const label = wordmark.trim() || name;
|
||||||
|
const line = slogan.trim();
|
||||||
|
const showImage = mode !== "text";
|
||||||
|
const showText = mode !== "image";
|
||||||
|
const box = brandLogoBox(size);
|
||||||
|
const src = logoLight || logoDark;
|
||||||
|
const both = !!(logoLight && logoDark && logoLight !== logoDark);
|
||||||
|
const useBox = showImage && (!!src || frame);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="j13-brand-lockup inline-flex items-center gap-2.5 min-w-0">
|
||||||
|
{showImage ? (
|
||||||
|
useBox ? (
|
||||||
|
<span
|
||||||
|
className="j13-brand-slot"
|
||||||
|
data-frame={frame ? "1" : undefined}
|
||||||
|
data-fit={src ? fit : undefined}
|
||||||
|
style={{ width: box.w, height: box.h }}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
both ? (
|
||||||
|
<>
|
||||||
|
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" />
|
||||||
|
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Logo src={src} fit={fit} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Bubble h={box.h} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Bubble h={36} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{showText ? (
|
||||||
|
<span className="leading-none min-w-0">
|
||||||
|
<span className="block font-extrabold text-[17px] tracking-tight truncate" style={{ color: "var(--ink)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{line ? (
|
||||||
|
<span className="block text-[10.5px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) {
|
||||||
|
if (fit === "repeat") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
|
||||||
|
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img src={src} alt="" className={`j13-brand-logo ${extra}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({ h }: { h: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||||
|
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
|
||||||
|
>
|
||||||
|
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import pkg from "../package.json";
|
import pkg from "../package.json";
|
||||||
|
import type { FooterLink } from "@/lib/brand";
|
||||||
import FooterTiming from "./FooterTiming";
|
import FooterTiming from "./FooterTiming";
|
||||||
|
|
||||||
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
||||||
@@ -9,8 +10,14 @@ function realVersion(pkgVersion: string): string {
|
|||||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
|
// 极简页脚:版权 + 合规链接 + 实时性能指标
|
||||||
export default function Footer({ siteName = "姜十三社区" }: { siteName?: string }) {
|
export default function Footer({
|
||||||
|
siteName = "姜十三社区",
|
||||||
|
links = [],
|
||||||
|
}: {
|
||||||
|
siteName?: string;
|
||||||
|
links?: FooterLink[];
|
||||||
|
}) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const brand = siteName.trim() || "姜十三社区";
|
const brand = siteName.trim() || "姜十三社区";
|
||||||
|
|
||||||
@@ -21,8 +28,22 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
|
|||||||
background: "var(--panel)",
|
background: "var(--panel)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
|
||||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||||
|
{links.length > 0 ? (
|
||||||
|
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||||
|
{links.map((item) => (
|
||||||
|
<a
|
||||||
|
key={`${item.label}-${item.url}`}
|
||||||
|
href={item.url}
|
||||||
|
className="meta text-[12px] hover:underline"
|
||||||
|
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
<FooterTiming version={realVersion(pkg.version)} />
|
<FooterTiming version={realVersion(pkg.version)} />
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
|||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import BrandLockup from "./BrandLockup";
|
||||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||||
|
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
initialUser,
|
initialUser,
|
||||||
@@ -37,6 +39,13 @@ export default function Header({
|
|||||||
initialChatUnread = 0,
|
initialChatUnread = 0,
|
||||||
initialTheme = "light",
|
initialTheme = "light",
|
||||||
siteName = "姜十三",
|
siteName = "姜十三",
|
||||||
|
wordmark = "",
|
||||||
|
slogan = "",
|
||||||
|
logoLight = "",
|
||||||
|
logoDark = "",
|
||||||
|
brandMark = "image_text",
|
||||||
|
brandLogoSize = "sq",
|
||||||
|
brandLogoFit = "contain",
|
||||||
allowRegister = true,
|
allowRegister = true,
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
@@ -44,6 +53,13 @@ export default function Header({
|
|||||||
initialChatUnread?: number;
|
initialChatUnread?: number;
|
||||||
initialTheme?: "light" | "dark";
|
initialTheme?: "light" | "dark";
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
|
wordmark?: string;
|
||||||
|
slogan?: string;
|
||||||
|
logoLight?: string;
|
||||||
|
logoDark?: string;
|
||||||
|
brandMark?: BrandMark;
|
||||||
|
brandLogoSize?: BrandLogoSize;
|
||||||
|
brandLogoFit?: BrandLogoFit;
|
||||||
allowRegister?: boolean;
|
allowRegister?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -205,23 +221,17 @@ export default function Header({
|
|||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||||||
{/* 左组:品牌 + 主导航 */}
|
{/* 左组:品牌 + 主导航 */}
|
||||||
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||||||
<Link href="/" className="flex items-center gap-2.5 group">
|
<Link href="/" className="flex items-center group min-w-0" aria-label={siteName}>
|
||||||
<span
|
<BrandLockup
|
||||||
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
mode={brandMark}
|
||||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
size={brandLogoSize}
|
||||||
>
|
fit={brandLogoFit}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
name={siteName}
|
||||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
wordmark={wordmark}
|
||||||
</svg>
|
slogan={slogan}
|
||||||
</span>
|
logoLight={logoLight}
|
||||||
<span className="leading-none">
|
logoDark={logoDark}
|
||||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
/>
|
||||||
{siteName}
|
|
||||||
</span>
|
|
||||||
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
|
|
||||||
J13 社区
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
||||||
|
|||||||
183
frontend/components/SiteBrandProvider.tsx
Normal file
183
frontend/components/SiteBrandProvider.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
|
||||||
|
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||||
|
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||||
|
|
||||||
|
export type SiteBrand = {
|
||||||
|
site_name: string;
|
||||||
|
site_wordmark: string;
|
||||||
|
site_slogan: string;
|
||||||
|
logo_light_url: string;
|
||||||
|
logo_dark_url: string;
|
||||||
|
favicon_url: string;
|
||||||
|
brand_mark: PublicSettings["brand_mark"];
|
||||||
|
brand_logo_size: PublicSettings["brand_logo_size"];
|
||||||
|
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||||
|
footer_links: PublicSettings["footer_links"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SiteBrandContext = createContext<SiteBrand | null>(null);
|
||||||
|
|
||||||
|
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
|
||||||
|
return {
|
||||||
|
site_name: s.site_name,
|
||||||
|
site_wordmark: s.site_wordmark,
|
||||||
|
site_slogan: s.site_slogan,
|
||||||
|
logo_light_url: s.logo_light_url,
|
||||||
|
logo_dark_url: s.logo_dark_url,
|
||||||
|
favicon_url: s.favicon_url,
|
||||||
|
brand_mark: s.brand_mark,
|
||||||
|
brand_logo_size: s.brand_logo_size,
|
||||||
|
brand_logo_fit: s.brand_logo_fit,
|
||||||
|
footer_links: s.footer_links,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||||
|
return (
|
||||||
|
a.site_name === b.site_name &&
|
||||||
|
a.site_wordmark === b.site_wordmark &&
|
||||||
|
a.site_slogan === b.site_slogan &&
|
||||||
|
a.logo_light_url === b.logo_light_url &&
|
||||||
|
a.logo_dark_url === b.logo_dark_url &&
|
||||||
|
a.favicon_url === b.favicon_url &&
|
||||||
|
a.brand_mark === b.brand_mark &&
|
||||||
|
a.brand_logo_size === b.brand_logo_size &&
|
||||||
|
a.brand_logo_fit === b.brand_logo_fit &&
|
||||||
|
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFavicon(url: string) {
|
||||||
|
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
|
||||||
|
if (!url) {
|
||||||
|
document.getElementById("j13-favicon")?.remove();
|
||||||
|
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
|
||||||
|
if (!hasDefault) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.href = "/favicon.ico";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.forEach((el) => {
|
||||||
|
if (el.id !== "j13-favicon") el.remove();
|
||||||
|
});
|
||||||
|
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.id = "j13-favicon";
|
||||||
|
link.rel = "icon";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
if (link.getAttribute("href") !== url) link.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retitle(prev: SiteBrand, next: SiteBrand) {
|
||||||
|
if (prev.site_name === next.site_name && prev.site_slogan === next.site_slogan) return;
|
||||||
|
const oldDefault = siteDocumentTitle(prev.site_name, prev.site_slogan);
|
||||||
|
const nextDefault = siteDocumentTitle(next.site_name, next.site_slogan);
|
||||||
|
const cur = document.title;
|
||||||
|
if (cur === oldDefault) {
|
||||||
|
document.title = nextDefault;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldName = prev.site_name.trim() || "姜十三论坛";
|
||||||
|
const newName = next.site_name.trim() || "姜十三论坛";
|
||||||
|
const suffix = ` · ${oldName}`;
|
||||||
|
if (oldName !== newName && cur.endsWith(suffix)) {
|
||||||
|
document.title = cur.slice(0, -suffix.length) + ` · ${newName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSiteBrand(): SiteBrand {
|
||||||
|
const ctx = useContext(SiteBrandContext);
|
||||||
|
if (!ctx) {
|
||||||
|
return {
|
||||||
|
site_name: "姜十三论坛",
|
||||||
|
site_wordmark: "",
|
||||||
|
site_slogan: "",
|
||||||
|
logo_light_url: "",
|
||||||
|
logo_dark_url: "",
|
||||||
|
favicon_url: "",
|
||||||
|
brand_mark: "image_text",
|
||||||
|
brand_logo_size: "sq",
|
||||||
|
brand_logo_fit: "contain",
|
||||||
|
footer_links: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
|
||||||
|
export default function SiteBrandProvider({
|
||||||
|
initial,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initial: PublicSettings;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [brand, setBrand] = useState<SiteBrand>(() => fromSettings(initial));
|
||||||
|
const brandRef = useRef(brand);
|
||||||
|
brandRef.current = brand;
|
||||||
|
|
||||||
|
const value = useMemo(() => brand, [brand]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = fromSettings(initial);
|
||||||
|
setBrand((prev) => {
|
||||||
|
if (sameBrand(prev, next)) return prev;
|
||||||
|
retitle(prev, next);
|
||||||
|
applyFavicon(next.favicon_url);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (raw: Partial<PublicSettings> | RtSettingsData) => {
|
||||||
|
const prev = brandRef.current;
|
||||||
|
const next: SiteBrand = {
|
||||||
|
site_name: typeof raw.site_name === "string" && raw.site_name.trim() ? raw.site_name : prev.site_name,
|
||||||
|
site_wordmark: typeof raw.site_wordmark === "string" ? raw.site_wordmark : prev.site_wordmark,
|
||||||
|
site_slogan: typeof raw.site_slogan === "string" ? raw.site_slogan : prev.site_slogan,
|
||||||
|
logo_light_url: typeof raw.logo_light_url === "string" ? raw.logo_light_url : prev.logo_light_url,
|
||||||
|
logo_dark_url: typeof raw.logo_dark_url === "string" ? raw.logo_dark_url : prev.logo_dark_url,
|
||||||
|
favicon_url: typeof raw.favicon_url === "string" ? raw.favicon_url : prev.favicon_url,
|
||||||
|
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||||
|
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||||
|
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||||
|
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
|
||||||
|
};
|
||||||
|
if (sameBrand(prev, next)) return;
|
||||||
|
retitle(prev, next);
|
||||||
|
applyFavicon(next.favicon_url);
|
||||||
|
brandRef.current = next;
|
||||||
|
setBrand(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const off = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||||
|
if (data) apply(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
let last = 0;
|
||||||
|
const offVisible = onDocumentVisible(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - last < VISIBLE_RECONCILE_GAP_MS) return;
|
||||||
|
last = now;
|
||||||
|
void apiGetSettings()
|
||||||
|
.then(apply)
|
||||||
|
.catch(() => {});
|
||||||
|
}, VISIBLE_RECONCILE_GAP_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
offVisible();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <SiteBrandContext.Provider value={value}>{children}</SiteBrandContext.Provider>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import Header from "@/components/Header";
|
|||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import BackToTop from "@/components/BackToTop";
|
import BackToTop from "@/components/BackToTop";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
|
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||||
|
|
||||||
function pathIsAdmin(pathname: string | null) {
|
function pathIsAdmin(pathname: string | null) {
|
||||||
if (!pathname) return false;
|
if (!pathname) return false;
|
||||||
@@ -39,7 +40,6 @@ export default function SiteChrome({
|
|||||||
unread,
|
unread,
|
||||||
chatUnread,
|
chatUnread,
|
||||||
theme,
|
theme,
|
||||||
siteName,
|
|
||||||
allowRegister,
|
allowRegister,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -48,7 +48,6 @@ export default function SiteChrome({
|
|||||||
unread: number;
|
unread: number;
|
||||||
chatUnread: number;
|
chatUnread: number;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
siteName: string;
|
|
||||||
allowRegister: boolean;
|
allowRegister: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -80,6 +79,8 @@ export default function SiteChrome({
|
|||||||
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
const brand = useSiteBrand();
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,14 @@ export default function SiteChrome({
|
|||||||
initialUnread={unread}
|
initialUnread={unread}
|
||||||
initialChatUnread={chatUnread}
|
initialChatUnread={chatUnread}
|
||||||
initialTheme={theme}
|
initialTheme={theme}
|
||||||
siteName={siteName}
|
siteName={brand.site_name}
|
||||||
|
wordmark={brand.site_wordmark}
|
||||||
|
slogan={brand.site_slogan}
|
||||||
|
logoLight={brand.logo_light_url}
|
||||||
|
logoDark={brand.logo_dark_url}
|
||||||
|
brandMark={brand.brand_mark}
|
||||||
|
brandLogoSize={brand.brand_logo_size}
|
||||||
|
brandLogoFit={brand.brand_logo_fit}
|
||||||
allowRegister={allowRegister}
|
allowRegister={allowRegister}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
@@ -104,7 +112,7 @@ export default function SiteChrome({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer siteName={siteName} />
|
<Footer siteName={brand.site_name} links={brand.footer_links} />
|
||||||
{showBackToTop ? (
|
{showBackToTop ? (
|
||||||
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
|
import {
|
||||||
|
normalizeBrandLogoFit,
|
||||||
|
normalizeBrandLogoSize,
|
||||||
|
normalizeBrandMark,
|
||||||
|
normalizeBrandURL,
|
||||||
|
normalizeFooterLinks,
|
||||||
|
normalizeKeywords,
|
||||||
|
type BrandLogoFit,
|
||||||
|
type BrandLogoSize,
|
||||||
|
type BrandMark,
|
||||||
|
type BrandSlot,
|
||||||
|
type FooterLink,
|
||||||
|
} from "./brand";
|
||||||
import { CSRF_COOKIE } from "./cookies";
|
import { CSRF_COOKIE } from "./cookies";
|
||||||
import { emitForceLogout } from "./userEvents";
|
import { emitForceLogout } from "./userEvents";
|
||||||
|
|
||||||
|
export type { FooterLink };
|
||||||
|
|
||||||
// API 基础配置
|
// API 基础配置
|
||||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||||||
@@ -647,6 +662,21 @@ export interface PublicSettings {
|
|||||||
trust_reviewed_publish: boolean;
|
trust_reviewed_publish: boolean;
|
||||||
site_name: string;
|
site_name: string;
|
||||||
site_description: string;
|
site_description: string;
|
||||||
|
/** 无 Logo 时页眉字标;空则回退显示名称 */
|
||||||
|
site_wordmark: string;
|
||||||
|
/** 浏览器标题后缀与页眉副标题;空则不显示 */
|
||||||
|
site_slogan: string;
|
||||||
|
site_keywords: string[];
|
||||||
|
logo_light_url: string;
|
||||||
|
logo_dark_url: string;
|
||||||
|
favicon_url: string;
|
||||||
|
/** 页眉形式:image_text 图加文字 / image 纯图 / text 纯文字 */
|
||||||
|
brand_mark: BrandMark;
|
||||||
|
/** 图槽画幅 */
|
||||||
|
brand_logo_size: BrandLogoSize;
|
||||||
|
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
|
||||||
|
brand_logo_fit: BrandLogoFit;
|
||||||
|
footer_links: FooterLink[];
|
||||||
allow_register: boolean;
|
allow_register: boolean;
|
||||||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
@@ -703,6 +733,16 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
trust_reviewed_publish: true,
|
trust_reviewed_publish: true,
|
||||||
site_name: "姜十三论坛",
|
site_name: "姜十三论坛",
|
||||||
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||||||
|
site_wordmark: "",
|
||||||
|
site_slogan: "",
|
||||||
|
site_keywords: [],
|
||||||
|
logo_light_url: "",
|
||||||
|
logo_dark_url: "",
|
||||||
|
favicon_url: "",
|
||||||
|
brand_mark: "image_text",
|
||||||
|
brand_logo_size: "sq",
|
||||||
|
brand_logo_fit: "contain",
|
||||||
|
footer_links: [],
|
||||||
allow_register: true,
|
allow_register: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
allow_messages: true,
|
allow_messages: true,
|
||||||
@@ -765,6 +805,16 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
data?.site_description !== undefined
|
data?.site_description !== undefined
|
||||||
? String(data.site_description).trim()
|
? String(data.site_description).trim()
|
||||||
: DEFAULT_PUBLIC_SETTINGS.site_description,
|
: DEFAULT_PUBLIC_SETTINGS.site_description,
|
||||||
|
site_wordmark: String(data?.site_wordmark ?? "").trim(),
|
||||||
|
site_slogan: String(data?.site_slogan ?? "").trim(),
|
||||||
|
site_keywords: normalizeKeywords(data?.site_keywords),
|
||||||
|
logo_light_url: normalizeBrandURL("logo_light", data?.logo_light_url),
|
||||||
|
logo_dark_url: normalizeBrandURL("logo_dark", data?.logo_dark_url),
|
||||||
|
favicon_url: normalizeBrandURL("favicon", data?.favicon_url),
|
||||||
|
brand_mark: normalizeBrandMark(data?.brand_mark),
|
||||||
|
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
|
||||||
|
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
|
||||||
|
footer_links: normalizeFooterLinks(data?.footer_links),
|
||||||
allow_register: data?.allow_register !== false,
|
allow_register: data?.allow_register !== false,
|
||||||
allow_comments: data?.allow_comments !== false,
|
allow_comments: data?.allow_comments !== false,
|
||||||
allow_messages: data?.allow_messages !== false,
|
allow_messages: data?.allow_messages !== false,
|
||||||
@@ -2141,6 +2191,16 @@ export type UpdateSiteSettingsBody = {
|
|||||||
trust_reviewed_publish?: boolean;
|
trust_reviewed_publish?: boolean;
|
||||||
site_name?: string;
|
site_name?: string;
|
||||||
site_description?: string;
|
site_description?: string;
|
||||||
|
site_wordmark?: string;
|
||||||
|
site_slogan?: string;
|
||||||
|
site_keywords?: string[];
|
||||||
|
logo_light_url?: string;
|
||||||
|
logo_dark_url?: string;
|
||||||
|
favicon_url?: string;
|
||||||
|
brand_mark?: BrandMark;
|
||||||
|
brand_logo_size?: BrandLogoSize;
|
||||||
|
brand_logo_fit?: BrandLogoFit;
|
||||||
|
footer_links?: FooterLink[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
allow_messages?: boolean;
|
allow_messages?: boolean;
|
||||||
@@ -2176,6 +2236,37 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
|
|||||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上传品牌图,只落盘返回 URL,不写入站点设置 */
|
||||||
|
export async function apiUploadBrand(slot: BrandSlot, file: File): Promise<string> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("slot", slot);
|
||||||
|
form.append("file", file, file.name || "brand.png");
|
||||||
|
const res = await fetchWithRefresh("/api/admin/upload/brand", {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "上传品牌图失败");
|
||||||
|
const url = String(data.url || "");
|
||||||
|
if (!url) throw new Error("上传品牌图失败");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把本人媒体库图片复制进品牌目录,不写入站点设置 */
|
||||||
|
export async function apiBrandFromMedia(slot: BrandSlot, attachmentId: number): Promise<string> {
|
||||||
|
const res = await fetchWithRefresh("/api/admin/upload/brand/from-media", {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({ attachment_id: attachmentId, slot }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
|
||||||
|
const url = String(data.url || "");
|
||||||
|
if (!url) throw new Error("选用媒体库图片失败");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
/** 上传站点背景并立即绑定前台或后台 */
|
/** 上传站点背景并立即绑定前台或后台 */
|
||||||
export async function apiUploadSiteBackground(
|
export async function apiUploadSiteBackground(
|
||||||
surface: "site" | "admin",
|
surface: "site" | "admin",
|
||||||
|
|||||||
169
frontend/lib/brand.ts
Normal file
169
frontend/lib/brand.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/** 站点品牌与页脚:公开设置字段的前端归一化(与 Go service/brand.go 对齐) */
|
||||||
|
|
||||||
|
export type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
new_tab: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
|
||||||
|
|
||||||
|
/** 页眉品牌形式 */
|
||||||
|
export type BrandMark = "image_text" | "image" | "text";
|
||||||
|
|
||||||
|
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
|
||||||
|
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
|
||||||
|
|
||||||
|
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
|
||||||
|
{ id: "sq", label: "方标", w: 36, h: 36 },
|
||||||
|
{ id: "2x1", label: "2:1", w: 80, h: 40 },
|
||||||
|
{ id: "3x1", label: "3:1", w: 120, h: 40 },
|
||||||
|
{ id: "4x1", label: "4:1", w: 176, h: 44 },
|
||||||
|
{ id: "16x9", label: "16:9", w: 96, h: 54 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeBrandMark(raw: unknown): BrandMark {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (s === "image" || s === "text" || s === "image_text") return s;
|
||||||
|
return "image_text";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
|
||||||
|
return "sq";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
|
||||||
|
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
|
||||||
|
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图槽铺法,与背景图同一套:填充裁切、适应留边、平铺重复、拉伸变形 */
|
||||||
|
export type BrandLogoFit = "cover" | "contain" | "repeat" | "stretch";
|
||||||
|
|
||||||
|
export const BRAND_LOGO_FITS: { id: BrandLogoFit; label: string }[] = [
|
||||||
|
{ id: "cover", label: "填充" },
|
||||||
|
{ id: "contain", label: "适应" },
|
||||||
|
{ id: "repeat", label: "平铺" },
|
||||||
|
{ id: "stretch", label: "拉伸" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (s === "cover" || s === "contain" || s === "repeat" || s === "stretch") return s;
|
||||||
|
return "contain";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BRAND_LIMITS = {
|
||||||
|
wordmark: 16,
|
||||||
|
slogan: 32,
|
||||||
|
keywordCount: 16,
|
||||||
|
keywordRunes: 20,
|
||||||
|
footerLinks: 8,
|
||||||
|
footerLabel: 32,
|
||||||
|
footerURL: 200,
|
||||||
|
logoBytes: 2 * 1024 * 1024,
|
||||||
|
faviconBytes: 512 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LOGO_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg)$/;
|
||||||
|
const FAVICON_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg|ico)$/;
|
||||||
|
|
||||||
|
export function runeCount(s: string): number {
|
||||||
|
return [...s].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器默认标题:有标语则为「名称 · 标语」,否则只有名称 */
|
||||||
|
export function siteDocumentTitle(name: string, slogan: string): string {
|
||||||
|
const n = name.trim() || "姜十三论坛";
|
||||||
|
const s = slogan.trim();
|
||||||
|
return s ? `${n} · ${s}` : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
|
||||||
|
const u = String(raw ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!u) return "";
|
||||||
|
const re = slot === "favicon" ? FAVICON_URL_RE : LOGO_URL_RE;
|
||||||
|
return re.test(u) ? u : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeywords(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const parts = String(item ?? "").split(/[,,;;]/);
|
||||||
|
for (const part of parts) {
|
||||||
|
const k = part.trim();
|
||||||
|
if (!k || runeCount(k) > BRAND_LIMITS.keywordRunes) continue;
|
||||||
|
const key = k.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(k);
|
||||||
|
if (out.length >= BRAND_LIMITS.keywordCount) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalFooterURL(url: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页脚地址:站内单斜杠路径,或 http(s) */
|
||||||
|
export function footerURLAllowed(raw: string): boolean {
|
||||||
|
const u = raw.trim();
|
||||||
|
if (!u || runeCount(u) > BRAND_LIMITS.footerURL) return false;
|
||||||
|
if (/\s/.test(u) || u.includes("\\")) return false;
|
||||||
|
if (/^(javascript|data):/i.test(u) || u.startsWith("//")) return false;
|
||||||
|
if (isExternalFooterURL(u)) return true;
|
||||||
|
return u.startsWith("/") && !u.startsWith("//");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFooterLinks(raw: unknown): FooterLink[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: FooterLink[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const rec = item as { label?: unknown; url?: unknown; new_tab?: unknown };
|
||||||
|
const label = String(rec.label ?? "").trim();
|
||||||
|
const url = String(rec.url ?? "").trim();
|
||||||
|
if (!label || runeCount(label) > BRAND_LIMITS.footerLabel) continue;
|
||||||
|
if (!footerURLAllowed(url)) continue;
|
||||||
|
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
||||||
|
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
||||||
|
out.push({ label, url, new_tab: newTab });
|
||||||
|
if (out.length >= BRAND_LIMITS.footerLinks) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandAccept(slot: BrandSlot): string {
|
||||||
|
if (slot === "favicon") return "image/png,image/webp,image/svg+xml,.ico,image/x-icon";
|
||||||
|
return "image/png,image/webp,image/svg+xml";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandFileOk(slot: BrandSlot, file: File): string | null {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const logo = slot !== "favicon";
|
||||||
|
const okExt = logo
|
||||||
|
? /\.(png|webp|svg)$/.test(name)
|
||||||
|
: /\.(png|webp|svg|ico)$/.test(name);
|
||||||
|
if (!okExt) {
|
||||||
|
return logo ? "Logo 仅支持 PNG / WebP / SVG" : "Favicon 仅支持 PNG / WebP / SVG / ICO";
|
||||||
|
}
|
||||||
|
const max = logo ? BRAND_LIMITS.logoBytes : BRAND_LIMITS.faviconBytes;
|
||||||
|
if (file.size > max) {
|
||||||
|
return logo ? "Logo 不能超过 2MB" : "Favicon 不能超过 512KB";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaURLFitsSlot(slot: BrandSlot, url: string): boolean {
|
||||||
|
const u = url.toLowerCase();
|
||||||
|
if (slot === "favicon") return /\.(png|webp|svg|ico)(\?|$)/.test(u);
|
||||||
|
return /\.(png|webp|svg)(\?|$)/.test(u);
|
||||||
|
}
|
||||||
@@ -43,6 +43,16 @@ export interface RtSettingsData {
|
|||||||
trust_reviewed_publish?: boolean;
|
trust_reviewed_publish?: boolean;
|
||||||
site_name?: string;
|
site_name?: string;
|
||||||
site_description?: string;
|
site_description?: string;
|
||||||
|
site_wordmark?: string;
|
||||||
|
site_slogan?: string;
|
||||||
|
site_keywords?: string[];
|
||||||
|
logo_light_url?: string;
|
||||||
|
logo_dark_url?: string;
|
||||||
|
favicon_url?: string;
|
||||||
|
brand_mark?: string;
|
||||||
|
brand_logo_size?: string;
|
||||||
|
brand_logo_fit?: string;
|
||||||
|
footer_links?: { label: string; url: string; new_tab: boolean }[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
allow_messages?: boolean;
|
allow_messages?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user