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)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 首页轮播图配置说明
|
||||
|
||||
> 适用范围:PC 首页 Hero 轮播(`HomeBanner.vue`)、移动端首页 Banner 轮播(`MobileHomeView.vue`)、后台「首页轮播图」弹窗(`HomeBannersDialog.vue`)。
|
||||
> 配置存储位置:系统配置表 `mobile.home_banners`(JSON 字符串),前台接口 `GET /api/mobile-home-config`。
|
||||
|
||||
## 一、出图规范(两端容器严格等比,按规范出图不裁切)
|
||||
|
||||
| 端 | 展示容器比例 | 建议出图尺寸 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| PC | 4:1 | **1600×400** | Hero 卡片宽度自适应,高度由 `aspect-ratio: 1600 / 400` 推导 |
|
||||
| 移动端 | 2:1 | **750×375**(或 720×360) | 只要有一条配图,整条轮播统一按 `aspect-ratio: 2 / 1` 展示 |
|
||||
|
||||
两端使用**不同的图片字段**,必须分别出图:
|
||||
|
||||
- PC 端只用 `pc.image_url`;
|
||||
- 移动端只用 `mobile.image_url`;
|
||||
- 若该端未配图,会退回文字卡片(眉标 + 主标题 + 胶囊文案),不会用另一端的图硬撑(避免严重裁切)。
|
||||
|
||||
历史数据兼容:早期只有扁平 `image_url` 的轮播,读取时会自动并入 `pc.image_url` 与 `mobile.image_url`,不会丢图。
|
||||
|
||||
## 二、数据结构
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"eyebrow": "三角洲行动账号专区",
|
||||
"title": "高哈夫币 · 安全交接 · 随租随玩",
|
||||
"badge": "HOT",
|
||||
"pill": "新人领券最高减 20 元",
|
||||
"tone": "blue",
|
||||
"enabled": true,
|
||||
"pc": {
|
||||
"image_url": "https://cdn.example/banner-pc.jpg",
|
||||
"alt": "首页活动横幅",
|
||||
"action": { "type": "route", "target": "/listings", "open_new_tab": false },
|
||||
"hotspots": [
|
||||
{
|
||||
"id": "hotspot-1",
|
||||
"label": "租币教程",
|
||||
"x": 6,
|
||||
"y": 20,
|
||||
"width": 20,
|
||||
"height": 24,
|
||||
"action": { "type": "route", "target": "/m/add-home-guide", "open_new_tab": false }
|
||||
}
|
||||
]
|
||||
},
|
||||
"mobile": { "image_url": "...", "action": {}, "hotspots": [] }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `enabled`:`false` 时前台不展示该条轮播(后台仍保留配置)。
|
||||
- `pc` / `mobile`:两端独立配置,结构相同(图片、整图点击行为、热区)。
|
||||
- `action.type`:`none` 不跳转 / `route` 站内路径 / `link` 外部链接 / `preview` 查看大图。
|
||||
- `action.open_new_tab`:仅 `route`、`link` 有意义。
|
||||
- `hotspots[].x/y/width/height`:**相对图片的百分比(0-100)**,与出图分辨率解耦,改分辨率不会跑位。
|
||||
- `hotspots[].image_url`:热区按钮图(透明 PNG)。配了图就渲染成海报上的「胶囊按钮」,留空则用半透明高亮框提示可点击。
|
||||
- `hotspots[].hover_scale`:鼠标悬浮时按钮图的放大倍数,默认 `1.06`,范围 `1-1.5`(`1` = 不放大)。
|
||||
|
||||
## 三、热区按钮图导出踩坑(重要)
|
||||
|
||||
热区按钮图**必须是带 alpha 透明通道的 PNG**,否则整个矩形会盖住海报。最常见的坑:
|
||||
|
||||
- 把设计工具/Figma/PS 里显示「透明区域」的**灰白棋盘格预览截图或另存**,棋盘格就成了真实像素,前台会照实显示成一块格子;
|
||||
- 从 AI 出图工具导出时选了不透明背景(或导出成 JPG),透明区域变成白底;
|
||||
- 直接把整张海报(含按钮)当热区图用,等于又盖了一层。
|
||||
|
||||
自查方法:
|
||||
|
||||
1. 把 PNG 拖到纯深色背景的窗口里看,如果还能看到灰白格子,就是图里带的;
|
||||
2. macOS 可用 `sips -g hasAlpha 图片.png` 检查是否真有 alpha 通道(`hasAlpha: yes` 才算透明图)。
|
||||
|
||||
后台已经加了兜底提示:上传「热区按钮图」时会做一次像素体检,若**没有任何透明像素**会立刻提示(「只有白/浅灰底色」还会明确点出疑似棋盘格);上传海报底图时也会提示疑似棋盘格的情况。提示只是提醒,不会阻止保存。
|
||||
|
||||
修复办法:回到设计源文件,隐藏背景后重新导出为 PNG(不要截图预览图);若只有一张带底纹的图,可在 PS 里用「色彩范围」选中浅灰网格后删除再导出。
|
||||
|
||||
> 已修复的历史坑:后台上传前会做一次「压缩优化」,旧实现先给 canvas 铺白底再编码,会把透明区域压成白底方块(透明按钮图上传后变白色矩形)。现在改为:检测到原图带透明像素时**不铺白底**并输出 `image/png`(无损保留 alpha),不透明图仍走 WebP 压缩。代码:`frontend/src/shared/utils/imageUpload.ts`。修复前上传过的图需要重新上传。
|
||||
|
||||
## 四、点击行为与悬浮反馈
|
||||
|
||||
1. 命中热区(且热区配置了真实跳转)→ 执行该热区的跳转;
|
||||
2. 未命中热区 → 执行该端「整图点击」行为(`none` 时不跳转);
|
||||
3. 完全没有配置跳转(整图 `none` 且无有效热区)→ 保留原有「点击查看大图」能力。
|
||||
|
||||
`preview` 类型会打开大图预览:PC 用 Element Plus `el-image-viewer`,移动端用内置 lightbox(2:1 框 + `object-fit: contain`,整图完整可见)。
|
||||
|
||||
悬浮反馈分两种:
|
||||
|
||||
- 热区配了 `image_url`:渲染透明 PNG 按钮图,鼠标悬浮时按 `hover_scale` 轻微放大(`transform: scale()` + `0.2s` 过渡)并加投影,按下时轻微回缩;
|
||||
- 热区没配图:悬浮显示半透明高亮框,提示「这里可点」。
|
||||
|
||||
这正是海报型轮播的常见做法:把海报上的按钮单独切透明 PNG 叠在底图上,既能点击跳转,又有「按钮浮起来」的手感。运营操作要点:
|
||||
|
||||
1. 底图(PC 1600×400 / 移动 750×375)里**不要**烘焙按钮,按钮留空;
|
||||
2. 把按钮单独导出为透明 PNG(尺寸与海报上的位置匹配);
|
||||
3. 后台在同一端的预览图上拖框对齐,再填该按钮的跳转。
|
||||
|
||||
## 五、后台怎么配
|
||||
|
||||
后台 → 系统配置 → `mobile.home_banners` → 「编辑首页轮播图」:
|
||||
|
||||
1. 每条轮播是一张卡片,可启用/停用、上移/下移、删除;卡片内先填文案(眉标/主标题/角标/胶囊/色调),无图时按文字卡片展示。
|
||||
2. 切到「PC 端 1600×400(4:1)」或「移动端 750×375(2:1)」页签,上传/填写该端图片,并配置「整图点击」行为。
|
||||
3. 在预览图上**单击**新增热区 → **拖动**移动 → 拖右下角小方块缩放;下方可为选中热区设置名称、精确坐标、跳转,以及**热区按钮图**与**悬浮放大倍数**(后台预览图会把按钮图半透明铺在框内,所见即所得)。
|
||||
4. 保存时写入 `mobile.home_banners`,后台会顺带把旧的扁平 `image_url` 结构升级为 `pc` / `mobile` 结构。
|
||||
|
||||
## 六、校验与兜底
|
||||
|
||||
- 前后端都会归一化配置:坐标收敛到 `0-100` 且不越界,宽高为 0 的热区直接丢弃;`route` / `link` 缺少目标时退化为 `none`(避免「点了没反应」的死热区)。
|
||||
- 后端归一化实现:`backend/internal/modules/systemconfig/normalize.go`(`normalizeHomeBanners`)。
|
||||
- 前端归一化实现:`frontend/src/features/listings/api/homeConfig.ts`(`normalizeHomeBanner`)。
|
||||
- 相关测试:`frontend/src/features/listings/api/homeConfig.spec.ts`、`backend/internal/modules/systemconfig/home_banners_test.go`。
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
bannerActionHints,
|
||||
bannerActionOptions,
|
||||
type BannerAction,
|
||||
type BannerActionType,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: BannerAction
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: BannerAction]
|
||||
}>()
|
||||
|
||||
const action = computed(() => props.modelValue)
|
||||
const needTarget = computed(() => action.value.type === 'route' || action.value.type === 'link')
|
||||
|
||||
function patch(partial: Partial<BannerAction>) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...partial })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="banner-action-editor">
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select
|
||||
:model-value="action.type"
|
||||
class="action-type"
|
||||
@update:model-value="(value: BannerActionType) => patch({ type: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bannerActionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="action-hint">{{ bannerActionHints[action.type] }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="needTarget" label="跳转目标">
|
||||
<el-input
|
||||
:model-value="action.target"
|
||||
:placeholder="action.type === 'link' ? 'https://example.com/act' : '/m/announcements'"
|
||||
@update:model-value="(value: string) => patch({ target: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="needTarget" label="打开方式">
|
||||
<el-switch
|
||||
:model-value="action.open_new_tab"
|
||||
active-text="新窗口"
|
||||
inactive-text="当前页"
|
||||
inline-prompt
|
||||
@update:model-value="
|
||||
(value: string | number | boolean) => patch({ open_new_tab: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-action-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-type {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin-left: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,689 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
createBannerHotspot,
|
||||
hasBannerImage,
|
||||
type BannerHotspot,
|
||||
type BannerVisual,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import { uploadAdminFile } from '@/shared/api/files'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { probeImageTransparency } from '@/shared/utils/imageTransparency'
|
||||
import BannerActionEditor from './BannerActionEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: BannerVisual
|
||||
ratio: { width: number; height: number }
|
||||
label: string
|
||||
standard: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: BannerVisual]
|
||||
}>()
|
||||
|
||||
type DragMode = 'move' | 'resize'
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
mode: DragMode
|
||||
clientX: number
|
||||
clientY: number
|
||||
origin: BannerHotspot
|
||||
}
|
||||
|
||||
const uploading = ref(false)
|
||||
const uploadingHotspot = ref(false)
|
||||
const bannerImageWarning = ref('')
|
||||
const hotspotImageWarning = ref('')
|
||||
const selectedId = ref('')
|
||||
const canvasRef = ref<HTMLElement | null>(null)
|
||||
const dragState = ref<DragState | null>(null)
|
||||
/** 拖拽结束会紧跟一次 click,用它避免「拖完又新增一个热区」。 */
|
||||
let dragging = false
|
||||
|
||||
const visual = computed(() => props.modelValue)
|
||||
const canvasStyle = computed(() => ({
|
||||
aspectRatio: `${props.ratio.width} / ${props.ratio.height}`,
|
||||
}))
|
||||
const selectedHotspot = computed(
|
||||
() => visual.value.hotspots.find(item => item.id === selectedId.value) ?? null
|
||||
)
|
||||
|
||||
function update(patch: Partial<BannerVisual>) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
}
|
||||
|
||||
function updateHotspot(id: string, patch: Partial<BannerHotspot>) {
|
||||
update({
|
||||
hotspots: visual.value.hotspots.map(item => (item.id === id ? { ...item, ...patch } : item)),
|
||||
})
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.round(Math.min(Math.max(value, min), Math.max(max, min)) * 100) / 100
|
||||
}
|
||||
|
||||
async function handleUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'home-banner')
|
||||
update({ image_url: uploaded.url })
|
||||
ElMessage.success('图片已上传')
|
||||
await warnAboutBannerImage(file)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '图片上传失败'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 底图应当被子元素盖住的部分自己处理好;这里只提示「棋盘格/纯色底被烘焙进来」的情况。 */
|
||||
async function warnAboutBannerImage(file: File) {
|
||||
bannerImageWarning.value = ''
|
||||
const probe = await probeImageTransparency(file)
|
||||
if (!probe?.suspectedBakedBackdrop) return
|
||||
bannerImageWarning.value =
|
||||
'这张图几乎只有白/浅灰两种颜色且没有透明区域,很像把「透明棋盘格」一起导出了,前台会照实显示。'
|
||||
ElMessage.warning('图片疑似带棋盘格底纹,请检查后重新导出')
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
update({ image_url: '' })
|
||||
bannerImageWarning.value = ''
|
||||
selectedId.value = ''
|
||||
}
|
||||
|
||||
/** 热区按钮图:运营把海报上的按钮单独切透明 PNG 传进来,悬浮时会轻微放大。 */
|
||||
async function handleHotspotUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
const target = selectedHotspot.value
|
||||
if (!file || !target) return
|
||||
uploadingHotspot.value = true
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'home-banner')
|
||||
patchSelected({ image_url: uploaded.url })
|
||||
ElMessage.success('热区图片已上传')
|
||||
await warnAboutHotspotImage(file)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '热区图片上传失败'))
|
||||
} finally {
|
||||
uploadingHotspot.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 热区按钮图必须是带透明通道的 PNG,否则会整块盖住海报(常见坑:把带棋盘格的预览图另存)。 */
|
||||
async function warnAboutHotspotImage(file: File) {
|
||||
hotspotImageWarning.value = ''
|
||||
const probe = await probeImageTransparency(file)
|
||||
if (!probe || probe.hasTransparentPixels) return
|
||||
hotspotImageWarning.value = probe.suspectedBakedBackdrop
|
||||
? '这张图没有透明区域,且只有白/浅灰底色:很可能是把「透明棋盘格」一起导出了,前台会原样显示这块格子。请重新导出带透明通道的 PNG。'
|
||||
: '这张图没有透明区域,会整块盖住海报背景;透明按钮图请用带 alpha 通道的 PNG。'
|
||||
ElMessage.warning('热区图片不带透明区域,请确认导出方式')
|
||||
}
|
||||
|
||||
/** 在图片空白处单击:以点击位置为中心新增一个默认尺寸热区。 */
|
||||
function addHotspotAt(event: MouseEvent) {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || dragging) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const width = 24
|
||||
const height = 24
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 100
|
||||
const hotspot = createBannerHotspot({
|
||||
x: clamp(x - width / 2, 0, 100 - width),
|
||||
y: clamp(y - height / 2, 0, 100 - height),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
update({ hotspots: [...visual.value.hotspots, hotspot] })
|
||||
selectedId.value = hotspot.id
|
||||
}
|
||||
|
||||
function hotspotStyle(hotspot: BannerHotspot) {
|
||||
return {
|
||||
left: `${hotspot.x}%`,
|
||||
top: `${hotspot.y}%`,
|
||||
width: `${hotspot.width}%`,
|
||||
height: `${hotspot.height}%`,
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(event: MouseEvent, hotspot: BannerHotspot, mode: DragMode) {
|
||||
event.preventDefault()
|
||||
selectedId.value = hotspot.id
|
||||
dragState.value = {
|
||||
id: hotspot.id,
|
||||
mode,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
origin: { ...hotspot },
|
||||
}
|
||||
window.addEventListener('mousemove', handleDragMove)
|
||||
window.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
function handleDragMove(event: MouseEvent) {
|
||||
const state = dragState.value
|
||||
const canvas = canvasRef.value
|
||||
if (!state || !canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const deltaX = ((event.clientX - state.clientX) / rect.width) * 100
|
||||
const deltaY = ((event.clientY - state.clientY) / rect.height) * 100
|
||||
const target = visual.value.hotspots.find(item => item.id === state.id)
|
||||
if (!target) return
|
||||
if (state.mode === 'move') {
|
||||
updateHotspot(state.id, {
|
||||
x: clamp(state.origin.x + deltaX, 0, 100 - target.width),
|
||||
y: clamp(state.origin.y + deltaY, 0, 100 - target.height),
|
||||
})
|
||||
return
|
||||
}
|
||||
updateHotspot(state.id, {
|
||||
width: clamp(state.origin.width + deltaX, 4, 100 - target.x),
|
||||
height: clamp(state.origin.height + deltaY, 4, 100 - target.y),
|
||||
})
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
if (!dragState.value) return
|
||||
dragging = true
|
||||
window.removeEventListener('mousemove', handleDragMove)
|
||||
window.removeEventListener('mouseup', stopDrag)
|
||||
dragState.value = null
|
||||
window.setTimeout(() => {
|
||||
dragging = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function selectHotspot(hotspot: BannerHotspot) {
|
||||
selectedId.value = hotspot.id
|
||||
hotspotImageWarning.value = ''
|
||||
}
|
||||
|
||||
/** 手动改 URL 时清空上一次的图片体检提示,避免误导。 */
|
||||
function setBannerImageUrl(value: string) {
|
||||
bannerImageWarning.value = ''
|
||||
update({ image_url: value })
|
||||
}
|
||||
|
||||
function setHotspotImageUrl(value: string) {
|
||||
hotspotImageWarning.value = ''
|
||||
patchSelected({ image_url: value })
|
||||
}
|
||||
|
||||
function clearHotspotImage() {
|
||||
hotspotImageWarning.value = ''
|
||||
patchSelected({ image_url: '' })
|
||||
}
|
||||
|
||||
/** 模板里统一走「当前选中热区」,避免在模板中写非空断言。 */
|
||||
function patchSelected(patch: Partial<BannerHotspot>) {
|
||||
const target = selectedHotspot.value
|
||||
if (!target) return
|
||||
updateHotspot(target.id, patch)
|
||||
}
|
||||
|
||||
function removeSelectedHotspot() {
|
||||
const target = selectedHotspot.value
|
||||
if (!target) return
|
||||
removeHotspot(target.id)
|
||||
}
|
||||
|
||||
function removeHotspot(id: string) {
|
||||
update({ hotspots: visual.value.hotspots.filter(item => item.id !== id) })
|
||||
if (selectedId.value === id) selectedId.value = ''
|
||||
}
|
||||
|
||||
function clearHotspots() {
|
||||
update({ hotspots: [] })
|
||||
selectedId.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', handleDragMove)
|
||||
window.removeEventListener('mouseup', stopDrag)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visual-editor">
|
||||
<div class="visual-head">
|
||||
<strong>{{ label }}</strong>
|
||||
<span class="visual-standard">建议出图 {{ standard }}</span>
|
||||
</div>
|
||||
|
||||
<div class="visual-image-row">
|
||||
<el-input
|
||||
:model-value="visual.image_url"
|
||||
placeholder="图片 URL,留空则该端退回文字卡片"
|
||||
@update:model-value="setBannerImageUrl"
|
||||
/>
|
||||
<label class="upload-trigger">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" @change="handleUpload" />
|
||||
{{ uploading ? '上传中' : '上传图片' }}
|
||||
</label>
|
||||
<el-button size="small" :disabled="!visual.image_url" @click="clearImage">清除图片</el-button>
|
||||
</div>
|
||||
<p v-if="bannerImageWarning" class="image-warning">{{ bannerImageWarning }}</p>
|
||||
|
||||
<div ref="canvasRef" class="visual-canvas" :style="canvasStyle" @click="addHotspotAt">
|
||||
<img v-if="hasBannerImage(visual)" :src="visual.image_url" alt="" />
|
||||
<div v-else class="visual-empty">先上传图片,再在图上单击新增热区</div>
|
||||
<div
|
||||
v-for="(hotspot, index) in visual.hotspots"
|
||||
:key="hotspot.id"
|
||||
class="hotspot-box"
|
||||
:class="{ active: hotspot.id === selectedId }"
|
||||
:style="hotspotStyle(hotspot)"
|
||||
@click.stop="selectHotspot(hotspot)"
|
||||
@mousedown.stop="startDrag($event, hotspot, 'move')"
|
||||
>
|
||||
<img
|
||||
v-if="hotspot.image_url"
|
||||
class="hotspot-box-image"
|
||||
:src="hotspot.image_url"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<span class="hotspot-index">{{ index + 1 }}</span>
|
||||
<span class="hotspot-name">{{ hotspot.label || '未命名热区' }}</span>
|
||||
<i class="hotspot-resize" @mousedown.stop="startDrag($event, hotspot, 'resize')" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="visual-tip">
|
||||
在预览图上单击可新增热区,按住拖动可移动,拖右下角小方块可缩放;两端坐标按百分比保存,换分辨率也不跑位。
|
||||
热区按钮图必须是<b>带透明通道的 PNG</b
|
||||
>:如果图里能看到灰白格子,说明导出时把「透明棋盘格」也存成了像素,前台会原样显示。
|
||||
</p>
|
||||
|
||||
<el-form label-width="86px" class="visual-form">
|
||||
<BannerActionEditor
|
||||
:model-value="visual.action"
|
||||
@update:model-value="value => update({ action: value })"
|
||||
/>
|
||||
<el-form-item label="整图说明">
|
||||
<el-input
|
||||
:model-value="visual.alt"
|
||||
placeholder="无障碍描述(可选,如「首页活动横幅」)"
|
||||
@update:model-value="(value: string) => update({ alt: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="hotspot-section">
|
||||
<div class="hotspot-section-head">
|
||||
<strong>热区({{ visual.hotspots.length }})</strong>
|
||||
<el-button size="small" :disabled="!visual.hotspots.length" @click="clearHotspots">
|
||||
清空热区
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="!visual.hotspots.length" class="visual-tip">
|
||||
未配置热区时,整张图按上面的「整图点击」行为响应。
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="hotspot-chips">
|
||||
<button
|
||||
v-for="(hotspot, index) in visual.hotspots"
|
||||
:key="hotspot.id"
|
||||
type="button"
|
||||
class="hotspot-chip"
|
||||
:class="{ active: hotspot.id === selectedId }"
|
||||
@click="selectHotspot(hotspot)"
|
||||
>
|
||||
{{ index + 1 }}. {{ hotspot.label || '未命名热区' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-form v-if="selectedHotspot" label-width="86px" class="hotspot-form" @submit.prevent>
|
||||
<el-form-item label="热区名称">
|
||||
<el-input
|
||||
:model-value="selectedHotspot.label"
|
||||
placeholder="如 租币教程"
|
||||
@update:model-value="(value: string) => patchSelected({ label: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="热区图片">
|
||||
<div class="hotspot-image-row">
|
||||
<el-input
|
||||
class="hotspot-image-input"
|
||||
:model-value="selectedHotspot.image_url"
|
||||
placeholder="透明 PNG 按钮图 URL,留空则用高亮框"
|
||||
@update:model-value="setHotspotImageUrl"
|
||||
/>
|
||||
<label class="upload-trigger">
|
||||
<input type="file" accept="image/png,image/webp" @change="handleHotspotUpload" />
|
||||
{{ uploadingHotspot ? '上传中' : '上传按钮图' }}
|
||||
</label>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="!selectedHotspot.image_url"
|
||||
@click="clearHotspotImage"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="hotspotImageWarning" class="image-warning">{{ hotspotImageWarning }}</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="悬浮放大">
|
||||
<el-input-number
|
||||
:model-value="selectedHotspot.hover_scale"
|
||||
:min="1"
|
||||
:max="1.5"
|
||||
:precision="2"
|
||||
:step="0.02"
|
||||
controls-position="right"
|
||||
@update:model-value="
|
||||
(value: number | undefined) => patchSelected({ hover_scale: value ?? 1 })
|
||||
"
|
||||
/>
|
||||
<span class="action-hint">1 = 不放大,建议 1.04-1.08(鼠标悬浮时按钮图放大)</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="位置尺寸">
|
||||
<div class="hotspot-numbers">
|
||||
<el-input-number
|
||||
:model-value="selectedHotspot.x"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
controls-position="right"
|
||||
@update:model-value="
|
||||
(value: number | undefined) => patchSelected({ x: value ?? 0 })
|
||||
"
|
||||
/>
|
||||
<el-input-number
|
||||
:model-value="selectedHotspot.y"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
controls-position="right"
|
||||
@update:model-value="
|
||||
(value: number | undefined) => patchSelected({ y: value ?? 0 })
|
||||
"
|
||||
/>
|
||||
<el-input-number
|
||||
:model-value="selectedHotspot.width"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
controls-position="right"
|
||||
@update:model-value="
|
||||
(value: number | undefined) => patchSelected({ width: value ?? 1 })
|
||||
"
|
||||
/>
|
||||
<el-input-number
|
||||
:model-value="selectedHotspot.height"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
controls-position="right"
|
||||
@update:model-value="
|
||||
(value: number | undefined) => patchSelected({ height: value ?? 1 })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<span class="action-hint">依次为 X / Y / 宽 / 高(百分比,0-100)</span>
|
||||
</el-form-item>
|
||||
|
||||
<BannerActionEditor
|
||||
:model-value="selectedHotspot.action"
|
||||
@update:model-value="value => patchSelected({ action: value })"
|
||||
/>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="danger" plain size="small" @click="removeSelectedHotspot">
|
||||
删除该热区
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visual-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visual-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visual-standard {
|
||||
color: #ff6a00;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.visual-image-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.visual-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 50% / 18px 18px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.visual-canvas img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visual-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hotspot-box {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
border: 1px dashed rgba(255, 106, 0, 0.85);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 106, 0, 0.16);
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
cursor: move;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 后台所见即所得:把热区按钮图半透明铺在框内,方便对齐海报上的按钮 */
|
||||
.hotspot-box-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0.82;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hotspot-box.active {
|
||||
border-style: solid;
|
||||
background: rgba(255, 106, 0, 0.32);
|
||||
box-shadow: 0 0 0 2px rgba(255, 106, 0, 0.35);
|
||||
}
|
||||
|
||||
.hotspot-index {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hotspot-name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.hotspot-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 2px solid #ff6a00;
|
||||
border-bottom: 2px solid #ff6a00;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.visual-tip {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.image-warning {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: #d97706;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.visual-form,
|
||||
.hotspot-form {
|
||||
padding: 10px 12px 0;
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 10px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.hotspot-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hotspot-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hotspot-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hotspot-chip {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hotspot-chip.active {
|
||||
border-color: #ff6a00;
|
||||
background: #fff4ea;
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hotspot-numbers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hotspot-numbers :deep(.el-input-number) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.hotspot-image-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotspot-image-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin-left: 8px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-trigger input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { uploadAdminFile } from '@/shared/api/files'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import {
|
||||
createEmptyBannerVisual,
|
||||
defaultHomeBanners,
|
||||
HOME_BANNER_MOBILE_RATIO,
|
||||
HOME_BANNER_PC_RATIO,
|
||||
mergeHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/shared/utils/json'
|
||||
import BannerVisualEditor from './BannerVisualEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -25,7 +28,6 @@ const emit = defineEmits<{
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const homeBannersDraft = ref<HomeBannerSlide[]>([])
|
||||
const uploadingBannerIndex = ref<number | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -40,6 +42,7 @@ watch(
|
||||
|
||||
function parseHomeBanners(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultHomeBanners)
|
||||
// mergeHomeConfig 顺带把旧的扁平 image_url 结构升级成 pc/mobile 两端结构
|
||||
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
|
||||
}
|
||||
|
||||
@@ -47,6 +50,16 @@ function cloneHomeBanners(banners: HomeBannerSlide[]) {
|
||||
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
|
||||
}
|
||||
|
||||
function hasContent(banner: HomeBannerSlide) {
|
||||
return Boolean(
|
||||
banner.title.trim() ||
|
||||
banner.pc.image_url.trim() ||
|
||||
banner.mobile.image_url.trim() ||
|
||||
banner.pc.hotspots.length ||
|
||||
banner.mobile.hotspots.length
|
||||
)
|
||||
}
|
||||
|
||||
function addHomeBanner() {
|
||||
homeBannersDraft.value.push({
|
||||
eyebrow: '首页推荐',
|
||||
@@ -54,7 +67,9 @@ function addHomeBanner() {
|
||||
badge: 'NEW',
|
||||
pill: '',
|
||||
tone: 'blue',
|
||||
image_url: '',
|
||||
enabled: true,
|
||||
pc: createEmptyBannerVisual(),
|
||||
mobile: createEmptyBannerVisual(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,41 +77,23 @@ function removeHomeBanner(index: number) {
|
||||
homeBannersDraft.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetHomeBanners() {
|
||||
homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners)
|
||||
function moveHomeBanner(index: number, step: number) {
|
||||
const target = index + step
|
||||
const list = homeBannersDraft.value
|
||||
if (target < 0 || target >= list.length) return
|
||||
const [item] = list.splice(index, 1)
|
||||
if (!item) return
|
||||
list.splice(target, 0, item)
|
||||
}
|
||||
|
||||
async function handleHomeBannerUpload(event: Event, index: number) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploadingBannerIndex.value = index
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'home-banner')
|
||||
const banner = homeBannersDraft.value[index]
|
||||
if (banner) {
|
||||
banner.image_url = uploaded.url
|
||||
if (!banner.title) {
|
||||
banner.title = banner.eyebrow || '首页轮播图'
|
||||
}
|
||||
}
|
||||
ElMessage.success('图片已上传')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '图片上传失败'))
|
||||
} finally {
|
||||
uploadingBannerIndex.value = null
|
||||
}
|
||||
function resetHomeBanners() {
|
||||
homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(
|
||||
homeBannersDraft.value.filter(item => item.title.trim() || item.image_url?.trim()),
|
||||
null,
|
||||
2
|
||||
)
|
||||
const value = JSON.stringify(homeBannersDraft.value.filter(hasContent), null, 2)
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value,
|
||||
description: description.value,
|
||||
@@ -116,7 +113,7 @@ async function handleSave() {
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑首页轮播图"
|
||||
width="920px"
|
||||
width="1080px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
@@ -124,71 +121,84 @@ async function handleSave() {
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="home-config-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span>首页轮播图</span>
|
||||
<div class="panel-actions">
|
||||
<el-button size="small" @click="resetHomeBanners">恢复默认轮播</el-button>
|
||||
<el-button size="small" type="primary" @click="addHomeBanner">添加轮播</el-button>
|
||||
</div>
|
||||
<el-alert type="info" :closable="false" class="size-alert">
|
||||
<template #title>
|
||||
<span>
|
||||
出图规范:PC 端 1600×400(4:1)、移动端
|
||||
750×375(2:1),两端容器严格等比,按规范出图不裁切。
|
||||
两端图片与跳转分开配置:在预览图上<b>单击新增热区</b>、拖动移动、拖右下角缩放,可为每个热区单独设置跳转;
|
||||
未配置热区时整张图按「整图点击」响应,全部留空则点击查看大图。
|
||||
</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<span>轮播列表({{ homeBannersDraft.length }})</span>
|
||||
<div class="panel-actions">
|
||||
<el-button size="small" @click="resetHomeBanners">恢复默认轮播</el-button>
|
||||
<el-button size="small" type="primary" @click="addHomeBanner">添加轮播</el-button>
|
||||
</div>
|
||||
<el-table :data="homeBannersDraft" size="small" border>
|
||||
<el-table-column label="图片" min-width="240">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="banner-image-editor">
|
||||
<el-input v-model="row.image_url" placeholder="图片 URL,留空使用文字卡片" />
|
||||
<div class="banner-image-tools">
|
||||
<img v-if="row.image_url" :src="row.image_url" alt="轮播图预览" />
|
||||
<div v-else class="empty-image">无图</div>
|
||||
<label class="upload-trigger">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="handleHomeBannerUpload($event, $index)"
|
||||
/>
|
||||
{{ uploadingBannerIndex === $index ? '上传中' : '上传图片' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="眉标" min-width="160">
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主标题" min-width="220">
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.title" placeholder="轮播主文案"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角标" width="110">
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.badge" placeholder="HOT"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="胶囊文案" min-width="220">
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.pill" placeholder="底部补充文案"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="色调" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.tone" placeholder="色调">
|
||||
<el-option label="蓝色" value="blue" />
|
||||
<el-option label="绿色" value="green" />
|
||||
<el-option label="橙色" value="orange" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)"
|
||||
>删除</el-button
|
||||
</div>
|
||||
|
||||
<div class="banner-cards">
|
||||
<div v-for="(banner, index) in homeBannersDraft" :key="index" class="banner-card">
|
||||
<div class="banner-card-head">
|
||||
<strong>轮播 {{ index + 1 }}</strong>
|
||||
<div class="banner-card-actions">
|
||||
<el-switch
|
||||
v-model="banner.enabled"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
class="banner-switch"
|
||||
/>
|
||||
<el-button size="small" :disabled="index === 0" @click="moveHomeBanner(index, -1)">
|
||||
上移
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="index === homeBannersDraft.length - 1"
|
||||
@click="moveHomeBanner(index, 1)"
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
下移
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeHomeBanner(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="banner-copy-fields">
|
||||
<el-input v-model="banner.eyebrow" placeholder="眉标,如 三角洲行动账号专区" />
|
||||
<el-input v-model="banner.title" placeholder="主标题(无图时的文字卡片文案)" />
|
||||
<el-input v-model="banner.badge" placeholder="角标,如 HOT" class="field-badge" />
|
||||
<el-input v-model="banner.pill" placeholder="胶囊文案(可选)" />
|
||||
<el-select v-model="banner.tone" placeholder="色调" class="field-tone">
|
||||
<el-option label="蓝色" value="blue" />
|
||||
<el-option label="绿色" value="green" />
|
||||
<el-option label="橙色" value="orange" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-tabs class="banner-tabs">
|
||||
<el-tab-pane label="PC 端 1600×400(4:1)">
|
||||
<BannerVisualEditor
|
||||
v-model="banner.pc"
|
||||
:ratio="HOME_BANNER_PC_RATIO"
|
||||
label="PC 端展示"
|
||||
standard="1600×400(4:1)"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="移动端 750×375(2:1)">
|
||||
<BannerVisualEditor
|
||||
v-model="banner.mobile"
|
||||
:ratio="HOME_BANNER_MOBILE_RATIO"
|
||||
label="移动端展示"
|
||||
standard="750×375(2:1)"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
@@ -205,8 +215,8 @@ async function handleSave() {
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-height: 65vh;
|
||||
gap: 14px;
|
||||
max-height: 68vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@@ -216,9 +226,12 @@ async function handleSave() {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.home-config-editor {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
.size-alert {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.size-alert :deep(.el-alert__title) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@@ -239,57 +252,62 @@ async function handleSave() {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-image-editor {
|
||||
.banner-cards {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.banner-image-tools {
|
||||
.banner-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 4px;
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.banner-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.banner-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-image-tools img,
|
||||
.empty-image {
|
||||
width: 68px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
background: #f3f4f6;
|
||||
object-fit: cover;
|
||||
.banner-switch {
|
||||
--el-switch-on-color: #ff6a00;
|
||||
}
|
||||
|
||||
.empty-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
.banner-copy-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
.banner-copy-fields :deep(.el-input) {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.upload-trigger input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
.banner-copy-fields .field-badge {
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
|
||||
.banner-copy-fields .field-tone {
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
|
||||
.banner-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
|
||||
createBannerHotspot,
|
||||
hasBannerImage,
|
||||
mergeHomeConfig,
|
||||
normalizeHomeBanner,
|
||||
} from './homeConfig'
|
||||
|
||||
describe('normalizeHomeBanner', () => {
|
||||
it('把旧的扁平 image_url 结构升级为 PC / 移动两端共用同一张图', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '旧结构',
|
||||
eyebrow: '首页',
|
||||
tone: 'green',
|
||||
image_url: ' https://cdn.example/legacy.png ',
|
||||
})
|
||||
|
||||
expect(banner.pc.image_url).toBe('https://cdn.example/legacy.png')
|
||||
expect(banner.mobile.image_url).toBe('https://cdn.example/legacy.png')
|
||||
expect(banner.tone).toBe('green')
|
||||
expect(banner.enabled).toBe(true)
|
||||
expect(banner.pc.hotspots).toEqual([])
|
||||
})
|
||||
|
||||
it('两端分别配图时互不覆盖', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '两端分图',
|
||||
pc: { image_url: 'https://cdn.example/pc.png' },
|
||||
mobile: { image_url: 'https://cdn.example/mobile.png' },
|
||||
})
|
||||
|
||||
expect(banner.pc.image_url).toBe('https://cdn.example/pc.png')
|
||||
expect(banner.mobile.image_url).toBe('https://cdn.example/mobile.png')
|
||||
})
|
||||
|
||||
it('只配移动图时 PC 端不会误用移动图', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '仅移动图',
|
||||
mobile: { image_url: 'https://cdn.example/mobile.png' },
|
||||
})
|
||||
|
||||
expect(banner.pc.image_url).toBe('')
|
||||
expect(banner.mobile.image_url).toBe('https://cdn.example/mobile.png')
|
||||
expect(hasBannerImage(banner.pc)).toBe(false)
|
||||
})
|
||||
|
||||
it('收敛热区坐标并丢弃无效热区', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '热区',
|
||||
pc: {
|
||||
image_url: '/pc.png',
|
||||
hotspots: [
|
||||
{
|
||||
id: ' h1 ',
|
||||
x: 90,
|
||||
y: 95,
|
||||
width: 20,
|
||||
height: 20,
|
||||
action: { type: 'route', target: '/x' },
|
||||
},
|
||||
{ id: 'zero', y: 10, width: 0, height: 10, action: { type: 'route', target: '/x' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(banner.pc.hotspots).toHaveLength(1)
|
||||
expect(banner.pc.hotspots[0]).toMatchObject({ id: 'h1', x: 80, y: 80 })
|
||||
})
|
||||
|
||||
it('缺失目标的跳转退化为不跳转,避免死热区', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '死区',
|
||||
pc: { image_url: '/pc.png', action: { type: 'route', target: ' ' } },
|
||||
mobile: { image_url: '/m.png', action: { type: 'link', target: 'https://a.example' } },
|
||||
})
|
||||
|
||||
expect(banner.pc.action).toEqual({ type: 'none', target: '', open_new_tab: false })
|
||||
expect(banner.mobile.action).toEqual({
|
||||
type: 'link',
|
||||
target: 'https://a.example',
|
||||
open_new_tab: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('热区按钮图去空格,悬浮倍数默认与收敛', () => {
|
||||
const banner = normalizeHomeBanner({
|
||||
title: '按钮图',
|
||||
pc: {
|
||||
image_url: '/pc.png',
|
||||
hotspots: [
|
||||
{ id: 'a', width: 20, height: 20, image_url: ' https://cdn.example/btn.png ' },
|
||||
{ id: 'b', width: 20, height: 20, hover_scale: 9 },
|
||||
{ id: 'c', width: 20, height: 20, hover_scale: 0.5 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(banner.pc.hotspots[0]?.image_url).toBe('https://cdn.example/btn.png')
|
||||
expect(banner.pc.hotspots[0]?.hover_scale).toBe(BANNER_HOTSPOT_DEFAULT_HOVER_SCALE)
|
||||
expect(banner.pc.hotspots[1]?.hover_scale).toBe(1.5)
|
||||
expect(banner.pc.hotspots[2]?.hover_scale).toBe(1)
|
||||
})
|
||||
|
||||
it('保留停用标记', () => {
|
||||
expect(normalizeHomeBanner({ title: '停用', enabled: false }).enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeHomeConfig', () => {
|
||||
it('过滤掉既无文案也无图片的空轮播', () => {
|
||||
const config = mergeHomeConfig({
|
||||
banners: [
|
||||
{ title: '有效' },
|
||||
{ title: ' ', pc: { image_url: 'https://cdn.example/pc.png' } },
|
||||
{ title: '' },
|
||||
],
|
||||
})
|
||||
|
||||
// 只有图片没有文案的轮播要保留,全空的条目被丢弃
|
||||
expect(config.banners.map(item => item.pc.image_url)).toEqual([
|
||||
'',
|
||||
'https://cdn.example/pc.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('全部无效时回落到默认轮播', () => {
|
||||
const config = mergeHomeConfig({ banners: [{ title: '' }] })
|
||||
expect(config.banners.length).toBeGreaterThan(0)
|
||||
expect(config.banners[0]?.pc.image_url).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerHotspot', () => {
|
||||
it('生成带默认行为的热区,便于后台直接框选', () => {
|
||||
const hotspot = createBannerHotspot({ x: 10, y: 12 })
|
||||
|
||||
expect(hotspot.id).toBeTruthy()
|
||||
expect(hotspot.action).toEqual({ type: 'none', target: '', open_new_tab: false })
|
||||
expect(hotspot.image_url).toBe('')
|
||||
expect(hotspot.hover_scale).toBe(BANNER_HOTSPOT_DEFAULT_HOVER_SCALE)
|
||||
expect(hotspot).toMatchObject({ x: 10, y: 12, width: 24, height: 24 })
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,190 @@ import type { ApiResponse } from '@/shared/types/types'
|
||||
import { normalizeAnnouncements } from '@/shared/utils/announcements'
|
||||
import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
|
||||
|
||||
/** 轮播出图规范:PC 1600×400(4:1)、移动端 750×375(2:1),两端容器严格等比,标准图零裁切。 */
|
||||
export const HOME_BANNER_PC_RATIO = { width: 1600, height: 400 }
|
||||
export const HOME_BANNER_MOBILE_RATIO = { width: 750, height: 375 }
|
||||
|
||||
export type BannerActionType = 'none' | 'route' | 'link' | 'preview'
|
||||
|
||||
/** 点击行为:站内路径 / 外部链接 / 查看大图 / 不跳转。 */
|
||||
export interface BannerAction {
|
||||
type: BannerActionType
|
||||
target: string
|
||||
open_new_tab: boolean
|
||||
}
|
||||
|
||||
/** 热区:相对图片的百分比坐标(0-100),与出图分辨率解耦。 */
|
||||
export interface BannerHotspot {
|
||||
id: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
/** 热区按钮图(透明 PNG):留空则用半透明高亮框表示可点击。 */
|
||||
image_url: string
|
||||
/** 悬浮放大倍数:1 表示不放大,建议 1.04-1.08。 */
|
||||
hover_scale: number
|
||||
action: BannerAction
|
||||
}
|
||||
|
||||
/** 单端视觉配置:图片 + 整图点击行为 + 热区(热点区域)。 */
|
||||
export interface BannerVisual {
|
||||
image_url: string
|
||||
alt: string
|
||||
action: BannerAction
|
||||
hotspots: BannerHotspot[]
|
||||
}
|
||||
|
||||
export interface HomeBannerSlide {
|
||||
eyebrow: string
|
||||
title: string
|
||||
badge: string
|
||||
pill: string
|
||||
tone: string
|
||||
image_url?: string
|
||||
enabled: boolean
|
||||
pc: BannerVisual
|
||||
mobile: BannerVisual
|
||||
}
|
||||
|
||||
export const bannerActionOptions: { value: BannerActionType; label: string }[] = [
|
||||
{ value: 'none', label: '不跳转' },
|
||||
{ value: 'route', label: '站内路径' },
|
||||
{ value: 'link', label: '外部链接' },
|
||||
{ value: 'preview', label: '查看大图' },
|
||||
]
|
||||
|
||||
export const bannerActionHints: Record<BannerActionType, string> = {
|
||||
none: '点击无动作;未配置热区时仍可点击查看大图',
|
||||
route: '站内路径,如 /listings、/m/announcements',
|
||||
link: '外部链接,需以 http:// 或 https:// 开头',
|
||||
preview: '点击放大查看原图',
|
||||
}
|
||||
|
||||
const BANNER_ACTION_TYPES: BannerActionType[] = ['none', 'route', 'link', 'preview']
|
||||
|
||||
export function createEmptyBannerAction(): BannerAction {
|
||||
return { type: 'none', target: '', open_new_tab: false }
|
||||
}
|
||||
|
||||
export function createEmptyBannerVisual(): BannerVisual {
|
||||
return { image_url: '', alt: '', action: createEmptyBannerAction(), hotspots: [] }
|
||||
}
|
||||
|
||||
export const BANNER_HOTSPOT_DEFAULT_HOVER_SCALE = 1.06
|
||||
const BANNER_HOTSPOT_MAX_HOVER_SCALE = 1.5
|
||||
|
||||
export function createBannerHotspot(partial: Partial<BannerHotspot> = {}): BannerHotspot {
|
||||
return {
|
||||
id: `hotspot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
label: '',
|
||||
x: 6,
|
||||
y: 20,
|
||||
width: 24,
|
||||
height: 24,
|
||||
image_url: '',
|
||||
hover_scale: BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
|
||||
action: createEmptyBannerAction(),
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBannerAction(raw: unknown): BannerAction {
|
||||
const source = isRecord(raw) ? raw : {}
|
||||
const rawType = typeof source.type === 'string' ? source.type.trim() : ''
|
||||
const type = BANNER_ACTION_TYPES.find(item => item === rawType) ?? 'none'
|
||||
const target = typeof source.target === 'string' ? source.target.trim() : ''
|
||||
// 目标缺失的跳转会变成「点了没反应」的死热区,直接退化为不跳转
|
||||
if ((type === 'route' || type === 'link') && !target) {
|
||||
return createEmptyBannerAction()
|
||||
}
|
||||
return { type, target, open_new_tab: source.open_new_tab === true }
|
||||
}
|
||||
|
||||
export function normalizeBannerHotspot(raw: unknown, index: number): BannerHotspot | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const width = clampPercent(raw.width)
|
||||
const height = clampPercent(raw.height)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
return {
|
||||
id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `hotspot-${index + 1}`,
|
||||
label: typeof raw.label === 'string' ? raw.label.trim() : '',
|
||||
x: clampPercent(raw.x, 100 - width),
|
||||
y: clampPercent(raw.y, 100 - height),
|
||||
width,
|
||||
height,
|
||||
image_url: typeof raw.image_url === 'string' ? raw.image_url.trim() : '',
|
||||
hover_scale: normalizeHotspotHoverScale(raw.hover_scale),
|
||||
action: normalizeBannerAction(raw.action),
|
||||
}
|
||||
}
|
||||
|
||||
/** 悬浮放大倍数:缺失/非法回落默认值,上限 1.5 避免热区盖住整张图。 */
|
||||
function normalizeHotspotHoverScale(value: unknown): number {
|
||||
const num = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) return BANNER_HOTSPOT_DEFAULT_HOVER_SCALE
|
||||
const clamped = Math.min(Math.max(num, 1), BANNER_HOTSPOT_MAX_HOVER_SCALE)
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单端视觉配置。
|
||||
* `legacyImageUrl` 兼容早期扁平结构(只有一张 image_url),保证历史配置两端都还能出图。
|
||||
*/
|
||||
export function normalizeBannerVisual(raw: unknown, legacyImageUrl = ''): BannerVisual {
|
||||
const source = isRecord(raw) ? raw : {}
|
||||
const imageUrl = typeof source.image_url === 'string' ? source.image_url.trim() : ''
|
||||
const rawHotspots = Array.isArray(source.hotspots) ? source.hotspots : []
|
||||
return {
|
||||
image_url: imageUrl || legacyImageUrl,
|
||||
alt: typeof source.alt === 'string' ? source.alt.trim() : '',
|
||||
action: normalizeBannerAction(source.action),
|
||||
hotspots: rawHotspots
|
||||
.map((item, index) => normalizeBannerHotspot(item, index))
|
||||
.filter((item): item is BannerHotspot => item !== null),
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化单个轮播:新结构(pc/mobile)优先,同时兼容旧的扁平 image_url 结构。 */
|
||||
export function normalizeHomeBanner(raw: unknown): HomeBannerSlide {
|
||||
const source = isRecord(raw) ? raw : {}
|
||||
const legacyImageUrl = typeof source.image_url === 'string' ? source.image_url.trim() : ''
|
||||
return {
|
||||
eyebrow: normalizeText(source.eyebrow),
|
||||
title: normalizeText(source.title),
|
||||
badge: normalizeText(source.badge),
|
||||
pill: normalizeText(source.pill),
|
||||
tone: normalizeBannerTone(typeof source.tone === 'string' ? source.tone : ''),
|
||||
enabled: source.enabled !== false,
|
||||
pc: normalizeBannerVisual(source.pc, legacyImageUrl),
|
||||
mobile: normalizeBannerVisual(source.mobile, legacyImageUrl),
|
||||
}
|
||||
}
|
||||
|
||||
/** 该端是否配了图(配图才走等比图片容器,否则退回文字卡片)。 */
|
||||
export function hasBannerImage(visual: BannerVisual): boolean {
|
||||
return Boolean(visual.image_url.trim())
|
||||
}
|
||||
|
||||
/** 热区是否可点击:配置了真实行为才算命中,否则让点击穿透到整图。 */
|
||||
export function isBannerActionInteractive(action: BannerAction): boolean {
|
||||
return action.type !== 'none'
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function clampPercent(value: unknown, max = 100): number {
|
||||
const num = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(num)) return 0
|
||||
const clamped = Math.min(Math.max(num, 0), max)
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export interface AWRecycleConfig {
|
||||
@@ -61,7 +238,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
|
||||
badge: 'HOT',
|
||||
pill: '新人领券最高减 20 元',
|
||||
tone: 'blue',
|
||||
image_url: '',
|
||||
enabled: true,
|
||||
pc: createEmptyBannerVisual(),
|
||||
mobile: createEmptyBannerVisual(),
|
||||
},
|
||||
{
|
||||
eyebrow: '平台担保交易',
|
||||
@@ -69,7 +248,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
|
||||
badge: 'SAFE',
|
||||
pill: '租号前先验实名与资料',
|
||||
tone: 'green',
|
||||
image_url: '',
|
||||
enabled: true,
|
||||
pc: createEmptyBannerVisual(),
|
||||
mobile: createEmptyBannerVisual(),
|
||||
},
|
||||
{
|
||||
eyebrow: '高效筛选',
|
||||
@@ -77,7 +258,9 @@ export const defaultHomeBanners: HomeBannerSlide[] = [
|
||||
badge: 'FAST',
|
||||
pill: '支持扫码号与账密号',
|
||||
tone: 'orange',
|
||||
image_url: '',
|
||||
enabled: true,
|
||||
pc: createEmptyBannerVisual(),
|
||||
mobile: createEmptyBannerVisual(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -91,18 +274,8 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
|
||||
const announcements = normalizeAnnouncements(config?.announcements)
|
||||
const banners =
|
||||
config?.banners
|
||||
?.map(item => {
|
||||
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
|
||||
return {
|
||||
eyebrow: banner.eyebrow?.trim() || '',
|
||||
title: banner.title?.trim() || '',
|
||||
badge: banner.badge?.trim() || '',
|
||||
pill: banner.pill?.trim() || '',
|
||||
tone: normalizeBannerTone(banner.tone),
|
||||
image_url: banner.image_url?.trim() || '',
|
||||
}
|
||||
})
|
||||
.filter(item => item.title || item.image_url) || []
|
||||
?.map(item => normalizeHomeBanner(item))
|
||||
.filter(item => item.title || hasBannerImage(item.pc) || hasBannerImage(item.mobile)) || []
|
||||
|
||||
const awRaw = config?.aw_recycle
|
||||
const awRecycle: AWRecycleConfig = {
|
||||
@@ -150,11 +323,7 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
|
||||
}
|
||||
}
|
||||
|
||||
function isBannerLike(value: unknown): value is Partial<HomeBannerSlide> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function normalizeBannerTone(tone?: string) {
|
||||
if (tone === 'green' || tone === 'orange') return tone
|
||||
function normalizeBannerTone(tone: string) {
|
||||
if (tone === 'green' || tone === 'orange' || tone === 'gold') return tone
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
isBannerActionInteractive,
|
||||
type BannerAction,
|
||||
type BannerHotspot,
|
||||
type BannerVisual,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
visual: BannerVisual
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
trigger: [action: BannerAction]
|
||||
}>()
|
||||
|
||||
/** 只渲染配置了真实行为的热区,其余保持点击穿透,交给整图行为处理。 */
|
||||
const hotspots = computed(() =>
|
||||
props.visual.hotspots.filter(item => isBannerActionInteractive(item.action))
|
||||
)
|
||||
|
||||
function hotspotStyle(hotspot: BannerHotspot) {
|
||||
return {
|
||||
left: `${hotspot.x}%`,
|
||||
top: `${hotspot.y}%`,
|
||||
width: `${hotspot.width}%`,
|
||||
height: `${hotspot.height}%`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 悬浮放大倍数通过 CSS 变量下传,运营可按按钮图大小微调。 */
|
||||
function imageStyle(hotspot: BannerHotspot) {
|
||||
return { '--hotspot-hover-scale': String(hotspot.hover_scale) }
|
||||
}
|
||||
|
||||
function hasHotspotImage(hotspot: BannerHotspot) {
|
||||
return Boolean(hotspot.image_url.trim())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hotspots.length" class="banner-hotspot-layer">
|
||||
<button
|
||||
v-for="item in hotspots"
|
||||
:key="item.id"
|
||||
class="banner-hotspot"
|
||||
:class="{ 'has-image': hasHotspotImage(item) }"
|
||||
type="button"
|
||||
:style="hotspotStyle(item)"
|
||||
:aria-label="item.label || '查看详情'"
|
||||
:title="item.label || undefined"
|
||||
@click.stop="emit('trigger', item.action)"
|
||||
>
|
||||
<img
|
||||
v-if="hasHotspotImage(item)"
|
||||
class="banner-hotspot-image"
|
||||
:src="item.image_url"
|
||||
:alt="item.label || ''"
|
||||
:style="imageStyle(item)"
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-hotspot-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.banner-hotspot {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* 无按钮图时用半透明高亮提示「这里可点」 */
|
||||
.banner-hotspot:not(.has-image):hover,
|
||||
.banner-hotspot:not(.has-image):focus-visible {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* 有按钮图时按图片展示,悬浮轻微放大 + 投影(与运营海报按钮一致的手感) */
|
||||
.banner-hotspot.has-image {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.banner-hotspot-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
}
|
||||
|
||||
.banner-hotspot.has-image:hover .banner-hotspot-image,
|
||||
.banner-hotspot.has-image:focus-visible .banner-hotspot-image {
|
||||
transform: scale(var(--hotspot-hover-scale, 1.06));
|
||||
filter: drop-shadow(0 6px 14px rgba(15, 23, 42, 0.35));
|
||||
}
|
||||
|
||||
.banner-hotspot.has-image:active .banner-hotspot-image {
|
||||
transform: scale(calc(var(--hotspot-hover-scale, 1.06) * 0.97));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-hotspot-image {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface HomeBannerSlide {
|
||||
title?: string
|
||||
eyebrow?: string
|
||||
pill?: string
|
||||
badge?: string
|
||||
tone?: string
|
||||
image_url?: string
|
||||
}
|
||||
import {
|
||||
hasBannerImage,
|
||||
type BannerAction,
|
||||
type BannerVisual,
|
||||
type HomeBannerSlide,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import { useBannerAction } from '@/features/listings/composables/useBannerAction'
|
||||
import BannerHotspotLayer from './BannerHotspotLayer.vue'
|
||||
|
||||
interface StatItem {
|
||||
label: string
|
||||
@@ -23,20 +22,18 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 轮播高度由 CSS 变量统一控制,方便大屏/窄屏按比例收敛。
|
||||
const carouselHeight = 'var(--home-hero-height, 340px)'
|
||||
const { run } = useBannerAction()
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewIndex = ref(0)
|
||||
|
||||
/** PC 端统一使用桌面图(1600×400),保证 4:1 容器内零裁切。 */
|
||||
const previewUrls = computed(() =>
|
||||
props.banners
|
||||
.map(slide => slide.image_url?.trim() || '')
|
||||
.filter((url): url is string => Boolean(url))
|
||||
props.banners.map(slide => slide.pc.image_url.trim()).filter((url): url is string => Boolean(url))
|
||||
)
|
||||
|
||||
function openPreview(slide: HomeBannerSlide) {
|
||||
const url = slide.image_url?.trim()
|
||||
const url = slide.pc.image_url.trim()
|
||||
if (!url) return
|
||||
const index = previewUrls.value.indexOf(url)
|
||||
if (index < 0) return
|
||||
@@ -49,30 +46,47 @@ function closePreview() {
|
||||
}
|
||||
|
||||
function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
return Boolean(slide.eyebrow?.trim() || slide.title?.trim() || slide.pill?.trim())
|
||||
return Boolean(slide.eyebrow.trim() || slide.title.trim() || slide.pill.trim())
|
||||
}
|
||||
|
||||
/** 热区/整图点击:配置跳转就跳,配了查看大图就看大图,都没配则退回查看大图。 */
|
||||
function handleAction(slide: HomeBannerSlide, action: BannerAction) {
|
||||
if (action.type === 'preview') {
|
||||
openPreview(slide)
|
||||
return
|
||||
}
|
||||
if (run(action)) return
|
||||
openPreview(slide)
|
||||
}
|
||||
|
||||
/** 光标反馈:可跳转用 pointer,可看大图用 zoom-in。 */
|
||||
function slideCursorClasses(visual: BannerVisual) {
|
||||
if (!hasBannerImage(visual)) return {}
|
||||
const actionable = visual.action.type === 'route' || visual.action.type === 'link'
|
||||
return { 'is-actionable': actionable, 'is-previewable': !actionable }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero-board">
|
||||
<el-carousel :height="carouselHeight" :interval="3600">
|
||||
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
|
||||
<el-carousel :interval="3600">
|
||||
<el-carousel-item v-for="slide in banners" :key="slide.pc.image_url || slide.title">
|
||||
<div
|
||||
class="hero-slide"
|
||||
:class="[
|
||||
`tone-${slide.tone}`,
|
||||
slideCursorClasses(slide.pc),
|
||||
{
|
||||
'has-image': slide.image_url,
|
||||
'has-image': hasBannerImage(slide.pc),
|
||||
'has-copy': hasSlideCopy(slide),
|
||||
'is-previewable': !!slide.image_url,
|
||||
},
|
||||
]"
|
||||
@click="openPreview(slide)"
|
||||
@click="handleAction(slide, slide.pc.action)"
|
||||
>
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
:src="slide.image_url"
|
||||
:alt="slide.title || slide.eyebrow || '首页轮播'"
|
||||
v-if="hasBannerImage(slide.pc)"
|
||||
:src="slide.pc.image_url"
|
||||
:alt="slide.pc.alt || slide.title || slide.eyebrow || '首页轮播'"
|
||||
/>
|
||||
<div class="hero-copy">
|
||||
<span v-if="slide.eyebrow">{{ slide.eyebrow }}</span>
|
||||
@@ -80,6 +94,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
<p v-if="slide.pill">{{ slide.pill }}</p>
|
||||
</div>
|
||||
<em v-if="slide.badge">{{ slide.badge }}</em>
|
||||
<BannerHotspotLayer :visual="slide.pc" @trigger="action => handleAction(slide, action)" />
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
@@ -104,22 +119,25 @@ function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* PC 出图规范 1600×400(4:1):容器靠 aspect-ratio 严格等比,换分辨率也不裁切。 */
|
||||
.hero-board {
|
||||
--home-hero-height: 340px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 1600 / 400;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hero-board :deep(.el-carousel) {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 覆盖 el-carousel 自带的 300px 容器高度与 height 属性内联样式 */
|
||||
.hero-board :deep(.el-carousel__container) {
|
||||
height: var(--home-hero-height, 340px);
|
||||
height: 100% !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -142,6 +160,10 @@ function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.hero-slide.is-actionable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-slide.tone-blue {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 56%, #fff7ed 100%);
|
||||
}
|
||||
@@ -159,6 +181,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.hero-slide.has-image.has-copy::after {
|
||||
@@ -264,20 +287,12 @@ function hasSlideCopy(slide: HomeBannerSlide) {
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.hero-board {
|
||||
--home-hero-height: 300px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-board {
|
||||
--home-hero-height: 240px;
|
||||
}
|
||||
|
||||
.hero-slide {
|
||||
padding: 22px 28px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { BannerAction } from '@/features/listings/api/homeConfig'
|
||||
|
||||
/**
|
||||
* 轮播点击行为统一执行器:站内路径走路由,外部链接走 window.open。
|
||||
* 返回是否真正执行了跳转,调用方可据此决定兜底行为(例如查看大图)。
|
||||
*/
|
||||
export function useBannerAction() {
|
||||
const router = useRouter()
|
||||
|
||||
function run(action: BannerAction): boolean {
|
||||
const target = action.target.trim()
|
||||
if (action.type === 'route' && target) {
|
||||
if (action.open_new_tab) {
|
||||
window.open(target, '_blank', 'noopener')
|
||||
return true
|
||||
}
|
||||
void router.push(target)
|
||||
return true
|
||||
}
|
||||
if (action.type === 'link' && target) {
|
||||
window.open(target, action.open_new_tab ? '_blank' : '_self', 'noopener')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { run }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
defaultHomeAnnouncements,
|
||||
defaultHomeBanners,
|
||||
fetchMobileHomeConfig,
|
||||
normalizeHomeBanner,
|
||||
type AWRecycleConfig,
|
||||
type CooperationFeedbackConfig,
|
||||
type HomeBannerSlide,
|
||||
@@ -60,10 +61,11 @@ const displayAnnouncements = computed(() => {
|
||||
})
|
||||
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
|
||||
const displayBanners = computed<HomeBannerSlide[]>(() => {
|
||||
if (siteStore.isDistribution && siteStore.banners.length > 0) {
|
||||
return mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
|
||||
}
|
||||
return banners.value
|
||||
const list =
|
||||
siteStore.isDistribution && siteStore.banners.length > 0
|
||||
? mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
|
||||
: banners.value
|
||||
return list.filter(slide => slide.enabled !== false)
|
||||
})
|
||||
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
|
||||
const awRecycleDialogVisible = ref(false)
|
||||
@@ -399,7 +401,7 @@ function restoreFromCache(): boolean {
|
||||
})
|
||||
if (pcHomeCache.homeConfigLoaded) {
|
||||
announcements.value = pcHomeCache.announcements.slice()
|
||||
banners.value = pcHomeCache.banners.map(slide => ({ ...slide }))
|
||||
banners.value = pcHomeCache.banners.map(slide => normalizeHomeBanner(slide))
|
||||
awRecycle.value = { ...pcHomeCache.awRecycle }
|
||||
cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback }
|
||||
publishOptions.value = pcHomeCache.publishOptions
|
||||
|
||||
@@ -285,9 +285,15 @@
|
||||
background: #ff6a00;
|
||||
}
|
||||
|
||||
/* 移动出图规范 750×375(2:1):只要有一条配图,整条轮播统一按 2:1 等比,标准图零裁切 */
|
||||
.banner-swipe.has-image {
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
|
||||
.mobile-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 128px;
|
||||
align-items: flex-end;
|
||||
padding: 16px 16px 24px;
|
||||
@@ -300,6 +306,10 @@
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.mobile-banner.is-actionable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-banner.tone-warm {
|
||||
background: #9a3412;
|
||||
}
|
||||
@@ -328,12 +338,8 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.banner-swipe.is-poster {
|
||||
height: 168px;
|
||||
}
|
||||
|
||||
.mobile-banner.is-poster {
|
||||
height: 168px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -387,9 +393,11 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 大图预览按移动出图规范 2:1 展示,并把整张图完整放进框内(不裁切) */
|
||||
.banner-lightbox-frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 2.15 / 1;
|
||||
aspect-ratio: 2 / 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -397,7 +405,7 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,15 @@ import {
|
||||
defaultHomeAnnouncements,
|
||||
defaultHomeBanners,
|
||||
fetchMobileHomeConfig,
|
||||
hasBannerImage,
|
||||
normalizeHomeBanner,
|
||||
type AWRecycleConfig,
|
||||
type BannerAction,
|
||||
type CooperationFeedbackConfig,
|
||||
type HomeBannerSlide,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import BannerHotspotLayer from '@/features/listings/components/BannerHotspotLayer.vue'
|
||||
import { useBannerAction } from '@/features/listings/composables/useBannerAction'
|
||||
import { showToast } from 'vant'
|
||||
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
|
||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||
@@ -72,6 +77,7 @@ const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const siteStore = useDistributionSiteStore()
|
||||
const homeCache = useMobileHomeCacheStore()
|
||||
const { run: runBannerAction } = useBannerAction()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
@@ -102,16 +108,18 @@ const displayAnnouncements = computed(() => {
|
||||
})
|
||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
|
||||
const displayBanners = computed<HomeBannerSlide[]>(() => {
|
||||
if (siteStore.isDistribution && siteStore.banners.length > 0) {
|
||||
return mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
|
||||
}
|
||||
return bannerSlides.value
|
||||
const list =
|
||||
siteStore.isDistribution && siteStore.banners.length > 0
|
||||
? mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
|
||||
: bannerSlides.value
|
||||
return list.filter(slide => slide.enabled !== false)
|
||||
})
|
||||
const bannerPreviewVisible = ref(false)
|
||||
const bannerPreviewIndex = ref(0)
|
||||
/** 移动端统一使用移动图(750×375),保证 2:1 容器内零裁切。 */
|
||||
const bannerPreviewImages = computed(() =>
|
||||
displayBanners.value
|
||||
.map(slide => slide.image_url?.trim() || '')
|
||||
.map(slide => slide.mobile.image_url.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
)
|
||||
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
|
||||
@@ -352,7 +360,7 @@ function restoreFromCache() {
|
||||
function restoreHomeConfigFromCache() {
|
||||
if (!homeCache.homeConfigLoaded) return false
|
||||
announcements.value = homeCache.announcements.slice()
|
||||
bannerSlides.value = homeCache.bannerSlides.map(slide => ({ ...slide }))
|
||||
bannerSlides.value = homeCache.bannerSlides.map(slide => normalizeHomeBanner(slide))
|
||||
publishOptions.value = homeCache.publishOptions
|
||||
return true
|
||||
}
|
||||
@@ -503,7 +511,7 @@ function openCooperationFeedback() {
|
||||
}
|
||||
|
||||
function openBannerPreview(slide: HomeBannerSlide) {
|
||||
const url = slide.image_url?.trim()
|
||||
const url = slide.mobile.image_url.trim()
|
||||
if (!url) return
|
||||
const startPosition = bannerPreviewImages.value.indexOf(url)
|
||||
if (startPosition < 0) return
|
||||
@@ -511,6 +519,16 @@ function openBannerPreview(slide: HomeBannerSlide) {
|
||||
bannerPreviewVisible.value = true
|
||||
}
|
||||
|
||||
/** 热区/整图点击:配置跳转就跳,配了查看大图就看大图,都没配则退回查看大图。 */
|
||||
function handleBannerAction(slide: HomeBannerSlide, action: BannerAction) {
|
||||
if (action.type === 'preview') {
|
||||
openBannerPreview(slide)
|
||||
return
|
||||
}
|
||||
if (runBannerAction(action)) return
|
||||
openBannerPreview(slide)
|
||||
}
|
||||
|
||||
function closeBannerPreview() {
|
||||
bannerPreviewVisible.value = false
|
||||
}
|
||||
@@ -876,34 +894,41 @@ if (!restoreHomeConfigFromCache()) {
|
||||
<p class="dist-card-sub">{{ siteStore.storefrontText.card_sub }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Banner 轮播 -->
|
||||
<!-- Banner 轮播:统一按移动出图规范 750×375(2:1)等比展示 -->
|
||||
<van-swipe
|
||||
class="banner-swipe"
|
||||
:class="{
|
||||
'is-poster': displayBanners.some(slide => slide.image_url && !hasBannerCopy(slide)),
|
||||
'has-image': displayBanners.some(slide => hasBannerImage(slide.mobile)),
|
||||
}"
|
||||
:autoplay="3600"
|
||||
lazy-render
|
||||
>
|
||||
<van-swipe-item v-for="slide in displayBanners" :key="slide.image_url || slide.title">
|
||||
<van-swipe-item
|
||||
v-for="slide in displayBanners"
|
||||
:key="slide.mobile.image_url || slide.title"
|
||||
>
|
||||
<div
|
||||
class="mobile-banner"
|
||||
:class="[
|
||||
`tone-${slide.tone}`,
|
||||
{
|
||||
'has-image': slide.image_url,
|
||||
'has-image': hasBannerImage(slide.mobile),
|
||||
'has-copy': hasBannerCopy(slide),
|
||||
'is-poster': !!slide.image_url && !hasBannerCopy(slide),
|
||||
'is-previewable': !!slide.image_url,
|
||||
'is-poster': hasBannerImage(slide.mobile) && !hasBannerCopy(slide),
|
||||
'is-actionable':
|
||||
slide.mobile.action.type === 'route' || slide.mobile.action.type === 'link',
|
||||
'is-previewable': !(
|
||||
slide.mobile.action.type === 'route' || slide.mobile.action.type === 'link'
|
||||
),
|
||||
},
|
||||
]"
|
||||
@click="openBannerPreview(slide)"
|
||||
@click="handleBannerAction(slide, slide.mobile.action)"
|
||||
>
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
v-if="hasBannerImage(slide.mobile)"
|
||||
class="banner-image"
|
||||
:src="slide.image_url"
|
||||
:alt="slide.title || slide.eyebrow || '首页轮播图'"
|
||||
:src="slide.mobile.image_url"
|
||||
:alt="slide.mobile.alt || slide.title || slide.eyebrow || '首页轮播图'"
|
||||
/>
|
||||
<div class="banner-copy">
|
||||
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
|
||||
@@ -911,6 +936,10 @@ if (!restoreHomeConfigFromCache()) {
|
||||
<span v-if="slide.pill" class="banner-pill">{{ slide.pill }}</span>
|
||||
</div>
|
||||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
||||
<BannerHotspotLayer
|
||||
:visual="slide.mobile"
|
||||
@trigger="action => handleBannerAction(slide, action)"
|
||||
/>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { analyzeImagePixels } from './imageTransparency'
|
||||
|
||||
function buildPixels(
|
||||
width: number,
|
||||
height: number,
|
||||
pick: (x: number, y: number) => [number, number, number, number]
|
||||
) {
|
||||
const data = new Uint8ClampedArray(width * height * 4)
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4
|
||||
const [r, g, b, a] = pick(x, y)
|
||||
data[offset] = r
|
||||
data[offset + 1] = g
|
||||
data[offset + 2] = b
|
||||
data[offset + 3] = a
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
describe('analyzeImagePixels', () => {
|
||||
it('带透明通道的按钮图判定为有透明区域', () => {
|
||||
const probe = analyzeImagePixels(
|
||||
buildPixels(10, 10, (x, y) => (x < 5 && y < 5 ? [0, 0, 0, 0] : [255, 106, 0, 255]))
|
||||
)
|
||||
|
||||
expect(probe.hasTransparentPixels).toBe(true)
|
||||
expect(probe.suspectedBakedBackdrop).toBe(false)
|
||||
})
|
||||
|
||||
it('识别出「透明棋盘格被烘焙进图片」的浅灰白格纹', () => {
|
||||
const probe = analyzeImagePixels(
|
||||
buildPixels(20, 20, (x, y) => {
|
||||
const light = (Math.floor(x / 5) + Math.floor(y / 5)) % 2 === 0
|
||||
return light ? [255, 255, 255, 255] : [238, 238, 238, 255]
|
||||
})
|
||||
)
|
||||
|
||||
expect(probe.hasTransparentPixels).toBe(false)
|
||||
expect(probe.suspectedBakedBackdrop).toBe(true)
|
||||
})
|
||||
|
||||
it('纯白底的不透明图也能识别出来', () => {
|
||||
const probe = analyzeImagePixels(buildPixels(8, 8, () => [255, 255, 255, 255]))
|
||||
|
||||
expect(probe.hasTransparentPixels).toBe(false)
|
||||
expect(probe.suspectedBakedBackdrop).toBe(true)
|
||||
})
|
||||
|
||||
it('彩色海报底图不会误判', () => {
|
||||
const probe = analyzeImagePixels(
|
||||
buildPixels(8, 8, (x, y) => [(x * 20) % 255, (y * 30) % 255, 180, 255])
|
||||
)
|
||||
|
||||
expect(probe.hasTransparentPixels).toBe(false)
|
||||
expect(probe.suspectedBakedBackdrop).toBe(false)
|
||||
})
|
||||
|
||||
it('空数据安全返回', () => {
|
||||
expect(analyzeImagePixels(new Uint8ClampedArray(0))).toEqual({
|
||||
hasTransparentPixels: false,
|
||||
transparentRatio: 0,
|
||||
suspectedBakedBackdrop: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 图片透明区域探测:用于后台在上传「热区按钮图 / 海报底图」时提示
|
||||
* 「这张图其实没有透明区域」——最常见的坑是把设计工具里带棋盘格的预览
|
||||
* 截图/另存下来,棋盘格就成了真实像素,前台会照实显示。
|
||||
*/
|
||||
|
||||
export interface ImageTransparencyProbe {
|
||||
/** 是否存在透明像素(alpha < 250) */
|
||||
hasTransparentPixels: boolean
|
||||
/** 透明像素占比(0-1) */
|
||||
transparentRatio: number
|
||||
/** 不透明像素是否只有极少数浅灰/白颜色(棋盘格或纯色底烘焙进图片的典型特征) */
|
||||
suspectedBakedBackdrop: boolean
|
||||
}
|
||||
|
||||
const SAMPLE_MAX_SIDE = 160
|
||||
/** alpha 低于该值即视为透明像素 */
|
||||
const TRANSPARENT_ALPHA = 250
|
||||
/** 无明显透明像素的判定阈值(透明像素占比) */
|
||||
const NEGLIGIBLE_TRANSPARENT_RATIO = 0.002
|
||||
|
||||
/** 纯像素分析(无 DOM 依赖,便于单测)。 */
|
||||
export function analyzeImagePixels(data: Uint8ClampedArray | number[]): ImageTransparencyProbe {
|
||||
const total = Math.floor(data.length / 4)
|
||||
if (total <= 0) {
|
||||
return { hasTransparentPixels: false, transparentRatio: 0, suspectedBakedBackdrop: false }
|
||||
}
|
||||
|
||||
let transparent = 0
|
||||
let opaque = 0
|
||||
let lightOnly = true
|
||||
const colors = new Set<number>()
|
||||
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const offset = index * 4
|
||||
const r = data[offset] ?? 0
|
||||
const g = data[offset + 1] ?? 0
|
||||
const b = data[offset + 2] ?? 0
|
||||
const a = data[offset + 3] ?? 0
|
||||
if (a < TRANSPARENT_ALPHA) {
|
||||
transparent += 1
|
||||
continue
|
||||
}
|
||||
opaque += 1
|
||||
const min = Math.min(r, g, b)
|
||||
const max = Math.max(r, g, b)
|
||||
if (min < 216 || max - min > 28) lightOnly = false
|
||||
// 量化到 4bit/通道,避免抗锯齿噪点导致颜色数虚高
|
||||
colors.add(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4))
|
||||
}
|
||||
|
||||
const transparentRatio = transparent / total
|
||||
return {
|
||||
hasTransparentPixels: transparentRatio > NEGLIGIBLE_TRANSPARENT_RATIO,
|
||||
transparentRatio,
|
||||
suspectedBakedBackdrop:
|
||||
opaque > 0 &&
|
||||
lightOnly &&
|
||||
transparentRatio <= NEGLIGIBLE_TRANSPARENT_RATIO &&
|
||||
colors.size <= 4,
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取本地文件像素并分析;环境不支持时返回 null(不阻塞上传)。 */
|
||||
export async function probeImageTransparency(file: File): Promise<ImageTransparencyProbe | null> {
|
||||
const source = await loadImageSource(file)
|
||||
if (!source) return null
|
||||
const width = Math.max(1, Math.round(source.width))
|
||||
const height = Math.max(1, Math.round(source.height))
|
||||
const scale = Math.min(1, SAMPLE_MAX_SIDE / Math.max(width, height))
|
||||
const targetWidth = Math.max(1, Math.round(width * scale))
|
||||
const targetHeight = Math.max(1, Math.round(height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return null
|
||||
context.clearRect(0, 0, targetWidth, targetHeight)
|
||||
context.drawImage(source as CanvasImageSource, 0, 0, targetWidth, targetHeight)
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight)
|
||||
return analyzeImagePixels(imageData.data)
|
||||
}
|
||||
|
||||
async function loadImageSource(file: File): Promise<ImageBitmap | HTMLImageElement | null> {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
return await createImageBitmap(file)
|
||||
} catch {
|
||||
// 继续走 <img> 兜底
|
||||
}
|
||||
}
|
||||
return await new Promise(resolve => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(null)
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveUploadEncoding } from './imageUpload'
|
||||
|
||||
describe('resolveUploadEncoding', () => {
|
||||
it('带透明通道的图必须输出 PNG,避免透明区域被压成实色', () => {
|
||||
const encoding = resolveUploadEncoding('home-banner', true)
|
||||
|
||||
expect(encoding.outputType).toBe('image/png')
|
||||
expect(encoding.extension).toBe('png')
|
||||
expect(encoding.maxSide).toBe(1920)
|
||||
expect(encoding.quality).toBe(0.84)
|
||||
})
|
||||
|
||||
it('不透明图继续用 WebP 压缩', () => {
|
||||
const encoding = resolveUploadEncoding('home-banner', false)
|
||||
|
||||
expect(encoding.outputType).toBe('image/webp')
|
||||
expect(encoding.extension).toBe('webp')
|
||||
})
|
||||
|
||||
it('未登记的 scene 使用兜底参数', () => {
|
||||
const encoding = resolveUploadEncoding('unknown-scene', false)
|
||||
|
||||
expect(encoding.maxSide).toBe(1600)
|
||||
expect(encoding.quality).toBe(0.82)
|
||||
})
|
||||
|
||||
it('头像等小尺寸场景保留各自的边长上限', () => {
|
||||
expect(resolveUploadEncoding('avatar', false).maxSide).toBe(512)
|
||||
expect(resolveUploadEncoding('qrcode', true).maxSide).toBe(2560)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { probeImageTransparency } from './imageTransparency'
|
||||
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1280,
|
||||
@@ -26,15 +28,41 @@ const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
qrcode: 0.95,
|
||||
}
|
||||
|
||||
export interface UploadEncoding {
|
||||
maxSide: number
|
||||
quality: number
|
||||
outputType: 'image/webp' | 'image/png'
|
||||
extension: 'webp' | 'png'
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定上传前的压缩参数。
|
||||
*
|
||||
* 关键点:带透明通道的图(例如热区透明按钮图、透明 Logo)必须输出 PNG,
|
||||
* 否则压缩过程会把透明区域压成实色(历史上就是先铺白底再编码,导致透明按钮图变白底方块)。
|
||||
*/
|
||||
export function resolveUploadEncoding(scene: string, preserveAlpha: boolean): UploadEncoding {
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
|
||||
return {
|
||||
maxSide,
|
||||
quality,
|
||||
outputType: preserveAlpha ? 'image/png' : 'image/webp',
|
||||
extension: preserveAlpha ? 'png' : 'webp',
|
||||
}
|
||||
}
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
|
||||
if (typeof document === 'undefined') return file
|
||||
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
|
||||
|
||||
try {
|
||||
// 先判断原图是否真的有透明像素:有则全程保留 alpha
|
||||
const probe = await probeImageTransparency(file)
|
||||
const preserveAlpha = Boolean(probe?.hasTransparentPixels)
|
||||
const { maxSide, quality, outputType, extension } = resolveUploadEncoding(scene, preserveAlpha)
|
||||
|
||||
const image = await loadImage(file)
|
||||
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -42,13 +70,13 @@ export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
// 不铺白底:canvas 本身保留透明,输出 PNG / WebP 都能带 alpha
|
||||
context.clearRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', quality)
|
||||
const blob = await canvasToBlob(canvas, outputType, quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, 'webp'), {
|
||||
type: 'image/webp',
|
||||
return new File([blob], replaceFileExt(file.name, extension), {
|
||||
type: outputType,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -17,9 +17,45 @@ describe('mapDistributionHomeBanners', () => {
|
||||
[{ image_url: 'https://cdn.example/poster.webp', title: '海报标题' }],
|
||||
'天域商行'
|
||||
)
|
||||
expect(slide?.image_url).toBe('https://cdn.example/poster.webp')
|
||||
expect(slide?.pc.image_url).toBe('https://cdn.example/poster.webp')
|
||||
expect(slide?.mobile.image_url).toBe('https://cdn.example/poster.webp')
|
||||
expect(slide?.title).toBe('')
|
||||
expect(slide?.eyebrow).toBe('')
|
||||
expect(slide?.pill).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to the copy card when the banner has no image', () => {
|
||||
const [slide] = mapDistributionHomeBanners([{ image_url: '', title: '精选账号' }], '天域商行')
|
||||
expect(slide?.pc.image_url).toBe('')
|
||||
expect(slide?.title).toBe('精选账号')
|
||||
expect(slide?.eyebrow).toBe('天域商行')
|
||||
})
|
||||
|
||||
it('turns link_url into a clickable banner action', () => {
|
||||
const [external] = mapDistributionHomeBanners(
|
||||
[{ image_url: '/poster.png', link_url: 'https://cdn.example/act' }],
|
||||
'天域商行'
|
||||
)
|
||||
expect(external?.pc.action).toEqual({
|
||||
type: 'link',
|
||||
target: 'https://cdn.example/act',
|
||||
open_new_tab: true,
|
||||
})
|
||||
|
||||
const [internal] = mapDistributionHomeBanners(
|
||||
[{ image_url: '/poster.png', link_url: '/m/announcements' }],
|
||||
'天域商行'
|
||||
)
|
||||
expect(internal?.pc.action).toEqual({
|
||||
type: 'route',
|
||||
target: '/m/announcements',
|
||||
open_new_tab: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps banners clickable for preview when no link is configured', () => {
|
||||
const [slide] = mapDistributionHomeBanners([{ image_url: '/poster.png' }], '天域商行')
|
||||
expect(slide?.pc.action.type).toBe('none')
|
||||
expect(slide?.pc.hotspots).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
mergeStorefrontText,
|
||||
type StorefrontText,
|
||||
} from '@/shared/utils/storefrontText'
|
||||
import {
|
||||
createEmptyBannerAction,
|
||||
createEmptyBannerVisual,
|
||||
type BannerAction,
|
||||
type BannerVisual,
|
||||
type HomeBannerSlide,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
|
||||
export interface DistributionBanner {
|
||||
image_url: string
|
||||
@@ -14,13 +21,36 @@ export interface DistributionBanner {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface DistributionHomeBanner {
|
||||
eyebrow: string
|
||||
title: string
|
||||
badge: string
|
||||
pill: string
|
||||
tone: string
|
||||
image_url?: string
|
||||
/** 分站首页轮播:有图时不再叠标题,避免整幅压暗。 */
|
||||
export function mapDistributionHomeBanners(
|
||||
banners: DistributionBanner[],
|
||||
siteName: string
|
||||
): HomeBannerSlide[] {
|
||||
return banners.map(banner => {
|
||||
const imageUrl = banner.image_url?.trim() || ''
|
||||
const action = resolveDistributionBannerAction(banner.link_url)
|
||||
const visual: BannerVisual = { ...createEmptyBannerVisual(), image_url: imageUrl, action }
|
||||
return {
|
||||
eyebrow: imageUrl ? '' : siteName,
|
||||
title: imageUrl ? '' : banner.title?.trim() || '精选游戏账号 · 随租随玩',
|
||||
badge: '本店精选',
|
||||
pill: imageUrl ? '' : '官方保障',
|
||||
tone: 'gold',
|
||||
enabled: true,
|
||||
// 分站装修只配一张图,两端共用,避免移动端丢图
|
||||
pc: { ...visual, hotspots: [] },
|
||||
mobile: { ...visual, hotspots: [] },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 分站轮播的 link_url:绝对地址走外链新窗口,站内路径走路由。 */
|
||||
function resolveDistributionBannerAction(linkUrl?: string): BannerAction {
|
||||
const target = linkUrl?.trim() || ''
|
||||
if (!target) return createEmptyBannerAction()
|
||||
if (/^https?:\/\//i.test(target)) return { type: 'link', target, open_new_tab: true }
|
||||
if (target.startsWith('/')) return { type: 'route', target, open_new_tab: false }
|
||||
return createEmptyBannerAction()
|
||||
}
|
||||
|
||||
/** 分站首页主标题:站名与主站联名。主站或站名已含主站名时保持原样。 */
|
||||
@@ -31,33 +61,6 @@ export function formatDistributionCoBrandName(siteName: string, isDistribution:
|
||||
return `${name}×大锤商行`
|
||||
}
|
||||
|
||||
/** 分站首页轮播:有图时不再叠标题,避免整幅压暗。 */
|
||||
export function mapDistributionHomeBanners(
|
||||
banners: DistributionBanner[],
|
||||
siteName: string
|
||||
): DistributionHomeBanner[] {
|
||||
return banners.map(banner => {
|
||||
const imageUrl = banner.image_url?.trim() || ''
|
||||
if (imageUrl) {
|
||||
return {
|
||||
eyebrow: '',
|
||||
title: '',
|
||||
badge: '本店精选',
|
||||
pill: '',
|
||||
tone: 'gold',
|
||||
image_url: imageUrl,
|
||||
}
|
||||
}
|
||||
return {
|
||||
eyebrow: siteName,
|
||||
title: banner.title?.trim() || '精选游戏账号 · 随租随玩',
|
||||
badge: '本店精选',
|
||||
pill: '官方保障',
|
||||
tone: 'gold',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface DistributionFooterInfo {
|
||||
icp?: string
|
||||
copyright?: string
|
||||
|
||||
Reference in New Issue
Block a user