feat(home-banner): 轮播支持 PC/移动两端分图与热区跳转,热区图悬浮放大;修复上传压缩压白透明 PNG
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user