后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

479
backend/service/brand.go Normal file
View 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))
}

View 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)
}
}

View 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")
}
}

View File

@@ -95,30 +95,40 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
type PublicSiteSettings struct {
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
BgAdminMode string `json:"bg_admin_mode"`
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
BgAdminMode string `json:"bg_admin_mode"`
SiteWordmark string `json:"site_wordmark"`
SiteSlogan string `json:"site_slogan"`
SiteKeywords []string `json:"site_keywords"`
LogoLightURL string `json:"logo_light_url"`
LogoDarkURL string `json:"logo_dark_url"`
FaviconURL string `json:"favicon_url"`
BrandMark string `json:"brand_mark"`
BrandLogoSize string `json:"brand_logo_size"`
BrandLogoFit string `json:"brand_logo_fit"`
FooterLinks []FooterLink `json:"footer_links"`
}
// SettingService 站点级键值设置
@@ -195,6 +205,11 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
ImageMaxMB: DefaultImageMaxMB,
BgSiteMode: DefaultBgMode,
BgAdminMode: DefaultBgMode,
SiteKeywords: []string{},
BrandMark: BrandMarkImageText,
BrandLogoSize: BrandLogoSizeSQ,
BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{},
}
accent, err := s.AccentColor()
if err != nil {
@@ -343,6 +358,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err
}
out.BgAdminMode = adminMode
if err := s.fillBrand(&out); err != nil {
return out, err
}
return out, nil
}

View File

@@ -71,7 +71,10 @@ func (s *UploadService) EnsureDir() error {
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
return err
}
return os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755)
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
return err
}
return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755)
}
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像