feat(home-banner): 轮播支持 PC/移动两端分图与热区跳转,热区图悬浮放大;修复上传压缩压白透明 PNG

This commit is contained in:
yml2213
2026-10-16 16:02:29 +08:00
parent b5867e3c5e
commit 9f8c0f9cb2
21 changed files with 2217 additions and 272 deletions
+40 -5
View File
@@ -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)
+115
View File
@@ -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 })
})
})
+191 -22
View File
@@ -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)
})
})
+36 -8
View File
@@ -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([])
})
})
+37 -34
View File
@@ -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