后台站点设计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))
}