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
@@ -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>