feat(home-banner): 轮播支持 PC/移动两端分图与热区跳转,热区图悬浮放大;修复上传压缩压白透明 PNG
This commit is contained in:
@@ -73,13 +73,48 @@ type AgreementContentDTO struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// HomeBannerItem 首页轮播项:文案 + PC/移动两端视觉配置(图片、整图点击、热区)。
|
||||
type HomeBannerItem struct {
|
||||
Eyebrow string `json:"eyebrow"`
|
||||
Title string `json:"title"`
|
||||
Badge string `json:"badge"`
|
||||
Pill string `json:"pill"`
|
||||
Tone string `json:"tone"`
|
||||
Eyebrow string `json:"eyebrow"`
|
||||
Title string `json:"title"`
|
||||
Badge string `json:"badge"`
|
||||
Pill string `json:"pill"`
|
||||
Tone string `json:"tone"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// ImageURL 早期扁平结构的图片字段,仅用于兼容读取:归一化时并入 PC/移动两端。
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
PC HomeBannerVisual `json:"pc"`
|
||||
Mobile HomeBannerVisual `json:"mobile"`
|
||||
}
|
||||
|
||||
// HomeBannerVisual 单端展示配置:图片 + 整图点击行为 + 热区。
|
||||
type HomeBannerVisual struct {
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Alt string `json:"alt,omitempty"`
|
||||
Action HomeBannerAction `json:"action"`
|
||||
Hotspots []HomeBannerHotspot `json:"hotspots,omitempty"`
|
||||
}
|
||||
|
||||
// HomeBannerAction 点击行为:none(不跳转)/ route(站内路径)/ link(外部链接)/ preview(查看大图)。
|
||||
type HomeBannerAction struct {
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
OpenNewTab bool `json:"open_new_tab"`
|
||||
}
|
||||
|
||||
// HomeBannerHotspot 热区:相对图片的百分比坐标(0-100),与出图分辨率解耦。
|
||||
type HomeBannerHotspot struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
// ImageURL 热区按钮图(透明 PNG),留空则前台用半透明高亮框提示可点击。
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
// HoverScale 悬浮放大倍数:1 表示不放大,建议 1.04-1.08。
|
||||
HoverScale float64 `json:"hover_scale,omitempty"`
|
||||
Action HomeBannerAction `json:"action"`
|
||||
}
|
||||
|
||||
type PublishOptionsDTO struct {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package systemconfig
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeHomeBannersMigratesLegacyImageURL(t *testing.T) {
|
||||
items := normalizeHomeBanners([]HomeBannerItem{
|
||||
{Title: "旧结构", ImageURL: " https://cdn.example/legacy.png "},
|
||||
})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("期望保留 1 条轮播,实际 %d", len(items))
|
||||
}
|
||||
if items[0].PC.ImageURL != "https://cdn.example/legacy.png" {
|
||||
t.Fatalf("旧图未迁移到 PC 端:%q", items[0].PC.ImageURL)
|
||||
}
|
||||
if items[0].Mobile.ImageURL != "https://cdn.example/legacy.png" {
|
||||
t.Fatalf("旧图未迁移到移动端:%q", items[0].Mobile.ImageURL)
|
||||
}
|
||||
if items[0].ImageURL != "" {
|
||||
t.Fatalf("旧字段应被清空,实际 %q", items[0].ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHomeBannersKeepsPerPlatformVisual(t *testing.T) {
|
||||
items := normalizeHomeBanners([]HomeBannerItem{{
|
||||
Title: "两端分图",
|
||||
PC: HomeBannerVisual{ImageURL: " https://cdn.example/pc.png "},
|
||||
Mobile: HomeBannerVisual{ImageURL: " https://cdn.example/mobile.png "},
|
||||
}})
|
||||
if items[0].PC.ImageURL != "https://cdn.example/pc.png" {
|
||||
t.Fatalf("PC 图被改动:%q", items[0].PC.ImageURL)
|
||||
}
|
||||
if items[0].Mobile.ImageURL != "https://cdn.example/mobile.png" {
|
||||
t.Fatalf("移动端图被改动:%q", items[0].Mobile.ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHomeBannersClampsHotspots(t *testing.T) {
|
||||
items := normalizeHomeBanners([]HomeBannerItem{{
|
||||
Title: "热区",
|
||||
PC: HomeBannerVisual{Hotspots: []HomeBannerHotspot{
|
||||
{ID: " h1 ", X: 90, Y: 95, Width: 20, Height: 20, Action: HomeBannerAction{Type: "route", Target: " /listings "}},
|
||||
{ID: "zero", Width: 0, Height: 10, Action: HomeBannerAction{Type: "route", Target: "/x"}},
|
||||
}},
|
||||
}})
|
||||
hotspots := items[0].PC.Hotspots
|
||||
if len(hotspots) != 1 {
|
||||
t.Fatalf("宽高为 0 的热区应被丢弃,剩余 %d 个", len(hotspots))
|
||||
}
|
||||
if hotspots[0].ID != "h1" || hotspots[0].X != 80 || hotspots[0].Y != 80 {
|
||||
t.Fatalf("热区未收敛到合法范围:%+v", hotspots[0])
|
||||
}
|
||||
if hotspots[0].Action.Type != "route" || hotspots[0].Action.Target != "/listings" {
|
||||
t.Fatalf("热区跳转未归一化:%+v", hotspots[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHomeBannersHotspotImageAndHoverScale(t *testing.T) {
|
||||
items := normalizeHomeBanners([]HomeBannerItem{{
|
||||
Title: "热区按钮图",
|
||||
PC: HomeBannerVisual{Hotspots: []HomeBannerHotspot{
|
||||
{ID: "a", Width: 10, Height: 10, ImageURL: " https://cdn.example/btn.png "},
|
||||
{ID: "b", Width: 10, Height: 10, HoverScale: 9},
|
||||
{ID: "c", Width: 10, Height: 10, HoverScale: 0.5},
|
||||
}},
|
||||
}})
|
||||
hotspots := items[0].PC.Hotspots
|
||||
if hotspots[0].ImageURL != "https://cdn.example/btn.png" {
|
||||
t.Fatalf("热区按钮图未清理空白:%q", hotspots[0].ImageURL)
|
||||
}
|
||||
if hotspots[0].HoverScale != defaultBannerHotspotHoverScale {
|
||||
t.Fatalf("未配置悬浮倍数应回落默认值,实际 %v", hotspots[0].HoverScale)
|
||||
}
|
||||
if hotspots[1].HoverScale != maxBannerHotspotHoverScale {
|
||||
t.Fatalf("超上限的悬浮倍数应被收敛,实际 %v", hotspots[1].HoverScale)
|
||||
}
|
||||
if hotspots[2].HoverScale != 1 {
|
||||
t.Fatalf("小于 1 的悬浮倍数应收敛为 1,实际 %v", hotspots[2].HoverScale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBannerActionFallsBackToNone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input HomeBannerAction
|
||||
expect HomeBannerAction
|
||||
}{
|
||||
{
|
||||
name: "缺失目标的站内跳转退化为不跳转",
|
||||
input: HomeBannerAction{Type: "route", Target: " "},
|
||||
expect: HomeBannerAction{Type: "none"},
|
||||
},
|
||||
{
|
||||
name: "未知类型退化为不跳转",
|
||||
input: HomeBannerAction{Type: "unknown", Target: "/x"},
|
||||
expect: HomeBannerAction{Type: "none"},
|
||||
},
|
||||
{
|
||||
name: "查看大图不保留目标与新窗口",
|
||||
input: HomeBannerAction{Type: "preview", Target: "/x", OpenNewTab: true},
|
||||
expect: HomeBannerAction{Type: "preview"},
|
||||
},
|
||||
{
|
||||
name: "外链保留新窗口标记",
|
||||
input: HomeBannerAction{Type: "link", Target: " https://cdn.example/a ", OpenNewTab: true},
|
||||
expect: HomeBannerAction{Type: "link", Target: "https://cdn.example/a", OpenNewTab: true},
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := normalizeBannerAction(testCase.input); got != testCase.expect {
|
||||
t.Fatalf("期望 %+v,实际 %+v", testCase.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHomeBannersFallsBackToDefaults(t *testing.T) {
|
||||
items := normalizeHomeBanners([]HomeBannerItem{{Title: " "}})
|
||||
if len(items) != len(defaultHomeBanners()) {
|
||||
t.Fatalf("全部无效时应回落默认轮播,实际 %d 条", len(items))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 热区悬浮放大倍数:与前端 BANNER_HOTSPOT_DEFAULT_HOVER_SCALE 保持一致。
|
||||
const defaultBannerHotspotHoverScale = 1.06
|
||||
const maxBannerHotspotHoverScale = 1.5
|
||||
|
||||
func normalizeAnnouncements(items []string) []string {
|
||||
normalized := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
@@ -26,11 +31,15 @@ func normalizeHomeBanners(items []HomeBannerItem) []HomeBannerItem {
|
||||
item.Badge = strings.TrimSpace(item.Badge)
|
||||
item.Pill = strings.TrimSpace(item.Pill)
|
||||
item.Tone = strings.TrimSpace(item.Tone)
|
||||
item.ImageURL = strings.TrimSpace(item.ImageURL)
|
||||
if item.Title == "" && item.ImageURL == "" {
|
||||
// 旧结构只有一张图,两端共用,避免历史配置升级后丢图
|
||||
legacyImageURL := strings.TrimSpace(item.ImageURL)
|
||||
item.ImageURL = ""
|
||||
item.PC = normalizeBannerVisual(item.PC, legacyImageURL)
|
||||
item.Mobile = normalizeBannerVisual(item.Mobile, legacyImageURL)
|
||||
if item.Title == "" && item.PC.ImageURL == "" && item.Mobile.ImageURL == "" {
|
||||
continue
|
||||
}
|
||||
if item.Tone != "green" && item.Tone != "orange" {
|
||||
if item.Tone != "green" && item.Tone != "orange" && item.Tone != "gold" {
|
||||
item.Tone = "blue"
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
@@ -41,6 +50,88 @@ func normalizeHomeBanners(items []HomeBannerItem) []HomeBannerItem {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeBannerVisual(visual HomeBannerVisual, legacyImageURL string) HomeBannerVisual {
|
||||
visual.ImageURL = strings.TrimSpace(visual.ImageURL)
|
||||
if visual.ImageURL == "" {
|
||||
visual.ImageURL = legacyImageURL
|
||||
}
|
||||
visual.Alt = strings.TrimSpace(visual.Alt)
|
||||
visual.Action = normalizeBannerAction(visual.Action)
|
||||
hotspots := make([]HomeBannerHotspot, 0, len(visual.Hotspots))
|
||||
for _, hotspot := range visual.Hotspots {
|
||||
item, ok := normalizeBannerHotspot(hotspot)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hotspots = append(hotspots, item)
|
||||
}
|
||||
visual.Hotspots = hotspots
|
||||
return visual
|
||||
}
|
||||
|
||||
// normalizeBannerHotspot 收敛热区坐标:宽高为 0 的热区无意义直接丢弃,其余保证不越界。
|
||||
func normalizeBannerHotspot(hotspot HomeBannerHotspot) (HomeBannerHotspot, bool) {
|
||||
width := clampBannerPercent(hotspot.Width, 100)
|
||||
height := clampBannerPercent(hotspot.Height, 100)
|
||||
if width <= 0 || height <= 0 {
|
||||
return HomeBannerHotspot{}, false
|
||||
}
|
||||
hotspot.ID = strings.TrimSpace(hotspot.ID)
|
||||
hotspot.Label = strings.TrimSpace(hotspot.Label)
|
||||
hotspot.ImageURL = strings.TrimSpace(hotspot.ImageURL)
|
||||
hotspot.HoverScale = normalizeHotspotHoverScale(hotspot.HoverScale)
|
||||
hotspot.Width = width
|
||||
hotspot.Height = height
|
||||
hotspot.X = clampBannerPercent(hotspot.X, 100-width)
|
||||
hotspot.Y = clampBannerPercent(hotspot.Y, 100-height)
|
||||
hotspot.Action = normalizeBannerAction(hotspot.Action)
|
||||
return hotspot, true
|
||||
}
|
||||
|
||||
// normalizeHotspotHoverScale 悬浮放大倍数:缺失/非法回落默认值,上限 1.5 防止热区盖住整张图。
|
||||
func normalizeHotspotHoverScale(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return defaultBannerHotspotHoverScale
|
||||
}
|
||||
if value > maxBannerHotspotHoverScale {
|
||||
value = maxBannerHotspotHoverScale
|
||||
} else if value < 1 {
|
||||
value = 1
|
||||
}
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
// normalizeBannerAction 目标缺失的跳转会变成「点了没反应」的死热区,统一退化为 none。
|
||||
func normalizeBannerAction(action HomeBannerAction) HomeBannerAction {
|
||||
action.Type = strings.TrimSpace(action.Type)
|
||||
action.Target = strings.TrimSpace(action.Target)
|
||||
switch action.Type {
|
||||
case "route", "link":
|
||||
if action.Target == "" {
|
||||
return HomeBannerAction{Type: "none"}
|
||||
}
|
||||
case "preview":
|
||||
action.Target = ""
|
||||
action.OpenNewTab = false
|
||||
case "none":
|
||||
action.Target = ""
|
||||
action.OpenNewTab = false
|
||||
default:
|
||||
return HomeBannerAction{Type: "none"}
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func clampBannerPercent(value float64, max float64) float64 {
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value > max {
|
||||
value = max
|
||||
}
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func normalizeOrderAgreements(agreements *OrderAgreementsDTO) {
|
||||
defaults := DefaultOrderAgreements()
|
||||
agreements.VirtualAssetPurchase.Title = strings.TrimSpace(agreements.VirtualAssetPurchase.Title)
|
||||
|
||||
Reference in New Issue
Block a user